dsh-device-fleet 0.0.0-stage → 0.1.0-dev.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +123 -0
- package/LICENSE +21 -0
- package/README.md +135 -2
- package/client.js +167 -0
- package/cordis.patch.yml +26 -0
- package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
- package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
- package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
- package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
- package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
- package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
- package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
- package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
- package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
- package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
- package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
- package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
- package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
- package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
- package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
- package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
- package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
- package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
- package/docs/adr/0019-execution-context-configured-locally.md +24 -0
- package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
- package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
- package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
- package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
- package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
- package/docs/notes/approval-inbox-phase.md +31 -0
- package/docs/notes/design-decisions-summary.md +49 -0
- package/docs/notes/dev-release.md +67 -0
- package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
- package/docs/notes/network-v1-v2-implementation.md +71 -0
- package/docs/notes/pairing-session-2026-11-09.md +30 -0
- package/docs/notes/remote-tests-2026-11-09.md +33 -0
- package/docs/notes/room-logic-phase.md +31 -0
- package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
- package/package.json +51 -3
- package/src/host/approval-inbox.js +135 -0
- package/src/host/approval-web.js +95 -0
- package/src/host/delegation.js +127 -0
- package/src/host/direct-call.js +209 -0
- package/src/host/execution-slot.js +96 -0
- package/src/host/index.js +10 -0
- package/src/host/room-service.js +324 -0
- package/src/host/store.js +156 -0
- package/src/host/tool-policy.js +151 -0
- package/src/host/tools.js +590 -0
- package/src/index.js +3 -0
- package/src/net/direct.js +69 -0
- package/src/net/discovery.js +105 -0
- package/src/net/fleet-node.js +509 -0
- package/src/net/identity.js +167 -0
- package/src/net/index.js +9 -0
- package/src/net/protocol.js +73 -0
- package/src/net/relay-client.js +258 -0
- package/src/net/relay-server.js +366 -0
- package/src/net/secure-channel.js +538 -0
- package/src/plugin/index.js +380 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# 网络层 v1/v2 实现说明
|
|
2
|
+
|
|
3
|
+
状态:**已实现并通过本机双端测试**(2026-11-09)。对应 ADR:[0017 网络层分阶段](../adr/0017-network-phases-direct-and-relay.md)。
|
|
4
|
+
|
|
5
|
+
## 文件
|
|
6
|
+
|
|
7
|
+
| 文件 | 作用 |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `src/net/identity.js` | 设备身份(Ed25519 签名 + X25519 协商)、`fleetKey` 邀请编解码、成员证明 HMAC、账号指纹、Relay 注册 token |
|
|
10
|
+
| `src/net/secure-channel.js` | 端到端安全通道:HELLO/HELLO_ACK 握手、签名与成员证明校验、X25519+HKDF 派生、AES-256-GCM 帧、计数防重放 |
|
|
11
|
+
| `src/net/protocol.js` | 版本号、帧类型(rpc.req/res/err、event、ping/pong、ready)、稳定 JSON、错误序列化 |
|
|
12
|
+
| `src/net/direct.js` | v1 直连:TCP 监听器 + 出站拨号(带超时) |
|
|
13
|
+
| `src/net/discovery.js` | v1 局域网发现:UDP 组播公告(fleetHash + deviceId + 直连端口) |
|
|
14
|
+
| `src/net/relay-server.js` | v2 Relay:控制面(注册/连接/接受/拒绝)+ 数据面(两条 TCP 拼接,只搬密文) |
|
|
15
|
+
| `src/net/relay-client.js` | v2 客户端:控制连接、自动重连、请求/接受连接、数据 socket |
|
|
16
|
+
| `src/net/fleet-node.js` | 设备门面:roster/TOFU、直连/发现/Relay 选择、RPC、事件、关闭 |
|
|
17
|
+
| `test/net.test.mjs` | 7 个测试(见下) |
|
|
18
|
+
|
|
19
|
+
## 线格式
|
|
20
|
+
|
|
21
|
+
### 安全通道握手(换行分隔 JSON)
|
|
22
|
+
|
|
23
|
+
1. 发起方 → 响应方 `hello`:`{t:'hello', v:1, fleetId, deviceId, name, edPublic, xPublic, ephX, nonce, ts, accountFingerprint, proof, sig}`
|
|
24
|
+
- `proof = HMAC(fleetKey, "member|fleetId|deviceId|edPublic|xPublic|accountFingerprint")`
|
|
25
|
+
- `sig = Ed25519(稳定 JSON(除 sig 外的全部字段))`
|
|
26
|
+
2. 响应方校验 fleet/时间戳/签名/成员证明/账号指纹/已固定公钥,回复 `hello.ack`(同构)。
|
|
27
|
+
3. 双方以 `X25519(ephX_i, ephX_r)` 为 IKM、`sha256(稳定 JSON(hello,ack))` 为 salt,HKDF 派生:
|
|
28
|
+
- 发起→响应 32B key + 4B nonce 前缀;响应→发起 32B key + 4B nonce 前缀。
|
|
29
|
+
4. 双方各发一条加密 `ready` 帧;收到对方 `ready` 后通道可用(`ready` 事件)。
|
|
30
|
+
|
|
31
|
+
### 加密帧
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
| u32be length | AES-256-GCM ciphertext | 16B tag |
|
|
35
|
+
nonce = 4B 方向前缀 || u64be 计数器;AAD = "dsh-device-fleet/frame/v1"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
计数器严格递增、按 TCP 顺序校验,重放/乱序/篡改都会使 `openFrame` 失败并断链。
|
|
39
|
+
|
|
40
|
+
## Relay 协议(v2)
|
|
41
|
+
|
|
42
|
+
控制面(TCP,换行分隔 JSON):
|
|
43
|
+
- `register {fleetId, deviceId, ts, token}` → `register.ok`;`token = HMAC(fleetKey, "relay-register|fleetId|deviceId|ts")`;Relay 用配置的 fleetKey 校验,时间戳 ±120s。
|
|
44
|
+
- `connect {reqId, targetDeviceId}` → 请求方收 `connect.pending`,目标方收 `incoming {reqId, fromDeviceId}`。
|
|
45
|
+
- 目标方 `accept {reqId}` → 双方收 `connect.ready {reqId, dataPort, token}`;`reject` → 请求方收 `connect.fail`。
|
|
46
|
+
- 心跳 `ping`/`pong`。
|
|
47
|
+
|
|
48
|
+
数据面(第二个 TCP 端口):双方分别连接并发送一行头
|
|
49
|
+
`{fleetId, deviceId, reqId, token}\n`,Relay 校验后把两条 socket 的原始字节互相 pipe。**Relay 不解析、不终止加密**,与 secure-channel 的密文完全兼容。
|
|
50
|
+
|
|
51
|
+
## 已验证(`node test/net.test.mjs`,7/7 通过)
|
|
52
|
+
|
|
53
|
+
1. 邀请编解码携带 fleet/账号指纹/Relay 地址;
|
|
54
|
+
2. v1 直连:认证握手 + RPC echo + 双向 roster TOFU 固定;
|
|
55
|
+
3. 不同 fleet 或不同账号指纹的握手被拒;
|
|
56
|
+
4. roster 中固定公钥与对端不一致时被拒;
|
|
57
|
+
5. v2 Relay:两设备经中继完成加密 RPC,Relay 记录到 splice;
|
|
58
|
+
6. Relay 拒绝不知道 fleetKey 的客户端;
|
|
59
|
+
7. LAN 组播发现发现对端并通过发现地址直连(组播不可用时测试自动跳过)。
|
|
60
|
+
|
|
61
|
+
运行:`node test/net.test.mjs`(受限沙箱里 `node --test test/` 需要 spawn 子进程,可能被拒;直接跑测试文件即可)。
|
|
62
|
+
|
|
63
|
+
## 当前限制 / 后续
|
|
64
|
+
|
|
65
|
+
- **无 NAT 打洞**:跨网一律走 Relay(v3 再做打洞)。
|
|
66
|
+
- **Relay 控制面为明文 TCP**:部署时应在 TLS/反代之后,或后续加 TLS。
|
|
67
|
+
- **TOFU 无密钥轮换/吊销**:Roster 只固定公钥,设备重装会变新身份;后续加签名 roster 与吊销。
|
|
68
|
+
- **无二进制流式分块**:RPC 参数/结果为 JSON(大文件需后续加 chunk 帧)。
|
|
69
|
+
- **无重放窗口**:严格顺序计数已防重放;断线重连即新会话,不做跨会话恢复。
|
|
70
|
+
- **无速率限制/审计**:Relay 与节点都未做限流;上线前补。
|
|
71
|
+
- **账号指纹需上层注入**:网络层只接收 `accountFingerprint` 字符串,由 Host 插件从 `deepseekAccount.getDeviceIdentity()` 派生。
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# 首次配对实录(2026-11-09)
|
|
2
|
+
|
|
3
|
+
## 环境
|
|
4
|
+
|
|
5
|
+
- 设备 A:本机(Windows,DSH desktop profile,`device-fleet` 0.1.0-dev.3)
|
|
6
|
+
- deviceId:`50fdaf90-f0aa-440e-9fa6-c60ba5051001`,deviceName:`desktop`
|
|
7
|
+
- 直连:`0.0.0.0:60506`(热点网段 `192.168.137.1:60506`)
|
|
8
|
+
- allowedRoots:`E:/Dsh_test`
|
|
9
|
+
- 设备 B:另一台设备(同一热点 `192.168.137.0/24`,plugin 0.1.0-dev.3)
|
|
10
|
+
- deviceId:`c2d42572-e5f0-4d7e-9fff-c28d9a668439`
|
|
11
|
+
- 热点网段内网段:A `192.168.137.1/24`,B 为客户端之一。
|
|
12
|
+
|
|
13
|
+
## 过程
|
|
14
|
+
|
|
15
|
+
1. dev.3 邀请携带 `endpoints`(A 的 `210.73.18.88:60506` 与 `192.168.137.1:60506`)。
|
|
16
|
+
2. B 安装 dev.3 → `fleet_join` 新邀请 → 重启;插件读取 enrollment endpoints,自动回连 A。
|
|
17
|
+
3. 结果:双方握手成功,roster 互固定公钥;A 侧 `fleet_list` 显示 B `online`,`fleet_status.connected` 含 B。
|
|
18
|
+
4. `fleet_tools(device=B)` 成功(说明 RPC/加密通道正常)。
|
|
19
|
+
5. `fleet_read(device=B, path=E:/Dsh_test/hello-from-A.txt)` 返回 `denied: no allowed roots configured on this device` —— 策略按设计拦截,B 侧尚未配置 allowedRoots。
|
|
20
|
+
|
|
21
|
+
## 结论
|
|
22
|
+
|
|
23
|
+
- **网络层 v1 直连 + 配对流程打通**(组播发现在该热点下不可用,邀请 endpoints 自动回连解决了问题)。
|
|
24
|
+
- **Tool Policy 生效**:目标设备未配置允许根时直调被拒。
|
|
25
|
+
- 待配置 B 的 `policy.allowedRoots` / `execution.cwd` 后可双向直调;`fleet_run` 待审批收件箱 UI。
|
|
26
|
+
|
|
27
|
+
## 复现要点
|
|
28
|
+
|
|
29
|
+
- 邀请里的 endpoints 由 `networkInterfaces()` 的非回环 IPv4 + 当前 direct port 组成;direct 端口每次插件重载会变,重载后需重新生成邀请或手动 `fleet_connect`。
|
|
30
|
+
- 同一账号指纹:邀请携带 `accountFingerprint`;B 登录不同账号会在加载时被标记 mismatch 并拒绝连接。
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# 跨设备调用实测(2026-11-09,dev.6)
|
|
2
|
+
|
|
3
|
+
环境:A `desktop` / `50fdaf90-…`(端口固定 60580);B `laptop` / `c2d42572-…`(dev.6,同一热点)。
|
|
4
|
+
测试文件:A `E:\Dsh_test\hello-from-A.txt`;B `E:\Dsh_test\hello-from-B.txt`。
|
|
5
|
+
|
|
6
|
+
## 结果
|
|
7
|
+
|
|
8
|
+
| # | 调用 | 结果 | 耗时 |
|
|
9
|
+
|---|---|---|---|
|
|
10
|
+
| 1 | `fleet_read(B, E:/Dsh_test/hello-from-B.txt)` | ✅ 返回 `hello from device B` | 48ms |
|
|
11
|
+
| 2 | `fleet_call(B, tool="read", {file_path:…hello-from-B.txt})` | ✅ 返回同一内容(原始工具直调) | 4ms |
|
|
12
|
+
| 3 | `fleet_fetch(B, hello-from-B.txt)` | ✅ 返回文本 | 1ms |
|
|
13
|
+
| 4 | `fleet_run(B, "echo hello-from-B")` | ⚠️ `approval_denied`(B 策略 run=ask,审批 UI 未实现,fail-closed 正确) | 即时 |
|
|
14
|
+
| 5 | `fleet_delegate(B, "读取 hello-from-B.txt 并原样返回")` | ✅ B 的 agent(spawn 子会话 `1a768f89…`)读文件并回传最终文本 | 2.7s |
|
|
15
|
+
| 6 | `fleet_delegate(B, "用 fleet_read 读 A 的 hello-from-A.txt 并原样返回")` | ✅ B 的 agent 反向调用 A 的 `read`,回传 `hello from device A (fleet test)`(子会话 `515f8c1b…`) | 2.5s |
|
|
16
|
+
|
|
17
|
+
## 结论
|
|
18
|
+
|
|
19
|
+
- v1 直连 + 端到端加密 + 配对/roster 固定公钥:**通过**;
|
|
20
|
+
- 直调(typed 快捷工具 / `fleet_call` 原始工具 / fetch):**通过**;
|
|
21
|
+
- Tool Policy 与 fail-closed 审批:**按设计生效**;
|
|
22
|
+
- 跨设备委派(B 的 agent 在本机执行并回传;B 的 agent 反向调 A 工具):**通过**;
|
|
23
|
+
- 遗留:审批收件箱 UI(`run`/`delegate` 的 `ask` 档目前只能拒绝或配置 auto);Room/Room Board/设备页 UI 未开始;委派返回文本里 `[reasoning]` 是结果里的 reasoning block 展示,可后续过滤。
|
|
24
|
+
|
|
25
|
+
## 复现命令(A 侧)
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
fleet_list()
|
|
29
|
+
fleet_read(device="c2d42572-…", path="E:/Dsh_test/hello-from-B.txt")
|
|
30
|
+
fleet_call(device="c2d42572-…", tool="read", parameters={file_path:"E:/Dsh_test/hello-from-B.txt"})
|
|
31
|
+
fleet_delegate(device="c2d42572-…", task="读取 E:/Dsh_test/hello-from-B.txt 并原样返回")
|
|
32
|
+
fleet_delegate(device="c2d42572-…", task="用 fleet_read 读设备 50fdaf90-… 的 E:/Dsh_test/hello-from-A.txt 并原样返回")
|
|
33
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Room 逻辑层实测(2026-11-09,dev.8,单机模拟参与者)
|
|
2
|
+
|
|
3
|
+
在当前这台 DSH 内,用两个**真实本地 session/agent** 充当 Room 参与者,验证寻址唤醒与看板串行交接,不依赖第二台设备。
|
|
4
|
+
|
|
5
|
+
## 流程与结果
|
|
6
|
+
|
|
7
|
+
1. `room_create` → roomId = 当前会话 `session-269260c9-…`,host 成员 `host:session-269260c9-…`。
|
|
8
|
+
2. `room_add_local(name="worker-a", cwd=E:/Dsh_test)` → 真实 session `session-d063026e-…`。
|
|
9
|
+
3. `room_send(to=[worker-a], text="读取 hello-from-A.txt 并用 room_send 回复 host")` → `woke worker-a`;
|
|
10
|
+
- worker-a 的 agent 真实跑了一轮模型 + 本机 `read`,并在 3.4s 内调用 `room_send` 回传 `hello from device A (fleet test)`;
|
|
11
|
+
- transcript 两条消息,完整可见。
|
|
12
|
+
4. `room_add_local(name="worker-b")`;看板:
|
|
13
|
+
- taskA `读取 hello-from-A.txt` owner=worker-a;
|
|
14
|
+
- taskB `汇总读取结果` owner=worker-b,`dependsOn=[taskA]`;
|
|
15
|
+
- taskC `并行:独立检查` owner=worker-a,无依赖。
|
|
16
|
+
5. `room_task_update(taskA, done)` → 返回 `handed off to worker-b`;worker-b 被点名唤醒,30s 内完成汇总并 `room_send` 给 host(内容含前序结果、交叉校验、并行任务仍 pending 的说明),同时把自己 taskB 置为 running。
|
|
17
|
+
6. `room_task_list`:taskA done、taskB running、taskC pending(并行任务未被串行交接影响)。
|
|
18
|
+
7. `room_close`。
|
|
19
|
+
|
|
20
|
+
## 结论
|
|
21
|
+
|
|
22
|
+
- Room 生命周期、成员管理、寻址消息、未点名不唤醒、host 权威日志:单机逻辑通过;
|
|
23
|
+
- Room Board `dependsOn` 串行交接 / 并行任务不阻塞:真实 agent 验证通过;
|
|
24
|
+
- 参与者使用的都是真实 DSH agent(各自 session),不是 mock。
|
|
25
|
+
|
|
26
|
+
## 限制(下一步)
|
|
27
|
+
|
|
28
|
+
- 远程参与者传输(把 local participant 换成远端设备成员)尚未接入;
|
|
29
|
+
- Client 面板/房间视图未做(用户无法在 GUI 直接看 room 时间线/看板);
|
|
30
|
+
- 房间状态在插件内存 + host session 事件里,插件重载后需要 rehydrate(未实现);
|
|
31
|
+
- 房间策略仅存储,尚未强制(invite/join/maxMembers 中 maxMembers 已生效)。
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Step 1:Host 插件接入 + 直调 MVP(实现说明)
|
|
2
|
+
|
|
3
|
+
状态:**代码完成、单元测试通过(net 8/8 + host 6/6),尚未在真实 DSH 进程内安装联调**。
|
|
4
|
+
|
|
5
|
+
## 本轮落地
|
|
6
|
+
|
|
7
|
+
| 文件 | 内容 |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `src/plugin/index.js` | Cordis Host 插件入口:`name`/`inject`/`Config`/`apply`;加载持久状态、启动 FleetNode、注册 6 个 `fleet_*` 工具、常驻提示 section、首次使用手册、编排钩子一行提示、账号指纹绑定、退出清理 |
|
|
10
|
+
| `src/host/store.js` | `$DSH_HOME/device-fleet/` 下的身份/Fleet/roster/config/enrollment 持久化(原子写);邀请创建与接受;账号指纹比对 |
|
|
11
|
+
| `src/host/tool-policy.js` | 设备本地 Tool Policy:allowedRoots、read/list/grep/fetch/run/tool 六种能力的 auto/ask/deny、原始工具 allowlist、一次性/TTL 授权 |
|
|
12
|
+
| `src/host/execution-slot.js` | Execution Slot:懒创建隐藏 DSH 会话(`sessionController.create` → `resolveAgent`),用 `ctx.tools.execute` 在该 agent 上下文里执行工具,不跑模型 |
|
|
13
|
+
| `src/host/direct-call.js` | `DirectCallHost`(B 侧:`fleet.ping/info/tools/call`,策略→审批→执行)、`DirectCallClient`(A 侧 RPC 封装与结果渲染) |
|
|
14
|
+
| `src/host/tools.js` | 6 个工具定义:`fleet_list` / `fleet_tools` / `fleet_call` + `fleet_read` / `fleet_fetch` / `fleet_run`(typed 快捷工具走同一 `fleet.call` 路径) |
|
|
15
|
+
| `cordis.patch.yml` | 插件挂载行与安全默认配置 |
|
|
16
|
+
| 网络层 | 握手新增 `pluginVersion` / `dshVersion` / `capabilities` 严格校验(ADR-0024);新增版本不匹配测试 |
|
|
17
|
+
|
|
18
|
+
## 配置示例(profile patch / 插件设置页)
|
|
19
|
+
|
|
20
|
+
```yaml
|
|
21
|
+
- id: device-fleet
|
|
22
|
+
config:
|
|
23
|
+
deviceName: desktop
|
|
24
|
+
discovery: true
|
|
25
|
+
relay: { host: relay.local, port: 47800, dataPort: 47801 }
|
|
26
|
+
execution:
|
|
27
|
+
cwd: D:/work # Execution Slot 的工作目录
|
|
28
|
+
agentPreset: standard # 必须是本机存在的 preset
|
|
29
|
+
shellTool: pwsh
|
|
30
|
+
policy:
|
|
31
|
+
allowedRoots: [D:/work] # 直调只能访问这些根
|
|
32
|
+
capabilities: { read: auto, list: auto, grep: auto, fetch: auto, run: ask, tool: deny }
|
|
33
|
+
allowedTools: []
|
|
34
|
+
approvalTtlMs: 900000
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 已测试
|
|
38
|
+
|
|
39
|
+
- 网络层 8 个测试:直连 RPC、组播发现、Relay 加密中继、跨 fleet/跨账号/固定公钥/版本不匹配拒绝、Relay 鉴权失败;
|
|
40
|
+
- Host 层 6 个测试:状态持久化与邀请入网、Tool Policy(根目录/能力档位/allowlist/TTL)、Execution Slot 懒创建与结果归一、DirectCallHost 的策略/审批/执行、工具 handler 的列表与快捷路由、客户端渲染;
|
|
41
|
+
- 全部 `src/**/*.js` 通过 `node --check`。
|
|
42
|
+
|
|
43
|
+
## 未验证 / 已知缺口
|
|
44
|
+
|
|
45
|
+
1. **未在真实 DSH 进程里加载**:`@deepseek-ai/dsh-tools`/`schemastery`/`dsh-llm` 的 peer 解析、Config schema 校验、`ctx.*` API 细节需要在安装后确认(sandbox 里无法 import profile 依赖)。
|
|
46
|
+
2. **审批收件箱 UI 未实现**:`ask` 档当前 fail-closed(`autoApproveAsk: true` 仅供开发);收件箱、TTL 授权、通知属于下一步。
|
|
47
|
+
3. **`fleet_run` 参数尚简单**:目前只传 `command`;cwd/超时/环境变量待补。
|
|
48
|
+
4. **`fleet_fetch` 只返回文本**(≤ 限制内),大文件/二进制与写入 A 侧工作区待做。
|
|
49
|
+
5. **Room / Board / 委派 / UI 未开始**;插件尚未安装到 profile(需要一次 danger-full-access 审批)。
|
|
50
|
+
6. 账号服务不可用时 `accountFingerprint = null`,握手不做账号比对;需要在真实登录态下验证。
|
|
51
|
+
|
|
52
|
+
## 下一步
|
|
53
|
+
|
|
54
|
+
1. 安装 bundle 到 `desktop` profile(`plugin_manager install_bundle`,需批准);
|
|
55
|
+
2. 在真实 DSH 里验证:插件加载、6 个工具出现在模型工具表、`fleet_list` 正常;
|
|
56
|
+
3. 用第二台设备(或第二个 DSH_HOME/实例)完成一次"fleet_read 读对端文件 + fleet_run 跑命令(带 B 侧审批)"的端到端联调;
|
|
57
|
+
4. 再进入 Room / Board / 委派与审批 UI。
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-device-fleet",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-dev.10",
|
|
4
|
+
"description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/plugin/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/plugin/index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./net": "./src/net/index.js",
|
|
11
|
+
"./host": "./src/host/index.js",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22"
|
|
16
|
+
},
|
|
17
|
+
"dsh": {
|
|
18
|
+
"bundle": {
|
|
19
|
+
"patch": "./cordis.patch.yml"
|
|
20
|
+
},
|
|
21
|
+
"client": {
|
|
22
|
+
"platform": "web",
|
|
23
|
+
"immediately": true,
|
|
24
|
+
"inject": [
|
|
25
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@deepseek-ai/cordis": "~4.1.0",
|
|
31
|
+
"@deepseek-ai/dsh-tools": "^0.2.2-rc.1",
|
|
32
|
+
"@deepseek-ai/dsh-llm": "^0.2.2-rc.1",
|
|
33
|
+
"@deepseek-ai/schemastery": "~3.19.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node test/net.test.mjs && node test/host.test.mjs",
|
|
37
|
+
"test:net": "node test/net.test.mjs",
|
|
38
|
+
"test:host": "node test/host.test.mjs"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src",
|
|
42
|
+
"client.js",
|
|
43
|
+
"cordis.patch.yml",
|
|
44
|
+
"README.md",
|
|
45
|
+
"CONTEXT.md",
|
|
46
|
+
"docs",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"tag": "dev"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT"
|
|
6
54
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Local approval inbox (ADR-0020): a target device asks its own user before a
|
|
2
|
+
// remote call/delegation with the `ask` policy can run. v1 has no client UI yet;
|
|
3
|
+
// the inbox is exposed through `fleet_approvals` / `fleet_approve` /
|
|
4
|
+
// `fleet_deny` and can be exercised with `fleet_simulate_request` while the
|
|
5
|
+
// client panel is still missing.
|
|
6
|
+
import { EventEmitter } from 'node:events'
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
export const APPROVAL_SCOPES = ['once', 'ttl']
|
|
10
|
+
|
|
11
|
+
const SUMMARY_ARG_LENGTH = 160
|
|
12
|
+
|
|
13
|
+
function summarizeArgs(args) {
|
|
14
|
+
try {
|
|
15
|
+
const text = JSON.stringify(args ?? {})
|
|
16
|
+
return text.length > SUMMARY_ARG_LENGTH ? `${text.slice(0, SUMMARY_ARG_LENGTH)}…` : text
|
|
17
|
+
} catch {
|
|
18
|
+
return '[unserializable arguments]'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ApprovalInbox extends EventEmitter {
|
|
23
|
+
constructor({ ttlMs = 60_000, maxPending = 50, now = Date.now } = {}) {
|
|
24
|
+
super()
|
|
25
|
+
this.ttlMs = ttlMs
|
|
26
|
+
this.maxPending = maxPending
|
|
27
|
+
this.now = now
|
|
28
|
+
this.closed = false
|
|
29
|
+
/** @type {Map<string, any>} */
|
|
30
|
+
this.pending = new Map()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ask the local user. Resolves `{ approved, scope?, reason? }` when approved,
|
|
35
|
+
* denied, expired or closed.
|
|
36
|
+
*/
|
|
37
|
+
request(request) {
|
|
38
|
+
if (this.closed) return Promise.resolve({ approved: false, reason: 'inbox-closed' })
|
|
39
|
+
if (this.pending.size >= this.maxPending) {
|
|
40
|
+
return Promise.resolve({ approved: false, reason: 'inbox-full' })
|
|
41
|
+
}
|
|
42
|
+
const id = randomUUID()
|
|
43
|
+
const createdAt = this.now()
|
|
44
|
+
const entry = {
|
|
45
|
+
id,
|
|
46
|
+
fromDeviceId: request.fromDeviceId ?? 'unknown',
|
|
47
|
+
capability: request.capability ?? 'tool',
|
|
48
|
+
tool: request.tool ?? null,
|
|
49
|
+
reason: request.reason ?? '',
|
|
50
|
+
argsSummary: summarizeArgs(request.args),
|
|
51
|
+
createdAt,
|
|
52
|
+
expiresAt: createdAt + this.ttlMs,
|
|
53
|
+
status: 'pending',
|
|
54
|
+
}
|
|
55
|
+
entry.promise = new Promise((resolve) => {
|
|
56
|
+
entry.resolve = resolve
|
|
57
|
+
})
|
|
58
|
+
entry.timer = setTimeout(() => this.expire(id), this.ttlMs)
|
|
59
|
+
entry.timer.unref?.()
|
|
60
|
+
this.pending.set(id, entry)
|
|
61
|
+
this.emit('request', this.summary(entry))
|
|
62
|
+
return entry.promise
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
summary(entry) {
|
|
66
|
+
return {
|
|
67
|
+
id: entry.id,
|
|
68
|
+
fromDeviceId: entry.fromDeviceId,
|
|
69
|
+
capability: entry.capability,
|
|
70
|
+
tool: entry.tool,
|
|
71
|
+
reason: entry.reason,
|
|
72
|
+
argsSummary: entry.argsSummary,
|
|
73
|
+
createdAt: entry.createdAt,
|
|
74
|
+
expiresAt: entry.expiresAt,
|
|
75
|
+
expiresInMs: Math.max(0, entry.expiresAt - this.now()),
|
|
76
|
+
status: entry.status,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
list() {
|
|
81
|
+
return [...this.pending.values()]
|
|
82
|
+
.filter((entry) => entry.status === 'pending')
|
|
83
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
84
|
+
.map((entry) => this.summary(entry))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
approve(id, { scope = 'once' } = {}) {
|
|
88
|
+
const entry = this.settle(id, 'approved')
|
|
89
|
+
if (!entry) throw Object.assign(new Error(`unknown approval request: ${String(id)}`), { code: 'unknown-request' })
|
|
90
|
+
const chosen = APPROVAL_SCOPES.includes(scope) ? scope : 'once'
|
|
91
|
+
entry.resolve({ approved: true, scope: chosen })
|
|
92
|
+
this.emit('approved', { ...this.summary(entry), scope: chosen })
|
|
93
|
+
return { ...this.summary(entry), scope: chosen }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
deny(id, reason = 'denied by local user') {
|
|
97
|
+
const entry = this.settle(id, 'denied')
|
|
98
|
+
if (!entry) throw Object.assign(new Error(`unknown approval request: ${String(id)}`), { code: 'unknown-request' })
|
|
99
|
+
entry.resolve({ approved: false, reason })
|
|
100
|
+
this.emit('denied', { ...this.summary(entry), reason })
|
|
101
|
+
return { ...this.summary(entry), reason }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
expire(id) {
|
|
105
|
+
const entry = this.pending.get(id)
|
|
106
|
+
if (!entry || entry.status !== 'pending') return
|
|
107
|
+
entry.status = 'expired'
|
|
108
|
+
this.pending.delete(id)
|
|
109
|
+
clearTimeout(entry.timer)
|
|
110
|
+
entry.resolve({ approved: false, reason: 'approval-timeout' })
|
|
111
|
+
this.emit('expired', this.summary(entry))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
settle(id, status) {
|
|
115
|
+
const entry = this.pending.get(id)
|
|
116
|
+
if (!entry || entry.status !== 'pending') return undefined
|
|
117
|
+
entry.status = status
|
|
118
|
+
this.pending.delete(id)
|
|
119
|
+
clearTimeout(entry.timer)
|
|
120
|
+
return entry
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Close the inbox: every pending request fails closed. */
|
|
124
|
+
close() {
|
|
125
|
+
if (this.closed) return
|
|
126
|
+
this.closed = true
|
|
127
|
+
for (const entry of this.pending.values()) {
|
|
128
|
+
clearTimeout(entry.timer)
|
|
129
|
+
entry.status = 'closed'
|
|
130
|
+
entry.resolve({ approved: false, reason: 'inbox-closed' })
|
|
131
|
+
}
|
|
132
|
+
this.pending.clear()
|
|
133
|
+
this.emit('closed')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Local approval HTTP surface for the approval panel (dev.9). The page is
|
|
2
|
+
// served by the DSH web carrier, so browser fetch carries the session cookie.
|
|
3
|
+
// All decisions go through ApprovalInbox; nothing here bypasses Tool Policy.
|
|
4
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
5
|
+
|
|
6
|
+
function json(res, status, value) {
|
|
7
|
+
const text = JSON.stringify(value)
|
|
8
|
+
res.statusCode = status
|
|
9
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
10
|
+
res.setHeader('cache-control', 'no-store')
|
|
11
|
+
res.end(text)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function readJsonBody(req) {
|
|
15
|
+
const chunks = []
|
|
16
|
+
let size = 0
|
|
17
|
+
for await (const chunk of req) {
|
|
18
|
+
size += chunk.length
|
|
19
|
+
if (size > MAX_BODY_BYTES) throw Object.assign(new Error('request body is too large'), { code: 'body-too-large' })
|
|
20
|
+
chunks.push(chunk)
|
|
21
|
+
}
|
|
22
|
+
if (chunks.length === 0) return {}
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
25
|
+
} catch {
|
|
26
|
+
throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** HTTP-free decision helper shared by the routes and unit tests. */
|
|
31
|
+
export function createApprovalApi({ inbox }) {
|
|
32
|
+
return {
|
|
33
|
+
list() {
|
|
34
|
+
return { approvals: inbox.list() }
|
|
35
|
+
},
|
|
36
|
+
decide(body = {}) {
|
|
37
|
+
const { id, action } = body
|
|
38
|
+
if (typeof id !== 'string' || id === '') {
|
|
39
|
+
throw Object.assign(new Error('id is required'), { code: 'bad-request' })
|
|
40
|
+
}
|
|
41
|
+
if (action === 'approve') {
|
|
42
|
+
const scope = body.scope === 'ttl' ? 'ttl' : 'once'
|
|
43
|
+
return { ok: true, result: inbox.approve(id, { scope }) }
|
|
44
|
+
}
|
|
45
|
+
if (action === 'deny') {
|
|
46
|
+
return { ok: true, result: inbox.deny(id, typeof body.reason === 'string' ? body.reason : 'denied by local user') }
|
|
47
|
+
}
|
|
48
|
+
throw Object.assign(new Error('action must be approve or deny'), { code: 'bad-request' })
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Register the approval routes on the DSH web carrier.
|
|
55
|
+
* @returns {() => void} disposer
|
|
56
|
+
*/
|
|
57
|
+
export function registerApprovalRoutes({ webServer, inbox, logger = null }) {
|
|
58
|
+
if (!webServer?.register || !inbox) return () => {}
|
|
59
|
+
const api = createApprovalApi({ inbox })
|
|
60
|
+
const disposers = []
|
|
61
|
+
disposers.push(
|
|
62
|
+
webServer.register({
|
|
63
|
+
kind: 'exact',
|
|
64
|
+
path: '/device-fleet/approvals',
|
|
65
|
+
handler: async (req, res) => {
|
|
66
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
|
|
67
|
+
try {
|
|
68
|
+
json(res, 200, api.list())
|
|
69
|
+
} catch (error) {
|
|
70
|
+
logger?.warn?.(`[device-fleet] approval list failed: ${String(error?.message ?? error)}`)
|
|
71
|
+
json(res, 500, { error: 'internal error' })
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
disposers.push(
|
|
77
|
+
webServer.register({
|
|
78
|
+
kind: 'exact',
|
|
79
|
+
path: '/device-fleet/approvals/decide',
|
|
80
|
+
handler: async (req, res) => {
|
|
81
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
82
|
+
try {
|
|
83
|
+
const body = await readJsonBody(req)
|
|
84
|
+
json(res, 200, api.decide(body))
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const status = ['bad-request', 'bad-json', 'body-too-large', 'unknown-request'].includes(error?.code) ? 400 : 500
|
|
87
|
+
json(res, status, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
return () => {
|
|
93
|
+
for (const dispose of disposers) dispose()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Cross-device delegation (ADR-0015): the caller asks the target device's
|
|
2
|
+
// agent to do a one-shot task and gets the final assistant output back.
|
|
3
|
+
//
|
|
4
|
+
// On the target device this runs through the official SubagentProvider
|
|
5
|
+
// contract with `delivery: 'caller'`, so the child agent's own model turn and
|
|
6
|
+
// tools run on that device, and the `result` promise resolves with the final
|
|
7
|
+
// content blocks. No Room, no shared history: delegation is a one-shot task.
|
|
8
|
+
const DEFAULT_DELEGATE_TIMEOUT_MS = 180_000
|
|
9
|
+
const DEFAULT_PROVIDER = 'spawn'
|
|
10
|
+
const DEFAULT_LABEL = 'fleet delegation'
|
|
11
|
+
|
|
12
|
+
function textOf(result) {
|
|
13
|
+
const blocks = Array.isArray(result?.output) ? result.output : []
|
|
14
|
+
return blocks
|
|
15
|
+
.map((block) => (block?.type === 'text' ? block.text : `[${String(block?.type ?? 'block')}]`))
|
|
16
|
+
.join('\n')
|
|
17
|
+
.trim()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function withTimeout(promise, timeoutMs, onTimeout) {
|
|
21
|
+
let timer
|
|
22
|
+
return Promise.race([
|
|
23
|
+
promise.finally(() => clearTimeout(timer)),
|
|
24
|
+
new Promise((_, reject) => {
|
|
25
|
+
timer = setTimeout(() => reject(onTimeout()), timeoutMs)
|
|
26
|
+
timer.unref?.()
|
|
27
|
+
}),
|
|
28
|
+
])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class DelegationHost {
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} options
|
|
34
|
+
* @param {object} options.subagents ctx.subagents service
|
|
35
|
+
* @param {object} options.slot ExecutionSlot
|
|
36
|
+
* @param {import('./tool-policy.js').ToolPolicy} options.policy
|
|
37
|
+
* @param {(request: object) => Promise<boolean>} [options.requestApproval]
|
|
38
|
+
* @param {string} [options.deviceId]
|
|
39
|
+
* @param {string} [options.defaultCwd]
|
|
40
|
+
* @param {string} [options.defaultAgentPreset]
|
|
41
|
+
* @param {string} [options.provider]
|
|
42
|
+
* @param {number} [options.timeoutMs]
|
|
43
|
+
*/
|
|
44
|
+
constructor({
|
|
45
|
+
subagents,
|
|
46
|
+
slot,
|
|
47
|
+
policy,
|
|
48
|
+
requestApproval,
|
|
49
|
+
deviceId,
|
|
50
|
+
defaultCwd,
|
|
51
|
+
defaultAgentPreset,
|
|
52
|
+
provider = DEFAULT_PROVIDER,
|
|
53
|
+
timeoutMs = DEFAULT_DELEGATE_TIMEOUT_MS,
|
|
54
|
+
}) {
|
|
55
|
+
this.subagents = subagents
|
|
56
|
+
this.slot = slot
|
|
57
|
+
this.policy = policy
|
|
58
|
+
this.requestApproval = requestApproval
|
|
59
|
+
this.deviceId = deviceId
|
|
60
|
+
this.defaultCwd = defaultCwd
|
|
61
|
+
this.defaultAgentPreset = defaultAgentPreset
|
|
62
|
+
this.provider = provider
|
|
63
|
+
this.timeoutMs = timeoutMs
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async delegate({ fromDeviceId, request = {} }) {
|
|
67
|
+
const task = typeof request.task === 'string' ? request.task.trim() : ''
|
|
68
|
+
if (task === '') throw Object.assign(new Error('delegate needs a non-empty task'), { code: 'bad-request' })
|
|
69
|
+
|
|
70
|
+
const decision = this.policy.decide({ capability: 'delegate', args: {} })
|
|
71
|
+
if (decision.decision === 'deny') {
|
|
72
|
+
throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability: 'delegate' } })
|
|
73
|
+
}
|
|
74
|
+
if (decision.decision === 'ask') {
|
|
75
|
+
const outcome =
|
|
76
|
+
typeof this.requestApproval === 'function'
|
|
77
|
+
? await this.requestApproval({ fromDeviceId, capability: 'delegate', tool: 'delegate', args: { task }, reason: decision.reason })
|
|
78
|
+
: { approved: false, reason: 'no approval channel' }
|
|
79
|
+
const approved = outcome === true || outcome?.approved === true
|
|
80
|
+
if (!approved) {
|
|
81
|
+
throw Object.assign(new Error(outcome?.reason ?? 'local approval was not granted'), { code: 'approval_denied' })
|
|
82
|
+
}
|
|
83
|
+
this.policy.grant(decision.key, { scope: outcome?.scope === 'ttl' ? 'ttl' : 'once' })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const agent = await this.slot.ensureAgent()
|
|
87
|
+
const controller = new AbortController()
|
|
88
|
+
const startedAt = Date.now()
|
|
89
|
+
let activation
|
|
90
|
+
try {
|
|
91
|
+
activation = await this.subagents.startActivation({
|
|
92
|
+
provider: request.provider ?? this.provider,
|
|
93
|
+
label: typeof request.label === 'string' && request.label !== '' ? request.label : DEFAULT_LABEL,
|
|
94
|
+
request: {
|
|
95
|
+
prompt: [{ type: 'text', text: task }],
|
|
96
|
+
parent: agent,
|
|
97
|
+
...(request.cwd ?? this.defaultCwd ? { cwd: request.cwd ?? this.defaultCwd } : {}),
|
|
98
|
+
...(request.agentPreset ?? this.defaultAgentPreset
|
|
99
|
+
? { agentPreset: request.agentPreset ?? this.defaultAgentPreset }
|
|
100
|
+
: {}),
|
|
101
|
+
},
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
delivery: 'caller',
|
|
104
|
+
})
|
|
105
|
+
const result = await withTimeout(
|
|
106
|
+
activation.result,
|
|
107
|
+
Number.isFinite(request.timeoutMs) ? request.timeoutMs : this.timeoutMs,
|
|
108
|
+
() => {
|
|
109
|
+
controller.abort()
|
|
110
|
+
return Object.assign(new Error('delegation timed out; the child agent was asked to stop'), { code: 'delegate-timeout' })
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
return {
|
|
114
|
+
text: textOf(result) || '(delegated agent returned no text)',
|
|
115
|
+
stopReason: result?.stopReason ?? null,
|
|
116
|
+
childId: activation.childId,
|
|
117
|
+
durationMs: Date.now() - startedAt,
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
try {
|
|
121
|
+
await activation?.dispose?.()
|
|
122
|
+
} catch {
|
|
123
|
+
/* best effort */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|