dsh-device-fleet 0.1.0-dev.6 → 0.1.0-dev.7

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.
@@ -38,4 +38,10 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
38
38
 
39
39
  - 邀请里新增 `endpoints`(创建者的局域网/热点 IPv4 + direct 端口),加入方重启后自动向这些地址回连,绕开组播发现失败;
40
40
  - 新增 `fleet_status`(deviceId/直连端口/发现/连接/roster/hints)与 `fleet_connect(host, port)`(手动直连,成功后会从握手学到对端 deviceId);
41
- - `fleet_list` 仍是本地视图;配对成功后双方 roster 会自动固定对端公钥。
41
+ - `fleet_list` 仍是本地视图;配对成功后双方 roster 会自动固定对端公钥。
42
+ ## 0.1.0-dev.6(稳定化)
43
+
44
+ - 直连端口可固定(profile `direct.port`),插件重载不再换端口;
45
+ - 连接断开后自动重试已知地址(不再只在启动时试一次);
46
+ - `fleet_connect` 手动地址会持久化到 `peer-hints.json`,重启后继续回连;
47
+ - 新增 `fleet_delegate`(dev.5):目标设备用 `SubagentProvider` + `delivery:'caller'` 跑 agent 并返回最终结果。
@@ -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
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.6",
3
+ "version": "0.1.0-dev.7",
4
4
  "description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
5
5
  "type": "module",
6
6
  "main": "src/plugin/index.js",
@@ -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
+ }
@@ -72,12 +72,15 @@ export class DelegationHost {
72
72
  throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability: 'delegate' } })
73
73
  }
74
74
  if (decision.decision === 'ask') {
75
- const approved =
75
+ const outcome =
76
76
  typeof this.requestApproval === 'function'
77
77
  ? await this.requestApproval({ fromDeviceId, capability: 'delegate', tool: 'delegate', args: { task }, reason: decision.reason })
78
- : false
79
- if (approved !== true) throw Object.assign(new Error('local approval was not granted'), { code: 'approval_denied' })
80
- this.policy.grant(decision.key, { scope: 'once' })
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' })
81
84
  }
82
85
 
83
86
  const agent = await this.slot.ensureAgent()
@@ -97,9 +97,11 @@ export class DirectCallHost {
97
97
  throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability, tool } })
98
98
  }
99
99
  if (decision.decision === 'ask') {
100
- const approved = await this.askApproval({ fromDeviceId, capability, tool, args, reason: decision.reason })
101
- if (!approved) throw Object.assign(new Error('local approval was not granted'), { code: 'approval_denied' })
102
- this.policy.grant(decision.key, { scope: 'once' })
100
+ const outcome = await this.askApproval({ fromDeviceId, capability, tool, args, reason: decision.reason })
101
+ if (outcome.approved !== true) {
102
+ throw Object.assign(new Error(outcome.reason ?? 'local approval was not granted'), { code: 'approval_denied' })
103
+ }
104
+ this.policy.grant(decision.key, { scope: outcome.scope === 'ttl' ? 'ttl' : 'once' })
103
105
  }
104
106
  const startedAt = Date.now()
105
107
  const result = await this.execute({ capability, tool, args })
@@ -116,12 +118,16 @@ export class DirectCallHost {
116
118
  }
117
119
 
118
120
  async askApproval({ fromDeviceId, capability, tool, args, reason }) {
119
- if (typeof this.requestApproval !== 'function') return false
121
+ if (typeof this.requestApproval !== 'function') return { approved: false, reason: 'no approval channel' }
120
122
  try {
121
- const approved = await this.requestApproval({ fromDeviceId, capability, tool, args, reason })
122
- return approved === true
123
- } catch {
124
- return false
123
+ const outcome = await this.requestApproval({ fromDeviceId, capability, tool, args, reason })
124
+ if (outcome === true) return { approved: true, scope: 'once' }
125
+ if (outcome && typeof outcome === 'object') {
126
+ return { approved: outcome.approved === true, scope: outcome.scope ?? 'once', reason: outcome.reason }
127
+ }
128
+ return { approved: false, reason: 'approval not granted' }
129
+ } catch (error) {
130
+ return { approved: false, reason: String(error?.message ?? error) }
125
131
  }
126
132
  }
127
133
 
package/src/host/tools.js CHANGED
@@ -22,8 +22,53 @@ function fail(error) {
22
22
  }
23
23
 
24
24
  /** Pure handlers; wrapped into ToolDefinitions by `createFleetToolDefinitions`. */
25
- export function createFleetHandlers({ node, client = new DirectCallClient({ node }), pairing = null, onPeerHint = null }) {
25
+ export function createFleetHandlers({
26
+ node,
27
+ client = new DirectCallClient({ node }),
28
+ pairing = null,
29
+ onPeerHint = null,
30
+ approvals = null,
31
+ }) {
26
32
  return {
33
+ async fleet_approvals() {
34
+ if (!approvals?.list) return { text: 'Error: [unsupported] the approval inbox is unavailable' }
35
+ const items = approvals.list()
36
+ if (items.length === 0) return { text: '(no pending approvals)' }
37
+ const lines = items.map(
38
+ (item) =>
39
+ `- ${item.id} · ${item.fromDeviceId} · ${item.capability}${item.tool ? `/${String(item.tool)}` : ''} · ${item.expiresInMs}ms left · ${item.argsSummary}`,
40
+ )
41
+ return { text: lines.join('\n') }
42
+ },
43
+
44
+ async fleet_approve({ id, scope }) {
45
+ if (!approvals?.approve) return { text: 'Error: [unsupported] the approval inbox is unavailable' }
46
+ try {
47
+ const result = approvals.approve(id, { scope: scope ?? 'once' })
48
+ return { text: `approved ${result.id} (${result.scope})` }
49
+ } catch (error) {
50
+ return fail(error)
51
+ }
52
+ },
53
+
54
+ async fleet_deny({ id, reason }) {
55
+ if (!approvals?.deny) return { text: 'Error: [unsupported] the approval inbox is unavailable' }
56
+ try {
57
+ const result = approvals.deny(id, reason ?? 'denied by local user')
58
+ return { text: `denied ${result.id}` }
59
+ } catch (error) {
60
+ return fail(error)
61
+ }
62
+ },
63
+
64
+ async fleet_simulate_request({ fromDeviceId, capability, tool, reason }) {
65
+ if (!approvals?.simulate) return { text: 'Error: [unsupported] approval simulation is disabled' }
66
+ try {
67
+ return { text: await approvals.simulate({ fromDeviceId, capability, tool, reason }) }
68
+ } catch (error) {
69
+ return fail(error)
70
+ }
71
+ },
27
72
  async fleet_status() {
28
73
  if (typeof node.status !== 'function') return { text: 'Error: [unsupported] status is unavailable' }
29
74
  return { text: JSON.stringify(node.status(), null, 2) }
@@ -180,6 +225,48 @@ export function createFleetToolDefinitions({ defineTool, handlers }) {
180
225
  output,
181
226
  execute: (args) => handlers.fleet_connect(args),
182
227
  }),
228
+ defineTool({
229
+ name: 'fleet_approvals',
230
+ description:
231
+ 'List pending approval requests waiting on this device (remote direct calls or delegations whose Tool Policy is `ask`). Approve or deny them with `fleet_approve` / `fleet_deny`. Approvals expire after the configured timeout and fail closed.',
232
+ parameters: {},
233
+ output,
234
+ execute: () => handlers.fleet_approvals(),
235
+ }),
236
+ defineTool({
237
+ name: 'fleet_approve',
238
+ description:
239
+ 'Approve one pending approval request on this device. `scope: "once"` allows one call; `scope: "ttl"` allows that capability for the configured TTL (default 15 minutes). Permanent grants belong in the device settings, not here.',
240
+ parameters: {
241
+ id: { type: 'string', required: true, description: 'Approval request id from `fleet_approvals`.' },
242
+ scope: { type: 'string', enum: ['once', 'ttl'], description: 'Approval scope; defaults to once.' },
243
+ },
244
+ output,
245
+ execute: (args) => handlers.fleet_approve(args),
246
+ }),
247
+ defineTool({
248
+ name: 'fleet_deny',
249
+ description: 'Deny one pending approval request on this device.',
250
+ parameters: {
251
+ id: { type: 'string', required: true, description: 'Approval request id from `fleet_approvals`.' },
252
+ reason: { type: 'string', description: 'Optional reason returned to the caller.' },
253
+ },
254
+ output,
255
+ execute: (args) => handlers.fleet_deny(args),
256
+ }),
257
+ defineTool({
258
+ name: 'fleet_simulate_request',
259
+ description:
260
+ 'Development helper (only when approval simulation is enabled): create a fake pending approval request so the inbox flow can be exercised without a second device. It does not execute anything.',
261
+ parameters: {
262
+ fromDeviceId: { type: 'string', description: 'Pretend caller device id.' },
263
+ capability: { type: 'string', description: 'Capability name, e.g. run.' },
264
+ tool: { type: 'string', description: 'Optional tool name.' },
265
+ reason: { type: 'string', description: 'Optional reason shown in the request.' },
266
+ },
267
+ output,
268
+ execute: (args) => handlers.fleet_simulate_request(args),
269
+ }),
183
270
  defineTool({
184
271
  name: 'fleet_invite',
185
272
  description:
@@ -14,6 +14,7 @@ import { FleetNode } from '../net/fleet-node.js'
14
14
  import { accountFingerprint } from '../net/identity.js'
15
15
  import { DirectCallClient, DirectCallHost } from '../host/direct-call.js'
16
16
  import { DelegationHost } from '../host/delegation.js'
17
+ import { ApprovalInbox } from '../host/approval-inbox.js'
17
18
  import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
18
19
  import { ToolPolicy } from '../host/tool-policy.js'
19
20
  import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
@@ -76,6 +77,13 @@ export const Config = Schema.object({
76
77
  .description('Direct-call Tool Policy owned by this device (ADR-0004/ADR-0019).'),
77
78
  promptSection: Schema.boolean().default(true).description('Render the one-sentence Fleet nudge when the Fleet has members.'),
78
79
  autoApproveAsk: Schema.boolean().default(false).description('Testing only: auto-approve every local approval request.'),
80
+ approvals: Schema.object({
81
+ ttlMs: Schema.number().default(60000),
82
+ maxPending: Schema.number().default(50),
83
+ simulation: Schema.boolean().default(false),
84
+ })
85
+ .default({ ttlMs: 60000, maxPending: 50, simulation: false })
86
+ .description('Local approval inbox (ADR-0020). `simulation` exposes fleet_simulate_request for local testing.'),
79
87
  })
80
88
 
81
89
  function packageVersion() {
@@ -157,6 +165,35 @@ export function apply(ctx, config) {
157
165
  const sessionController = service('sessionController')
158
166
  const fs = service('fs')
159
167
  const subagents = service('subagents')
168
+ const inbox = new ApprovalInbox({
169
+ ttlMs: config.approvals?.ttlMs ?? 60_000,
170
+ maxPending: config.approvals?.maxPending ?? 50,
171
+ })
172
+ inbox.on('request', (request) => logger.info?.(`[device-fleet] approval requested by ${request.fromDeviceId}: ${request.capability}/${String(request.tool ?? '')}`))
173
+ const approvalsApi = {
174
+ list: () => inbox.list(),
175
+ approve: (id, options) => inbox.approve(id, options),
176
+ deny: (id, reason) => inbox.deny(id, reason),
177
+ simulate: config.approvals?.simulation
178
+ ? async ({ fromDeviceId, capability, tool, reason }) => {
179
+ const promise = inbox.request({
180
+ fromDeviceId: fromDeviceId ?? 'simulated-device',
181
+ capability: capability ?? 'run',
182
+ tool: tool ?? null,
183
+ reason: reason ?? 'simulated approval request',
184
+ args: {},
185
+ })
186
+ const summary = inbox.list().at(-1)
187
+ // Keep the promise from becoming an unhandled rejection when the
188
+ // request expires without an answer; the demo only inspects the inbox.
189
+ promise.catch(() => {})
190
+ return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
191
+ }
192
+ : null,
193
+ }
194
+ const requestApproval = config.autoApproveAsk
195
+ ? async () => ({ approved: true, scope: 'once' })
196
+ : async (request) => inbox.request(request)
160
197
  if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
161
198
  const slot = sessionController
162
199
  ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
@@ -173,12 +210,7 @@ export function apply(ctx, config) {
173
210
  defaultAgentPreset: config.execution?.agentPreset,
174
211
  provider: config.execution?.subagentProvider ?? 'spawn',
175
212
  timeoutMs: config.execution?.delegateTimeoutMs ?? 180_000,
176
- requestApproval: config.autoApproveAsk
177
- ? async () => true
178
- : async (request) => {
179
- logger.info?.(`[device-fleet] delegation approval required from ${request.fromDeviceId} (no approval UI yet)`)
180
- return false
181
- },
213
+ requestApproval,
182
214
  })
183
215
  : null
184
216
 
@@ -195,12 +227,7 @@ export function apply(ctx, config) {
195
227
  shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
196
228
  approvalTimeoutMs: config.execution?.approvalTimeoutMs ?? 60_000,
197
229
  delegation,
198
- requestApproval: config.autoApproveAsk
199
- ? async () => true
200
- : async (request) => {
201
- logger.info?.(`[device-fleet] approval required from ${request.fromDeviceId} for ${request.capability}/${String(request.tool)} (no approval UI yet)`)
202
- return false
203
- },
230
+ requestApproval,
204
231
  })
205
232
  : null
206
233
 
@@ -279,6 +306,7 @@ export function apply(ctx, config) {
279
306
  logger.warn?.(`[device-fleet] cannot persist peer hint: ${String(error?.message ?? error)}`)
280
307
  }
281
308
  },
309
+ approvals: approvalsApi,
282
310
  })
283
311
  const definitions = createFleetToolDefinitions({ defineTool, handlers })
284
312
  const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
@@ -328,6 +356,7 @@ export function apply(ctx, config) {
328
356
  })
329
357
 
330
358
  ctx.effect(() => () => {
359
+ inbox.close()
331
360
  void node.close()
332
361
  })
333
362