diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts index 9d4123bce8c..e5652bcdae4 100644 --- a/packages/plugin-sdk/src/contracts.ts +++ b/packages/plugin-sdk/src/contracts.ts @@ -10209,6 +10209,36 @@ export const contracts = { 'authorityRevision', ], }, + { + description: + 'Missing operating-system grants the waiting tool call needs. The producer\npolls the real grant state and commits `SystemPermissionGranted` itself;\nthe user can only cancel.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission', + }, + toolUseId: { + type: 'string', + }, + grants: { + description: 'Distinct missing grants.', + type: 'array', + items: { + $ref: '#/$defs/SystemPermissionGrant', + }, + minItems: 1, + }, + application: { + description: + 'The application name the operating system lists in its settings.', + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'toolUseId', 'grants', 'application'], + }, ], }, PermissionBasis: { @@ -10773,6 +10803,23 @@ export const contracts = { type: 'string', enum: ['low', 'high'], }, + SystemPermissionGrant: { + description: + 'A macOS privacy grant (TCC) that the Host process itself needs before native\nComputer Use can run. This is operating-system state the user changes in\nSystem Settings, not a Maka grant; the Host observes it and commits the outcome.', + oneOf: [ + { + description: 'System Settings > Privacy & Security > Accessibility.', + type: 'string', + const: 'accessibility', + }, + { + description: + 'System Settings > Privacy & Security > Screen & System Audio Recording.', + type: 'string', + const: 'screen_recording', + }, + ], + }, InteractionOutcome: { oneOf: [ { @@ -10927,6 +10974,24 @@ export const contracts = { additionalProperties: false, required: ['kind', 'decision', 'committedAt'], }, + { + description: + 'Producer-committed once every requested system grant is present; no answer maps here.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission_granted', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'committedAt'], + }, { type: 'object', properties: { @@ -14062,6 +14127,7 @@ export const contracts = { 'form', 'client_capability', 'computer_app', + 'system_permission', ], }, }, @@ -16565,6 +16631,17 @@ export const contracts = { additionalProperties: false, required: ['kind'], }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission_cancel', + }, + }, + additionalProperties: false, + required: ['kind'], + }, ], }, Decision: { @@ -16988,6 +17065,36 @@ export const contracts = { 'authorityRevision', ], }, + { + description: + 'Missing operating-system grants the waiting tool call needs. The producer\npolls the real grant state and commits `SystemPermissionGranted` itself;\nthe user can only cancel.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission', + }, + toolUseId: { + type: 'string', + }, + grants: { + description: 'Distinct missing grants.', + type: 'array', + items: { + $ref: '#/$defs/SystemPermissionGrant', + }, + minItems: 1, + }, + application: { + description: + 'The application name the operating system lists in its settings.', + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'toolUseId', 'grants', 'application'], + }, ], }, PermissionBasis: { @@ -17552,6 +17659,23 @@ export const contracts = { type: 'string', enum: ['low', 'high'], }, + SystemPermissionGrant: { + description: + 'A macOS privacy grant (TCC) that the Host process itself needs before native\nComputer Use can run. This is operating-system state the user changes in\nSystem Settings, not a Maka grant; the Host observes it and commits the outcome.', + oneOf: [ + { + description: 'System Settings > Privacy & Security > Accessibility.', + type: 'string', + const: 'accessibility', + }, + { + description: + 'System Settings > Privacy & Security > Screen & System Audio Recording.', + type: 'string', + const: 'screen_recording', + }, + ], + }, InteractionOutcome: { oneOf: [ { @@ -17706,6 +17830,24 @@ export const contracts = { additionalProperties: false, required: ['kind', 'decision', 'committedAt'], }, + { + description: + 'Producer-committed once every requested system grant is present; no answer maps here.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission_granted', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'committedAt'], + }, { type: 'object', properties: { @@ -23525,6 +23667,7 @@ export const contracts = { 'form', 'client_capability', 'computer_app', + 'system_permission', ], }, AgentComputerApps: { diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts index 1034e97e86f..c5ebd132606 100644 --- a/packages/plugin-sdk/src/generated/runtime.ts +++ b/packages/plugin-sdk/src/generated/runtime.ts @@ -3273,7 +3273,8 @@ export type AgentInteractionKind = | 'permissions' | 'form' | 'client_capability' - | 'computer_app'; + | 'computer_app' + | 'system_permission'; export type InteractionAnswer = | { kind: 'service_approval'; @@ -3317,6 +3318,9 @@ export type InteractionAnswer = } | { kind: 'computer_app_cancel'; + } + | { + kind: 'system_permission_cancel'; }; export type Decision = 'allow' | 'deny'; export type Decision2 = @@ -3387,6 +3391,20 @@ export type InteractionRequest = display: ComputerAppDisplay; allowAlways: boolean; authorityRevision: number; + } + | { + kind: 'system_permission'; + toolUseId: string; + /** + * Distinct missing grants. + * + * @minItems 1 + */ + grants: [SystemPermissionGrant, ...SystemPermissionGrant[]]; + /** + * The application name the operating system lists in its settings. + */ + application: string; }; export type FormField = { name: string; @@ -3468,6 +3486,12 @@ export type GrantScope = }; export type ComputerAppPlatform = 'macos' | 'windows'; export type ComputerAppRisk = 'low' | 'high'; +/** + * A macOS privacy grant (TCC) that the Host process itself needs before native + * Computer Use can run. This is operating-system state the user changes in + * System Settings, not a Maka grant; the Host observes it and commits the outcome. + */ +export type SystemPermissionGrant = 'accessibility' | 'screen_recording'; export type InteractionOutcome = | { kind: 'service_approval_decision'; @@ -3515,6 +3539,10 @@ export type InteractionOutcome = decision: ComputerAppDecision; committedAt: number; } + | { + kind: 'system_permission_granted'; + committedAt: number; + } | { kind: 'closure'; reason: ClosureReason; diff --git a/platform/computer-use/src/lib.rs b/platform/computer-use/src/lib.rs index 43f80d338fe..2af2ebd20a0 100644 --- a/platform/computer-use/src/lib.rs +++ b/platform/computer-use/src/lib.rs @@ -63,6 +63,7 @@ mod permission_subject; pub mod protocol; #[cfg(feature = "native-desktop")] mod session; +pub mod system_permission; #[cfg(all(feature = "native-desktop", target_os = "windows"))] mod windows_permission_subject; diff --git a/platform/computer-use/src/macos/tcc.rs b/platform/computer-use/src/macos/tcc.rs index 81522552a4f..8d602d305f2 100644 --- a/platform/computer-use/src/macos/tcc.rs +++ b/platform/computer-use/src/macos/tcc.rs @@ -205,18 +205,44 @@ fn path_of(pid: libc::pid_t) -> Option { String::from_utf8(buffer).ok() } -pub(crate) fn require(grant: Grant) -> Result<(), ToolError> { +impl From for Grant { + fn from(grant: maka_agent_facts::interaction::SystemPermissionGrant) -> Self { + use maka_agent_facts::interaction::SystemPermissionGrant; + match grant { + SystemPermissionGrant::Accessibility => Self::Accessibility, + SystemPermissionGrant::ScreenRecording => Self::ScreenRecording, + } + } +} + +/// Whether the grant is in effect, confirming it with a real call when it +/// has not been confirmed yet. It blocks for the call. +pub(crate) fn present(grant: Grant) -> bool { if grant.confirmed().load(Ordering::Relaxed) { - return Ok(()); + return true; } - if grant.observed() { + let observed = grant.observed(); + if observed { grant.confirmed().store(true, Ordering::Relaxed); - return Ok(()); } + observed +} + +/// Asks macOS to show its request for the grant, once per Host. Returns +/// whether this call asked. +pub(crate) fn ask(grant: Grant) -> bool { let asked = !grant.asked().swap(true, Ordering::Relaxed); if asked { grant.request(); } + asked +} + +pub(crate) fn require(grant: Grant) -> Result<(), ToolError> { + if present(grant) { + return Ok(()); + } + let asked = ask(grant); Err(ToolError::Failed(message( grant, &responsible_application(), @@ -253,6 +279,11 @@ fn suspect(error: &ToolError) -> Option { } } +/// The failure for a grant still missing after the user was asked. +pub(crate) fn still_missing(grant: Grant, application: &str) -> String { + message(grant, application, false) +} + /// The tool error for a missing grant. `permission_required:` is the stable /// prefix; the rest tells the user which switch to turn on. fn message(grant: Grant, application: &str, asked: bool) -> String { @@ -271,7 +302,7 @@ fn message(grant: Grant, application: &str, asked: bool) -> String { } /// The name macOS shows for the process that holds this Host's grants. -fn responsible_application() -> String { +pub(crate) fn responsible_application() -> String { responsible_path() .map(|path| application_name(&path)) .unwrap_or_else(|| "the application that started Maka".into()) diff --git a/platform/computer-use/src/system_permission.rs b/platform/computer-use/src/system_permission.rs new file mode 100644 index 00000000000..0a3f952b14d --- /dev/null +++ b/platform/computer-use/src/system_permission.rs @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +//! The operating system's privacy grants native Computer Use needs. +//! +//! On macOS these are Accessibility and Screen Recording, which belong to +//! the application responsible for the Host. Other platforms need none. + +use crate::protocol::{Command, Handle, NativeComputerCall, NativeComputerMethod}; +use maka_agent_facts::interaction::SystemPermissionGrant; + +/// macOS grants used by this command. Inventory, documentation and browser +/// work remain usable before the user grants native control or capture. +pub fn required(command: &Command) -> &'static [SystemPermissionGrant] { + use SystemPermissionGrant::{Accessibility, ScreenRecording}; + match command { + Command::GetApp { .. } + | Command::Observe { + handle: Handle::App(_), + .. + } + | Command::NativeComputer { + call: + NativeComputerCall::Invoke { + operation: NativeComputerMethod::GetAppState, + .. + }, + } => &[Accessibility, ScreenRecording], + Command::StartAudioRecording { .. } => &[ScreenRecording], + Command::NativeComputer { + call: + NativeComputerCall::Invoke { + operation: NativeComputerMethod::ListApps, + .. + }, + } => &[], + Command::NativeComputer { + call: NativeComputerCall::Invoke { .. }, + } + | Command::Action { + handle: Handle::App(_), + .. + } + | Command::ClipboardRead { + handle: Handle::App(_), + } + | Command::ClipboardReadText { + handle: Handle::App(_), + } + | Command::ClipboardWrite { + handle: Handle::App(_), + .. + } + | Command::ClipboardWriteText { + handle: Handle::App(_), + .. + } => &[Accessibility], + _ => &[], + } +} + +/// The grants native desktop work needs that are not in effect now, checked +/// with real calls. It blocks for those calls. +pub fn missing(required: &[SystemPermissionGrant]) -> Vec { + #[cfg(all(feature = "native-desktop", target_os = "macos"))] + { + required + .iter() + .copied() + .filter(|grant| !crate::macos::tcc::present((*grant).into())) + .collect() + } + #[cfg(not(all(feature = "native-desktop", target_os = "macos")))] + { + let _ = required; + Vec::new() + } +} + +/// Asks the operating system to show its own request for each grant, at +/// most once per grant for this Host. +pub fn request(grants: &[SystemPermissionGrant]) { + #[cfg(all(feature = "native-desktop", target_os = "macos"))] + for grant in grants { + crate::macos::tcc::ask((*grant).into()); + } + #[cfg(not(all(feature = "native-desktop", target_os = "macos")))] + let _ = grants; +} + +/// The name System Settings lists for the application the grants belong to. +pub fn application() -> String { + #[cfg(all(feature = "native-desktop", target_os = "macos"))] + { + crate::macos::tcc::responsible_application() + } + #[cfg(not(all(feature = "native-desktop", target_os = "macos")))] + String::new() +} + +/// The tool failure for grants still missing: one `permission_required:` +/// line for each, naming the switch to turn on. +pub fn failure(grants: &[SystemPermissionGrant], application: &str) -> String { + #[cfg(all(feature = "native-desktop", target_os = "macos"))] + { + grants + .iter() + .map(|grant| crate::macos::tcc::still_missing((*grant).into(), application)) + .collect::>() + .join("\n") + } + #[cfg(not(all(feature = "native-desktop", target_os = "macos")))] + { + let _ = (grants, application); + String::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::AppReference; + + #[test] + fn browser_setup_and_documentation_do_not_require_native_grants() { + for input in [ + r#"{"method":"readDocumentation","name":"browser"}"#, + r#"{"method":"configureCursor","options":{"enabled":false}}"#, + r#"{"method":"cursorState","options":{}}"#, + r#"{"method":"listBrowsers","options":{}}"#, + r#"{"method":"stopAudioRecording"}"#, + ] { + let command = serde_json::from_str(input).unwrap(); + assert_eq!(required(&command), &[], "{input}"); + } + assert_eq!( + required(&Command::GetApp { + target: AppReference::Name("Notes".into()) + }), + &[ + SystemPermissionGrant::Accessibility, + SystemPermissionGrant::ScreenRecording + ], + ); + assert_eq!( + required(&Command::StartAudioRecording { + options: Default::default() + }), + &[SystemPermissionGrant::ScreenRecording], + ); + } +} diff --git a/platform/event-log/src/interactions/records.rs b/platform/event-log/src/interactions/records.rs index 9a7e0ecfdc6..c8252219c71 100644 --- a/platform/event-log/src/interactions/records.rs +++ b/platform/event-log/src/interactions/records.rs @@ -86,6 +86,10 @@ pub(crate) fn equivalent(left: &InteractionOutcome, right: &InteractionOutcome) decision: right, .. }, ) => left == right, + ( + InteractionOutcome::SystemPermissionGranted { .. }, + InteractionOutcome::SystemPermissionGranted { .. }, + ) => true, ( InteractionOutcome::Closure { reason: left, .. }, InteractionOutcome::Closure { reason: right, .. }, diff --git a/platform/event-log/tests/integration/main.rs b/platform/event-log/tests/integration/main.rs index 0377ec46a04..e3528fbfc70 100644 --- a/platform/event-log/tests/integration/main.rs +++ b/platform/event-log/tests/integration/main.rs @@ -64,6 +64,7 @@ mod steering; mod stream_delivery; mod streaming_usage; mod support; +mod system_permission_interactions; mod tool_ancestry; mod tool_payloads; mod tool_rejection; diff --git a/platform/event-log/tests/integration/system_permission_interactions.rs b/platform/event-log/tests/integration/system_permission_interactions.rs new file mode 100644 index 00000000000..6208b047232 --- /dev/null +++ b/platform/event-log/tests/integration/system_permission_interactions.rs @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use maka_agent_facts::interaction::ClosureReason; +use maka_agent_facts::interaction::InteractionAnswer; +use maka_agent_facts::interaction::InteractionOutcome; +use maka_agent_facts::interaction::InteractionRecord; +use maka_event_log::EventLog; +use serde_json::json; + +fn record(request_id: &str) -> InteractionRecord { + serde_json::from_value(json!({ + "sessionId":"session","turnId":"turn","runId":"run","requestId":request_id,"createdAt":2, + "request":{"kind":"system_permission","toolUseId":"tool", + "grants":["accessibility","screen_recording"],"application":"Maka Host"}, + "outcome":null + })) + .unwrap() +} + +#[tokio::test] +async fn system_permission_grant_is_producer_committed_once_and_survives_reopen() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("runtime.sqlite"); + let log = EventLog::open(&path).await.unwrap(); + log.create_session("session", "create", &json!({}), 1) + .await + .unwrap(); + assert!( + log.establish_interaction(&record("granted")) + .await + .unwrap() + .matches + ); + assert!( + log.commit_interaction_outcome( + "granted", + InteractionOutcome::QuestionAnswer { + answers: vec![Some("yes".into())], + committed_at: 3, + } + ) + .await + .is_err(), + "only the system permission outcome or a closure can settle the request" + ); + let committed = log + .commit_interaction_outcome( + "granted", + InteractionOutcome::SystemPermissionGranted { committed_at: 3 }, + ) + .await + .unwrap(); + assert!(committed.matches); + assert!( + log.commit_interaction_outcome( + "granted", + InteractionOutcome::SystemPermissionGranted { committed_at: 4 }, + ) + .await + .unwrap() + .matches, + "a repeated grant observation is idempotent" + ); + assert!( + !log.commit_interaction_outcome( + "granted", + InteractionAnswer::SystemPermissionCancel {}.into_outcome(5), + ) + .await + .unwrap() + .matches, + "first outcome wins over a late cancel" + ); + + assert!( + log.establish_interaction(&record("cancelled")) + .await + .unwrap() + .matches + ); + let cancel = InteractionAnswer::SystemPermissionCancel {}; + let cancelled = log + .commit_interaction_outcome("cancelled", cancel.clone().into_outcome(6)) + .await + .unwrap(); + assert!(cancelled.matches); + assert!(cancel.matches_outcome(cancelled.record.outcome.as_ref().unwrap())); + assert!( + !log.commit_interaction_outcome( + "cancelled", + InteractionOutcome::SystemPermissionGranted { committed_at: 7 }, + ) + .await + .unwrap() + .matches + ); + assert!( + log.commit_interaction_outcome( + "cancelled", + InteractionOutcome::Closure { + reason: ClosureReason::TimedOut, + committed_at: 8, + } + ) + .await + .is_err(), + "the card has no response deadline" + ); + + log.close().await.unwrap(); + let reopened = EventLog::open(&path).await.unwrap(); + assert_eq!( + reopened.interaction("granted").await.unwrap().unwrap(), + committed.record + ); + assert_eq!( + reopened.interaction("cancelled").await.unwrap().unwrap(), + cancelled.record + ); + reopened.close().await.unwrap(); +} diff --git a/plugins/agent/api/src/contract/interaction.rs b/plugins/agent/api/src/contract/interaction.rs index fc803d2f21f..e490adf4428 100644 --- a/plugins/agent/api/src/contract/interaction.rs +++ b/plugins/agent/api/src/contract/interaction.rs @@ -48,6 +48,7 @@ pub enum InteractionKind { Form, ClientCapability, ComputerApp, + SystemPermission, } #[derive(Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/plugins/agent/facts/src/interaction.rs b/plugins/agent/facts/src/interaction.rs index e348aa2a4e0..f93896af4be 100644 --- a/plugins/agent/facts/src/interaction.rs +++ b/plugins/agent/facts/src/interaction.rs @@ -138,6 +138,18 @@ pub enum Decision { Deny, } +/// A macOS privacy grant (TCC) that the Host process itself needs before native +/// Computer Use can run. This is operating-system state the user changes in +/// System Settings, not a Maka grant; the Host observes it and commits the outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SystemPermissionGrant { + /// System Settings > Privacy & Security > Accessibility. + Accessibility, + /// System Settings > Privacy & Security > Screen & System Audio Recording. + ScreenRecording, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ClosureReason { @@ -200,6 +212,18 @@ pub enum InteractionRequest { allow_always: bool, authority_revision: u64, }, + /// Missing operating-system grants the waiting tool call needs. The producer + /// polls the real grant state and commits `SystemPermissionGranted` itself; + /// the user can only cancel. + SystemPermission { + tool_use_id: String, + /// Distinct missing grants. + #[schemars(length(min = 1))] + grants: Vec, + /// The application name the operating system lists in its settings. + #[schemars(length(min = 1))] + application: String, + }, } #[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)] @@ -225,6 +249,7 @@ pub enum InteractionAnswer { decision: ComputerAppDecision, }, ComputerAppCancel {}, + SystemPermissionCancel {}, } #[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)] @@ -260,6 +285,8 @@ pub enum InteractionOutcome { decision: ComputerAppDecision, committed_at: u64, }, + /// Producer-committed once every requested system grant is present; no answer maps here. + SystemPermissionGranted { committed_at: u64 }, Closure { reason: ClosureReason, committed_at: u64, @@ -275,6 +302,7 @@ impl InteractionOutcome { | Self::FormAnswer { committed_at, .. } | Self::ClientCapabilityDecision { committed_at, .. } | Self::ComputerAppDecision { committed_at, .. } + | Self::SystemPermissionGranted { committed_at } | Self::Closure { committed_at, .. } => *committed_at, } } @@ -303,10 +331,12 @@ impl InteractionAnswer { decision, committed_at, }, - Self::ComputerAppCancel {} => InteractionOutcome::Closure { - reason: ClosureReason::ProducerCancelled, - committed_at, - }, + Self::ComputerAppCancel {} | Self::SystemPermissionCancel {} => { + InteractionOutcome::Closure { + reason: ClosureReason::ProducerCancelled, + committed_at, + } + } Self::Form { result } => InteractionOutcome::FormAnswer { result, committed_at, @@ -345,7 +375,7 @@ impl InteractionAnswer { }, ) => left == right, ( - Self::ComputerAppCancel {}, + Self::ComputerAppCancel {} | Self::SystemPermissionCancel {}, InteractionOutcome::Closure { reason: ClosureReason::ProducerCancelled, .. diff --git a/plugins/agent/facts/src/interaction/decode.rs b/plugins/agent/facts/src/interaction/decode.rs index 689d0f8e848..47a6c848c13 100644 --- a/plugins/agent/facts/src/interaction/decode.rs +++ b/plugins/agent/facts/src/interaction/decode.rs @@ -105,6 +105,14 @@ fn request(v: &Value) -> Result { authority_revision: field(v, "authorityRevision")?, } } + Some("system_permission") => { + shape(v, &["kind", "toolUseId", "grants", "application"])?; + InteractionRequest::SystemPermission { + tool_use_id: field(v, "toolUseId")?, + grants: field(v, "grants")?, + application: field(v, "application")?, + } + } Some("form") => { shape(v, &["kind", "toolUseId", "message", "requester", "fields"])?; let input = crate::capability::form_decode::decode_input(&serde_json::json!({ @@ -173,6 +181,10 @@ fn answer(v: &Value) -> Result { shape(v, &["kind"])?; InteractionAnswer::ComputerAppCancel {} } + Some("system_permission_cancel") => { + shape(v, &["kind"])?; + InteractionAnswer::SystemPermissionCancel {} + } Some("form") => InteractionAnswer::Form { result: result(v, false)?, }, @@ -219,6 +231,10 @@ fn outcome(v: &Value) -> Result { committed_at, } } + Some("system_permission_granted") => { + shape(v, &["kind", "committedAt"])?; + InteractionOutcome::SystemPermissionGranted { committed_at } + } Some("closure") => { shape(v, &["kind", "reason", "committedAt"])?; InteractionOutcome::Closure { diff --git a/plugins/agent/facts/src/interaction/tests.rs b/plugins/agent/facts/src/interaction/tests.rs index 13a52a2d632..0ae17e4ed92 100644 --- a/plugins/agent/facts/src/interaction/tests.rs +++ b/plugins/agent/facts/src/interaction/tests.rs @@ -128,5 +128,161 @@ fn browser_download_consent_is_origin_scoped_and_distinct_from_page_access() { } } -// Append inside plugins/agent/facts/src/interaction/tests.rs. Uses the real -// canonical request decoder, decisions and serializer on every build host. +fn system_permission_request() -> Value { + json!({ + "kind": "system_permission", + "toolUseId": "tool_123", + "grants": ["accessibility", "screen_recording"], + "application": "Maka Host" + }) +} + +#[test] +fn system_permission_request_round_trips_and_validates_grants_and_application() { + let request: InteractionRequest = serde_json::from_value(system_permission_request()).unwrap(); + assert_eq!( + request, + InteractionRequest::SystemPermission { + tool_use_id: "tool_123".into(), + grants: vec![ + SystemPermissionGrant::Accessibility, + SystemPermissionGrant::ScreenRecording + ], + application: "Maka Host".into(), + } + ); + assert_eq!( + serde_json::to_value(&request).unwrap(), + system_permission_request() + ); + let mut single = system_permission_request(); + single["grants"] = json!(["screen_recording"]); + assert!(serde_json::from_value::(single).is_ok()); + + for grants in [ + json!([]), + json!(["accessibility", "accessibility"]), + json!(["microphone"]), + json!(null), + ] { + let mut input = system_permission_request(); + input["grants"] = grants; + assert!(serde_json::from_value::(input).is_err()); + } + for application in [ + json!(""), + json!(" "), + json!(" Maka Host"), + json!("Maka\nHost"), + json!("Maka\0Host"), + json!(null), + ] { + let mut input = system_permission_request(); + input["application"] = application; + assert!(serde_json::from_value::(input).is_err()); + } + let mut input = system_permission_request(); + input["toolUseId"] = json!(""); + assert!(serde_json::from_value::(input).is_err()); + let mut input = system_permission_request(); + input["extra"] = json!(true); + assert!(serde_json::from_value::(input).is_err()); + let mut input = system_permission_request(); + input.as_object_mut().unwrap().remove("application"); + assert!(serde_json::from_value::(input).is_err()); +} + +#[test] +fn system_permission_cancel_closes_as_producer_cancelled_only_for_its_request() { + let answer: InteractionAnswer = + serde_json::from_value(json!({"kind": "system_permission_cancel"})).unwrap(); + assert_eq!(answer, InteractionAnswer::SystemPermissionCancel {}); + assert_eq!( + serde_json::to_value(&answer).unwrap(), + json!({"kind": "system_permission_cancel"}) + ); + assert!( + serde_json::from_value::( + json!({"kind": "system_permission_cancel", "extra": 1}) + ) + .is_err() + ); + let outcome = answer.clone().into_outcome(7); + assert_eq!( + outcome, + InteractionOutcome::Closure { + reason: ClosureReason::ProducerCancelled, + committed_at: 7, + } + ); + assert!(answer.matches_outcome(&outcome)); + assert!( + !answer.matches_outcome(&InteractionOutcome::SystemPermissionGranted { committed_at: 7 }) + ); + + let request: InteractionRequest = serde_json::from_value(system_permission_request()).unwrap(); + let computer_app: InteractionRequest = serde_json::from_value(computer_app_request()).unwrap(); + assert_eq!(answer.validate_for_request(&request), Ok(())); + assert!(answer.validate_for_request(&computer_app).is_err()); + assert!( + InteractionAnswer::ComputerAppCancel {} + .validate_for_request(&request) + .is_err() + ); +} + +#[test] +fn system_permission_granted_is_producer_committed_and_only_for_its_request() { + let outcome: InteractionOutcome = + serde_json::from_value(json!({"kind": "system_permission_granted", "committedAt": 42})) + .unwrap(); + assert_eq!( + outcome, + InteractionOutcome::SystemPermissionGranted { committed_at: 42 } + ); + assert_eq!(outcome.committed_at(), 42); + assert_eq!( + serde_json::to_value(&outcome).unwrap(), + json!({"kind": "system_permission_granted", "committedAt": 42}) + ); + for invalid in [ + json!({"kind": "system_permission_granted"}), + json!({"kind": "system_permission_granted", "committedAt": 42, "grants": []}), + json!({"kind": "system_permission_granted", "committedAt": MAX_SAFE_INTEGER + 1}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } + assert!( + serde_json::from_value::(json!({"kind": "system_permission_granted"})) + .is_err(), + "no user answer may claim the grant" + ); + + let request: InteractionRequest = serde_json::from_value(system_permission_request()).unwrap(); + let computer_app: InteractionRequest = serde_json::from_value(computer_app_request()).unwrap(); + assert_eq!(outcome.validate_for_request(&request), Ok(())); + assert!(outcome.validate_for_request(&computer_app).is_err()); + let question: InteractionRequest = serde_json::from_value(json!({ + "kind": "question", + "toolUseId": "tool_123", + "questions": [{"question": "Continue?", "options": [{"label": "Yes"}, {"label": "No"}]}] + })) + .unwrap(); + assert!(outcome.validate_for_request(&question).is_err()); + assert_eq!( + InteractionOutcome::Closure { + reason: ClosureReason::ProducerCancelled, + committed_at: 1, + } + .validate_for_request(&request), + Ok(()) + ); + assert!( + InteractionOutcome::ComputerAppDecision { + decision: ComputerAppDecision::Once, + committed_at: 1, + } + .validate_for_request(&request) + .is_err() + ); +} diff --git a/plugins/agent/facts/src/interaction/validation.rs b/plugins/agent/facts/src/interaction/validation.rs index 0511c202409..7b572b443eb 100644 --- a/plugins/agent/facts/src/interaction/validation.rs +++ b/plugins/agent/facts/src/interaction/validation.rs @@ -249,6 +249,31 @@ impl InteractionRequest { return Err("Invalid computer application authority revision"); } } + Self::SystemPermission { + tool_use_id, + grants, + application, + } => { + if tool_use_id.is_empty() { + return Err("Invalid interaction tool use identity"); + } + if grants.is_empty() { + return Err("System permission request requires a grant"); + } + if grants + .iter() + .enumerate() + .any(|(index, grant)| grants[..index].contains(grant)) + { + return Err("Duplicate system permission grant"); + } + if application.trim().is_empty() + || application.trim() != application + || application.chars().any(char::is_control) + { + return Err("Invalid system permission application name"); + } + } } Ok(()) } @@ -286,7 +311,8 @@ impl InteractionOutcome { | InteractionRequest::ServiceApproval { .. } | InteractionRequest::Question { .. } | InteractionRequest::Permissions { .. } - | InteractionRequest::ComputerApp { .. }, + | InteractionRequest::ComputerApp { .. } + | InteractionRequest::SystemPermission { .. }, Self::Closure { reason: ClosureReason::TimedOut, .. @@ -317,6 +343,7 @@ impl InteractionOutcome { ) => question::validate_answer_count(answers, questions), (_, Self::Closure { .. }) | (InteractionRequest::ServiceApproval { .. }, Self::ServiceApprovalDecision { .. }) + | (InteractionRequest::SystemPermission { .. }, Self::SystemPermissionGranted { .. }) | ( InteractionRequest::ClientCapability { .. }, Self::ClientCapabilityDecision { .. }, @@ -387,7 +414,10 @@ impl InteractionAnswer { Self::ComputerApp { decision }, InteractionRequest::ComputerApp { allow_always, .. }, ) => decision.validate_for(*allow_always), - (Self::ComputerAppCancel {}, InteractionRequest::ComputerApp { .. }) => Ok(()), + (Self::ComputerAppCancel {}, InteractionRequest::ComputerApp { .. }) + | (Self::SystemPermissionCancel {}, InteractionRequest::SystemPermission { .. }) => { + Ok(()) + } _ => Err("Interaction answer does not match request"), } } diff --git a/plugins/agent/native/src/owner/interactions.rs b/plugins/agent/native/src/owner/interactions.rs index b7543b5b3ad..803b5c4cdff 100644 --- a/plugins/agent/native/src/owner/interactions.rs +++ b/plugins/agent/native/src/owner/interactions.rs @@ -51,7 +51,8 @@ impl Owner { | InteractionRequest::Question { tool_use_id, .. } | InteractionRequest::Form { tool_use_id, .. } | InteractionRequest::ClientCapability { tool_use_id, .. } - | InteractionRequest::ComputerApp { tool_use_id, .. } => { + | InteractionRequest::ComputerApp { tool_use_id, .. } + | InteractionRequest::SystemPermission { tool_use_id, .. } => { Some(tool_use_id.clone()) } }, @@ -66,6 +67,9 @@ impl Owner { InteractionKind::ClientCapability } InteractionRequest::ComputerApp { .. } => InteractionKind::ComputerApp, + InteractionRequest::SystemPermission { .. } => { + InteractionKind::SystemPermission + } }, }) .collect(), @@ -141,6 +145,7 @@ impl Owner { | InteractionRequest::ServiceApproval { .. } | InteractionRequest::Permissions { .. } | InteractionRequest::Form { .. } + | InteractionRequest::SystemPermission { .. } ) { return reject(InteractionRejection::Unsupported); @@ -154,6 +159,7 @@ impl Owner { | InteractionAnswer::ClientCapability { .. } | InteractionAnswer::ComputerApp { .. } | InteractionAnswer::ComputerAppCancel {} + | InteractionAnswer::SystemPermissionCancel {} ) || input.answer.validate_for_request(&record.request).is_err() { return reject(InteractionRejection::InvalidAnswer); diff --git a/plugins/agent/native/src/owner/tests/interactions.rs b/plugins/agent/native/src/owner/tests/interactions.rs index 9ef5fa28dba..8c23a317c3c 100644 --- a/plugins/agent/native/src/owner/tests/interactions.rs +++ b/plugins/agent/native/src/owner/tests/interactions.rs @@ -192,6 +192,123 @@ async fn native_browser_consent_accepts_original_producer_and_rejects_foreign_pr .expect("native browser producer consent fixture timed out"); } +#[tokio::test] +async fn native_system_permission_is_listed_cancellable_and_producer_granted() { + tokio::time::timeout(Duration::from_secs(30), async { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let fixture = Fixture::new( + &format!("http://{}/v1", listener.local_addr().unwrap()), + None, + ) + .await; + let session = fixture.create("System permission").await; + let started = fixture.send(&session).await.unwrap(); + let (mut held, _) = listener.accept().await.unwrap(); + request(&mut held).await; + let store = fixture.session(&session).await; + let invocation = store + .session_projection::(&session) + .await + .unwrap() + .unwrap() + .root_turn + .unwrap() + .invocation; + let observed: Result<(), String> = async { + for id in ["system_cancelled", "system_granted"] { + let record = InteractionRecord { + session_id: session.clone(), + turn_id: invocation.turn_id.clone(), + run_id: invocation.run_id.clone(), + request_id: id.into(), + created_at: 1, + outcome: None, + request: maka_agent_facts::interaction::InteractionRequest::SystemPermission { + tool_use_id: "computer_tool".into(), + grants: vec![ + maka_agent_facts::interaction::SystemPermissionGrant::Accessibility, + maka_agent_facts::interaction::SystemPermissionGrant::ScreenRecording, + ], + application: "Maka Host".into(), + }, + }; + store + .establish_run_interaction(&invocation, &record) + .await + .map_err(|error| error.to_string())?; + } + let listed = fixture + .query("pendingInteractions", json!({"sessionId":session})) + .await?; + for id in ["system_cancelled", "system_granted"] { + let entries = listed["entries"].as_array().ok_or("pending entries")?; + if !entries.contains( + &json!({"requestId":id,"kind":"system_permission","toolUseId":"computer_tool"}), + ) { + return Err(format!("system permission was not listed: {listed}")); + } + } + let respond = |answer: Value| { + fixture.mutation( + 2, + "respond", + json!({"sessionId":session,"requestId":"system_cancelled","answer":answer}), + Uuid::new_v4(), + ) + }; + let foreign = respond(json!({"kind":"computer_app_cancel"})).await?; + if foreign != json!({"kind":"rejected","reason":"invalid_answer"}) { + return Err(format!("foreign cancel was accepted: {foreign}")); + } + if respond(json!({"kind":"system_permission_granted"})) + .await + .is_ok() + { + return Err("a user answer claimed the system grant".into()); + } + let cancelled = respond(json!({"kind":"system_permission_cancel"})).await?; + if cancelled["kind"] != "recorded" + || cancelled["record"]["outcome"]["kind"] != "closure" + || cancelled["record"]["outcome"]["reason"] != "producer_cancelled" + { + return Err(format!( + "system permission cancel was not recorded: {cancelled}" + )); + } + let granted = store + .commit_interaction_outcome( + "system_granted", + InteractionOutcome::SystemPermissionGranted { committed_at: 2 }, + ) + .await + .map_err(|error| error.to_string())?; + if !granted.matches + || granted.record.outcome + != Some(InteractionOutcome::SystemPermissionGranted { committed_at: 2 }) + { + return Err("producer grant was not committed".into()); + } + Ok(()) + } + .await; + fixture + .mutation( + 2, + "stop", + json!({"sessionId":session,"invocationId":started["invocationId"]}), + Uuid::new_v4(), + ) + .await + .unwrap(); + fixture.drained().await; + fixture.app.shutdown().await.unwrap(); + fixture.config.shutdown().await.unwrap(); + observed.unwrap(); + }) + .await + .expect("native system permission fixture timed out"); +} + #[tokio::test] async fn native_question_roundtrip_is_bound_owned_and_first_wins() { tokio::time::timeout(Duration::from_secs(30), async { diff --git a/plugins/agent/native/src/owner/tests/service_tools/approval_park.rs b/plugins/agent/native/src/owner/tests/service_tools/approval_park.rs index f435b039e55..65da76ef938 100644 --- a/plugins/agent/native/src/owner/tests/service_tools/approval_park.rs +++ b/plugins/agent/native/src/owner/tests/service_tools/approval_park.rs @@ -98,6 +98,9 @@ async fn diagnostic_state(fixture: &Fixture, session: &str) -> String { maka_agent_facts::interaction::InteractionRequest::ComputerApp { .. } => "computer_app", + maka_agent_facts::interaction::InteractionRequest::SystemPermission { + .. + } => "system_permission", }; (record.request_id, kind) }) diff --git a/plugins/agent/ports/src/execution/message.rs b/plugins/agent/ports/src/execution/message.rs index 9d5bccc3db1..f1404ba1853 100644 --- a/plugins/agent/ports/src/execution/message.rs +++ b/plugins/agent/ports/src/execution/message.rs @@ -115,6 +115,7 @@ pub enum InteractionKind { Permissions, ClientCapability, ComputerApp, + SystemPermission, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/plugins/workspace/workspace-computer/native/src/evaluate.rs b/plugins/workspace/workspace-computer/native/src/evaluate.rs index 63f4442b7b5..c19f5eeaa33 100644 --- a/plugins/workspace/workspace-computer/native/src/evaluate.rs +++ b/plugins/workspace/workspace-computer/native/src/evaluate.rs @@ -372,6 +372,19 @@ impl Computer { } targets.push(api::audio_target()); } + let required = maka_computer_use::system_permission::required(&command); + if !required.is_empty() { + system_permission::ensure( + self, + &call, + basis, + required, + &operation_id, + &cancellation, + &cell, + ) + .await?; + } let subject = { let mut desktop = tokio::select! { biased; () = cancellation.cancelled() => return Err(failed("Computer Use cancelled before app resolution").into()), diff --git a/plugins/workspace/workspace-computer/native/src/lib.rs b/plugins/workspace/workspace-computer/native/src/lib.rs index 3ebe30aaa20..c0ad1a00a57 100644 --- a/plugins/workspace/workspace-computer/native/src/lib.rs +++ b/plugins/workspace/workspace-computer/native/src/lib.rs @@ -27,6 +27,7 @@ mod browser_popup; mod evaluate; mod native_monitor; mod sessions; +mod system_permission; use futures_util::{FutureExt, future::BoxFuture}; use maka_agent_facts::{tool_call::ToolOrigin, tools::ToolError}; diff --git a/plugins/workspace/workspace-computer/native/src/system_permission.rs b/plugins/workspace/workspace-computer/native/src/system_permission.rs new file mode 100644 index 00000000000..42fb684a657 --- /dev/null +++ b/plugins/workspace/workspace-computer/native/src/system_permission.rs @@ -0,0 +1,169 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +//! The operating system's privacy grants native desktop work needs. When one +//! is missing, the Host asks the operating system to show its request and +//! shows a card naming the switches to turn on, then waits: it checks the +//! grants again while the card is open and continues the call once they are +//! in effect. Cancelling the card fails the call as before. +use super::*; +use maka_agent_facts::interaction::{ + InteractionOutcome, InteractionRecord, InteractionRequest, SystemPermissionGrant, +}; +use maka_agent_facts::tools::ToolCallContext; +use maka_agent_facts::workspace::PermissionBasis; +use maka_capabilities::call::Scope; +use maka_computer_use::system_permission; +use sha2::{Digest, Sha256}; + +/// How often the grants are checked again while the card is open. +const RECHECK: std::time::Duration = std::time::Duration::from_millis(1500); + +/// The grants still missing; `None` when the check itself failed. +async fn missing(required: &'static [SystemPermissionGrant]) -> Option> { + tokio::task::spawn_blocking(move || system_permission::missing(required)) + .await + .ok() +} + +pub(super) async fn ensure( + owner: &Computer, + call: &Scope, + basis: PermissionBasis, + required: &'static [SystemPermissionGrant], + native_operation_id: &str, + cancellation: &CancellationToken, + cell: &maka_code_runtime::CellContext, +) -> Result<(), ToolError> { + // A failed check leaves the native call to report what it meets. + let grants = missing(required).await.unwrap_or_default(); + if grants.is_empty() { + return Ok(()); + } + let application = tokio::task::spawn_blocking({ + let grants = grants.clone(); + move || { + system_permission::request(&grants); + system_permission::application() + } + }) + .await + .map_err(|_| failed("System permission check stopped"))?; + let still_missing = + |grants: &[SystemPermissionGrant]| failed(system_permission::failure(grants, &application)); + let (configuration, _) = access::current(owner, call, "cua_repl").await?; + if configuration.permission_basis() != basis { + return Err(failed("Computer Use permission boundary changed")); + } + if !configuration.permissions.request_permissions { + return Err(still_missing(&grants)); + } + let context = ToolCallContext { + execution: call.execution().cloned(), + invocation: call.identity.agent().expect("checked Agent").clone(), + operation_id: call.identity.operation_id().expect("checked tool").into(), + }; + let store = maka_agent_authority::permissions::execution( + &owner.store, + &context.invocation, + context.execution.as_deref(), + )?; + let request_id = format!( + "system_permission_{}", + hex::encode(Sha256::digest( + serde_json::to_vec(&( + &context.invocation, + &context.operation_id, + native_operation_id, + &grants + )) + .expect("trusted grant list") + )) + ); + let record = InteractionRecord { + session_id: context.invocation.session_id.clone(), + turn_id: context.invocation.turn_id.clone(), + run_id: context.invocation.run_id.clone(), + request_id, + created_at: maka_agent_authority::now(), + outcome: None, + request: InteractionRequest::SystemPermission { + tool_use_id: context.tool_use_id(), + grants: grants.clone(), + application: application.clone(), + }, + }; + let observed = async { + let committed = store + .establish_run_interaction(&context.invocation, &record) + .await + .map_err(|error| format!("System permission request publication unavailable: {error}"))?; + if !committed.matches { + return Err("System permission request identity conflict".into()); + } + let _timeout = cell.suspend_timeout(); + let granted = async { + loop { + tokio::time::sleep(RECHECK).await; + if missing(required).await.is_some_and(|grants| grants.is_empty()) { + // The first outcome stands: a cancel committed meanwhile wins. + store + .commit_interaction_outcome( + &record.request_id, + InteractionOutcome::SystemPermissionGranted { + committed_at: maka_agent_authority::now(), + }, + ) + .await + .map_err(|error| format!("System permission outcome unavailable: {error}"))?; + return Ok::<_, String>(()); + } + } + }; + tokio::select! { + outcome = maka_agent_authority::interaction_wait::wait(&store, &record, cancellation) => outcome.map_err(str::to_owned), + result = granted => { + result?; + maka_agent_authority::interaction_wait::wait(&store, &record, cancellation).await.map_err(str::to_owned) + }, + } + } + .await; + let outcome = match observed { + Ok(outcome) => outcome, + Err(error) => match maka_agent_authority::interaction_wait::withdraw(&store, &record).await + { + Ok(Some(outcome)) => outcome, + Ok(None) => return Err(failed(error)), + Err(error) => return Err(ToolError::CleanupUnconfirmed(error.into())), + }, + }; + if cancellation.is_cancelled() { + return Err(failed("System permission request cancelled")); + } + if matches!(outcome, InteractionOutcome::SystemPermissionGranted { .. }) { + return Ok(()); + } + // A cancel stands even if the grants arrived as the card closed. + match missing(required).await { + Some(missing) if !missing.is_empty() => Err(still_missing(&missing)), + Some(_) => Err(failed("System permission request cancelled")), + None => Err(still_missing(&grants)), + } +} diff --git a/products/maka/acp/src/foreground.rs b/products/maka/acp/src/foreground.rs index 4ef08f79bec..a1bc501fa40 100644 --- a/products/maka/acp/src/foreground.rs +++ b/products/maka/acp/src/foreground.rs @@ -137,6 +137,8 @@ impl Foreground { self.refresh().await?; let mut callbacks = JoinSet::>::new(); let mut seen = HashSet::new(); + // Callbacks by request, so one settled elsewhere can be dropped. + let mut live = HashMap::::new(); let mut pending = Vec::::new(); let mut heartbeat = tokio::time::interval(Duration::from_secs(15)); heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -171,15 +173,13 @@ impl Foreground { { continue; } - if seen.len() >= 1024 || callbacks.len() >= 16 { - return Err("ACP interaction limit exceeded".into()); - } seen.insert(record.request_id.clone()); let callback = interaction::request(&record, &self.capabilities)?; let client = self.client.clone(); let peer = self.peer.clone(); let cancel = self.active.cancel.clone(); - callbacks.spawn(async move { + let request_id = record.request_id.clone(); + let handle = callbacks.spawn(async move { let response = peer.request(callback, &cancel).await?; let current: InteractionRecord = client.query("agent", "interaction", json!({"sessionId":record.session_id,"requestId":record.request_id})).await?; if current.outcome.is_some() { return Ok(()); } @@ -187,6 +187,7 @@ impl Foreground { let result: InteractionResult = client.mutate("agent", "respond", json!({"sessionId":current.session_id,"requestId":current.request_id,"answer":answer}), Attachments::new()).await?; match result { InteractionResult::Recorded { record } if record.outcome.is_some() => Ok(()), _ => Err("Host rejected interaction response".into()) } }); + live.insert(request_id, handle); self.state(acp::StateUpdate::RequiresAction( acp::RequiresActionStateUpdate::new(), )) @@ -206,7 +207,12 @@ impl Foreground { }, result = &mut next => break Some(result?), callback = callbacks.join_next(), if !callbacks.is_empty() => { - callback.ok_or("ACP callback task missing")???; + match callback.ok_or("ACP callback task missing")? { + Ok(result) => result?, + // Settled elsewhere; see `live`. + Err(error) if error.is_cancelled() => {} + Err(error) => return Err(error.into()), + } if callbacks.is_empty() { self.state(acp::StateUpdate::Running(acp::RunningStateUpdate::new())).await?; } }, _ = heartbeat.tick() => { @@ -219,7 +225,21 @@ impl Foreground { continue; }; match page { - DeliveryPage::Interactions { pending: update } => pending = update.entries, + DeliveryPage::Interactions { pending: update } => { + // An interaction settled without this client, such as one + // its producer resolved, no longer waits for its answer. + live.retain(|request_id, handle| { + let waiting = update + .entries + .iter() + .any(|entry| &entry.request_id == request_id); + if !waiting { + handle.abort(); + } + waiting + }); + pending = update.entries; + } page => self.frame(page).await?, } } diff --git a/products/maka/acp/src/interaction.rs b/products/maka/acp/src/interaction.rs index 668ece510cd..93fa408bca0 100644 --- a/products/maka/acp/src/interaction.rs +++ b/products/maka/acp/src/interaction.rs @@ -47,7 +47,8 @@ pub fn request( InteractionRequest::ServiceApproval { .. } | InteractionRequest::Permissions { .. } | InteractionRequest::ClientCapability { .. } - | InteractionRequest::ComputerApp { .. } => permission::request(snapshot), + | InteractionRequest::ComputerApp { .. } + | InteractionRequest::SystemPermission { .. } => permission::request(snapshot), InteractionRequest::Form { .. } | InteractionRequest::Question { .. } => { form::request(snapshot, capabilities) } @@ -66,7 +67,8 @@ pub fn answer( InteractionRequest::ServiceApproval { .. } | InteractionRequest::Permissions { .. } | InteractionRequest::ClientCapability { .. } - | InteractionRequest::ComputerApp { .. }, + | InteractionRequest::ComputerApp { .. } + | InteractionRequest::SystemPermission { .. }, Response::Permission(response), ) => permission::answer(snapshot, response)?, ( diff --git a/products/maka/acp/src/interaction/permission.rs b/products/maka/acp/src/interaction/permission.rs index ee12c91a984..f4e5869534d 100644 --- a/products/maka/acp/src/interaction/permission.rs +++ b/products/maka/acp/src/interaction/permission.rs @@ -24,6 +24,7 @@ use maka_agent_facts::interaction::InteractionRecord; use maka_agent_facts::interaction::InteractionRequest; use maka_agent_facts::interaction::{ ComputerAppDecision, ComputerAppPlatform, ComputerAppRisk, GrantCapability, GrantScope, + SystemPermissionGrant, }; use maka_sandbox::grant; @@ -227,6 +228,41 @@ pub(super) fn request(snapshot: &InteractionRecord) -> Result { + let names = grants + .iter() + .map(|grant| match grant { + SystemPermissionGrant::Accessibility => "Accessibility", + SystemPermissionGrant::ScreenRecording => "Screen Recording", + }) + .collect::>() + .join(" and "); + Ok(Callback::Permission( + acp::RequestPermissionRequest::new( + acp::SessionId::new(snapshot.session_id.as_str()), + format!("{application} needs {names}"), + vec![acp::PermissionOption::new( + "reject_once", + "Cancel", + acp::PermissionOptionKind::RejectOnce, + )], + ) + .description(format!( + "Grant {names} to {application} in System Settings > Privacy & Security. \ + The tool call continues automatically once the access is granted." + )) + .subject(acp::RequestPermissionSubject::from( + acp::ToolCallUpdate::new(acp::ToolCallId::new(tool_use_id.as_str())) + .raw_input(serde_json::to_value(&snapshot.request)?), + )), + )) + } _ => Err("Expected a permission interaction".into()), } } @@ -327,6 +363,20 @@ pub(super) fn answer( }; InteractionAnswer::ComputerApp { decision } } + InteractionRequest::SystemPermission { .. } => match response.outcome { + acp::RequestPermissionOutcome::Cancelled => { + InteractionAnswer::SystemPermissionCancel {} + } + acp::RequestPermissionOutcome::Selected(selected) + if selected.option_id.0.as_ref() == "reject_once" => + { + InteractionAnswer::SystemPermissionCancel {} + } + acp::RequestPermissionOutcome::Selected(_) => { + return Err("Client selected an unoffered system permission option".into()); + } + _ => return Err("Unsupported ACP permission outcome".into()), + }, _ => return Err("Expected a permission interaction".into()), }) } @@ -495,6 +545,70 @@ mod tests { } } + fn system_permission() -> InteractionRecord { + snapshot(InteractionRequest::SystemPermission { + tool_use_id: "tool_system".into(), + grants: vec![ + SystemPermissionGrant::Accessibility, + SystemPermissionGrant::ScreenRecording, + ], + application: "Maka Host".into(), + }) + } + + #[test] + fn system_permission_prompt_names_grants_and_offers_only_cancel() { + let record = system_permission(); + let Callback::Permission(callback) = + request(&record, &acp::ClientCapabilities::default()).unwrap() + else { + panic!("permission callback"); + }; + assert_eq!( + callback.title, + "Maka Host needs Accessibility and Screen Recording" + ); + assert_eq!( + callback.description.as_deref(), + Some( + "Grant Accessibility and Screen Recording to Maka Host in System Settings > Privacy & Security. The tool call continues automatically once the access is granted." + ) + ); + assert_eq!( + serde_json::to_value(&callback.options).unwrap(), + json!([{"optionId":"reject_once","name":"Cancel","kind":"reject_once"}]) + ); + let Some(acp::RequestPermissionSubject::ToolCall(subject)) = callback.subject else { + panic!("tool subject"); + }; + let subject = serde_json::to_value(subject).unwrap(); + assert_eq!(subject["toolCall"]["toolCallId"], "tool_system"); + assert_eq!( + subject["toolCall"]["rawInput"]["grants"], + json!(["accessibility", "screen_recording"]) + ); + } + + #[test] + fn system_permission_answers_can_only_cancel() { + let record = system_permission(); + for response in [ + json!({"outcome":{"outcome":"cancelled"}}), + json!({"outcome":{"outcome":"selected","optionId":"reject_once"}}), + ] { + assert_eq!( + answer(&record, response).unwrap(), + InteractionAnswer::SystemPermissionCancel {} + ); + } + for response in [ + json!({"outcome":{"outcome":"selected","optionId":"allow_once"}}), + json!({"outcome":{"outcome":"_future_approval"}}), + ] { + assert!(answer(&record, response).is_err()); + } + } + #[test] fn computer_output_audio_is_named_and_remains_a_session_capability() { let InteractionRequest::ComputerApp { diff --git a/products/maka/acp/src/transport.rs b/products/maka/acp/src/transport.rs index 41ce2284cc3..ddfa3c69a66 100644 --- a/products/maka/acp/src/transport.rs +++ b/products/maka/acp/src/transport.rs @@ -80,12 +80,12 @@ impl Peer { tokio::select! { _ = self.stop.cancelled() => Err("ACP connection closed".into()), _ = cancel.cancelled() => Err("ACP interaction cancelled".into()), - result = tokio::time::timeout(Duration::from_secs(300), async { + result = async { Ok::<_, Error>(match callback { Callback::Permission(request) => Response::Permission(self.connection.send_request(request).block_task().await?), Callback::Elicitation(request) => Response::Elicitation(self.connection.send_request(request).block_task().await?), }) - }) => result?, + } => result, } } } diff --git a/products/maka/workbench/app/InteractionComposer.tsx b/products/maka/workbench/app/InteractionComposer.tsx index e32b7a44b21..f518ae2a181 100644 --- a/products/maka/workbench/app/InteractionComposer.tsx +++ b/products/maka/workbench/app/InteractionComposer.tsx @@ -33,7 +33,7 @@ import { Input } from 'nsh/base'; import type { ViewRef } from 'nsh'; import { useHost } from './host'; import { keymap } from 'nsh'; -import { windowSize } from 'nsh'; +import { openUrl, windowSize } from 'nsh'; import { Button } from 'nsh/base'; import type { InteractionAnswer, @@ -66,6 +66,11 @@ import { Icon } from './Icon'; import { CapabilityRequest, computerCapability } from './CapabilityRequest'; import { FormRequest } from './FormRequest'; import { ComputerAppRequest } from './ComputerAppRequest'; +import { + SystemPermissionRequest, + systemPermissionName, + systemPermissionPane, +} from './SystemPermissionRequest'; import { computerAppOptions } from './interaction-computer-app'; import { formSubmission, @@ -183,6 +188,7 @@ export function interactionOutcomeSummary(outcome: InteractionOutcome): UiText { return outcome.decision === 'allow' ? uiMessage('Capability allowed') : uiMessage('Capability denied'); + if (outcome.kind === 'system_permission_granted') return uiMessage('System permissions granted'); if (outcome.kind === 'computer_app_decision') return outcome.decision === 'deny' ? uiMessage('Application access denied') @@ -256,6 +262,14 @@ export function InteractionComposer({ const current = request(); return current?.kind === 'computer_app' ? current : null; }; + const systemPermissionRequest = () => { + const current = request(); + return current?.kind === 'system_permission' ? current : null; + }; + // A cancel answer has a kind of its own; it applies to its request kind. + const cancels = (answer: InteractionAnswer) => + (answer.kind === 'computer_app_cancel' && !!computerAppRequest()) || + (answer.kind === 'system_permission_cancel' && !!systemPermissionRequest()); const sameRecord = (value: InteractionRecord | null | undefined) => value?.sessionId === sessionId && value?.requestId === requestId; const ownsAction = (owner: InteractionOwner) => @@ -265,7 +279,9 @@ export function InteractionComposer({ return current ? JSON.stringify(current.questions) : null; }; const responseIdentity = () => - formRequest() || capabilityRequest() || computerAppRequest() ? JSON.stringify(request()) : null; + formRequest() || capabilityRequest() || computerAppRequest() || systemPermissionRequest() + ? JSON.stringify(request()) + : null; const form = () => formSubmission(formRequest()?.fields ?? [], formDrafts()); const changeForm = ( choice: InteractionOwner & { @@ -489,12 +505,7 @@ export function InteractionComposer({ if (ownsAction(owner)) void refresh(); }; const respond = async (answer: InteractionAnswer) => { - if ( - blocked() || - (answer.kind !== request()?.kind && - !(answer.kind === 'computer_app_cancel' && computerAppRequest())) - ) - return; + if (blocked() || (answer.kind !== request()?.kind && !cancels(answer))) return; busy.set(true); error.set(''); try { @@ -578,6 +589,15 @@ export function InteractionComposer({ if (kind === 'computer_app') return { kind, decision: 'deny' }; return null; }; + const openSettings = async (url: string) => { + let opened = false; + try { + opened = (await openUrl(url)).opened; + } catch (reason) { + console.warn('System Settings did not open:', reason); + } + if (alive && !opened) error.set(tr('System Settings could not be opened.')); + }; const deny = () => { const answer = denialAnswer(); if (answer) void respond(answer); @@ -592,16 +612,17 @@ export function InteractionComposer({ ) => { if (!ownsAction(rendered) || blocked()) return; const answer = JSON.parse(rendered.answerJSON) as InteractionAnswer | null; - if ( - !answer || - (answer.kind !== request()?.kind && - !(answer.kind === 'computer_app_cancel' && computerAppRequest())) - ) - return; + if (!answer || (answer.kind !== request()?.kind && !cancels(answer))) return; if (answer.kind === 'question' && rendered.questionsJSON !== questionIdentity()) return; if (answer.kind === 'service_approval' && rendered.sourceJSON !== sourceIdentity()) return; if ( - ['form', 'client_capability', 'computer_app', 'computer_app_cancel'].includes(answer.kind) && + [ + 'form', + 'client_capability', + 'computer_app', + 'computer_app_cancel', + 'system_permission_cancel', + ].includes(answer.kind) && rendered.requestJSON !== responseIdentity() ) return; @@ -668,6 +689,7 @@ export function InteractionComposer({ form: tr('Form request'), client_capability: tr('Capability request'), computer_app: tr('Application access request'), + system_permission: tr('System permission request'), }[current.kind] : tr('Review request'); }; @@ -993,6 +1015,9 @@ export function InteractionComposer({ + + + } > @@ -1067,6 +1092,22 @@ export function InteractionComposer({ gap: 8, }} > + + + + {(grant) => ( + void openSettings(systemPermissionPane(grant()))} + /> + )} + + ; + +/** The System Settings pane that holds each grant. */ +const panes: Record = { + accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', + screen_recording: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture', +}; + +export function systemPermissionPane(grant: SystemPermissionGrant): string { + return panes[grant]; +} + +/** The grant's name as System Settings shows it. */ +export function systemPermissionName(grant: SystemPermissionGrant): string { + return grant === 'accessibility' ? tr('Accessibility') : tr('Screen & System Audio Recording'); +} + +export function SystemPermissionRequest(props: { request: SystemPermissionRequest }) { + return ( + + + {tr( + '{application} needs these macOS permissions to use the computer. Turn them on in System Settings; the request continues on its own once they are on.', + { application: props.request.application }, + )} + + + + {(grant) => ( + + {systemPermissionName(grant())} + + )} + + + + {tr( + 'If {application} is not listed, reopen System Settings, or add it with the + button.', + { + application: props.request.application, + }, + )} + + + ); +} diff --git a/products/maka/workbench/app/locale-conversation.ts b/products/maka/workbench/app/locale-conversation.ts index 1d23f7ada11..660ae858961 100644 --- a/products/maka/workbench/app/locale-conversation.ts +++ b/products/maka/workbench/app/locale-conversation.ts @@ -1618,6 +1618,31 @@ export const conversationMessages = { '此對話已變更。請核對目前沙箱設定後重新選擇。', ], 'Application access request': ['Application access request', '应用访问请求', '應用程式存取請求'], + 'System permission request': ['System permission request', '系统权限请求', '系統權限請求'], + '{application} needs these macOS permissions to use the computer. Turn them on in System Settings; the request continues on its own once they are on.': + [ + '{application} needs these macOS permissions to use the computer. Turn them on in System Settings; the request continues on its own once they are on.', + '{application} 需要以下 macOS 权限才能操作电脑。请在系统设置中打开,打开后请求会自动继续。', + '{application} 需要以下 macOS 權限才能操作電腦。請在系統設定中開啟,開啟後請求會自動繼續。', + ], + Accessibility: ['Accessibility', '辅助功能', '輔助使用'], + 'Screen & System Audio Recording': [ + 'Screen & System Audio Recording', + '录屏与系统录音', + '螢幕與系統錄音', + ], + 'If {application} is not listed, reopen System Settings, or add it with the + button.': [ + 'If {application} is not listed, reopen System Settings, or add it with the + button.', + '如果列表中没有 {application},请重新打开系统设置,或用 + 按钮添加。', + '如果清單中沒有 {application},請重新打開系統設定,或用 + 按鈕加入。', + ], + 'Open {name} settings': ['Open {name} settings', '打开{name}设置', '開啟{name}設定'], + 'System Settings could not be opened.': [ + 'System Settings could not be opened.', + '无法打开系统设置。', + '無法開啟系統設定。', + ], + 'System permissions granted': ['System permissions granted', '系统权限已开启', '系統權限已開啟'], 'Application access denied': [ 'Application access denied', '已拒绝应用访问',