From de0c6200725a28b015767d9c018ecb315305806b Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 18:07:36 +0800 Subject: [PATCH] Show host computer-use permission status on remote clients --- crates/computer-use-mcp/src/permissions.rs | 39 +-- crates/core/src/lib.rs | 1 + crates/core/src/permissions.rs | 38 +++ crates/protocol/src/query.rs | 3 + crates/runtime/src/pipe.rs | 5 + crates/ui/src/host_permissions.rs | 306 +++++++++++++++++++++ crates/ui/src/lib.rs | 3 +- crates/ui/src/settings_page.rs | 36 +-- crates/ui/src/store/mod.rs | 14 + docs/DESIGN.md | 18 +- docs/computer-use.md | 7 + locales/en.yml | 7 +- locales/zh-CN.yml | 7 +- 13 files changed, 424 insertions(+), 60 deletions(-) create mode 100644 crates/core/src/permissions.rs create mode 100644 crates/ui/src/host_permissions.rs diff --git a/crates/computer-use-mcp/src/permissions.rs b/crates/computer-use-mcp/src/permissions.rs index 284db571..53be3a62 100644 --- a/crates/computer-use-mcp/src/permissions.rs +++ b/crates/computer-use-mcp/src/permissions.rs @@ -6,20 +6,7 @@ //! effect after the app restarts (macOS shows its own "Quit & Reopen" dialog); //! callers must persist any restart-continuity marker *before* requesting. -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PermissionKind { - Accessibility, - ScreenRecording, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct PermissionStatus { - pub accessibility: bool, - pub screen_recording: bool, -} +pub use tcode_core::permissions::{ComputerUsePermissions, PermissionKind, PermissionStatus}; /// The explicit user-facing action to perform for a missing permission. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -59,24 +46,22 @@ impl PermissionGrantFlow { } } -impl PermissionStatus { - pub fn granted(&self, kind: PermissionKind) -> bool { - match kind { - PermissionKind::Accessibility => self.accessibility, - PermissionKind::ScreenRecording => self.screen_recording, - } - } - - pub fn all_granted(&self) -> bool { - self.accessibility && self.screen_recording - } -} - /// Non-prompting snapshot of both TCC grants for this process. pub fn check() -> PermissionStatus { imp::check() } +/// Non-prompting host snapshot including whether these grants apply at all. +pub fn host_status() -> ComputerUsePermissions { + if cfg!(target_os = "macos") { + ComputerUsePermissions::MacOs(check()) + } else if cfg!(target_os = "windows") { + ComputerUsePermissions::NotRequired + } else { + ComputerUsePermissions::Unsupported + } +} + /// Fire the native request for one permission kind. The system prompt may /// complete asynchronously or stop appearing after an earlier attempt. Callers /// should offer [`open_settings_pane`] as a later, explicit fallback instead of diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 280a2a9a..1862dd20 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -3,6 +3,7 @@ pub mod acp; pub mod attachments; pub mod git; +pub mod permissions; pub mod project; pub mod provider_models; pub mod provider_status; diff --git a/crates/core/src/permissions.rs b/crates/core/src/permissions.rs new file mode 100644 index 00000000..4af3cf4a --- /dev/null +++ b/crates/core/src/permissions.rs @@ -0,0 +1,38 @@ +//! Computer-use permission facts, reported by the machine running the agent. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionKind { + Accessibility, + ScreenRecording, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionStatus { + pub accessibility: bool, + pub screen_recording: bool, +} + +impl PermissionStatus { + pub fn granted(&self, kind: PermissionKind) -> bool { + match kind { + PermissionKind::Accessibility => self.accessibility, + PermissionKind::ScreenRecording => self.screen_recording, + } + } + + pub fn all_granted(&self) -> bool { + self.accessibility && self.screen_recording + } +} + +/// Platform semantics are supplied by the host, never inferred by a client. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "platform", content = "status", rename_all = "snake_case")] +pub enum ComputerUsePermissions { + MacOs(PermissionStatus), + NotRequired, + Unsupported, +} diff --git a/crates/protocol/src/query.rs b/crates/protocol/src/query.rs index ff479fb0..0a45cf84 100644 --- a/crates/protocol/src/query.rs +++ b/crates/protocol/src/query.rs @@ -11,6 +11,8 @@ pub enum Query { Hosting { action: HostingAction, }, + /// Read the agent host's system grants without requesting access. + ComputerUsePermissions, Ping, /// Records strictly before the absolute event cursor, oldest first. SessionHistoryPage { @@ -98,6 +100,7 @@ pub const MAX_THREAD_EXPORT_BYTES: usize = 8 * 1024 * 1024; #[serde(tag = "type", content = "content", rename_all = "snake_case")] pub enum QueryResponse { Hosting(HostingState), + ComputerUsePermissions(tcode_core::permissions::ComputerUsePermissions), Pong, SessionHistoryPage { records: Vec, diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index 25ec993b..c0e9ea7c 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -594,6 +594,11 @@ fn dispatch_query( message: "this host has no remote hosting controls".into(), }) }), + Query::ComputerUsePermissions => cx.spawn_background(async { + Ok(QueryResponse::ComputerUsePermissions( + computer_use_mcp::permissions::host_status(), + )) + }), Query::Ping => cx.spawn_background(async { Ok(QueryResponse::Pong) }), Query::ListActiveWorkspace { session_id } => { let cwd = app diff --git a/crates/ui/src/host_permissions.rs b/crates/ui/src/host_permissions.rs new file mode 100644 index 00000000..61c3d6ff --- /dev/null +++ b/crates/ui/src/host_permissions.rs @@ -0,0 +1,306 @@ +//! Read-only system grants from the attached agent host, on every client. +use gpui::{ + Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, Subscription, Task, + Window, div, px, +}; +use gpui_base::{h_flex, v_flex}; +use tcode_client::ConnectionState; +use tcode_core::permissions::ComputerUsePermissions; + +use crate::{ + sizing::Sizable as _, store::WorkspaceStore, theme::ActiveTheme as _, widgets::button::Button, +}; + +pub(crate) struct HostPermissions { + store: Entity, + result: Option>, + request: Option>, + connected: bool, + _connection: Subscription, +} + +impl HostPermissions { + pub(crate) fn new(store: Entity, cx: &mut Context) -> Self { + let connected = *store.read(cx).connection_state() == ConnectionState::Connected; + let connection = cx.observe(&store, |this, store, cx| { + let connected = *store.read(cx).connection_state() == ConnectionState::Connected; + if this.connected != connected { + this.connected = connected; + // A disconnected snapshot is no longer evidence of current grants. + // Cancel its request so a delayed answer cannot restore old status. + this.request = None; + this.result = None; + if connected { + this.refresh(cx); + } + cx.notify(); + } + }); + let mut panel = Self { + store, + result: None, + request: None, + connected, + _connection: connection, + }; + panel.refresh(cx); + panel + } + + fn refresh(&mut self, cx: &mut Context) { + if !self.connected || self.request.is_some() { + return; + } + self.result = None; + let task = self + .store + .update(cx, |store, cx| store.computer_use_permissions(cx)); + self.request = Some(cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |this, cx| { + this.result = Some(result); + this.request = None; + cx.notify(); + }); + })); + cx.notify(); + } +} + +impl Render for HostPermissions { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let mut body = v_flex().w_full().gap_3().p_3().text_size(px(13.)); + match &self.result { + Some(Ok(ComputerUsePermissions::MacOs(status))) => { + for (name, granted) in [ + ("permissions.accessibility.name", status.accessibility), + ("permissions.screen_recording.name", status.screen_recording), + ] { + let (label, bg, fg) = if granted { + ( + "permissions.granted", + cx.theme().success, + cx.theme().success_foreground, + ) + } else { + ( + "permissions.missing", + cx.theme().warning, + cx.theme().warning_foreground, + ) + }; + body = body.child( + h_flex() + .w_full() + .gap_3() + .items_center() + .child(div().flex_1().min_w_0().child(crate::tr!(name))) + .child(crate::material::semantic_chip( + crate::tr!(label), + bg.opacity(0.12), + fg, + )), + ); + } + if let Some(host) = self.store.read(cx).remote_host_name() { + body = + body.child(div().text_color(cx.theme().muted_foreground).child( + crate::tr!("permissions.manage_on_host", host = host).into_owned(), + )); + } + body = body.child( + div() + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("permissions.restart_on_host")), + ); + } + Some(Ok(ComputerUsePermissions::NotRequired)) => { + body = body.child(crate::tr!("permissions.not_required")); + } + Some(Ok(ComputerUsePermissions::Unsupported)) => { + body = body.child(crate::tr!("permissions.host_unsupported")); + } + Some(Err(error)) => { + body = body + .child( + div() + .text_color(cx.theme().danger_foreground) + .child(crate::tr!("permissions.check_failed")), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(error.clone()), + ); + } + None => { + body = body.child(crate::tr!(if self.connected { + "permissions.checking" + } else { + "permissions.disconnected" + })); + } + } + let compact = crate::window_seam::window_is_compact(window, cx); + let mut button = Button::new("host-permissions-recheck") + .outline() + .small() + .label(crate::tr!("permissions.recheck")) + .disabled(!self.connected || self.request.is_some()) + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))); + if compact { + button = button.w_full(); + } + crate::material::group(cx).child(body.child(div().child(button))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext as _, TestAppContext}; + use tcode_client::HostLink; + use tcode_protocol::{ + ClientPayload, EventEnvelope, HostMessage, IndexSnapshot, Query, ServerEvent, Topic, + decode_client_line, encode_line, + }; + + #[gpui::test] + fn host_grants_refresh_and_never_survive_disconnect_or_failed_recheck(cx: &mut TestAppContext) { + let (to_host, requests) = async_channel::unbounded(); + let (replies, from_host) = async_channel::unbounded(); + let link = HostLink::new(to_host, from_host); + let store = cx.new(|cx| { + WorkspaceStore::new_attached( + link.clone(), + crate::store::WorkspaceAttachment::Remote { + host_id: "permission-host".into(), + host_name: "Agent Mac".into(), + }, + None, + false, + cx, + ) + }); + let pump_link = link.clone(); + let executor = cx.background_executor.clone(); + let _pump = cx.background_executor.spawn(async move { + pump_link + .pump_with_timer(|| executor.timer(std::time::Duration::from_millis(25))) + .await; + }); + let baseline = || { + for (topic, event) in [ + ( + Topic::Index, + ServerEvent::IndexSnapshot(IndexSnapshot { + projects: vec![], + sessions: vec![], + activity: Default::default(), + title_generating: Default::default(), + }), + ), + ( + Topic::Settings, + ServerEvent::SettingsSnapshot(Default::default()), + ), + ] { + replies + .send_blocking( + encode_line(&HostMessage::Event(EventEnvelope { + request_id: None, + topic, + event, + })) + .unwrap(), + ) + .unwrap(); + } + }; + baseline(); + cx.run_until_parked(); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + let panel = cx.new(|cx| HostPermissions::new(store.clone(), cx)); + cx.run_until_parked(); + let next_query = || { + while let Ok(line) = requests.try_recv() { + let message = decode_client_line(&line).unwrap(); + if let ClientPayload::Query(query) = message.payload { + assert_eq!(query, Query::ComputerUsePermissions); + return message.id; + } + } + panic!("expected host permission query"); + }; + let reply = |id, result: &str| { + replies + .send_blocking(format!( + "{{\"type\":\"query_result\",\"content\":{{\"id\":{id},\"result\":{result}}}}}\n" + )) + .unwrap(); + }; + // These platform facts come from the wire, independently of the test + // runner's OS and grants. Rechecking must replace the previous result. + let id = next_query(); + reply( + id, + r#"{"Ok":{"type":"computer_use_permissions","content":{"platform":"mac_os","status":{"accessibility":true,"screen_recording":false}}}}"#, + ); + cx.run_until_parked(); + panel.read_with(cx, |panel, _| { + assert_eq!( + panel.result, + Some(Ok(ComputerUsePermissions::MacOs( + tcode_core::permissions::PermissionStatus { + accessibility: true, + screen_recording: false, + } + ))) + ) + }); + panel.update(cx, |panel, cx| panel.refresh(cx)); + cx.run_until_parked(); + let stale_id = next_query(); + link.set_connection_state(ConnectionState::Reconnecting { + attempt: 1, + reason: None, + }); + cx.run_until_parked(); + reply( + stale_id, + r#"{"Ok":{"type":"computer_use_permissions","content":{"platform":"not_required"}}}"#, + ); + cx.run_until_parked(); + panel.read_with(cx, |panel, _| { + assert!( + panel.result.is_none(), + "late success must not overwrite disconnection" + ); + assert!(panel.request.is_none()); + }); + link.set_connection_state(ConnectionState::Connected); + cx.run_until_parked(); + baseline(); + cx.run_until_parked(); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + cx.run_until_parked(); + reply( + next_query(), + r#"{"Ok":{"type":"computer_use_permissions","content":{"platform":"not_required"}}}"#, + ); + cx.run_until_parked(); + panel.read_with(cx, |panel, _| { + assert_eq!(panel.result, Some(Ok(ComputerUsePermissions::NotRequired))) + }); + panel.update(cx, |panel, cx| panel.refresh(cx)); + cx.run_until_parked(); + reply( + next_query(), + r#"{"Err":{"code":"unsupported","message":"old host"}}"#, + ); + cx.run_until_parked(); + panel.read_with(cx, |panel, _| { + assert_eq!(panel.result, Some(Err("old host".into()))) + }); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 6d6624ca..c530c3b4 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -15,10 +15,11 @@ pub(crate) mod diff; pub mod gallery_support; pub(crate) mod git; mod highlight; +mod host_permissions; pub mod i18n; pub mod icon; /// macOS TCC permission status and grant flow. Compiled only where the platform -/// actually has one; every other build shows the host/unsupported note instead. +/// actually has one; other attachments query the host for read-only status. #[cfg(all(feature = "local-permissions", target_os = "macos"))] mod local_permissions; pub mod markdown; diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index c90e11f8..d3b23ed3 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -330,6 +330,7 @@ pub struct SettingsPage { /// *and* the workspace is this machine's. #[cfg(all(feature = "local-permissions", target_os = "macos"))] local_permissions: Option>, + host_permissions: Option>, /// One focus handle per toggle row, keyed by row id. The row owns keyboard /// activation, so its capture-phase Space handler must be able to tell /// "the row is focused" from "the inline reset button inside it is". @@ -520,6 +521,7 @@ impl SettingsPage { hydrated: false, #[cfg(all(feature = "local-permissions", target_os = "macos"))] local_permissions, + host_permissions: None, toggle_focus: HashMap::new(), _subscriptions: subscriptions, }; @@ -715,6 +717,7 @@ impl SettingsPage { } fn select_section(&mut self, section: Section, cx: &mut Context) { + self.host_permissions = None; if !section.applies(&self.capabilities) { self.section = Section::General; Self::return_to_settings_root(&self.window_state, cx); @@ -1178,6 +1181,9 @@ impl SettingsPage { } else { self.usage_refresh_sent = false; } + if self.section != Section::ComputerUse { + self.host_permissions = None; + } let column = match self.section { Section::General => self.render_general(cx), Section::Providers => self.render_providers(window, cx), @@ -2416,13 +2422,8 @@ impl SettingsPage { ) } - /// The Computer Use "System permissions" group. - /// - /// Permissions belong to the machine that runs the agent, so only a local - /// attachment on a platform with TCC shows live status and grant controls. - /// A remote client is told where to manage them; it never infers the host's - /// permission state from its own operating system. - fn permissions_group(&self, cx: &mut Context) -> AnyElement { + /// Status belongs to the attached host; native grant controls stay local. + fn permissions_group(&mut self, cx: &mut Context) -> AnyElement { let column = v_flex().child(self.section_label(crate::tr!("computer_use.permissions_section"), cx)); if self.capabilities.can_manage_local_permissions() @@ -2430,23 +2431,10 @@ impl SettingsPage { { return column.child(rows).into_any_element(); } - let message = match self.store.read(cx).remote_host_name() { - Some(host) => crate::tr!("permissions.manage_on_host", host = host).into_owned(), - None => crate::tr!("permissions.unsupported").into_owned(), - }; - column - .child( - crate::material::group(cx).child( - div() - .w_full() - .px_3() - .py_3() - .text_size(px(13.)) - .text_color(cx.theme().muted_foreground) - .child(message), - ), - ) - .into_any_element() + let rows = self.host_permissions.get_or_insert_with(|| { + cx.new(|cx| crate::host_permissions::HostPermissions::new(self.store.clone(), cx)) + }); + column.child(rows.clone()).into_any_element() } #[cfg(all(feature = "local-permissions", target_os = "macos"))] diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 251325b3..6eecc1ee 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -2358,6 +2358,20 @@ impl WorkspaceStore { }) } + pub fn computer_use_permissions( + &self, + cx: &mut App, + ) -> Task> { + let host = self.host.clone(); + cx.spawn( + async move |_| match host.query(Query::ComputerUsePermissions).await { + Ok(QueryResponse::ComputerUsePermissions(status)) => Ok(status), + Ok(_) => Err("unexpected computer-use permissions response".into()), + Err(error) => Err(error.message), + }, + ) + } + #[cfg(target_family = "wasm")] pub fn hosting( &self, diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9ed56d21..b748d217 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1080,12 +1080,18 @@ remote link, while operations that drive a local native facility require that facility here. Thus Browser is omitted without an embedded preview backend and Other devices is omitted without hosting support. A mixed section stays listed for its applicable rows and withholds only the unavailable rows. Computer Use -configuration is replicated and stays editable; only its **System permissions** -group is local — it shows live status and Grant/Recheck when this build can read -them *and* the workspace is this machine's, and otherwise says to manage system -permissions on the named host. A client never reports the host's permission -state from its own OS. A stale deep link or command targeting a withheld section -lands on the Settings root rather than opening an empty page. +configuration is replicated and stays editable. Its **System permissions** group +shows local status and Grant/Recheck when this build can manage permissions on +this machine. Other attachments query the agent host and show separate +Accessibility and Screen Recording status chips plus **Recheck**. Opening the +section, reconnecting, or pressing Recheck reads a fresh, non-prompting snapshot. +Remote clients name the host where grants must be managed and explain that a new +Screen Recording grant requires restarting Tcode there. Loading, query failures, +and disconnection are explicit unknown states, never missing or granted status; +a disconnected snapshot is discarded. Hosts without these system gates say no +separate grants are required; unsupported hosts say Computer Use is unavailable. +The client's own OS never supplies the host's status. A stale deep link or command +targeting a withheld section lands on the Settings root rather than opening an empty page. Editable fields are seeded from the host's settings the first time a real snapshot exists, not from local defaults, and a field the user has since edited diff --git a/docs/computer-use.md b/docs/computer-use.md index 4692af97..8868d151 100644 --- a/docs/computer-use.md +++ b/docs/computer-use.md @@ -159,6 +159,13 @@ The relevant Settings pages are: the next explicit action becomes **Open System Settings** and deep-links the matching `x-apple.systempreferences` pane. Returning to Tcode also triggers a recheck. +Remote clients show the host's Accessibility and Screen Recording grants in the +same System permissions group. Opening the section, reconnecting, and **Recheck** +query the host without prompting. Grants and any required restart must still be +performed on that host. Disconnection or a failed query shows unknown status, +not a cached success. Windows reports that separate grants are unnecessary; +unsupported hosts are identified explicitly. + The persisted computer-use block additionally accepts `allow_foreground_fallback` (default `false`) and `show_agent_cursor` (default `true`). Both use serde defaults, so settings files from before background delivery continue to load without migration. diff --git a/locales/en.yml b/locales/en.yml index 686ba318..040c67f1 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -344,12 +344,17 @@ browser: disabled_error: "The embedded browser is disabled in Settings → Browser." evaluate_disabled_error: "preview_evaluate is disabled in Settings → Browser." permissions: + checking: "Checking host permissions…" + disconnected: "Host permissions are unknown while disconnected. Reconnect to check." + check_failed: "Unable to read host permissions. Try again after checking the connection and host version." + not_required: "This host does not require separate Accessibility or Screen Recording grants." + host_unsupported: "Computer use is not supported on this host." + restart_on_host: "After granting Screen Recording, restart Tcode on the host for it to take effect." granted: "Granted" missing: "Not granted" grant: "Request Access" open_settings: "Open System Settings" recheck: "Recheck" - unsupported: "Only available on macOS." manage_on_host: "Manage these system permissions on %{host}." restart_banner: "Screen Recording grants take effect only after Tcode restarts." relaunch: "Relaunch Tcode" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 5fe94545..c85e1516 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -344,12 +344,17 @@ browser: disabled_error: "内嵌浏览器已在“设置 → 浏览器”中禁用。" evaluate_disabled_error: "preview_evaluate 已在“设置 → 浏览器”中禁用。" permissions: + checking: "正在检查主机权限…" + disconnected: "连接断开,无法确认主机权限。重新连接后将再次检查。" + check_failed: "无法读取主机权限,请检查连接和主机版本后重试。" + not_required: "此主机无需单独授予辅助功能或屏幕录制权限。" + host_unsupported: "此主机不支持电脑操作。" + restart_on_host: "授予屏幕录制权限后,请在主机上重启 Tcode 使其生效。" granted: "已授权" missing: "未授权" grant: "请求授权" open_settings: "打开系统设置" recheck: "重新检查" - unsupported: "仅在 macOS 上可用。" manage_on_host: "请在 %{host} 上管理系统权限。" restart_banner: "屏幕录制授权需重新启动 Tcode 后才会生效。" relaunch: "重新启动 Tcode"