dsh-device-fleet 0.1.0-dev.1 → 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.
@@ -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
+ }
@@ -27,6 +27,7 @@ export class DirectCallHost {
27
27
  * @param {string} [options.dshVersion]
28
28
  * @param {string[]} [options.capabilities]
29
29
  * @param {string} [options.shellTool]
30
+ * @param {object} [options.delegation] DelegationHost for `fleet.delegate`
30
31
  */
31
32
  constructor({
32
33
  slot,
@@ -40,6 +41,7 @@ export class DirectCallHost {
40
41
  capabilities = [],
41
42
  shellTool = DEFAULT_SHELL_TOOL,
42
43
  approvalTimeoutMs = DEFAULT_POLICY_TIMEOUT_MS,
44
+ delegation = null,
43
45
  }) {
44
46
  this.slot = slot
45
47
  this.policy = policy
@@ -52,6 +54,7 @@ export class DirectCallHost {
52
54
  this.capabilities = capabilities
53
55
  this.shellTool = shellTool
54
56
  this.approvalTimeoutMs = approvalTimeoutMs
57
+ this.delegation = delegation
55
58
  }
56
59
 
57
60
  info() {
@@ -75,6 +78,11 @@ export class DirectCallHost {
75
78
  return { deviceId: this.deviceId, tools: await this.slot.listTools(), policy: this.policy.summarize() }
76
79
  case 'fleet.call':
77
80
  return this.handleCall({ fromDeviceId: deviceId, request: params ?? {} })
81
+ case 'fleet.delegate':
82
+ if (!this.delegation) {
83
+ throw Object.assign(new Error('delegation is unavailable on this device'), { code: 'unsupported' })
84
+ }
85
+ return this.delegation.delegate({ fromDeviceId: deviceId, request: params ?? {} })
78
86
  default:
79
87
  throw Object.assign(new Error(`unknown fleet method: ${String(method)}`), { code: 'unknown-method' })
80
88
  }
@@ -89,9 +97,11 @@ export class DirectCallHost {
89
97
  throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability, tool } })
90
98
  }
91
99
  if (decision.decision === 'ask') {
92
- const approved = await this.askApproval({ fromDeviceId, capability, tool, args, reason: decision.reason })
93
- if (!approved) throw Object.assign(new Error('local approval was not granted'), { code: 'approval_denied' })
94
- 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' })
95
105
  }
96
106
  const startedAt = Date.now()
97
107
  const result = await this.execute({ capability, tool, args })
@@ -108,12 +118,16 @@ export class DirectCallHost {
108
118
  }
109
119
 
110
120
  async askApproval({ fromDeviceId, capability, tool, args, reason }) {
111
- if (typeof this.requestApproval !== 'function') return false
121
+ if (typeof this.requestApproval !== 'function') return { approved: false, reason: 'no approval channel' }
112
122
  try {
113
- const approved = await this.requestApproval({ fromDeviceId, capability, tool, args, reason })
114
- return approved === true
115
- } catch {
116
- 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) }
117
131
  }
118
132
  }
119
133
 
@@ -178,6 +192,11 @@ export class DirectCallClient {
178
192
  return this.node.request(deviceId, 'fleet.call', { capability, tool, args }, options)
179
193
  }
180
194
 
195
+ /** Ask the target device's agent to do a one-shot task and return its final text. */
196
+ async delegate(deviceId, request, options = {}) {
197
+ return this.node.request(deviceId, 'fleet.delegate', request, options)
198
+ }
199
+
181
200
  /** Format a remote result for a model-facing text tool. */
182
201
  render(result) {
183
202
  if (result?.isError) return `Error: ${result.text || 'remote tool failed'}`
@@ -55,11 +55,25 @@ export class ExecutionSlot {
55
55
  */
56
56
  async executeTool({ name, args = {}, signal }) {
57
57
  const agent = await this.ensureAgent()
58
- const batch = await this.tools.execute({
59
- calls: [{ id: randomUUID(), name, arguments: JSON.stringify(args ?? {}) }],
60
- agent,
61
- signal,
62
- })
58
+ // execToolBatch requires a real AbortSignal: without one the pipeline
59
+ // throws before dispatch, which surfaces as "Tool batch execution failed".
60
+ const effectiveSignal = signal ?? new AbortController().signal
61
+ let batch
62
+ try {
63
+ batch = await this.tools.execute({
64
+ calls: [{ id: randomUUID(), name, arguments: JSON.stringify(args ?? {}) }],
65
+ agent,
66
+ signal: effectiveSignal,
67
+ })
68
+ } catch (error) {
69
+ const cause = error?.cause
70
+ const detail = cause?.message ?? error?.message ?? String(error)
71
+ throw Object.assign(new Error(`tool ${name} failed: ${String(detail)}`), {
72
+ code: 'tool-failed',
73
+ cause,
74
+ data: { causeCode: cause?.code ?? null, causeMessage: cause?.message ?? null },
75
+ })
76
+ }
63
77
  const first = batch?.results?.[0]?.result
64
78
  if (!first) throw Object.assign(new Error(`tool ${name} returned no result`), { code: 'tool-no-result' })
65
79
  const text = (first.content ?? [])
package/src/host/index.js CHANGED
@@ -3,4 +3,8 @@ export * from './store.js'
3
3
  export * from './tool-policy.js'
4
4
  export * from './execution-slot.js'
5
5
  export * from './direct-call.js'
6
+ export * from './delegation.js'
7
+ export * from './approval-inbox.js'
8
+ export * from './approval-web.js'
9
+ export * from './room-service.js'
6
10
  export * from './tools.js'