电脑操作
Spark Work 电脑操作(Computer Use)完整指南:Agent 直接操作真实桌面的能力。先讲清两条架构完全不同的使用路径——原子动作(会话模型自己逐步操作,每个会话一个隐式会话、空闲 5 分钟释放)与委派的 start_task(独立决策循环,各自的任务契约预算不同),再展开权限模型:会话权限层里 fail-closed 的 computer_direct_action / computer_unknown 硬闸,与生产路径上更早生效的自动放行名单的关系,以及 Broker 层「策略只做风险分级、不做内容拦截」的真实边界(evaluate 在所有分支返回 allow,唯一 deny 是 focus_mismatch;任务契约里 allowedApps / allowedDomains / forbiddenActions 等字段没有任何读取点)。执行内核部分覆盖 L0–L4 风险计算与哪些升级规则在生产不可达、十个原子工具与 click 的语义改写、坐标按「模型看到的图」换算、type_text 带 into/submit 会拆成最多三个动作、五个控制工具的真实语义(pause 与 takeover 逐行等价、resume 只有 MCP 路径会重启算子、bind_target 必须先暂停)。底层部分覆盖原生宿主为什么必需、双向签名校验与本地信任旁路、5 字节帧头 stdio 协议与「没有请求级取消,超时即 SIGKILL 宿主」、心跳与每会话一次的重启预算、元素树渲染与 treeVersion 的 stale 语义、以及 macOS 与 Windows 的能力不对等。数据部分覆盖三套尺寸的截图、AES-256-GCM 加密快照库与保留策略(只有证据帧有 24 小时 TTL、整体无磁盘配额)、预览令牌的 5 分钟 TTL 与 turn 边界撤销、17 项凭据类应用黑名单的实际覆盖面。最后是界面可达性(设置页只有授权与诊断、那套设置没有界面、全新安装不会武装紧急停止键)与 10 个 V2 开关里哪 4 个没有任何消费者。文末附 30 行排查表。
电脑操作(Computer Use)是 SparkWork 里唯一允许 Agent 直接操作你的真实桌面的能力: 看屏幕、读元素树、点击、输入、滚动、拖拽,以及把一整个多步目标委派给它自己跑。 它的实现方式和其他能力都不同——不是纯 Electron/JS,而是一个独立的原生宿主子进程(macOS 是 Swift 写的 SparkComputerHost,Windows 是 Rust 写的 spark-computer-host),主进程只能通过 stdio 帧协议指挥它。
这一页按真实代码讲清六件事:两条使用路径(原子动作与委派任务)、权限为什么是两道独立闸门、 策略引擎的实际强制力边界、原生宿主的信任与自愈、快照的加密与隐私边界、 以及 10 个 V2 开关里哪 4 个其实没有任何消费者。
先给结论:这套能力在代码层面是「可观测 + 可接管 + 有界预算」的, 但它不是沙箱——策略引擎对动作只做风险分级、不做内容拦截, 多数「安全字段」是声明而非强制。下面逐条给出证据。
1. 定位与真实入口
电脑操作是「会话内的即时能力」,不是长期资源:它绑定在某个 Agent 会话与某一轮的上下文里, 没有独立的项目管理页。你能看到的界面只有三处:设置页、聊天流里的活动卡片、系统托盘。
1.1 入口与界面文案
- 设置页:分组「系统」下的「电脑操作」, 项 id 为
computer-use,图标Icons.Monitor, 搜索关键词是['辅助功能', '屏幕录制', '输入控制', '系统权限', 'computer use'](apps/desktop/src/renderer/design/views/SettingsView.tsx:449-457)。 渲染的是ComputerUseSettingsSection(同文件:545)。 - 该页顶部说明原文是:「系统授权集中在这里管理。授权完成后,Agent 发起的电脑操作任务默认直接执行,不再逐步审批。」(
design/computer-use/ComputerUseSettingsSection.tsx:154-157)。 这句话就是整套设计的核心口径。 - 聊天流:电脑操作过程以卡片形式出现在消息之间,由
ComputerActivityProvider/ComputerActivitySegmentsBridge/ComputerActivitySegmentCard渲染 (design/views/ChatView.tsx:5147-5168)。 - 系统托盘:
ComputerControlTrayService,菜单里三条动作的文案是「暂停 Agent」/「立即接管」/「停止控制」(apps/desktop/src/main/index.ts:685/693/701)—— 注意这与聊天卡片里的「暂停 / 接管 / 停止」不是同一套文案,别混用。
一个容易踩的细节:设置页搜索框把关键词整串当子串匹配,而关键词里写的是带空格的 computer use,所以搜带连字符的 computer-use 搜不到这一项 (SettingsView.tsx:517-521)。
1.2 27 个工具的全貌
电脑操作给模型暴露 27 个工具,名字都在 mcp__spark_computer__ 命名空间下, 清单硬编码在 COMPUTER_USE_AGENT_TOOL_NAMES(apps/desktop/src/main/services/computer-use/ComputerUseMcpProvider.ts:6-37), HTTP 桥侧还有一份完全相同的白名单 ALLOWED_TOOLS(ComputerUseAgentBridge.ts:12-41)。按职责分四组:
| 分组 | 工具 | 数量 |
|---|---|---|
| 观测 | get_capabilities、diagnose_native_host、list_apps、list_windows、get_screen_state、get_app_state、capture_app_snapshot、screenshot | 8 |
| 原子动作 | click、type_text、paste、set_value、invoke_element、press_key、scroll、drag、select_text、perform_secondary_action | 10 |
| 委派任务 | start_task、get_status、wait_for_completion、open_app、bind_target | 5 |
| 控制 | pause、resume、stop、takeover | 4 |
工具一旦挂载,会话权限层不会再对它逐次弹审批(见第 3 节)。 另外要注意:MCP 服务器是无条件挂载给所有会话的,只判 computerUseMcpProvider != null,并没有「本会话是否开启电脑操作」这个开关 (packages/agent-runtime/src/services/session.service.ts:2926-2940)。 真正生效的门槛在每次调用时的能力预检。
1.3 两条使用路径
同一个工具面里有两种架构完全不同的用法,这是理解整篇文档的关键:
| 原子动作路径 | 委派任务路径 | |
|---|---|---|
| 入口工具 | click / type_text 等 10 个 | start_task |
| 谁做决策 | 会话模型自己(它就是电脑操作 agent) | 独立的决策循环 ComputerTaskOperator |
| 决策模型 | 当前会话的模型 | 单独的 Provider,可按会话解析(resolveDecisionModel) |
| 节奏 | 一次工具调用 = 一个动作,返回新树 + 新截图 | 后台循环自己跑,直到完成 / 失败 / 需要接管 |
| 会话对象 | 隐式会话,每个 Agent 会话一个,空闲 5 分钟释放 | 显式 computerSession,带任务契约与预算 |
| 契约预算 | maxSteps: 2000 / maxRuntimeMs: 12h / maxConsecutiveNoops: 20 | maxSteps: 100 / maxRuntimeMs: 20min / maxConsecutiveNoops: 8 |
两条路径共用同一个执行内核(ComputerControlBroker → 原生宿主), 但原子动作不经过 ComputerTaskOperator。 契约常量分别在 ComputerAtomicActionService.ts:52-69 与 ComputerUseAgentController.ts:430-441。
2. 系统权限与能力
电脑操作依赖操作系统级授权,这是唯一需要你手动参与的环节。 代码里没有绕过系统授权的路径:拿不到权限就是能力不可用。
2.1 平台支持矩阵
协议里声明了 macOS / Windows / Linux 三种平台与多种后端技术 (packages/protocol/src/computer-use/native-wire.ts:38:screen_capture_kit / windows_graphics_capture / xdg_portal / x11, 无障碍为 axui_element / uia / at_spi), 但仓库里只实现了 macOS 与 Windows 两个宿主:apps/desktop/native/ 下只有 macos/ 与 windows/, 且工厂函数把非 mac/win 或非 arm64/x64 直接降级为不可用后端 (NativeHostBackendFactory.ts:54-59)。Linux 目前没有任何可运行实现。
| 能力 | macOS(Swift) | Windows(Rust) |
|---|---|---|
| 截图 | ScreenCaptureKit(需 macOS 14+) | Windows.Graphics.Capture |
| 元素树 | AX,深度 48、元素上限 2000 | UIA,深度 30、元素上限 100000 |
| 后台输入 | AX 语义 + CGEventPostToPid | PostMessageW + UIA 语义 |
| 前台输入 | CGEvent | SendInput |
| 视觉兜底 | ✅ Vision OCR | ❌ 无(UIA 为空即报错) |
| 虚拟光标指示 | ✅ MacVirtualCursor | ❌ 无 |
| 键盘布局自适应 | ✅ 按当前布局解析 | ❌ 硬编码 VK 表 |
| 锁屏检测错误码 | 有 screen_locked 分支 | 无对应概念 |
| 安全桌面检测 | 无对应概念 | ✅ 拒绝截图/输入 |
不要把两平台当能力对等。元素上限差 50 倍 (MacControlPolicy.swift:19 vs windows_host.rs:337), mac 有 OCR 与虚拟光标兜底而 Windows 没有,Windows 有安全桌面概念而 mac 用锁屏错误码代替。 另外 macOS 的树渲染文本预算是 48k (NativeAXTreeRenderer.swift:62,注释明确要求「必须 ≤ TS 侧上限」), 而 Windows 用的是 90k(tree_render.rs:25), 但 TS 侧决策前只截 48k(ComputerDecisionAdapter.ts:23)—— 也就是说 Windows 侧超出的部分会被客户端静默截断,长界面可能丢尾部元素。
2.2 macOS 的三项授权
macOS 需要三项系统权限,代码里都是「先 Preflight 探测、按需 Request 弹窗」:
- 屏幕录制:
CGPreflightScreenCaptureAccess()/CGRequestScreenCaptureAccess()(MacScreenCaptureProvider.swift:38-39、:64); 缺权时报screen_permission_denied(可重试)。 - 辅助功能:
AXIsProcessTrusted()/AXIsProcessTrustedWithOptions()(:70、:1130);缺权时报accessibility_permission_denied。 - 事件投递:
CGPreflightPostEventAccess()/CGRequestPostEventAccess()(:71、:1135)。 注意这一项缺失也报accessibility_permission_denied, 没有独立错误码(MacScreenCaptureProvider.swift:789)。
界面上的两项授权行对应关系是:「屏幕录制」= screen、「辅助功能与输入控制」= accessibility + input 两项同时满足(ComputerUseSettingsSection.tsx:78-91)。 这里有个组合态容易困惑:状态文字优先显示 input 的状态,而「已授权」要求两个都 granted—— 于是「辅助功能已授权但输入控制未授权」时,会显示「待授权」但按钮变成「打开系统设置」 (因为 state 已不是 not_determined)。
点「请求授权」时,只有 not_determined 才会真正调系统弹窗 (app-snapshot:request-permissions);其他状态直接跳系统设置页 (computer-use:open-system-settings)。 深链文案是:「请前往“系统设置 → 隐私与安全性 → 屏幕录制/辅助功能”允许 SparkWork(开发模式下可能显示为 Electron)。」(:264-265)。
2.3 Windows 的能力探测
Windows 没有授权弹窗,也没有 denied / not_determined 状态: 它把三项能力按运行时探测结果直接映射成 granted 或 restricted(windows_host.rs:328-332)。request_permissions 请求在 Windows 上只是重新探测一次 (windows_host.rs:131-132),不会弹任何系统界面。 UIA 不可用时执行动作报 environment_unavailable(windows_host.rs:367-372)。
协议层的权限状态枚举是五值:granted / denied / not_determined / restricted / unsupported(native-wire.ts:19),macOS 会用到前三者,Windows 只用前四者中的两个。
2.4 能力探测的关键常量
客户端对 get_capabilities 结果做了缓存,刷新间隔是 1 秒(NativeHostClient.ts:27、:185), 所以你在设置页改完权限、切回应用窗口时,界面会在下一个焦点事件后自动重测 (ComputerUseSettingsSection.tsx:59-70 监听 focus 与 visibilitychange)。
「已就绪」的判定是四项同时成立:available === true 且 screen / accessibility / input 三项都是 granted(:248-255)。任一项不满足就显示「电脑操作尚未就绪」, 并展示 unavailableReason 原文,或兜底文案「请完成下方系统授权并重新检测。」
3. 权限与审批:两道闸门的真实关系
这是整篇最容易写错的地方。代码里存在两套与电脑操作有关的放行逻辑, 但它们当前的实际效果是「都不逐动作拦」——原因却完全不同。先把结论摆出来:
- 会话权限层里确实有一条 fail-closed 硬闸:
computer_direct_action与computer_unknown永不批准, 且连测试都锁死了这条语义。 - 但生产路径上,电脑操作工具在到达这条硬闸之前就被引擎的自动放行名单放行了, 所以它不会真的被这条规则拦到。
- 执行层(Broker)不做逐动作审批: 策略引擎只做风险分级,动作以
approvalTicketId = null直接执行。
3.1 第一道:会话权限层
动作名由 resolveComputerPermissionAction 映射 (packages/agent-runtime/src/computer-use/computer-permission-action.ts:38-44), 规则表在 permission.service.ts:41-50,三个内置 profile 共用同一份:
| 动作 | scope | mode | 覆盖的工具 |
|---|---|---|---|
computer_observe | session | allow | 10 个观测 / 查询类 |
computer_task_start | session | allow | start_task |
computer_pause | session | allow | pause |
computer_resume | session | allow | resume、bind_target |
computer_stop | session | allow | stop |
computer_takeover | session | allow | takeover |
computer_direct_action | any | deny | 13 个低层动作名(click、type_text、set_value 等) |
computer_unknown | any | deny | 其余任何 mcp__spark_computer__* |
requestApproval 开头有两条短路,位置在一切 profile 查询、会话记忆与 forcePrompt 之前(permission.service.ts:389-390):
if (isUnapprovableComputerAction(action)) return false
if (isGovernedComputerTaskAction(action)) return true这个顺序很关键:mode === 'deny' 在更靠后的代码里是可以被 forcePrompt: true 绕过的,但上面这两条短路在它之前就返回了, 所以电脑操作的两条 deny 规则无法被任何配置绕开。 回归测试把这条语义写死:即使把 computer_direct_action 配成 allow,mcp__spark_computer__click 依然被拒且不弹卡(packages/agent-runtime/src/__tests__/services/permission.service.test.ts:365-380)。
注释把这个意图写得很直白(permission.service.ts:96-101): 受管 Computer Use 的高层工具「在 requestApproval 入口直接放行, 确保旧库、自定义 profile、会话记忆和 forcePrompt 都不会重新引入应用内审批」; 内置规则版本号也为此升到 3(:105)。
3.2 但生产路径其实在更早处就放行了
这里有一个必须讲清、否则会误导的层次问题。工具在到达 PermissionService 之前,会先经过执行引擎的 canUseTool 回调, 而该回调里有一条「自动放行名单」的短路,位置在审批回调之前。
Claude SDK 路径的实现(packages/agent-runtime/src/sdk/claude-sdk-executor.ts): 自动放行在 :895-897,而宿主审批回调在 :922:
if (callbackAllowedTools.has(toolName)) { // :895
return allowTool(input, callbackOptions.toolUseID, 'user_temporary')
}
...
const approvalCallback = config.approvalCallback // :922
if (approvalCallback == null) return denyTool('Permission check failed', ...)而电脑操作的全部 27 个工具名恰好就在这份名单里: MCP provider 把它们放进 allowedTools(ComputerUseMcpProvider.ts:66-68),并在紧邻的注释里写明了设计意图——「Desktop task authorization is task-scoped and identical in every permission mode.」(桌面任务授权是任务级的,在每个权限模式下都完全一致)。
也就是说:会话权限层里那条 fail-closed 硬闸是一条兜底网,而不是生产路径上真正生效的闸门。 它保证「任何绕过引擎自动放行名单的路径(自定义 profile、旧数据库、会话记忆、 强制弹卡)都无法批准一个低层电脑动作」,但正常使用时,mcp__spark_computer__click 并不会走到它。 Codex 路径同理:其交互审批只覆盖命令执行与文件补丁两类,mcpToolCall 只是一条通知,不产生审批 (codex-app-server-executor.ts:1473-1491)。
怎么理解这条设计:电脑操作的控制点被有意集中在执行层—— 即「用户同意开始一次桌面任务」这个动作本身就是授权, 之后不再逐步打断。设置页那句「授权完成后默认直接执行,不再逐步审批」正是这个意思。
动作风险等级另有一张映射表(permission.service.ts:186-194):computer_observe / computer_pause / computer_stop / computer_takeover 为 low,computer_task_start / computer_resume / computer_direct_action / computer_unknown 为 high。 由于上面两条短路的存在,这些等级在当前实现里不参与拦截决策。
3.3 执行层:Broker 做了什么、没做什么
真正执行动作的是 ComputerControlBroker。它在每次动作前做三件事:
- 新鲜度校验:动作携带的
frameId与treeVersion必须与当前观测一致,否则抛stale_frame/stale_tree(ComputerControlBroker.ts:344-351)。 这里有一个刻意的例外:不校验前台应用/窗口, 注释写明理由是「绑定任务必须在用户切到别的应用时继续操作它的目标窗口」 (:352-355)——这正是「不抢焦点、后台操作」的实现基础。 - 策略评估:调
ComputerPolicyService.evaluate得到风险等级与决策(见第 4 节)。 - 动作落库:写入
computer_actions, 其中approvalTicketId硬编码为null(:389)。deny分支之后没有任何审批分支, 直接进入startExecuting(..., null)(:211)。
有一个名字容易误读的错误码:approval_mismatch(:374-378)。 它与审批无关——触发条件是「同一个 actionId 被复用, 但动作参数 / 风险等级 / 策略决策不同」,本质是幂等保护。
3.4 审批链为什么是停用的
仓库里有一整套完整的审批实现(ComputerApprovalService、ComputerActionApprovalPresenter、NativeComputerActionApprovalPrompt), 包含票证摘要、过期语义、远端只能批 L2 等细节,但在当前模型下整条链没有接到任何生产路径上。证据有四条:
- 接口上直接标了废弃:
{/** @deprecated Persisted approval UI compatibility; direct execution never creates this. */}(ComputerTaskOperator.ts:90-91);构造参数同样标注 「never read by direct execution」(:128-129), 且takeApprovedTicket在算子体内从未被调用。 createComputerActionApprovalPresenter与createNativeComputerActionApprovalPrompt只有定义、零生产调用点,引用只出现在各自的测试文件里。requiresUserPresence在策略决策里恒为false(ComputerPolicyService.ts:66-76两处decision(...)的最后一个实参都是false), 而任务契约里的userPresence恒被写成'required'——两者从未被比较。- 策略层从不产出
require_approval/require_handoff: 这两个值只出现在协议 schema、数据库 CHECK 约束与校验器里,没有任何decision(...)调用传过它们。
渲染端还留着一个故意封死的通道:computer-use:approve-action 永远抛错,文案是「Computer actions can be approved only through the native one-time confirmation surface」(registerComputerUseIpc.ts:294-302)。 也就是说,即使未来要做逐动作审批,也不打算走渲染端界面。
唯一与「完全放行」相关的判断是 isFullAccess: 它只认 claude-bypass 与 codex-full-access 两个模式 (ComputerActionApprovalPresenter.ts:51-53,注意不含任何 spark-* 模式)。但既然这条链已停用,它当前不产生实际差异。
4. 策略引擎与它的真实边界
所有动作的风险判定都来自 ComputerPolicyService.evaluate(apps/desktop/src/main/services/computer-use/ComputerPolicyService.ts:43-82), 它唯一的调用点是 Broker(ComputerControlBroker.ts:181)。 这个类只有 129 行,但它的边界必须讲清楚,否则很容易写出「策略引擎会拦截危险动作」这种错误结论。
4.1 L0–L4 是怎么算出来的
风险等级由两个来源取较大值:动作基线风险,与策略上下文里的 effect 风险。
动作基线表(ComputerPolicyService.ts:26-41):
| 基线 | 动作 |
|---|---|
L0 | observe、move、scroll、wait_for |
L1 | focus_window、select_text、set_value、type_text、paste_text、invoke_element、click、drag、keypress、app_command |
effect 风险表(:10-16):read_only→L0、reversible_local→L1、external_write→L2、high_impact→L3、restricted→L4。
三条升级规则(:59-73):
policyContext.target.kind === 'unknown'→ 至少 L2。- 敏感文本写入 → 若数据类含
credential则 L4,否则 L2。 - 本地文本写入且数据类含非
public→ 至少 L2。
但要注意这些升级规则里有一部分在生产路径不可达, 因为 policyContext 的唯一生产者是 policyContextFor(ComputerActionPolicyContext.ts:9-59,被算子与原子路径共用),而它:
- 只产出 3 种 effect:
read_only、reversible_local、external_write(:45-52)。 因此high_impact(L3)与restricted(L4)无法从生产代码产生—— 它们只出现在测试里。 - 只产出
element或window两种 target kind (:53-55),所以unknown→ L2 那条规则也不可达。 - 只产出 3 种数据类组合:
[]、['public']、['credential'](:56)。 所谓 personal / financial / health 等分类在生产路径不会出现。
还有一个设计上值得注意的点:policyContextFor 用一个正则匹配模型自己写的 intent 文本来决定 effect 是否升级为 external_write(:26-29,命中 send|submit|publish|post|purchase|buy|pay|delete|remove|confirm|book|order 或对应中文词即升级)。也就是说,风险等级有一部分取决于模型怎么描述自己的意图, 而不是纯粹取决于动作本身。
顺带一个反直觉的结论:invoke_element 在普通语义调用时 (action 为空或为 'invoke')既不算 read_only、也不算 reversible_local, 会落到 external_write → L2。 也就是说最常用的「点按钮」动作拿到的是 L2,而带 select/expand 等显式子动作时才是 L1(:35-40 与 policyContextFor:35-43 的差异)。
4.2 唯一的硬拒绝是 focus_mismatch
这是本节最重要的结论:evaluate 在所有分支上都返回 'allow',唯一的 'deny' 出现在开头—— 观测到的应用 id 与动作的目标应用 id 不一致时 (ComputerPolicyService.ts:52-54):
if (observedApp.id !== envelope.targetAppId) {
return decision(envelope.actionId, 'L1', 'deny', 'focus_mismatch', false)
}其余所有情况都走到底部的 allow(:75-81),reason 只有两种: L0 时是 read_only_action,其余是 within_task_scope。 连 L2 的「外部写入」也一样放行——测试标题直接写着「allows external writes and high-impact actions without per-action approval」(ComputerPolicyService.test.ts:87)。
换个说法:这个策略引擎的产出是「风险标签」,不是「拦截器」。 它会告诉你某个动作多危险(写进 computer_actions.risk_level, 并在活动卡片里展示),但不因此阻止它。 真正的刹车是预算、新鲜度校验、用户接管与 kill switch,而不是内容策略。
另外 focus_mismatch 在原子路径上几乎不会由策略层产生, 因为 envelope 的 targetAppId 就是 observation.foreground.app.id(ComputerAtomicActionService.ts),必然自洽。 实际抛这个码的是原生宿主与快照服务,不是策略层。
宿主侧的 focus_mismatch 现在还有第二个来源:用户中途上手。如果某个动作正在执行时,用户在受控窗口里按下鼠标(且该点在最上层窗口确实属于受控进程), 或者受控应用正好在最前台而用户敲了键盘,宿主会立即中止该动作并返回
focus_mismatch(retryable: true, 文案明确指出是「用户与目标窗口发生了交互」)。 这是节奏让位,不是命令停工: 原子路径会按既有的 STALE_ERROR_CODES 逻辑重新观测并重试一次。 真正的「停下」只有 Esc(见 9.x 中断令牌),它才返回不可重试的 handoff_required。
接管判定是按「动作窗口」而非按会话的(NativeTakeoverWindow,仅原生宿主侧):动作开始时 begin 会丢掉此前 记录的一切交互,动作结束时 end 清空窗口,因此用户在两次动作之间使用电脑 永远不会中止后续动作。 这一点曾长期失守——早前的实现把「用户碰过受控窗口」记成会话级 粘滞标志,一次误触就让该会话之后每个后台动作都返回 handoff_required, 而系统提示词又要求模型「不要重试」,于是整块电脑操作能力看起来完全不可用。
4.3 任务契约里哪些字段真的生效
start_task 会构造一个 ComputerTaskContract(ComputerUseAgentController.ts:430-441)。 这个契约有 10 个字段,但策略引擎完全不用它——evaluate 的形参名是 _taskContract(下划线表示未使用,ComputerPolicyService.ts:46)。实测各字段的强制力:
| 字段 | 生产值 | 是否真的生效 |
|---|---|---|
objective | 用户目标 | ✅ 进决策提示词与日志 |
successCriteria | 由目标/验收条件推导 | ✅ 验收引擎实际使用 |
maxSteps | 100 | ✅ 超限抛 task_step_limit_exceeded |
maxRuntimeMs | 1200000(20 分钟) | ✅ 超限抛 task_runtime_exceeded |
maxConsecutiveNoops | 8 | ✅ 连续无变化即中止 |
allowedApps | 恒 [] | ❌ 无任何读取点 |
allowedDomains | 恒 [] | ❌ 无任何读取点 |
allowedDataClasses | 恒 ['public','internal','personal'] | ❌ 无任何读取点 |
forbiddenActions | 恒 [] | ❌ 无任何读取点 |
userPresence | 恒 'required' | ❌ 从不与 requiresUserPresence 比较 |
后 5 个字段的声明本身就在协议里写了理由——它们标注为桌面执行不使用应用白名单 (packages/protocol/src/computer-use/session.ts:55-57 的注释)。 所以文档里不能写「任务契约限制了可操作的应用/域名/动作」, 当前实现里没有这个约束。生产者只有两处,且写的都是空值/常量:ComputerAtomicActionService.ts:61-68 与 ComputerUseAgentController.ts:433-440。
同理,bind_target 的工具描述里写着「bind to a new window whose strong application identity is already allowed by the task contract」(ComputerUseAgentBridge.ts:516-517),但代码从不校验 allowedApps。照工具描述写文档会出错。
5. 原子动作链路
这条路径让会话模型自己成为电脑操作 agent:一次工具调用只下发一个动作, 返回新的元素树与截图,模型据此决定下一步。这是 Codex 风格的决策架构, 与 start_task 的委派模型并存 (ComputerAtomicActionService.ts:74-80 的类注释)。
5.1 十个原子工具
原子工具名记录在 ATOMIC_TOOL_NAMES,控制器据此分流 (ComputerUseAgentController.ts:190-192 → :487-501):click、type_text、paste、set_value、 invoke_element、press_key、scroll、drag、 select_text、perform_secondary_action。 每次调用都会重做一次能力预检(:493-496), 所以中途撤销系统权限会立刻生效。
工具优先级在系统提示词里有明确排序(computer-use-system-prompt.ts 的 ## Tool priority 段):语义元素动作 > 元素定向点击 > 原始截图坐标 > 键盘。 坐标是最后手段,且是最新截图里的像素坐标、左上角原点。
5.2 click 的语义改写与坐标换算
两个容易踩的实现细节:
- 元素左键单击会被改写成语义调用,不是合成鼠标事件:当
at是elementId、按钮为左键(默认)、点击次数为 1 时, 实际下发的是invoke_element(ComputerAtomicToolHandlers.ts:250-268)。 这也是「不抢焦点」能成立的原因——走的是无障碍语义,不是全局鼠标事件。 - 坐标要按模型看到的图换算:模型收到的截图可能已被降采样, 所以坐标换算用的是「返回给模型那张图的尺寸」而非原始截图尺寸, 并且会被
clamp01夹到 [0,1] 的归一化窗口坐标 (:532-545、:584)。
另一个反直觉的点:type_text / paste 带 into 时, 会先额外下发一次 invoke_element(action: 'focus'); 若再带 submit: true,还会追加一次 keypress ['Enter'](:411-443、:445-477)。 也就是说一次「工具调用」在时间线与动作表里可能是三条记录。 排查时按工具名数动作会数错。
5.3 隐式会话与空闲释放
原子路径为每个 Agent 会话维护一个隐式电脑会话, 复用的状态是 preflighting / observing / planning / waiting_approval / acting(ComputerAtomicActionService.ts:27-33)。 空闲 5 分钟(IDLE_RELEASE_MS = 300_000,:50)后释放, 下次调用再透明重建。
空闲计时器有两个细节:用 unref() 不阻塞进程退出(:125), 且 observe/dispatch 进入时先清掉计时器、finally 再重新装(:147、:186-191)——注释解释了原因: 计时器若在动作中途触发,会伪造出一个 session_canceled。
为什么释放要这么久?源码注释给了解释:推理模型在两次工具调用之间常常思考数分钟, 太短的计时器会让任务卡片在过程中反复消失又出现(原文用了「断断续续」这个词), 并强制每次调用都重启截图流。macOS 的持久捕获另有 90 秒帧空闲停放, 用来在不打断会话的前提下熄灭系统的「屏幕共享」指示。
陈旧错误会自动自愈一次:stale_frame / stale_tree / focus_mismatch 命中后, 会自动重新观测、重建动作并重试一次(:19、:173-185)。 所以模型看到 stale_tree 时更可能应该换一个新元素 id, 而不是重复同一个调用。
6. 委派任务链路
start_task 让 Agent 把一整个目标交出去,由独立的决策循环在后台跑到结束。 它走的不是渲染端 IPC,而是主进程内置的 HTTP MCP 服务器。
6.1 从 MCP 调用到算子循环
完整链路(每一步都有对应的失败收敛):
- MCP 桥:
ComputerUseAgentBridge校验 bearer 令牌后, 把tools/call转成controller.invoke(sessionId, toolName, args)(ComputerUseAgentBridge.ts:677-683)。 令牌是 32 随机字节的 base64url,TTL 30 分钟且每次鉴权滑动续期(:551-559、:708-722)。 - 能力预检:
start_task先取getCapabilities(), 不支持执行就直接抛(ComputerUseAgentController.ts:409-412), 并检查本轮上下文是否存在(:413-414)。 - 环境校验:只接受
my_desktop,其余抛「This build currently provides governed execution only on My Desktop」(:416-418)。工具入参的 Zod 也把environment收成z.literal('my_desktop')(:659)。 - 目标解析:给了
targetApp就按应用标识解析并直接前台化; 否则用targetWindowId,都没有则报错(:419-424)。 - 决策模型解析:
resolveDecisionModel(sessionId)(:425)。 - 建会话:写
computer_sessions行并记computer_session_started事件(ComputerSessionManager.ts:123-147)。 - 占用桌面通道:
coordinator.claim(computerSession.id, sessionId)(:459)。 - 激活并起循环:
sessions.activate()把状态置observing,然后launchOperator在后台跑ComputerTaskOperator.run()(:460-463)。
失败会回滚:除 session_canceled 外的异常都会 broker.stop + coordinator.release(:468-476)。
6.2 桌面独占通道
同一时刻只能有一个任务真正驱动桌面,由 ComputerDesktopExecutionCoordinator 串行化(ComputerDesktopExecutionCoordinator.ts:30-69)。规则是非对称的:
- 同一个 Agent 会话再次 claim:只是转移通道, 不杀兄弟会话——这样
start_task与原子截图/点击可以并存(:38-41)。 - 不同的 Agent 会话claim:会停掉前一个持有者及其全部同名会话(
:46-63)。
值得注意的是 sessions.activate() 不创建持久化 actuator lease(ComputerSessionManager.ts:231-255,注释说明 lease 由主进程的 coordinator 接管)。 所以 computer_actuator_leases 表与 actuator_lease_conflict 错误码在这条路径上主要作为兜底存在。
6.3 状态与事件枚举
会话状态共 11 个(packages/protocol/src/computer-use/session.ts:16-28, 数据库侧是同一份):
preflighting | observing | planning | waiting_approval | acting
| verifying | paused | handoff_required | completed | failed | canceled其中要区分三组不同用途的子集:
- 后端认的执行态只有 4 个:
observing/planning/waiting_approval/acting(EXECUTABLE_STATUSES,ComputerSessionManager.ts:26-31)—— 注意不含preflighting与verifying。 - 主进程可写入的 phase 有 7 个(
:33-40)。 - 对 Agent 可见的算子运行态是另一层:
running/completed/failed/handoff_required/not_running(ComputerUseAgentController.ts:34-37、:575)。
waiting_approval 这个状态虽然被列进可执行集合与 phase 类型,但没有任何生产代码把会话置为该状态,与第 3.4 节「审批链停用」是同一个事实。
时间线事件共 14 种(packages/protocol/src/computer-use/events.ts:24-142):session_started、observation_created、action_requested、action_blocked、action_executed、action_failed、approval_requested、approval_resolved、verification_started、verification_completed、handoff_required、session_completed、session_failed、session_canceled。 其中 approval_requested 同样没有任何生产发出点。
还有一个排查陷阱:暂停不产生时间线事件。
所以活动卡片的状态徽标只看最后一条事件, 非终态一律显示「进行中」(ComputerActivityBlock.tsx:381-396)—— 你按了暂停,卡片可能仍显示「进行中」。
7. 五个控制工具的真实语义
这五个工具的名字都很好懂,但代码行为与名字的暗示有两处明显偏差。 入口在 ComputerUseAgentController 的 switch。
7.1 pause 与 takeover 在主进程路径上逐行等价
两者都是「硬暂停」:调 broker.pause、释放通道、作废本次运行记录。
case 'pause': { // :320-326
const paused = await services.broker.pause(computerSession.id)
services.coordinator.release(computerSession.id)
this.invalidateRun(services, computerSession.id)
return { computerSession: paused }
}
case 'takeover': { // :334-340
const paused = await services.broker.pause(computerSession.id)
services.coordinator.release(computerSession.id)
this.invalidateRun(services, computerSession.id)
return { computerSession: paused }
}唯一的差别在渲染端 IPC 路径:computer-use:takeover 会额外记录 owner 归属,供后续 resume / bind-target 做归属校验 (registerComputerUseIpc.ts:245-254)。 但无论哪条路径,它都不会把窗口或输入交给用户,也不激活任何窗口—— 「接管」的实际含义是「停下 Agent,让用户自己接手」。
底层 broker.pause 做三件事(ComputerControlBroker.ts:314-320): 清观测缓存、撤销待审批、executor.cancelSession() 中断在途动作;随后会话状态置 paused 并 abort 掉当前 controller (ComputerSessionManager.ts:319-336)。 在途打字会被停在半途,拖拽会松开按键。
7.2 resume 只有一条路能真正恢复运行
MCP 路径的 resume 会重新拉起算子循环(:341-368):校验必须是 paused → 重新解析决策模型 → 重新 claim 通道 → broker.resume → launchOperator。 失败会把会话重新暂停并释放通道(:360-367)。
而渲染端 IPC 版的 resume 只调 broker.resume,不重启算子(registerComputerUseIpc.ts:208-232)。 如果它被调用,会话会停在 observing 而没有任何驱动。 好消息是渲染端当前根本没有调用它(见 12.2)。
7.3 wait_for_completion 是有界等待,超时不算失败
它的实现是「先看当前状态,若已是会返回的状态就直接返回, 否则挂到会话状态订阅上等,直到超时」(:591-629)。 会立即返回的状态集合是 6 个(:39-46):completed / failed / canceled / paused / waiting_approval / handoff_required。
- 超时参数默认 120 秒,允许范围 100–300000 毫秒(
:707-712)。 - 超时返回体里带
timedOut: true,不代表任务失败。 - 命中终态时还会等算子收尾完成再返回(
finishAfterCleanup,:609-615),所以返回值里的operator字段是可信的。 - 任务失败时额外附带一条 continuation 提示,让 Agent 向用户报告失败并询问兜底方案 (
:571-589)。
7.4 bind_target 的先决条件是「已暂停」
这是唯一能显式更换目标窗口的工具,而且必须在会话 paused 状态下调用,否则抛错「Pause the Computer Use task before changing its bound target window」(ComputerUseAgentController.ts:303-319)。
绑定后的行为变化(NativeHostComputerUseBackend.ts:370-376):
- 清掉该会话的观测缓存,强制下一步必须重新观测。
- 后续观测不再跟随前台窗口变化,而是锁定绑定的窗口与所属应用。
这正好解释了系统提示词里那句「你的会话绑定在它自己的应用上: 用户中途切到别的应用也继续干」——绑定是默认行为(start_task 时建立),bind_target 是中途改绑。 界面上对应的是卡片里的「更换窗口」按钮,它只在暂停态出现(ComputerActivityBlock.tsx:318-327),窗口列表会过滤掉最小化的窗口。
另外 stop 的请求体里有一个 reason 字段, schema 接受但在 handler 里未被解构使用(协议 ipc.ts:88-93 vs registerComputerUseIpc.ts:234)——传了也没用。
8. 验收是怎么判定的
委派任务不会「跑完就算成功」:它必须通过 ComputerVerificationEngine 对 successCriteria 的逐条判定(ComputerVerificationEngine.ts:19-30)。 判定结果只有 4 种 reason:assertion_passed、assertion_failed、unsupported_evidence、model_visual_assertion。 整体通过的条件是「至少一条判据且全部通过」。
8.1 三种 kind 的判定规则
| kind | 支持的断言 | 判定方式 |
|---|---|---|
accessibility | exists / visible / enabled / focused / value_equals / text_contains | 在元素树里筛匹配元素,只看第一个 |
visual | text_present / text_absent | 把树文本 + 无障碍文本拼成字符串做 includes |
application_state | frontmost / window_exists / running / window_title_contains | 比对前台应用 id 或窗口清单 |
accessibility 的 selector 是「填了的字段都需匹配」的 AND 关系, 且只检查第一个匹配元素(:136 的 elements[0])—— 如果界面里有同名的多个元素,判定只看第一个。visible 的判定是「存在且宽高都大于 0」(:121-152)。
application_state 里有一条非对称规则值得记住:running 在 expected: true 时用「存在窗口」证明它在运行; 但在 expected: false 时,没有窗口并不能证明它没在运行, 于是返回 null → 归为 unsupported_evidence(:85-91)。写判据时不要依赖「应用未运行」这种否定断言。
8.2 visual 判定有一条模型兜底
这是整套验收里最需要讲清楚的一条。当一条 visual 判据的字符串匹配没有通过,但同时满足另外两个条件时,引擎会判通过, reason 记为 model_visual_assertion(:48-69):
modelVisualApproval === true——由决策模型主动声明「可以验收了」时传入;- 原生树没有可用证据——即树版本不是
visual-开头、 元素为空、且树文本是空 /[]/ 空的 diff。
这条兜底的用意是:Canvas、Electron 自绘界面等场景可能完全没有无障碍元素, 此时不能因为「字符串匹配不到」就永远判失败。 但它也意味着视觉类验收在无树可读时依赖模型自述——modelVisualApproval 由算子在模型说「ready_for_verification」时传 true(ComputerTaskOperator.ts:278), 而自动校验路径传 false(:193)。
判据的来源有三层兜底:显式 successCriteria → 文本形式的 acceptanceCriteria(会被翻译成 visual 的 text_present)→ 从 goal 里抽引号内文本 → 都拿不到时退化为 application_state 的 frontmost(desktop)(ComputerUseAgentController.ts:770-816)。
8.3 验收的两个现存缺口
缺口一:三种 kind 声明了但引擎不支持。
协议里的 VerificationSpec 允许 dom / file / external_readback 三种 kind (packages/protocol/src/computer-use/verification.ts:164-207), 但 ComputerVerificationEngine 里没有任何分支处理它们:passed 保持 null → 归为 unsupported_evidence → passed = false,任务将永远无法通过验收, 只会一直把失败反馈给模型、直到步数或时长预算耗尽。
好消息是 MCP 侧 start_task 的入参 schema 只暴露了三种可用 kind(ComputerUseAgentBridge.ts:70-157),所以正常调用不会踩到。 但「schema 支持」不等于「能验收」这个区别要记住。
缺口二:验收结果不落库。
ComputerTaskOperator 的 verifications 依赖在构造函数里赋值后全文再无读取(声明于 :117、赋值于 :144,仅此三处),ComputerVerificationRepository.create/complete 也零生产调用。 结果是 computer_verifications 表在生产路径恒为空, 会话完成事件里的 verificationIds 恒为 [](ComputerSessionManager.ts:394-400), 而 computer-use:get-verification 必然返回 null(该通道在渲染端也零调用)。
也就是说:验收判定在本次运行内生效(决定任务是否算完成),但不留档。 事后要复现「为什么判通过」只能看活动时间线里的 verification_completed 事件。
9. 原生宿主
这是电脑操作与其他所有能力最大的架构差异:真正的观测与输入不在 Electron 进程里, 而在一个独立的原生子进程中完成。
9.1 为什么必须有原生宿主
代码里不存在纯 Electron/JS 的替代实现——后端只有两个类: 真实现 NativeHostComputerUseBackend(NativeHostComputerUseBackend.ts:171)与占位UnavailableComputerUseBackend(ComputerUseBackend.ts:58)。 平台/架构不匹配时直接降级为不可用,没有 JS 兜底 (NativeHostBackendFactory.ts:54-59)。
原因从协议枚举就能看出来——它把后端技术写死成原生 API 名: screen 只能是 screen_capture_kit / windows_graphics_capture / xdg_portal / x11, accessibility 只能是 axui_element / uia / at_spi, input 只能是 cg_event / send_input / xdg_remote_desktop(packages/protocol/src/computer-use/native-wire.ts:38)。 macOS 宿主实际调用的 API 包括 ScreenCaptureKit(SCScreenshotManager、 SCContentFilter)、CGEventPostToPid、Vision OCR, 以及一个用 dlsym 动态解析的私有 SPI _AXUIElementPostNotification(MacFocusForger.swift:16-18 说明该符号 「存在于所有已发布的 macOS 的 HIServices 中,但不在公开 SDK 头文件里」)。 Windows 宿主用 Windows.Graphics.Capture、UIA COM 与 PostMessageW。 这些都是 JS 拿不到的能力。
macOS 上还有一条实践证据解释了为什么必须做原生无障碍读取:Electron/Chromium 应用必须设置私有属性 AXManualAccessibility 才会暴露内容树, 否则只能读到一个几乎空的树 (MacAccessibilityController.swift,注释把这件事称作「竞争对手能读到 Electron 应用结构树而我们之前只看到空树」的最大原因)。
这条握手还有第二个坑:它是异步的。属性设置成功只代表应用接受了请求, 渲染进程把内容树建好并通过 IPC 发布出去需要时间,被系统节流的后台渲染进程可能要几秒。 实测(2026-09-30):刚启动的 Electron 应用 174 ms 就能读到 400 个节点; 长时间闲置的 Electron 应用连续 2.3 s 的三次遍历都只有 9-10 个节点、且没有 AXWebArea, 等树建好后同样的调用拿到 503 个节点。 所以宿主现在以「文档容器是否存在且有内容」为收敛判据(NativeWebTreeReadiness.swift),带退避地重复遍历(总追加等待约 1.75 s), 并且绝不缓存没收敛的树。 仍然没就绪时,树文本首行会带一条 [accessibility: … only the window shell is shown …] 标记, 同时往 stderr 写一行 ax_web_tree_readiness 诊断(含首次遍历与收敛后的节点数)。 原生 AppKit 应用会以 AXError.attributeUnsupported 拒绝这个属性, 因此「是否接受握手」本身就是精确的「这个应用是否懒建树」判据,不需要猜 bundle id。
9.2 双向签名校验
主进程用 spawn 拉起宿主,shell: false、stdio 管道 (NativeHostClient.ts:147)。信任是双向的:
主进程 → 宿主(校验可执行文件):
- macOS:要求宿主的 Team ID 与当前 App 自己的签名 Team ID 一致, 并逐项核对标识符、SHA-256、文件名、平台、架构 (
NativeHostArtifact.ts:282-288、NativeHostBackendFactory.ts:163-171); 还会用codesign --verify --strict并生成 requirement 复核(:473、:607)。 - Windows:用 PowerShell 取签名者证书 SHA-256,同样与 App 自身指纹绑定 (
NativeHostArtifact.ts:592-596、NativeHostBackendFactory.ts:111-118)。 - 文件级约束:manifest ≤ 64 KiB、可执行 ≤ 256 MB、必须是常规文件且非符号链接、 POSIX 下不得 group/world 可写、版本不得低于
0.1.0(NativeHostArtifact.ts:11-14、:412-425)。
宿主 → 父进程(校验谁拉起了自己):
- macOS 的
ParentProcessTrustPolicy要求:宿主标识符为com.spark-agent.desktop.computer-host、父标识符为com.spark-agent.desktop、双方都必须anchor apple generic、 且双方 Team ID 非空且相等(ParentProcessTrustPolicy.swift:49-74)。 校验在宿主启动最前面执行,失败即exit(EX_NOPERM)(=77) (HostMain.swift:10-13)。 - Windows 同样先验自己、再验父进程签名者指纹,并要求父进程产品名为
SparkWork且镜像路径以sparkwork.exe结尾 (runtime_auth.rs:45-80、parent_auth.rs:91-102)。 它用WinVerifyTrust且只接受缓存内的吊销检查(可离线), 并且只信任已验证的叶子证书——注释明确说明绝不扫描未认证的 PKCS#7 证书袋, 因为攻击者可以往里面追加东西(runtime_auth.rs:221-235)。 - 两端都做了防 PID 复用:取父进程 id 前后各一次并比对进程创建时间 (
ParentProcessAuthorizer.swift:99-109、parent_auth.rs:81-84)。
有一套「本地信任」旁路需要你知道:当 trustMode === 'local' 时, 主进程会向宿主注入环境变量 SPARK_COMPUTER_LOCAL_TRUST=1(NativeHostClient.ts:143-146),宿主读到后直接跳过全部校验(ParentProcessAuthorizer.swift:15-19); Rust 侧只在 debug_assertions 或 local-trust feature 下认它。 而且有一条保护:本地信任 + 已签名 App 会被拒绝(抛 native_host_untrusted),只有未签名 App(开发态)才允许 (NativeHostBackendFactory.ts:130-139)。 所以不要把这个分支当成生产链路的常态。
两个退出码在两平台是一致的:77 = 认证失败、76 = 协议失败(HostMain.swift:13、:29;windows_host.rs:28、:39)。
9.3 通信协议
帧格式非常简单,三份实现完全一致:
- 5 字节定长帧头:4 字节大端长度 + 1 字节 kind (
NativeHostFrameCodec.ts:1-3、NativeFrameCodec.swift:40-42、frame_codec.rs:4)。 - kind 只有两种:
1 = json、2 = binary。 宿主只接受 JSON 帧作为请求,收到二进制即致命错误 (HostMain.swift:47、windows_host.rs:53-54)。 - 载荷上限 64 MiB(
67_108_864), 三份实现各有一个常量名(MAX_NATIVE_HOST_FRAME_PAYLOAD_BYTES/maxNativeHostFramePayloadBytes/MAX_FRAME_PAYLOAD_BYTES)。 注意帧头里的长度是载荷长度、不含那 5 字节头(frame_codec.rs:106),且空载荷被视为协议错误。 - 二进制帧必须紧邻它的 JSON 描述符:客户端在收到二进制时若不在等待状态, 直接抛
Native Host binary payload was not adjacent to its descriptor(NativeHostClient.ts:498-500)。
请求 8 种(native-wire.ts:167-213,Swift / Rust 侧同名):get_capabilities、request_permissions、list_windows、capture_window、observe、execute_action、cancel_session、ping。 响应 8 种:capabilities、windows、 capture_result、observation、action_result、ack、pong、error。 协议版本 1,三处常量一致(native-version.ts:3、 NativeHostProtocol.swift:4、protocol.rs:9)。
错误响应还有一条「报错本身不许致命」的约束。宿主先前的实现会对错误码与文案做严格校验, 不合法就抛异常——而异常会从请求处理器一路逃到宿主顶层,被当成协议失败处理、整个宿主进程退出。同类问题还有一处:请求级校验失败 (字段缺失/类型不对)原先也走同一条致命路径,而帧边界早已被解码器接受、流是同步的。 实测(2026-09-30):探针发一个缺 appId/windowId 的 observe,宿主 exit(76) 且调用方一直干等到超时——生产里这等于把 会话仅有的 1 次重启预算烧在一次畸形请求上。现在请求级解码失败只回一个 invalid_request 错误响应并继续服务 (NativeHostRequestDecoding),只有帧层错误 (长度/类型非法、流已失步)才终止进程。新码必须同时登记到客户端的协议枚举, 否则「不再杀死宿主」会变成「杀死客户端」——因此新增跨语言契约测试native-error-code-parity.test.ts:读取宿主 allowedErrorCodes并断言每个码都在 ComputerUseErrorCodeSchema 里(该测试已用「先删码、必须失败」 的方式验证过不是空跑)。
错误响应还有一条「报错本身不许致命」的约束。宿主先前的实现会对错误码与文案做严格校验, 不合法就抛异常——而异常会从请求处理器一路逃到宿主顶层,被当成协议失败处理、整个宿主进程退出。实测(2026-09-30):锁屏时观测返回的错误码 screen_locked 恰好不在那张合法码表里,于是「锁屏」变成了宿主死亡 + 烧掉本会话唯一一次重启预算。现在错误编码器不再抛异常:不认识的错误码降级为 environment_unavailable 并把原始码写进消息([screen_locked] …), 超长文案被截断到 4000 字符,非法 requestId 回填为 unknown, 并且有单测锁住「宿主发出的每个错误码都在合法表里」这条不变量。
超时与取消是这个协议里最容易误解的部分:
| 机制 | 行为 | 证据 |
|---|---|---|
| 默认请求超时 | 20 秒 | NativeHostClient.ts:26 |
| 超时上限 | 180 秒 | :29 |
| 动作类超时 | 按动作时长 + 宽限 + 6 秒 skyshot 预算动态计算 | :636-651 |
| 超时后果 | 整个宿主进程被 SIGKILL | :402-404 |
| AbortSignal 触发 | 同样是杀进程,不是取消单个请求 | :407-411 |
cancel_session 请求 | 只让宿主「忘记该会话状态」,不杀进程 | :298-300 |
进程内没有请求级取消——这是必须讲清的一点: 取消一个动作的代价是重启整个宿主进程。为此客户端做了一个兜底: 终止前先 stdin.end(),让宿主的输入守卫有机会补发鼠标/按键抬起事件, 避免按键卡住(:623-625,宽限 300 毫秒,:30)。
并发模型是单 FIFO 车道(operationTail), 但有容量上限:64 个在飞 + 64 个排队(:31-32)。超限返回 environment_unavailable 并附诊断码 native_host_request_capacity_reached / native_host_operation_capacity_reached(:352-357、:392-397)。
会话取消在宿主侧是粘性的:取消后再执行会被拒 (NativeHostRequestHandler.swift:203-204、:232)。 两个平台的饱和策略不同:macOS 用有界 FIFO 驱逐(上限 64,MacScreenCaptureProvider.swift:828-830), Windows 则在超过 10000 条后清空并进入饱和态、全部拒绝(cancellation_policy.rs:3-25)。别把两者写成同一套语义。
另外 macOS 的请求解析比客户端的 Zod 更严: 它用 requireKeys(..., exactly: [...]) 做逐字段白名单,多一个字段就报 invalidRequestFields(NativeHostProtocol.swift:81-166)。
9.4 健康检查与自愈
宿主是长驻子进程,主进程用心跳保护它:每 5 秒用 get_capabilities 探活 (NativeHostHealthService.ts:31、NativeHostComputerUseBackend.ts:238-240),连续 3 次失败才判定不健康(:32、:96-104)。 注释解释了为什么是 3 次而不是 1 次——单次抖动不该触发重启。
「宿主死了」有两条不同路径,处理方式不同:
- 心跳连续失败 → 立刻尝试重连(
NativeHostSupervisor.ts:280-281)。 - 某次操作报硬错误 → 记录终态失败但不同步重连, 等下一次
acquire()再说(:159-165)。
重启预算是每个会话 1 次(DEFAULT_MAX_RESTARTS_PER_SESSION = 1,:74), 预算耗尽后进入 failed,之后所有获取都直接失败(:173-174)。 预算只在会话取消时重置(NativeHostComputerUseBackend.ts:586-603)—— 注释解释了原因:否则一次旧的崩溃会把电脑操作永久锁死。
重连后的状态恢复策略很讲究:
- 清空观测缓存——新进程没有旧状态,强制调用方重新绑定并重新观测。
- 保留目标绑定——
targetBindings不被清除(:370-376)。
能让连接作废的错误只有5 个(:889-895):native_host_missing、 native_host_untrusted、native_host_incompatible、 action_timeout、session_canceled。 反过来说,stale_frame / stale_tree / focus_mismatch 都不会作废连接——它们只是「这次动作没成,重新看一眼」。
只有幂等操作会被透明重试,最多 3 次,且必须宿主标了 retryable: 即 observe / list_windows / get_capabilities。execute_action 与 capture_window 首次失败即抛出(:45、:676-677)。
还有一个独立于运行时心跳的部署期自检: 用 --spark-verify-native-host 参数启动,配合 SPARK_NATIVE_HOST_SMOKE_REPORT 环境变量输出报告, 只读、不开窗、不发任何动作,报告以 0600 独占方式写入 (ComputerUsePackagedSmoke.ts:5、:51-53;接线在 apps/desktop/src/main/index.ts:1125-1131)。
10. 元素树与 stale 语义
模型「看到」的界面不是截图,而是一份 Markdown 大纲;截图是辅助。 理解这份树的 id 与版本语义,是排查 stale_tree 的前提。
10.1 树如何渲染成 Markdown
格式在两侧的注释里都给了示例(NativeAXTreeRenderer.swift:10-15、tree_render.rs:5-6):
- window "Settings" [1]
- group "Sidebar" [2]
- textField "Search" = "vpn" [focused] [5]两条实现要点:元素 id 是渲染出来的行号(NativeAXTreeRenderer.swift:87、tree_render.rs:65), 所以跨帧不可复用;缩进用前序扁平列表 + depth 还原层级。
渲染时会做几类取舍:
- 先做结构折叠再渲染(
NativeAXTreeStructure.swift),六条规则:- 没有名称/值/占位符、未聚焦未选中、也没有真实按压动作的单子节点包装层被折叠(Chromium 会在网页内容外面套一长串空
AXGroup); - 无名称容器可采纳子节点的文本(ARIA 的 name-from-content):既包括 cell/row/button 这类「标签在子节点里」的角色,也包括任何可按压的无名容器——实测会话列表每一行都是「无名
AXGroup+invoke+ 一个标题文本 子节点」,不采纳就只能渲染成- 组 [58]。多个候选时优先文本角色, 且只从文本/图标类子节点取名(从AXCell取名会把「一行两格」的表格读成「一行一格」); - 无名布局盒被提升掉:无名称/值/动作、未聚焦未选中、角色属于纯布局 (
AXGroup/AXUnknown/AXGenericElement/…)的多子节点 容器不再占一行也不占一级缩进,子节点提升到上一层。结构角色(AXDialog、AXSheet、AXScrollArea、AXTable、AXRow、AXCell、AXList、AXTabGroup、AXToolbar) 一律保留; - 被影子化的按压包装被丢弃:自身可按压、唯一子节点也可按压且矩形完全一致 (±1pt)时,两者是同一个点击目标的两个名字。子节点不可按压时绝不折叠—— 否则会删掉该矩形里唯一的点击目标;
- 装饰整棵剪掉:
AXValueIndicator、AXScrollBar内部的箭头/页码按钮, 以及只画符号的列表标记("•");有序列表编号("1.")是内容,保留; - 整棵子树都没有任何可用信息时才丢弃。
focus/select/set_value不算「有内容」——Chromium 对普通布局组也会把它们报成 true, 计入就等于把所有包装层留在树里;同样地scroll也不算, 而且证据更硬:实测两个 Electron 应用里 847/850 与 843/847 个元素都报scroll(连AXImage、AXStaticText都报),真滚动容器改用角色AXScrollArea判定。AXSubrole则用来保留语义容器:AXLandmarkNavigation/AXLandmarkRegion/AXApplicationStatus/AXUserInterfaceTooltip会占一行,而行内文本样式组 (AXCodeStyleGroup/AXStrongStyleGroup)不会——它标注的是一段文本, 文本本身会作为提升后的子节点留下。 - 没有名称/值/占位符、未聚焦未选中、也没有真实按压动作的单子节点包装层被折叠(Chromium 会在网页内容外面套一长串空
- 只保留一个
[focused]:逐元素的AXFocused不可信(实测 Finder 侧栏 24 个AXCell全部报 true,加上AXList共 25 个「焦点」),宿主改为读取应用级AXFocusedUIElement并只标记该元素。 - 超预算即截断并留标记:超出文本预算时输出
[truncated: N elements omitted — …](:94-107)。 截断是位置性的 (按文档顺序,也就是沿窗口自上而下),实测(2026-09-30,打包版 SparkWork 长会话):原始树 1822 个元素、渲染到 1019 行就截断,而消息输入框(AXTextArea,原始序号 1752) 根本没有出现在树里——模型看不到整件事最需要的那个控件。标记因此写清「少了什么」与 「怎么办」:截图永远是完整下发的,树里没有的控件可以按坐标动作。 - 遍历被元素上限截断也会留标记:collector 在
maxNativeTreeElements(2000)处停止递归,此前完全无声——实测打包版 SparkWork 正好产出 2000 个元素,树看起来就是「窗口到此为止」。现在树尾追加[truncated: this window has more than 2000 elements …],与上面那条 「渲染预算」标记区分开:一个是没读,一个是没展开。 - 容器子元素过多时只显示前若干个并标注总数 (
:153-154)。 - 敏感值被抹掉:macOS 侧 secure 元素的 value 直接置空 (
MacControlPolicy.swift:309); Windows 侧密码/安全字段的 value 清空、name 统一替换为Sensitive field(uia_policy.rs:213-215)。 两端都把每个元素的actions截断到 20 条(:222、MacControlPolicy.swift:313)。
macOS 还额外做了两件 Windows 没有的事:强制打开 Chromium/Electron 的无障碍树(MacAccessibilityController.swift:45、:159), 以及合并已展开的菜单(:106、:142)。
10.2 treeVersion 与 stale_tree
treeVersion 是渲染文本的内容哈希(不是单调计数器)—— 这意味着界面没变时版本号也不变,注释明确说明了这个设计 (MacControlPolicy.swift:230-231)。
stale_tree 有两种触发条件:
- 动作携带的
treeVersion≠ 宿主当前版本(MacControlPolicy.swift:286、uia_policy.rs:195-196)。 - 元素 id 在当前版本里解析不到,或元素已被销毁 / PID 不匹配(
MacAccessibilityController.swift:519-523)。
客户端侧还有一层更外层的 stale_frame: 校验观测身份的 frameId(NativeHostComputerUseBackend.ts:441-462)。 两者都被映射为可重试(NativeHostRequestHandler.swift:373-376、 windows_host.rs:950-955)。
宿主在动作结束后会主动作废自己的观测绑定, 所以下一个动作必须先重新观测(MacScreenCaptureProvider.swift:299-301、 windows_host.rs:789)。 还有一种兜底:当帧/树版本变了但窗口身份没变时, 宿主会尝试重建一次缓存树而不是直接失败 (MacScreenCaptureProvider.swift:556-567)。
正确的处理方式(也是系统提示词里写的):
- 看到
stale_tree/stale_frame:重新读取一次状态(screenshot或get_app_state),用新 id 重试。 - 不要原样重复同一个调用——旧 id 不会自己变有效。
- 如果返回里说「树的帧没有变化」,说明这个动作没产生任何变化,应该换策略而不是重试。
10.3 两个平台的树差异
这段容易写错,逐条对照:
| macOS | Windows | |
|---|---|---|
| 文本预算 | 48 000 UTF-16 单元 | 90 000 UTF-16 单元 |
| 元素上限 | 2000 | 100000 |
| 树版本哈希输入 | 渲染文本 + 元素个数 | 仅渲染文本 |
| 超预算时 | 截断 + 明确标记 | 同样截断 + 标记 |
两个不一致值得记下来: 其一,Windows 的 90k 预算高于 TS 侧决策前的 48k 截断 (ComputerDecisionAdapter.ts:23、:373), 而 macOS 的注释明确要求「必须保持在 TS 侧上限以下,否则尾部 [n] 会被切掉」—— 这意味着 Windows 侧长界面确实可能丢尾部元素。 其二,两端的哈希输入不同,所以不要断言两端算法一致。
还有一个已被废弃但代码里还留着的机制:NativeHostTreeReconciler 同时支持 full 与 diff 两种模式 (NativeHostTreeReconciler.ts:9-11), 但两个宿主现在都只发 full: macOS 已在 MacControlPolicy.publish 里删掉了 wire diff (MacControlPolicy.swift:236-240 明确写 diff mode gone), Rust 侧永远走 TreeMode::Full(uia_policy.rs:188)。 所以不要因为 V2 开关里有 incrementalTree 就写「增量树已上线」, 真实状态是「diff 通道已废弃,full 无条件生效」。
11. 快照、隐私与留存
电脑操作会产生大量截图。这一节讲清它们怎么产生、存哪里、谁能看、什么时候删。
11.1 截图路径与三套尺寸
所有截图都走原生宿主。仓库里 desktopCapturer 零命中——Electron 只提供 nativeImage 的解码与编码能力。
三种尺寸是三样不同的东西,不要混为一谈:
| 产物 | 尺寸 | 编码 | 是否落盘 |
|---|---|---|---|
image blob(capture_app_snapshot) | 原始分辨率,不缩放 | PNG | ✅ 加密落盘 |
preview blob | 宽 > 1200 才缩到 1200 | PNG | ✅ 加密落盘 |
| 模型帧(决策输入) | 长边 ≤ 1600 | JPEG 质量 85 | ❌ 只在内存 |
| 证据帧(观测审计) | 宽 ≤ 1200,且已打码 | PNG | ✅ 加密落盘 |
常量与理由(ElectronSnapshotImageProcessor.ts:4-13):
const MAX_IMAGE_DIMENSION = 16_384
const MAX_PREVIEW_WIDTH = 1_200
const MAX_MODEL_IMAGE_LONG_EDGE = 1_600
const MODEL_IMAGE_JPEG_QUALITY = 85模型帧那条注释把动机讲得很清楚:视觉模型内部会把超过约 2 MP 的图降采样, 所以传完整 Retina 截图只会白白增加上传量与首字延迟(一张 5120×2880 的 PNG 编码成 base64 后每步有 7–14 MB)。 1600 长边的 JPEG 既保留小控件可读性,又把载荷压掉约 20 倍。
另外两组硬限额:单张截图 ≤ 64 MiB、像素数 ≤ 50 000 000 (NativeApplicationSnapshotCaptureService.ts:19-20), 且宿主只允许返回 PNG(:282-301 校验 payload.kind === 'image_png')。 两端截图都不包含物理光标——因为光标会破坏静态界面的 frameId 去重 (MacScreenCaptureProvider.swift:157-160、capture.rs:126)。
11.2 加密存储与清理
blob 落在 userData/snapshot-vault/blobs/,文件名是 24 随机字节的 hex + .svb(48 位十六进制), 与 blobId / snapshotId 无任何字面关联—— 对应关系只存在 SQLite 的 computer_snapshot_blobs.storage_key 里 (SnapshotVault.ts:12、:71)。
加密参数(SnapshotVault.ts:7-11、:61-70):
- 算法 AES-256-GCM,nonce 12 字节、认证标签 16 字节。
- 文件头有魔数
SPKSVLT与格式版本,AAD 绑定 blobId 与 kind (:268-273的JSON.stringify({ domain: 'spark.snapshot-vault', formatVersion, blobId, kind }))。 - 密钥是 32 字节安装级密钥,存在操作系统钥匙串里 (引用名
snapshot-vault-installation-key-v1,SnapshotVaultKeyProvider.ts:4、:43-50); 首次使用时随机生成并写入。密钥长度不是 256 位直接抛错(SnapshotVault.ts:200-204)。 - 目录权限
0700、文件0600, 写入走「临时文件 + fsync + rename」的原子替换(:206-226)。
保留与清理规则(这里有反直觉的地方):
- 没有任何数量或总体积上限——
SnapshotVault里不存在磁盘配额、blob 计数或 LRU 逻辑。磁盘占用没有兜底保护。 - 只有
retention_mode = 'ttl'的行会被过期扫描清掉 (application-snapshot.repository.ts:192-199)。 - 证据帧的 TTL 是 24 小时(
ComputerObservationEvidenceStore.ts:16、:121)。 capture_app_snapshot产出的快照没有 TTL: 带会话时是session模式(靠外键级联删除), 不带会话时是manual模式,永不自动删除(NativeApplicationSnapshotCaptureService.ts:252)。- 清理任务启动时立即跑一次、之后每 6 小时一次 (
SnapshotVaultMaintenance.ts:12、:49-54), 单批 200 条(:13);孤儿文件有 24 小时宽限期 (SnapshotVault.ts:13)。
所以「删了会话磁盘就会立刻干净」是错的:.svb 文件最多会滞留到下一轮清理 (≤ 6 小时)。反过来,「快照会自动保留 N 天后删除」也是错的—— 只有证据帧有 24 小时 TTL,manual 模式的快照不会自动消失。
11.3 预览令牌
界面里的截图通过自定义协议 spark-snapshot:// 读取, 路径形如 spark-snapshot://snapshot/<id>/preview?cap=<token>(SnapshotPreviewCapability.ts:115-119)。
- 令牌默认 5 分钟有效(
DEFAULT_TTL_MS = 5 * 60_000,:3), 合法区间 1 秒到 60 分钟;令牌是 32 随机字节的 base64url,格式为/^[A-Za-z0-9_-]{43,128}$/(:5、:36)。 - 协议侧校验极其严格:URL 必须逐字符等于重新拼出的结果,
cap只能出现一次,且不允许任何其他 query 参数 (SnapshotProtocol.ts:130-134)。 - 只允许
GET/HEAD(其他方法 405), 只允许读取image与preview两类 blob (text类型拿不到), 并按魔数嗅探 PNG/JPEG/WebP/GIF,否则 415 (:51-65、:160-182)。 - 失败一律返回 404 / 500,不泄漏路径或加密细节(
:55、:74-77); 响应头带cache-control: private, no-store与content-security-policy: default-src 'none'; sandbox(:18-23)。 - 授权判定只看「令牌存在 + 未过期 + snapshotId 相等」(
:66-75)。
撤销有两个维度:按会话撤销(:77-81)与按快照撤销(:83-87)。turn 边界确实会按会话撤销——会话收尾的三个分支都会调 revokeComputerUseSession(packages/agent-runtime/src/services/session.service.ts:4221、:4255、:4288), 最终落到 ComputerUseMcpProvider.ts:73-76 的 revokeSession。
两个容易理解错的点:
- 撤销只废掉「已发出」的令牌,不等于预览永久失效—— 渲染端只要再调一次
app-snapshot:get就能拿到新令牌 (ApplicationSnapshotPreviewCard.tsx:43-50、:70在图片加载失败时自动续签)。 sessionId为空的授权无法被 turn 边界撤销, 只能等 5 分钟 TTL 过期——manual模式的快照就属于这种。
11.4 隐私边界:实际做了什么
这里必须按代码写,不能写成理想设计。实际有两道独立的保护, 覆盖面比直觉窄得多。
第一道:凭据类应用黑名单(17 项)
名单硬编码在 NativeApplicationSnapshotCaptureService.ts:21-39: macOS 侧是 com.apple.securityagent、com.apple.loginwindow、com.apple.passwords、com.apple.keychainaccess、 securityagent;Windows 侧是 logonui.exe、 credentialuibroker.exe、consent.exe、lockapp.exe; 外加密码管理器 1password / bitwarden / keepass 各自的可执行名(含 .exe 变体)。 命中时抛 sensitive_input_blocked(:304-327)。
三条必须说清的边界:
- 它是精确 identity 匹配(小写 + 反斜杠归一化 + 取 basename),不做子串匹配。
- 名单只覆盖凭据与系统认证类应用——浏览器、聊天软件、 任何可能显示密码的普通窗口都不在名单里。
- 这道检查只作用于
capture_app_snapshot(主动截某个应用)这条路(:192),观测路径不经过它。
第二道:敏感区域打码(只作用于观测证据帧)
敏感区域来自原生宿主上报的 AX secure 元素边界 (MacControlPolicy.swift:281 的 sensitiveRegions: bounded.filter(\.secure).map(\.bounds))。 TS 侧在落盘前把这些区域涂成灰色 (RGBA 128,128,128,255,ElectronSnapshotImageProcessor.ts:108-117、:191-194), 坐标要先按缩放比从窗口坐标换算到图像坐标(:105-116)。 打码情况会记进元数据(ComputerObservationEvidenceStore.ts:253-258)。
与之相对的一个反向事实:capture_app_snapshot 的快照完全不打码——redaction 字段被硬编码为 { applied: false, reasonCodes: [], regionCount: 0 }(NativeApplicationSnapshotCaptureService.ts:251、:277)。 所以「所有快照都打码」是错的:打码只发生在观测证据帧上。
另外,纯视觉兜底路径(OCR 回退)产出的树不带敏感区域信息, 两处都是 sensitiveRegions: [](MacScreenCaptureProvider.swift:439、:1044)。
最后一道工程约束在渲染端:CSP 只允许 spark-snapshot: 作为图片源 (apps/desktop/src/renderer/index.html:19), 且渲染前用正则强校验 URL 形状 (design/services/event-mapper.ts:2749-2752)。
12. 界面能做什么、不能做什么
这一节不讲功能清单,而是讲哪些能力在界面上真的可达—— 因为这个子系统的界面覆盖度远低于后端能力覆盖度。
12.1 设置页只有授权与诊断,没有开关
ComputerUseSettingsSection 一共 280 行,渲染的内容只有四块: 状态摘要条、两行系统授权、一个诊断块,以及「重新检测 / 请求授权 / 打开系统设置 / 运行诊断 / 复制诊断」这几个按钮。它没有任何一个开关或复选框。文案是硬编码中文、不走 i18n(与聊天卡片相反,那张卡走 t())。
授权行的原文与状态文案(ComputerUseSettingsSection.tsx:78-91、:198、:256-260):
| 界面文案 | 说明 |
|---|---|
| 屏幕录制 | 读取当前应用窗口画面,用于识别按钮、输入框和操作结果。 |
| 辅助功能与输入控制 | 读取可访问性结构,并执行点击、键盘输入、滚动和窗口切换。 |
| 已授权 / 未授权 / 受系统限制 / 当前不可用 / 待授权 | 分别对应 ready / denied / restricted / unsupported / not_determined |
| 电脑操作已就绪 / 电脑操作尚未就绪 | 四项能力探测全通过才算就绪 |
诊断块的文案是「Native Host 与核心链路」,说明写着「检查运行时、权限、协议状态及观察/动作各阶段延迟,不包含截图或输入内容」(:215-218)——这句话是准确的,指标收集器确实不含内容(见 13.2)。 诊断结果会显示 diagnosticCode 与三个延迟数值: 观测、动作、动作后观测(:222-227、:243-253)。
关键落差:那套 ComputerUseSettings 设置没有任何界面。
代码里定义了一份完整的设置结构,存在 app_settings 表的 category computer-use / key settings 下(registerComputerUseIpc.ts:34-35):
{
enabled: true,
environments: { safeBrowser: false, safeDesktop: false, myDesktop: true },
allowedApps: [],
redactSensitiveContent: true,
fullRecordingEnabled: false,
evidenceRetentionDays: 30,
killSwitch: 'CommandOrControl+Shift+Esc',
remote: { observe: false, approveL2: false, control: false },
}但渲染端从不调用 computer-use:get-settings 与 computer-use:update-settings(这两个通道各注册了 handler, 但全仓渲染端零引用)。于是有四个字段除了默认值之外没有任何生产读取点:
redactSensitiveContent(默认true)fullRecordingEnabled(默认false)evidenceRetentionDays(默认30)- 整个
remote对象,含observe/approveL2/control
它们会被 schema 校验、会被持久化、会被读回界面——但没有任何代码根据它们改变行为。 这一点如果写成「可以配置敏感内容打码 / 保留天数 / 远程审批」就是错的。
由此还引出一个关于 kill switch 的重要事实。
紧急停止的快捷键确实是真实注册的——主进程启动时注入了 Electron 的 globalShortcut(apps/desktop/src/main/index.ts:1119-1124 的 shortcutRegistrar: globalShortcut,位于正常的数据库初始化路径上, 不是只在冒烟测试分支里)。未注入时用的是 fail-closed 实现:register 恒返回 false(ComputerUseServices.ts:326-329)。
但武装(arm)动作只有两条路径: (a) 启动时的 reconcilePersistedKillSwitch; (b) computer-use:update-settings handler 内的 armKillSwitchBestEffort。 而 (a) 读的是原始持久化行,没有默认值兜底(registerComputerUseIpc.ts:446-454 直接 safeParse(store.get(...)), 与 loadSettings 的 ?: structuredClone(DEFAULT...) 不同)。 由于没有任何界面写这一行,全新安装时该记录不存在 → safeParse 失败 → 直接 return。
结论:默认值里的 CommandOrControl+Shift+Esc 在全新安装上不会被注册。不要写「默认快捷键 Cmd/Ctrl+Shift+Esc 可随时紧急停止」。 想启用它,需要让那一行设置被真正写入(例如通过平台工具的 settings_set 写入该 category/key, 它不校验 category/key,packages/agent-runtime/src/services/settings.service.ts:29-31), 之后下次启动才会武装。
一旦武装,它的行为是取消所有活跃会话,与界面上的「停止」走同一条代码路径 (ComputerUseServices.ts:224-241 对每个活跃会话调 broker.killSwitch, 后者即 stop,ComputerControlBroker.ts:335-342)。 两个细节:重复按键会被合并(一次 kill 未完成时后续按键被吞掉, ComputerKillSwitchService.ts:21);被 kill 的会话不能 resume——canceled 不是 paused,恢复只能起一个新会话 (ComputerSessionManager.ts:338-343)。 另外代码里没有用任何标记把「kill switch 中止」与「用户点停止」区分开, 时间线上看不出是哪一种。
还要区分两个完全不同的「Esc」:kill switch 是 CommandOrControl+Shift+Esc,停掉所有会话;物理 Esc 键只是取消当前这一个动作, 让调用方收到 handoff_required(NativeInterruptionToken.swift:6-10 有完整语义说明)。 系统提示词特别强调:模型自己通过 press_key 发 Esc 不会触发它, 因为合成按键不算用户输入。
12.2 聊天里的活动卡片
卡片由 ComputerActivitySegmentsBridge 按消息切段渲染(ChatView.tsx:5147-5168)。 每段的构成:
- 状态徽标:取该段最后一条事件推断(
ComputerActivityBlock.tsx:381-396),文案有「已完成 / 失败 / 已停止 / 需要接管 / 等待确认 / 进行中」。 - 行:事件标签 + 时间(
HH:mm:ss,:297-298)。 - 耗时:按整条会话时间线的首末事件差计算,小于 1 秒不显示(
:501-510)。 - 控制按钮:暂停 / 接管 / 停止 / 更换窗口,只在「本段是最新一段、有会话、且非终态」时出现(
:305-374)。
两个显示上的取舍值得知道:
computer_observation_created事件被显式过滤掉不显示(:216),所以卡片不会列出每一次观测。- 同一
actionId的requested事件在出现终态后会被折叠(:403-417),所以不是「每个动作两行」。
错误码在卡片里会做一次中文映射,但这张映射表只有 7 个键, 且其中三个键不是协议枚举里的码(:482-493 用的 permission_denied / native_host_not_found / target_lost, 而枚举里是 screen_permission_denied / native_host_missing / focus_mismatch)。 结果是绝大多数真实错误会原样显示英文错误码(?? errorCode 兜底)。 排查时直接按英文码查本页第 14 节更可靠。
托盘里的按钮失败只写日志、界面无提示(apps/desktop/src/main/index.ts:717),这也是一个排查盲区。
12.3 托盘与画中画(PIP)
- 托盘:
ComputerControlTrayService,在createTray()里构造(main/index.ts:565-575),订阅会话状态刷新菜单。菜单文案是 「暂停 Agent / 立即接管 / 停止控制」——与卡片里的「暂停 / 接管 / 停止」不是同一套。 - PIP 面板:
ComputerUsePipService, 但它不是渲染端 React 组件——它是主进程创建的BrowserWindow,内容是一段内联的data:text/html页面 (ComputerUsePipService.ts:91-116、HTML 常量在:151-216)。 只有pipPanel这个 V2 开关为真时才创建 (ComputerUseServices.ts:185-206),该开关默认为真。
渲染端实际调用的电脑操作通道只有 11 个(get-capabilities、 diagnose-native-host、open-system-settings、 list-windows、list-sessions、get-timeline、 bind-target、pause、stop、takeover、 resolve-app-command), 而注册的通道有 20 个。剩下 9 个没有任何渲染端调用者:
start resume get-status get-settings update-settings
list-apps approve-action deny-action get-verification它们的存在方式各有不同:start / resume / get-status / list-apps 是主进程 MCP 路径的 IPC 镜像,实际由 Agent 调用;get-settings / update-settings 是那份「没有界面」的设置;approve-action 是被故意封死的(永远抛错);get-verification 则必然返回 null(见 8.3)。
这带来一个实际的界面落差:聊天卡片上没有「继续」按钮。 会话暂停后,只有 Agent 通过 MCP 调 resume 才能真正恢复运行 (渲染端既不调用它,它的 IPC 版实现也不重启算子循环)。 同理,bind_target(更换窗口)之后要靠 Agent 调 resume 继续。
13. V2 开关与自动回滚
computer-use 目录下有一组 SPARK_COMPUTER_USE_V2_* 环境变量控制的开关。 它们的语义与很多人的直觉不同:没有 V1/V2 总开关,10 个开关默认全开, 且只能被显式关闭、不能通过配置打开任何「旧版」。
13.1 十个开关的真实消费情况
定义在 computerUseV2Flags.ts:1-12,默认值在 :38-49 (十个全为 true)。源码注释写明了口径:「V2 is the shipped product path.」——V2 就是当前产品路径, 每个优化都能通过环境变量显式关闭或运行时独立回滚。
环境变量名统一前缀 SPARK_COMPUTER_USE_V2_(:23-34), 解析规则是「只有 0 / false / no / off 算关闭, 其他任何非空字符串都算开启」(:137-140)。 所以 SPARK_COMPUTER_USE_V2_PIP_PANEL=anything 是开,不是关。
逐个人工核实过的消费情况:
| 开关 | 生产读取点 | 作用 |
|---|---|---|
hostSupervisor | NativeHostBackendFactory.ts:92 | 启用原生宿主 supervisor(自愈) |
incrementalTree | ComputerTaskOperator.ts:42 | 取反决定是否请求 full 决策树 |
actionBatch | ComputerTaskOperator.ts:219 | 是否允许模型一次返回一批动作 |
persistentCapture | NativeHostComputerUseBackend.ts:227 | 持久捕获流(而非每次截一张) |
actionSkyshot | NativeHostComputerUseBackend.ts:474 | 动作回包附带后置观测 |
pipPanel | ComputerUseServices.ts:185 | 是否创建画中画面板 |
installedArtifactDiagnostics | 无 | — |
backgroundSemanticLane | 无 | — |
activityTimeline | 无 | — |
visibleControlIndicator | 无 | — |
四个开关没有任何消费者:installedArtifactDiagnostics、backgroundSemanticLane、activityTimeline、visibleControlIndicator。 它们会出现在 get_capabilities 返回的开关快照里 (ComputerUseAgentController.ts:200-201 把快照塞进返回值), 但读取它们不会改变任何行为。连它们对应的四个包装函数也无人调用。
由此推出两个容易写错的结论: 其一,活动时间线不是由 activityTimeline 开关控制的——ComputerUseTimelineStore 无条件在跑,卡片也是无条件渲染的; 其二,不要因为 incrementalTree 默认开启就写「增量树已上线」—— 两个宿主现在都只发 full(见 10.3),diff 通道已废弃。
另外 persistentCapture 虽然确实被消费, 但它对应的熔断计分没有被接上:recordPersistentCaptureBudget()零生产调用(只在自己的类里定义), 所以它不会被自动回滚,相关阈值形同虚设。
13.2 自动回滚阈值
ComputerUseV2RolloutController 会按运行统计自动关闭某个开关。 语义是进程内、不落盘、下次启动自然重置,且只能关不能开。 阈值(ComputerUseV2RolloutController.ts:24-35):
| 指标 | 阈值 |
|---|---|
| 宿主崩溃率 | 0.005(样本 ≥ 200) |
| 安装制品失败率 | 0.001(样本 ≥ 1000) |
| 动作错误率 | 0.01(样本 ≥ 500) |
| 接管停止 P99 | 500 毫秒(样本 ≥ 100) |
| 持久捕获预算失败率 | 0.01(样本 ≥ 200,但计分未被接上) |
统计窗口上限 2048 个样本(:37)。 有两处命名与直觉不符,排查时会误导:
- 接管耗时超限回滚的是
actionBatch, 不是 supervisor,也不是可见指示器(:108的rollback('actionBatch', 'takeover_stop_p99_exceeded', ...))。 - 安装制品校验失败时被禁用的是
hostSupervisor, 不是installedArtifactDiagnostics(:80-84)。 所以那个开关永远不会被回滚——它本来也没有消费者。
回滚的后果也不一样:只有 hostSupervisor 会真的拆掉 supervisor 并清空观测(NativeHostComputerUseBackend.ts:248-256), 其余开关回滚只是让读取点返回 false。
14. 排查表
按现象查。每条都给出了对应的判定点或错误码——错误码以协议枚举为准, 不要依赖聊天卡片里的中文映射(见 12.2)。
| 现象 | 先看哪里 | 说明 |
|---|---|---|
观测/操作报 focus_mismatch | error.message 原文 | 这个码在宿主里承担三种含义,靠消息区分:请求的窗口已被最小化(「The target window is minimized…」,窗口还在,恢复它或改观测同应用的另一个窗口)、 请求的窗口已不存在(「…no longer available」)、前台应用身份真的变了。 实测曾把「最小化」错报成第三种,现已区分(9.3 / 10.1) |
报 invalid_request | 请求字段是否缺项/类型不符 | 帧边界合法但载荷不合契约:宿主回一个错误响应后继续服务, 不再 exit(9.3) |
| 树看起来「就这样结束了」 | 树尾是否有 [truncated: …] 标记 | 两种截断要分清:渲染预算截断(N elements omitted,树没展开完)与遍历上限截断 (more than 2000 elements,根本没读)。后者此前完全无声(10.1) |
| 设置页显示「尚未就绪」 | 两行授权状态 + unavailableReason 原文 | 四项能力探测需全部 granted;就绪判定见 2.4 |
| macOS 授权后仍显示未授权 | screen_permission_denied / accessibility_permission_denied | 事件投递权限缺失也报 accessibility 码,没有独立码(2.2) |
| Windows 上没有任何授权弹窗 | get_capabilities 的 restricted | Windows 无弹窗、无 denied 状态,只有 granted/restricted(2.3) |
报 native_host_missing | 安装制品是否存在、平台/架构是否匹配 | 诊断 stage 为 discover,修复动作 reinstall |
报 native_host_untrusted | Team ID / 发布者指纹是否与 App 一致 | 本地信任 + 已签名 App 会故意被拒(9.2) |
报 host_handshake_failed / 协议错误 | 协议版本、帧格式 | 宿主退出码 76 即协议失败、77 即认证失败(9.3) |
动作报 stale_frame / stale_tree | 重新读一次状态,用新元素 id 重试 | 元素 id 是行号、跨帧不可复用;不要原样重复调用(10.2) |
| Electron/Chromium 窗口只读到标题栏按钮,树里没有网页内容 | 树文本首行是否有 [accessibility: … only the window shell is shown …]; 日志里的 ax_web_tree_readiness | 内容树是异步建的,后台渲染进程还会被节流;再观测一次通常就完整了,宿主已带退避重试(9.1) |
锁屏后报 screen_locked | 会话是否已解锁 | 锁屏时观测与动作都不可用,应等待用户解锁而不是重试。该错误码曾经不在宿主的合法错误码表里, 会让宿主直接退出并烧掉重启预算(9.3) |
动作报 action_noop | 界面是否真的变了 | 无变化会被记为失败而非成功;应换策略 |
动作报 focus_mismatch | 目标应用是否已切换/关闭 | 策略层唯一的硬拒绝条件(4.2) |
报 action_timeout | 宿主是否被杀、是否会自动重启 | 超时即 SIGKILL 整个宿主;每会话只有 1 次重启预算(9.4) |
| 宿主反复重启后彻底不可用 | supervisor 状态是否 failed | 预算耗尽;只有取消会话才重置(9.4) |
报 native_host_request_capacity_reached | 是否有并发调用堆积 | 上限 64 在飞 + 64 排队(9.3) |
报 environment_unavailable | 环境开关、UIA 可用性、并发状态 | 该码同时用于「环境关闭」「UIA 不可用」「并发未完成(可重试)」 |
报 computer_disabled | ComputerUseSettings.enabled | 默认 true;但该设置无界面,落库值可能异常 |
报 sensitive_input_blocked | 目标是否在 17 项凭据类黑名单里 | 精确 identity 匹配;只作用于主动截图路径(11.4) |
报 decision_model_error | 会话 Provider 是否可用、是否本地 CLI | 本地 CLI Provider 被显式拒绝;失败会带诊断与修复建议 |
报 task_step_limit_exceeded / task_runtime_exceeded | 步数 100 / 时长 20 分钟预算 | 委派路径的默认预算;原子路径宽松得多(1.3) |
| 任务一直跑不完、从不验收 | 判据里是否用了 dom / file / external_readback | 这三种 kind 引擎不支持,永远判失败(8.3) |
| 任务明明完成了却判失败 | 判据文本是否与界面实际文本一致 | visual 判定是字符串 includes;无树证据时才走模型兜底(8.2) |
| 截图里少了元素 / 树被截断 | 树文本预算 | mac 48k / Windows 90k,但 TS 侧只截 48k → Windows 可能丢尾部(10.3) |
| 点不中元素 / 坐标偏移 | 坐标是否按归一化窗口坐标传 | 换算基于「模型看到那张图」的尺寸,可能已被降采样(5.2) |
| 动作数比工具调用数多 | type_text / paste 是否带 into / submit | 会自动追加 focus 与 Enter,一次调用最多 3 个动作(5.2) |
磁盘上还有 .svb 文件 | 下一轮清理(≤ 6 小时) | 且 manual 模式快照永不自动删除(11.2) |
| 截图预览打不开 / 图片裂开 | 令牌是否过期(5 分钟) | 渲染端会自动续签;sessionId 为空的授权只能等 TTL(11.3) |
| 界面里无法彻底关闭电脑操作 | 设置页确实只有授权与诊断 | 那套设置有 schema、有 IPC、无界面(12.1) |
| 暂停后卡片仍显示「进行中」 | 时间线最后一条事件 | 暂停不产生事件;徽标只看末事件(6.3) |
| 暂停后无法从界面继续 | 卡片上没有「继续」按钮 | 需要 Agent 调 MCP resume(12.3) |
| 紧急停止快捷键没反应 | 设置行是否已持久化 | 全新安装不会武装,需要先写入该设置并重启(12.1) |
| 卡片里显示英文错误码 | 对照本表与协议错误码枚举 | 中文映射表只有 7 个键且 3 个键名对不上(12.2) |
| 托盘按钮点了没反应 | 主进程日志 | 托盘失败只写日志、界面无提示(12.2) |
15. 事实边界说明
这一页的每条结论都来自仓库代码。有几处必须交代清楚,避免你把它们当成运行时验证结果:
- 关于「会话权限层的硬闸是否真的会被触发」:代码顺序是可确认的 (自动放行在审批回调之前)。但
canUseTool的具体调用策略属于外部 SDK (@anthropic-ai/claude-agent-sdk)的行为,不在本仓库内。 所以本页的表述是「该硬闸是 fail-closed 兜底网,生产路径存在更早的自动放行」, 而不是「它必然不会被触发」。 - spark 引擎路径未核实:
spark-*权限模式下 「自动放行名单是否先于 approver 生效」的判定在引擎内核里,不在本仓库中。 - Windows 发布证书指纹无法从仓库确认:它是编译期环境变量注入的, 仓库内没有证书或指纹字面量。
- 原生宿主内部是否存在未暴露的超时:Swift / Rust 源码里没有请求级超时,
action_timeout完全由客户端产生;若宿主内部另有超时,本仓库看不到。 - kill switch 与「用户点停止」在时间线上无法区分—— 代码里没有为此写任何标记事件,这是可确认的事实,不是未核实项。
最后一句实话:这套能力的工程完成度很高(双向签名、加密快照、心跳自愈、自动回滚、 有界预算),但它的策略层是「标注」而不是「拦截」, 多个安全字段是声明而非强制。使用前请按 4.2 与 4.3 的实际边界来评估风险, 不要依赖任务契约里的那些未生效字段。
速查表
- 设置入口
- 设置 → 系统 → 电脑操作(项 id computer-use)
- 工具数量
- 27 个 mcp__spark_computer__* 工具
- 默认开关
- enabled=true;environments 只有 myDesktop=true
- 可用环境
- 仅 my_desktop(工具入参是 z.literal)
- 平台支持
- macOS + Windows;Linux 无实现,直接降级为不可用
- 默认请求超时
- 20 秒(上限 180 秒);超时即 SIGKILL 宿主进程
- 帧格式
- 5 字节头(4 字节大端长度 + 1 字节 kind),载荷上限 64 MiB
- 协议版本
- 1
- 心跳
- 每 5 秒 get_capabilities,连续 3 次失败判定不健康
- 重启预算
- 每会话 1 次,仅取消会话时重置
- 并发上限
- 64 在飞 + 64 排队
- 宿主退出码
- 76 = 协议失败,77 = 认证失败
- 委派任务预算
- maxSteps 100 / maxRuntimeMs 20 分钟 / maxConsecutiveNoops 8
- 原子会话预算
- maxSteps 2000 / maxRuntimeMs 12 小时 / maxConsecutiveNoops 20
- 原子会话空闲释放
- 5 分钟(IDLE_RELEASE_MS = 300000)
- 预览令牌 TTL
- 5 分钟(合法区间 1 秒 ~ 60 分钟)
- 快照加密
- AES-256-GCM,密钥存 OS 钥匙串,目录 0700 / 文件 0600
- 快照清理周期
- 启动即跑一次,之后每 6 小时;孤儿文件宽限 24 小时
- 证据帧 TTL
- 24 小时
- 凭据类应用黑名单
- 17 项(仅作用于主动截图路径,精确 identity 匹配)
- 决策树文本上限
- TS 侧 48000 字符(mac 树 48000 / Windows 树 90000)
- 元素树上限
- mac 2000 个元素 / Windows 100000 个元素
- 批量动作范围
- MIN_BATCH_ACTIONS 2 ~ MAX_BATCH_ACTIONS 8
- V2 开关
- 10 个,默认全 true;其中 4 个无任何消费者
- 默认紧急停止键
- CommandOrControl+Shift+Esc(全新安装不会武装)
- 错误码总数
- 32 个(packages/protocol/src/computer-use/errors.ts)
常见问题
展开 6 条常见问题
- 电脑操作需要我手动开启吗?默认是开着的吗?
默认是开启的:设置项 enabled 默认 true,environments 里只有 myDesktop 为 true(safeBrowser / safeDesktop 默认关闭,且工具入参只接受 my_desktop)。你唯一必须手动做的是在「设置 → 系统 → 电脑操作」里完成系统授权——macOS 需要屏幕录制、辅助功能、事件投递三项,Windows 只需运行时探测通过。设置页本身没有任何开关可以关掉这个能力。
- Agent 操作我的电脑时,会抢走我的鼠标和焦点吗?
默认不会。动作优先走无障碍语义(例如元素左键单击会被改写为 invoke_element),鼠标键盘投递是后台优先的,不移动你的物理光标也不抢焦点。你切到别的应用时它会继续操作自己绑定的目标窗口。例外是少数必须前台的操作:Windows 的拖拽与 focus_window 需要先把目标窗口提到前台;macOS 的 move / drag / keypress 也只能走真实事件流。
- 每次点击都会弹审批框让我确认吗?
不会,当前版本没有逐动作审批。设置页原文就写着「授权完成后,Agent 发起的电脑操作任务默认直接执行,不再逐步审批」。仓库里那套审批实现(含票证与过期语义)已标注为废弃、没有接到生产路径上,computer-use:approve-action 这个 IPC 还会直接抛错。动作以 approvalTicketId = null 直接执行。你真正能用的刹车是暂停、接管、停止与紧急停止快捷键。
- 策略引擎会拦截危险动作吗?
不会拦截内容,只做风险分级。ComputerPolicyService.evaluate 在所有分支上都返回 allow,唯一的 deny 是「观测到的应用与动作目标应用不一致」(focus_mismatch)。风险等级 L0–L4 会算出来、写进动作记录并在卡片上展示,但不因此阻止执行——测试里连 L3 高风险动作都是直接放行的。任务契约里的 allowedApps / allowedDomains / forbiddenActions 等字段在生产路径没有任何读取点。
- 截图会保存到磁盘吗?会打码吗?我能控制保留多久吗?
会保存,且是加密保存:blob 落在 userData/snapshot-vault/blobs/ 下,文件名是 24 随机字节的十六进制 + .svb,用 AES-256-GCM 加密,密钥存在操作系统钥匙串里。打码只发生在「观测证据帧」上(涂灰 AX 上报的 secure 区域),capture_app_snapshot 产出的快照完全不打码。保留策略要分清:证据帧有 24 小时 TTL,而 capture_app_snapshot 的快照在无会话时是 manual 模式、永不自动删除,且整体没有任何磁盘配额或 LRU。设置里那几个看起来像保留策略的字段(evidenceRetentionDays、redactSensitiveContent、fullRecordingEnabled)除默认值外没有任何生产读取点。
- 紧急停止快捷键默认生效吗?
全新安装上不会。快捷键注册器确实是真实注入的 Electron globalShortcut,但武装(arm)只在启动时读取持久化设置行、或调用 computer-use:update-settings 时发生;而渲染端从不调用那个通道,所以设置行根本不存在,启动时 safeParse 失败就直接 return。想启用需要先让那一行设置被真正写入,然后重启。一旦武装,它的行为是取消所有活跃会话,且被它取消的会话不能 resume——只能重新开始。