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.
- package/docs/notes/dev-release.md +7 -1
- package/docs/notes/remote-tests-2026-11-09.md +33 -0
- package/package.json +1 -1
- package/src/host/approval-inbox.js +135 -0
- package/src/host/delegation.js +7 -4
- package/src/host/direct-call.js +14 -8
- package/src/host/tools.js +88 -1
- package/src/plugin/index.js +41 -12
|
@@ -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.
|
|
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
|
+
}
|
package/src/host/delegation.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
80
|
-
|
|
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()
|
package/src/host/direct-call.js
CHANGED
|
@@ -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
|
|
101
|
-
if (
|
|
102
|
-
|
|
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
|
|
122
|
-
return approved
|
|
123
|
-
|
|
124
|
-
|
|
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({
|
|
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:
|
package/src/plugin/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|