dsh-computers 1.1.0 → 1.3.0
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/PROTOCOL.md +135 -0
- package/README.md +45 -16
- package/agent/README.md +112 -5
- package/agent/dsh-computers-agent.mjs +835 -20
- package/client.js +777 -10
- package/cordis.patch.yml +2 -0
- package/index.js +32 -2
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/hub.js +346 -140
- package/lib/routes.js +425 -38
- package/lib/state.js +11 -2
- package/lib/tools.js +684 -85
- package/lib/version.js +62 -0
- package/package.json +1 -1
package/lib/routes.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
optionalCount, optionalString, platformLabel, sha256Hex, tokenMatches,
|
|
22
22
|
} from './keys.js'
|
|
23
23
|
import { normalizeSessionId } from './selection.js'
|
|
24
|
+
import { versionAtLeast, versionsDiffer } from './version.js'
|
|
24
25
|
|
|
25
26
|
/** Base path shared by every route (PROTOCOL §3). */
|
|
26
27
|
export const API_BASE = '/api/dsh-computers'
|
|
@@ -34,6 +35,12 @@ const CONTROL_MAX_BYTES = 16 * 1024
|
|
|
34
35
|
/** Result posts carry two ≤1 MiB streams plus JSON escaping. */
|
|
35
36
|
const RESULT_MAX_BYTES = 8 * 1024 * 1024
|
|
36
37
|
|
|
38
|
+
/** Streaming chunk frames are small (≤32 KiB output, base64 + JSON). */
|
|
39
|
+
const CHUNK_MAX_BYTES = 1024 * 1024
|
|
40
|
+
|
|
41
|
+
/** File responses can carry 5 000 entries / 500 matches; stay generous but bounded. */
|
|
42
|
+
const FS_RESULT_MAX_BYTES = 16 * 1024 * 1024
|
|
43
|
+
|
|
37
44
|
/** Platform names an agent may report → the label the settings page shows. */
|
|
38
45
|
const PLATFORM_LABELS = new Map([
|
|
39
46
|
['macos', 'macOS'],
|
|
@@ -107,7 +114,7 @@ function createRouteRegistrar(ctx, onWarn) {
|
|
|
107
114
|
* @param {URL} options.agentUrl - machine agent bundle location.
|
|
108
115
|
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
109
116
|
*/
|
|
110
|
-
export function createRoutes({ ctx, config, store, hub, selections, version, agentUrl, role = 'both', onWarn = () => {} }) {
|
|
117
|
+
export function createRoutes({ ctx, config, store, hub, selections, access, fsOps = null, version, agentUrl, role = 'both', onWarn = () => {} }) {
|
|
111
118
|
/** Current clock as ISO text. */
|
|
112
119
|
const nowIso = () => new Date().toISOString()
|
|
113
120
|
/** Parsed request URL (pathname + query). */
|
|
@@ -115,6 +122,102 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
115
122
|
/** Exact-route registrar shared with the runtime surface. */
|
|
116
123
|
const route = createRouteRegistrar(ctx, onWarn)
|
|
117
124
|
|
|
125
|
+
/** Agent features reported by the computer (v1 agents report none = exec). */
|
|
126
|
+
const capabilitiesOf = (computer) => (Array.isArray(computer.capabilities) && computer.capabilities.length > 0
|
|
127
|
+
? computer.capabilities.filter((entry) => typeof entry === 'string')
|
|
128
|
+
: ['exec'])
|
|
129
|
+
|
|
130
|
+
/** The computer's default working directory, when its agent reported one. */
|
|
131
|
+
const workspaceBaseOf = (computer) => (typeof computer.workspaceBase === 'string' && computer.workspaceBase !== ''
|
|
132
|
+
? computer.workspaceBase
|
|
133
|
+
: null)
|
|
134
|
+
|
|
135
|
+
/** §11.6.4: an enrollment is pending until its first successful stream. */
|
|
136
|
+
const isActivated = (computer) => typeof computer.activatedAt === 'string'
|
|
137
|
+
|
|
138
|
+
/** Computers the browser/tools may see (activated only). */
|
|
139
|
+
const visibleComputers = () => store.read().computers.filter(isActivated)
|
|
140
|
+
|
|
141
|
+
/** Whether the computer's agentVersion is behind this plugin's. */
|
|
142
|
+
const isDrift = (computer) => versionsDiffer(computer.agentVersion, version)
|
|
143
|
+
|
|
144
|
+
/** §11.6.5: whether the agent is older than the configured minimum. */
|
|
145
|
+
const needsUpdate = (computer) => !versionAtLeast(computer.agentVersion, config.minAgentVersion)
|
|
146
|
+
|
|
147
|
+
/** Coerce one reported capability list. */
|
|
148
|
+
const cleanCapabilities = (value) => (Array.isArray(value)
|
|
149
|
+
? [...new Set(value.filter((entry) => typeof entry === 'string').map((entry) => entry.trim()).filter((entry) => ['exec', 'fs', 'pty'].includes(entry)))]
|
|
150
|
+
: null)
|
|
151
|
+
|
|
152
|
+
/** Coerce a reported workspace base (absolute POSIX or Windows path). */
|
|
153
|
+
const cleanWorkspaceBase = (value) => {
|
|
154
|
+
if (typeof value !== 'string') return null
|
|
155
|
+
const trimmed = value.trim()
|
|
156
|
+
if (trimmed === '' || trimmed.length > 1024) return null
|
|
157
|
+
return /^(\/|[A-Za-z]:[\\/])/.test(trimmed) ? trimmed : null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Handshake metadata from a stream open (§11.6.1): query `?meta=<json>` and/or
|
|
162
|
+
* `x-dsh-*` headers. Unknown fields are ignored.
|
|
163
|
+
*/
|
|
164
|
+
const handshakeFromRequest = (req) => {
|
|
165
|
+
let meta = {}
|
|
166
|
+
// The agent's own transport (§11.6.1): base64url JSON in one header.
|
|
167
|
+
const encoded = req.headers?.['x-dsh-computers-metadata']
|
|
168
|
+
const encodedText = Array.isArray(encoded) ? encoded[0] : encoded
|
|
169
|
+
if (typeof encodedText === 'string' && encodedText.trim() !== '') {
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(Buffer.from(encodedText.trim(), 'base64url').toString('utf8'))
|
|
172
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) meta = parsed
|
|
173
|
+
} catch {
|
|
174
|
+
// A malformed handshake header simply contributes nothing.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const rawMeta = urlOf(req).searchParams.get('meta')
|
|
178
|
+
if (rawMeta !== null && rawMeta !== '') {
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(rawMeta)
|
|
181
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) meta = parsed
|
|
182
|
+
} catch {
|
|
183
|
+
// A malformed meta blob simply contributes nothing.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const headerField = {
|
|
187
|
+
'x-dsh-agent-version': 'agentVersion',
|
|
188
|
+
'x-dsh-hostname': 'hostname',
|
|
189
|
+
'x-dsh-os': 'os',
|
|
190
|
+
'x-dsh-arch': 'arch',
|
|
191
|
+
'x-dsh-workspace-base': 'workspaceBase',
|
|
192
|
+
'x-dsh-capabilities': 'capabilities',
|
|
193
|
+
}
|
|
194
|
+
for (const [header, field] of Object.entries(headerField)) {
|
|
195
|
+
const raw = req.headers?.[header]
|
|
196
|
+
const text = Array.isArray(raw) ? raw[0] : raw
|
|
197
|
+
if (typeof text !== 'string' || text.trim() === '') continue
|
|
198
|
+
meta[field] = field === 'capabilities'
|
|
199
|
+
? text.split(',').map((entry) => entry.trim()).filter((entry) => entry !== '')
|
|
200
|
+
: text.trim()
|
|
201
|
+
}
|
|
202
|
+
return meta
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The §3.1 enrollment metadata fields present in one body. */
|
|
206
|
+
const enrollMetadata = (body, machine) => {
|
|
207
|
+
const source = machine !== null && typeof machine === 'object' ? machine : {}
|
|
208
|
+
const metadata = body.metadata !== null && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {}
|
|
209
|
+
const capabilities = cleanCapabilities(body.capabilities)
|
|
210
|
+
?? cleanCapabilities(metadata.capabilities)
|
|
211
|
+
?? cleanCapabilities(source.capabilities)
|
|
212
|
+
const workspaceBase = cleanWorkspaceBase(body.workspaceBase)
|
|
213
|
+
?? cleanWorkspaceBase(metadata.workspaceBase)
|
|
214
|
+
?? cleanWorkspaceBase(source.workspaceBase)
|
|
215
|
+
return {
|
|
216
|
+
...(capabilities === null ? {} : { capabilities }),
|
|
217
|
+
...(workspaceBase === null ? {} : { workspaceBase }),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
118
221
|
/** Ask the host's browser gate; true when the request was already refused. */
|
|
119
222
|
const refuseBrowser = (req, res) => refuseBrowserRequest(ctx, req, res)
|
|
120
223
|
|
|
@@ -149,9 +252,10 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
149
252
|
return null
|
|
150
253
|
}
|
|
151
254
|
|
|
152
|
-
/** Protocol §4.1 projection of one registry record. */
|
|
255
|
+
/** Protocol §4.1 + §11.1 projection of one registry record. */
|
|
153
256
|
const publicComputer = (computer) => {
|
|
154
257
|
const online = hub.isOnline(computer)
|
|
258
|
+
const capabilities = capabilitiesOf(computer)
|
|
155
259
|
return {
|
|
156
260
|
id: computer.id,
|
|
157
261
|
name: computer.name,
|
|
@@ -161,7 +265,13 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
161
265
|
online,
|
|
162
266
|
lastSeenAt: computer.lastSeenAt ?? null,
|
|
163
267
|
createdAt: computer.createdAt ?? null,
|
|
268
|
+
activatedAt: computer.activatedAt ?? null,
|
|
164
269
|
agentVersion: computer.agentVersion ?? null,
|
|
270
|
+
capabilities,
|
|
271
|
+
capabilitiesUpdatedAt: computer.capabilitiesUpdatedAt ?? null,
|
|
272
|
+
workspaceBase: workspaceBaseOf(computer),
|
|
273
|
+
drift: isDrift(computer),
|
|
274
|
+
updateRequired: needsUpdate(computer),
|
|
165
275
|
cpuModel: computer.cpuModel ?? null,
|
|
166
276
|
memoryBytes: computer.memoryBytes ?? null,
|
|
167
277
|
// Display sugar for the "1/1 Bot" badge (PROTOCOL §4.1).
|
|
@@ -222,33 +332,43 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
222
332
|
: null
|
|
223
333
|
const hostname = optionalString(machine?.hostname, 255)
|
|
224
334
|
if (hostname === null) throw new HttpError(400, 'bad-request', 'machine.hostname is required')
|
|
225
|
-
const osName = optionalString(machine?.os, 64)
|
|
226
|
-
const
|
|
335
|
+
const osName = optionalString(machine?.os, 64)
|
|
336
|
+
const explicitName = normalizeDisplayName(pendingKey.label)
|
|
227
337
|
?? normalizeDisplayName(optionalString(machine?.name, 255))
|
|
228
|
-
|
|
338
|
+
const displayName = explicitName ?? hostname
|
|
229
339
|
const requestedPlatform = optionalString(machine?.platform, 64)
|
|
230
340
|
const label = requestedPlatform !== null
|
|
231
|
-
? PLATFORM_LABELS.get(requestedPlatform.toLowerCase()) ?? platformLabel(osName)
|
|
232
|
-
: platformLabel(osName)
|
|
341
|
+
? PLATFORM_LABELS.get(requestedPlatform.toLowerCase()) ?? (osName === null ? null : platformLabel(osName))
|
|
342
|
+
: (osName === null ? null : platformLabel(osName))
|
|
233
343
|
|
|
344
|
+
// Only fields the agent actually reported are merged. A re-enroll on a
|
|
345
|
+
// reconnecting stream must not downgrade unknown fields to "unknown".
|
|
234
346
|
const fields = {
|
|
235
347
|
name: displayName,
|
|
236
348
|
hostname,
|
|
237
349
|
os: osName,
|
|
238
|
-
arch: optionalString(machine?.arch, 64)
|
|
350
|
+
arch: optionalString(machine?.arch, 64),
|
|
239
351
|
platformLabel: label,
|
|
240
|
-
cpuModel: optionalString(machine?.cpuModel, 256)
|
|
241
|
-
memoryBytes: optionalCount(machine?.memoryBytes)
|
|
242
|
-
nodeVersion: optionalString(machine?.nodeVersion, 64)
|
|
243
|
-
agentVersion: optionalString(body.agentVersion, 64) ?? optionalString(machine?.agentVersion, 64)
|
|
352
|
+
cpuModel: optionalString(machine?.cpuModel, 256),
|
|
353
|
+
memoryBytes: optionalCount(machine?.memoryBytes),
|
|
354
|
+
nodeVersion: optionalString(machine?.nodeVersion, 64),
|
|
355
|
+
agentVersion: optionalString(body.agentVersion, 64) ?? optionalString(machine?.agentVersion, 64),
|
|
244
356
|
}
|
|
357
|
+
const definedFields = Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== null))
|
|
358
|
+
const metadata = enrollMetadata(body, machine)
|
|
359
|
+
const nowEnroll = nowIso()
|
|
245
360
|
const token = mintSecret()
|
|
246
361
|
const tokenHash = sha256Hex(token)
|
|
247
|
-
// Re-enroll in place
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
362
|
+
// Re-enroll in place when the key asked for it (PROTOCOL §3.1) or when
|
|
363
|
+
// this still-pending key is already bound to a computer (retry before the
|
|
364
|
+
// first successful stream, §11.6.4).
|
|
365
|
+
const boundId = typeof pendingKey.computerId === 'string' ? pendingKey.computerId : null
|
|
366
|
+
const replaceTarget = boundId !== null
|
|
367
|
+
? doc.computers.find((computer) => computer.id === boundId)
|
|
368
|
+
: pendingKey.replace === true
|
|
369
|
+
? doc.computers.find((computer) => computer.hostname === hostname
|
|
370
|
+
|| computer.name.toLowerCase() === displayName.toLowerCase())
|
|
371
|
+
: undefined
|
|
252
372
|
|
|
253
373
|
const enrolled = store.mutate((document) => {
|
|
254
374
|
const keyEntry = document.pendingKeys.find((entry) => entry.key === keyValue)
|
|
@@ -260,21 +380,45 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
260
380
|
computer = document.computers.find((entry) => entry.id === replaceTarget.id)
|
|
261
381
|
}
|
|
262
382
|
if (computer !== undefined) {
|
|
263
|
-
// Same id
|
|
264
|
-
//
|
|
265
|
-
|
|
383
|
+
// Same id: replace the token so the previous install stops working.
|
|
384
|
+
// A re-enroll that reports no machine.name must NOT rename the record
|
|
385
|
+
// to the hostname (the user's chosen name survives reconnects).
|
|
386
|
+
const previousName = computer.name
|
|
387
|
+
Object.assign(computer, definedFields, metadata, {
|
|
388
|
+
tokenHash,
|
|
389
|
+
lastSeenAt: nowEnroll,
|
|
390
|
+
capabilities: metadata.capabilities ?? computer.capabilities ?? ['exec'],
|
|
391
|
+
capabilitiesUpdatedAt: nowEnroll,
|
|
392
|
+
})
|
|
393
|
+
if (explicitName === null) computer.name = previousName
|
|
266
394
|
} else {
|
|
267
395
|
computer = {
|
|
268
396
|
id: newComputerId(),
|
|
269
|
-
|
|
397
|
+
version: 1,
|
|
398
|
+
name: displayName,
|
|
399
|
+
hostname,
|
|
400
|
+
os: osName ?? 'unknown',
|
|
401
|
+
arch: optionalString(machine?.arch, 64) ?? 'unknown',
|
|
402
|
+
platformLabel: label ?? 'Unknown',
|
|
403
|
+
cpuModel: optionalString(machine?.cpuModel, 256),
|
|
404
|
+
memoryBytes: optionalCount(machine?.memoryBytes),
|
|
405
|
+
nodeVersion: optionalString(machine?.nodeVersion, 64),
|
|
406
|
+
agentVersion: optionalString(body.agentVersion, 64) ?? optionalString(machine?.agentVersion, 64) ?? 'unknown',
|
|
407
|
+
...metadata,
|
|
408
|
+
capabilities: metadata.capabilities ?? ['exec'],
|
|
409
|
+
capabilitiesUpdatedAt: nowEnroll,
|
|
410
|
+
workspaceBase: metadata.workspaceBase ?? null,
|
|
270
411
|
tokenHash,
|
|
271
|
-
createdAt:
|
|
272
|
-
lastSeenAt:
|
|
412
|
+
createdAt: nowEnroll,
|
|
413
|
+
lastSeenAt: nowEnroll,
|
|
414
|
+
// §11.6.4: hidden from the list until the first successful stream.
|
|
415
|
+
activatedAt: null,
|
|
273
416
|
}
|
|
274
417
|
document.computers.push(computer)
|
|
275
418
|
}
|
|
276
|
-
|
|
419
|
+
// The key stays unused/pending until the agent's first stream activates it.
|
|
277
420
|
keyEntry.computerId = computer.id
|
|
421
|
+
keyEntry.enrolledAt = nowEnroll
|
|
278
422
|
return { computerId: computer.id, name: computer.name, replaced: replaceTarget !== undefined }
|
|
279
423
|
})
|
|
280
424
|
|
|
@@ -295,8 +439,53 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
295
439
|
const computer = requireComputer(req, res)
|
|
296
440
|
if (computer === undefined) return
|
|
297
441
|
if (!requireMethod(req, res, 'GET')) return
|
|
298
|
-
|
|
299
|
-
|
|
442
|
+
const handshake = handshakeFromRequest(req)
|
|
443
|
+
const capabilities = cleanCapabilities(handshake.capabilities)
|
|
444
|
+
const workspaceBase = cleanWorkspaceBase(handshake.workspaceBase)
|
|
445
|
+
const agentVersion = optionalString(handshake.agentVersion, 64)
|
|
446
|
+
const handshakeHost = optionalString(handshake.hostname, 255)
|
|
447
|
+
const handshakeOs = optionalString(handshake.os, 64)
|
|
448
|
+
const handshakeArch = optionalString(handshake.arch, 64)
|
|
449
|
+
const activated = store.mutate((document) => {
|
|
450
|
+
const record = document.computers.find((entry) => entry.id === computer.id)
|
|
451
|
+
if (record === undefined) return null
|
|
452
|
+
const now = nowIso()
|
|
453
|
+
record.lastSeenAt = now
|
|
454
|
+
// §11.6.1: every reconnect refreshes the stored handshake metadata.
|
|
455
|
+
if (agentVersion !== null) record.agentVersion = agentVersion
|
|
456
|
+
if (handshakeHost !== null) record.hostname = handshakeHost
|
|
457
|
+
if (handshakeOs !== null) record.os = handshakeOs
|
|
458
|
+
if (handshakeArch !== null) record.arch = handshakeArch
|
|
459
|
+
if (capabilities !== null) {
|
|
460
|
+
record.capabilities = capabilities
|
|
461
|
+
record.capabilitiesUpdatedAt = now
|
|
462
|
+
}
|
|
463
|
+
if (workspaceBase !== null) record.workspaceBase = workspaceBase
|
|
464
|
+
const firstStream = typeof record.activatedAt !== 'string'
|
|
465
|
+
if (firstStream) record.activatedAt = now
|
|
466
|
+
// §11.6.4: the first successful stream activates the enrollment.
|
|
467
|
+
if (firstStream) {
|
|
468
|
+
for (const key of document.pendingKeys) {
|
|
469
|
+
if (key.computerId === record.id && typeof key.usedAt !== 'string') key.usedAt = now
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
name: record.name,
|
|
474
|
+
capabilities: Array.isArray(record.capabilities) && record.capabilities.length > 0 ? record.capabilities : ['exec'],
|
|
475
|
+
}
|
|
476
|
+
})
|
|
477
|
+
if (activated === null) {
|
|
478
|
+
jsonError(res, 401, 'invalid-token', 'authorization: Bearer <agent token> is required')
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
hub.openStream(computer.id, activated.name, req, res, { capabilities: activated.capabilities })
|
|
482
|
+
if (fsOps !== null) {
|
|
483
|
+
// Abandon file requests whose stream died (but not one replaced by a
|
|
484
|
+
// reconnecting stream for the same computer).
|
|
485
|
+
res.on('close', () => {
|
|
486
|
+
if (!hub.isLive(computer.id)) fsOps.failComputer(computer.id)
|
|
487
|
+
})
|
|
488
|
+
}
|
|
300
489
|
})
|
|
301
490
|
|
|
302
491
|
route('/agent/results', async (req, res) => {
|
|
@@ -315,6 +504,32 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
315
504
|
sendJson(res, 200, { ok: true })
|
|
316
505
|
})
|
|
317
506
|
|
|
507
|
+
route('/agent/results/chunk', async (req, res) => {
|
|
508
|
+
const computer = requireComputer(req, res)
|
|
509
|
+
if (computer === undefined) return
|
|
510
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
511
|
+
const body = await readObjectBody(req, CHUNK_MAX_BYTES)
|
|
512
|
+
store.touch(computer.id)
|
|
513
|
+
if (!hub.settleChunk(computer.id, body)) {
|
|
514
|
+
jsonError(res, 404, 'unknown-request', 'this execution is no longer pending (already settled or timed out)')
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
sendJson(res, 200, { ok: true })
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
route('/agent/fs/result', async (req, res) => {
|
|
521
|
+
const computer = requireComputer(req, res)
|
|
522
|
+
if (computer === undefined) return
|
|
523
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
524
|
+
const body = await readObjectBody(req, FS_RESULT_MAX_BYTES)
|
|
525
|
+
store.touch(computer.id)
|
|
526
|
+
if (fsOps === null || !fsOps.settle(computer.id, body)) {
|
|
527
|
+
jsonError(res, 404, 'unknown-request', 'this file request is no longer pending')
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
sendJson(res, 200, { ok: true })
|
|
531
|
+
})
|
|
532
|
+
|
|
318
533
|
route('/agent/heartbeat', async (req, res) => {
|
|
319
534
|
// Authenticate before anything else on machine routes (PROTOCOL §3).
|
|
320
535
|
const computer = requireComputer(req, res)
|
|
@@ -322,22 +537,30 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
322
537
|
if (!requireMethod(req, res, 'POST')) return
|
|
323
538
|
let body = {}
|
|
324
539
|
try {
|
|
325
|
-
body = await readObjectBody(req,
|
|
540
|
+
body = await readObjectBody(req, 256 * 1024)
|
|
326
541
|
} catch (error) {
|
|
327
542
|
// An empty heartbeat body is legal; anything else is still a 400.
|
|
328
543
|
if (!(error instanceof HttpError) || error.code !== 'bad-request' || !/empty/.test(error.message)) throw error
|
|
329
544
|
}
|
|
330
545
|
const meta = body.meta !== null && typeof body.meta === 'object' && !Array.isArray(body.meta) ? body.meta : null
|
|
546
|
+
const reportedCapabilities = cleanCapabilities(body.capabilities) ?? cleanCapabilities(meta?.capabilities)
|
|
547
|
+
const reportedWorkspaceBase = cleanWorkspaceBase(body.workspaceBase) ?? cleanWorkspaceBase(meta?.workspaceBase)
|
|
331
548
|
const name = store.mutate((document) => {
|
|
332
549
|
const record = document.computers.find((entry) => entry.id === computer.id)
|
|
333
550
|
if (record === undefined) return computer.name
|
|
334
|
-
|
|
551
|
+
const now = nowIso()
|
|
552
|
+
record.lastSeenAt = now
|
|
553
|
+
if (reportedCapabilities !== null) {
|
|
554
|
+
record.capabilities = reportedCapabilities
|
|
555
|
+
record.capabilitiesUpdatedAt = now
|
|
556
|
+
}
|
|
557
|
+
if (reportedWorkspaceBase !== null) record.workspaceBase = reportedWorkspaceBase
|
|
558
|
+
const agentVersion = optionalString(body.agentVersion, 64) ?? optionalString(meta?.agentVersion, 64)
|
|
559
|
+
if (agentVersion !== null) record.agentVersion = agentVersion
|
|
335
560
|
if (meta !== null) {
|
|
336
561
|
const cpuModel = optionalString(meta.cpuModel, 256)
|
|
337
|
-
const agentVersion = optionalString(meta.agentVersion, 64)
|
|
338
562
|
const memoryBytes = optionalCount(meta.memoryBytes)
|
|
339
563
|
if (cpuModel !== null) record.cpuModel = cpuModel
|
|
340
|
-
if (agentVersion !== null) record.agentVersion = agentVersion
|
|
341
564
|
if (memoryBytes !== null) record.memoryBytes = memoryBytes
|
|
342
565
|
}
|
|
343
566
|
return record.name
|
|
@@ -360,7 +583,8 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
360
583
|
role,
|
|
361
584
|
serverUrl: resolveBaseUrl({ ctx, config, req }),
|
|
362
585
|
heartbeatMs: config.heartbeatMs,
|
|
363
|
-
|
|
586
|
+
// §11.6.4: a computer appears only after its first successful stream.
|
|
587
|
+
computers: visibleComputers().map(publicComputer),
|
|
364
588
|
pendingKey: pendingKey === null
|
|
365
589
|
? null
|
|
366
590
|
: { key: pendingKey.key, expiresAt: pendingKey.expiresAt ?? null, os: pendingKey.os ?? 'macos' },
|
|
@@ -433,7 +657,11 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
433
657
|
const linked = typeof entry.computerId === 'string'
|
|
434
658
|
? doc.computers.find((candidate) => candidate.id === entry.computerId)
|
|
435
659
|
: undefined
|
|
436
|
-
if (linked !== undefined) {
|
|
660
|
+
if (linked !== undefined && !isActivated(linked)) {
|
|
661
|
+
// Enrolled but the agent never completed a stream: the dialog keeps
|
|
662
|
+
// polling (§11.6.4).
|
|
663
|
+
status = 'waiting'
|
|
664
|
+
} else if (linked !== undefined) {
|
|
437
665
|
status = 'connected'
|
|
438
666
|
computer = { id: linked.id, name: linked.name, online: hub.isOnline(linked) }
|
|
439
667
|
} else if (typeof entry.usedAt === 'string') {
|
|
@@ -457,9 +685,11 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
457
685
|
const found = store.read().computers.some((computer) => computer.id === id)
|
|
458
686
|
if (!found) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(id)}`)
|
|
459
687
|
hub.closeStream(id)
|
|
688
|
+
if (fsOps !== null) fsOps.failComputer(id, 'the computer was deleted')
|
|
460
689
|
store.mutate((document) => {
|
|
461
690
|
document.computers = document.computers.filter((computer) => computer.id !== id)
|
|
462
691
|
})
|
|
692
|
+
access.store.forgetComputer(id)
|
|
463
693
|
sendJson(res, 200, { ok: true })
|
|
464
694
|
})
|
|
465
695
|
|
|
@@ -483,6 +713,148 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
483
713
|
sendJson(res, 200, { ok: true, name })
|
|
484
714
|
})
|
|
485
715
|
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
// Access control (the two access dialogs)
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Payload for the conversation-level dialog ("它可以使用哪些电脑?"): every
|
|
722
|
+
* computer with the EFFECTIVE answer for this session's preset + allow-list.
|
|
723
|
+
* `sessionAllowed` is the raw rule (null = "all"), so the client can tell a
|
|
724
|
+
* customized list from the default.
|
|
725
|
+
*/
|
|
726
|
+
const accessPayload = async (sessionId) => {
|
|
727
|
+
const presetId = access.presetIdForSession(sessionId)
|
|
728
|
+
const presets = await access.presets()
|
|
729
|
+
const sessionAllowed = access.store.sessionAllow(sessionId)
|
|
730
|
+
return {
|
|
731
|
+
sessionId,
|
|
732
|
+
presetId,
|
|
733
|
+
presets,
|
|
734
|
+
computers: visibleComputers().map((computer) => {
|
|
735
|
+
const presetOk = access.presetAllows(computer.id, presetId)
|
|
736
|
+
const sessionOk = access.sessionAllows(sessionId, computer.id)
|
|
737
|
+
return {
|
|
738
|
+
id: computer.id,
|
|
739
|
+
name: computer.name,
|
|
740
|
+
online: hub.isOnline(computer),
|
|
741
|
+
platformLabel: computer.platformLabel ?? 'Unknown',
|
|
742
|
+
allowed: presetOk && sessionOk,
|
|
743
|
+
sessionAllowed: sessionOk,
|
|
744
|
+
presetAllowed: presetOk,
|
|
745
|
+
}
|
|
746
|
+
}),
|
|
747
|
+
sessionAllowed,
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Payload for the computer-level dialog ("哪些 Bot 可以使用它?"). */
|
|
752
|
+
const accessComputerPayload = async (computerId) => {
|
|
753
|
+
const computer = visibleComputers().find((candidate) => candidate.id === computerId)
|
|
754
|
+
if (computer === undefined) {
|
|
755
|
+
throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(computerId)}`)
|
|
756
|
+
}
|
|
757
|
+
const presets = await access.presets()
|
|
758
|
+
const allowedPresets = access.store.computerPresets(computerId)
|
|
759
|
+
return {
|
|
760
|
+
computer: {
|
|
761
|
+
id: computer.id,
|
|
762
|
+
name: computer.name,
|
|
763
|
+
online: hub.isOnline(computer),
|
|
764
|
+
platformLabel: computer.platformLabel ?? 'Unknown',
|
|
765
|
+
},
|
|
766
|
+
presets: presets.map((preset) => ({
|
|
767
|
+
id: preset.id,
|
|
768
|
+
name: preset.name,
|
|
769
|
+
allowed: allowedPresets === null || allowedPresets.includes(preset.id),
|
|
770
|
+
})),
|
|
771
|
+
allowedPresets,
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
route('/access', async (req, res) => {
|
|
776
|
+
// The host's request gate runs first on every browser route (PROTOCOL §4).
|
|
777
|
+
if (refuseBrowser(req, res)) return
|
|
778
|
+
const method = typeof req.method === 'string' ? req.method.toUpperCase() : ''
|
|
779
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
780
|
+
const sessionId = normalizeSessionId(urlOf(req).searchParams.get('sessionId'))
|
|
781
|
+
if (sessionId === null) {
|
|
782
|
+
throw new HttpError(400, 'bad-request', 'sessionId must be a non-empty string (max 256 characters)')
|
|
783
|
+
}
|
|
784
|
+
sendJson(res, 200, await accessPayload(sessionId))
|
|
785
|
+
return
|
|
786
|
+
}
|
|
787
|
+
if (method === 'POST') {
|
|
788
|
+
const body = await readObjectBody(req, CONTROL_MAX_BYTES)
|
|
789
|
+
const sessionId = normalizeSessionId(body.sessionId)
|
|
790
|
+
if (sessionId === null) {
|
|
791
|
+
throw new HttpError(400, 'bad-request', 'sessionId must be a non-empty string (max 256 characters)')
|
|
792
|
+
}
|
|
793
|
+
if (!Object.hasOwn(body, 'allowedComputers')) {
|
|
794
|
+
throw new HttpError(400, 'bad-request', 'allowedComputers is required (an array of computer ids or null)')
|
|
795
|
+
}
|
|
796
|
+
if (body.allowedComputers !== null) {
|
|
797
|
+
if (!Array.isArray(body.allowedComputers)) {
|
|
798
|
+
throw new HttpError(400, 'bad-request', 'allowedComputers must be an array of computer ids or null')
|
|
799
|
+
}
|
|
800
|
+
const known = new Set(visibleComputers().map((computer) => computer.id))
|
|
801
|
+
for (const entry of body.allowedComputers) {
|
|
802
|
+
if (typeof entry !== 'string' || !known.has(entry.trim())) {
|
|
803
|
+
throw new HttpError(400, 'bad-request', `unknown computer id ${JSON.stringify(entry)}`)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
access.store.setSessionAllow(sessionId, body.allowedComputers)
|
|
808
|
+
sendJson(res, 200, await accessPayload(sessionId))
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
methodNotAllowed(res, 'GET / POST')
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
route('/access/computer', async (req, res) => {
|
|
815
|
+
// The host's request gate runs first on every browser route (PROTOCOL §4).
|
|
816
|
+
if (refuseBrowser(req, res)) return
|
|
817
|
+
const method = typeof req.method === 'string' ? req.method.toUpperCase() : ''
|
|
818
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
819
|
+
const computerId = typeof urlOf(req).searchParams.get('computerId') === 'string'
|
|
820
|
+
? urlOf(req).searchParams.get('computerId').trim()
|
|
821
|
+
: ''
|
|
822
|
+
if (computerId === '') {
|
|
823
|
+
throw new HttpError(400, 'bad-request', 'computerId is required')
|
|
824
|
+
}
|
|
825
|
+
sendJson(res, 200, await accessComputerPayload(computerId))
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
if (method === 'POST') {
|
|
829
|
+
const body = await readObjectBody(req, CONTROL_MAX_BYTES)
|
|
830
|
+
const computerId = typeof body.computerId === 'string' ? body.computerId.trim() : ''
|
|
831
|
+
if (computerId === '') {
|
|
832
|
+
throw new HttpError(400, 'bad-request', 'computerId is required')
|
|
833
|
+
}
|
|
834
|
+
const known = visibleComputers().some((computer) => computer.id === computerId)
|
|
835
|
+
if (!known) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(computerId)}`)
|
|
836
|
+
if (!Object.hasOwn(body, 'allowedPresets')) {
|
|
837
|
+
throw new HttpError(400, 'bad-request', 'allowedPresets is required (an array of preset ids or null)')
|
|
838
|
+
}
|
|
839
|
+
if (body.allowedPresets !== null) {
|
|
840
|
+
if (!Array.isArray(body.allowedPresets)) {
|
|
841
|
+
throw new HttpError(400, 'bad-request', 'allowedPresets must be an array of preset ids or null')
|
|
842
|
+
}
|
|
843
|
+
const roster = await access.presets()
|
|
844
|
+
const knownPresets = new Set(roster.map((preset) => preset.id))
|
|
845
|
+
for (const entry of body.allowedPresets) {
|
|
846
|
+
if (typeof entry !== 'string' || !knownPresets.has(entry.trim())) {
|
|
847
|
+
throw new HttpError(400, 'bad-request', `unknown Agent preset id ${JSON.stringify(entry)}`)
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
access.store.setComputerPresets(computerId, body.allowedPresets)
|
|
852
|
+
sendJson(res, 200, await accessComputerPayload(computerId))
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
methodNotAllowed(res, 'GET / POST')
|
|
856
|
+
})
|
|
857
|
+
|
|
486
858
|
// ---------------------------------------------------------------------------
|
|
487
859
|
// Per-session target selection (task-9)
|
|
488
860
|
// ---------------------------------------------------------------------------
|
|
@@ -495,7 +867,7 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
495
867
|
const selectionPayload = (sessionId, selectedId) => {
|
|
496
868
|
const computer = selectedId === null
|
|
497
869
|
? undefined
|
|
498
|
-
:
|
|
870
|
+
: visibleComputers().find((candidate) => candidate.id === selectedId)
|
|
499
871
|
return {
|
|
500
872
|
sessionId,
|
|
501
873
|
computerId: computer === undefined ? null : computer.id,
|
|
@@ -540,8 +912,14 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
540
912
|
throw new HttpError(400, 'bad-request', 'computerId must be a non-empty string or null')
|
|
541
913
|
}
|
|
542
914
|
const computerId = body.computerId.trim()
|
|
543
|
-
const
|
|
544
|
-
if (
|
|
915
|
+
const computer = visibleComputers().find((candidate) => candidate.id === computerId)
|
|
916
|
+
if (computer === undefined) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(computerId)}`)
|
|
917
|
+
// Pinning is itself an authorization decision: a conversation may not
|
|
918
|
+
// pin a machine its preset rule or allow-list denies.
|
|
919
|
+
const presetId = access.presetIdForSession(sessionId)
|
|
920
|
+
if (!access.isAllowed({ sessionId, presetId, computerId })) {
|
|
921
|
+
throw new HttpError(403, 'forbidden', access.denialMessage(computer, presetId))
|
|
922
|
+
}
|
|
545
923
|
selections.set(sessionId, computerId)
|
|
546
924
|
sendJson(res, 200, selectionPayload(sessionId, computerId))
|
|
547
925
|
return
|
|
@@ -576,12 +954,21 @@ export function createRoutes({ ctx, config, store, hub, selections, version, age
|
|
|
576
954
|
|
|
577
955
|
route('/health', async (req, res) => {
|
|
578
956
|
if (!requireMethod(req, res, 'GET')) return
|
|
579
|
-
const computers =
|
|
957
|
+
const computers = visibleComputers()
|
|
580
958
|
sendJson(res, 200, {
|
|
581
959
|
ok: true,
|
|
582
960
|
version,
|
|
583
961
|
computers: computers.length,
|
|
584
|
-
online: hub.
|
|
962
|
+
online: computers.filter((computer) => hub.isOnline(computer)).length,
|
|
963
|
+
// §11.1: expose each agent's version/capabilities + drift inline.
|
|
964
|
+
agents: computers.map((computer) => ({
|
|
965
|
+
id: computer.id,
|
|
966
|
+
name: computer.name,
|
|
967
|
+
agentVersion: computer.agentVersion ?? null,
|
|
968
|
+
capabilities: capabilitiesOf(computer),
|
|
969
|
+
drift: isDrift(computer),
|
|
970
|
+
updateRequired: needsUpdate(computer),
|
|
971
|
+
})),
|
|
585
972
|
})
|
|
586
973
|
})
|
|
587
974
|
}
|
|
@@ -692,6 +1079,6 @@ export function createRuntimeStatusRoutes({ ctx, config, role = 'runtime', versi
|
|
|
692
1079
|
|
|
693
1080
|
route('/health', async (req, res) => {
|
|
694
1081
|
if (!requireMethod(req, res, 'GET')) return
|
|
695
|
-
sendJson(res, 200, { ok: true, version, computers: 0, online: 0 })
|
|
1082
|
+
sendJson(res, 200, { ok: true, version, computers: 0, online: 0, agents: [] })
|
|
696
1083
|
})
|
|
697
1084
|
}
|
package/lib/state.js
CHANGED
|
@@ -65,11 +65,20 @@ export function prunePendingKeys(doc, nowMs) {
|
|
|
65
65
|
* @param {unknown} raw - parsed JSON (or undefined when the file was missing).
|
|
66
66
|
* @returns {object} a valid registry document.
|
|
67
67
|
*/
|
|
68
|
-
export function normalizeState(raw) {
|
|
68
|
+
export function normalizeState(raw, now = undefined) {
|
|
69
69
|
const doc = emptyState()
|
|
70
70
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return doc
|
|
71
71
|
if (Array.isArray(raw.computers)) {
|
|
72
72
|
doc.computers = raw.computers.filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry))
|
|
73
|
+
for (const computer of doc.computers) {
|
|
74
|
+
// §11.6.4 migration: records written before the activation lifecycle have
|
|
75
|
+
// no `activatedAt` property at all. They were activated by definition, so
|
|
76
|
+
// backfill it from createdAt instead of hiding a working machine. A v1.1
|
|
77
|
+
// enrollment sets `activatedAt: null` explicitly and stays pending.
|
|
78
|
+
if (computer.activatedAt === undefined) {
|
|
79
|
+
computer.activatedAt = typeof computer.createdAt === 'string' ? computer.createdAt : new Date(now ?? Date.now()).toISOString()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
73
82
|
}
|
|
74
83
|
if (Array.isArray(raw.pendingKeys)) {
|
|
75
84
|
doc.pendingKeys = raw.pendingKeys.filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry))
|
|
@@ -104,7 +113,7 @@ export function createStore({ stateDir, now = Date.now, onWarn = () => {} }) {
|
|
|
104
113
|
return
|
|
105
114
|
}
|
|
106
115
|
try {
|
|
107
|
-
doc = normalizeState(JSON.parse(raw))
|
|
116
|
+
doc = normalizeState(JSON.parse(raw), now())
|
|
108
117
|
} catch {
|
|
109
118
|
// A half-written legacy file must not brick the plugin: keep it for
|
|
110
119
|
// inspection and start from an empty registry.
|