dsh-device-fleet 0.1.0-dev.3 → 0.1.0-dev.31
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/README.md +7 -3
- package/assets/avatars/host.png +0 -0
- package/assets/avatars/user.png +0 -0
- package/assets/avatars/worker.png +0 -0
- package/client.js +1682 -0
- package/cordis.patch.yml +3 -3
- package/docs/notes/approval-inbox-phase.md +31 -0
- package/docs/notes/approval-panel-ui.md +38 -0
- package/docs/notes/dev-release.md +134 -5
- package/docs/notes/fleet-console-ui.md +44 -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/ui-surface-audit.md +93 -0
- package/package.json +11 -1
- package/src/host/approval-inbox.js +163 -0
- package/src/host/approval-web.js +95 -0
- package/src/host/avatar-web.js +179 -0
- package/src/host/config-web.js +84 -0
- package/src/host/delegation.js +127 -0
- package/src/host/direct-call.js +27 -8
- package/src/host/execution-slot.js +27 -5
- package/src/host/index.js +8 -0
- package/src/host/room-service.js +435 -0
- package/src/host/runtime-config.js +192 -0
- package/src/host/store.js +27 -1
- package/src/host/tool-policy.js +13 -10
- package/src/host/tools.js +341 -1
- package/src/host/ui-web.js +139 -0
- package/src/net/fleet-node.js +30 -0
- package/src/plugin/index.js +421 -16
|
@@ -0,0 +1,163 @@
|
|
|
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, historyLimit = 50, now = Date.now } = {}) {
|
|
24
|
+
super()
|
|
25
|
+
this.ttlMs = ttlMs
|
|
26
|
+
this.maxPending = maxPending
|
|
27
|
+
this.historyLimit = historyLimit
|
|
28
|
+
this.now = now
|
|
29
|
+
this.closed = false
|
|
30
|
+
/** @type {Map<string, any>} */
|
|
31
|
+
this.pending = new Map()
|
|
32
|
+
/** @type {any[]} */
|
|
33
|
+
this.historyEntries = []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
record(entry, outcome, extra = {}) {
|
|
37
|
+
this.historyEntries.unshift({
|
|
38
|
+
id: entry.id,
|
|
39
|
+
fromDeviceId: entry.fromDeviceId,
|
|
40
|
+
capability: entry.capability,
|
|
41
|
+
tool: entry.tool,
|
|
42
|
+
reason: entry.reason,
|
|
43
|
+
argsSummary: entry.argsSummary,
|
|
44
|
+
createdAt: entry.createdAt,
|
|
45
|
+
resolvedAt: this.now(),
|
|
46
|
+
outcome,
|
|
47
|
+
...extra,
|
|
48
|
+
})
|
|
49
|
+
if (this.historyEntries.length > this.historyLimit) this.historyEntries.length = this.historyLimit
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Recently resolved requests (newest first). */
|
|
53
|
+
history() {
|
|
54
|
+
return this.historyEntries.map((entry) => ({ ...entry }))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ask the local user. Resolves `{ approved, scope?, reason? }` when approved,
|
|
59
|
+
* denied, expired or closed.
|
|
60
|
+
*/
|
|
61
|
+
request(request) {
|
|
62
|
+
if (this.closed) return Promise.resolve({ approved: false, reason: 'inbox-closed' })
|
|
63
|
+
if (this.pending.size >= this.maxPending) {
|
|
64
|
+
return Promise.resolve({ approved: false, reason: 'inbox-full' })
|
|
65
|
+
}
|
|
66
|
+
const id = randomUUID()
|
|
67
|
+
const createdAt = this.now()
|
|
68
|
+
const entry = {
|
|
69
|
+
id,
|
|
70
|
+
fromDeviceId: request.fromDeviceId ?? 'unknown',
|
|
71
|
+
capability: request.capability ?? 'tool',
|
|
72
|
+
tool: request.tool ?? null,
|
|
73
|
+
reason: request.reason ?? '',
|
|
74
|
+
argsSummary: summarizeArgs(request.args),
|
|
75
|
+
createdAt,
|
|
76
|
+
expiresAt: createdAt + this.ttlMs,
|
|
77
|
+
status: 'pending',
|
|
78
|
+
}
|
|
79
|
+
entry.promise = new Promise((resolve) => {
|
|
80
|
+
entry.resolve = resolve
|
|
81
|
+
})
|
|
82
|
+
entry.timer = setTimeout(() => this.expire(id), this.ttlMs)
|
|
83
|
+
entry.timer.unref?.()
|
|
84
|
+
this.pending.set(id, entry)
|
|
85
|
+
this.emit('request', this.summary(entry))
|
|
86
|
+
return entry.promise
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
summary(entry) {
|
|
90
|
+
return {
|
|
91
|
+
id: entry.id,
|
|
92
|
+
fromDeviceId: entry.fromDeviceId,
|
|
93
|
+
capability: entry.capability,
|
|
94
|
+
tool: entry.tool,
|
|
95
|
+
reason: entry.reason,
|
|
96
|
+
argsSummary: entry.argsSummary,
|
|
97
|
+
createdAt: entry.createdAt,
|
|
98
|
+
expiresAt: entry.expiresAt,
|
|
99
|
+
expiresInMs: Math.max(0, entry.expiresAt - this.now()),
|
|
100
|
+
status: entry.status,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
list() {
|
|
105
|
+
return [...this.pending.values()]
|
|
106
|
+
.filter((entry) => entry.status === 'pending')
|
|
107
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
108
|
+
.map((entry) => this.summary(entry))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
approve(id, { scope = 'once' } = {}) {
|
|
112
|
+
const entry = this.settle(id, 'approved')
|
|
113
|
+
if (!entry) throw Object.assign(new Error(`unknown approval request: ${String(id)}`), { code: 'unknown-request' })
|
|
114
|
+
const chosen = APPROVAL_SCOPES.includes(scope) ? scope : 'once'
|
|
115
|
+
entry.resolve({ approved: true, scope: chosen })
|
|
116
|
+
this.record(entry, 'approved', { scope: chosen })
|
|
117
|
+
this.emit('approved', { ...this.summary(entry), scope: chosen })
|
|
118
|
+
return { ...this.summary(entry), scope: chosen }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
deny(id, reason = 'denied by local user') {
|
|
122
|
+
const entry = this.settle(id, 'denied')
|
|
123
|
+
if (!entry) throw Object.assign(new Error(`unknown approval request: ${String(id)}`), { code: 'unknown-request' })
|
|
124
|
+
entry.resolve({ approved: false, reason })
|
|
125
|
+
this.record(entry, 'denied', { reason })
|
|
126
|
+
this.emit('denied', { ...this.summary(entry), reason })
|
|
127
|
+
return { ...this.summary(entry), reason }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
expire(id) {
|
|
131
|
+
const entry = this.pending.get(id)
|
|
132
|
+
if (!entry || entry.status !== 'pending') return
|
|
133
|
+
entry.status = 'expired'
|
|
134
|
+
this.pending.delete(id)
|
|
135
|
+
clearTimeout(entry.timer)
|
|
136
|
+
entry.resolve({ approved: false, reason: 'approval-timeout' })
|
|
137
|
+
this.record(entry, 'expired')
|
|
138
|
+
this.emit('expired', this.summary(entry))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
settle(id, status) {
|
|
142
|
+
const entry = this.pending.get(id)
|
|
143
|
+
if (!entry || entry.status !== 'pending') return undefined
|
|
144
|
+
entry.status = status
|
|
145
|
+
this.pending.delete(id)
|
|
146
|
+
clearTimeout(entry.timer)
|
|
147
|
+
return entry
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Close the inbox: every pending request fails closed. */
|
|
151
|
+
close() {
|
|
152
|
+
if (this.closed) return
|
|
153
|
+
this.closed = true
|
|
154
|
+
for (const entry of this.pending.values()) {
|
|
155
|
+
clearTimeout(entry.timer)
|
|
156
|
+
entry.status = 'closed'
|
|
157
|
+
entry.resolve({ approved: false, reason: 'inbox-closed' })
|
|
158
|
+
this.record(entry, 'closed')
|
|
159
|
+
}
|
|
160
|
+
this.pending.clear()
|
|
161
|
+
this.emit('closed')
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -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,179 @@
|
|
|
1
|
+
// Agent avatars used by the Room conversation (dev.25/26). Built-in PNG files
|
|
2
|
+
// ship with the package; the user can upload a library into the state dir and
|
|
3
|
+
// Room members cycle through it.
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { basename, join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export const AVATARS = {
|
|
8
|
+
host: 'host.png',
|
|
9
|
+
worker: 'worker.png',
|
|
10
|
+
user: 'user.png',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
|
14
|
+
const MAX_UPLOAD_BYTES = 3 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
function safeFileName(input, extension) {
|
|
17
|
+
const base = String(input ?? 'avatar')
|
|
18
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
|
19
|
+
.replace(/^-+|-+$/g, '')
|
|
20
|
+
.slice(0, 48)
|
|
21
|
+
return `${base || 'avatar'}-${Date.now().toString(36)}${extension}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function listLibrary(dir) {
|
|
25
|
+
if (!existsSync(dir)) return []
|
|
26
|
+
return readdirSync(dir)
|
|
27
|
+
.filter((name) => EXTENSIONS.has(name.slice(name.lastIndexOf('.')).toLowerCase()))
|
|
28
|
+
.sort()
|
|
29
|
+
.map((name) => ({ name, url: `/device-fleet/avatar-file?name=${encodeURIComponent(name)}` }))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseDataUrl(dataUrl) {
|
|
33
|
+
const match = /^data:image\/(png|jpe?g|webp);base64,([A-Za-z0-9+/=]+)$/.exec(String(dataUrl ?? ''))
|
|
34
|
+
if (!match) throw Object.assign(new Error('dataUrl must be a base64 png/jpeg/webp image'), { code: 'bad-request' })
|
|
35
|
+
const extension = match[1] === 'jpeg' ? '.jpg' : `.${match[1]}`
|
|
36
|
+
const buffer = Buffer.from(match[2], 'base64')
|
|
37
|
+
if (buffer.length === 0 || buffer.length > MAX_UPLOAD_BYTES) {
|
|
38
|
+
throw Object.assign(new Error(`avatar must be between 1 byte and ${MAX_UPLOAD_BYTES} bytes`), { code: 'bad-request' })
|
|
39
|
+
}
|
|
40
|
+
return { extension, buffer }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function json(res, status, value) {
|
|
44
|
+
res.statusCode = status
|
|
45
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
46
|
+
res.end(JSON.stringify(value))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readJson(req) {
|
|
50
|
+
const chunks = []
|
|
51
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
52
|
+
if (chunks.length === 0) return {}
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
55
|
+
} catch {
|
|
56
|
+
throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} options
|
|
62
|
+
* @param {object} options.webServer ctx.webServer
|
|
63
|
+
* @param {string} options.dir built-in avatar directory
|
|
64
|
+
* @param {string} options.libraryDir user-uploaded avatar directory
|
|
65
|
+
*/
|
|
66
|
+
export function registerAvatarRoutes({ webServer, dir, libraryDir = null, logger = null }) {
|
|
67
|
+
if (!webServer?.register || !dir) return () => {}
|
|
68
|
+
const disposers = []
|
|
69
|
+
const sendPng = (res, file, alt = null) => {
|
|
70
|
+
try {
|
|
71
|
+
const data = readFileSync(file)
|
|
72
|
+
res.statusCode = 200
|
|
73
|
+
res.setHeader('content-type', alt ?? 'image/png')
|
|
74
|
+
res.setHeader('cache-control', 'public, max-age=3600')
|
|
75
|
+
res.setHeader('content-length', String(data.length))
|
|
76
|
+
res.end(data)
|
|
77
|
+
} catch {
|
|
78
|
+
logger?.warn?.(`[device-fleet] avatar missing: ${file}`)
|
|
79
|
+
res.statusCode = 404
|
|
80
|
+
res.end()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const [name, file] of Object.entries(AVATARS)) {
|
|
84
|
+
disposers.push(
|
|
85
|
+
webServer.register({
|
|
86
|
+
kind: 'exact',
|
|
87
|
+
path: `/device-fleet/avatar/${name}`,
|
|
88
|
+
handler: async (req, res) => {
|
|
89
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
90
|
+
res.statusCode = 405
|
|
91
|
+
res.end()
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (req.method === 'HEAD') {
|
|
95
|
+
res.statusCode = 200
|
|
96
|
+
res.end()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
sendPng(res, join(dir, file))
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (libraryDir) {
|
|
105
|
+
try {
|
|
106
|
+
mkdirSync(libraryDir, { recursive: true })
|
|
107
|
+
} catch {
|
|
108
|
+
/* best effort */
|
|
109
|
+
}
|
|
110
|
+
disposers.push(
|
|
111
|
+
webServer.register({
|
|
112
|
+
kind: 'exact',
|
|
113
|
+
path: '/device-fleet/avatar-library',
|
|
114
|
+
handler: async (req, res) => {
|
|
115
|
+
try {
|
|
116
|
+
if (req.method === 'GET') return json(res, 200, { items: listLibrary(libraryDir) })
|
|
117
|
+
if (req.method === 'POST') {
|
|
118
|
+
const body = await readJson(req)
|
|
119
|
+
const { extension, buffer } = parseDataUrl(body.dataUrl)
|
|
120
|
+
const name = safeFileName(body.name, extension)
|
|
121
|
+
writeFileSync(join(libraryDir, name), buffer)
|
|
122
|
+
return json(res, 200, { ok: true, item: { name, url: `/device-fleet/avatar-file?name=${encodeURIComponent(name)}` } })
|
|
123
|
+
}
|
|
124
|
+
return json(res, 405, { error: 'method not allowed' })
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return json(res, 400, { error: String(error?.message ?? error), code: error?.code ?? 'bad-request' })
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
disposers.push(
|
|
132
|
+
webServer.register({
|
|
133
|
+
kind: 'exact',
|
|
134
|
+
path: '/device-fleet/avatar-file',
|
|
135
|
+
handler: async (req, res) => {
|
|
136
|
+
if (req.method !== 'GET') {
|
|
137
|
+
res.statusCode = 405
|
|
138
|
+
res.end()
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
let name = ''
|
|
142
|
+
try {
|
|
143
|
+
name = new URL(req.url ?? '/', 'http://local').searchParams.get('name') ?? ''
|
|
144
|
+
} catch {
|
|
145
|
+
name = ''
|
|
146
|
+
}
|
|
147
|
+
const clean = basename(name)
|
|
148
|
+
if (clean === '' || clean !== name || !EXTENSIONS.has(clean.slice(clean.lastIndexOf('.')).toLowerCase())) {
|
|
149
|
+
res.statusCode = 400
|
|
150
|
+
res.end()
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
sendPng(res, join(libraryDir, clean))
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
disposers.push(
|
|
158
|
+
webServer.register({
|
|
159
|
+
kind: 'exact',
|
|
160
|
+
path: '/device-fleet/avatar-library/remove',
|
|
161
|
+
handler: async (req, res) => {
|
|
162
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
163
|
+
try {
|
|
164
|
+
const body = await readJson(req)
|
|
165
|
+
const clean = basename(String(body.name ?? ''))
|
|
166
|
+
if (clean === '' || clean !== body.name) throw Object.assign(new Error('invalid name'), { code: 'bad-request' })
|
|
167
|
+
unlinkSync(join(libraryDir, clean))
|
|
168
|
+
return json(res, 200, { ok: true })
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return json(res, 400, { error: String(error?.message ?? error) })
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
return () => {
|
|
177
|
+
for (const dispose of disposers) dispose()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Runtime config HTTP surface (dev.15): read effective config, list UI options,
|
|
2
|
+
// and apply a patch live. The UI posts here; no plugin or DSH restart is involved.
|
|
3
|
+
const MAX_BODY_BYTES = 256 * 1024
|
|
4
|
+
|
|
5
|
+
function json(res, status, value) {
|
|
6
|
+
res.statusCode = status
|
|
7
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
8
|
+
res.setHeader('cache-control', 'no-store')
|
|
9
|
+
res.end(JSON.stringify(value))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function readJsonBody(req) {
|
|
13
|
+
const chunks = []
|
|
14
|
+
let size = 0
|
|
15
|
+
for await (const chunk of req) {
|
|
16
|
+
size += chunk.length
|
|
17
|
+
if (size > MAX_BODY_BYTES) throw Object.assign(new Error('request body is too large'), { code: 'body-too-large' })
|
|
18
|
+
chunks.push(chunk)
|
|
19
|
+
}
|
|
20
|
+
if (chunks.length === 0) return {}
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
23
|
+
} catch {
|
|
24
|
+
throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* @param {object} options.webServer ctx.webServer
|
|
31
|
+
* @param {object} options.runtime RuntimeConfig
|
|
32
|
+
* @param {() => Promise<object>|object} [options.getOptions] UI option catalog (presets, shells, paths)
|
|
33
|
+
* @param {(patch: object) => Promise<object>|object} [options.preparePatch] side effects before applying (e.g. mkdir)
|
|
34
|
+
*/
|
|
35
|
+
export function registerConfigRoutes({ webServer, runtime, getOptions = null, preparePatch = null, logger = null }) {
|
|
36
|
+
if (!webServer?.register || !runtime) return () => {}
|
|
37
|
+
const disposers = []
|
|
38
|
+
disposers.push(
|
|
39
|
+
webServer.register({
|
|
40
|
+
kind: 'exact',
|
|
41
|
+
path: '/device-fleet/options',
|
|
42
|
+
handler: async (req, res) => {
|
|
43
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
|
|
44
|
+
try {
|
|
45
|
+
json(res, 200, (await getOptions?.()) ?? {})
|
|
46
|
+
} catch (error) {
|
|
47
|
+
logger?.warn?.(`[device-fleet] options failed: ${String(error?.message ?? error)}`)
|
|
48
|
+
json(res, 500, { error: 'internal error' })
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
disposers.push(
|
|
54
|
+
webServer.register({
|
|
55
|
+
kind: 'exact',
|
|
56
|
+
path: '/device-fleet/config',
|
|
57
|
+
handler: async (req, res) => {
|
|
58
|
+
if (req.method === 'GET') {
|
|
59
|
+
try {
|
|
60
|
+
json(res, 200, runtime.snapshot())
|
|
61
|
+
} catch (error) {
|
|
62
|
+
json(res, 500, { error: String(error?.message ?? error) })
|
|
63
|
+
}
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
67
|
+
try {
|
|
68
|
+
const body = await readJsonBody(req)
|
|
69
|
+
const patch = body?.patch ?? body
|
|
70
|
+
const prepared = preparePatch ? await preparePatch(patch) : patch
|
|
71
|
+
const config = await runtime.update(prepared ?? patch)
|
|
72
|
+
json(res, 200, { ok: true, config, appliedAt: Date.now() })
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const status = ['bad-request', 'bad-json', 'body-too-large', 'readonly'].includes(error?.code) ? 400 : 500
|
|
75
|
+
if (status === 500) logger?.warn?.(`[device-fleet] config update failed: ${String(error?.message ?? error)}`)
|
|
76
|
+
json(res, status, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
return () => {
|
|
82
|
+
for (const dispose of disposers) dispose()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -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
|
+
}
|