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/LICENSE +21 -0
- package/PROTOCOL.md +694 -0
- package/README.md +176 -2
- package/agent/README.md +383 -0
- package/agent/dsh-computers-agent.mjs +2808 -0
- package/client.js +3986 -0
- package/cordis.patch.yml +25 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +241 -0
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +587 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +1084 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +190 -0
- package/lib/tools.js +993 -0
- package/lib/version.js +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
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
|
+
}
|