diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 8ca0be4275..1983213dbe 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -150,6 +150,9 @@ await api.invoke('your_command', { request: { ... } }); - 新增功能时,从一开始就要考虑远程工作区和远程控制同步适配。只支持本地的行为很容易让远程场景功能缺失。 - 如果某个功能无法合理支持远程工作区,必须做能力屏蔽,或展示明确的不支持提示,不能让它以通用错误的形式失败。 +- 每个桌面端 Tauri 命令都必须在 + `src/apps/desktop/src/api/remote_workspace_policy.rs` 中声明远程工作区策略; + 该文件的契约测试会拒绝没有显式策略的新命令,并禁止 legacy-unaudited 存量清单增长。 ### Agent loop 行为 diff --git a/AGENTS.md b/AGENTS.md index 95508ad25e..036ea7cfc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,6 +158,10 @@ await api.invoke('your_command', { request: { ... } }); - When adding features, consider remote workspace and remote control synchronization support from the start. Local-only behavior can silently leave remote scenarios incomplete. - If a feature cannot reasonably support remote workspaces, gate it or show a clear unsupported-state message instead of letting it fail with a generic error. +- Every desktop Tauri command must declare its remote-workspace policy in + `src/apps/desktop/src/api/remote_workspace_policy.rs`; the contract test there + rejects new commands without an explicit policy and forbids growing the + legacy-unaudited backlog. ### Agent loop behavior diff --git a/docs/superpowers/specs/2026-07-16-mobile-web-login-device-switch-design.md b/docs/superpowers/specs/2026-07-16-mobile-web-login-device-switch-design.md new file mode 100644 index 0000000000..1bee4be5a1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-mobile-web-login-device-switch-design.md @@ -0,0 +1,113 @@ +# Mobile-Web 账号接续与设备互联 UI/UX 设计 + +Date: 2026-07-16 +Scope: `src/mobile-web`, `src/crates/services/services-integrations` (remote_connect contracts), `src/crates/assembly/core` (remote_connect service) + +## 背景与定位 + +Mobile-web 是手机端的**有限接续平台**:扫码接续单台开启网络中继的 BitFun Desktop, +提供工作区/会话切换与新建、聊天等有限能力。桌面端登录账号后,配对应自动把账号身份 +(delegated identity)继承给 mobile-web,使其可以把控制目标切换到同账号下任意在线设备, +切换后仍只提供原有的有限能力(数据面复用 `RemoteCommand`,不做桌面级 Peer Mode +transport 替换,不引入 `peer_control_attach/detach`)。 + +## 排查结论(根因) + +1. **P0 — 账号身份从未到达 mobile**:桌面配对成功后用同一 `correlation_id` 连续发送 + `initial_sync` 与 `delegate_identity` 两帧 room 响应;relay 的 pending 是一次性的 + (`resolve_pending` 移除即失效),第二帧命中 `No pending request` 被丢弃。 + mobile `pair()` 只读取一次 HTTP 响应。结果 `hasDelegatedIdentity` 恒为 false, + 设备列表 / 设备 RPC 全部不可用。(web-ui 的 `accountDelegateToPaired` 定义后从未被 + 调用,属死代码,不在本次范围内。) +2. **P1 — 切换后状态未刷新**:`DevicesPage` 仅改 `RelayHttpClient.pairedDeviceId` + 即返回,store 中旧设备的 workspace/sessions/messages 残留;无当前控制设备指示。 +3. **P1 — 导航 bug**:`App.tsx` 的 `popstate` 无 `devices` 分支,设备页按系统返回键 + 历史栈与 UI 脱节;设备页导航未走 `navigateTo`,无 push/pop 动画。 +4. **P1 — UI 脱节**:`DevicesPage` 无任何 SCSS(BEM 类未定义); + `session-list__devices-btn` 无样式且 title/aria 硬编码英文; + `devices.online/offline` 用 emoji;PairingPage 的 3D cube 与整体品牌视觉 + (Logo-ICON + token 化 header)不一致;`devices.*` 存在大量遗留未用 key。 + +## 方案(已确认) + +身份传递改为**拉模型**:配对完成后 mobile 通过 room 通道主动请求账号身份, +单请求单响应,不依赖 relay 多帧能力。 + +### 后端 + +1. `services-integrations/src/remote_connect.rs` + - `RemoteCommand` 新增 `GetDelegatedIdentity`(serde tag `get_delegated_identity`)。 + - `RemoteResponse` 新增 `DelegateIdentity { token, user_id, master_key, device_id }` + (serde tag `delegate_identity`,与 mobile 既有解析字段完全一致;master_key 为 base64)。 + - `handle_remote_command` 对 `GetDelegatedIdentity` 返回 + `Error("Delegated identity is not available on this host")`(宿主未拦截时的兜底)。 + - 合同测试补充两个 serde tag 断言。 +2. `assembly/core/src/service/remote_connect/mod.rs` + - `CommandReceived` 分支在 `decrypt_command` 成功后拦截 `GetDelegatedIdentity`: + 调用 `delegated_identity_fn`;成功 → 组 `DelegateIdentity` 响应 + (user_id 取 trusted mobile identity,device_id 取本机 device_id), + 经 `encrypt_response(request_id)` 回发;失败/未登录 → `Error`(mobile 视为未登录)。 + - 删除配对成功后推送 `delegate_identity` 第二帧的死代码(从未生效,只产生 relay 告警)。 + +### Mobile-web + +3. `RelayHttpClient.ts` / `RemoteSessionManager.ts` + - 移除 `pair()` 中的 `delegate_identity` 拦截分支(后端不再在 pair 响应发身份)。 + - 新增 `requestDelegatedIdentity(): Promise`:room 通道发 + `{cmd:'get_delegated_identity'}`;`resp==='delegate_identity'` 时写入 + token/masterKey/`pairedDeviceId`,并记录 `homeDeviceId`(扫码桌面); + error 响应返回 false(不抛异常)。 + - 命令路由:目标为扫码桌面(`pairedDeviceId === homeDeviceId`)时保持走 + room 通道(已验证路径,且不依赖账号 relay 与配对 relay 同源);仅切换到 + 其它同账号设备时走 `sendDeviceRpc`。 +4. `store.ts` + - 新增 `controlTarget: { deviceId, deviceName } | null` 与 setter; + - 新增 `resetForDeviceSwitch()`:清空 workspace/assistant/pairedDisplayMode/ + sessions/messages/activeTurn(保留连接与身份状态); + - `resetConnectionState` 一并清 `controlTarget`。 +5. `PairingPage.tsx` + - 配对成功后 best-effort 调 `requestDelegatedIdentity()`(失败不阻断接续); + 成功后异步 `listDevices()` 解析本机设备名写入 `controlTarget`。 + - UI 对齐整体风格:3D cube 换为 `Logo-ICON` 品牌图(含轻呼吸动画), + 右上角补主题切换(与语言切换并列),表单、状态、错误样式全部走既有 token。 +6. `DevicesPage.tsx` 重做 + - 挂载时若无身份先尝试 `requestDelegatedIdentity()` 一次(桌面后登录账号的场景 + 无需重新扫码);仍无身份 → 空状态卡(图标 + `noDelegatedIdentity` + 重试按钮)。 + - 列表卡片对齐 sessions 视觉:设备图标、名称、短 id、状态点 + 文案(去 emoji)、 + 「当前」徽标与「扫码设备」徽标;离线不可点。 + - 点在线设备:卡片 busy → `peer_mode_ping` 探测 → `pairedDeviceId` 切换 → + `resetForDeviceSwitch()` → 写 `controlTarget` → pop 返回会话页(重挂载自动重拉)。 + - 失败:内联错误条;401 → `tokenExpired` 文案。 + - 30s 自动刷新 + header 手动刷新按钮。 +7. `SessionListPage.tsx` + - Devices 按钮补样式(同 theme-btn 圈形),title/aria 用 `devices.title`; + 控制目标非扫码设备时按钮呈 accent 态。 + - header 用户行追加当前控制设备名(非扫码设备时显示),语义与 health dot 一致。 +8. `App.tsx` + - devices 页走 `navigateTo('devices','push'/'pop')`,纳入 nav-page 动画; + `popstate` 增加 devices 分支。 +9. i18n `messages.ts`(en-US / zh-CN / zh-TW 三语) + - `devices.*` 重写:title、noDelegatedIdentity、loading、refresh、noDevices、 + online、offline(文字)、current、pairedDesktop、switchFailed、tokenExpired、 + retry;删除全部遗留未用 key(noSessions/newSession/sendMessage 等)。 +10. 样式 + - 新增 `styles/components/devices.scss` 并挂入 `index.scss`; + - `sessions.scss` 增补 devices-btn 与 header 目标设备名样式; + - `pairing.scss` 重写品牌区(logo 替代 cube)。 + - 只复用既有 CSS 变量,不新增颜色字面量(规避 theme 审计增长)。 + +## 错误处理 + +- 身份请求失败(桌面未登录):接续流程不受影响;Devices 页展示引导文案与重试。 +- 设备切换 ping 失败 / RPC 失败:停留在 Devices 页,内联错误,目标不切换。 +- 401(delegated token 失效):`tokenExpired` 文案提示重新扫码或桌面端重新登录。 +- 切换后目标设备离线:既有 reconnect banner 与 health dot 继续生效(ping 走新目标)。 + +## 验证 + +- `cargo check --workspace`;`cargo test -p bitfun-services-integrations remote_connect_contracts` 聚焦合同测试。 +- `pnpm --dir src/mobile-web run type-check`;`pnpm run build:mobile-web`。 +- `pnpm run i18n:audit`(messages.ts 文案变更)。 +- 手动路径(PR 说明):扫码配对 → 自动继承身份 → 设备页可见同账号设备 → + 切换在线设备 → 会话页数据来自新设备且 header 显示目标 → 系统返回键正常 → + 桌面未登录时设备页引导文案。 diff --git a/src/apps/desktop/src/api/git_api.rs b/src/apps/desktop/src/api/git_api.rs index ac599b5318..90169dacc3 100644 --- a/src/apps/desktop/src/api/git_api.rs +++ b/src/apps/desktop/src/api/git_api.rs @@ -11,7 +11,10 @@ use bitfun_core::service::git::{ use bitfun_core::service::git::{ GitBranch, GitCommit, GitOperationResult, GitRepository, GitStatus, }; -use bitfun_core::service::remote_ssh::{lookup_remote_connection, normalize_remote_workspace_path}; +use bitfun_core::service::remote_ssh::{ + build_remote_git_command as build_remote_git_command_shared, lookup_remote_connection, + normalize_remote_workspace_path, +}; use log::{error, info}; use serde::{Deserialize, Serialize}; use std::time::Instant; @@ -38,26 +41,8 @@ async fn resolve_remote_git_target(repository_path: &str) -> Option String { - if value - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '-' | '_' | ':' | '=' | '@')) - { - value.to_string() - } else { - format!("'{}'", value.replace('\'', "'\\''")) - } -} - fn build_remote_git_command(repository_path: &str, args: &[String]) -> String { - let mut parts = vec![ - "git".to_string(), - "-C".to_string(), - shell_quote(repository_path), - "--no-pager".to_string(), - ]; - parts.extend(args.iter().map(|arg| shell_quote(arg))); - parts.join(" ") + build_remote_git_command_shared(repository_path, args) } async fn execute_remote_git_command( diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 3625688372..e07ae113b0 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -32,6 +32,7 @@ pub mod miniapp_export_api; pub mod path_target; pub mod peer_host_invoke; pub mod remote_connect_api; +pub mod remote_workspace_policy; pub mod review_platform_api; pub mod runtime_api; pub mod search_api; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs new file mode 100644 index 0000000000..40ee1e0e6b --- /dev/null +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -0,0 +1,2013 @@ +//! Remote workspace command policy registry. +//! +//! Every Tauri command registered in `lib.rs` (`tauri::generate_handler!`) +//! must declare how it behaves when the active workspace is a remote SSH +//! workspace (`WorkspaceKind::Remote`). This registry exists because remote +//! SSH workspaces have no central command router: each handler adapts itself +//! (usually through `resolve_desktop_path_target` / `lookup_remote_connection` +//! / `is_remote_path`), so nothing else forces a new command to consider +//! remote workspaces at all. Historically that produced silent local/remote +//! feature gaps (for example the PR reviewer opening to a blank panel in +//! remote workspaces). +//! +//! Rules enforced by the contract tests in this module: +//! +//! - Every registered command has exactly one policy entry, and every entry +//! matches a registered command. +//! - `LegacyUnaudited` is a frozen backlog: entries may leave it after their +//! remote behavior has been audited, but no command may enter it. New +//! commands must ship with an explicit policy. +//! +//! Policy semantics: +//! +//! - `RemoteRouted`: the handler detects remote workspace paths/sessions and +//! executes on the remote host (or is itself part of the remote SSH +//! machinery). This is the target state for workspace-facing features. +//! - `RemoteUnsupported`: the handler explicitly rejects remote workspaces +//! with a clear, user-visible error or gated UI state. Silent fake-success +//! or empty payloads do not qualify. +//! - `LocalOnly`: the command intentionally operates on the local host +//! regardless of workspace (windowing, tray, local browser, devtools, OS +//! automation). +//! - `WorkspaceAgnostic`: behavior does not depend on where the workspace +//! filesystem lives (accounts, i18n, SSH connection management, Remote +//! Connect, announcements, app lifecycle metadata). +//! - `LegacyUnaudited`: pre-existing command whose remote behavior has not +//! been audited yet. Auditing one means reading the handler, fixing or +//! gating remote behavior if needed, and moving it to a real policy. + +/// How a Tauri command behaves for remote SSH workspaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteWorkspacePolicy { + /// Routed to the remote host for remote workspace paths/sessions. + RemoteRouted, + /// Explicitly rejected for remote workspaces with a clear error. + RemoteUnsupported, + /// Intentionally local-host behavior regardless of workspace. + LocalOnly, + /// Independent of workspace filesystem location. + WorkspaceAgnostic, + /// Frozen backlog; must not grow. See module docs. + LegacyUnaudited, +} + +/// Declared remote-workspace policy for every registered Tauri command. +pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = &[ + ("accept_file", RemoteWorkspacePolicy::LegacyUnaudited), + ("accept_operation", RemoteWorkspacePolicy::LegacyUnaudited), + ("accept_session", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "account_auto_sync", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_connect_devices", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_delegate_to_paired", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_delete_device", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_delete_synced_session", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_device_rpc", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_execute_on_device", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_export_all_sessions", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_export_local_session", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_fetch_session_turns", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_fetch_settings", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_fetch_synced_sessions", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_get_credential_hint", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_import_remote_sessions", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_list_devices", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("account_login", RemoteWorkspacePolicy::WorkspaceAgnostic), + ("account_logout", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "account_online_devices", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_send_session_to_device", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("account_status", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "account_sync_session", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_sync_settings", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "account_token_expired", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "activate_session_goal", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("add_skill", RemoteWorkspacePolicy::LegacyUnaudited), + ("analyze_work_state", RemoteWorkspacePolicy::LegacyUnaudited), + ("apply_patch", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "archive_all_sessions", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("archive_session", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "browser_control_create_launcher", + RemoteWorkspacePolicy::LocalOnly, + ), + ( + "browser_control_get_status", + RemoteWorkspacePolicy::LocalOnly, + ), + ("browser_control_launch", RemoteWorkspacePolicy::LocalOnly), + ( + "browser_control_list_browsers", + RemoteWorkspacePolicy::LocalOnly, + ), + ( + "browser_control_restart_with_cdp", + RemoteWorkspacePolicy::LocalOnly, + ), + ("browser_get_url", RemoteWorkspacePolicy::LocalOnly), + ("browser_webview_create", RemoteWorkspacePolicy::LocalOnly), + ("browser_webview_eval", RemoteWorkspacePolicy::LocalOnly), + ("browser_webview_navigate", RemoteWorkspacePolicy::LocalOnly), + ("browser_webview_reload", RemoteWorkspacePolicy::LocalOnly), + ( + "browser_webview_set_bounds", + RemoteWorkspacePolicy::LocalOnly, + ), + ("btw_ask_stream", RemoteWorkspacePolicy::LegacyUnaudited), + ("btw_cancel", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "cancel_acp_dialog_turn", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("cancel_dialog_turn", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "cancel_insights_generation", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "cancel_mcp_remote_oauth", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("cancel_search", RemoteWorkspacePolicy::LegacyUnaudited), + ("cancel_session", RemoteWorkspacePolicy::LegacyUnaudited), + ("cancel_tool", RemoteWorkspacePolicy::LegacyUnaudited), + ("cancel_transfer", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "canonicalize_agent_profile_configs", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "check_command_exists", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "check_commands_exist", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "check_for_updates", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "check_git_isolation", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("check_path_exists", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "cleanup_invalid_workspaces", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("cleanup_storage", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "cleanup_storage_with_policy", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "clear_mcp_remote_auth", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "clear_session_thread_goal", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("close_workspace", RemoteWorkspacePolicy::LegacyUnaudited), + ("compact_session", RemoteWorkspacePolicy::LegacyUnaudited), + ("compress_path", RemoteWorkspacePolicy::LegacyUnaudited), + ("compute_diff", RemoteWorkspacePolicy::LegacyUnaudited), + ("computer_use_get_status", RemoteWorkspacePolicy::LocalOnly), + ( + "computer_use_open_system_settings", + RemoteWorkspacePolicy::LocalOnly, + ), + ( + "computer_use_request_permissions", + RemoteWorkspacePolicy::LocalOnly, + ), + ( + "confirm_tool_execution", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "control_background_command", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "control_deep_review_queue", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "create_acp_flow_session", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "create_assistant_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("create_cron_job", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "create_custom_agent", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("create_directory", RemoteWorkspacePolicy::LegacyUnaudited), + ("create_file", RemoteWorkspacePolicy::LegacyUnaudited), + ("create_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), + ("create_session", RemoteWorkspacePolicy::LegacyUnaudited), + ("create_subagent", RemoteWorkspacePolicy::LegacyUnaudited), + ("debug_close_devtools", RemoteWorkspacePolicy::LocalOnly), + ("debug_devtools_available", RemoteWorkspacePolicy::LocalOnly), + ("debug_element_picked", RemoteWorkspacePolicy::LocalOnly), + ("debug_open_devtools", RemoteWorkspacePolicy::LocalOnly), + ("decompress_path", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "delete_agent_companion_pet_package", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "delete_all_archived_sessions", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "delete_assistant_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("delete_cron_job", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "delete_custom_agent", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("delete_directory", RemoteWorkspacePolicy::LegacyUnaudited), + ("delete_file", RemoteWorkspacePolicy::LegacyUnaudited), + ("delete_mcp_server", RemoteWorkspacePolicy::LegacyUnaudited), + ("delete_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "delete_persisted_session", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("delete_session", RemoteWorkspacePolicy::LegacyUnaudited), + ("delete_skill", RemoteWorkspacePolicy::LegacyUnaudited), + ("delete_subagent", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "discover_cli_credentials", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "dismiss_announcement", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "download_skill_market", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("editor_ai_cancel", RemoteWorkspacePolicy::LegacyUnaudited), + ("editor_ai_stream", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "ensure_assistant_bootstrap", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "ensure_coordinator_session", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("execute_tool", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "explorer_get_children", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "explorer_get_children_paginated", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "explorer_get_file_tree", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("export_config", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "export_diagnostics_bundle", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "export_local_file_to_path", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "export_session_transcript", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "fetch_mcp_app_resource", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("fork_session", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "generate_commit_message", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "generate_greeting_only", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("generate_insights", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "generate_session_title", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_acp_clients", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_acp_session_commands", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_acp_session_options", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_agent_models", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_agent_profile_config", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_agent_profile_configs", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_all_modified_files", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_all_tools_info", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_announcement_tips", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("get_app_state", RemoteWorkspacePolicy::WorkspaceAgnostic), + ("get_app_version", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "get_available_modes", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_available_tools", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_baseline_snapshot_diff", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_clipboard_files", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_config", RemoteWorkspacePolicy::LegacyUnaudited), + ("get_configs", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_current_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_custom_agent_detail", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_default_review_team_definition", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_directory_children", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_directory_children_paginated", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_file_change_history", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_file_diff", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_file_editor_sync_hash", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_file_metadata", RemoteWorkspacePolicy::LegacyUnaudited), + ("get_file_tree", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_global_config_health", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_global_config_status", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_health_status", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "get_latest_insights", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_mcp_prompt", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_mcp_remote_oauth_session", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_mcp_server_status", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_mcp_servers", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_mcp_tool_ui_uri", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_memory_paths", RemoteWorkspacePolicy::LegacyUnaudited), + ("get_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_miniapp_draft_storage", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_miniapp_storage", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_miniapp_versions", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_mode_skill_configs", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_model_configs", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_opened_workspaces", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_operation_diff", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_operation_summary", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_pending_announcements", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "get_project_storage_paths", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_readonly_tools_info", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_recent_workspaces", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_runtime_capabilities", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_runtime_logging_info", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_session_file_diff_stats", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_session_files", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_session_operations", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_session_stats", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_session_thread_goal", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_session_turns", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_session_usage_report", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_skill_configs", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_snapshot_sessions", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_snapshot_system_stats", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_startup_native_trace", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("get_statistics", RemoteWorkspacePolicy::LegacyUnaudited), + ("get_storage_paths", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_storage_statistics", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "get_subagent_detail", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("get_system_info", RemoteWorkspacePolicy::WorkspaceAgnostic), + ("get_tool_info", RemoteWorkspacePolicy::LegacyUnaudited), + ("get_turn_files", RemoteWorkspacePolicy::LegacyUnaudited), + ("get_watched_paths", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "get_work_state_summary", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("git_add_files", RemoteWorkspacePolicy::RemoteRouted), + ("git_add_worktree", RemoteWorkspacePolicy::RemoteUnsupported), + ("git_checkout_branch", RemoteWorkspacePolicy::RemoteRouted), + ("git_cherry_pick", RemoteWorkspacePolicy::RemoteRouted), + ("git_cherry_pick_abort", RemoteWorkspacePolicy::RemoteRouted), + ( + "git_cherry_pick_continue", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("git_commit", RemoteWorkspacePolicy::RemoteRouted), + ("git_create_branch", RemoteWorkspacePolicy::RemoteRouted), + ("git_delete_branch", RemoteWorkspacePolicy::RemoteRouted), + ("git_get_branches", RemoteWorkspacePolicy::RemoteRouted), + ("git_get_changed_files", RemoteWorkspacePolicy::RemoteRouted), + ("git_get_commits", RemoteWorkspacePolicy::RemoteRouted), + ("git_get_diff", RemoteWorkspacePolicy::RemoteRouted), + ( + "git_get_enhanced_branches", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("git_get_file_content", RemoteWorkspacePolicy::RemoteRouted), + ("git_get_graph", RemoteWorkspacePolicy::RemoteUnsupported), + ("git_get_repository", RemoteWorkspacePolicy::RemoteRouted), + ( + "git_get_repository_basic", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("git_get_status", RemoteWorkspacePolicy::RemoteRouted), + ("git_is_repository", RemoteWorkspacePolicy::RemoteRouted), + ( + "git_list_worktrees", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ("git_pull", RemoteWorkspacePolicy::RemoteRouted), + ("git_push", RemoteWorkspacePolicy::RemoteRouted), + ( + "git_remove_worktree", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ("git_reset_files", RemoteWorkspacePolicy::RemoteRouted), + ("git_reset_to_commit", RemoteWorkspacePolicy::RemoteRouted), + ("git_resolve_revision", RemoteWorkspacePolicy::RemoteRouted), + ("grant_miniapp_path", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "grant_miniapp_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("has_insights_data", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "hide_agent_companion_desktop_pet", + RemoteWorkspacePolicy::LocalOnly, + ), + ( + "hide_main_window_after_close_request", + RemoteWorkspacePolicy::LocalOnly, + ), + ("i18n_get_config", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "i18n_get_current_language", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "i18n_get_supported_languages", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("i18n_set_config", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "i18n_set_language", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "import_agent_companion_pet_package", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("import_config", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "initialize_acp_clients", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("initialize_ai", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "initialize_mcp_servers", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "initialize_mcp_servers_non_destructive", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "initialize_project_storage", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "initialize_snapshot", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "initialize_tray_after_startup", + RemoteWorkspacePolicy::LocalOnly, + ), + ( + "initialize_workspace_startup_state", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "install_acp_client_cli", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("install_update", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "list_agent_companion_pets", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "list_agent_tool_names", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "list_ai_models_by_config", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "list_archived_sessions", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "list_background_command_activities", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("list_cron_jobs", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "list_directory_files", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "list_manageable_subagents", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("list_mcp_prompts", RemoteWorkspacePolicy::LegacyUnaudited), + ("list_mcp_resources", RemoteWorkspacePolicy::LegacyUnaudited), + ("list_miniapps", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "list_persisted_sessions", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "list_persisted_sessions_page", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("list_sessions", RemoteWorkspacePolicy::LegacyUnaudited), + ("list_skill_market", RemoteWorkspacePolicy::LegacyUnaudited), + ("list_subagents", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "list_visible_subagents", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "load_acp_json_config", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "load_canvas_artifact", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("load_canvas_state", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "load_git_repo_history", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "load_insights_report", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "load_mcp_json_config", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "load_persisted_session_metadata", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("load_session_turns", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "lsp_change_document", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("lsp_close_document", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "lsp_close_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("lsp_detect_project", RemoteWorkspacePolicy::LegacyUnaudited), + ("lsp_did_change", RemoteWorkspacePolicy::LegacyUnaudited), + ("lsp_did_close", RemoteWorkspacePolicy::LegacyUnaudited), + ("lsp_did_open", RemoteWorkspacePolicy::LegacyUnaudited), + ("lsp_did_save", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "lsp_find_references", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_find_references_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_format_document", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_format_document_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_all_server_states", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_code_actions_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_completions", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_completions_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_document_highlight_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_document_symbols_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("lsp_get_hover", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "lsp_get_hover_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_inlay_hints_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("lsp_get_plugin", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "lsp_get_semantic_tokens_range_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_semantic_tokens_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_server_capabilities", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_server_state", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_get_supported_extensions", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_goto_definition", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_goto_definition_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("lsp_initialize", RemoteWorkspacePolicy::LegacyUnaudited), + ("lsp_install_plugin", RemoteWorkspacePolicy::LegacyUnaudited), + ("lsp_list_plugins", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "lsp_list_workspaces", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("lsp_open_document", RemoteWorkspacePolicy::LegacyUnaudited), + ("lsp_open_workspace", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "lsp_prestart_server", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_rename_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("lsp_save_document", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "lsp_start_server_for_file", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_stop_all_servers", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("lsp_stop_server", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "lsp_stop_server_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "lsp_uninstall_plugin", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "mark_announcement_seen", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "miniapp_agent_cancel", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_agent_cancel_stale_runs", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("miniapp_agent_run", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "miniapp_agent_turn_text", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("miniapp_ai_cancel", RemoteWorkspacePolicy::LegacyUnaudited), + ("miniapp_ai_chat", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "miniapp_ai_complete", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_ai_list_models", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_apply_draft", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_create_draft", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_decline_builtin_update", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_dialog_message", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_discard_draft", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_draft_host_call", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_draft_worker_call", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_draft_worker_stop", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_get_customization_metadata", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("miniapp_get_draft", RemoteWorkspacePolicy::LegacyUnaudited), + ("miniapp_host_call", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "miniapp_import_from_path", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_install_deps", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_permission_diff_for_draft", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("miniapp_recompile", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "miniapp_render_slide_page", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_runtime_status", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_set_draft_permissions", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_sync_draft_from_fs", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_sync_from_fs", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_worker_call", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_worker_list_running", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "miniapp_worker_stop", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("minimize_to_tray", RemoteWorkspacePolicy::LocalOnly), + ( + "never_show_announcement", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "notify_cron_host_ready", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "open_html_file_in_browser", + RemoteWorkspacePolicy::LocalOnly, + ), + ("open_remote_workspace", RemoteWorkspacePolicy::RemoteRouted), + ("open_workspace", RemoteWorkspacePolicy::LegacyUnaudited), + ("paste_files", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "peer_control_attach", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "peer_control_detach", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "peer_controller_set_active", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "peer_host_invoke_complete", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("peer_mode_ping", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "predownload_acp_client_adapter", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "preview_commit_message", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "probe_acp_client_requirements", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "quick_analyze_work_state", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "quick_commit_message", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("quit_app", RemoteWorkspacePolicy::LocalOnly), + ( + "read_background_command_output", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("read_file_content", RemoteWorkspacePolicy::LegacyUnaudited), + ("read_mcp_resource", RemoteWorkspacePolicy::LegacyUnaudited), + ("record_file_change", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "refresh_cli_credential", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "refresh_model_client", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("reject_file", RemoteWorkspacePolicy::LegacyUnaudited), + ("reject_operation", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "reject_tool_execution", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("reload_config", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "reload_custom_agents", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "reload_global_config", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("reload_subagents", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "remote_close_workspace", + RemoteWorkspacePolicy::RemoteRouted, + ), + ( + "remote_connect_configure_bot", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_configure_custom_server", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_get_bot_verbose_mode", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_get_device_info", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_get_form_state", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_get_lan_ip", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_get_lan_network_info", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_get_methods", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_set_bot_verbose_mode", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_set_form_state", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_start", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_status", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_stop", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_stop_bot", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_weixin_qr_poll", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remote_connect_weixin_qr_start", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("remote_create_dir", RemoteWorkspacePolicy::RemoteRouted), + ( + "remote_download_to_local_path", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("remote_execute", RemoteWorkspacePolicy::RemoteRouted), + ("remote_exists", RemoteWorkspacePolicy::RemoteRouted), + ("remote_get_tree", RemoteWorkspacePolicy::RemoteRouted), + ( + "remote_get_workspace_info", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("remote_open_workspace", RemoteWorkspacePolicy::RemoteRouted), + ("remote_read_dir", RemoteWorkspacePolicy::RemoteRouted), + ("remote_read_file", RemoteWorkspacePolicy::RemoteRouted), + ("remote_remove", RemoteWorkspacePolicy::RemoteRouted), + ( + "remote_remove_workspace", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("remote_rename", RemoteWorkspacePolicy::RemoteRouted), + ( + "remote_upload_from_local_path", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("remote_write_file", RemoteWorkspacePolicy::RemoteRouted), + ( + "remove_recent_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("rename_file", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "reorder_opened_workspaces", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "replace_mode_skill_selection", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "report_canvas_runtime_error", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "report_ide_control_result", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "reset_agent_profile_config", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "reset_assistant_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("reset_config", RemoteWorkspacePolicy::LegacyUnaudited), + ("reset_memory", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "reset_mode_skill_selection", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "reset_workspace_persona_files", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "resize_agent_companion_desktop_pet", + RemoteWorkspacePolicy::LocalOnly, + ), + ("restart_app", RemoteWorkspacePolicy::LocalOnly), + ("restart_mcp_server", RemoteWorkspacePolicy::LegacyUnaudited), + ("restore_session", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "restore_session_view", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "restore_session_with_turns", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("reveal_in_explorer", RemoteWorkspacePolicy::LocalOnly), + ( + "review_platform_clear_auth_token", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "review_platform_get_issue", + RemoteWorkspacePolicy::RemoteRouted, + ), + ( + "review_platform_get_pull_request_ci_log", + RemoteWorkspacePolicy::RemoteRouted, + ), + ( + "review_platform_get_pull_request_detail", + RemoteWorkspacePolicy::RemoteRouted, + ), + ( + "review_platform_get_pull_request_detail_page", + RemoteWorkspacePolicy::RemoteRouted, + ), + ( + "review_platform_get_pull_request_review_target", + RemoteWorkspacePolicy::RemoteRouted, + ), + ( + "review_platform_get_pull_request_review_target_by_identity", + RemoteWorkspacePolicy::RemoteRouted, + ), + ( + "review_platform_get_workspace_context", + RemoteWorkspacePolicy::RemoteRouted, + ), + ( + "review_platform_get_workspace_snapshot", + RemoteWorkspacePolicy::RemoteRouted, + ), + ( + "review_platform_update_auth_token", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("rollback_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), + ("rollback_session", RemoteWorkspacePolicy::LegacyUnaudited), + ("rollback_to_turn", RemoteWorkspacePolicy::LegacyUnaudited), + ("run_init_agents_md", RemoteWorkspacePolicy::LegacyUnaudited), + ("run_system_command", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "save_acp_json_config", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("save_canvas_state", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "save_git_repo_history", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "save_mcp_json_config", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "save_merged_diff_content", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "save_session_metadata", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("save_session_turn", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "scan_workspace_info", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("search_build_index", RemoteWorkspacePolicy::RemoteRouted), + ( + "search_file_contents", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("search_filenames", RemoteWorkspacePolicy::LegacyUnaudited), + ("search_files", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "search_get_repo_status", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("search_rebuild_index", RemoteWorkspacePolicy::RemoteRouted), + ( + "search_skill_market", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "send_background_command_input", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "send_mcp_app_message", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("send_system_notification", RemoteWorkspacePolicy::LocalOnly), + ( + "set_acp_session_model", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "set_active_workspace", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("set_agent_model", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "set_agent_profile_config", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("set_config", RemoteWorkspacePolicy::LegacyUnaudited), + ("set_macos_edit_menu_mode", RemoteWorkspacePolicy::LocalOnly), + ( + "set_miniapp_draft_storage", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "set_miniapp_storage", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "set_mode_skill_disabled", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "set_session_memory_mode", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "set_session_thread_goal_status", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "set_subagent_timeout", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "show_agent_companion_desktop_pet", + RemoteWorkspacePolicy::LocalOnly, + ), + ("show_main_window", RemoteWorkspacePolicy::LocalOnly), + ("ssh_connect", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "ssh_delete_connection", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("ssh_disconnect", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "ssh_disconnect_all", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("ssh_get_config", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "ssh_get_server_info", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "ssh_has_stored_password", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("ssh_is_connected", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "ssh_list_config_hosts", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "ssh_list_saved_connections", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "ssh_save_connection", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "start_acp_dialog_turn", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("start_dialog_turn", RemoteWorkspacePolicy::LegacyUnaudited), + ("start_file_watch", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "start_mcp_remote_oauth", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("start_mcp_server", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "start_search_file_contents_stream", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "start_search_filenames_stream", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("startup_window_control", RemoteWorkspacePolicy::LocalOnly), + ("steer_dialog_turn", RemoteWorkspacePolicy::LegacyUnaudited), + ("stop_acp_client", RemoteWorkspacePolicy::LegacyUnaudited), + ("stop_file_watch", RemoteWorkspacePolicy::LegacyUnaudited), + ("stop_mcp_server", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "submit_acp_permission_response", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "submit_mcp_interaction_response", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "submit_user_answers", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "subscribe_config_updates", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "sync_config_to_global", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("terminal_ack", RemoteWorkspacePolicy::RemoteRouted), + ("terminal_close", RemoteWorkspacePolicy::RemoteRouted), + ("terminal_create", RemoteWorkspacePolicy::RemoteRouted), + ("terminal_execute", RemoteWorkspacePolicy::RemoteRouted), + ("terminal_get", RemoteWorkspacePolicy::LegacyUnaudited), + ("terminal_get_history", RemoteWorkspacePolicy::RemoteRouted), + ( + "terminal_get_shells", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "terminal_has_shell_integration", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("terminal_list", RemoteWorkspacePolicy::LegacyUnaudited), + ("terminal_resize", RemoteWorkspacePolicy::RemoteRouted), + ("terminal_send_command", RemoteWorkspacePolicy::RemoteRouted), + ( + "terminal_shutdown_all", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("terminal_signal", RemoteWorkspacePolicy::RemoteRouted), + ("terminal_write", RemoteWorkspacePolicy::RemoteRouted), + ( + "test_ai_config_connection", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("test_ai_connection", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "toggle_main_window_fullscreen", + RemoteWorkspacePolicy::LocalOnly, + ), + ( + "touch_session_activity", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "trigger_announcement", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("unarchive_session", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "update_app_status", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("update_cron_job", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "update_custom_agent", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "update_mcp_remote_auth", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("update_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "update_session_model", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "update_session_thread_goal_objective", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "update_session_title", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("update_subagent", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "update_subagent_config", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "update_workspace_info", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "upload_image_contexts", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("validate_config", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "validate_skill_path", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "validate_tool_input", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ( + "webdriver_bridge_result", + RemoteWorkspacePolicy::LegacyUnaudited, + ), + ("write_file_content", RemoteWorkspacePolicy::LegacyUnaudited), +]; + +pub fn remote_workspace_policy(command: &str) -> Option { + REMOTE_WORKSPACE_COMMAND_POLICIES + .iter() + .find(|(name, _)| *name == command) + .map(|(_, policy)| *policy) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + /// Extracts the command names registered in `tauri::generate_handler!`. + fn registered_commands() -> BTreeSet { + let source = include_str!("../lib.rs"); + let start = source + .find("generate_handler![") + .expect("lib.rs must register commands via tauri::generate_handler!") + + "generate_handler![".len(); + let block = &source[start..]; + let end = block + .find("])") + .expect("generate_handler! block must terminate with `])`"); + block[..end] + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with("//")) + .map(|line| { + let entry = line.trim_end_matches(','); + entry + .rsplit("::") + .next() + .expect("command path segments are non-empty") + .to_string() + }) + .collect() + } + + #[test] + fn every_registered_command_declares_a_remote_workspace_policy() { + let registered = registered_commands(); + assert!( + registered.len() > 400, + "generate_handler! parsing looks broken; only {} commands found", + registered.len() + ); + + let declared: BTreeSet = REMOTE_WORKSPACE_COMMAND_POLICIES + .iter() + .map(|(name, _)| (*name).to_string()) + .collect(); + assert_eq!( + declared.len(), + REMOTE_WORKSPACE_COMMAND_POLICIES.len(), + "remote workspace policy registry contains duplicate command entries" + ); + + let missing: Vec<_> = registered.difference(&declared).cloned().collect(); + assert!( + missing.is_empty(), + "commands registered in generate_handler! without a remote workspace policy \ + (declare one in REMOTE_WORKSPACE_COMMAND_POLICIES; new commands must not use \ + LegacyUnaudited): {missing:?}" + ); + + let stale: Vec<_> = declared.difference(®istered).cloned().collect(); + assert!( + stale.is_empty(), + "remote workspace policies declared for commands that are no longer registered: {stale:?}" + ); + } + + /// `LegacyUnaudited` is a frozen backlog: commands may graduate out of it + /// once their remote workspace behavior is audited, but no command may be + /// added to it. Do not append to this list; give new commands a real + /// policy instead. + #[test] + fn legacy_unaudited_backlog_must_not_grow() { + let unaudited: BTreeSet<&str> = REMOTE_WORKSPACE_COMMAND_POLICIES + .iter() + .filter(|(_, policy)| *policy == RemoteWorkspacePolicy::LegacyUnaudited) + .map(|(name, _)| *name) + .collect(); + let frozen: BTreeSet<&str> = LEGACY_UNAUDITED_BASELINE.iter().copied().collect(); + + let added: Vec<_> = unaudited.difference(&frozen).collect(); + assert!( + added.is_empty(), + "new commands must declare an explicit remote workspace policy instead of \ + LegacyUnaudited: {added:?}" + ); + } + + /// Frozen at introduction time. Only removals are allowed. + const LEGACY_UNAUDITED_BASELINE: &[&str] = &[ + "accept_file", + "accept_operation", + "accept_session", + "activate_session_goal", + "add_skill", + "analyze_work_state", + "apply_patch", + "archive_all_sessions", + "archive_session", + "btw_ask_stream", + "btw_cancel", + "cancel_acp_dialog_turn", + "cancel_dialog_turn", + "cancel_insights_generation", + "cancel_mcp_remote_oauth", + "cancel_search", + "cancel_session", + "cancel_tool", + "cancel_transfer", + "canonicalize_agent_profile_configs", + "check_command_exists", + "check_commands_exist", + "check_git_isolation", + "check_path_exists", + "cleanup_invalid_workspaces", + "cleanup_storage", + "cleanup_storage_with_policy", + "clear_mcp_remote_auth", + "clear_session_thread_goal", + "close_workspace", + "compact_session", + "compress_path", + "compute_diff", + "confirm_tool_execution", + "control_background_command", + "control_deep_review_queue", + "create_acp_flow_session", + "create_assistant_workspace", + "create_cron_job", + "create_custom_agent", + "create_directory", + "create_file", + "create_miniapp", + "create_session", + "create_subagent", + "decompress_path", + "delete_agent_companion_pet_package", + "delete_all_archived_sessions", + "delete_assistant_workspace", + "delete_cron_job", + "delete_custom_agent", + "delete_directory", + "delete_file", + "delete_mcp_server", + "delete_miniapp", + "delete_persisted_session", + "delete_session", + "delete_skill", + "delete_subagent", + "discover_cli_credentials", + "download_skill_market", + "editor_ai_cancel", + "editor_ai_stream", + "ensure_assistant_bootstrap", + "ensure_coordinator_session", + "execute_tool", + "explorer_get_children", + "explorer_get_children_paginated", + "explorer_get_file_tree", + "export_config", + "export_diagnostics_bundle", + "export_local_file_to_path", + "export_session_transcript", + "fetch_mcp_app_resource", + "fork_session", + "generate_commit_message", + "generate_greeting_only", + "generate_insights", + "generate_session_title", + "get_acp_clients", + "get_acp_session_commands", + "get_acp_session_options", + "get_agent_models", + "get_agent_profile_config", + "get_agent_profile_configs", + "get_all_modified_files", + "get_all_tools_info", + "get_available_modes", + "get_available_tools", + "get_baseline_snapshot_diff", + "get_clipboard_files", + "get_config", + "get_configs", + "get_current_workspace", + "get_custom_agent_detail", + "get_default_review_team_definition", + "get_directory_children", + "get_directory_children_paginated", + "get_file_change_history", + "get_file_diff", + "get_file_editor_sync_hash", + "get_file_metadata", + "get_file_tree", + "get_global_config_health", + "get_global_config_status", + "get_latest_insights", + "get_mcp_prompt", + "get_mcp_remote_oauth_session", + "get_mcp_server_status", + "get_mcp_servers", + "get_mcp_tool_ui_uri", + "get_memory_paths", + "get_miniapp", + "get_miniapp_draft_storage", + "get_miniapp_storage", + "get_miniapp_versions", + "get_mode_skill_configs", + "get_model_configs", + "get_opened_workspaces", + "get_operation_diff", + "get_operation_summary", + "get_project_storage_paths", + "get_readonly_tools_info", + "get_recent_workspaces", + "get_runtime_capabilities", + "get_runtime_logging_info", + "get_session_file_diff_stats", + "get_session_files", + "get_session_operations", + "get_session_stats", + "get_session_thread_goal", + "get_session_turns", + "get_session_usage_report", + "get_skill_configs", + "get_snapshot_sessions", + "get_snapshot_system_stats", + "get_statistics", + "get_storage_paths", + "get_storage_statistics", + "get_subagent_detail", + "get_tool_info", + "get_turn_files", + "get_watched_paths", + "get_work_state_summary", + "grant_miniapp_path", + "grant_miniapp_workspace", + "has_insights_data", + "import_agent_companion_pet_package", + "import_config", + "initialize_acp_clients", + "initialize_ai", + "initialize_mcp_servers", + "initialize_mcp_servers_non_destructive", + "initialize_project_storage", + "initialize_snapshot", + "initialize_workspace_startup_state", + "install_acp_client_cli", + "list_agent_companion_pets", + "list_agent_tool_names", + "list_ai_models_by_config", + "list_archived_sessions", + "list_background_command_activities", + "list_cron_jobs", + "list_directory_files", + "list_manageable_subagents", + "list_mcp_prompts", + "list_mcp_resources", + "list_miniapps", + "list_persisted_sessions", + "list_persisted_sessions_page", + "list_sessions", + "list_skill_market", + "list_subagents", + "list_visible_subagents", + "load_acp_json_config", + "load_canvas_artifact", + "load_canvas_state", + "load_git_repo_history", + "load_insights_report", + "load_mcp_json_config", + "load_persisted_session_metadata", + "load_session_turns", + "lsp_change_document", + "lsp_close_document", + "lsp_close_workspace", + "lsp_detect_project", + "lsp_did_change", + "lsp_did_close", + "lsp_did_open", + "lsp_did_save", + "lsp_find_references", + "lsp_find_references_workspace", + "lsp_format_document", + "lsp_format_document_workspace", + "lsp_get_all_server_states", + "lsp_get_code_actions_workspace", + "lsp_get_completions", + "lsp_get_completions_workspace", + "lsp_get_document_highlight_workspace", + "lsp_get_document_symbols_workspace", + "lsp_get_hover", + "lsp_get_hover_workspace", + "lsp_get_inlay_hints_workspace", + "lsp_get_plugin", + "lsp_get_semantic_tokens_range_workspace", + "lsp_get_semantic_tokens_workspace", + "lsp_get_server_capabilities", + "lsp_get_server_state", + "lsp_get_supported_extensions", + "lsp_goto_definition", + "lsp_goto_definition_workspace", + "lsp_initialize", + "lsp_install_plugin", + "lsp_list_plugins", + "lsp_list_workspaces", + "lsp_open_document", + "lsp_open_workspace", + "lsp_prestart_server", + "lsp_rename_workspace", + "lsp_save_document", + "lsp_start_server_for_file", + "lsp_stop_all_servers", + "lsp_stop_server", + "lsp_stop_server_workspace", + "lsp_uninstall_plugin", + "miniapp_agent_cancel", + "miniapp_agent_cancel_stale_runs", + "miniapp_agent_run", + "miniapp_agent_turn_text", + "miniapp_ai_cancel", + "miniapp_ai_chat", + "miniapp_ai_complete", + "miniapp_ai_list_models", + "miniapp_apply_draft", + "miniapp_create_draft", + "miniapp_decline_builtin_update", + "miniapp_dialog_message", + "miniapp_discard_draft", + "miniapp_draft_host_call", + "miniapp_draft_worker_call", + "miniapp_draft_worker_stop", + "miniapp_get_customization_metadata", + "miniapp_get_draft", + "miniapp_host_call", + "miniapp_import_from_path", + "miniapp_install_deps", + "miniapp_permission_diff_for_draft", + "miniapp_recompile", + "miniapp_render_slide_page", + "miniapp_runtime_status", + "miniapp_set_draft_permissions", + "miniapp_sync_draft_from_fs", + "miniapp_sync_from_fs", + "miniapp_worker_call", + "miniapp_worker_list_running", + "miniapp_worker_stop", + "notify_cron_host_ready", + "open_workspace", + "paste_files", + "predownload_acp_client_adapter", + "preview_commit_message", + "probe_acp_client_requirements", + "quick_analyze_work_state", + "quick_commit_message", + "read_background_command_output", + "read_file_content", + "read_mcp_resource", + "record_file_change", + "refresh_cli_credential", + "refresh_model_client", + "reject_file", + "reject_operation", + "reject_tool_execution", + "reload_config", + "reload_custom_agents", + "reload_global_config", + "reload_subagents", + "remove_recent_workspace", + "rename_file", + "reorder_opened_workspaces", + "replace_mode_skill_selection", + "report_canvas_runtime_error", + "report_ide_control_result", + "reset_agent_profile_config", + "reset_assistant_workspace", + "reset_config", + "reset_memory", + "reset_mode_skill_selection", + "reset_workspace_persona_files", + "restart_mcp_server", + "restore_session", + "restore_session_view", + "restore_session_with_turns", + "rollback_miniapp", + "rollback_session", + "rollback_to_turn", + "run_init_agents_md", + "run_system_command", + "save_acp_json_config", + "save_canvas_state", + "save_git_repo_history", + "save_mcp_json_config", + "save_merged_diff_content", + "save_session_metadata", + "save_session_turn", + "scan_workspace_info", + "search_file_contents", + "search_filenames", + "search_files", + "search_skill_market", + "send_background_command_input", + "send_mcp_app_message", + "set_acp_session_model", + "set_active_workspace", + "set_agent_model", + "set_agent_profile_config", + "set_config", + "set_miniapp_draft_storage", + "set_miniapp_storage", + "set_mode_skill_disabled", + "set_session_memory_mode", + "set_session_thread_goal_status", + "set_subagent_timeout", + "start_acp_dialog_turn", + "start_dialog_turn", + "start_file_watch", + "start_mcp_remote_oauth", + "start_mcp_server", + "start_search_file_contents_stream", + "start_search_filenames_stream", + "steer_dialog_turn", + "stop_acp_client", + "stop_file_watch", + "stop_mcp_server", + "submit_acp_permission_response", + "submit_mcp_interaction_response", + "submit_user_answers", + "subscribe_config_updates", + "sync_config_to_global", + "terminal_get", + "terminal_get_shells", + "terminal_list", + "terminal_shutdown_all", + "test_ai_config_connection", + "test_ai_connection", + "touch_session_activity", + "unarchive_session", + "update_cron_job", + "update_custom_agent", + "update_mcp_remote_auth", + "update_miniapp", + "update_session_model", + "update_session_thread_goal_objective", + "update_session_title", + "update_subagent", + "update_subagent_config", + "update_workspace_info", + "upload_image_contexts", + "validate_config", + "validate_skill_path", + "validate_tool_input", + "webdriver_bridge_result", + "write_file_content", + ]; +} diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index 042f320e07..bc2fbb0cf6 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -782,7 +782,7 @@ mod tests { .allowed_tool_names .contains(&"ReviewPlatform".to_string())); assert!(manifest - .collapsed_tool_names + .deferred_tool_names .contains(&"ReviewPlatform".to_string())); assert!(manifest .tool_definitions diff --git a/src/crates/assembly/core/src/service/remote_connect/mod.rs b/src/crates/assembly/core/src/service/remote_connect/mod.rs index bf9893dcaf..be709ff022 100644 --- a/src/crates/assembly/core/src/service/remote_connect/mod.rs +++ b/src/crates/assembly/core/src/service/remote_connect/mod.rs @@ -165,25 +165,19 @@ pub struct RemoteConnectService { online_devices: Arc>>, /// Callback that provides a delegated identity (token + master_key) /// for paired mobile/IM clients. Set by the desktop layer after account - /// login. Called automatically after pairing succeeds. - delegated_identity_fn: Arc< - RwLock< - Option< - Arc< - dyn Fn() -> std::pin::Pin< - Box< - dyn std::future::Future> - + Send - + Sync, - >, - > + Send - + Sync, - >, - >, - >, - >, + /// login. Resolved on demand when a paired client sends + /// `get_delegated_identity` over the room channel. + delegated_identity_fn: Arc>>, } +/// Provider returning `(token, master_key, relay_url)` for the paired client. +type DelegatedIdentityFn = Arc< + dyn Fn() -> std::pin::Pin< + Box> + Send + Sync>, + > + Send + + Sync, +>; + impl RemoteConnectService { pub fn new(config: RemoteConnectConfig) -> Result { let device_identity = DeviceIdentity::from_current_machine()?; @@ -274,6 +268,40 @@ impl RemoteConnectService { *trusted_mobile_identity.write().await = Some(identity); } + /// Answer a paired client's `get_delegated_identity` request using the + /// provider registered by the desktop layer after account login. + async fn resolve_delegated_identity_response( + delegated_identity_fn: &Arc>>, + trusted_mobile_identity: &Arc>>, + local_device_id: &str, + ) -> remote_server::RemoteResponse { + let provider = delegated_identity_fn.read().await.clone(); + let Some(get_identity) = provider else { + return remote_server::RemoteResponse::Error { + message: "Desktop is not logged into a BitFun account".to_string(), + }; + }; + let Some((token, master_key, _relay_url)) = get_identity().await else { + return remote_server::RemoteResponse::Error { + message: "Desktop is not logged into a BitFun account".to_string(), + }; + }; + let user_id = trusted_mobile_identity + .read() + .await + .as_ref() + .map(|identity| identity.user_id.clone()) + .unwrap_or_default(); + use base64::{engine::general_purpose::STANDARD as B64, Engine}; + info!("Delegated identity resolved for paired client"); + remote_server::RemoteResponse::DelegateIdentity { + token, + user_id, + master_key: B64.encode(master_key), + device_id: local_device_id.to_string(), + } + } + async fn send_pairing_error_response( relay_arc: &Arc>>, correlation_id: &str, @@ -567,7 +595,19 @@ impl RemoteConnectService { Ok((cmd, request_id)) => { handled_as_active_command = true; debug!("Remote command: {cmd:?}"); - let response = server.dispatch(&cmd).await; + let response = if matches!( + cmd, + remote_server::RemoteCommand::GetDelegatedIdentity + ) { + RemoteConnectService::resolve_delegated_identity_response( + &delegated_identity_fn_arc, + &trusted_mobile_identity_arc, + &local_device_id, + ) + .await + } else { + server.dispatch(&cmd).await + }; match server .encrypt_response(&response, request_id.as_deref()) { @@ -669,51 +709,12 @@ impl RemoteConnectService { *server_arc.write().await = Some(server); - // After pairing succeeds, if the desktop - // is logged into an account, delegate the - // account identity to the paired client - // (token + master_key via room channel). - let delegate_fn = - delegated_identity_fn_arc.read().await.clone(); - if let Some(get_identity) = delegate_fn { - if let Some((token, master_key, _relay_url)) = - get_identity().await - { - use base64::{ - engine::general_purpose::STANDARD as B64, - Engine, - }; - let identity_json = serde_json::json!({ - "resp": "delegate_identity", - "token": token, - "user_id": submitted_identity.user_id.clone(), - "master_key": B64.encode(&master_key), - "device_id": local_device_id.clone(), - }); - let identity_str = - serde_json::to_string(&identity_json) - .unwrap_or_default(); - if let Ok((denc, dnonce)) = - encryption::encrypt_to_base64( - &shared_secret, - &identity_str, - ) - { - if let Some(ref client) = - *relay_arc.read().await - { - let _ = client - .send_relay_response( - &correlation_id, - &denc, - &dnonce, - ) - .await; - info!("Delegated identity sent to paired client"); - } - } - } - } + // The delegated account identity is NOT + // pushed here: the relay pending request + // for this correlation_id is consumed by + // the initial_sync response, so a second + // frame would be dropped. Paired clients + // pull it via `get_delegated_identity`. } } Ok(false) => { diff --git a/src/crates/assembly/core/src/service/remote_ssh/mod.rs b/src/crates/assembly/core/src/service/remote_ssh/mod.rs index 5bf1989fc6..a0b04850c5 100644 --- a/src/crates/assembly/core/src/service/remote_ssh/mod.rs +++ b/src/crates/assembly/core/src/service/remote_ssh/mod.rs @@ -13,6 +13,7 @@ pub mod remote_terminal; pub mod types; pub mod workspace_state; +pub use bitfun_services_integrations::remote_ssh::{build_remote_git_command, shell_quote_posix}; #[cfg(not(feature = "ssh-remote"))] pub use bitfun_services_integrations::remote_ssh::{ get_global_remote_exec_process_manager, KnownHostEntry, PTYSession, PortForward, diff --git a/src/crates/assembly/core/src/service/review_platform/mod.rs b/src/crates/assembly/core/src/service/review_platform/mod.rs index 571c277368..7ad3327a52 100644 --- a/src/crates/assembly/core/src/service/review_platform/mod.rs +++ b/src/crates/assembly/core/src/service/review_platform/mod.rs @@ -38,6 +38,59 @@ impl ReviewPlatformWorkspaceClassifier for CoreReviewPlatformWorkspaceClassifier async fn is_remote_workspace_path(&self, path: &str) -> bool { crate::service::remote_ssh::workspace_state::is_remote_path(path).await } + + async fn execute_remote_git_command( + &self, + workspace_path: &str, + current_dir: &str, + args: &[&str], + ) -> Result { + use crate::service::remote_ssh::workspace_state::{ + get_remote_workspace_manager, lookup_remote_connection, + }; + use bitfun_services_integrations::remote_ssh::{ + build_remote_git_command, normalize_remote_workspace_path, + }; + + let entry = lookup_remote_connection(workspace_path) + .await + .ok_or_else(|| { + ReviewPlatformError::InvalidRepository(format!( + "No SSH connection is registered for remote workspace {workspace_path}" + )) + })?; + let manager = match get_remote_workspace_manager() { + Some(state) => state.get_ssh_manager().await, + None => None, + } + .ok_or_else(|| { + ReviewPlatformError::InvalidRepository( + "SSH connection manager is not initialized for remote workspaces".to_string(), + ) + })?; + + let command = build_remote_git_command(&normalize_remote_workspace_path(current_dir), args); + let (stdout, stderr, exit_code) = manager + .execute_command(&entry.connection_id, &command) + .await + .map_err(|error| { + ReviewPlatformError::InvalidRepository(format!( + "Failed to execute git command on remote workspace: {error}" + )) + })?; + + if exit_code == 0 { + return Ok(stdout); + } + let message = if stderr.trim().is_empty() { + stdout + } else { + stderr + }; + Err(ReviewPlatformError::InvalidRepository( + message.trim().to_string(), + )) + } } fn owner_service() -> Result { @@ -284,3 +337,28 @@ impl ReviewPlatformService { owner_service()?.clear_auth_token(platform, host).await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn remote_git_execution_fails_loudly_without_registered_connection() { + let classifier = CoreReviewPlatformWorkspaceClassifier; + + let error = classifier + .execute_remote_git_command( + "/bitfun-tests/unregistered-remote-workspace", + "/bitfun-tests/unregistered-remote-workspace", + &["remote", "-v"], + ) + .await + .expect_err("unregistered remote workspaces must not silently succeed"); + + let message = error.to_string(); + assert!( + message.contains("No SSH connection is registered"), + "unexpected error message: {message}" + ); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 764e53f8b0..24a38f7b59 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -2116,6 +2116,11 @@ pub enum RemoteCommand { path: String, session_id: Option, }, + /// Ask the paired desktop to delegate its logged-in account identity + /// (token + master_key) to this room-channel client so it can call the + /// relay device APIs directly. Answered by the host runtime; other hosts + /// return an error response. + GetDelegatedIdentity, Ping, // ── Device-to-device distributed control ────────────────────────────── @@ -2318,6 +2323,14 @@ pub enum RemoteResponse { }, /// Event already delivered out-of-band; ack only. DeviceEventAccepted, + /// Delegated account identity for a paired room-channel client. + /// `master_key` is base64-encoded; `device_id` is the delegating host. + DelegateIdentity { + token: String, + user_id: String, + master_key: String, + device_id: String, + }, Error { message: String, }, @@ -2439,6 +2452,13 @@ where .await, ), + // Answered by the host runtime (which owns the delegated identity + // provider) before dispatch reaches this router; this is the fallback + // for hosts that cannot delegate an account identity. + RemoteCommand::GetDelegatedIdentity => RemoteResponse::Error { + message: "Delegated identity is not available on this host".to_string(), + }, + RemoteCommand::SendSessionToDevice { .. } | RemoteCommand::ExecuteOnDevice { .. } | RemoteCommand::DeviceQueryInfo diff --git a/src/crates/services/services-integrations/src/remote_ssh/mod.rs b/src/crates/services/services-integrations/src/remote_ssh/mod.rs index bb614c3f2f..d0a49607c3 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/mod.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/mod.rs @@ -4,6 +4,7 @@ //! the legacy public path. pub mod paths; +pub mod remote_git; mod shell; pub mod types; pub mod workspace_registry; @@ -27,6 +28,7 @@ pub mod remote_fs; pub mod remote_terminal; pub use paths::*; +pub use remote_git::{build_remote_git_command, shell_quote_posix}; pub use types::*; pub use workspace_registry::*; pub use workspace_services::{remote_workspace_services, RemoteWorkspaceFs, RemoteWorkspaceShell}; diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_git.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_git.rs new file mode 100644 index 0000000000..151192b6e7 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_git.rs @@ -0,0 +1,62 @@ +//! Shared builder for git commands executed on remote SSH workspaces. +//! +//! Every host-side feature that shells out to `git` inside a remote SSH +//! workspace (desktop git commands, review-platform repository probing, ...) +//! must build the command line through this module so quoting and pager +//! behavior stay consistent. + +/// Quotes a value for a POSIX shell command line. +pub fn shell_quote_posix(value: &str) -> String { + if !value.is_empty() + && value.chars().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '-' | '_' | ':' | '=' | '@') + }) + { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +/// Builds a `git -C --no-pager ` command line for +/// execution through an SSH channel. +pub fn build_remote_git_command(repository_path: &str, args: I) -> String +where + I: IntoIterator, + S: AsRef, +{ + let mut parts = vec![ + "git".to_string(), + "-C".to_string(), + shell_quote_posix(repository_path), + "--no-pager".to_string(), + ]; + parts.extend(args.into_iter().map(|arg| shell_quote_posix(arg.as_ref()))); + parts.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_values_stay_unquoted() { + assert_eq!(shell_quote_posix("/home/user/repo"), "/home/user/repo"); + assert_eq!(shell_quote_posix("rev-parse"), "rev-parse"); + } + + #[test] + fn special_values_are_single_quoted() { + assert_eq!(shell_quote_posix("a b"), "'a b'"); + assert_eq!(shell_quote_posix("it's"), "'it'\\''s'"); + assert_eq!(shell_quote_posix(""), "''"); + } + + #[test] + fn builds_git_command_with_no_pager() { + assert_eq!( + build_remote_git_command("/srv/my repo", ["remote", "-v"]), + "git -C '/srv/my repo' --no-pager remote -v" + ); + } +} diff --git a/src/crates/services/services-integrations/src/review_platform.rs b/src/crates/services/services-integrations/src/review_platform.rs index 4e1224626d..d811609006 100644 --- a/src/crates/services/services-integrations/src/review_platform.rs +++ b/src/crates/services/services-integrations/src/review_platform.rs @@ -526,11 +526,40 @@ pub struct ReviewPlatformWorkspaceSnapshot { pub auth_challenge: Option, } -/// Classifies workspace paths that must be handled by a product-level remote -/// runtime before review-platform code probes the local Git repository. +/// Product-level workspace runtime access for review-platform Git probing. +/// +/// Review-platform only touches the workspace for repository discovery +/// (`git rev-parse --show-toplevel`, `git remote -v`); provider data itself is +/// fetched over HTTP from the host running BitFun. Remote SSH workspaces are +/// therefore fully supported as long as the product runtime can execute those +/// Git probes on the remote host, which is what this port injects. #[async_trait::async_trait] pub trait ReviewPlatformWorkspaceClassifier: Send + Sync { + /// True when `path` belongs to a remote workspace whose Git repository is + /// not reachable through the local filesystem. async fn is_remote_workspace_path(&self, path: &str) -> bool; + + /// Executes a Git command inside a remote workspace and returns stdout. + /// + /// Only called for paths where [`Self::is_remote_workspace_path`] returned + /// `true`. `workspace_path` identifies the remote connection; `current_dir` + /// is the remote directory the Git command must run in (it may differ from + /// `workspace_path` once the repository root has been resolved). + /// + /// The default implementation fails loudly so hosts that classify paths as + /// remote without wiring remote Git execution surface a clear error instead + /// of silently degrading. + async fn execute_remote_git_command( + &self, + workspace_path: &str, + current_dir: &str, + args: &[&str], + ) -> Result { + let _ = (current_dir, args); + Err(ReviewPlatformError::InvalidRepository(format!( + "Remote workspace Git execution is not wired for {workspace_path}" + ))) + } } #[derive(Clone)] @@ -540,6 +569,18 @@ pub struct ReviewPlatformService { workspace_classifier: Arc, } +/// Resolved Git execution scope for one workspace path. +#[derive(Debug, Clone)] +struct WorkspaceGitScope { + /// Original workspace path; identifies the remote connection for remote + /// scopes (the repository root may sit above the registered workspace + /// root and would not resolve a connection on its own). + workspace_path: String, + /// Git repository root the probes must run in. + repository_root: String, + remote: bool, +} + struct LocalOnlyReviewPlatformWorkspaceClassifier; #[async_trait::async_trait] @@ -713,20 +754,70 @@ impl ReviewPlatformService { .await } + /// Resolves how Git probes must run for `repository_path` (local process + /// vs. remote execution through the injected workspace runtime). + async fn workspace_git_scope( + &self, + repository_path: &str, + ) -> Result { + if self.is_remote_workspace_path(repository_path).await { + let output = self + .workspace_classifier + .execute_remote_git_command( + repository_path, + repository_path, + &["rev-parse", "--show-toplevel"], + ) + .await?; + // Remote roots are POSIX paths on the remote host; never apply + // local (Windows) path normalization to them. + let root = parse_repository_root_output(&output)?; + return Ok(WorkspaceGitScope { + workspace_path: repository_path.to_string(), + repository_root: root, + remote: true, + }); + } + + let root = get_repository_root(repository_path).await?; + Ok(WorkspaceGitScope { + workspace_path: repository_path.to_string(), + repository_root: root, + remote: false, + }) + } + + async fn execute_scope_git_command( + &self, + scope: &WorkspaceGitScope, + args: &[&str], + ) -> Result { + if scope.remote { + self.workspace_classifier + .execute_remote_git_command(&scope.workspace_path, &scope.repository_root, args) + .await + } else { + execute_git_command(&scope.repository_root, args).await + } + } + pub async fn discover_remotes( &self, repository_path: &str, ) -> Result, ReviewPlatformError> { let auth_tokens = self.load_stored_tokens().await?; - Self::discover_remotes_with_tokens(repository_path, &auth_tokens).await + let scope = self.workspace_git_scope(repository_path).await?; + self.discover_remotes_in_scope(&scope, &auth_tokens).await } - async fn discover_remotes_with_tokens( - repository_path: &str, + async fn discover_remotes_in_scope( + &self, + scope: &WorkspaceGitScope, auth_tokens: &ReviewPlatformAuthTokens, ) -> Result, ReviewPlatformError> { - let root = get_repository_root(repository_path).await?; - let output = execute_git_command(&root, &["remote", "-v"]).await?; + let output = self + .execute_scope_git_command(scope, &["remote", "-v"]) + .await?; let mut seen = HashSet::new(); let mut remotes = Vec::new(); @@ -795,19 +886,11 @@ impl ReviewPlatformService { per_page: Option, include_pull_requests: bool, ) -> Result { - if self.is_remote_workspace_path(repository_path).await { - return Ok(empty_snapshot( - Vec::new(), - None, - None, - "Pull request browsing is not available for remote SSH workspaces yet.", - )); - } - let pagination_request = PullRequestPagination::new(page, per_page); let auth_tokens = self.load_stored_tokens().await?; - let root = get_repository_root(repository_path).await?; - let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let scope = self.workspace_git_scope(repository_path).await?; + let root = scope.repository_root.clone(); + let remotes = self.discover_remotes_in_scope(&scope, &auth_tokens).await?; let selected_remote = select_remote(&remotes, remote_id).cloned(); let Some(remote) = selected_remote else { @@ -961,15 +1044,9 @@ impl ReviewPlatformService { remote_id: &str, pull_request_id: &str, ) -> Result { - if self.is_remote_workspace_path(repository_path).await { - return Err(ReviewPlatformError::UnsupportedPlatform( - "remote SSH workspace".to_string(), - )); - } - let auth_tokens = self.load_stored_tokens().await?; - let root = get_repository_root(repository_path).await?; - let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let scope = self.workspace_git_scope(repository_path).await?; + let remotes = self.discover_remotes_in_scope(&scope, &auth_tokens).await?; let remote = remotes .into_iter() .find(|remote| remote.id == remote_id) @@ -1251,15 +1328,9 @@ impl ReviewPlatformService { repository_path: &str, remote_id: Option<&str>, ) -> Result { - if self.is_remote_workspace_path(repository_path).await { - return Err(ReviewPlatformError::UnsupportedPlatform( - "remote SSH workspace".to_string(), - )); - } - let auth_tokens = self.load_stored_tokens().await?; - let root = get_repository_root(repository_path).await?; - let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let scope = self.workspace_git_scope(repository_path).await?; + let remotes = self.discover_remotes_in_scope(&scope, &auth_tokens).await?; let remote = select_remote_for_action(&remotes, remote_id)?.clone(); if !remote.supported { return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); @@ -1309,19 +1380,19 @@ impl ReviewPlatformService { host: &str, project_path: &str, ) -> bool { - if self.is_remote_workspace_path(repository_path).await { - return false; - } if !matches!( platform, ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab ) { return false; } - let Ok(root) = get_repository_root(repository_path).await else { + let Ok(scope) = self.workspace_git_scope(repository_path).await else { return false; }; - let Ok(output) = execute_git_command(&root, &["remote", "-v"]).await else { + let Ok(output) = self + .execute_scope_git_command(&scope, &["remote", "-v"]) + .await + else { return false; }; output.lines().any(|line| { @@ -4160,18 +4231,22 @@ fn canonicalize_stored_tokens( async fn get_repository_root(repository_path: &str) -> Result { let output = execute_git_command(repository_path, &["rev-parse", "--show-toplevel"]).await?; - let root = output + let root = parse_repository_root_output(&output)?; + Ok(normalize_repository_root(&root)) +} + +fn parse_repository_root_output(output: &str) -> Result { + output .lines() .next() .map(str::trim) .filter(|value| !value.is_empty()) + .map(str::to_string) .ok_or_else(|| { ReviewPlatformError::InvalidRepository( "Git repository root was not returned".to_string(), ) - })?; - - Ok(normalize_repository_root(root)) + }) } async fn execute_git_command( @@ -7263,6 +7338,44 @@ mod tests { } } + /// Remote workspace runtime stub that records routed git commands and + /// answers repository probes for a GitLab-hosted remote repository. + #[derive(Default)] + struct RecordingRemoteGitRuntime { + commands: std::sync::Mutex)>>, + } + + #[async_trait::async_trait] + impl ReviewPlatformWorkspaceClassifier for RecordingRemoteGitRuntime { + async fn is_remote_workspace_path(&self, _path: &str) -> bool { + true + } + + async fn execute_remote_git_command( + &self, + workspace_path: &str, + current_dir: &str, + args: &[&str], + ) -> Result { + self.commands.lock().expect("commands lock").push(( + workspace_path.to_string(), + current_dir.to_string(), + args.iter().map(|arg| arg.to_string()).collect(), + )); + match args { + ["rev-parse", "--show-toplevel"] => Ok("/srv/projects\n".to_string()), + ["remote", "-v"] => Ok(concat!( + "origin\thttps://gitlab.com/example/repo.git (fetch)\n", + "origin\thttps://gitlab.com/example/repo.git (push)\n", + ) + .to_string()), + _ => Err(ReviewPlatformError::InvalidRepository(format!( + "unexpected remote git command: {args:?}" + ))), + } + } + } + fn temp_token_store_path(name: &str) -> PathBuf { let id = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -7770,21 +7883,85 @@ mod tests { } #[tokio::test] - async fn workspace_snapshot_uses_injected_remote_classifier_before_git_probe() { + async fn workspace_snapshot_fails_loudly_when_remote_git_execution_is_not_wired() { let path = temp_token_store_path("remote-classifier"); let service = ReviewPlatformService::new(path, Arc::new(AlwaysRemoteWorkspace)); - let snapshot = service + let error = service .workspace_snapshot("not-a-git-repository", None, None, None) .await - .expect("remote workspace should return unsupported snapshot"); + .expect_err("remote workspace without remote git wiring must fail loudly"); + + assert!( + error + .to_string() + .contains("Remote workspace Git execution is not wired"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn workspace_context_routes_git_probes_through_remote_runtime() { + let path = temp_token_store_path("remote-git-routing"); + let runtime = Arc::new(RecordingRemoteGitRuntime::default()); + let service = ReviewPlatformService::new(path, runtime.clone()); + + let snapshot = service + .workspace_context("/srv/projects/bitfun", None) + .await + .expect("remote workspace context should resolve through remote git"); + + assert_eq!( + snapshot + .repository + .as_ref() + .map(|repository| repository.project_path.as_str()), + Some("example/repo") + ); + assert_eq!( + snapshot + .repository + .as_ref() + .and_then(|repository| repository.workspace_path.as_deref()), + Some("/srv/projects") + ); + let commands = runtime.commands.lock().expect("commands lock").clone(); assert_eq!( - snapshot.message.as_deref(), - Some("Pull request browsing is not available for remote SSH workspaces yet.") + commands, + vec![ + ( + "/srv/projects/bitfun".to_string(), + "/srv/projects/bitfun".to_string(), + vec!["rev-parse".to_string(), "--show-toplevel".to_string()], + ), + ( + "/srv/projects/bitfun".to_string(), + "/srv/projects".to_string(), + vec!["remote".to_string(), "-v".to_string()], + ), + ], + "git probes must run through the remote runtime, keyed by the workspace path" + ); + } + + #[tokio::test] + async fn repository_trusts_provider_identity_uses_remote_runtime() { + let path = temp_token_store_path("remote-trust"); + let runtime = Arc::new(RecordingRemoteGitRuntime::default()); + let service = ReviewPlatformService::new(path, runtime); + + assert!( + service + .repository_trusts_provider_identity( + "/srv/projects/bitfun", + ReviewPlatformKind::Gitlab, + "gitlab.com", + "example/repo", + ) + .await, + "remote workspace remotes must participate in provider identity trust" ); - assert!(snapshot.remotes.is_empty()); - assert!(snapshot.pull_requests.is_empty()); } #[tokio::test] @@ -7846,6 +8023,15 @@ mod tests { fs::write(&file, "content") .await .expect("temporary file should be created"); + // `git rev-parse --show-toplevel` reports the canonical path (for + // example `/private/var/...` instead of `/var/...` on macOS). + let root = root + .canonicalize() + .expect("temporary repository root should canonicalize"); + let nested = nested + .canonicalize() + .expect("nested directory should canonicalize"); + let file = nested.join("tracked.txt"); execute_git_command( root.to_str() diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index d45a2b8932..29cc531452 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -1946,6 +1946,26 @@ fn remote_connect_command_wire_shape_lives_in_owner_contract() { assert_eq!(poll["since_version"], 7); assert_eq!(poll["known_msg_count"], 3); assert_eq!(poll["known_model_catalog_version"], 11); + + let get_identity = serde_json::to_value(RemoteCommand::GetDelegatedIdentity) + .expect("serialize get delegated identity command"); + assert_eq!(get_identity["cmd"], "get_delegated_identity"); + let parsed: RemoteCommand = serde_json::from_str(r#"{"cmd":"get_delegated_identity"}"#) + .expect("parse get delegated identity command"); + assert_eq!(parsed, RemoteCommand::GetDelegatedIdentity); + + let identity = serde_json::to_value(RemoteResponse::DelegateIdentity { + token: "token-1".to_string(), + user_id: "user-1".to_string(), + master_key: "bWFzdGVyLWtleQ==".to_string(), + device_id: "device-1".to_string(), + }) + .expect("serialize delegate identity response"); + assert_eq!(identity["resp"], "delegate_identity"); + assert_eq!(identity["token"], "token-1"); + assert_eq!(identity["user_id"], "user-1"); + assert_eq!(identity["master_key"], "bWFzdGVyLWtleQ=="); + assert_eq!(identity["device_id"], "device-1"); } #[test] diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index fd1c588a27..ed4decc313 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -173,6 +173,10 @@ const AppContent: React.FC = () => { navigateTo('sessions', 'pop'); }, [navigateTo]); + const doPopFromDevices = useCallback(() => { + navigateTo('sessions', 'pop'); + }, [navigateTo]); + useEffect(() => { const onPopState = () => { const stack = pageStackRef.current; @@ -191,6 +195,8 @@ const AppContent: React.FC = () => { doPopFromChat(); } else if (currentPage === 'workspace') { doPopFromWorkspace(); + } else if (currentPage === 'devices') { + doPopFromDevices(); } } finally { isPopstateNavRef.current = false; @@ -199,7 +205,7 @@ const AppContent: React.FC = () => { window.addEventListener('popstate', onPopState); return () => window.removeEventListener('popstate', onPopState); - }, [doPopFromChat, doPopFromWorkspace]); + }, [doPopFromChat, doPopFromWorkspace, doPopFromDevices]); const handleOpenWorkspace = useCallback(() => { navigateTo('workspace', 'push'); @@ -260,14 +266,13 @@ const AppContent: React.FC = () => { /> )} - {page === 'devices' && clientRef.current && ( - { - pageStackRef.current = pageStackRef.current.slice(0, -1); - setPage('sessions'); - }} - /> + {shouldShow('devices') && clientRef.current && ( +
+ +
)} {shouldShow('sessions') && sessionMgrRef.current && (
@@ -276,11 +281,7 @@ const AppContent: React.FC = () => { onSelectSession={handleSelectSession} onOpenWorkspace={handleOpenWorkspace} onDisconnect={handleDisconnect} - onOpenDevices={() => { - pageStackRef.current = [...pageStackRef.current, 'devices']; - history.pushState({ page: 'devices' }, ''); - setPage('devices'); - }} + onOpenDevices={() => navigateTo('devices', 'push')} />
)} diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index e5409c59d3..aeaed6cc73 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -154,26 +154,18 @@ export const messages: Record = { }, devices: { title: 'Devices', - noDelegatedIdentity: 'The paired desktop is not logged into a BitFun account. Please log in on the desktop to enable multi-device control.', + noDelegatedIdentity: 'The paired desktop is not logged into a BitFun account. Log in on the desktop to enable multi-device control, then retry.', loading: 'Loading...', + refresh: 'Refresh', + retry: 'Retry', noDevices: 'No devices found', - noSessions: 'No sessions', - newSession: '+ New Session', - selected: 'Selected', - tokenExpired: 'Session expired. Please re-pair or ask the desktop user to log in again.', - loadDeviceFailed: 'Failed to load device', - loadMessagesFailed: 'Failed to load messages', - sendFailed: 'Failed to send message', - createSessionFailed: 'Failed to create session', - sendMessage: 'Send', - sendMessagePlaceholder: 'Send message...', - sessionsTitle: 'Sessions', - chatTitle: 'Chat', - online: '🟢', - offline: '⚪', - selectWorkspace: 'Select workspace', - selectWorkspaceFirst: 'Please select a workspace first', - noCurrentWorkspace: 'No open workspace on this device. Select a recent workspace first.', + online: 'Online', + offline: 'Offline', + current: 'Current', + pairedDesktop: 'Paired', + switchFailed: 'Failed to switch device', + controllingDevice: 'Controlling {name}', + tokenExpired: 'Account authorization expired. Re-scan the QR code or ask the desktop user to log in again.', }, tools: { ls: 'LS', @@ -331,26 +323,18 @@ export const messages: Record = { }, devices: { title: '设备', - noDelegatedIdentity: '所连接的桌面端未登录 BitFun 账号。请在桌面端登录以启用多设备控制。', + noDelegatedIdentity: '所连接的桌面端未登录 BitFun 账号。请在桌面端登录以启用多设备控制,然后重试。', loading: '加载中...', + refresh: '刷新', + retry: '重试', noDevices: '未找到设备', - noSessions: '暂无会话', - newSession: '+ 新建会话', - selected: '已选中', - tokenExpired: '会话已过期,请重新配对或让桌面端用户重新登录。', - loadDeviceFailed: '加载设备失败', - loadMessagesFailed: '加载消息失败', - sendFailed: '发送失败', - createSessionFailed: '创建会话失败', - sendMessage: '发送', - sendMessagePlaceholder: '发送消息...', - sessionsTitle: '会话', - chatTitle: '聊天', - online: '🟢', - offline: '⚪', - selectWorkspace: '选择工作区', - selectWorkspaceFirst: '请先选择工作区', - noCurrentWorkspace: '该设备当前没有打开的工作区,请先选择最近工作区。', + online: '在线', + offline: '离线', + current: '当前', + pairedDesktop: '已配对', + switchFailed: '切换设备失败', + controllingDevice: '正在控制 {name}', + tokenExpired: '账号授权已过期,请重新扫码或让桌面端用户重新登录。', }, tools: { ls: '列表', @@ -508,26 +492,18 @@ export const messages: Record = { }, devices: { title: '設備', - noDelegatedIdentity: '所連接的桌面端未登入 BitFun 帳號。請在桌面端登入以啟用多設備控制。', + noDelegatedIdentity: '所連接的桌面端未登入 BitFun 帳號。請在桌面端登入以啟用多設備控制,然後重試。', loading: '加載中...', + refresh: '重新整理', + retry: '重試', noDevices: '未找到設備', - noSessions: '暫無會話', - newSession: '+ 新建會話', - selected: '已選中', - tokenExpired: '會話已過期,請重新配對或讓桌面端用戶重新登入。', - loadDeviceFailed: '加載設備失敗', - loadMessagesFailed: '加載消息失敗', - sendFailed: '發送失敗', - createSessionFailed: '創建會話失敗', - sendMessage: '發送', - sendMessagePlaceholder: '發送消息...', - sessionsTitle: '會話', - chatTitle: '聊天', - online: '🟢', - offline: '⚪', - selectWorkspace: '選擇工作區', - selectWorkspaceFirst: '請先選擇工作區', - noCurrentWorkspace: '此裝置目前沒有開啟的工作區,請先選擇最近工作區。', + online: '在線', + offline: '離線', + current: '目前', + pairedDesktop: '已配對', + switchFailed: '切換設備失敗', + controllingDevice: '正在控制 {name}', + tokenExpired: '帳號授權已過期,請重新掃碼或讓桌面端用戶重新登入。', }, tools: { ls: '列表', diff --git a/src/mobile-web/src/pages/DevicesPage.tsx b/src/mobile-web/src/pages/DevicesPage.tsx index 318dd81035..c593cf81af 100644 --- a/src/mobile-web/src/pages/DevicesPage.tsx +++ b/src/mobile-web/src/pages/DevicesPage.tsx @@ -1,12 +1,16 @@ /** - * Devices Page — list same-account devices and select the control target. - * Nested sessions/chat shells were removed; the main Workspace/Session/Chat - * surfaces talk to the selected peer via RelayHttpClient.pairedDeviceId. + * Devices Page — list same-account devices and pick the control target. + * + * The mobile stays a limited companion surface: switching only retargets + * RelayHttpClient.pairedDeviceId (device RPC data plane) and resets the + * per-device UI state. Workspace/Session/Chat then talk to the new peer + * through the same limited command set. */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { RelayHttpClient } from '../services/RelayHttpClient'; import { useI18n } from '../i18n'; +import { useMobileStore } from '../services/store'; interface DeviceInfo { device_id: string; @@ -20,111 +24,287 @@ interface Props { } /** Check whether an error is an HTTP 401 (token expired/unauthorized). */ -function isTokenExpiredError(e: any): boolean { - const msg = String(e?.message || ''); +function isTokenExpiredError(e: unknown): boolean { + const msg = String((e as { message?: string })?.message || ''); return msg.includes('HTTP 401') || msg.includes('Unauthorized'); } +const BackIcon = () => ( + + + +); + +const RefreshIcon = () => ( + + + + + + +); + +const DeviceIcon = () => ( + + + + + +); + +const NoIdentityIcon = () => ( + + + + + + +); + const DevicesPage: React.FC = ({ client, onBack }) => { const { t } = useI18n(); + const { setControlTarget, resetForDeviceSwitch } = useMobileStore(); const [devices, setDevices] = useState([]); + const [identityReady, setIdentityReady] = useState(client.hasDelegatedIdentity); + const [identityChecking, setIdentityChecking] = useState(!client.hasDelegatedIdentity); const [loading, setLoading] = useState(false); + const [switchingId, setSwitchingId] = useState(null); const [error, setError] = useState(null); const [tokenExpired, setTokenExpired] = useState(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); const refreshDevices = useCallback(async () => { if (!client.hasDelegatedIdentity) return; try { const list = await client.listDevices(); + if (!mountedRef.current) return; setDevices(list); setTokenExpired(false); - } catch (e: any) { + setError(null); + } catch (e: unknown) { + if (!mountedRef.current) return; if (isTokenExpiredError(e)) { setTokenExpired(true); - setError(t('devices.tokenExpired')); } else { - setError(String(e?.message || e)); + setError(String((e as { message?: string })?.message || e)); } } - }, [client, t]); + }, [client]); + + // Acquire the delegated identity lazily: the desktop may have logged into + // its account after this mobile session was paired. + const ensureIdentity = useCallback(async () => { + if (client.hasDelegatedIdentity) { + setIdentityReady(true); + setIdentityChecking(false); + return true; + } + setIdentityChecking(true); + let granted = false; + try { + granted = await client.requestDelegatedIdentity(); + } catch { + granted = false; + } + if (mountedRef.current) { + setIdentityReady(granted); + setIdentityChecking(false); + } + return granted; + }, [client]); useEffect(() => { + let timer: ReturnType | undefined; + const init = async () => { + const granted = await ensureIdentity(); + if (!granted || !mountedRef.current) return; + setLoading(true); + await refreshDevices(); + if (mountedRef.current) setLoading(false); + timer = setInterval(refreshDevices, 30_000); + }; + void init(); + return () => { + if (timer) clearInterval(timer); + }; + }, [ensureIdentity, refreshDevices]); + + const handleManualRefresh = useCallback(async () => { + if (loading || switchingId) return; + const granted = await ensureIdentity(); + if (!granted) return; setLoading(true); - refreshDevices().finally(() => setLoading(false)); - const timer = setInterval(refreshDevices, 30_000); - return () => clearInterval(timer); - }, [refreshDevices]); + await refreshDevices(); + if (mountedRef.current) setLoading(false); + }, [ensureIdentity, loading, refreshDevices, switchingId]); const selectDevice = useCallback(async (d: DeviceInfo) => { - if (!d.online) return; - setLoading(true); + if (!d.online || switchingId) return; + if (client.pairedDeviceId === d.device_id) return; + setSwitchingId(d.device_id); setError(null); try { - // Probe peer host before switching the mobile control target. + // Probe the peer host before switching the mobile control target. const ping = await client.sendDeviceRpc<{ resp?: string; ok?: boolean; error?: string }>(d.device_id, { cmd: 'host_invoke', command: 'peer_mode_ping', args: {}, }); if (ping.resp === 'host_invoke_result' && ping.ok === false) { - throw new Error(ping.error || 'Peer device is not ready'); + throw new Error(ping.error || t('devices.switchFailed')); } client.pairedDeviceId = d.device_id; + resetForDeviceSwitch(); + setControlTarget({ + deviceId: d.device_id, + deviceName: d.device_name, + isHome: d.device_id === client.homeDeviceId, + }); onBack(); - } catch (e: any) { + } catch (e: unknown) { + if (!mountedRef.current) return; if (isTokenExpiredError(e)) { setTokenExpired(true); - setError(t('devices.tokenExpired')); } else { - setError(String(e?.message || e)); + setError(String((e as { message?: string })?.message || e) || t('devices.switchFailed')); } } finally { - setLoading(false); + if (mountedRef.current) setSwitchingId(null); } - }, [client, onBack, t]); + }, [client, onBack, resetForDeviceSwitch, setControlTarget, switchingId, t]); - if (!client.hasDelegatedIdentity) { - return ( -
-
- -

{t('devices.title')}

+ const renderBody = () => { + if (identityChecking) { + return ( +
+ + {t('devices.loading')}
-
{t('devices.noDelegatedIdentity')}
+ ); + } + + if (!identityReady) { + return ( +
+ +

{t('devices.noDelegatedIdentity')}

+ +
+ ); + } + + if (tokenExpired) { + return ( +
+ +

{t('devices.tokenExpired')}

+
+ ); + } + + if (loading && devices.length === 0) { + return ( +
+ + {t('devices.loading')} +
+ ); + } + + if (devices.length === 0) { + return
{t('devices.noDevices')}
; + } + + return ( +
+ {devices.map((d) => { + const isCurrent = client.pairedDeviceId === d.device_id; + const isHome = client.homeDeviceId === d.device_id; + const isSwitching = switchingId === d.device_id; + const clickable = d.online && !isCurrent && !switchingId; + return ( + + ); + })}
); - } + }; return (
- -

{t('devices.title')}

+ +

{t('devices.title')}

+
- {tokenExpired && ( -
{t('devices.tokenExpired')}
- )} {error && !tokenExpired &&
{error}
} - {loading &&
{t('devices.loading')}
} - - {!loading && !tokenExpired && ( -
- {devices.length === 0 &&
{t('devices.noDevices')}
} - {devices.map(d => ( -
selectDevice(d)}> - {d.device_name} - - {d.online ? t('devices.online') : t('devices.offline')} {d.device_id.slice(0, 8)} - {client.pairedDeviceId === d.device_id ? ` · ${t('devices.selected')}` : ''} - -
- ))} -
- )} +
+ {renderBody()} +
); }; diff --git a/src/mobile-web/src/pages/PairingPage.tsx b/src/mobile-web/src/pages/PairingPage.tsx index 30dc103ab9..319fae9bc6 100644 --- a/src/mobile-web/src/pages/PairingPage.tsx +++ b/src/mobile-web/src/pages/PairingPage.tsx @@ -4,22 +4,21 @@ import { useI18n } from '../i18n'; import { RelayHttpClient } from '../services/RelayHttpClient'; import { RemoteSessionManager } from '../services/RemoteSessionManager'; import { useMobileStore } from '../services/store'; +import { useTheme } from '../theme'; +import logoIcon from '../assets/Logo-ICON.png'; interface PairingPageProps { onPaired: (client: RelayHttpClient, sessionMgr: RemoteSessionManager) => void; } -const CubeLogo: React.FC = () => ( -
-
-
-
-
-
-
-
-
-
+const ThemeToggleIcon: React.FC<{ isDark: boolean }> = ({ isDark }) => ( + + {isDark ? ( + + ) : ( + + )} + ); const MOBILE_INSTALL_ID_KEY = 'bitfun.mobile.install_id'; @@ -81,6 +80,7 @@ function resolveRelayBaseUrl(): { room: string | null; pk: string | null; httpBa const PairingPage: React.FC = ({ onPaired }) => { const { t } = useI18n(); + const { isDark, toggleTheme } = useTheme(); const { connectionStatus, setConnectionStatus, @@ -178,6 +178,42 @@ const PairingPage: React.FC = ({ onPaired }) => { if (initialSync.sessions) { store.setSessions(initialSync.sessions); } + + // Inherit the desktop's logged-in account identity (best-effort). + // When granted, the mobile can list and control same-account devices. + // Soft timeout so a slow/unsupported desktop never blocks pairing; + // DevicesPage retries identity acquisition on demand. + try { + const delegated = await Promise.race([ + client.requestDelegatedIdentity(), + new Promise((resolve) => { + window.setTimeout(() => resolve(false), 10_000); + }), + ]); + const homeDeviceId = client.homeDeviceId; + if (delegated && homeDeviceId) { + store.setControlTarget({ deviceId: homeDeviceId, deviceName: null, isHome: true }); + void client + .listDevices() + .then((devices) => { + const home = devices.find((d) => d.device_id === homeDeviceId); + if (home) { + useMobileStore.getState().setControlTarget({ + deviceId: homeDeviceId, + deviceName: home.device_name, + isHome: true, + }); + } + }) + .catch(() => { + // Device name resolution is cosmetic; ignore failures. + }); + } + } catch { + // Desktop without account login (or delegation failure) is a normal + // single-device pairing; continue without device switching. + } + onPaired(client, sessionMgr); } catch (e: any) { const errorMessage = e?.message || t('pairing.pairingFailed'); @@ -275,8 +311,15 @@ const PairingPage: React.FC = ({ onPaired }) => {
+
- + BitFun
{t('shared.product.remote')}
diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index 34d5933e36..d86167f080 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -157,6 +157,7 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS setPairedDisplayMode, authenticatedUserId, connectionHealth, + controlTarget, } = useMobileStore(); const { isDark, toggleTheme } = useTheme(); const [creating, setCreating] = useState(false); @@ -593,14 +594,21 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS { switch (connectionHealth) { case 'connected': return t('sessions.connectionConnected'); case 'checking': return t('sessions.connectionChecking'); case 'unreachable': return t('sessions.connectionUnreachable'); default: return t('sessions.connectionUnpaired'); } })()} /> {authenticatedUserId} + {controlTarget && !controlTarget.isHome && controlTarget.deviceName && ( + + {controlTarget.deviceName} + + )} )}
{onOpenDevices && ( -
)} + {!selectedPr && detailOnly && !loading && !detailLoading && !authChallenge && !detailError && !error && ( +
+ + + {snapshot.message + || 'This pull request could not be resolved from the remotes of the current workspace.'} + +
+ + {initialPullRequestUrl && ( + + )} +
+
+ )} + {!selectedPr && !detailOnly && !loading && (