# DSH Remote Protocol v1 2026-10-25 实现边界:Host 插件、随包 Remote 客户端和 Android 已移除 Harness `0.1.1-rc.2` ApiProxy 兼容。请使用提供官方 Typert Remote carrier 的 Host;仅提供 ApiProxy 或不支持能力发现的旧 Host 会被拒绝,不再降级。CodeX/ACP 保留独立数据面,Desktop 内存投影统一使用 Typert。本轮不改独立 VS Code Client 或独立 Remote Web 仓库。 `harness.api.*` 方法/事件/capability 的以下定义仅保留为历史线协议与旧 Client 类型契约;当前 Host 不注册这些方法,调用返回 `METHOD_NOT_FOUND`。 状态:Draft v0.2(首版发布前,不保留旧业务 RPC 兼容) 日期:2026-09-10 协议版本:`1` 实现状态:**当前仓库实现 Client/Plugin 侧协议与最小单账号 Relay Server;完整 Server 由独立项目实现** ### Claude ACP Host 实验性接入(Issue #125) `claude` 是独立 ACP backend ID,默认关闭,只使用现有加密 `agent.acp.*` 数据面及固定方法 allowlist;`initialize`、`session/new`、`session/prompt` 的 backend 可显式选择 `claude`。不得按命令名称猜测后端或在其不可用时回退到 Cursor。Host 使用本机预授权的 `claude-agent-acp` stdio adapter,不发送 Cursor `authenticate(cursor_login)`。权限响应仍仅允许现有单次允许/拒绝契约。Host 通过固定 `dsh/workspaceList` / `dsh/sessionList` 使用上游 ACP `session/list` 的分页目录与 Session authority;只接受 Host realpath 验证的真实 cwd。Claude `session/load` 必须显式指定 backend,并由 Host catalog 或本机已创建的 Session 确认 cwd,不能接受 Client 自报路径;活动回复期间禁止重新 load,独立 history 读取不受此限制。`dsh/sessionHistory` 可使用 `claude:`,前缀必须匹配 backend;历史只经独立临时 ACP process 的 `session/load` replay 获取,不读本机 transcript 文件,最多 8 MiB / 10,000 更新,超限显式失败,读取失败不得返回成功的部分历史或伪造空历史。保留最多 30 个 live/loading Session,以及一个临时 history process;重复 load 不重放污染 live stream。`session/cancel` 使用标准无请求 ID 的 notification;Gateway 同步撤销该会话待审批句柄并隔离迟到权限请求,其他会话/后端不受影响,最终完成状态由上游 `cancelled` 确认,不伪造停止成功。Claude Desktop/Android 使用 `claude:cwd:` 工作区与 `claude:` 会话前缀,仅作客户端内存投影。模型目录与切换复用下述固定 `dsh/sessionModels` / `dsh/selectModel`,不开放 Claude 图片、文件或终端扩展。恢复会话发送 Prompt 前,Host 必须使用可信 cwd 加载;准备期间取消不启动迟到回复,也不清除后续重试。加密跨设备及原生设备验收仍待完成。 ## 0. 文档地位与仓库边界(规范性) 本文必须保留,是 Host Plugin、所有 Remote Client、共享协议包和外部 Server 的唯一线协议契约。 当前仓库负责: - `packages/protocol` 的类型、schema、编解码和版本校验 - Plugin 的v0.1.2 alpha.1–rc.1 与 v0.1.5 rc.1 Session V3 Typert Remote tunnel、加密、重连和 capability 行为 - Mock Host/Client 与协议 conformance fixtures `apps/server` 实现本文的单账号注册/刷新、设备发现、Control 与 Relay 子集;范围见 [最小 Server](../apps/server/README.md)。其余多账号、主机匹配码、Browser 授权、WebRTC/TURN 与 Admin 能力仍属于独立 Server,不得把未实现的能力声明为可用。 任何与本文不一致的示例代码都视为未完成实现,不能反向修改协议语义。当前尚未发布, 旧 Android 业务 RPC 明确不兼容;首版发布后破坏兼容性的变更必须提升协议版本。 ## 1. 范围 DSH Remote Protocol 定义 Host Plugin、DSH Remote Server 和 Remote Client 之间的互操作边界,包括: - 设备注册和 Server credential - 账号授权与 Host/Client membership - Host 账号密码或一次性主机匹配码接入 - Remote Web 授权换取 Browser Launcher 独立设备凭证 - WebSocket authentication - WebRTC signaling 与 Relay routing - Host/Client 端到端安全通道 - 历史 rc.2 ApiProxy 线协议定义(当前 Host / Android 已移除) - v0.1.2 alpha.1–rc.1 与 v0.1.5 rc.1 Session V3 Typert Remote 的 unary、stream 与 `$events` 双向事件 carrier - reconnect 与原生 stream 重建 - capability 与版本协商 - 错误码、限制和安全不变量 Admin API 不属于 E2EE Remote Protocol。它是同站点的独立 HTTPS API,定义在 [server.md](server.md)。 ## 2. 规范术语 本文的“必须”“禁止”“应该”“可以”是规范性要求。 - **Host**:运行 DeepSeek Harness 与 `@dsh-remote/plugin` 的设备。 - **Client**:通过 Remote transport 控制 Host 的 Desktop Harness、VS Code、Android 或 Remote Web; Browser Launcher 只注册 `role=client` device 以发现 Host,本身不是 Remote Client runtime。 - **Server**:负责账号授权、presence、signaling 和 opaque Relay 的协调服务。 - **Device**:具有随机 deviceId 和本地 identity key 的 Host 或 Client。 - **Membership**:Server 根据相同账号归属自动派生的 Host/Client 授权边。 - **Connection**:一个已授权 Host/Client pair 的临时通信实例。 - **Control frame**:Server 可读的 WebSocket signaling/routing JSON。 - **Remote message**:安全通道内的 RPC/Event 业务消息,Server 不可读。 - **Secure channel**:Host 与 Client 之间基于 Noise 的端到端加密会话。 ## 3. 分层 ```text HTTPS REST account login / Web-to-Browser authorization / device token / device metadata / TURN WSS Control Channel hello / connect / signaling / secure handshake relay / opaque relay Secure Channel Noise transport ciphertext Remote Protocol RPC request / response / error / event Harness Business Tunnel v0.1.2 Typert Remote call / stream / events ``` 业务层禁止直接调用 WebSocket、RTCPeerConnection 或 Server REST;必须通过 `RemoteTransport` 与 client/plugin core。 ## 4. 编码与基础类型 ### 4.1 JSON REST、Control frame 和解密后的 Remote message 使用 UTF-8 JSON。发送端禁止输出 `NaN`、`Infinity`、负零、稀疏数组或非 JSON 类型。 接收端必须: - 拒绝无效 UTF-8/JSON。 - 拒绝重复安全关键字段的非规范解析结果。 - 对未识别的必需 enum 值 fail closed。 - 不把原始 parser 错误直接展示给用户。 共享 Control 和解密消息解码器拒绝同一对象内的重复字段。转义后相同的字段名也视为重复。 原始字节必须是有效 UTF-8。REST 入口尚未接入此检查。 ### 4.2 标识符 - `deviceId`:UUID 或 ULID 字符串。新实现应该生成 UUIDv7 或 ULID。 - 接收端必须接受已有 Client 生成的 UUIDv4 `deviceId`。 - `membershipId`, `connectionId`, `message.id`:UUIDv7 或 ULID 字符串。 - `sessionId`:Harness 原生 SessionId,不由 Server 改写。 - `requestId`:引用发起 RPC 的 Remote message `id`。 - 内层 `rpcId`:Harness rc.2 ApiProxy 原生 request/response correlation id,Plugin 不改写;alpha 的关联由官方 Gateway carrier 负责。 ID 只能用于定位,不能单独作为授权凭据。 ### 4.3 时间 所有 JSON 时间使用 Unix epoch milliseconds,字段名以 `At` 结尾。TTL 同时返回绝对 `expiresAt`;Client 不应仅依赖本机倒计时做安全判断。 ### 4.4 Binary/Base64URL REST/Control JSON 中的 key、nonce、handshake 和 ciphertext 使用无 padding Base64URL。WebRTC DataChannel 可直接发送 binary Noise transport frame,不再 Base64 编码。 ## 5. 版本规则 顶层协议版本字段为: ```json { "v": 1 } ``` 规则: - Major protocol 只有整数版本。 - 接收端不支持 `v` 时返回 `UNSUPPORTED_VERSION` 并关闭连接。 - v1 可以新增 optional 字段和 capability;不能改变现有字段语义或 enum 含义。 - 新增 RPC/Event 必须由 capability 宣告。 - 未协商的 method/event 不能发送。 - hello 的 `protocols` 是无重复版本集合。Server 选择双方支持的最高版本。 - 没有共同版本时,Server 返回 `UNSUPPORTED_VERSION` 并关闭连接。 ## 6. REST 通用格式 成功响应直接返回 endpoint schema。失败响应统一为: ```json { "error": { "code": "HOST_REGISTRATION_CODE_EXPIRED", "message": "The host registration code has expired.", "requestId": "01K...", "retryable": false, "details": {} } } ``` `details` 可省略,只能包含不泄露 secret/内部栈的结构化信息。 分页响应: ```json { "items": [], "nextCursor": null } ``` ## 7. Device Descriptor ```json { "deviceId": "01K...", "name": "Workstation", "role": "host", "platform": "linux", "identityKey": "base64url-x25519-public-key", "clientVersion": "0.1.0" } ``` 规则: - `role` 仅为 `host` 或 `client`。 - `deviceId` 必须是 ULID 或 UUID。新设备应该使用 UUIDv7 或 ULID。 - `name` 必须包含可见文本。UTF-8 编码后不得超过 128 字节。 - `platform` 必须匹配 `[a-z0-9][a-z0-9._-]*`。长度不得超过 64 字节。 - `identityKey` 是 Noise static X25519 public key。 - `identityKey` 必须是 32 字节公钥的规范无 padding Base64URL。编码长度必须是 43 个字符。 - `clientVersion` 和 `harnessVersion` 的 UTF-8 编码长度必须是 1 到 64 字节。 - Host 可在注册时携带 `harnessVersion`,Server 必须接受;Host 也会从 Harness `host.describe` 读取运行中版本并在首次 `hello` 中刷新上报。 - Client descriptor 禁止携带 `harnessVersion`。 - `name` 是不可信显示字符串,限制长度并转义。 - Server 禁止接受同一 deviceId 替换为不同 identityKey。 - Descriptor 和注册 envelope 禁止包含未定义字段。 ## 8. 设备注册与 Token 面向插件实现的端到端接入步骤、错误处理和本地存储要求另见 [Host 插件接入指南](plugin-integration.md)。 ### 8.1 注册 `POST /api/v1/devices/register` Host 和 Client 的首次账号归属必须携带由同一 Server 签发的站点账号 Bearer token; 同一安装的相反角色可按 §8.1.2 从已有 device credential 继承 owner。插件默认连接 `https://dsh.r2049.cn`,也可以由用户配置自定义 Server;登录、注册、refresh、 WebSocket 不得跨域混用。注册成功后,Server 为同一账号下的 Host 与 Client 自动 建立或恢复 membership。 请求: ```json { "v": 1, "device": { "deviceId": "01K...", "name": "Chrome on Pixel", "role": "client", "platform": "web", "identityKey": "...", "clientVersion": "0.1.0" } } ``` 响应: ```json { "accessToken": "opaque-or-jwt", "accessTokenExpiresAt": 1786000000000, "refreshToken": "opaque-secret", "refreshTokenExpiresAt": 1789000000000 } ``` Access token 和 refresh token 的 UTF-8 编码长度必须是 16 到 8192 字节。 两个到期时间必须是正安全整数。 注册是账号授权的 bootstrap。Client 注册完成后可列出并连接同账号的 Host;不同 账号之间不建立 membership,也不能读取设备详情或 presence。 ### 8.1.1 主机匹配码注册 除账号 Bearer token 外,Host 插件也可以使用账号网页生成的一次性主机匹配码。 该码只用于授权一个 Host 加入账号,不参与 Host/Client 连接。 账号生成匹配码: ```http POST /api/v1/account/host-registration-codes Authorization: Bearer ``` 响应: ```json { "registrationId": "01K...", "code": "ABCD-EFGH", "expiresAt": 1786000000000 } ``` 插件注册 Host: ```http POST /api/v1/devices/register-with-code ``` 请求包含 `{ "v": 1, "code": "ABCD-EFGH", "device": }`, 成功响应与 §8.1 相同。主机匹配码 10 分钟过期、单次消费、只允许 `role=host`, Server 使用独立用途的 keyed hash 落库。 匹配码请求必须使用大写 `XXXX-XXXX` 格式。每个 `X` 只能是 ASCII 字母或数字。 ### 8.1.2 自有设备角色切换 同一 Plugin 安装已持有有效 Host 或 Client device credential 时,可以用该设备 access token 为本机注册相反角色,无需再次登录账号或输入主机匹配码: ```http POST /api/v1/devices/register-owned-role Authorization: Bearer ``` 请求 body 与 §8.1 相同,但 descriptor 必须使用新的 deviceId、独立 identity key 和与 当前设备相反的 role。Server 从当前设备继承 `owner_account`,拒绝无 owner、相同 deviceId 或相同 role,并为新角色签发独立 token pair、同步同账号 membership。 角色切换只复用账号归属,不得复用 Host/Client 私钥或 device token。 ### 8.2 Refresh `POST /api/v1/auth/refresh` ```json { "deviceId": "01K...", "refreshToken": "opaque-secret" } ``` Server 必须轮换 refresh token。完整多账号 Server 对未再次轮换、未撤销且未过期的后继 token 提供幂等恢复,不使用固定两分钟窗口;过时 token 被拒绝但不撤销当前有效凭据。恢复数据异常或数据库繁忙返回可重试 503。自部署 Server 的策略以其实现为准。Client 必须原子替换本地 token;不能在日志或 URL 中传 token。 HTTP `401/403` 的空响应、非 JSON 响应或不符合协议的错误体应保留凭据并退避重试,不单凭状态码判定设备授权失效。完整 Server 握手时的授权快照竞争使用 WebSocket `1013`(稍后重试),Client 保留凭据重连,不消耗授权刷新恢复次数。 成功响应必须使用 §8.1 的 token pair schema。请求和响应禁止包含未定义字段。 ### 8.3 Remote Web 授权换取 Browser Launcher 凭证 Browser Launcher 不实现账号、密码或 OAuth 登录。用户已在 Remote Web 登录时,扩展从同源 页面临时读取当前 web token,并立即用它和本地产生的独立设备 identity 换取自己的 device token pair: ```http POST /api/v1/auth/browser-authorizations/exchange Authorization: Bearer Content-Type: application/json ``` ```json { "v": 1, "device": { "deviceId": "01KBROWSER...", "name": "Browser on macOS", "role": "client", "platform": "browser", "identityKey": "base64url-x25519-public-key", "clientVersion": "0.3.29" } } ``` 响应为 §8.1 的 device token pair,并额外返回非空 `account`。约束: - `account` 的 UTF-8 编码长度必须是 1 到 254 字节。 - exchange 只接受有效的 `typ=web` Bearer,并且只能注册 `role=client, platform=browser`;设备 descriptor、账号归属、membership 和 token 签发规则与 §8.1 相同。 - 扩展不得把 web token 写入 `chrome.storage`、日志、URL 或长期内存;exchange 完成后只保存 自己的 Browser device credential。 - 浏览器扩展只负责 Host presence 展示和打开 Remote Web,不实现 Remote transport、ApiProxy 或会话 UI。点击在线 Host 后直接打开同源 `/app/remote/{hostDeviceId}`,复用浏览器现有的 Web 登录状态;任何 token 都不得进入 URL。 ## 9. 账号授权协议 账号归属是访问边界。每次 Host 或 Client 注册时,Server 为该设备与同账号下所有 未撤销的异角色设备建立 membership;后注册的 Host 或 Client 必须得到相同结果。 已撤销授权边在同账号设备重新注册时恢复,因为账号状态是权威来源。 规范要求: - Host 与 Client 的 `owner_account` 必须非空且完全一致。 - 不允许匿名 Client 注册,也没有设备码创建、领取、确认或轮询接口。 - `GET /api/v1/devices` 只返回当前 Client 通过同账号授权边可访问的 Host。 - 设备详情、presence、TURN 和连接请求继续校验 membership,防止 IDOR。 - Server 发出 `connect.incoming` 前再次校验双方账号一致,历史跨账号授权边无效。 - Host 只接受 `authorization: "account"` 的连接事件,并使用自己的 device token 调用 `GET /api/v1/devices/{clientDeviceId}`;只有返回的 membership、role 和 `identityKey` 与事件完全一致,且不冲突于本机 pinned key,才把该 key 绑定到连接。 - 注销账号会清除 Client 设备 token;重新登录同账号可重新注册设备并恢复访问。 ## 10. Control Channel ### 10.1 WebSocket URL:`wss:///ws/v1/connect` 连接后 5 秒内必须发送 `hello`。在 `hello.ack` 前,除 hello 外的 frame 均拒绝。 ### 10.2 Control frame envelope ```json { "v": 1, "id": "01K...", "type": "hello", "timestamp": 1786000000000, "payload": {} } ``` Control frame 是 Server 可读 JSON,不得放置 Remote 业务明文。 Control frame 必须使用 WebSocket text message。接收端必须拒绝 binary message。 非 Relay Control frame 的 UTF-8 JSON 上限为 64 KiB。 Relay Control frame 的 UTF-8 JSON 上限为 1 MiB。 ### 10.3 Hello ```json { "v": 1, "id": "01KMSG...", "type": "hello", "timestamp": 1786000000000, "payload": { "role": "host", "deviceId": "01KHOST...", "accessToken": "...", "protocols": [1], "clientVersion": "0.2.9", "harnessVersion": "0.1.0-rc.8", "capabilities": ["transport.p2p", "transport.turn", "transport.relay"] } } ``` `clientVersion` 是插件/Client 软件的版本,与 Device Descriptor §7 的 `clientVersion` 同源,用于 Server 展示设备版本和诊断;与 `hello.ack` 的 `serverVersion` 对称。插件建立连接时必须上报自己的 版本;对 Server 而言这是 v1 新增的 optional 字段,不能因为缺失或未知版本而拒绝连接。 `protocols` 必须包含至少一个非负安全整数,且不能有重复值。当前实现只支持 v1。 `capabilities` 必须是无重复的非空字符串集合。接收端必须忽略未知 capability。 发送端只能使用双方都支持的 capability。 Host 的 `harnessVersion` 优先来自本机 Harness `host.describe.version`;旧 Harness 返回已知 占位值或不提供该方法时,从当前 `@deepseek-ai/dsh` 运行包读取版本。Host 注册 descriptor 在可用时携带该值,首次 `hello` 也会再次上报以刷新设备记录。字段同样可选:插件不发送时 Server 必须保留已有值,不能清空或拒绝连接;Client 不发送该字段。 ack: ```json { "v": 1, "id": "01K...", "type": "hello.ack", "timestamp": 1786000000000, "payload": { "protocol": 1, "serverVersion": "0.1.0", "connectionSessionId": "01KWS...", "heartbeatIntervalMs": 25000, "maxControlFrameBytes": 65536, "maxRelayFrameBytes": 1048576, "capabilities": ["transport.relay", "transport.p2p"], "webrtcEnabled": true, "webrtcFallbackTimeoutMs": 12000 } } ``` WebSocket 关闭后 access token 不能通过 URL/query 泄露。Server 日志必须过滤 hello payload 中的 token。 `hello.ack.capabilities` 是 Client hello 与 Server 支持能力的交集,不是 Server 的完整能力列表。 Server 不能返回 Client 未宣告的 capability。Client 后续只能使用该集合中的能力。 该字段对旧 Server 兼容为 optional。字段缺失时,Client 只能使用 `transport.relay`。 `maxControlFrameBytes` 和 `maxRelayFrameBytes` 可以声明更小的上限。 双方必须对后续收发 frame 使用该上限。大于 v1 默认值的声明必须拒绝。 `heartbeatIntervalMs` 必须是正安全整数。 `webrtcEnabled` 是可选实现扩展。值为 `false` 时,Client 只能使用 Relay。 字段缺失时,Client 可以协商 WebRTC。Client 仍必须遵守 `hello.ack.capabilities`。 `webrtcFallbackTimeoutMs` 是可选实现扩展。它必须是正安全整数。 字段缺失时,Client 使用本地超时。字段存在时,Client 使用 Server 与本地值中的较大值。 Host 的 WebRTC responder 同样取 Server 与本地等待时间中的较大值,避免在 Client 仍在协商时提前结束。 连接启动的超时按阶段独立计算:Control 授权等待在合法 `connect.accepted` 后结束, 不覆盖之后的 ICE 配置获取、WebRTC 协商或 Noise 握手。ICE 配置获取必须有界; WebRTC 使用上述协商超时,失败时按已协商的 transport 能力回退。选定传输后才开始 Noise 握手计时,认证完成前禁止发送业务消息。取消或断线必须终止当前启动过程, 不得由迟到的凭据、信令或握手恢复已结束的连接。该拆分不增加 frame、字段或 capability。 ## 11. 建立 Host/Client Connection Client control frame: ```json { "v": 1, "id": "01K...", "type": "connect.request", "timestamp": 1786000000000, "payload": { "hostDeviceId": "01KHOST...", "preferredTransports": ["lan", "p2p", "turn", "relay"] } } ``` Server 校验 membership、双方账号归属一致且 Host online 后创建 `connectionId`,向 Host 发送: ```json { "v": 1, "id": "01K...", "type": "connect.incoming", "timestamp": 1786000000000, "payload": { "connectionId": "01KCONN...", "clientDeviceId": "01KCLIENT...", "clientIdentityKey": "...", "authorization": "account", "preferredTransports": ["lan", "p2p", "turn", "relay"], "relayEndpoint": "wss://relay.example/ws", "relayTicket": "dshrt..", "relayNodeId": "relay-bj-1", "relayEpoch": 7 } } ``` 当双方声明 `transport.relay.attach` 且 Server 选择独立 Relay 时,`connect.accepted` 同样 携带 `relayEndpoint`、节点绑定的短期 `relayTicket`、`relayNodeId` 和 `relayEpoch`。Host 和 Client 必须分别使用自己收到的 ticket 连接该 endpoint;ticket 不能发送到 Control WebSocket 或复用到另一条 connection。缺少这些字段时,双方继续使用旧的 Control WebSocket Relay 兼容路径。 WebRTC DataChannel 建立后,Initiator 必须先发送 `transport.selected`,再开始 Noise 握手。Host 必须按同一 Control WebSocket 上的消息顺序,在创建 authenticated channel 前应用该 选择;不得忽略该帧并仅依赖本机异步 RTC ready 回调,否则两端可能分别把同一条 Noise channel 绑定到 WebRTC 与 Relay。Noise channel 建立后,迟到或冲突的 `transport.selected` 不得切换 已有连接的数据面。Server 必须按 `connectionId` 保持 `transport.selected` 与 `secure.handshake` 的转发顺序;Host 仍必须容忍二者被乱序交付,在 transport 尚为 `negotiating` 时暂存握手,而不能自行猜测为 Relay。 `transport.selected.payload.transport` 的允许值为 `lan | p2p | turn | relay`。`lan` 是一等 线协议值,双方声明 `transport.lan` capability 时必须端到端保留;Server 仅可为未声明该 capability、仍只接受 `p2p | turn | relay` 的旧 RC2 Host 将 `lan` 兼容降级为 `p2p`。 Host 必须要求 `authorization` 为 `account`,校验 identityKey 格式,并通过受 membership 保护的设备详情接口确认 Client descriptor。本机已有相同 deviceId 但公钥不同则必须拒绝; 验证通过后写入/更新本机 pinned peer,并把该 key 与 `connectionId` 绑定,才可返回 `connect.accepted`。安全握手期间不允许替换远端 key。 同一 Host 必须允许不同 `clientDeviceId` 各自建立并保持独立的 active connection,例如手机 Web 与电脑 Web 可同时连接。RPC pending 数、stream namespace、stream 上限与断开清理均按 `connectionId` 隔离。仅同一个 `clientDeviceId` 的新连接替换该设备的旧连接,不得关闭其他 Client 的 connection 或原生流。 Server 使用现有 `error` control frame 的可选 `payload.connectionId` 通知单条逻辑连接 断开。字段存在时 Host 只关闭该 connection 的 Noise/RTC、RPC router 和 stream,不得把错误 提升为整个 Control WebSocket 的终止状态;字段不存在时保持原有 Control/操作级错误语义。 这是兼容扩展,旧插件可以忽略未知字段。Server 对 `hello.clientVersion < 0.2.13`、缺失或 无效版本保持 last-client-wins,避免把多个 Client fan-in 到旧插件的单例连接状态。 ## 12. Secure Channel ### 12.1 算法 账号授权连接使用成熟 Noise 实现: ```text Noise_IK_25519_ChaChaPoly_SHA256 ``` - Initiator:Client。 - Responder:Host。 - 双方 static X25519 key 来自账号授权的设备注册与 `connect.incoming` 事件。 - Prologue 绑定:`DSH-REMOTE`, protocol v1, connectionId, Host deviceId, Client deviceId。 - 禁止自行实现 Noise state machine 或修改算法组合。 Client 从同账号设备详情获取 Host identity key;Host 将账号授权的连接事件与同一设备 详情交叉校验后获取 Client identity key。双方必须把这些 key 固定到本机 trust store, 并绑定到 Noise 握手和 connectionId。 ### 12.2 Handshake relay Noise handshake bytes 通过 Control frame 转发: ```json { "v": 1, "id": "01K...", "type": "secure.handshake", "timestamp": 1786000000000, "payload": { "connectionId": "01KCONN...", "targetDeviceId": "01KHOST...", "step": 1, "data": "base64url-noise-handshake" } } ``` Server 只校验 connection ownership、step 上限和 frame size,不解析 Noise payload。 `step` 只允许 `1` 或 `2`。`data` 必须是规范的无 padding Base64URL。 ### 12.3 Transport frames Noise handshake 完成后,Remote message JSON 作为 Noise transport plaintext。独立 Relay 时 Noise ciphertext 放入 data WebSocket 的 `relay` frame;没有 attach 能力的旧客户端继续放入 `relay` control frame;WebRTC 时直接发送 binary ciphertext。 Noise transport 单消息最大为 65,535 bytes(包含 AEAD tag)。编码后的 Remote message 超过 48 KiB 时,发送方必须先切成 DSH secure fragments,再逐片 Noise 加密。fragment 使用 binary plaintext:`DSHF` magic、1-byte version、32-bit messageId、16-bit index、 16-bit total、32-bit totalBytes 和最多 48 KiB payload。接收方只在同一 authenticated channel 内按 messageId 重组,最多接受 4 MiB 的完整消息和 8 个并行重组;重复、乱序、 长度不一致或超限必须关闭 secure channel。小消息继续直接使用 Remote message JSON, 保持兼容和低开销。 每方向维护独立 nonce/counter。重复、过旧、认证失败、超限或连接不匹配 frame 必须关闭 secure channel。达到 Noise 实现建议的消息/字节阈值时 rekey 或重建 connection。 `fixtures/crypto/v1/noise-ik.json` 固定双方 static key、ephemeral private key、prologue、握手消息、 传输密文和 counter。其他实现必须产生相同的无填充 base64url 输出。 这些输出是 protocol v1 的 compatibility baseline。实现或依赖升级不得直接重写该向量。 有意改变 wire output 时必须发布新协议版本和新向量。 TLS/WSS 保护到 Server 的链路,但不能替代本节 E2EE。 ## 13. Relay frame ```json { "v": 1, "id": "01K...", "type": "relay", "timestamp": 1786000000000, "payload": { "connectionId": "01KCONN...", "targetDeviceId": "01KHOST...", "counter": 42, "ciphertext": "base64url-noise-transport-message" } } ``` `counter` 必须是 `0..Number.MAX_SAFE_INTEGER` 范围内的整数。它用于 Server 基础限速和排序诊断, 不作为解密 nonce 的权威来源。Noise frame 内部状态才是认证依据。 `ciphertext` 必须是规范的无 padding Base64URL。 Server 禁止解密、解析、缓存到数据库或记录 `ciphertext`。 独立 Relay data WebSocket 使用同样的 v1 envelope,但连接建立后的第一帧必须是: ```json { "v": 1, "id": "01K...", "type": "relay.attach", "timestamp": 1786000000000, "payload": { "connectionId": "01KCONN...", "deviceId": "01KDEVICE...", "peerDeviceId": "01KPEER...", "ticket": "dshrt.." } } ``` Relay node 验证 ticket 的签名、`aud`、`nodeId`、`connectionId`、设备对、`epoch` 和有效期, 并确保每个设备端在同一 connection 只有一个 active attach。成功后返回 `relay.ready`;之后 只接受目标匹配的 `relay` frame 和心跳。任一端断开时,另一端收到 `relay.detach`,必须关闭 当前 Noise connection 并重新建立完整的 connection,不能继续复用旧的 Noise 状态。 ## 14. WebRTC Signaling ### Offer ```json { "v": 1, "id": "01K...", "type": "signal.offer", "timestamp": 1786000000000, "payload": { "connectionId": "01KCONN...", "targetDeviceId": "01KHOST...", "sdp": "..." } } ``` ### Answer `type: signal.answer`,payload 同上并携带 answer SDP。 ### ICE ```json { "v": 1, "id": "01K...", "type": "signal.ice", "timestamp": 1786000000000, "payload": { "connectionId": "01KCONN...", "targetDeviceId": "01KHOST...", "candidate": { "candidate": "...", "sdpMid": "0", "sdpMLineIndex": 0 } } } ``` DataChannel 名称:`dsh`,`ordered: true`。即使 WebRTC 已加密,Remote message 仍通过 Noise secure channel,保持 Relay/P2P 相同的应用安全边界。 连接降级顺序:LAN -> P2P -> TURN -> Relay。WebRTC 候选使用 RFC 1918、链路本地或回环地址时 可记录为 LAN;Tailscale `fd7a:115c:a1e0::/48`、`100.64.0.0/10` 等 overlay/CGNAT 地址即使 以 `host` candidate 出现也必须记录为 P2P。切换 transport 不改变 secure channel peer identity;必要时重建 Noise connection 并执行 event resync。 ## 15. Remote Message Envelope 以下消息仅存在于 secure channel plaintext 中: ```json { "v": 1, "id": "01KMSG...", "type": "rpc.request", "timestamp": 1786000000000, "payload": {} } ``` `type` v1 枚举: - `rpc.request` - `rpc.response` - `rpc.error` - `event` - `ping` - `pong` Control-only 类型(hello, signaling, relay)禁止出现在 secure channel 业务层。 ## 16. RPC Plugin Host 的 Harness 业务路由只接受 capability 对应的官方 Typert Remote tunnel。已移除的历史 rc.2 路由包括:`harness.api.call`、 `harness.api.transfer.open`、`harness.api.transfer.chunk`、`harness.api.transfer.commit`、 `harness.api.transfer.read`、`harness.api.transfer.close`、`harness.api.respond`、 `harness.api.stream.open`、`harness.api.stream.close`。当前 Host 仅使用: `harness.remote.call`、`harness.remote.transfer.open`、`harness.remote.transfer.chunk`、 `harness.remote.transfer.commit`、`harness.remote.transfer.read`、`harness.remote.transfer.close`、 `harness.remote.stream.open`、`harness.remote.stream.close`。所有新旧 Host 都可接受只读的 `harness.transport.describe` capability 探测;旧 Host 对此返回 `METHOD_NOT_FOUND`。 文件预览使用 `fileviewer.read.v1` capability 下的 `fileviewer.call`。可选 Codex 领域仍属于同一个 Remote Plugin, 但在线上仍使用独立的 `codex.appserver.v1` / `codex.appserver.transfer.v1` capability、 `codex.app.*` RPC 和 event namespace;只有认证 Client Plugin 内的临时展示 target 可以把它包装成 原生 Harness Session carrier,Android Client 则只能在本机内存中投影到已有移动端状态。Host、Server 与 Android 都不得伪造或持久化第二份 Harness/CodeX Session。 工作区根目录写入只通过独立 `workspace.upload.v1` 的四个固定 RPC, 不得将其加入 Harness / CodeX / ACP 的任意方法透传。参见 [Workspace upload](#workspace-upload)。 旧 `system.info`、`workspace.get`、`sessions.*`、`session.*`、 `permissions.respond`、`connection.ping` 与 `sync.from` 已退出 Plugin 协议,Host 必须返回 `METHOD_NOT_FOUND`。Android 旧原型不是兼容目标。 ### 16.1 Request ```json { "v": 1, "id": "01KREQUEST...", "type": "rpc.request", "timestamp": 1786000000000, "payload": { "method": "harness.api.call", "params": { "method": "session.list", "rpcId": "native-rpc-id", "payload": {} } } } ``` ### 16.2 Response ```json { "v": 1, "id": "01KRESPONSE...", "type": "rpc.response", "timestamp": 1786000000100, "payload": { "requestId": "01KREQUEST...", "result": {} } } ``` ### 16.3 Error ```json { "v": 1, "id": "01KERROR...", "type": "rpc.error", "timestamp": 1786000000100, "payload": { "requestId": "01KREQUEST...", "code": "SESSION_NOT_FOUND", "message": "The session is no longer available.", "retryable": false, "details": {} } } ``` 每个 request 必须恰好产生一个 response 或 error。事件不是 RPC 完成信号。调用端必须按 requestId 关联并在超时后丢弃晚到 response。 调用端在结果未知时不得盲目重发官方 Harness mutation。rc.2 内层 `rpcId` 保持 Harness 的 原生关联语义;alpha 保持官方 Gateway 的 `{ ok, value|error }` envelope;外层 request id 只关联隧道 response/error。 ### 16.4 plugin.route.call `plugin.route.call`(capability `plugin.route.v1`)把 Host 本机其他 DSH 插件的 HTTP 路由 (当前仅 ds-bot 的 `POST api/bot`)通过认证业务通道转发给远程 Client。Host 与 Client 控制入口只接受 `route` 精确等于编译期固定白名单中的 `api/bot`;其他插件路径、官方 Harness RPC 路径、路径规范化变体及 query 均以 `INVALID_MESSAGE` 拒绝,不进入本机 Connection handler。不得通过该入口绕过 Harness Remote allowlist、Remote 设置限制或 终端归属与输入权校验。`body` 只允许 `{ endpoint, payload? }`,`payload` 为普通 JSON 对象或 `null`(数组与原始值拒绝);额外顶层字段拒绝。Host 在进入任何本机 carrier 前 按 `endpoint` 执行以下编译期固定操作白名单,新增上游操作默认拒绝: - 查询:`state`、`models`、`efforts`、`exchange`、`earlier`、`usage`、`room-progress`、`schedules`。 - Bot 管理:`create-bot`、`duplicate-bot`、`update-bot`、`delete-bot`、`set-main`、`set-flags`。 - 群聊管理:`create-room`、`update-room`、`delete-room`、`room-continue`。 - Agent 会话管理:`add-agent`、`agent-session`、`agent-adopt`、`agent-archive`、`agent-rename`、`remove-agent`。 - 会话交互:`send`、`btw`、`btw-cancel`、`answer-question`、`dismiss-question`、`view-reply`、`approval-answer`、`stop-member-work`。 `view-reply` 转发 DS Bot 0.2.0 / 0.2.1 交互卡片的 `sessionId`、`viewId`、`action`、`values`, 用于选择、确认、表单及 0.2.1 的 `submit` 提交;公式输入通过 `values.inputs` 传递。 待答卡片身份、答案合法性及重复提交由 Host 上 DS Bot 原生 handler 校验;Remote 保留既有认证、消息大小与超时边界,不重放提交。 DS Bot 0.2.1 旁问:`btw { sessionId, text }` 返回卡片 ID,`btw-cancel { sessionId, id }` 只取消该旁问;不调用 Harness interrupt。答案通过 `state.btwCards[botId]` 刷新,旧分段 由 `earlier.btw` 返回,均不投影为 Harness 对话事件或写入本机历史。Android 在普通 `send` 前识别精确的 `/btw` 命令;空问题、群聊或旧 Bot 不支持时保留草稿并拒绝,不降级为普通 Prompt。 `room-continue { roomId }` 由原生 Host 恢复达到接力上限的群聊,Android 依据 `room.paused` 展示继续入口;不在 Client 实现接力计数或重放。所有 mutation 继续遵循原有不自动重试规则。 Android 卡片使用固定 0.2.1 schema 和受限表达式解释器,支持本地计算与 SVG 图表; HTML 仅展示摘要和主机打开提示,不执行 HTML/脚本,不增加网络访问或通用 RPC。 `approval-answer` 只接受 DS Bot 的 `allow`(单次)或 `reject`,不开放 session-wide 审批。 安装/升级、日程增删、凭据和 connector 管理、能力脚本、memory 写入、浏览器控制、诊断 文件及本机偏好设置等操作均不开放;不允许通过 `api/bot` 间接扩大 Harness Remote 权限。 被拒绝的操作返回 `INVALID_MESSAGE`,浏览器兼容入口显示安全的 `remote-plugin/operation-not-allowed` 原生错误封装。 ```json "params": { "route": "api/bot", "body": { "endpoint": "state", "payload": null } } ``` Result 为 `{ "status": number, "json": unknown }`,即本机路由的 HTTP 状态码与解析后的 JSON 响应体。请求与响应序列化后都不得超过 `MAX_SECURE_MESSAGE_BYTES`;单次调用超时 30s。 Host 未注入本机路由服务时不得宣告 `plugin.route.v1`,此时 `plugin.route.call` 返回 `FEATURE_NOT_SUPPORTED`。 Host 优先通过官方 Connection 的 `createSharedFetchHandler('/api')` 调用已注册的本机 Fetch 路由;进入此处的 peer 已通过 Remote channel 的账号、信任与加密校验。此路径无需 HTTP listener 或浏览器 Cookie,适用于 Desktop。仅旧版缺少该 carrier 时使用认证后的 loopback HTTP;不存在可用 carrier 时返回 503,未注册路由保留官方 404。 浏览器兼容入口在 Client bundle 注册时安装,仅拦截同源 `POST api/bot`(含文档代理前缀)。 每次请求先通过本机控制接口读取当前 authority;控制 HTTP 请求必须使用官方 `{ type: "client-request", rpcId, method, payload }`,响应按 `rpcId` 关联。只有明确的本地模式 才调用原始本地路由;远程模式断线、缺少 capability 或当前使用 CodeX/ACP 会话目录时拒绝, 不得回落到本地数据。成功 roster 或会话创建/更新响应发布前,Client 校验响应引用的 Bot 当前/历史 Session、群聊及成员 Session、Agent Session 已存在于官方 Session 目录。 Agent 的 roster ID 不作为 Session ID。目录缺少引用时通过公开 `sessions.refresh` 最多 刷新两次(第一次可能加入创建前已在途的旧 baseline);仍缺少 ID、刷新失败或没有可用 刷新方法时拒绝发布。`phase=ready` 是官方目录单调的初始化状态,不代表本次刷新成功; 不能仅据此发布新 ID。已可解析的 Session 无需额外刷新,无 roster 的 `state.unchanged` 响应不引入新 Session。不创建另一套 Session 实例或写入持久化。以上属于 DS Bot 的 兼容入口,不代表任意插件前端已完成适配。 该兼容入口的拒绝使用 DS Bot 原生的 HTTP 200 + `{ ok: false, error: { code, message, details } }`;这不是调用成功,Client 必须读取 `ok`。断线、Host 缺少路由能力、CodeX/ACP catalog、Host 未注册 `api/bot`(404)、Host carrier 不可用(503)及 Session catalog 未就绪分别返回固定错误码与安全说明,不以统一 HTTP 502 隐藏原因。取消仍拒绝 fetch。 Host 的 Bot/Agent 界面模式仅为页面本地偏好,不影响 `api/bot` 的注册;Host 需在连接的 同一 DSH 实例/profile 中启用 `ds-bot`。 ## 17. Capability Host handshake 的 capability 例子: ```json [ "transport.relay", "harness.remote.v1", "harness.remote.transfer.v1", "harness.remote.stream.transfer.v1", "fileviewer.read.v1", "workspace.upload.v1", "codex.appserver.v1", "codex.appserver.transfer.v1", "agent.acp.v1", "agent.acp.transfer.v1" ] ``` 建立 Noise channel 后,Desktop/Web/Android Client 必须先调用 `harness.transport.describe`,Params 为 空对象,Result 为 `{ "capabilities": string[], "workspaceTypes"?: RemoteWorkspaceTypeDescription[] }`。当前 Host 按实际注入服务动态返回 `harness.remote.v1` 或 `harness.remote.v3`,不得宣告不存在的 carrier。 Typert Host 必须只宣告与本机 Session wire 对应的一项:v0.1.2 使用 `harness.remote.v1`,v0.1.5 rc.1 使用 `harness.remote.v3`;禁止同时宣告两项。旧 Host 返回 `METHOD_NOT_FOUND` 时,当前 Plugin Client 与 Android 必须拒绝连接,不得按版本号推测 ApiProxy 能力。 支持 `remote.history.compact.v1` 的 Host 接受加密 RPC `remote.history.configure`, Params 为严格的 `{ "compact": boolean }`,Result 回显 `{ "compact": boolean }`。 模式默认为 `false`,仅绑定当前已认证连接,断线后重置;不修改 Host 配置或会话原始记录, 不影响其他 Client。移动端全局偏好在每次打开会话前设置,重连重新打开时再次设置。 未声明该能力的旧 Host 不发送此 RPC,也不在移动端做本地精简兜底。 精简作用于 Typert `session/page|history` 与 `session/follow` snapshot、 CodeX 和 ACP 的 `dsh/sessionHistory`,包括既有分块 transfer 路径。保留正文、历史记录顺序、 seq/cursor/throughSeq/hasMore、轮次生命周期及工具关联/错误状态;工具参数和输出转为有界摘要, 省略完整工具展示投影。实时流与审批原样传递,允许的 RPC/权限边界不变。 关闭模式后重新打开会话加载完整历史;CodeX 已有的详情句柄与按需读取继续有效。 此能力只减少 Host → Client 历史载荷,不保证减少后端读取历史成本。 ACP 的 `agent.acp.v1` / `agent.acp.transfer.v1` 及 `agent.acp..v1` 声明表示 Host 已启用且实现该后端协议,不代表 CLI 已初始化完成。ACP 后台初始化不阻塞 Harness Remote 上线;初始化中或启动失败时,`workspaceTypes` 对应项的 `available` 必须为 `false`,数据面调用 返回不可用。关闭的后端不声明能力;明确选择的后端不可用时禁止转发到其他后端。后端就绪后, 再次调用 `harness.transport.describe` 可取得最新可用性,无需重新建立加密连接。 `workspace.upload.v1` 声明独立的当前会话工作区根目录上传能力,固定方法为 `workspace.upload.open|chunk|commit|abort`。Client 必须以加密通道内的 `harness.transport.describe` 结果确认该能力;它不表示任意目录可写,也不扩展原生 附件、图片 transfer 或只读预览 allowlist。协议详见 [Workspace upload](#workspace-upload)。 未声明该能力时不调用上传 RPC;旧 Host 的入口保持禁用。 ### 17.1 工作区类型能力声明(workspaceTypes) 为支持 Web 等客户端动态渲染工作区类型选择器并避免在前端硬编码判断,Host 在 `harness.transport.describe` 响应中提供结构化的工作区类型列表 `workspaceTypes`: ```json { "capabilities": [ "transport.relay", "harness.api.v1", "harness.api.transfer.v1", "codex.appserver.v1", "codex.appserver.transfer.v1" ], "workspaceTypes": [ { "id": "codex", "name": "CodeX", "capability": "codex.appserver.v1", "available": true } ] } ``` #### 概念区分 在 Remote 协议中明确区分以下三层概念: 1. **工作区类型(workspace type)**: - 表示 Host 支持的工作区类型与协议载体形态(例如 CodeX)。 - `id` 使用稳定的小写标识符(如 `codex`);`name` 仅用于展示(如 `CodeX`);`capability` 为关联的底层能力标识(如 `codex.appserver.v1`);`available` 表示当前真实可调用。 - Web 端与客户端通过遍历 `workspaceTypes` 动态渲染工作区类型入口,无需为新类型增加前端分支判断。 - 严禁将项目路径、会话内容、prompt、文件内容或 tool output 放入能力描述响应。 2. **工作区 / 项目(workspace / project)**: - 表示 Host 上的具体项目目录实体(例如 CodeX 的具体 project root 目录,或 Harness 原生 workspace 目录)。 - 具体项目依然通过各工作区领域的数据面接口(例如 `project/list`、`thread/list` 中的 cwd 或 Harness workspace 列表)获取,绝不混入类型声明。 3. **会话(session / thread)**: - 表示在具体工作区/项目上下文中的具体交互会话。 - 例如 CodeX Thread(`codex:`)或 Harness Session ID,由各会话生命周期与消息流管理。 #### 准确性与兼容约束 1. **真实可用性判定**: - 只有真实可调用工作区运行时(如 CodeX App Server)且能力协商完成(`account/read` 成功确认已登录)时,才声明 `available: true`。 - 若服务启动失败、权限不足、未登录、协议不兼容或能力未协商成功,必须返回不可用或直接省略该条目,严禁仅根据本地安装文件存在就声明可用。 2. **双向兼容性**: - 继续保留现有的 `capabilities` 字符串数组;不支持 `workspaceTypes` 的旧客户端仍可通过 `codex.appserver.v1` 工作。 - 新客户端支持读取 `workspaceTypes`,并在未提供时平滑回退;遇到 malformed 数据安全容错,不得崩溃。 - 不修改现有的 Harness Remote、WebRTC、Relay、Noise 加密和 Server 中继协议。 3. **新增工作区类型扩展规范**: - 后续扩展新的工作区类型时,必须配套提供: 1. 对应的 `capability` 标识; 2. `workspaceTypes` 中的声明条目(稳定小写 `id` 与展示名称 `name`); 3. 对应的数据面 RPC / Stream / Event allowlist 策略及通道隔离实现。 Web 接入时,`workspaceTypes` 是现有 `harness.transport.describe` 响应的可选加法字段, 无需修改 Server 中继或握手协议。既有 Harness / CodeX 数据面应直接复用;新客户端优先读取 有效的类型条目及 `available`,旧 Host 未提供该字段时才沿用 `capabilities` 判断。类型列表 不是 Harness 入口的完整替代:当前 Host 单独声明 CodeX 和已启用的 ACP 后端,Harness 入口由 `harness.remote.v1|v3` 能力决定。未知类型可以展示其 `name`, 但只有已有对应 RPC / 数据面处理器时才允许进入,不得据类型声明代理任意方法。 Typert Remote contract 随 Desktop Plugin 发布物升级,新增的可选业务能力保持加法兼容。 rc.2 ApiProxy 已明确移除。当前实现不翻译 rc.2 与 Typert 的完整 Harness 业务模型:本地与远端 carrier 或 Session 代际不一致时,Desktop Client 必须在选择目标、创建 Workspace 或其它 mutation 前 返回 `HARNESS_VERSION_INCOMPATIBLE`。 当前 Plugin Client / Android Harness 兼容矩阵: | Host Plugin / Harness | 业务 carrier | Remote Workspace / Session | File Viewer | 图片分块 | | --- | --- | --- | --- | --- | | `0.3.15–0.3.23` / rc.2 | `harness.api.v1`(历史 capability) | 不支持(已移除) | `0.3.17+` 且 provider 存在时支持 | 不支持 | | `0.3.24–0.3.36` / rc.2 | `harness.api.v1` | 不支持(已移除) | provider 存在时支持 | `harness.api.transfer.v1` | | `0.4.x` / rc.2 | capability 探测返回 `harness.api.v1` | 不支持(已移除) | provider 存在时支持 | `harness.api.transfer.v1` | | `0.4.x` / v0.1.2 alpha.1–rc.1 | capability 探测返回 `harness.remote.v1` | 支持 | provider 存在时支持 | `harness.remote.transfer.v1` | | `0.4.12+` / v0.1.5 rc.1 与 v0.1.6 alpha.1 | capability 探测返回 `harness.remote.v3` | 支持 | provider 存在时支持 | `harness.remote.transfer.v1` | 当前 Plugin Client 与 Android 必须使用加密通道上的 capability 探测,不从 `clientVersion` 推测或降级到旧 ApiProxy。 ## 18. 历史数据结构(已废弃) §18.1–18.6 是冻结 Android 原型的旧投影,已在代码中清除,不是 Plugin Host 可接受或 发出的结构。当前业务结构以官方 Typert Remote contract 为准;rc.2 的以下定义仅作历史记录。 原结构包括:SystemInfo、WorkspaceInfo、SessionSummary(含 status/createdAt/lastSeq)、 Message(含 role/content 数组)、ToolCall(含 callId/input/output)和 PermissionRequest (含 status/expiresAt)。这些类型不再出现在 `packages/protocol` 中。 ## 19. Core RPC Methods 旧 Core RPC(`system.info`、`workspace.get`、`sessions.list`、`sessions.get`、`sessions.create`、 `session.send`、`session.stop`、`permissions.respond`、`connection.ping`、`sync.from`) 均已退出 Plugin 协议,Host 必须拒绝。 ### Historical Native Harness API bridge(已移除) `harness.api.v1` 用于让安装了 Plugin Client face 的本地 Harness 继续使用官方 UI 操作远端 Host。它不是通用反射 RPC;Host 必须以代码内固定 allowlist 校验每个 `method`,未知或禁止方法返回 `METHOD_NOT_ALLOWED`。 #### `harness.api.call` Params: ```json { "method": "session.list", "rpcId": "native-harness-rpc-id", "payload": {} } ``` Result 是 Harness `ApiProxy` 的原生 `RpcResponse`,必须回显内层 `rpcId`。v1 allowlist 仅包括: - session list/search/create/history/models/selectModel/rename/fork/prompt/attachment/updateQueue/cancel - subagent list/history/prompt/interrupt - `host.describe`、`host.listDirectory`(只读目录元数据,用于远程 Workspace 选择器) - workspace list/create/rename/delete/reorder/attach/archive - skill list、agent preset list/select/read - goal create/edit/pause/resume/complete/clear - `commands.list`、`commands.execute`(经官方 Typert gateway 分发,使用 Host 对当前 Agent 的有效注册命令) - LLM provider/model list - Host 设置与模型发现平面:`settings.describe/update/replace/mutate`、`credentials.describe/set/unset`、`llm.discoverModels` `session.attachment` 只转发 Harness 原生的只读查询;Host ApiProxy 必须验证 attachment 已被指定 session 的持久化日志引用后才返回内容。它不能创建、上传、修改或枚举附件。明确禁止 native path open/picker、`settings.openDocument`、目录创建、绕过 File Viewer provider 的文件读取、attachment upload、download 以及任何未列出方法。`host.listDirectory` 只返回单层目录元数据。Host 应优先调用官方 ApiProxy browse capability;当桌面 Harness 只组合 `native` picker 时,Plugin 可在已认证的 Host bridge 内提供等价的只读元数据实现。该兜底必须限制结果数量,只返回目录名、绝对路径、面包屑、Home 路径和 hidden 标志,不得读取文件内容、写入文件系统或扩展为通用文件系统 RPC。若该只读列表能力可用,Bridge 可在 `host.describe` 响应中补充 `canOpenPath: true`,仅用于让 Remote Web 显示目录浏览入口,不代表允许 native picker 或打开路径。`commands.list` 与 `commands.execute` 不是通用方法调用入口:Bridge 必须要求 payload 仅含 `agentId`(`execute` 另含长度受限的 `line` 和空附件数组;当前字段名为 `submittedAttachments: []`,旧 Client 使用 `images: []` 时 Host bridge 会按 Typert descriptor 自动兼容,省略两者时注入空数组),并经官方 Typert gateway 使用 Host 对当前 Agent 解析出的有效命令目录和 handler。命令语法、名称解析、Agent scoped shadowing、参数校验和执行语义均由 Host 命令注册表负责,与本地 Harness UI 一致;未注册命令不会进入 handler。额外字段、非空附件数组、缺失必需参数和超长输入在 Bridge 边界 fail closed。外层 Remote request id 负责安全通道去重,内层 `rpcId` 保持 Harness UI 的原生关联语义。 命令附件字段按 Host 版本选择:v0.1.5 及以上使用 `submittedAttachments`,更早版本使用 `images`;未知版本按旧字段处理。 Host 设置平面允许已认证且通过本地 identity 固定的 Remote peer 配置 Host 当前注册的设置分区,必须满足以下作用域约束,否则在 Bridge 边界 fail closed: - `settings.update/replace/mutate` 的目标命名空间必须存在于 Host 原生 `settings.describe` 实时返回的目录中(未注册或目录不可用即拒绝);`patch`/`section`/`ops` 序列化后不得超过 64 KiB,`ops` 最多 64 条,`path` 最多 8 段且每段不超过 64 字节,`ns` 不超过 128 字节。 - `settings.describe` 返回原生 seam 当前注册的全部命名空间及其已经过原生 secret redaction 的值;Bridge 必须强制把 `hasDocument` 设为 `false`。写入仍可携带 `expectedRevision`,冲突语义由原生 seam 决定。 - `credentials.describe/set/unset` 沿用 Harness 官方的全局 credential-reference 语义。引用名称必须是 POSIX 环境变量形态(`^[A-Za-z_][A-Za-z0-9_]*$`,最长 128 字节),`set` 的 value 最长 8 KiB。credential 值只允许在 `credentials.set` 入站方向出现,不得写入日志、不得出现在任何响应里。 - `llm.discoverModels` 只允许探测 HTTPS 端点(localhost 允许 HTTP),禁止 URL 内嵌凭据和 fragment;`baseURL` 最长 2048 字节,`apiKey` 最长 8 KiB,且仅作草稿探测,Host 不存储、不返回该 key。Bridge 必须把所有探测失败折叠为固定的 `model-discovery-failed` 文案和不含 endpoint/key 的安全 details,不得透传 adapter 错误消息。 - `settings.openDocument`、native path open/picker、目录创建、任意文件访问仍然禁止。设置写入不允许命中实时注册目录以外的命名空间;credential 引用、设置 payload 和模型发现端点必须继续满足上述边界。 #### `harness.api.transfer.*` `harness.api.transfer.v1` 是 `harness.api.call` 的有界分块封装,用于 rc.2 图片 prompt 和 `session.attachment` 响应超过单条 4 MiB secure message 的情况。分块重组后的内容仍必须是 完全相同的 `{ method, rpcId, payload }` 原生 envelope,并再次经过固定 allowlist;它不是新的 Harness 业务协议,也不能绕过 ApiProxy。Client 不直接调用 DeepSeek Files API,API key、图片 预处理、上传与 file id 缓存仍只存在于 Host 的官方 adapter。 - `open`: `{ transferId, totalBytes, totalChunks }`,每个 authenticated connection 最多两个活动输入 transfer。 - `chunk`: `{ transferId, index, data }`,`data` 为 canonical base64,解码后每块最多 512 KiB,必须从 0 开始严格有序且恰好一次。 - `commit`: `{ transferId }`,完整重组并调用原生 ApiProxy;小响应内联返回,大响应返回 `{ kind: "chunked", transferId, totalBytes, totalChunks }`。 - `read`: `{ transferId, index }`,严格顺序读取响应分块。 - `close`: `{ transferId }`,成功、取消或失败后释放输入/输出状态;连接断开时 Host 必须清除该连接的全部 transfer。 单个 transfer 上限 288 MiB(容纳上游默认 200 MiB source image aggregate 的 base64 envelope),空闲 2 分钟过期。任何重复 id、乱序、重放、非 canonical base64、声明长度不符、 超限或跨 connection transfer id 都必须 fail closed。每个 chunk 仍作为独立 Remote RPC 经过 Noise 加密、计数器防重放和 membership/trusted-peer 校验。 #### `harness.api.respond` Params:`{ "message": ClientResponse }`。只用于回答由 Harness 原生事件流发出的 approval/question ServerRequest;`rpcId` 必须由同一 `connectionId` 的 mux stream 实际发出,Client 不能自行创造可回答的 Host request,也不能回答只发送给其他 connection 的请求。 #### `harness.api.stream.open` / `harness.api.stream.close` Open Params: ```json { "streamId": "client-stream-id", "stream": "mux", "rpcId": "native-open-rpc-id", "payload": {} } ``` `stream` 仅允许 `mux | host`,每条 peer connection 最多同时打开三个原生流:常驻的 host/mux 各一条,加一条 mux 切换缓冲。mux 流的 `payload` 可携带可选 `sessionId`(focus):提供后 Host 只转发该 session 的 mux 帧(`session/event`、approval、question 等),其余 session 的流量不进入 tunnel;省略时转发全部。Remote Web 每次只关注一个 session,用 focus 避免把其他活跃 session 的大事件流(可能达数 MB)推过 WebRTC/relay 数据面。切换 session 时 Client 先打开新 mux,成功后立即关闭旧 mux;新流失败时保留旧流。Close Params:`{ "streamId": "client-stream-id" }`。`streamId` namespace、三条流上限和生命周期都属于发起它的 `connectionId`;不同 Client 可使用相同 `streamId`,不得互相关闭或接收对方的 tunnel event。连接替换、撤销或断开时 Host 只取消该 connection 的全部流。 ### Harness Typert Remote bridge(当前基线 0.2.1,兼容 0.2.0 RC) `harness.remote.v1` 只承载 `dsh-v0.1.2-alpha.1`–`rc.1` 官方 `TypertGateway` 已编码的 carrier envelope;`harness.remote.v3` 承载 `dsh-v0.1.5-rc.1` 的相同官方 carrier 与 Session V3 业务 envelope。Plugin 不解析或重建远端 Harness Session、Workspace、Approval、Question 等业务模型,也不 注册第二套 Remote namespace。 两项 capability 复用相同的 `harness.remote.call`、stream 与 transfer RPC。它们的区别是 Session 业务语义:V3 使用 `SessionWireHeader.version = 3` 与 `isSeeded`,replacement surface operation 使用 `{ op: "replace", startSeq, endSeq }`,实时 Assistant 输出使用 `assistant-stream` frame,并要求最终 `assistant/message.data.stream`。v0.1.2 的 replacement 使用 `{ op: "replace", start, end }`。Desktop Client 不在 v0.1.2 与 V3 格式之间翻译,只允许相同代际互连; Android 与 VS Code 可直接消费各自已声明支持的 Host 格式。 #### `harness.remote.call` Params: ```json { "endpoint": "workspace/create", "payload": { "args": { "request": { "path": "/workspace/project" } } } } ``` `workspace/create` 可在 Host 用户权限下递归创建缺失目录,独立于终端开关。Host 在写入前 拒绝空路径、NUL、相对路径与根目录;创建和官方注册必须使用完全相同的路径(保留空格及 符号链接路径语义)。官方注册失败时保留已创建目录,不执行递归清理。此授权只适用于 Harness 工作区创建,不新增通用写入 RPC,不改变 CodeX `project/create` 的已有目录限制。 Result 必须保持官方 Connection RPC 结果:`{ "ok": true, "value"?: ... }` 或 `{ "ok": false, "error": { "code", "message", "details" } }`。Host 通过启动时捕获的本地 Gateway dispatcher 调用,禁止回到已切换的 Client facade 形成递归。 Harness `0.2.1-rc.1` 要求显式 Peer:仅在 ConnectionController 完成账号成员与本地信任认证后, 为每条连接创建官方 owner Peer。该 Peer 只在 Host 内持有,绑定该连接的 RPC、stream、 `$events/result` 与插件 Fetch 路由,断线即 dispose;Client 不得提供 principal/Peer, 不得共享浏览器 Peer 或在重连时复用已销毁 Peer。Remote 固定 allowlist 仍独立生效。 固定 allowlist 包括原生 UI 所需的 Session、Workspace、Commands、Goals、Settings、 Credentials、LLM、Skills、Subagents、Message Feedback、Plugin Inventory、File References、 Session Reference、只读 `directoryPicker/list` 与布尔能力探测 `session/canOpenWorkspacePath` endpoints、只读 `remoteAccess/follow`,以及 Gateway 内部的 `$events` 和 `$events/result`。明确禁止 `directoryPicker/pick`、`directoryPicker/createDirectory`、 `session/openWorkspacePath`、Settings/native open、动态 Cordis package/source/runtime 操作、 Agent Preset copy/delete 和任何未列出的 endpoint。endpoint 必须命中代码内固定集合,不能 根据 Typert registry 动态扩张。`directoryPicker/list` 遇到只支持 Browser capability 的 Host picker 时,可以退回 Plugin Host 本地只读目录枚举;`session/canOpenWorkspacePath` 可基于该 受限枚举返回 `true`,且仍只允许单层目录元数据。Client 的 Gateway target switch 必须让 `dynamicCordisRunner/*` 固定调用本地 Gateway,不得因选中远端 Host 而把本机 UI/runtime 装载请求转发到 Host。 原生自动化任务 UI 的 `schedule/list`、`schedule/catalog`、`schedule/history`、 `schedule/update` 与 `schedule/delete` 使用相同官方 carrier,支持列表、投递历史、修改与删除。 任务绑定、输入校验、更新冲突和持久化均由 Host 官方 Schedule service 处理,Remote 原样返回 业务结果;`schedule/changed` 复用官方 `$events` 通道。创建任务仍通过会话中的官方 `schedule_create` 工具,不开放不存在的 Remote `schedule/create` 或按 namespace 放开其他方法。Client 的本机 HTTP dispatcher 将远程 RPC 拒绝与传输错误封装为官方 `{ ok: false, error }`,保留 request 的 `rpcId`,不得回落到本地服务或把预期拒绝变成裸 HTTP 500。 Harness `0.2.1` 增加固定官方端点 `userQuestions/answer`(call)、`userQuestions/attachWait`(stream)与可选 `claudeCodeMods/pressBand`(call)、`claudeCodeMods/watchBand`(stream)。它们复用现有加密 carrier,不增加 Remote namespace。问答使用 Gateway 验证的 `agentId`、`callId`;Mods 使用 `agentId`、`generation` 和 `actionId`,所有业务校验由官方 Gateway/service 执行。禁止按 namespace 放开其他方法。 限时问答事件保留官方 `wait.callId` 与 `wait.timed`;Client 先打开 `attachWait`,根据 Host 返回的 `remainingMs` 计时,窗口内回答沿用 `$events/result`,到期回传具备 `name: UserQuestionError`、`code: ASK_TIMED_OUT` 的拒绝结果。断线释放 claim 并取消本地计时,禁止重放回复。只有 Host 的 `userQuestions.active` projection 标记 `continued` 后,才可调用 `answer` 补答;`answer` 接受回复仅表示已排队,Client 不将其提前标记为已回答;`settled` 确认问题已结束。投影序号落后时不得恢复旧问题。上一版 RC 没有 `wait.timed` 的问答保持旧事件流程。 当前兼容维护目标为 Typert Session V3 `0.2.x`;前述 ApiProxy 章节仅保留历史线协议,当前 Host 不再实现其路由,Plugin Client 与 Android 不再连接该 carrier。 #### `harness.remote.stream.open` / `harness.remote.stream.close` Open Params:`{ "streamId", "endpoint", "payload" }`;Close Params:`{ "streamId" }`。 `endpoint` 可为 allowlisted stream Remote(例如 `workspace/follow`、`session/follow`、 `session/control`)或 `$events`。每个 authenticated connection 最多 16 条 alpha stream; stream id、取消和断开清理均按 `connectionId` 隔离。 `session/follow` 与 `session/page` 复用官方 `SessionAddress`:仅 catalog 明确标记 `origin: 'subagent'` 的会话使用 `{ kind: 'subagent', parentSessionId, childSessionId, mode }`; 普通会话和 fork 会话使用 `{ kind: 'session', sessionId }`。`parentSessionId` 也表示 fork 来源,不能单独作为 subagent 判据;Host 的 origin/parent 校验失败不得通过地址降级绕过。 Host 对每个 item 发送 `harness.remote.frame`: ```json { "streamId": "...", "hasValue": true, "value": {} } ``` `value` 可省略,此时 `hasValue: true` 表示官方 stream 实际产生了一个 `undefined` item, 不能把它解释成流结束。终止通过 `harness.remote.stream.closed` 单独发送,reason 为 `cancelled|completed|failed|peer-disconnected`;`failed` 可携带 Gateway 归一化后的 `failure: { code, message, details }`。 支持 `harness.remote.stream.transfer.v1` 的 Host 允许 Client 在 open 中显式附加 `transferFrames: true`。Client 必须先通过 `harness.transport.describe` 确认该能力; 旧 Host 不接收该字段,未选择此能力的 Client 继续收到原有 inline 帧。 选中后,超过 4 MiB secure-message 上限的单个 item 使用以下描述帧,不能同时含 `value`: ```json { "streamId": "...", "hasValue": true, "transfer": { "kind": "chunked", "transferId": "UUID", "totalBytes": 6000000, "totalChunks": 12 } } ``` `harness.remote.transfer.read/close` 按既有 512 KiB 分块规则读取并释放该输出 transfer; 拼回的 UTF-8 JSON 是完整原帧 `{ streamId, hasValue: true, value? }`,禁止嵌套 transfer, 必须核对 streamId、总大小、块数、逐块序号/字节数及 canonical base64。单项最多 288 MiB, 与 unary response 共用每连接最多两个输出 transfer 的限额。Host 在 Client 完成读取并 close 前暂停该 source,后续 delta 和 closed 不能越过它;Client 按序重组后才交给原生 UI。 Client 的 256 项/4 MiB 等待队列只存小描述,同一 stream 同时只重组一项;不截断消息、 工具结果或游标,不修改 SessionStore。取消、断线、提前 close、乱序读取及 2 分钟 idle 均终止该 stream 并释放 transfer;失败不重放 prompt 或其他操作。 当前 Desktop Plugin 已实现此可选能力。其他 Client 未显式启用时沿用原协议;此扩展 不要求 Server 变更,也不提高单条加密消息或 Relay 帧上限。 `$events` 是官方 alpha Gateway 的 Host-to-Client Cordis event stream。Client 对 waterfall 事件的结果必须经 allowlisted `$events/result` unary endpoint 返回原 Host;Plugin 不创造 permission id、decision enum 或额外响应状态机。 #### `harness.remote.transfer.*` `harness.remote.transfer.v1` 是 `harness.remote.call` 的有界分块封装。操作、512 KiB chunk、 288 MiB 总大小、严格有序/恰好一次、canonical base64、每连接输入/输出各两个 transfer 和 2 分钟 idle 规则与 `harness.api.transfer.v1` 相同。重组后的 JSON 必须仍是 `{ endpoint, payload }`,并再次经过同一固定 endpoint allowlist;它不能绕过 Gateway 或 扩展可调用业务面。直接调用若收到 `RESPONSE_TOO_LARGE`,Client 必须以相同请求自动重试一次 该分块路径;其他错误不得触发隐式重试。 新版 DSH 0.1.6 的 `permissions` Session 投影仅保留 `currentValue`;选项通过官方 `permissionPresets/catalog`(`{ args: {} }`)读取,返回 `{ options: [{ value, name, description? }] }`。 Host 固定 allowlist 仅增加该只读 endpoint;预设切换继续使用官方 `commands/execute`。 旧版投影自带 `options` 时继续使用原选项。catalog 不可用时不得猜测选项或提升权限。 ### Native sidebar: read-only files and Host-configurable terminal Harness `0.1.6-alpha.2` 原生侧栏通过现有 `harness.remote.*` carrier 传输;不新增 Harness wire format。 Android 用户可显式下载工作区原文件到系统目录选择器授权的手机目录(每文件最多 100 MiB,包含恰好 100 MiB;独立于移动端预览上限:PDF 16 MiB、图片 8 MiB)。复用下述 `stat` / `readBytes` 分块读取及路径、版本、大小和取消校验,不新增 download RPC,不写 Host 文件;Office 下载保存原文件,转换后的 PDF 仅用于预览。 固定只读 allowlist:`workspaceFiles/list|stat|read|readBytes|readAll|readRelated|changes`,以及 `officeToPdf/generation|render`。Host 官方 Session lookup、组合文件系统、分页与大小限制继续生效。 注意:上游 `list` / `changes` 限于工作区;文件读取遵循 Session 文件系统权限,允许工作区外的已授权文件, 不能把“只读”表述成“只能读取 cwd 内文件”。旧 `fileviewer.read.v1` 仍使用 provider 授权。 对 `codex:` scope,Host 使用 CodeX thread 当前 `cwd` 作为独立根目录,并额外拒绝根目录之外的路径; 该映射不改变 Harness Session 的既有工作区权限。CodeX 终端同样按 thread 建立独立 context。 `terminal.enabled` 默认 true。Host 本地开关切换即保存并更新拦截,在后续加密 capability 探测中宣告 `harness.terminal.v1`。 只允许 unary `terminal/environment|shells|list|create|write|resize|rename|close` 与 stream `terminal/follow|retain`;不允许 wildcard、exec、spawn 或改变官方终端语义。 Harness 从 `0.2.1-alpha.3` 起将原生输入改为 `terminal/attach` 双向流,RC.1 沿用此契约。 本仓库不增加通用 uplink RPC:Desktop 在本地将原生 attach 转成既有 follow/write/resize, Host 在通过终端策略后将 Harness follow 转成官方 attach;CodeX/ACP 保留各自终端域。 每个 attachment 的输入 sequence 从 1 递增,Host ack 后才完成 write/resize;拒绝、取消或 断线不能确认或重放输入。Host 每 attachment 至多 32 个待确认输入,队列上限 64 项/256 KiB; 原生客户端侧输出缓冲最多 256 项/4 MiB。状态快照按官方 writable 映射既有 controllerId。 旧 Host 与 CodeX/ACP 的终端环境在本地补充 `maxBufferedBytes=262144`,不扩展 Host 权限。 关闭时,任何终端调用都返回 `TERMINAL_DISABLED`,提示用户在 Host 本地开启开关后重试。 这个提示不能用于一般登录/连接失败。开关是对受信任 Remote 设备的 Shell 授权,不是 Agent 审批: 终端继承 Host 执行环境系统用户权限,cwd 不是 filesystem sandbox。 Host 生命周期内记录 `(sessionId, terminalId) -> authenticated deviceId`,最多 256 个归属记录; Client 不能接管本地创建或其它设备的终端。list 仅返回本设备归属;write/resize 还必须命中当前连接 建立的 attachment。断开结束 follow/retain,不重放 write;重新连接通过官方快照恢复已保留的终端。 进程回收遵循上游保留/空闲策略,Host 重启不恢复进程。终端关闭成功移除归属。 Host 访问设置禁止由 Remote `settings/update|replace|mutate` 修改(命名空间 `ds-harness-remote`、`dsh-remote`,包括 legacy ApiProxy);只能通过 Host 本地设置入口修改。 加密发送串行化,队列最多 8 MiB / 256 消息;Relay 等待 WebSocket 缓冲回落至 512 KiB, 最多等待 10 秒,失败关闭通道。Client stream 队列最多 4 MiB / 256 帧,超限终止流并要求恢复。 这些上限不保证终端每一行输出都可保留;恢复使用上游有界屏幕快照。 ### Restricted loopback preview Capability `loopback.http-ws.v1`,唯一方法 `loopback.call`,schema 和共享类型在 `packages/protocol/src/loopback.ts`。所有请求必须经已有 membership + trusted identity + Noise 通道。 Host `loopback.ports` 是本地配置的端口白名单,默认空,最多 16 个,范围 1024–65535;在 Host 本地保存后立即生效,无需重启。 撤销端口时 Host 必须关闭所有连接中该端口已有的 HTTP/WebSocket 句柄,包括等待响应或握手的请求, 结束挂起的读取并丢弃该句柄缓存的数据;仍被允许的端口不受影响。重新允许端口后必须新建句柄,不能恢复已撤销的句柄。 目标固定 IPv4 `127.0.0.1`,不接受 hostname、URL、DNS、CONNECT、TLS 或任意 TCP 通道。 请求 HTTP body 允许 POST/PUT 等开发服务操作;这不是只读文件通道,授权端口意味着允许与该服务交互。 请求为以下严格对象: - `{op:"describe"}` → `{ports:number[]}`。 - `{op:"http.open", id:UUID, port, path, method, headers:[name,value][], body?:base64}` → `{status,headers}`。 - `{op:"http.read", id}` → `{data:base64,done:boolean}`,按需读取最多 64 KiB,一次仅一个在途 read。 - `{op:"ws.open", id, port, path, headers, protocols:string[]}` → `{protocol:string}`。 - `{op:"ws.send", id, data:base64, binary:boolean}` → `{sent:true}`;按顺序发送,不重放。 - `{op:"ws.read", id}` → `{messages:[{data,binary}],closed:boolean}`,最多等待 20 秒;仅一个在途 read。 - `{op:"close", id}` → `{closed:true}`,幂等清理 HTTP/WS handle。 handle 按加密连接隔离;最多 16 个。HTTP 头等待 20 秒,请求体最大 1 MiB,响应最多 64 MiB; WS 单消息最大 256 KiB、待消费队列最大 1 MiB / 256 消息。60 秒没有 RPC 活动回收 handle; 断线清理全部 handle。禁止 Host 自动跟随 HTTP 或 WS 重定向;不记录 headers/body/URL 查询内容。 Client Plugin 在本机 `127.0.0.1` 的随机端口创建代理,每个 Host 端口使用独立随机 `dsh-<192-bit secret>.localhost` origin。必须验证完整 Host 与有值的 Origin,拒绝 service worker 注册, 移除 hop-by-hop headers、Cookie Domain 和跨 origin 重定向。上游 Origin 按已授权目标重写, HTTP 请求/响应和 WS 消息在端到端加密通道中传输,Relay 只处理密文。 浏览器→本机代理与 Host→loopback 服务是本机 HTTP/WS,不宣称这两段是 TLS。 第一版入口是 Desktop / 浏览器连接本机 Harness 时的 Remote Header「预览服务」,输入授权端口后 打开原生 Browser tab;相对资源路径与使用当前 origin 的 HMR WebSocket 可正常转发。 硬编码远端 localhost 地址、外部重定向、HTTPS upstream、远程 Web preview gateway、Android/VS Code 预览 UI 不在本版范围。浏览器必须支持 `.localhost` 的 loopback 解析;预览域名是临时访问凭据, 退出 Remote 或断线关闭监听器。普通公网网页仍由原生侧栏浏览器直接访问。 ### File Viewer read bridge `fileviewer.read.v1` 仅用于把已安装 `dsh-file-viewer` 的现有只读能力带到 Remote UI, 不构成通用文件系统 RPC。业务方法只有 `fileviewer.call`: ```json { "endpoint": "readRange", "payload": { "path": "/workspace/report.csv", "offset": 0, "length": 524288 } } ``` `endpoint` 只允许: - `stat`:payload `{ "path": locator }`; - `readRange`:payload `{ "path": locator, "offset": nonNegativeInteger, "length": 1..524288 }`; - `list`:payload `{ "path": locator }`,返回最多 1000 项。 Host 必须调用 `fileViewerHost` 服务,让被选中的 File Viewer provider 执行根目录或 locator 授权;Remote 不得直接调用 Node filesystem 绕过该边界。Host 必须再次验证返回 schema 和 大小。Client 可把多个不超过 512 KiB 的 range 拼成 File Viewer 所需的较大读取。 禁止 `readHead`(Client 以 offset 0 的 `readRange` 实现)、`openExternal`、写入、上传、删除、 重命名、执行和任意 endpoint。Host 未安装 File Viewer、请求超限、provider 拒绝或返回异常时 必须 fail closed。错误不得回显 Host 内部路径或原始 filesystem 异常。 ### Agent ACP domain Agent ACP 是现有 Remote Plugin 内部的通用业务面(见 #65),不是第二个 Plugin,也不是 Codex 专用 API。Host 通过 `acp.enabled` 和 `acp.backends` 独立配置 Cursor / Antigravity / Pi adapter 的启用、command、args 与 cwd;旧 `cursor.enabled` / `cursor.binary` 仅兼容 Cursor。 `agent.acp.v1`、`agent.acp.transfer.v1` 与 `agent.acp..v1` 表示启用且已实现的协议; 实际就绪状态见 `workspaceTypes.available`,后台初始化不得阻塞 Harness Remote。 现有 `harness.api.*` / `harness.remote.*` 和 `codex.app.*` 的线协议、路由与能力不受影响。 业务 RPC 固定为 `agent.acp.call|respond|stream.*|transfer.*`,事件固定为 `agent.acp.frame|stream.closed|catalog.changed`,所有 Client(包括 Web)使用同一独立 ACP 数据面。 `agent.acp.call` 不是通用代理;编译期 allowlist 仅含 `initialize`、`session/new`、`session/load`、 `session/prompt`、`session/cancel`、以及 Host 扩展 `dsh/directoryList`、`dsh/workspaceList`、 `dsh/sessionList`、`dsh/sessionHistory`、`dsh/sessionArchive`。这些 `dsh/` 名称是 ACP call 内的 Host 扩展, 不得通过 Harness ApiProxy / Typert 业务端点调用。 `dsh/sessionHistory` 是只读访问:已认证且受信任的同账号连接通过 backend、会话目录 authority 与真实 cwd 校验后,可以读取其他 Remote 连接正在使用的会话历史,包括活动回复期间; 读取不得取得或转移 live session owner。Pi / Claude / OpenCode 使用独立 history reader, 不向 live stream 重放历史。`session/load`、Prompt、cancel、模型切换、审批和工具的既有 归属与权限检查保持不变。 OpenCode 原生归档要求 `agent.acp.opencode.archive.v1`。`dsh/sessionArchive` 的严格参数 为 `{ backend: "opencode", sessionId: nativeId }`,不接受 Client cwd、URL 或附加 payload。 Host 按 CLI 帮助中的参数能力决定是否启动私有 HTTP listener;不支持网络参数的 ACP 构建仍可通过 stdio 提供会话列表、历史和对话。此时归档返回 `OPENCODE_ARCHIVE_UNAVAILABLE`, 目录使用原生 ACP 列表,不请求不存在的 HTTP listener,也不伪造归档状态。 Host 以可信 catalog / realpath 和原生返回的 ID/目录确认会话归属,只调用同一 ACP 进程的 认证 loopback 原生 HTTP GET / PATCH,将 `time.archived` 设为当前时间。原生确认后返回 `{ backend: "opencode", sessionId: nativeId, archived: true }`,并向当前认证 ACP peers 发布 `agent.acp.catalog.changed`,data 同响应。事件用于目录更新,不表示 turn 完成,不断线重放。 原生列表逐项核对归档状态,默认目录隐藏归档项,但保留其可信 cwd 以读取历史;分页遍历后再 应用 limit。无 Host 归档索引、不写 DSH 持久化、不删除会话,不提供 unarchive 或已归档查询。 加载、Prompt 准备/执行、模型写入、待审批与归档互斥;冲突返回 `ACP_SESSION_BUSY`。 归档后写操作返回 `ACP_SESSION_ARCHIVED`;只读历史仍不要求 live owner。空闲会话允许其他 受信任同账号连接归档。原生失败或响应未确认返回 `OPENCODE_ARCHIVE_UNAVAILABLE`,后端 关闭或替换不能返回迟到成功。Desktop/Android 在成功确认后更新内存,失败保留原状态。 其他 ACP 后端无此能力,禁止仅移除本地投影并返回归档成功。具体进程隔离与验证边界见 [OpenCode 原生归档设计](design/acp-session-archive.md)。 Client 侧 `initialize` 由 Gateway 返回 backend-neutral 能力描述,不把 Cursor 私有 method 名暴露为公共面。AGY 的 `dsh/sessionList` 在指定有效工作目录时可异步预热该目录的一个空闲进程,不等待初始化、 不发送 prompt,也不注册客户端 Session;`session/new` 才领取预热进程。AGY 摘要数据库 中零步数且无标题的记录若已有持久化 transcript,历史发现应按数据库中的工作区归属 恢复该会话;没有 transcript 的空闲预热进程不显示为历史会话。Remote 在 AGY 回复完成 和历史重新发现时读取持久化标题,并以有序 title projection 更新已有会话;AGY 标题 为空时使用 transcript 中首条用户消息的摘要,不修改 AGY 数据库。`session/new` 强制 `mcpServers: []`,cwd 必须经 `realpath` 确认为 Host 上已存在的绝对目录。Cursor Prompt 仅允许文本块;AGY 还允许 `{ type: "image", mimeType, data }`(canonical base64、PNG/JPEG/WebP/GIF, 每张最多 8 MiB,每次最多 4 张)。`session/prompt.backend` 可显式指定后端,必须与已有会话后端 一致,图片不能转发到 Cursor。图片 Prompt 和含图片的 History 使用已有 ACP transfer 分块通道。 按用户 2026-10-07 授权,Host 在 tmp 专用私有缓存按会话哈希目录和随机文件名保存图片, 总量上限 512 MiB,拒绝越界和 symlink;24 小时后在后续上传时清理,系统也可能提前清理 tmp。 AGY 1.3.0 的 stream-json 不接受 image block,Host 将缓存目录加入 AGY `--add-dir`, 用 text 内的受限引用让其 `view_file` 读取图片。缓存目录不作为 Workspace 暴露。 Remote 将图片投影到原生 user/message 与 session/attachment,仅返回所属 Session 引用的图片; 历史从该 AGY transcript 的引用恢复仍有效的附件,用户文案移除内部缓存引用。 ACP transfer 不得接受 Client 提交的文件路径,不提供通用文件上传 RPC,也不写入项目目录。独立的工作区根目录上传仅通过 `workspace.upload.v1`,见 [Workspace upload](#workspace-upload)。权限类上游请求经 `agent.acp.respond` 回传,仅允许 `allow-once` / `reject-once` / `cancel`;禁止 `allow-always` 或其他持久授权。Host 按上游 `options[].kind` 的 `allow_once` / `reject_once` 映射本次请求真实 `optionId`(不可假设拒绝 ID 为 `reject-once`),仅接受唯一、有效的单次选项;`cancel` 取消待审批请求而不授权。权限回复不得附带任意 `result` 绕过映射。审批句柄绑定 Host 后端实例、会话与所有者连接,跨连接、重放、断线或重配后的旧句柄必须拒绝;无主或未知上游请求 fail closed,不向其他设备广播。Cursor 专属 plan/question 只保留明确的受限响应形状,不扩展通用 RPC。有序 `session/update` 与 `session/request_permission` 经 `agent.acp.frame` 下发;Host 生成的 requestHandle/sessionId/upstreamMethod 不得被上游字段覆盖。 Web / Desktop 可在 Client 内存中把 ACP Workspace / Session 投影到原生 Harness UI, 该投影不改变 Harness Host API,也不写入 DSH SessionStore、Workspace 数据库或日志。 客户端工作区发现与历史读取只调用 ACP 接口,不回退读取 Client 本机的 AGY 数据库或文件。 新工作区 ID 分别使用 `cursor:cwd:` 和 `antigravity:cwd:`; 工作区只接受所属后端前缀,不兼容旧 AGY 的 `cursor:cwd:` ID,也不接受无前缀路径作为 ID。既有 Session ID(Cursor `cursor:`、AGY `acp:`) 保持不变。`agent.acp.v1` 仅表示公共 ACP 数据面;Cursor 和 AGY 分别必须明确声明 `agent.acp.cursor.v1` 和 `agent.acp.antigravity.v1`。不提供早期 ACP 配置或工作区 ID 的迁移逻辑;该限制不表示 ACP 尚未实现或发布。 `dsh/sessionHistory` 使用有界 ACP transfer 读取含图片或大文本的响应;图片 Prompt 的 transfer commit 使用与普通 Prompt 相同的长超时,chunk 顺序、大小与连接归属校验不变。 History 显式后端及 Session 前缀必须与已绑定的后端一致;Cursor History 不读取 AGY transcript。 Pi 使用独立 `agent.acp.pi.v1`,工作区 ID 为 `pi:cwd:`、展示 Session ID 为 `pi:`。Pi 首版仅接收文字 Prompt,不因上游支持 image 而放宽 Remote 图片策略。Host 从 `pi-acp` 的标准 `session/list` 获取目录 authority,所有目录仍须 `realpath` 确认为真实目录;历史经独立 wrapper 的 `session/load` 通知恢复。活动 Session 的 wrapper 不与历史读取共享,Client 断线不取消正在运行的 Prompt。Pi 工具由本机 Pi 执行,默认无逐工具审批;Host Pi 扩展发出的 confirm 经上游 `session/request_permission` 使用同一单次审批契约,Remote 不自动安装扩展或虚构工具沙箱。Pi 目录/历史读取失败必须保留错误,不投影为成功的空结果。历史工具输出使用原生 `tool/result` 事件,并保留 bash terminal 输出及失败状态;`pi-acp@0.0.34` 未重放的原始 thinking、工具输入、消息 ID 和时间不能由 Remote 伪造,未知时间为 `0`,消息 ID 仅为稳定投影标识。后端前缀、归属和工具 scope 校验与其他 ACP 后端相同,不扩大 Harness/Codex API。 Pi 首次 Prompt 的可信 cwd / `session/load` 准备阶段也接受 `session/cancel`;已取消的准备任务不得再向上游提交 Prompt,原 Prompt call 最终返回 `{ accepted: false, stopReason: 'cancelled' }`。旧准备任务完成或失败不能释放后续新 turn 的 reservation,不向新 turn 发送迟到的旧 completion。已提交的 Prompt 仍由上游确认停止;断线不取消 Prompt。Client 投影须区分 `prompt_failed`、`stopReason: 'cancelled'`、`max_tokens` 与正常结束,不得把失败或中断改写为完成。 Cursor adapter 实现细节见 [ACP Remote](acp-remote.md)。 ### Codex App Server domain Codex 是现有 Remote Plugin 内部的可选独立业务领域,不是第二个 Plugin。Host 本机配置 `codex.enabled: true` 后,Plugin 才可使用配置的 `codex.binary` 启动 `codex app-server`。Plugin 与 App Server 只使用默认 stdio JSONL;App Server 不监听 Remote/公网端口。初始化与账户状态探测 成功后,Host 才宣告 `codex.appserver.v1` 与 `codex.appserver.transfer.v1`;Workspace authority 在 首次读取时按下述规则解析,空 `project/list` 不得让已经可用的 Codex domain 整体降级。 当 `codex.binary` 保持默认值时,macOS Host 可以优先发现 ChatGPT App 内置 Codex 后再回退到 `PATH`;用户显式配置的 binary 不得被替换或补充候选项。 Client Plugin 可在用户从 Remote 工作区选择器进入 CodeX 模式后,将已认证的 `codex.app.*` carrier 包装成临时 Typert target。该 target 只在内存中把 CodeX 工作目录、 Thread、History/live frame 映射为 DSH 原生 Workspace/Session/Event,使原生 Conversation Renderer 和 Composer 可以消费;它不是新的线协议,也不得把虚拟记录写入 DSH SessionStore、Workspace 数据库或 Harness 日志。退出 CodeX 模式或连接关闭时必须销毁 target 和全部 stream。 Android Client 可在相同 capability 探测后直接消费该 `codex.app.*` carrier,并只在 Android 内存中 把 Workspace catalog、Thread 与 History/live frame 投影到已有 Workspace/Session/Chat 状态;不得据此 恢复冻结的旧 Android `sessions.*`/`session.*` RPC,也不得持久化第二份 CodeX 数据。 业务 RPC 固定为: - `codex.app.call`、`codex.app.respond`; - `codex.app.stream.open`、`codex.app.stream.close`; - `codex.app.transfer.open|chunk|commit|read|close`。 `codex.app.call` envelope 为 `{ "method": string, "params": unknown }`,但它不是通用 JSON-RPC 代理。Host 编译期 allowlist 仅包含 `account/read`、`model/list`、`project/list`、`project/create`、`thread/list`、 `thread/read`、`dsh/sessionHistory`、`dsh/directoryList`、`thread/start`、`thread/resume`、`thread/fork`、 `thread/name/set`、`thread/archive`、`thread/unarchive`、`thread/unsubscribe`、`turn/start`、 `turn/steer` 与 `turn/interrupt`,且每个 params 都必须通过严格 schema。`thread/delete`、 `thread/shellCommand`、`thread/inject_items`、`thread/rollback`、background terminal、 `command/*`、`process/*`、`config/*`、登录写接口和未显式列出的实验 API 一律返回 `METHOD_NOT_ALLOWED`。 CodeX App Server 的 `project/list` 是虚拟 Workspace 的首选来源。只要它返回至少一个带绝对根目录的 有效项目,Client Plugin 与 Android Client 就只投影这些项目,`thread/list` 里不能归属到任一 project id 或 project root 的 Thread 不得返回为可见 Session。当 `project/list` 返回空列表、全部项目均无有效根 目录,或该方法不可用时,Host 才可把 App Server 自己通过 `thread/list` 返回的绝对 `cwd` 作为精确 Workspace authority;Client Plugin、Remote Web 与 Android Client 可为这些 `cwd` 生成只读后备 Workspace。不得从多个 `cwd` 推测或提升到共同父目录。没有绝对 `cwd` 的 Thread 在后备模式下不可见。 `project/create` 只允许 `{ name, roots: [{ path }], idempotencyKey }`:`name` 为 1..256 字符, `idempotencyKey` 为 16..256 字符,且 `roots` 必须恰好包含一个 1..4096 字符的绝对目录。Host 必须在 调用 App Server 前对该路径执行 `resolve`、`realpath` 和目录类型校验,并把 canonical path 作为新增 project root。返回值只保留 project 的 `id`、`name`、绝对 `roots`、`position`、`createdAt` 与 `updatedAt`,不得转发 metadata。该操作会显式扩展后续的 Workspace authority,但不会创建目录、 写入 DSH Workspace 存储或开放任意 App Server method。 其它带 `threadId` 的调用必须先用只读 `thread/read(includeTurns:false)` 重新验证该 Thread 仍属于当前 Workspace authority;如果该只读验证因 App Server 当前状态临时失败,Host 最多只能用同一 authority 下的 `thread/list` 结果兜底。Remote 创建 Thread 时 Host 只能接受 `project/list` 暴露的项目根目录、 后备模式中 `thread/list` 已返回的绝对 `cwd`,或这些 authority 根目录经过词法路径与 `realpath` 双重 校验后仍位于根内的真实子目录。不得接受越过 authority 根的 Client 自报路径、`..` 跳转或符号链接 逃逸。`dsh/directoryList` 只供 CodeX 虚拟 Workspace 的原生新建会话流程浏览目录,Host 只能对上述 authority 根内的真实目录返回单层只读子目录元数据、面包屑和截断标记,不得返回文件内容、执行打开、 创建目录或把符号链接越界目标暴露为可选目录。虚拟 Session 的原生权限控件 只暴露 `workspace-write` 与 `danger-full-access` 两个固定 preset;Client 传 preset 名,Host 映射为 App Server 的审批与 sandbox 字段,不接受任意 sandbox、writable roots 或额外授权。默认 `workspace-write` 映射为 `approvalPolicy: "on-request"`、`sandbox: "workspace-write"` 和 `sandboxPolicy.type: "workspaceWrite"`;用户显式确认的 `danger-full-access` 映射为 `approvalPolicy: "never"`、`sandbox: "danger-full-access"` 和 `sandboxPolicy.type: "dangerFullAccess"`。`thread/start` 未带 preset 时使用 `workspace-write` 作为 Remote 新 Thread 的保守默认;已有 Thread 的 `thread/resume|fork` 与 `turn/start` 未带 preset 时, Host 不注入审批或 sandbox 覆盖,让 App Server 继承该 Thread 已保存的默认值。只要 Client 显式提交 preset,Host 仍必须重新映射为 canonical 策略,不能接受 Client 提交的底层字段或 Thread 历史里的旧策略。 审批响应本身仍只允许单次 accept/decline/cancel,不提供 `allow_session`。 CodeX App Server 可把 `turn/start` 的部分配置覆盖作为同一 Thread 后续 turn 的默认值;Remote 可从 `thread/start|resume|fork` 响应或 `thread/settings/updated` 事件里的有效 `approvalPolicy` 与 `sandbox/sandboxPolicy` 反推上述两个固定 preset。`thread/list` / `thread/read` 的 `Thread` 元数据本身 不携带该设置,所以 Android Client 仍可以把用户显式选择的 CodeX preset 作为本地 UI 偏好,按 Host 设备与 Thread 维度保存,并在之后需要显式覆盖时继续提交同一个 preset。 Web 与 Desktop Remote 的审批控件按 Thread 回显 Host 已确认的 preset,不能把客户端默认值或 同工作区其它 Thread 的选择当成当前策略。`dsh/sessionHistory` 可附带 `permissionPreset: "workspace-write" | "danger-full-access" | null`:Host 只从成功的 App Server 响应、明确完成的设置更新和 `thread/settings/updated` 记录内存快照;不支持的策略组合、未知设置 以及 Host App Server 重启后返回 `null`,旧 Host 可省略该字段。纯查看不得调用 `thread/resume`。 Client 未获知策略时显示沿用 Host 设置,发送与 fork 默认省略 preset,避免缓存覆盖其它客户端的修改。 新 Thread 仍显式采用 `workspace-write`,不继承相邻 Thread 的 Full access。 显式 `thread/resume(permissionPreset)` 由 Host 优先调用官方 `thread/settings/update` 落实到 已加载的 Thread;未加载或旧 App Server 可回退到 resume,但返回策略不匹配时必须再次设置成功, 否则返回错误,不得假报切换成功。该内部调用只含固定映射后的审批、sandbox 和已允许的 model, 不扩大 Remote method allowlist。另一连接持有 active writer 时拒绝权限切换;成功后同步同 Thread 观察者,并在后续只读 History 中回显。 CodeX 虚拟 Session 接受文本,以及 Desktop Composer 从剪贴板或 Android 系统图片选择器产生的 PNG、JPEG、WebP、GIF 图片 Prompt。 Client 只能把原生 `{ type: "image", mediaType, data }` 转为 CodeX 领域的同形受限 input,并在存在 图片时使用 `codex.appserver.transfer.v1`;Host 必须验证 MIME、canonical base64、part 数量与 288 MiB envelope 上限,再转换为 App Server 的 `{ type: "image", url: "data:..." }`。禁止外部 URL、Host path、临时文件、attachment id 和其它 input 类型。由于不开放通用文件附件,Client 仍 隐藏 Composer 的“+”入口;Host 不能依赖 UI 隐藏作为安全边界。 每条认证连接拥有独立的 `streamId -> threadId` 订阅与 transfer 容器。一个 Thread 同时只允许 一个 connection 持有 active turn mutation lease。`codex.app.frame` 只发送给订阅该 Thread 的 connection;`codex.app.stream.closed` 结束对应虚拟 stream。命令执行和文件变更审批只路由给 active turn owner,并以 Host 生成的 opaque `requestHandle` 暴露。`codex.app.respond` 只接受 `accept|decline|cancel`,错误 connection、重复、过期或伪造 handle 必须 fail closed;断线时未决 审批自动 decline。`acceptForSession`、execpolicy amendment 和额外 permission grant 不得进入 Remote frame。 `codex.appserver.transfer.v1` 使用独立的 512 KiB chunk、288 MiB 总上限、canonical base64, 承载大 History 响应和图片 Prompt request;它保持严格有序/恰好一次、每连接输入/输出各两个 transfer 和 2 分钟 idle 清理。重组后的内容必须再次 解析为 `codex.app.call` 并经过同一 allowlist / project-list policy,不能借分块扩权。 App Server 意外退出或 stdio 失效时,Host 立即撤回动态 capability、清空 active-turn lease 与审批 handle,并以 `failed` 结束全部旧 Codex stream。Host 按 `1s -> 2s -> 4s -> 8s -> 15s` 最多五次 重启 App Server;重启只重新执行 initialize/account probe,禁止重放任何 call/mutation。Desktop Client 必须重新探测 capability,重新打开 stream,并以 Host 分页 `dsh/sessionHistory` 替换本地 baseline 后继续归并 live event;旧 Host 不支持分页扩展时才回退到 `thread/read(includeTurns:true)`。 Android Client 同样必须重新探测 capability、重开 stream,并以 Host 分页 `dsh/sessionHistory` 替换移动端 baseline;若分页基线包含仍在运行的 Turn,Host 必须保留 open step/turn 语义并返回顶层 `activeTurnId`,以便同一连接或同一设备的替换连接可以发起 `turn/interrupt`。任何一端都不得自动重放不确定的 mutation。 Desktop 打开 Thread 时使用 Host 分页 `dsh/sessionHistory` 建立持久化 baseline;Host 可通过 `thread/turns/list` / `thread/items/list` 读取,并兼容旧版 `thread/read(includeTurns:true)`。 读取失败(包括 busy、超时和异常分页响应)不得被转换为成功的空 baseline;真正为空的成功响应 仍可展示空会话。Desktop 重新进入 CodeX 工作区时,可在内存中按 Host/工作区记住上次成功打开的 Thread;仅当它仍属于该工作区的 Host catalog 时恢复,否则选择最近更新的 Thread。 该记忆不写入 Harness 持久化,退出账号清除,也不得用于重放任何写操作。Remote stream 只是 Host 侧的事件过滤器,不得因纯查看自动调用 `thread/resume`。用户明确继续发送前才 resume;如果 App Server 因 Thread 已在本进程加载而拒绝重复 resume,Host 可在已经完成 `thread/read` 归属校验后把该操作视为幂等成功。不确定结果的 turn、rename 或 archive 不自动重放。 ### Workspace upload `workspace.upload.v1` 是将文件保存到**当前会话工作区根目录**的可选能力。四个 RPC 只在已认证的端到端加密业务通道内调用,Server 仅转发密文,不接收、暂存或解析文件。 该能力独立于 Harness / CodeX / ACP 的业务 carrier、图片 transfer 和只读文件预览。 消息继续使用原有 text/image 结构;本扩展不新增消息 `attachments` 字段或文件内容 part。 #### 能力协商与会话范围 建立 Noise 通道后,Client 必须通过 `harness.transport.describe` 确认 `capabilities` 包含 `workspace.upload.v1`,才能发起以下 RPC。该声明表示 Host 实现了 上传协议,不保证任意会话都有可写工作区。Host 按公开 `sessionId` 解析可信目录: | 会话来源 | 公开 sessionId | Host 目录 authority | | --- | --- | --- | | Harness Session V3 | 原生 Session ID | 当前已认证官方 Peer 的 `session/list` 中,精确匹配可见会话的绝对 `cwd` | | CodeX | `codex:` | CodeX 领域验证过的 Thread workspace | | Antigravity | `acp:` | ACP 领域已记录的同一 backend/session 的可信 `cwd` | | Cursor / Pi / OpenCode / Claude | `cursor:` / `pi:` / `opencode:` / `claude:` 加原生会话 ID | ACP 领域已记录的同一 backend/session 的可信 `cwd` | ACP 同时要求调用连接的 peer 仍有效、backend 可用、session owner 匹配;拥有者断开后, 按已有 ACP 规则重新取得会话归属。Harness 旧 carrier、未知或不可见会话、缺失绝对目录、 后端不可用时必须拒绝,不能回退到 Client 自报目录。Web 对旧 Host 或当前不支持的会话禁用入口。 #### RPC 参数与结果 所有 Params 都是严格对象,拒绝额外字段。`sessionId` 为 1–512 字符的公开会话 ID; `uploadId` 是 Client 在发送 open **之前**生成的 UUID。上传句柄只在创建它的连接及 `sessionId` 内有效,不允许跨连接、跨会话复用,不支持跨连接续传。 | RPC | Params | Result | | --- | --- | --- | | `workspace.upload.open` | `{sessionId, uploadId, name, totalBytes}` | `{uploadId, chunkBytes}` | | `workspace.upload.chunk` | `{sessionId, uploadId, offset, data}` | `{uploadId, receivedBytes}` | | `workspace.upload.commit` | `{sessionId, uploadId}` | `{name, path, bytes}` | | `workspace.upload.abort` | `{sessionId, uploadId}` | `{aborted:true}` | - `open`:`totalBytes` 是非负整数:Host 验证当前连接为 LAN 或直接 P2P 时最大 1 GiB(1,073,741,824 bytes);TURN、Relay、未知路径最大 20 MiB。Host 在 open、chunk 和 commit 重新检查实际路径;大文件传输回退至中继时拒绝并清理未提交数据。旧 Host 可保留 20 MiB 限额,Client 不得自动重传。`name` 是单个文件名,UTF-8 编码最多 220 bytes;禁止 `/`、`\`、ASCII 控制字符、DEL、`<>:"|?*`、`.`、`..`、尾部点/空格、 Windows 保留名,以及 Host 临时文件前缀 `.dsh-upload-`。不能传 `cwd`、目标目录或目标路径。 Host 当前返回 `chunkBytes: 65536`,表示后续每块**解码后**最多 64 KiB。 - `chunk`:`offset` 是从 0 开始的非负整数字节偏移,必须等于上一块确认后的 `receivedBytes`。`data` 使用标准、规范的 Base64(含所需 padding),不使用 Base64URL; 解码结果不得为空、不得超过 `chunkBytes`,累计不得超过 `totalBytes`。Host 顺序写入, 返回累计接收字节数;Client 必须核对 `uploadId` 和 `receivedBytes`,不能跳块或重复追加。 Client 应按文件切片编码,不能把完整文件一次展开为 Base64。 - `commit`:只有累计字节数等于 `totalBytes` 才能提交;零字节文件可直接 open → commit。 Host flush 后原子发布,已有文件、目录或符号链接均不能覆盖。同名时依次尝试 `name (1).ext` 等名称,最多 10,000 个候选;结果中的 `name` 是最终名称, `path` 必须严格等于 `./` 加最终 `name`,`bytes` 必须等于 `totalBytes`。 - `abort`:移除同一会话下尚未提交的上传;不存在或已提交时幂等返回 `{aborted:true}`, 不删除已经发布的文件。活动句柄属于其他会话时返回 `UPLOAD_NOT_FOUND`,不影响原上传。 例如上传原名 `report.txt` 的三个字节 `abc`,且目录中已有同名文件: ```text workspace.upload.open Params: {"sessionId":"session-1","uploadId":"550e8400-e29b-41d4-a716-446655440000","name":"report.txt","totalBytes":3} Result: {"uploadId":"550e8400-e29b-41d4-a716-446655440000","chunkBytes":65536} workspace.upload.chunk Params: {"sessionId":"session-1","uploadId":"550e8400-e29b-41d4-a716-446655440000","offset":0,"data":"YWJj"} Result: {"uploadId":"550e8400-e29b-41d4-a716-446655440000","receivedBytes":3} workspace.upload.commit Params: {"sessionId":"session-1","uploadId":"550e8400-e29b-41d4-a716-446655440000"} Result: {"name":"report (1).txt","path":"./report (1).txt","bytes":3} ``` 以上 Params/Result 仍使用 §16 的 `rpc.request` / `rpc.response` / `rpc.error` envelope, 每次调用各有独立 request ID;上传顺序由 `uploadId`、`offset` 和 Host 状态共同约束。 #### 生命周期、重试与落盘边界 每连接最多 2 个活动上传。Host 在工作区根目录排他创建随机 `.dsh-upload-*.part` 临时文件,权限为 `0600`;根目录必须经过 realpath 与目录身份验证,并在分块写入、 发布前重验会话 authority 和根目录身份。目录改变或失去 authority 时拒绝继续。 Host 本地进程仍保有原有文件系统权限,本协议不隔离恶意本地进程的并发目录修改。 未提交上传在空闲达到 2 min 后进入清理范围;Host 定时或处理下一次调用时回收。 取消、失败和连接断开也清理未提交临时文件;不得跟随被替换的根目录或删除被替换的 临时条目。进程崩溃后的残留不支持恢复上传。每次目录 authority 解析最多等待 15 s。 原子发布成功是提交点。提交点后的 abort、断线或 Client 取消不能撤销文件。 Host 为每连接最多缓存 64 个提交结果,保留至提交后 2 min 或被淘汰;缓存存在时, 同一会话、同一 `uploadId` 的 commit 重试返回原结果,不重复发布。重复 open 不是 幂等恢复;chunk 也不支持同偏移重放。chunk 结果未知或提交缓存已失效时,不得盲目重放。 commit 超时/丢包意味着结果未知,工作区中可能已有文件;重新上传必须使用新 `uploadId`, 并可能产生一个自动改名的副本。Client 在 open/传输失败时可用已知 `uploadId` 尝试 abort, 但不能把 abort 成功解释为“此前的 commit 未发生”。Client 本地取消应立即停止等待, 不能等待原 RPC 或 abort 回执后才释放上传队列;迟到回复不能继续分块或加入草稿。 Web 在选择、拖拽或粘贴文件后开始上传,每个输入栏最多保留 5 个文件;这是 Web 的 交互限制,不是 Host 全局配额。所有保留的上传成功前禁止发送;失败需重试或移除。 成功后将 Host 返回路径以普通文本(例如 `已上传文件:./report (1).txt`)插入草稿。 移除已完成条目只移除草稿引用,工作区文件保留。切换 Host/会话取消未完成上传并清除旧引用; 图片 Prompt 的既有行为保持不变。异步编码图片和发送确认回调必须重验当前会话、连接与 文件批次,不能漏过新加入的文件或清理其他会话的草稿/上传条目。同 Host/会话的连接对象 替换也要取消原连接的未完成上传,并使条目可重试。文件内容、文件名、绝对路径不得写入 Remote 日志。 #### 上传错误 错误仍使用 §16.3 的 envelope,不回显路径、文件内容、原始异常或 stack。 以下 `UPLOAD_*` 属于子系统扩展码,不要求加入 v1 标准错误码枚举: | code | 含义 | | --- | --- | | `INVALID_MESSAGE` | 参数类型、额外字段、文件声明大小或 Base64 字符串长度等 schema 校验失败 | | `FEATURE_NOT_SUPPORTED` | Host 没有安装该上传处理器 | | `UPLOAD_INVALID_NAME` | 文件名不满足上述限制 | | `UPLOAD_ID_IN_USE` | 当前连接中该 UUID 已用于活动上传或仍有提交缓存 | | `UPLOAD_LIMIT` | 当前连接活动上传已达上限 | | `UPLOAD_WORKSPACE_UNAVAILABLE` | 会话无法解析为可信绝对工作区 | | `UPLOAD_WORKSPACE_CHANGED` | 会话目录或根目录/临时文件身份改变 | | `UPLOAD_NOT_FOUND` | 活动句柄不存在、已过期或 session 不匹配;无缓存的 commit 也返回此码 | | `UPLOAD_INVALID_CHUNK` | 非规范 Base64、错误偏移、空块或解码后超限 | | `UPLOAD_INCOMPLETE` | 收到的字节数不足声明大小 | | `UPLOAD_NAME_CONFLICT` | 所有候选文件名均已占用 | | `UPLOAD_TIMEOUT` | 目录 authority 解析超时 | | `UPLOAD_CLOSED` | 上传连接已关闭 | | `UPLOAD_FAILED` | 文件系统或其他内部操作失败,不公开原始异常 | 后端的 `ACP_*`、`CURSOR_*`、`CODEX_*` 等 authority 错误也可能直接返回,不表示可回退 到另一后端。Client 不应只按这张表推断全部错误。失败可能使该活动上传被清理;除仍在缓存 内的 commit 重试外,用户重新上传应创建新的 UUID,不自动重放有副作用的调用。 ## 20. Events Plugin 按 Harness 代际发送 `harness.api.frame` / `harness.api.stream.closed`,或 `harness.remote.frame` / `harness.remote.stream.closed`。可选 Codex 领域发送 `codex.app.frame` / `codex.app.stream.closed`。 冻结 Android 原型事件(`session.created`、`session.updated`、`message.created`、 `message.delta`、`tool.started`、`tool.updated`、`tool.finished`、 `permission.requested`、`permission.resolved`、`agent.status`、`connection.stats`) 已退出协议,Host 不得发送,Client 不得依赖。历史类型概览见 §18。 Event envelope: ```json { "v": 1, "id": "01KEVENT...", "type": "event", "timestamp": 1786000000000, "payload": { "seq": 8272, "event": "harness.api.frame", "data": {} } } ``` `seq` 在同一 Host identity 上严格递增。重放必须保留原始 seq/id/timestamp。 ### `harness.api.frame` data:`{ "streamId": "...", "frame": RpcRequest }`。该 event 不进入 Host 的通用 seq replay buffer;Harness Client Runtime 负责按原生流语义重连并重新取得 history baseline。 ### `harness.api.stream.closed` data:`{ "streamId": "...", "reason": "cancelled|completed|failed|peer-disconnected" }`。Client 收到后必须结束对应 iterator;transport 意外关闭时本地模式切换器必须 fail closed,不得继续向旧 Host 提交请求。 ### `harness.remote.frame` data:`{ "streamId": "...", "hasValue": true, "value"?: unknown }`。`hasValue` 区分显式 `undefined` item 与无数据;该 event 不进入通用 seq replay buffer。 ### `harness.remote.stream.closed` data:`{ "streamId": "...", "reason": "cancelled|completed|failed|peer-disconnected", "failure"?: { "code", "message", "details" } }`。 Client 收到后结束对应 iterator;`failed` 必须以 Gateway 归一化 failure 拒绝 iterator。 ## 21. Event Replay 与重连 本节旧 `sync.from` 机制已退出 Plugin。Desktop Client 重连后重新打开官方 mux/host stream,并由 Harness UI 重新读取原生 history baseline;Plugin 不维护第二套 replay buffer。 Client 为每台 Host 持久化 `lastSeq`,但不必持久化解密后的 conversation。 Secure channel 恢复后调用扩展 RPC: ```text sync.from ``` Params: ```json { "afterSeq": 8271 } ``` Result: ```json { "events": [], "lastSeq": 8290, "hasMore": false } ``` 如果 Host replay buffer 不再包含所需 seq,返回: ```json { "code": "FULL_RESYNC_REQUIRED", "details": { "currentSeq": 9000 } } ``` Client 随后调用 `sessions.get` 获取当前会话 snapshot。恢复期间 Send/permission 按钮必须禁用,避免在未知状态上产生副作用。 Event 去重 key 为 `(hostDeviceId, seq)`。收到 `seq <= lastSeq` 的重放事件忽略;收到 `seq > lastSeq + 1` 立即暂停 reducer 并同步。 ## 22. Ping/Pong 应用层 `connection.ping` 已退出 Plugin;只保留 Control Channel heartbeat。 Control Channel heartbeat 默认 25 秒。75 秒未收到有效 pong 时,连接断开。 Control ping/pong 的 nonce 可用于估计 Server RTT。 开源自部署 Server 的心跳超时可能只发送 WebSocket close `4001` / `CONNECTION_FAILED`, 不向超时的 peer 发送 `error` 帧。Client 将这一精确组合视为可重试的连接故障,重新进行 授权与加密握手;不能重放业务请求。`4001` 也用于授权失败,因此不能仅按 close code 重试;未知 reason 与 `4003`(连接被替换)仍须停止自动恢复。 Control ping: ```json { "v": 1, "id": "01K...", "type": "ping", "timestamp": 1786000000000, "payload": { "nonce": "01K..." } } ``` pong 回显 nonce。Heartbeat 不能携带业务数据。 ## 23. 错误码 ### Protocol / Version - `INVALID_MESSAGE` - `UNSUPPORTED_VERSION` - `CAPABILITY_NOT_SUPPORTED` - `METHOD_NOT_FOUND` - `METHOD_NOT_ALLOWED` - `REQUEST_CONFLICT` - `FRAME_TOO_LARGE` - `RATE_LIMITED` ### Auth / Device - `AUTH_REQUIRED` - `AUTH_INVALID` - `ACCOUNT_AUTH_REQUIRED` - `TOKEN_EXPIRED` - `DEVICE_NOT_FOUND` - `DEVICE_REVOKED` - `DEVICE_OWNERSHIP_REQUIRED` - `MEMBERSHIP_REQUIRED` - `PEER_IDENTITY_MISMATCH` - `HOST_REGISTRATION_CODE_NOT_FOUND` - `HOST_REGISTRATION_CODE_EXPIRED` - `HOST_REGISTRATION_CODE_CONSUMED` ### Connection / Transport - `HOST_OFFLINE` - `CONNECTION_NOT_FOUND` - `CONNECTION_FAILED` - `CONNECTION_REPLACED` - `P2P_FAILED` - `TURN_UNAVAILABLE` - `RELAY_UNAVAILABLE` - `SLOW_CONSUMER` - `SECURE_CHANNEL_FAILED` ### Harness - `HARNESS_UNAVAILABLE` - `HARNESS_VERSION_INCOMPATIBLE` - `RESPONSE_TOO_LARGE` - `SESSION_NOT_FOUND` - `SESSION_NOT_READY` - `AGENT_BUSY` - `PERMISSION_DENIED` - `PERMISSION_NOT_PENDING` - `RPC_TIMEOUT` - `FULL_RESYNC_REQUIRED` - `INTERNAL_ERROR` 错误 message 面向用户但不包含内部路径、stack、secret 或原始异常。`retryable` 只表示同一操作稍后重试可能成功,不代表 Client 应自动重放非幂等请求。`error.payload.connectionId` 为可选字段;存在时错误作用域仅限该逻辑连接。 错误码字段可以使用非空扩展字符串。子系统扩展应使用稳定前缀。 当前前缀包括 `CODEX_*`、`FILE_VIEWER_*` 和 `UPLOAD_*`。扩展码不属于上面的 v1 标准错误码集合。 上传错误及重试语义见 [Workspace upload](#workspace-upload)。 ## 24. 默认限制 | 项 | 默认限制 | | --- | --- | | Hello timeout | 5 s | | Host registration code TTL | 10 min | | Control JSON frame | 64 KiB | | Relay Control JSON frame | 1 MiB | | Reassembled secure message | 4 MiB | | Harness business transfer chunk(解码后) | 512 KiB | | Harness ApiProxy / Typert Remote transfer | 288 MiB;每连接输入/输出各 2 个;2 min idle | | Codex App Server transfer | 512 KiB chunk;288 MiB;每连接输入/输出各 2 个;2 min idle | | File Viewer range / RPC | 512 KiB | | File Viewer directory entries / RPC | 1,000 | | RPC text input | 64 KiB | | Workspace upload file | LAN / direct P2P:1 GiB;TURN / Relay / 未知:20 MiB;允许零字节 | | Workspace upload chunk(解码后) | 64 KiB;以 open 返回的 chunkBytes 为准 | | Workspace active uploads / connection | 2;2 min idle 后回收 | | Workspace upload authority lookup | 每次最多 15 s | | Workspace upload commit cache / connection | 最多 64 项;提交后最多 2 min | | Web composer files | 5;仅 Web 交互限制 | | 同连接 pending RPC | 128 | | 同 session pending permission | 16 | | ICE candidates / connection | 256 | | Heartbeat interval | 25 s | | Heartbeat disconnect | 75 s | | Event replay window | 至少 10,000 events 或 15 min,取先达到者 | 工作区上传采用上述固定限额,`chunkBytes` 由 open 返回,不新增 hello/system.info 字段。 其他限制如由 Server/Host 协商缩小,必须在 hello/system.info 中公布。超限必须返回稳定错误或关闭违规连接,不能无限缓存。 ## 25. 安全不变量 所有 conforming 实现必须满足: 1. 账号归属是 Host/Client 访问边界,device token 只能代表其所属账号内的设备。 2. Membership、双方账号一致性、Host/Client 本机 pinned peer 和 connection identity key 绑定必须同时成立。 3. Remote RPC/Event 不以明文经过或落盘到 Server。 4. TLS/WSS 不能替代 Noise secure channel。 5. Client 不能请求通用 shell/filesystem RPC 绕过 Harness;原生 terminal、loopback 与经 capability 协商的 `workspace.upload.v1` 各自遵循固定边界。上传只写入 Host 为当前会话验证的根目录,不接受 Client 目标路径。 6. Permission 只能映射 Harness 当前 request,默认 fail closed。 7. `harness.api.call.method`、`harness.remote.call.endpoint` 与 `codex.app.call.method` 必须命中各自编译期固定 allowlist;禁止通过对象反射、Typert/Cordis registry、service 名或任意 endpoint 扩权。 8. 当前 Harness v1 只允许 Remote `allow_once`/`deny`,不得伪造 session grant。 9. Device revoke 使 token、membership 和现有 connection 失效。 10. 重放/乱序/身份不匹配的 secure frame 必须拒绝。 11. Host/Client 首次账号归属必须由同一 Server 的账号授权;Host 也可使用该账号生成的 一次性主机匹配码。同一安装仅可用 device token 注册独立的相反角色;device token 仍不可调用账号接口,切换 Server 不得复用旧 origin 的身份、凭证或授权状态。 12. 日志禁止记录 token、code 明文、key、prompt、source、workspace 和 tool output。 13. Admin 无法从数据库或 API 获取 E2EE conversation。 14. 未协商 capability 的功能不得调用或展示为可用。 15. Codex Thread 继续归 Codex App Server 所有;Remote 可在 Client Plugin 内临时伪装为原生 Workspace/Session/Event,也可在 Android 内存中投影为已有移动端 Workspace/Session/Chat 供展示与操作,但不得写入 DSH SessionStore、Workspace 数据库、Harness 日志或另建 CodeX 数据存储。 ## 26. Conformance 测试 协议实现至少通过: - JSON/envelope/schema/version vectors - RPC correlation、timeout、late response 和 idempotency - Workspace upload 分块顺序/规范 Base64/声明大小校验、零字节与同名字节完整性 - Workspace upload 连接/会话/后端 authority 隔离、路径穿越与根目录/临时条目替换拒绝 - Workspace upload 取消/空闲/断线清理、迟到目录解析取消与提交点后文件保留 - Workspace upload 提交缓存幂等、缓存失效后的结果未知与旧 Host capability 降级 - event seq、duplicate、gap、replay 和 full resync - Host/Client account authorization、owner mismatch 与跨账号访问拒绝 - Host registration code expire/single-use - Noise IK handshake、peer mismatch、tamper、replay 和 rekey - signaling/relay membership authorization - Relay capture 无法解密业务 payload - permission allow/deny/cancel/timeout/disconnect fail-closed - transport 从 P2P/TURN 降级 Relay 后保持 session continuity UI 排版、静态说明和 Admin 普通筛选不属于协议 conformance。 ## 27. 实现差距管理 当前 `packages/protocol`、`packages/crypto`、`packages/webrtc` 和 Server scaffold 是早期代码,不得仅因类型存在就声明符合本规范。 实现阶段应维护 checklist: - 每个本文 frame/type 都有 schema。 - 每个 schema 都有正反测试 vector。 - Noise library 和握手 transcript 已固定。 - Server 只解析 control envelope,不解析 relay plaintext。 - Host Typert bridge allowlist 与真实 Harness API 一致。 - Web/Host/Mock Host 至少两两互操作。 ### CodeX Session workspace and terminal scope A Remote `workspaceFileScopeId` or terminal `agentId` may identify either a Harness Session or a CodeX Session. CodeX sessions use the form `codex:` and are resolved by the Host; they are never looked up as Harness agents. The Host obtains the current CodeX thread `cwd`, creates an independent workspace-file scope and terminal context per thread, and applies the same path normalization, realpath, symlink, size, terminal ownership, and Remote settings checks as Harness sessions. A CodeX scope cannot access paths outside that thread's `cwd`; invalid, ended, or cwd-less threads return stable `CODEX_SESSION_INVALID`, `CODEX_THREAD_UNAVAILABLE`, or `CODEX_WORKSPACE_UNAVAILABLE` errors. Server transport only forwards encrypted RPC and does not perform this mapping or authorization. CodeX terminals reuse the official `terminal/*` semantics unchanged: `environment` returns `{cwd,maxInputBytes,maxCols,maxRows,scrollback}`, `shells` returns `{path,args,name}`, and `list` / `create` / every snapshot and state `info` carry the official `WebTerminalInfo` including `controllerId`. `write|resize|rename|close` resolve to void, `retain` acknowledges with `{type:"retained"}` without taking input ownership, and `follow` starts with `{type:"snapshot",sequence,screen,info}` followed by `{type:"output",sequence,data}` where each `sequence` is exactly the previous one plus one. CodeX and ACP share a Host-owned `RemoteTerminalController`, without registering Harness Agents or writing SessionStore. Shell discovery uses the Host subprocess execution environment and verifies installed candidates. The provider's default shell is first; ordinary POSIX shells use `-i`, PowerShell uses `-NoLogo`, and cmd uses no extra arguments. `create.request.shellPath` may select only an exact verified candidate. An unavailable declared default fails rather than silently selecting a different shell. Older local providers without discovery verify only the Host-local environment/account default shell, without treating unclassified provider errors as missing candidates. The Host starts shells through its `subprocess` service (PTY, supported shell activity inspection, containment and process-range termination) when available. Allocation cancellation is detached after publication: request timeouts and transport disconnects cannot kill committed terminals. The compatibility fallback is a plain pipe; it cannot provide PTY resize or terminal job control. After allocation passes its cancellation and enable checks and starts the output pump, creation remains successful even if the request is cancelled before its response returns; this preserves the committed terminal's device claim. The controller maintains a bounded xterm headless screen and serializes its current state for `snapshot.screen`; Clients reset their emulator before applying it. A snapshot may include fewer scrollback rows to stay within 256 KiB. Each output frame contains at most 16,384 UTF-16 code units, preserving surrogate pairs, and slow followers fail explicitly when their queued JSON frames exceed 1 MiB. A new attachment recovers the screen; input is never replayed. Terminal policy and tool dispatch must resolve the same session; conflicting `agentId`, `sessionId` or `workspaceFileScopeId` values fail closed. Creation is idempotent for an active `(sessionId,id)`, including pending allocation; there are at most 8 retained/pending terminals per session and 256 per domain. `follow` grants input to that physical attachment; a newer follower or detachment revokes the old input claim. `retain` keeps a window hold without taking input. Input additionally requires that the current controller follower belongs to the calling connection; reusing an attachment ID on another connection does not grant the old connection access. Disconnect also fences pending follower opens. A shell is reclaimed only after two hours of continuously confirmed idle activity without holds or followers; unsupported/unknown activity and busy jobs never qualify. Disconnect only detaches subscriptions. Backend reconfiguration and Host shutdown cancel pending allocations and await process cleanup; failed cleanup keeps the terminal and its device ownership closed to input for explicit retry and periodic cleanup retry. No terminal output is written to logs or persistence. ACP cleanup errors do not skip changed backend shutdown. Unfinished backend reconfiguration is retained separately from the desired configuration: affected backends cannot accept RPC or restart until cleanup succeeds, and saving the same settings retries that cleanup. Unchanged backends remain usable. ### ACP catalog reads without prewarming `agent.acp.call` 内的 `dsh/sessionList` Params 新增可选布尔字段 `prewarm`,默认 `true`。 `false` 仅关闭该次列表读取触发的空闲 CLI 预热,不改变后端选择、身份/权限校验或 catalog 来源;Android 批量读取各目录时使用它,防止为每个目录启动一个进程。此字段不扩展 method allowlist,也不提供文件写入能力。 ### ACP shared workspace tools and live AGY models `agent.acp.workspace-files.v1` enables readonly workspace files. `agent.acp.terminal.v1` is advertised only while the Host Remote terminal setting is enabled. Both work for Cursor and Antigravity; session scope is supplied by the backend, never a client cwd. `agent.acp.antigravity.models.v1` enables the live installed AGY model catalog and confirmed per-conversation model/effort selection. Remote versions below `0.5.0` continue to ignore `workspaceTypes`; newer clients require these optional capabilities. - `agent.acp.call` method `dsh/toolCall` takes `{sessionId,backend,endpoint,args}`. `sessionId` is the native ID; every scope in `args` must equal its UI ID (`cursor:` or `acp:`). The fixed unary endpoints are `workspaceFiles/list|stat|read|readBytes|readAll` and `terminal/environment|shells|list|create|write|resize|rename|close`. - `agent.acp.stream.open` accepts an optional `tool:{backend,endpoint,args}` with only `workspaceFiles/changes|terminal/follow|terminal/retain`. Tool values use `agent.acp.frame` with the explicit `streamId` and `frame:{method:"dsh/workspaceTool",params:{value}}`. Agent updates continue to use session-scoped delivery. Existing stream close and disconnect cleanup apply; late opens after cancellation release their subscriptions. - Host scopes come only from canonical session creation or the Host transcript catalog. Missing scope, backend mismatch, cross-session IDs, path traversal and symlinks outside the root fail closed. Terminals keep the shared native snapshots, monotonic output sequences, device ownership and connection attachment policy. Disconnect retains terminals for reconnect; domain shutdown ends its processes. - `dsh/sessionModels` takes `{sessionId,backend:"antigravity"}` and returns `{current,routable,groups,failures}` in the existing session model shape. `dsh/selectModel` additionally takes `{model,reasoningEffort?}` and returns `{selected}` only after resuming the same AGY conversation with validated CLI `--model`/`--effort` flags. Model IDs and effort variants come from `agy models`; Thinking variants use their CLI model ID and do not pass an invalid `--effort thinking`. Selection is rejected during a prompt or another model change. Until an explicit choice is confirmed, `current.model` is `host-settings`; it does not claim a default model that the Host has not reported. Existing Host configuration and permissions remain active. Clients without these optional capabilities keep Host settings. The live model directory is cached for the Host backend lifetime; concurrent reads share discovery, failed discovery can retry, and reloading the backend invalidates the cache. Selecting the confirmed model and effort again is a no-op. Optional `agent.acp.antigravity.model-load.v1` means selection also loads a Host-discovered conversation directly with its target model and canonical cwd; clients may omit the preceding `session/load`. Without it, keep the load step. The Host emits a session update `{sessionUpdate:"model_selection",selected}` after confirming selection and before prompts with that selection. Clients preserve the selected model in each turn's message source and do not relabel earlier replies after a model change. AGY transcripts without model metadata must not be assigned a fabricated historical model. ACP model selection also accepts `backend:"cursor"|"claude"|"pi"|"opencode"` through the same `dsh/sessionModels` / `dsh/selectModel` envelope. Pi uses its wrapper's reported model/thinking options. Cursor, Claude and OpenCode use the session's `configOptions` (including grouped model choices), or legacy `models.availableModels`; no static catalog is advertised. The Host loads saved sessions only with catalog-validated cwd, deduplicates live loads, and excludes history replay from live streams. Cursor, Claude and OpenCode changes use fixed Host-local `session/set_config_option` keys `model` and `effort`, or legacy `session/set_model` when only legacy models are reported. These upstream methods are not exposed as generic Remote RPC. Selection is per session, rejected during prompts/another change, and acknowledged only after upstream success; modern config responses/updates must confirm the value. Effort choices apply only to the current model: select a different model first, then read its effort choices. Missing catalogs and rejected changes never become successful local selections. Cursor uses one process per session because its upstream model manager is process-wide (maximum 30 active sessions plus the catalog process); Claude keeps per-session model state in its existing adapter. Neither adds generic configuration RPC or fabricated choices. The display name is `Claude`, backend ID `claude`. ### Optional read-only history preview `codex.app.call` accepts `{method: "dsh/sessionHistory", params, previewHistory: true}` on supporting Hosts. Ordinary history is returned directly up to 2 MiB. Tool-result bodies over 4 KiB are represented by an opaque `remoteDetail: {threadId, detailId}` handle; the original remains in a per-authenticated-peer memory cache (64 entries, 16 MiB, ten minutes). `dsh/historyDetail` accepts exactly that pair, verifies the peer/thread/expiry and revalidates `thread/read` before returning the original through the existing bounded transfer. Handles never authorize mutations. Clients fall back to the existing read-only transfer only on `INVALID_MESSAGE` (older Host) or `RESPONSE_TOO_LARGE`; timeouts and authority failures are not replayed. ### Official plugin management (2026-10-22) The authenticated Typert channel allows only `pluginManager/listBundles`, `pluginManager/listPlugins`, `pluginManager/setBundleEnabled` and `pluginManager/setPluginEnabled` for the official plugin page. Enablement keeps the official `{ args: { name, enabled } }` / `{ args: { id, enabled } }` envelope and native saved/runtime/restart outcomes. The Host checks the current official inventory before mutation; unknown/read-only entries, Remote itself and bundles containing Remote are rejected. These endpoints cannot be opened as streams. Plugin configuration continues through the existing official `settings` endpoints; Remote access namespaces remain local-only. Installation, removal, arbitrary plugin-manager methods and legacy ApiProxy plugin management remain disallowed. This adds no new wire format or capability and requires an updated Host plugin.