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/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) ?? 'unknown'
226
- const displayName = normalizeDisplayName(pendingKey.label)
335
+ const osName = optionalString(machine?.os, 64)
336
+ const explicitName = normalizeDisplayName(pendingKey.label)
227
337
  ?? normalizeDisplayName(optionalString(machine?.name, 255))
228
- ?? hostname
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) ?? 'unknown',
350
+ arch: optionalString(machine?.arch, 64),
239
351
  platformLabel: label,
240
- cpuModel: optionalString(machine?.cpuModel, 256) ?? null,
241
- memoryBytes: optionalCount(machine?.memoryBytes) ?? null,
242
- nodeVersion: optionalString(machine?.nodeVersion, 64) ?? null,
243
- agentVersion: optionalString(body.agentVersion, 64) ?? optionalString(machine?.agentVersion, 64) ?? 'unknown',
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 only when the key asked for it (PROTOCOL §3.1).
248
- const replaceTarget = pendingKey.replace === true
249
- ? doc.computers.find((computer) => computer.hostname === hostname
250
- || computer.name.toLowerCase() === displayName.toLowerCase())
251
- : undefined
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, same name unless the agent reported a new one: the old
264
- // token hash is replaced so the previous install stops working.
265
- Object.assign(computer, fields, { tokenHash, lastSeenAt: nowIso() })
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
- ...fields,
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: nowIso(),
272
- lastSeenAt: nowIso(),
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
- keyEntry.usedAt = nowIso()
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
- store.touch(computer.id)
299
- hub.openStream(computer.id, computer.name, req, res)
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, 64 * 1024)
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
- record.lastSeenAt = nowIso()
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
- computers: doc.computers.map(publicComputer),
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
- : store.read().computers.find((candidate) => candidate.id === selectedId)
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 known = store.read().computers.some((candidate) => candidate.id === computerId)
544
- if (!known) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(computerId)}`)
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 = store.read().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.onlineCount(),
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.