dsh-computers 0.0.0-stage → 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 ADDED
@@ -0,0 +1,1084 @@
1
+ /**
2
+ * Every HTTP route dsh-computers serves (PROTOCOL §3 and §4).
3
+ *
4
+ * Two surfaces share `/api/dsh-computers`:
5
+ * - machine routes (`/agent/*`) authenticate with a bearer token and never
6
+ * pass the browser gate;
7
+ * - browser routes (state, keys, key-status, delete, rename) ask the host's
8
+ * request gate first, then answer JSON;
9
+ * - public routes (join.sh, join.ps1, agent.mjs, health) are unauthenticated
10
+ * but key- or path-scoped.
11
+ *
12
+ * Node builtins only.
13
+ */
14
+
15
+ import {
16
+ HttpError, joinPs1, joinSh, jsonError, methodNotAllowed, readJsonBody,
17
+ refuseBrowserRequest, resolveBaseUrl, sendJson, sendText, serveAgentScript,
18
+ } from './http.js'
19
+ import {
20
+ bearerToken, isExpired, mintSecret, newComputerId, normalizeDisplayName,
21
+ optionalCount, optionalString, platformLabel, sha256Hex, tokenMatches,
22
+ } from './keys.js'
23
+ import { normalizeSessionId } from './selection.js'
24
+ import { versionAtLeast, versionsDiffer } from './version.js'
25
+
26
+ /** Base path shared by every route (PROTOCOL §3). */
27
+ export const API_BASE = '/api/dsh-computers'
28
+
29
+ /** Enrollment bodies are small (PROTOCOL §3.1: max 16 KiB). */
30
+ const ENROLL_MAX_BYTES = 16 * 1024
31
+
32
+ /** Browser control bodies are tiny. */
33
+ const CONTROL_MAX_BYTES = 16 * 1024
34
+
35
+ /** Result posts carry two ≤1 MiB streams plus JSON escaping. */
36
+ const RESULT_MAX_BYTES = 8 * 1024 * 1024
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
+
44
+ /** Platform names an agent may report → the label the settings page shows. */
45
+ const PLATFORM_LABELS = new Map([
46
+ ['macos', 'macOS'],
47
+ ['darwin', 'macOS'],
48
+ ['osx', 'macOS'],
49
+ ['linux', 'Linux'],
50
+ ['windows', 'Windows'],
51
+ ['win32', 'Windows'],
52
+ ])
53
+
54
+ /** Enforce one method; answers 405 and returns false otherwise. */
55
+ function requireMethod(req, res, ...methods) {
56
+ const method = typeof req.method === 'string' ? req.method.toUpperCase() : ''
57
+ if (method === 'HEAD' && methods.includes('GET')) return true
58
+ if (methods.includes(method)) return true
59
+ methodNotAllowed(res, methods.join(' / '))
60
+ return false
61
+ }
62
+
63
+ /** Parse an object body or throw the protocol's 400. */
64
+ async function readObjectBody(req, maxBytes) {
65
+ const body = await readJsonBody(req, maxBytes)
66
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
67
+ throw new HttpError(400, 'bad-request', 'request body must be a JSON object')
68
+ }
69
+ return body
70
+ }
71
+
72
+ /**
73
+ * Build the exact-route registrar used by both route surfaces. Handlers own
74
+ * the whole response lifecycle, which is what lets the SSE route stay open.
75
+ */
76
+ function createRouteRegistrar(ctx, onWarn) {
77
+ return (suffix, handler) => {
78
+ const path = `${API_BASE}${suffix}`
79
+ ctx.effect(() => ctx.webServer.register({
80
+ kind: 'exact',
81
+ path,
82
+ handler: async (req, res) => {
83
+ try {
84
+ await handler(req, res)
85
+ } catch (error) {
86
+ if (res.headersSent) {
87
+ try {
88
+ res.end()
89
+ } catch {
90
+ // Client already gone.
91
+ }
92
+ return
93
+ }
94
+ if (error instanceof HttpError) {
95
+ jsonError(res, error.status, error.code, error.message)
96
+ return
97
+ }
98
+ onWarn(`dsh-computers: ${req.method} ${path} failed: ${error && error.stack ? error.stack : error}`)
99
+ jsonError(res, 500, 'internal', 'the dsh-computers host failed to handle this request')
100
+ }
101
+ },
102
+ }), `dsh-computers: ${path}`)
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Register every route on the host web server.
108
+ * @param {object} options - route wiring.
109
+ * @param {object} options.ctx - Cordis context (webServer + optional connection).
110
+ * @param {object} options.config - resolved plugin config.
111
+ * @param {object} options.store - registry store.
112
+ * @param {object} options.hub - SSE/exec hub.
113
+ * @param {string} options.version - plugin version reported by /health.
114
+ * @param {URL} options.agentUrl - machine agent bundle location.
115
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
116
+ */
117
+ export function createRoutes({ ctx, config, store, hub, selections, access, fsOps = null, version, agentUrl, role = 'both', onWarn = () => {} }) {
118
+ /** Current clock as ISO text. */
119
+ const nowIso = () => new Date().toISOString()
120
+ /** Parsed request URL (pathname + query). */
121
+ const urlOf = (req) => new URL(typeof req.url === 'string' && req.url !== '' ? req.url : '/', 'http://dsh-computers.invalid')
122
+ /** Exact-route registrar shared with the runtime surface. */
123
+ const route = createRouteRegistrar(ctx, onWarn)
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
+
221
+ /** Ask the host's browser gate; true when the request was already refused. */
222
+ const refuseBrowser = (req, res) => refuseBrowserRequest(ctx, req, res)
223
+
224
+ /** Resolve the bearer token to a registry computer, or undefined. */
225
+ const authenticate = (req) => {
226
+ const token = bearerToken(req)
227
+ if (token === null) return undefined
228
+ for (const computer of store.read().computers) {
229
+ if (tokenMatches(token, computer.tokenHash)) return computer
230
+ }
231
+ return undefined
232
+ }
233
+
234
+ /** Refuse a machine route without valid credentials. */
235
+ const requireComputer = (req, res) => {
236
+ const computer = authenticate(req)
237
+ if (computer === undefined) {
238
+ jsonError(res, 401, 'invalid-token', 'authorization: Bearer <agent token> is required')
239
+ return undefined
240
+ }
241
+ return computer
242
+ }
243
+
244
+ /** The pending key the browser should show, if any. */
245
+ const currentPendingKey = (doc, nowMs) => {
246
+ for (let index = doc.pendingKeys.length - 1; index >= 0; index -= 1) {
247
+ const entry = doc.pendingKeys[index]
248
+ if (typeof entry.usedAt === 'string') continue
249
+ if (isExpired(entry.expiresAt, nowMs)) continue
250
+ return entry
251
+ }
252
+ return null
253
+ }
254
+
255
+ /** Protocol §4.1 + §11.1 projection of one registry record. */
256
+ const publicComputer = (computer) => {
257
+ const online = hub.isOnline(computer)
258
+ const capabilities = capabilitiesOf(computer)
259
+ return {
260
+ id: computer.id,
261
+ name: computer.name,
262
+ hostname: computer.hostname,
263
+ os: computer.os,
264
+ platformLabel: computer.platformLabel,
265
+ online,
266
+ lastSeenAt: computer.lastSeenAt ?? null,
267
+ createdAt: computer.createdAt ?? null,
268
+ activatedAt: computer.activatedAt ?? null,
269
+ agentVersion: computer.agentVersion ?? null,
270
+ capabilities,
271
+ capabilitiesUpdatedAt: computer.capabilitiesUpdatedAt ?? null,
272
+ workspaceBase: workspaceBaseOf(computer),
273
+ drift: isDrift(computer),
274
+ updateRequired: needsUpdate(computer),
275
+ cpuModel: computer.cpuModel ?? null,
276
+ memoryBytes: computer.memoryBytes ?? null,
277
+ // Display sugar for the "1/1 Bot" badge (PROTOCOL §4.1).
278
+ bots: { used: online ? 1 : 0, total: 1 },
279
+ }
280
+ }
281
+
282
+ /** Key-validated installer scripts share one refusal rule. */
283
+ const installerKey = (req, res, requiredSearchParam) => {
284
+ const keyValue = urlOf(req).searchParams.get(requiredSearchParam)
285
+ if (keyValue === null || keyValue === '') {
286
+ sendText(res, 410, 'missing enrollment key — open the Computers settings page and copy a fresh command\n')
287
+ return null
288
+ }
289
+ const entry = store.read().pendingKeys.find((candidate) => candidate.key === keyValue)
290
+ if (entry === undefined) {
291
+ sendText(res, 410, 'unknown enrollment key — create a new one on the Computers settings page\n')
292
+ return null
293
+ }
294
+ if (typeof entry.usedAt === 'string') {
295
+ sendText(res, 410, 'this enrollment key was already used — create a new one on the Computers settings page\n')
296
+ return null
297
+ }
298
+ if (isExpired(entry.expiresAt, Date.now())) {
299
+ sendText(res, 410, 'this enrollment key expired — create a new one on the Computers settings page\n')
300
+ return null
301
+ }
302
+ return entry
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Machine routes (bearer token, no browser gate)
307
+ // ---------------------------------------------------------------------------
308
+
309
+ route('/agent/enroll', async (req, res) => {
310
+ if (!requireMethod(req, res, 'POST')) return
311
+ const body = await readObjectBody(req, ENROLL_MAX_BYTES)
312
+ const keyValue = typeof body.key === 'string' ? body.key.trim() : ''
313
+ if (!/^dshc_[A-Za-z0-9_-]{20,}$/.test(keyValue)) {
314
+ throw new HttpError(400, 'bad-request', 'key must be a dshc_ enrollment key')
315
+ }
316
+ const nowMs = Date.now()
317
+ const doc = store.read()
318
+ const pendingKey = doc.pendingKeys.find((entry) => entry.key === keyValue)
319
+ // Order matters: an unknown key is 401, a consumed one 409, a stale one 410.
320
+ if (pendingKey === undefined) {
321
+ throw new HttpError(401, 'invalid-key', 'unknown enrollment key; create a new one on the Computers settings page')
322
+ }
323
+ if (typeof pendingKey.usedAt === 'string') {
324
+ throw new HttpError(409, 'key-used', 'this enrollment key was already used; create a new one')
325
+ }
326
+ if (isExpired(pendingKey.expiresAt, nowMs)) {
327
+ throw new HttpError(410, 'key-expired', 'this enrollment key expired; create a new one')
328
+ }
329
+
330
+ const machine = body.machine !== null && typeof body.machine === 'object' && !Array.isArray(body.machine)
331
+ ? body.machine
332
+ : null
333
+ const hostname = optionalString(machine?.hostname, 255)
334
+ if (hostname === null) throw new HttpError(400, 'bad-request', 'machine.hostname is required')
335
+ const osName = optionalString(machine?.os, 64)
336
+ const explicitName = normalizeDisplayName(pendingKey.label)
337
+ ?? normalizeDisplayName(optionalString(machine?.name, 255))
338
+ const displayName = explicitName ?? hostname
339
+ const requestedPlatform = optionalString(machine?.platform, 64)
340
+ const label = requestedPlatform !== null
341
+ ? PLATFORM_LABELS.get(requestedPlatform.toLowerCase()) ?? (osName === null ? null : platformLabel(osName))
342
+ : (osName === null ? null : platformLabel(osName))
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".
346
+ const fields = {
347
+ name: displayName,
348
+ hostname,
349
+ os: osName,
350
+ arch: optionalString(machine?.arch, 64),
351
+ platformLabel: label,
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),
356
+ }
357
+ const definedFields = Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== null))
358
+ const metadata = enrollMetadata(body, machine)
359
+ const nowEnroll = nowIso()
360
+ const token = mintSecret()
361
+ const tokenHash = sha256Hex(token)
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
372
+
373
+ const enrolled = store.mutate((document) => {
374
+ const keyEntry = document.pendingKeys.find((entry) => entry.key === keyValue)
375
+ if (keyEntry === undefined) {
376
+ throw new HttpError(401, 'invalid-key', 'unknown enrollment key; create a new one on the Computers settings page')
377
+ }
378
+ let computer
379
+ if (replaceTarget !== undefined) {
380
+ computer = document.computers.find((entry) => entry.id === replaceTarget.id)
381
+ }
382
+ if (computer !== undefined) {
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
394
+ } else {
395
+ computer = {
396
+ id: newComputerId(),
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,
411
+ tokenHash,
412
+ createdAt: nowEnroll,
413
+ lastSeenAt: nowEnroll,
414
+ // §11.6.4: hidden from the list until the first successful stream.
415
+ activatedAt: null,
416
+ }
417
+ document.computers.push(computer)
418
+ }
419
+ // The key stays unused/pending until the agent's first stream activates it.
420
+ keyEntry.computerId = computer.id
421
+ keyEntry.enrolledAt = nowEnroll
422
+ return { computerId: computer.id, name: computer.name, replaced: replaceTarget !== undefined }
423
+ })
424
+
425
+ if (enrolled.replaced) {
426
+ // The old agent's token no longer authenticates; drop its session.
427
+ hub.closeStream(enrolled.computerId)
428
+ }
429
+ sendJson(res, 200, {
430
+ computerId: enrolled.computerId,
431
+ token,
432
+ name: enrolled.name,
433
+ serverUrl: resolveBaseUrl({ ctx, config, req }),
434
+ })
435
+ })
436
+
437
+ route('/agent/events', async (req, res) => {
438
+ // Authenticate before anything else on machine routes (PROTOCOL §3).
439
+ const computer = requireComputer(req, res)
440
+ if (computer === undefined) return
441
+ if (!requireMethod(req, res, 'GET')) return
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
+ }
489
+ })
490
+
491
+ route('/agent/results', async (req, res) => {
492
+ // Authenticate before anything else on machine routes (PROTOCOL §3).
493
+ const computer = requireComputer(req, res)
494
+ if (computer === undefined) return
495
+ if (!requireMethod(req, res, 'POST')) return
496
+ const body = await readObjectBody(req, RESULT_MAX_BYTES)
497
+ store.touch(computer.id)
498
+ if (!hub.settle(computer.id, body)) {
499
+ // The server already settled/timed out this request (PROTOCOL §3.3);
500
+ // the agent ignores this answer.
501
+ jsonError(res, 404, 'unknown-request', 'this execution is no longer pending (already settled or timed out)')
502
+ return
503
+ }
504
+ sendJson(res, 200, { ok: true })
505
+ })
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
+
533
+ route('/agent/heartbeat', async (req, res) => {
534
+ // Authenticate before anything else on machine routes (PROTOCOL §3).
535
+ const computer = requireComputer(req, res)
536
+ if (computer === undefined) return
537
+ if (!requireMethod(req, res, 'POST')) return
538
+ let body = {}
539
+ try {
540
+ body = await readObjectBody(req, 256 * 1024)
541
+ } catch (error) {
542
+ // An empty heartbeat body is legal; anything else is still a 400.
543
+ if (!(error instanceof HttpError) || error.code !== 'bad-request' || !/empty/.test(error.message)) throw error
544
+ }
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)
548
+ const name = store.mutate((document) => {
549
+ const record = document.computers.find((entry) => entry.id === computer.id)
550
+ if (record === undefined) return computer.name
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
560
+ if (meta !== null) {
561
+ const cpuModel = optionalString(meta.cpuModel, 256)
562
+ const memoryBytes = optionalCount(meta.memoryBytes)
563
+ if (cpuModel !== null) record.cpuModel = cpuModel
564
+ if (memoryBytes !== null) record.memoryBytes = memoryBytes
565
+ }
566
+ return record.name
567
+ })
568
+ sendJson(res, 200, { ok: true, name })
569
+ })
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // Browser routes (host request gate first)
573
+ // ---------------------------------------------------------------------------
574
+
575
+ route('/state', async (req, res) => {
576
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
577
+ if (refuseBrowser(req, res)) return
578
+ if (!requireMethod(req, res, 'GET')) return
579
+ const doc = store.read()
580
+ const pendingKey = currentPendingKey(doc, Date.now())
581
+ sendJson(res, 200, {
582
+ version: 2,
583
+ role,
584
+ serverUrl: resolveBaseUrl({ ctx, config, req }),
585
+ heartbeatMs: config.heartbeatMs,
586
+ // §11.6.4: a computer appears only after its first successful stream.
587
+ computers: visibleComputers().map(publicComputer),
588
+ pendingKey: pendingKey === null
589
+ ? null
590
+ : { key: pendingKey.key, expiresAt: pendingKey.expiresAt ?? null, os: pendingKey.os ?? 'macos' },
591
+ })
592
+ })
593
+
594
+ route('/keys', async (req, res) => {
595
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
596
+ if (refuseBrowser(req, res)) return
597
+ if (!requireMethod(req, res, 'POST')) return
598
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
599
+ const osName = typeof body.os === 'string' ? body.os.trim().toLowerCase() : 'macos'
600
+ if (!['macos', 'windows', 'linux'].includes(osName)) {
601
+ throw new HttpError(400, 'bad-request', 'os must be one of macos, windows, linux')
602
+ }
603
+ let label = null
604
+ if (body.label !== undefined && body.label !== null) {
605
+ label = normalizeDisplayName(body.label)
606
+ if (label === null) throw new HttpError(400, 'bad-request', 'label must be 1-64 visible characters')
607
+ }
608
+ const replace = body.replace === true
609
+ const createdAt = nowIso()
610
+ const expiresAt = new Date(Date.now() + config.keyTtlMinutes * 60_000).toISOString()
611
+ const keyValue = mintSecret()
612
+
613
+ store.mutate((document) => {
614
+ // Only one pending key at a time: creating a new one invalidates the
615
+ // earlier ones (PROTOCOL §4.2).
616
+ for (const entry of document.pendingKeys) {
617
+ if (typeof entry.usedAt !== 'string') entry.usedAt = createdAt
618
+ }
619
+ document.pendingKeys.push({
620
+ key: keyValue,
621
+ createdAt,
622
+ expiresAt,
623
+ os: osName,
624
+ label,
625
+ usedAt: null,
626
+ computerId: null,
627
+ replace,
628
+ })
629
+ })
630
+
631
+ const serverUrl = resolveBaseUrl({ ctx, config, req })
632
+ const installUrl = `${serverUrl}${API_BASE}/join.sh?key=${encodeURIComponent(keyValue)}`
633
+ const windowsUrl = `${serverUrl}${API_BASE}/join.ps1?key=${encodeURIComponent(keyValue)}`
634
+ sendJson(res, 200, {
635
+ key: keyValue,
636
+ os: osName,
637
+ expiresAt,
638
+ serverUrl,
639
+ command: `curl -fsSL '${installUrl}' | sh`,
640
+ windowsCommand: `irm '${windowsUrl}' | iex`,
641
+ installUrl,
642
+ })
643
+ })
644
+
645
+ route('/key-status', async (req, res) => {
646
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
647
+ if (refuseBrowser(req, res)) return
648
+ if (!requireMethod(req, res, 'GET')) return
649
+ const keyValue = urlOf(req).searchParams.get('key')
650
+ const doc = store.read()
651
+ const entry = keyValue === null
652
+ ? undefined
653
+ : doc.pendingKeys.find((candidate) => candidate.key === keyValue)
654
+ let status = 'unknown'
655
+ let computer = null
656
+ if (entry !== undefined) {
657
+ const linked = typeof entry.computerId === 'string'
658
+ ? doc.computers.find((candidate) => candidate.id === entry.computerId)
659
+ : 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) {
665
+ status = 'connected'
666
+ computer = { id: linked.id, name: linked.name, online: hub.isOnline(linked) }
667
+ } else if (typeof entry.usedAt === 'string') {
668
+ status = 'used'
669
+ } else if (isExpired(entry.expiresAt, Date.now())) {
670
+ status = 'expired'
671
+ } else {
672
+ status = 'waiting'
673
+ }
674
+ }
675
+ sendJson(res, 200, { status, computer })
676
+ })
677
+
678
+ route('/delete', async (req, res) => {
679
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
680
+ if (refuseBrowser(req, res)) return
681
+ if (!requireMethod(req, res, 'POST')) return
682
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
683
+ const id = typeof body.id === 'string' ? body.id.trim() : ''
684
+ if (id === '') throw new HttpError(400, 'bad-request', 'id is required')
685
+ const found = store.read().computers.some((computer) => computer.id === id)
686
+ if (!found) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(id)}`)
687
+ hub.closeStream(id)
688
+ if (fsOps !== null) fsOps.failComputer(id, 'the computer was deleted')
689
+ store.mutate((document) => {
690
+ document.computers = document.computers.filter((computer) => computer.id !== id)
691
+ })
692
+ access.store.forgetComputer(id)
693
+ sendJson(res, 200, { ok: true })
694
+ })
695
+
696
+ route('/rename', async (req, res) => {
697
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
698
+ if (refuseBrowser(req, res)) return
699
+ if (!requireMethod(req, res, 'POST')) return
700
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
701
+ const id = typeof body.id === 'string' ? body.id.trim() : ''
702
+ if (id === '') throw new HttpError(400, 'bad-request', 'id is required')
703
+ const name = normalizeDisplayName(body.name)
704
+ if (name === null) throw new HttpError(400, 'bad-request', 'name must be 1-64 visible characters')
705
+ const found = store.read().computers.some((computer) => computer.id === id)
706
+ if (!found) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(id)}`)
707
+ store.mutate((document) => {
708
+ const record = document.computers.find((computer) => computer.id === id)
709
+ if (record !== undefined) record.name = name
710
+ })
711
+ // Optional `config` event (PROTOCOL §3.2): the agent picks up the new name.
712
+ hub.send(id, 'config', { name, execEnabled: config.execEnabled })
713
+ sendJson(res, 200, { ok: true, name })
714
+ })
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
+
858
+ // ---------------------------------------------------------------------------
859
+ // Per-session target selection (task-9)
860
+ // ---------------------------------------------------------------------------
861
+
862
+ /**
863
+ * Protocol shape shared by GET and POST /selection. A stored id whose
864
+ * computer no longer exists is reported as "no selection" so the client can
865
+ * render the unselected state instead of a dangling target.
866
+ */
867
+ const selectionPayload = (sessionId, selectedId) => {
868
+ const computer = selectedId === null
869
+ ? undefined
870
+ : visibleComputers().find((candidate) => candidate.id === selectedId)
871
+ return {
872
+ sessionId,
873
+ computerId: computer === undefined ? null : computer.id,
874
+ computer: computer === undefined
875
+ ? null
876
+ : {
877
+ id: computer.id,
878
+ name: computer.name,
879
+ online: hub.isOnline(computer),
880
+ platformLabel: computer.platformLabel ?? 'Unknown',
881
+ },
882
+ }
883
+ }
884
+
885
+ route('/selection', async (req, res) => {
886
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
887
+ if (refuseBrowser(req, res)) return
888
+ const method = typeof req.method === 'string' ? req.method.toUpperCase() : ''
889
+ if (method === 'GET' || method === 'HEAD') {
890
+ const sessionId = normalizeSessionId(urlOf(req).searchParams.get('sessionId'))
891
+ if (sessionId === null) {
892
+ throw new HttpError(400, 'bad-request', 'sessionId must be a non-empty string (max 256 characters)')
893
+ }
894
+ sendJson(res, 200, selectionPayload(sessionId, selections.get(sessionId)))
895
+ return
896
+ }
897
+ if (method === 'POST') {
898
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
899
+ const sessionId = normalizeSessionId(body.sessionId)
900
+ if (sessionId === null) {
901
+ throw new HttpError(400, 'bad-request', 'sessionId must be a non-empty string (max 256 characters)')
902
+ }
903
+ if (!Object.hasOwn(body, 'computerId')) {
904
+ throw new HttpError(400, 'bad-request', 'computerId is required (a computer id or null)')
905
+ }
906
+ if (body.computerId === null) {
907
+ selections.clear(sessionId)
908
+ sendJson(res, 200, selectionPayload(sessionId, null))
909
+ return
910
+ }
911
+ if (typeof body.computerId !== 'string' || body.computerId.trim() === '') {
912
+ throw new HttpError(400, 'bad-request', 'computerId must be a non-empty string or null')
913
+ }
914
+ const computerId = body.computerId.trim()
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
+ }
923
+ selections.set(sessionId, computerId)
924
+ sendJson(res, 200, selectionPayload(sessionId, computerId))
925
+ return
926
+ }
927
+ methodNotAllowed(res, 'GET / POST')
928
+ })
929
+
930
+ // ---------------------------------------------------------------------------
931
+ // Public routes
932
+ // ---------------------------------------------------------------------------
933
+
934
+ route('/join.sh', async (req, res) => {
935
+ if (!requireMethod(req, res, 'GET')) return
936
+ const entry = installerKey(req, res, 'key')
937
+ if (entry === null) return
938
+ const serverUrl = resolveBaseUrl({ ctx, config, req })
939
+ sendText(res, 200, joinSh(serverUrl, entry.key), 'text/x-shellscript; charset=utf-8')
940
+ })
941
+
942
+ route('/join.ps1', async (req, res) => {
943
+ if (!requireMethod(req, res, 'GET')) return
944
+ const entry = installerKey(req, res, 'key')
945
+ if (entry === null) return
946
+ const serverUrl = resolveBaseUrl({ ctx, config, req })
947
+ sendText(res, 200, joinPs1(serverUrl, entry.key), 'text/plain; charset=utf-8')
948
+ })
949
+
950
+ route('/agent.mjs', async (req, res) => {
951
+ if (!requireMethod(req, res, 'GET')) return
952
+ await serveAgentScript(res, agentUrl)
953
+ })
954
+
955
+ route('/health', async (req, res) => {
956
+ if (!requireMethod(req, res, 'GET')) return
957
+ const computers = visibleComputers()
958
+ sendJson(res, 200, {
959
+ ok: true,
960
+ version,
961
+ computers: computers.length,
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
+ })),
972
+ })
973
+ })
974
+ }
975
+
976
+ /**
977
+ * Register the runtime-mode routes (PROTOCOL §10.5) on the host web server.
978
+ *
979
+ * These are browser-gated exactly like §4 and every answer carries the full
980
+ * §10.3 status object (plus `role`, so a pure `role: 'runtime'` deployment —
981
+ * which registers no `/state` route — can still tell the client what to render).
982
+ *
983
+ * @param {object} options - route wiring.
984
+ * @param {object} options.ctx - Cordis context.
985
+ * @param {object} options.config - resolved plugin config.
986
+ * @param {object} options.runtime - runtime manager (lib/runtime.js).
987
+ * @param {string} options.role - 'hub' | 'runtime' | 'both' (echoed in answers).
988
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
989
+ */
990
+ export function createRuntimeRoutes({ ctx, config, runtime, role = 'both', onWarn = () => {} }) {
991
+ const route = createRouteRegistrar(ctx, onWarn)
992
+ const refuseBrowser = (req, res) => refuseBrowserRequest(ctx, req, res)
993
+ /** Every answer is the status object plus the role the client is talking to. */
994
+ const respond = (res, status) => sendJson(res, 200, { ...status, role })
995
+
996
+ route('/runtime', async (req, res) => {
997
+ if (refuseBrowser(req, res)) return
998
+ if (!requireMethod(req, res, 'GET')) return
999
+ respond(res, runtime.status())
1000
+ })
1001
+
1002
+ route('/runtime/connect', async (req, res) => {
1003
+ if (refuseBrowser(req, res)) return
1004
+ if (!requireMethod(req, res, 'POST')) return
1005
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
1006
+ const serverUrl = typeof body.serverUrl === 'string' ? body.serverUrl.trim() : ''
1007
+ if (serverUrl === '') throw new HttpError(400, 'bad-request', 'serverUrl is required')
1008
+ // The client may send `key` (spec) or `enrollmentKey` (older drafts).
1009
+ const key = typeof body.key === 'string'
1010
+ ? body.key.trim()
1011
+ : typeof body.enrollmentKey === 'string'
1012
+ ? body.enrollmentKey.trim()
1013
+ : ''
1014
+ if (key === '') throw new HttpError(400, 'bad-request', 'key is required')
1015
+ // configure() validates the URL, enrolls against the hub and maps the hub's
1016
+ // 401/409/410 onto the protocol's invalid-key/key-used/key-expired codes;
1017
+ // a transport failure becomes 502 hub-unreachable.
1018
+ respond(res, await runtime.configure({ serverUrl, key }))
1019
+ })
1020
+
1021
+ route('/runtime/enable', async (req, res) => {
1022
+ if (refuseBrowser(req, res)) return
1023
+ if (!requireMethod(req, res, 'POST')) return
1024
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
1025
+ if (typeof body.enabled !== 'boolean') {
1026
+ throw new HttpError(400, 'bad-request', 'enabled must be a boolean')
1027
+ }
1028
+ respond(res, await runtime.enable(body.enabled))
1029
+ })
1030
+
1031
+ route('/runtime/disconnect', async (req, res) => {
1032
+ if (refuseBrowser(req, res)) return
1033
+ if (!requireMethod(req, res, 'POST')) return
1034
+ // Body is optional on disconnect: nothing to read, nothing to validate.
1035
+ // Drain it anyway so keep-alive sockets are not reset between requests.
1036
+ try {
1037
+ req.resume()
1038
+ } catch {
1039
+ // Nothing to drain.
1040
+ }
1041
+ respond(res, await runtime.disconnect())
1042
+ })
1043
+ }
1044
+
1045
+ /**
1046
+ * Register the minimal read-only routes a `role: 'runtime'` deployment still
1047
+ * needs (PROTOCOL §10.1, client contract in §10.6).
1048
+ *
1049
+ * A runtime is not a controller, so the hub API stays unregistered — no
1050
+ * `/agent/*`, no keys/join scripts, no served agent bundle. But the settings
1051
+ * client learns its role from `GET /state`; without it the page can only show
1052
+ * a load-error card. `/state` therefore answers with `role` and an empty
1053
+ * computer list (a runtime owns no registry), and `/health` stays for probes.
1054
+ *
1055
+ * @param {object} options - route wiring.
1056
+ * @param {object} options.ctx - Cordis context.
1057
+ * @param {object} options.config - resolved plugin config.
1058
+ * @param {string} options.role - always 'runtime' here; echoed in /state.
1059
+ * @param {string} options.version - plugin version reported by /health.
1060
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
1061
+ */
1062
+ export function createRuntimeStatusRoutes({ ctx, config, role = 'runtime', version, onWarn = () => {} }) {
1063
+ const route = createRouteRegistrar(ctx, onWarn)
1064
+ const refuseBrowser = (req, res) => refuseBrowserRequest(ctx, req, res)
1065
+
1066
+ route('/state', async (req, res) => {
1067
+ if (refuseBrowser(req, res)) return
1068
+ if (!requireMethod(req, res, 'GET')) return
1069
+ sendJson(res, 200, {
1070
+ version: 2,
1071
+ role,
1072
+ serverUrl: resolveBaseUrl({ ctx, config, req }),
1073
+ heartbeatMs: config.heartbeatMs,
1074
+ // This deployment only follows a hub; it has no computer registry.
1075
+ computers: [],
1076
+ pendingKey: null,
1077
+ })
1078
+ })
1079
+
1080
+ route('/health', async (req, res) => {
1081
+ if (!requireMethod(req, res, 'GET')) return
1082
+ sendJson(res, 200, { ok: true, version, computers: 0, online: 0, agents: [] })
1083
+ })
1084
+ }