@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/README.zh.md +195 -0
  4. package/assets/icon.svg +47 -0
  5. package/cordis.patch.yml +41 -0
  6. package/lib/bridge/acp-bridge.js +905 -0
  7. package/lib/bridge/peer-connection.js +730 -0
  8. package/lib/client.js +2842 -0
  9. package/lib/core/acp-model-overlay.js +121 -0
  10. package/lib/core/approval-queue.js +216 -0
  11. package/lib/core/audit-log.js +322 -0
  12. package/lib/core/desktop-notify.js +173 -0
  13. package/lib/core/environment-prompt.js +76 -0
  14. package/lib/core/relay-supervisor.js +278 -0
  15. package/lib/core/review-engine.js +679 -0
  16. package/lib/core/sandbox-diagnosis.js +258 -0
  17. package/lib/core/sandbox-probe.js +310 -0
  18. package/lib/core/self-acp-spec.js +117 -0
  19. package/lib/core/selfcheck-runner.js +150 -0
  20. package/lib/core/settings-store.js +226 -0
  21. package/lib/core/shot-gc.js +334 -0
  22. package/lib/core/task-cwd.js +50 -0
  23. package/lib/core/task-memory.js +163 -0
  24. package/lib/core/task-registry.js +238 -0
  25. package/lib/core/toolcall-cache.js +206 -0
  26. package/lib/core/transfer-queue.js +58 -0
  27. package/lib/core/transfer.js +1144 -0
  28. package/lib/core/worker-arming.js +334 -0
  29. package/lib/index.js +2207 -0
  30. package/lib/provider/out-of-process-helpers.js +76 -0
  31. package/lib/provider/remote-provider.js +261 -0
  32. package/lib/tools/mediator-shim.mjs +180 -0
  33. package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
  34. package/lib/tools/remote-tools.js +680 -0
  35. package/lib/tools/selfcheck-fix-prompt.md +206 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +72 -4
  39. package/slave/acp-profile-approval.patch.yml +66 -0
  40. package/slave/file-transfer.mjs +552 -0
  41. package/slave/integrity-probe.ps1 +99 -0
  42. package/slave/make-launch-spec.mjs +184 -0
  43. package/slave/net-address.mjs +138 -0
  44. package/slave/node_modules/.package-lock.json +26 -0
  45. package/slave/node_modules/ws/LICENSE +20 -0
  46. package/slave/node_modules/ws/README.md +548 -0
  47. package/slave/node_modules/ws/browser.js +8 -0
  48. package/slave/node_modules/ws/index.js +22 -0
  49. package/slave/node_modules/ws/lib/buffer-util.js +131 -0
  50. package/slave/node_modules/ws/lib/constants.js +19 -0
  51. package/slave/node_modules/ws/lib/event-target.js +292 -0
  52. package/slave/node_modules/ws/lib/extension.js +203 -0
  53. package/slave/node_modules/ws/lib/limiter.js +55 -0
  54. package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
  55. package/slave/node_modules/ws/lib/receiver.js +743 -0
  56. package/slave/node_modules/ws/lib/sender.js +607 -0
  57. package/slave/node_modules/ws/lib/stream.js +161 -0
  58. package/slave/node_modules/ws/lib/subprotocol.js +62 -0
  59. package/slave/node_modules/ws/lib/validation.js +152 -0
  60. package/slave/node_modules/ws/lib/websocket-server.js +562 -0
  61. package/slave/node_modules/ws/lib/websocket.js +1421 -0
  62. package/slave/node_modules/ws/package.json +74 -0
  63. package/slave/node_modules/ws/wrapper.mjs +21 -0
  64. package/slave/pairing.mjs +175 -0
  65. package/slave/permission-pairing.mjs +240 -0
  66. package/slave/prune-shots.mjs +170 -0
  67. package/slave/relay.mjs +1351 -0
  68. package/slave/repair-workspace-integrity.ps1 +75 -0
  69. package/slave/screenshot.ps1 +114 -0
  70. package/slave/selfcheck.ps1 +350 -0
  71. package/slave/stage-app.ps1 +116 -0
  72. package/slave/workspace-integrity.mjs +157 -0
@@ -0,0 +1,1351 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * relay.mjs -- SLAVE side of dsh-plugin-remote-use.
4
+ *
5
+ * The relay is the plugin host on the worker. It bridges a WebSocket to a
6
+ * `dsh --profile acp` child process over stdio AND serves the plugin's own RPC
7
+ * methods, so the master can ask questions ACP has no method for:
8
+ *
9
+ * ws client <--text frames (NDJSON)--> relay --+--> dsh-remote/* (handled here)
10
+ * +--> everything else --> dsh --profile acp
11
+ *
12
+ * The `dsh-remote/*` methods are `ping`, `selfcheck`, `pairing`, `screenshot`, `shotcache`, `stat` and
13
+ * `list`. `shotcache` is the odd one out: it is the only method that both reports AND deletes (the
14
+ * worker's own captured PNGs), which is why its rules live in `prune-shots.mjs` with their own
15
+ * tests rather than inline here. `stat`/`list` are the metadata half of file transfer, whose byte
16
+ * half is HTTP (`/file`, `/file/commit`) for the reason /screenshot is: see file-transfer.mjs.
17
+ *
18
+ * WHY THE RELAY OWNS SELF-CHECK: the readiness report has session-level checks
19
+ * (`S7` unlocked, `S8` live input injection) that only mean anything in an
20
+ * INTERACTIVE session -- in the services session `EnumDisplayDevices` returns nothing
21
+ * and `SendInput` reports 0/3 even though the worker is perfectly healthy
22
+ * (DESIGN §5.10.2.1 constraint B). The relay runs as the credential-owning user in
23
+ * that interactive session, so a PowerShell child of the relay lands in the right
24
+ * session and the report is real.
25
+ *
26
+ * Depends only on `ws` + Node builtins, deliberately: the worker should need the
27
+ * smallest possible dependency surface.
28
+ *
29
+ * Usage:
30
+ * node relay.mjs --port 7331 --token <secret> --cmd-file <agent-spec.json>
31
+ * [--selfcheck-script selfcheck.ps1] [--selfcheck-cache-ms 30000]
32
+ * [--screenshot-script screenshot.ps1] [--shot-dir <dir>]
33
+ * [--keep-shots <n>] [--keep-shot-bytes <n>]
34
+ * [--max-transfer-bytes <n>]
35
+ * [--settings-file <settings.json>]
36
+ * [--repair-integrity] [--no-integrity-repair]
37
+ * [--allow-no-auth] [--verbose]
38
+ */
39
+ import { spawn, spawnSync } from 'node:child_process'
40
+ import { createServer } from 'node:http'
41
+ import { createReadStream, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
42
+ import { dirname, join, resolve } from 'node:path'
43
+ import { homedir, networkInterfaces } from 'node:os'
44
+ import { timingSafeEqual } from 'node:crypto'
45
+ import { pipeline } from 'node:stream/promises'
46
+ import { fileURLToPath } from 'node:url'
47
+ import { WebSocketServer } from 'ws'
48
+ // Sibling file, not a package: it ships next to this one so a worker needs no install step.
49
+ import { RELAY_SHOT_PATTERN, pruneShots, shotUsage } from './prune-shots.mjs'
50
+ import { encodePairing } from './pairing.mjs'
51
+ import { bestAddress } from './net-address.mjs'
52
+ import { effectiveSessionMode, sessionModeCheck, sessionModeConsistency } from './permission-pairing.mjs'
53
+ import { repairWorkspaceIntegrity } from './workspace-integrity.mjs'
54
+ import {
55
+ DEFAULT_MAX_TRANSFER_BYTES,
56
+ commitPart,
57
+ deletePart,
58
+ listDir,
59
+ maxTransferBytesOf,
60
+ putChunk,
61
+ rangeOf,
62
+ readJsonBody,
63
+ resolveTransferPath,
64
+ sha256File,
65
+ statTarget,
66
+ } from './file-transfer.mjs'
67
+
68
+ const HERE = dirname(fileURLToPath(import.meta.url))
69
+
70
+ // ---------- args ----------
71
+ const argv = process.argv.slice(2)
72
+ function arg(name, fallback) {
73
+ const i = argv.indexOf(`--${name}`)
74
+ return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : fallback
75
+ }
76
+ const PORT = Number(arg('port', '7331'))
77
+ const HOST = arg('host', '0.0.0.0')
78
+ const TOKEN = arg('token', '')
79
+ const CMD_FILE = arg('cmd-file', '')
80
+ const DSH_CMD = arg('dsh', 'dsh --profile acp')
81
+ const SELFCHECK_SCRIPT = arg('selfcheck-script', join(HERE, 'selfcheck.ps1'))
82
+ const SELFCHECK_CACHE_MS = Number(arg('selfcheck-cache-ms', '30000'))
83
+ const ALLOW_NO_AUTH = argv.includes('--allow-no-auth')
84
+ const VERBOSE = argv.includes('--verbose')
85
+
86
+ /**
87
+ * The settings file the plugin writes on THIS machine.
88
+ *
89
+ * A supervised relay is started by the plugin, so the plugin already knows the mode; a hand-managed
90
+ * relay (a scheduled task with a fixed `--cmd-file`) does not. Reading the same file gives both
91
+ * deployments one place to compare "what is configured" against "what this relay will spawn".
92
+ */
93
+ const CONFIGURED_SETTINGS_FILE = arg('settings-file', join(
94
+ process.env.DSH_HOME ?? join(homedir(), '.dsh'),
95
+ 'storages', 'dsh-plugin-remote-use', 'settings.json',
96
+ ))
97
+
98
+ /**
99
+ * Clear a stale Low mandatory label from the task directory when the session is full access.
100
+ *
101
+ * Measured 2026-10-19 on a real worker: `workspace-write` leaves an inheritable Low label on the
102
+ * workspace, and Windows launches a Low-labelled image at Low integrity even from a High token --
103
+ * so a GUI app copied into the tree cannot write `%LOCALAPPDATA%`. Full access is exactly the mode
104
+ * in which that label is no longer protecting anything. The repair is opt-out so an operator can
105
+ * keep the label deliberately.
106
+ */
107
+ const REPAIR_INTEGRITY = argv.includes('--repair-integrity')
108
+ const NO_INTEGRITY_REPAIR = argv.includes('--no-integrity-repair')
109
+ const INTEGRITY_REPAIR_TIMEOUT_MS = Number(arg('integrity-repair-timeout-ms', '60000'))
110
+
111
+ // ---------- file transfer ----------
112
+ /**
113
+ * The hard write ceiling for `PUT /file`, and the same ceiling for reading one back out.
114
+ *
115
+ * WHY THE WORKER GETS ITS OWN CEILING AT ALL: the master has a budget too, but the master is the side
116
+ * that decided to start the transfer, and a token that leaked -- or a second master that paired with
117
+ * this worker -- would otherwise be able to fill this disk. A ceiling this process enforces is the
118
+ * only one that holds when the other side does not cooperate. Both ends default to 8 GiB so a
119
+ * transfer that never touched a flag does not die at the last chunk.
120
+ *
121
+ * Unusable values keep the default rather than removing the ceiling: see `maxTransferBytesOf` in
122
+ * file-transfer.mjs for why `0` must not mean "no limit" here.
123
+ */
124
+ const MAX_TRANSFER_BYTES = maxTransferBytesOf(Number(arg('max-transfer-bytes', String(DEFAULT_MAX_TRANSFER_BYTES))))
125
+
126
+ /**
127
+ * The task directory: where a RELATIVE transfer path resolves.
128
+ *
129
+ * IT IS PASSED IN, NOT INFERRED FROM `process.cwd()`. Measured 2026-10-16 on the reference worker:
130
+ * a SUPERVISED relay starts with its working directory set to the DSH profile directory -- the plugin
131
+ * spawns it without a `cwd`, so it inherits the desktop process's -- which is NOT the directory the
132
+ * operator armed. The consequence was the worst shape there is: `remotePath: "note.txt"` reported
133
+ * success while the bytes landed in `...\.dsh\profiles\desktop\`, and the master had no way to see it
134
+ * (`--task-dir` now carries the armed value, the same one the pairing string advertises, and the
135
+ * master echoes back what the relay resolved).
136
+ *
137
+ * Capturing it once is still deliberate: a `process.chdir` later would silently re-point every
138
+ * relative transfer at a different directory.
139
+ */
140
+ const TASK_DIR = (() => {
141
+ const given = arg('task-dir', '')
142
+ return given === '' ? process.cwd() : resolve(given)
143
+ })()
144
+ /** Which source `TASK_DIR` came from. Reported at startup, because "where did this come from" is
145
+ * exactly the question the bug above made unanswerable. */
146
+ const TASK_DIR_SOURCE = arg('task-dir', '') === '' ? 'process.cwd()' : '--task-dir'
147
+ /**
148
+ * What a RELATIVE transfer path may resolve against: a directory someone actually chose, or nothing.
149
+ *
150
+ * `null` means no `--task-dir` was given, and the transfer layer then REFUSES a relative path. The old
151
+ * behaviour fell back to `process.cwd()`, which for a supervised relay is the DSH profile directory --
152
+ * so a relative upload landed there and reported success (PITFALLS F40). Absolute paths are unaffected;
153
+ * a hand-started relay that wants relative paths passes `--task-dir .`.
154
+ */
155
+ const DECLARED_TASK_DIR = TASK_DIR_SOURCE === '--task-dir' ? TASK_DIR : null
156
+
157
+ // ---------- screenshot ----------
158
+ const SCREENSHOT_SCRIPT = arg('screenshot-script', join(HERE, 'screenshot.ps1'))
159
+ const SHOT_DIR = arg('shot-dir', join(HERE, 'shots'))
160
+ /** The capture script writes its geometry report here; it sits next to the script. */
161
+ const SCREENSHOT_REPORT = join(dirname(SCREENSHOT_SCRIPT), 'screenshot-report.txt')
162
+ /**
163
+ * How many captures to keep on the worker, newest first.
164
+ *
165
+ * WHY THE WORKER PRUNES ITS OWN: a capture is a full-resolution PNG (measured ~9.5 MB on the
166
+ * reference worker), and this directory had reached 56.9 MB across six captures before anyone
167
+ * looked. The host keeps its own copies too and could try to delete these over HTTP, but a
168
+ * worker that cleans up after itself needs no new protocol surface and keeps working when the
169
+ * host is the thing that went away. A negative value disables pruning.
170
+ */
171
+ const KEEP_SHOTS = Number(arg('keep-shots', '20'))
172
+
173
+ /**
174
+ * Byte ceiling for those captures, enforced oldest-first after the count budget.
175
+ *
176
+ * WHY THIS EXISTS ON TOP OF THE COUNT: a count bounds FILES, not DISK. The same worker that
177
+ * reached 56.9 MB across six captures had a count budget of 20, which reads as "under control"
178
+ * while the directory it describes is 190 MB. 0 means "no byte budget", and that is the default
179
+ * deliberately: a relay upgraded by a master that knows nothing about this flag must keep
180
+ * behaving exactly as it did. A non-finite or negative value is also "no budget" -- never
181
+ * "delete everything" (see prune-shots.mjs).
182
+ */
183
+ const KEEP_SHOT_BYTES = Number(arg('keep-shot-bytes', '0'))
184
+
185
+ /**
186
+ * The address to advertise in a pairing string.
187
+ *
188
+ * `--host 0.0.0.0` (the default) means "listen everywhere", which is not an address a master
189
+ * can dial. So the advertised host is either an explicit `--advertise-host`, or the machine's
190
+ * best guess at its LAN address.
191
+ *
192
+ * WHY "BEST GUESS" RATHER THAN "FIRST IPv4": a Windows machine with Hyper-V, WSL, Docker or
193
+ * VirtualBox installed has several non-internal IPv4 addresses, and they are enumerated in no
194
+ * useful order. Picking the first one produced `172.20.0.1` on the development host -- a
195
+ * Hyper-V switch that no peer can reach -- so the pairing string was well-formed and useless.
196
+ * The heuristic below prefers an ordinary LAN address and skips interfaces whose names give
197
+ * them away as virtual.
198
+ *
199
+ * It is still a heuristic, which is why `--advertise-host` exists and why the choice is logged.
200
+ */
201
+ const ADVERTISE_HOST = arg('advertise-host', '')
202
+
203
+ function advertisableHost() {
204
+ if (ADVERTISE_HOST !== '') return ADVERTISE_HOST
205
+ if (HOST !== '0.0.0.0' && HOST !== '::' && HOST !== '') return HOST
206
+
207
+ // Shared with the plugin's arming path so the two cannot disagree about which address is the
208
+ // real LAN one. See slave/net-address.mjs for why the first non-internal IPv4 is not enough.
209
+ return bestAddress(networkInterfaces()) || '127.0.0.1'
210
+ }
211
+
212
+ /**
213
+ * The pairing string for this relay, or '' when it cannot be produced.
214
+ *
215
+ * Returns '' rather than a plausible-looking string when there is no token: an unauthenticated
216
+ * relay would otherwise emit a pairing string that pairs a master to an open worker.
217
+ */
218
+ function pairingString() {
219
+ if (TOKEN === '') return ''
220
+ return encodePairing({
221
+ url: `ws://${advertisableHost()}:${PORT}`,
222
+ token: TOKEN,
223
+ name: arg('pairing-name', ''),
224
+ })
225
+ }
226
+
227
+ /**
228
+ * Where the pairing string is left for an operator who cannot read this process's console.
229
+ *
230
+ * `--pairing-file ''` disables it. That matters when the relay is spawned by the plugin rather
231
+ * than run by hand: `HERE` is then the plugin's own `slave/` directory, and writing there means
232
+ * an installed plugin modifies its own installation -- which on a `link:` install is the
233
+ * operator's source checkout. Found by running the supervised listener and looking at the repo.
234
+ */
235
+ const PAIRING_FILE = arg('pairing-file', join(HERE, 'pairing.txt'))
236
+
237
+ /**
238
+ * `--print-pairing`: print the pairing string and exit.
239
+ *
240
+ * This is the path a person actually uses -- they are already on the worker to start it, so
241
+ * printing costs nothing and beats going to fetch a file. It deliberately does NOT print the
242
+ * token in any other form: one line of output, one paste at the master.
243
+ */
244
+ if (argv.includes('--print-pairing')) {
245
+ const pairing = pairingString()
246
+ if (pairing === '') {
247
+ process.stderr.write('cannot build a pairing string: this relay has no --token, so a master would pair with an unauthenticated worker\n')
248
+ process.exit(2)
249
+ }
250
+ process.stdout.write(`${pairing}\n`)
251
+ process.exit(0)
252
+ }
253
+
254
+ /** Plugin RPC namespace. Anything here is handled locally, never forwarded to ACP. */
255
+ const PLUGIN_PREFIX = 'dsh-remote/'
256
+
257
+ /**
258
+ * Load the ACP agent launch spec.
259
+ *
260
+ * Preferred: a JSON file `{command, args, env}`. Spawning an executable with an argv
261
+ * ARRAY avoids every Windows quoting hazard: `cmd.exe /c` loses quotes through the
262
+ * PowerShell->node hand-off, and Node refuses to spawn a `.cmd` without a shell
263
+ * (EINVAL). The spec must point at `<install>\DeepSeek Harness.exe` and replicate what
264
+ * `dsh.cmd` does, rather than launching the shim (DESIGN §13.1).
265
+ */
266
+ function loadLaunchSpec() {
267
+ if (CMD_FILE !== '') {
268
+ const raw = readFileSync(CMD_FILE, 'utf8').replace(/^\uFEFF/, '')
269
+ const spec = JSON.parse(raw)
270
+ if (typeof spec?.command !== 'string' || !Array.isArray(spec?.args)) {
271
+ throw new Error(`--cmd-file ${CMD_FILE} must contain {"command":string,"args":string[]}`)
272
+ }
273
+ return { command: spec.command, args: spec.args, env: spec.env ?? {} }
274
+ }
275
+ const parts = (DSH_CMD.match(/"[^"]*"|\S+/g) ?? []).map((s) => s.replace(/^"|"$/g, ''))
276
+ return { command: parts[0], args: parts.slice(1), env: {} }
277
+ }
278
+
279
+ const LAUNCH = loadLaunchSpec()
280
+
281
+ const log = (...a) => console.log(`[relay ${new Date().toISOString().slice(11, 23)}]`, ...a)
282
+ const vlog = (...a) => { if (VERBOSE) log(...a) }
283
+
284
+ /**
285
+ * Read a text file, tolerating the encoding Windows PowerShell actually produces.
286
+ *
287
+ * `Tee-Object -FilePath` and `Out-File` write UTF-16LE with a BOM in PowerShell 5.1, while
288
+ * `Set-Content -Encoding UTF8` writes UTF-8. A pure-UTF-8 reader silently sees NUL bytes
289
+ * interleaved and every prefix match fails -- that is how the screenshot geometry came back
290
+ * as 0x0 even though the report was correct on disk. Detect instead of assuming.
291
+ *
292
+ * @param {string} path
293
+ * @returns {string} decoded text without a BOM, or '' when unreadable
294
+ */
295
+ function readTextFile(path) {
296
+ let buf
297
+ try { buf = readFileSync(path) } catch { return '' }
298
+ if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xFE) {
299
+ return buf.subarray(2).toString('utf16le')
300
+ }
301
+ if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) {
302
+ // UTF-16BE: swap to LE, then decode.
303
+ const swapped = Buffer.from(buf.subarray(2))
304
+ swapped.swap16()
305
+ return swapped.toString('utf16le')
306
+ }
307
+ let start = 0
308
+ if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) start = 3
309
+ return buf.subarray(start).toString('utf8')
310
+ }
311
+
312
+ /** mtime of a path, or 0 when it does not exist / cannot be read. */
313
+ function mtimeOf(path) {
314
+ if (typeof path !== 'string' || path === '') return 0
315
+ try { return statSync(path).mtimeMs } catch { return 0 }
316
+ }
317
+
318
+ /** The plugin's settings file, as text. '' means absent or unreadable. */
319
+ function readSettingsText() {
320
+ return readTextFile(CONFIGURED_SETTINGS_FILE)
321
+ }
322
+
323
+ /**
324
+ * What this relay will ACTUALLY spawn, next to what the machine is configured for.
325
+ *
326
+ * Two different truths, deliberately reported together: `effective` is the spec the child gets,
327
+ * `configured` is the settings-page value. A manual relay can make them disagree for hours, which is
328
+ * exactly the F38 failure -- one side says full access, the other runs workspace-write, and neither
329
+ * surface says so. `mismatch` is the field that makes that visible.
330
+ */
331
+ function sessionModeSnapshot() {
332
+ const consistency = sessionModeConsistency({
333
+ specEnv: LAUNCH.env,
334
+ env: process.env,
335
+ settingsText: readSettingsText(),
336
+ })
337
+ return {
338
+ consistency,
339
+ state: {
340
+ sessionMode: consistency.effective.mode,
341
+ sessionModeFrom: consistency.effective.from,
342
+ effectiveSessionMode: consistency.effectiveMode,
343
+ configuredSessionMode: consistency.configured,
344
+ sessionModeMismatch: consistency.mismatch,
345
+ sessionModeSettingsError: consistency.settingsError,
346
+ settingsFile: CONFIGURED_SETTINGS_FILE,
347
+ settingsMtime: mtimeOf(CONFIGURED_SETTINGS_FILE),
348
+ launchSpec: {
349
+ path: CMD_FILE,
350
+ mtime: mtimeOf(CMD_FILE),
351
+ mode: typeof LAUNCH.env?.DSH_PERMISSION_MODE === 'string' ? LAUNCH.env.DSH_PERMISSION_MODE : '',
352
+ },
353
+ integrityRepair: INTEGRITY_REPAIR,
354
+ },
355
+ }
356
+ }
357
+
358
+ /** The subset of the mode state that `/healthz` may expose without authentication. */
359
+ function healthModeState() {
360
+ const { state } = sessionModeSnapshot()
361
+ return {
362
+ sessionMode: state.sessionMode,
363
+ sessionModeFrom: state.sessionModeFrom,
364
+ effectiveSessionMode: state.effectiveSessionMode,
365
+ configuredSessionMode: state.configuredSessionMode,
366
+ sessionModeMismatch: state.sessionModeMismatch,
367
+ integrityRepair: {
368
+ applied: state.integrityRepair.applied,
369
+ skipped: state.integrityRepair.skipped,
370
+ reason: state.integrityRepair.reason,
371
+ },
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Add the configured/effective check to a self-check report and recompute its counts.
377
+ *
378
+ * The report came from PowerShell, which cannot see the launch spec or the settings file. The relay
379
+ * can, so the check is appended here rather than in the script. Recomputing the verdict is not
380
+ * optional: a `warn` row with `verdict: ready` is the "report lies" shape this project keeps fixing.
381
+ */
382
+ function withSessionModeCheck(report, snapshot) {
383
+ const base = { ...report, ...snapshot.state }
384
+ const check = sessionModeCheck(snapshot.consistency, { specPath: CMD_FILE })
385
+ if (check === null) return base
386
+ const checks = [...(Array.isArray(report.checks) ? report.checks : []), check]
387
+ const blockingFailures = checks.filter((c) => c.severity === 'blocking' && c.status === 'fail').length
388
+ const warnings = checks.filter((c) => (c.severity === 'blocking' || c.severity === 'warning') && c.status === 'warn').length
389
+ const unchecked = checks.filter((c) => c.status === 'unchecked').length
390
+ const verdict = blockingFailures > 0
391
+ ? 'blocked'
392
+ : unchecked > 0
393
+ ? 'partial'
394
+ : warnings > 0
395
+ ? 'warn'
396
+ : 'ready'
397
+ return { ...base, checks, blockingFailures, warnings, unchecked, verdict }
398
+ }
399
+
400
+ /**
401
+ * Repair the workspace label BEFORE the port opens.
402
+ *
403
+ * Doing it at module load rather than on the first connection is deliberate: a GUI task can start
404
+ * immediately after arming, and a repair that races it would leave the first launch Low. A large
405
+ * workspace makes this pass take longer; the timeout and the result are reported either way.
406
+ */
407
+ const EARLY_MODE = effectiveSessionMode({ specEnv: LAUNCH.env, env: process.env })
408
+ const EFFECTIVE_MODE = EARLY_MODE.mode === '' ? 'workspace-write' : EARLY_MODE.mode
409
+ if (EFFECTIVE_MODE === 'danger-full-access' && DECLARED_TASK_DIR !== null) {
410
+ // Say it BEFORE a potentially slow recursive pass, so a relay that takes a few extra seconds to
411
+ // listen is explainable from its own output.
412
+ log(`workspace integrity: checking ${TASK_DIR} for a stale Low label ...`)
413
+ }
414
+ const INTEGRITY_REPAIR = NO_INTEGRITY_REPAIR
415
+ ? { applied: false, skipped: true, reason: 'disabled with --no-integrity-repair', error: '', dir: '', durationMs: 0, timedOut: false }
416
+ : repairWorkspaceIntegrity({
417
+ mode: EFFECTIVE_MODE,
418
+ dir: TASK_DIR,
419
+ declared: DECLARED_TASK_DIR !== null,
420
+ force: REPAIR_INTEGRITY,
421
+ timeoutMs: Number.isFinite(INTEGRITY_REPAIR_TIMEOUT_MS) ? INTEGRITY_REPAIR_TIMEOUT_MS : 60_000,
422
+ })
423
+ if (INTEGRITY_REPAIR.applied) {
424
+ log(`workspace integrity: ${INTEGRITY_REPAIR.reason} (${INTEGRITY_REPAIR.dir}, ${INTEGRITY_REPAIR.durationMs} ms${INTEGRITY_REPAIR.timedOut ? ', TIMED OUT' : ''})`)
425
+ } else if (INTEGRITY_REPAIR.error !== '') {
426
+ log(`WARNING: workspace integrity repair failed for ${INTEGRITY_REPAIR.dir}: ${INTEGRITY_REPAIR.error}`)
427
+ } else if (EFFECTIVE_MODE === 'danger-full-access') {
428
+ log(`workspace integrity: not repaired -- ${INTEGRITY_REPAIR.reason}`)
429
+ }
430
+
431
+ // Say it at startup, not only when someone opens the report: a hand-managed relay can otherwise run
432
+ // at the wrong level indefinitely while the settings page shows the intended one.
433
+ const STARTUP_MODE = sessionModeSnapshot()
434
+ if (STARTUP_MODE.consistency.mismatch) {
435
+ log(`WARNING: configured session mode ${STARTUP_MODE.consistency.configured} does not match the `
436
+ + `launch spec's effective ${STARTUP_MODE.consistency.effectiveMode}; this relay will spawn the SPEC's mode. `
437
+ + `Re-generate the spec from the saved setting and restart the relay.`)
438
+ } else if (STARTUP_MODE.consistency.settingsError !== '') {
439
+ log(`WARNING: ${STARTUP_MODE.consistency.settingsError} (${CONFIGURED_SETTINGS_FILE})`)
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // plugin RPC: dsh-remote/selfcheck
444
+ // ---------------------------------------------------------------------------
445
+
446
+ /** @type {{ at: number, report: object } | undefined} */
447
+ let selfcheckCache
448
+
449
+ /**
450
+ * Run the readiness script and return its JSON report.
451
+ *
452
+ * The report is cached because `S8` genuinely moves the mouse: re-running it before
453
+ * every delegation would fight the task being dispatched. A caller passes
454
+ * `maxAgeMs: 0` to force a fresh run (the settings page does this).
455
+ *
456
+ * @param {number} maxAgeMs
457
+ * @returns {Promise<object>}
458
+ */
459
+ async function runSelfCheck(maxAgeMs) {
460
+ const now = Date.now()
461
+ if (selfcheckCache !== undefined && maxAgeMs > 0 && now - selfcheckCache.at <= maxAgeMs) {
462
+ return { ...selfcheckCache.report, fresh: false, cachedAgeMs: now - selfcheckCache.at }
463
+ }
464
+
465
+ let exists = false
466
+ try { exists = statSync(SELFCHECK_SCRIPT).isFile() } catch { exists = false }
467
+ const degraded = (check) => ({
468
+ schemaVersion: 1,
469
+ generatedAt: new Date().toISOString(),
470
+ verdict: 'blocked',
471
+ sessionSegment: 'unavailable',
472
+ blockingFailures: 1,
473
+ warnings: 0,
474
+ unchecked: 0,
475
+ checks: [check],
476
+ fresh: true,
477
+ })
478
+ if (!exists) {
479
+ return degraded({
480
+ id: 'S0', name: 'self-check script present', severity: 'blocking', status: 'fail',
481
+ expected: SELFCHECK_SCRIPT, actual: 'missing',
482
+ fix: 'deploy selfcheck.ps1 next to relay.mjs, or pass --selfcheck-script',
483
+ })
484
+ }
485
+
486
+ const run = await new Promise((resolve) => {
487
+ // The task directory is where remote work actually runs, so it is the tree S20 must measure.
488
+ // Only under `danger-full-access`: a `workspace-write` workspace is SUPPOSED to be Low, and
489
+ // warning about it there would teach the operator to ignore the warning. Only an EXPLICIT
490
+ // `--task-dir` is passed: an inherited cwd can be System32 (the launcher comment above explains
491
+ // why), and probing an arbitrary inherited directory is worse than reporting no workspace at all.
492
+ const selfcheckArgs = [...PS_BASE_ARGS, '-File', SELFCHECK_SCRIPT]
493
+ if (DECLARED_TASK_DIR !== null && EFFECTIVE_MODE === 'danger-full-access') {
494
+ selfcheckArgs.push('-Workspace', DECLARED_TASK_DIR)
495
+ }
496
+ const ps = spawn(PS_EXE, selfcheckArgs,
497
+ { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
498
+ let out = ''
499
+ let err = ''
500
+ ps.stdout.on('data', (d) => { out += d.toString('utf8') })
501
+ // stderr is kept because "the script produced no JSON" is usually answered by it (a parse
502
+ // error, an execution-policy refusal, a missing assembly). Discarding it is what made this
503
+ // failure read as a JSON error instead of "PowerShell could not start".
504
+ ps.stderr.on('data', (d) => { err += d.toString('utf8') })
505
+ ps.on('error', (e) => resolve({ error: e.message, out, err }))
506
+ ps.on('exit', (code) => resolve({ code, out, err }))
507
+ setTimeout(() => { try { ps.kill() } catch { /* already gone */ } }, 60_000)
508
+ })
509
+
510
+ // A SPAWN FAILURE means the script never ran at all. Say THAT, with the interpreter it tried:
511
+ // reporting it as a JSON parse error is how a machine without PowerShell 7 looked like a worker
512
+ // producing a malformed report (measured 2026-11-05 on a fresh worker).
513
+ if (run.error !== undefined) {
514
+ log(`self-check spawn error: ${run.error}`)
515
+ return degraded({
516
+ id: 'S0', name: 'self-check script ran', severity: 'blocking', status: 'fail',
517
+ expected: PS_EXE, actual: String(run.error).slice(0, 200),
518
+ fix: 'this machine needs PowerShell 7 (install pwsh) or a runnable powershell.exe; the self-check cannot produce a report without one',
519
+ })
520
+ }
521
+
522
+ // The script writes selfcheck.json next to itself; prefer that over stdout, which
523
+ // also carries the human-readable twin.
524
+ const jsonPath = join(dirname(SELFCHECK_SCRIPT), 'selfcheck.json')
525
+ let parsed
526
+ try {
527
+ parsed = JSON.parse(readTextFile(jsonPath).replace(/^\uFEFF/, ''))
528
+ } catch (e) {
529
+ // The exit code and the script's own stderr answer the question this message raises; without
530
+ // them "Unexpected end of JSON input" is the only clue an operator gets.
531
+ const stderrTail = String(err ?? '').trim().split(/\r?\n/u).slice(-2).join(' / ')
532
+ const detail = [String(e.message), run.code === undefined ? '' : `exit ${run.code}`, stderrTail]
533
+ .filter((part) => part !== '').join(' | ')
534
+ return degraded({
535
+ id: 'S0', name: 'self-check produced a report', severity: 'blocking', status: 'fail',
536
+ expected: jsonPath, actual: detail.slice(0, 300),
537
+ fix: `run ${SELFCHECK_SCRIPT} by hand to see the failure`,
538
+ })
539
+ }
540
+
541
+ selfcheckCache = { at: Date.now(), report: parsed }
542
+ return { ...parsed, fresh: true, cachedAgeMs: 0 }
543
+ }
544
+
545
+ /**
546
+ * Handle a plugin RPC locally.
547
+ * @returns {Promise<object|null>} a JSON-RPC response, or null to forward to ACP
548
+ */
549
+ async function handleLocalRpc(msg) {
550
+ const id = msg.id
551
+ const reply = (result) => ({ jsonrpc: '2.0', id, result })
552
+ const fail = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } })
553
+
554
+ switch (msg.method) {
555
+ case `${PLUGIN_PREFIX}ping`:
556
+ // The heartbeat reply is deliberately I/O-free. The master pings every 15 s, and a
557
+ // directory scan on every one of those is pure waste -- the shot cache changes only when
558
+ // someone captures or cleans up. `cache: true` is the master saying it is about to draw
559
+ // or resize the cache, which is the only moment the scan buys anything.
560
+ return reply({
561
+ pong: true,
562
+ at: new Date().toISOString(),
563
+ pid: process.pid,
564
+ ...(msg.params?.cache === true
565
+ ? {
566
+ shotCache: {
567
+ ...shotUsage(SHOT_DIR, RELAY_SHOT_PATTERN),
568
+ keepShots: KEEP_SHOTS,
569
+ maxBytes: KEEP_SHOT_BYTES,
570
+ },
571
+ }
572
+ : {}),
573
+ })
574
+
575
+ case `${PLUGIN_PREFIX}selfcheck`: {
576
+ const maxAgeMs = Number.isFinite(msg.params?.maxAgeMs) ? Number(msg.params.maxAgeMs) : SELFCHECK_CACHE_MS
577
+ try {
578
+ // The session mode rides along with the report the master ALREADY renders. It is the answer
579
+ // to "which permission level is this worker actually at", which until now could only be
580
+ // guessed from a spec file that this process may never have read (see effectiveSessionMode).
581
+ //
582
+ // The settings file is read on EVERY call, not cached at startup: the settings page can be
583
+ // saved while this relay keeps running, and a mismatch that only appears after a restart is
584
+ // exactly the "both are true for hours" failure this check exists to end.
585
+ const snapshot = sessionModeSnapshot()
586
+ return reply(withSessionModeCheck(await runSelfCheck(maxAgeMs), snapshot))
587
+ } catch (e) {
588
+ return fail(-32603, `self-check failed: ${e instanceof Error ? e.message : String(e)}`)
589
+ }
590
+ }
591
+
592
+ case `${PLUGIN_PREFIX}pairing`: {
593
+ // The master asking for a pairing string, e.g. to re-pair after a settings reset. It is
594
+ // already authenticated -- this RPC arrives over the token-checked WebSocket -- so
595
+ // returning the credential here is not a new exposure.
596
+ const pairing = pairingString()
597
+ if (pairing === '') return fail(-32603, 'this relay has no token, so it cannot produce a pairing string')
598
+ return reply({ ok: true, pairing })
599
+ }
600
+
601
+ case `${PLUGIN_PREFIX}screenshot`: {
602
+ try {
603
+ const shot = await captureScreenshot()
604
+ // Metadata only. The bytes come back over GET /screenshot: base64 in a JSON-RPC
605
+ // result would inflate a multi-megabyte PNG by a third and would have to travel
606
+ // through the ACP message pump, which exists for small control frames.
607
+ return reply({
608
+ ok: true,
609
+ width: shot.width,
610
+ height: shot.height,
611
+ bytes: shot.bytes,
612
+ displays: shot.displays,
613
+ capturedAt: shot.capturedAt,
614
+ path: shot.path,
615
+ downloadPath: '/screenshot',
616
+ })
617
+ } catch (e) {
618
+ return fail(-32603, `screenshot failed: ${e instanceof Error ? e.message : String(e)}`)
619
+ }
620
+ }
621
+
622
+ case `${PLUGIN_PREFIX}shotcache`: {
623
+ // The master's view of -- and lever on -- this worker's screenshot cache. The numbers are a
624
+ // handful of integers and travel over the JSON-RPC channel the plugin already owns, so this
625
+ // needs no new endpoint and no second thing to keep authenticated.
626
+ try {
627
+ const params = msg.params ?? {}
628
+ // The ceiling travels WITH the call rather than being stored: the master is the side that
629
+ // knows the budget it wants now, and a relay that remembered the last one would keep
630
+ // enforcing a value an operator has already changed. Anything unusable falls back to the
631
+ // command line, so this also works against a worker started with the flag.
632
+ const maxBytes = Number.isFinite(params.maxBytes) ? Number(params.maxBytes) : KEEP_SHOT_BYTES
633
+ let usage
634
+ let pruned
635
+ if (params.clear === true) {
636
+ // "Enforce the budget" and "clear the cache" are two different operations and only the
637
+ // first is automatic. `maxBytes: 0` cannot express the second -- it means "no budget" --
638
+ // so clearing gets its own flag, and it wins if a caller sends both.
639
+ pruned = clearShots()
640
+ } else if (params.prune === true) {
641
+ pruned = pruneOldShots(maxBytes)
642
+ } else {
643
+ // Report only: nothing was asked for, so nothing was removed and `kept` describes what
644
+ // is on disk (a 0 there would read as "the cache is empty", the opposite of the case an
645
+ // operator opens this screen for). `overBudgetBytes` still tells the truth, which is
646
+ // what shows the master that a `prune: true` would reclaim something.
647
+ usage = shotUsage(SHOT_DIR, RELAY_SHOT_PATTERN)
648
+ pruned = {
649
+ removed: 0,
650
+ kept: usage.files,
651
+ freedBytes: 0,
652
+ overBudgetBytes: maxBytes > 0 ? Math.max(0, usage.bytes - maxBytes) : 0,
653
+ }
654
+ }
655
+ // Measured AFTER any deletion (or reused from the report-only branch above), so what comes
656
+ // back describes the state the master will actually find.
657
+ usage ??= shotUsage(SHOT_DIR, RELAY_SHOT_PATTERN)
658
+ return reply({
659
+ ok: true,
660
+ dir: SHOT_DIR,
661
+ files: usage.files,
662
+ bytes: usage.bytes,
663
+ newestAt: usage.newestAt,
664
+ oldestAt: usage.oldestAt,
665
+ keepShots: KEEP_SHOTS,
666
+ maxBytes,
667
+ pruned,
668
+ })
669
+ } catch (e) {
670
+ return fail(-32603, `shot cache failed: ${e instanceof Error ? e.message : String(e)}`)
671
+ }
672
+ }
673
+
674
+ case `${PLUGIN_PREFIX}stat`: {
675
+ // The pre-flight question every transfer starts with: is it there, how big is it, and when was
676
+ // it last written. It travels over the RPC channel rather than HTTP because the answer is four
677
+ // fields, and a HEAD would have to be asked on a path this side parsed anyway.
678
+ const resolved = resolveTransferPath(msg.params?.path, DECLARED_TASK_DIR)
679
+ if (!resolved.ok) return fail(-32602, resolved.error)
680
+ try {
681
+ // `hash: true` is opt-in: a stat is what the master asks before deciding whether to transfer
682
+ // anything, and hashing an 8 GiB file to answer that would cost more than the transfer.
683
+ const info = await statTarget(resolved.path, { hash: msg.params?.hash === true })
684
+ return reply({
685
+ ok: true,
686
+ path: resolved.path,
687
+ exists: info.exists,
688
+ isDir: info.isDir,
689
+ size: info.size,
690
+ mtime: info.mtime,
691
+ // Absent rather than null when it was not asked for: "not computed" and "computed and
692
+ // there is no such thing" are different answers, and only one of them is about the file.
693
+ ...(info.sha256 !== undefined ? { sha256: info.sha256 } : {}),
694
+ })
695
+ } catch (e) {
696
+ return fail(-32603, `stat failed: ${e instanceof Error ? e.message : String(e)}`)
697
+ }
698
+ }
699
+
700
+ case `${PLUGIN_PREFIX}list`: {
701
+ const resolved = resolveTransferPath(msg.params?.path, DECLARED_TASK_DIR)
702
+ if (!resolved.ok) return fail(-32602, resolved.error)
703
+ try {
704
+ const listed = await listDir(resolved.path, { limit: msg.params?.limit })
705
+ // A path that is not a directory is an error, not an empty listing: "there is nothing there"
706
+ // and "that is a file" lead an operator to opposite conclusions.
707
+ if (!listed.ok) return fail(-32602, `${listed.error} (path=${resolved.path})`)
708
+ return reply({ ok: true, path: resolved.path, entries: listed.entries, truncated: listed.truncated })
709
+ } catch (e) {
710
+ return fail(-32603, `list failed: ${e instanceof Error ? e.message : String(e)}`)
711
+ }
712
+ }
713
+
714
+ default:
715
+ // Unknown dsh-remote/* method: answer explicitly, never silently forward. A
716
+ // silent forward would make a future protocol addition look like a no-op.
717
+ return fail(-32601, `unknown plugin method "${msg.method}"`)
718
+ }
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // screenshot capture
723
+ // ---------------------------------------------------------------------------
724
+
725
+ let shotSeq = 0
726
+ /** Single-flight: two captures must not race on the same report file or output path. */
727
+ let capturing
728
+
729
+ /**
730
+ * Capture the worker desktop with the sidecar script.
731
+ *
732
+ * MUST run in the interactive session -- in session 0 there is no desktop and the capture
733
+ * is empty (the same false negative the display checks have). The relay runs as the
734
+ * logged-in user, so a child process inherits the right session; that is precisely why
735
+ * the relay owns this rather than an SSH-invoked helper.
736
+ *
737
+ * @returns {Promise<{path:string,width:number,height:number,bytes:number,displays:string[],capturedAt:string}>}
738
+ */
739
+ /**
740
+ * The PowerShell to run the helper scripts with, and the arguments that must precede `-File`.
741
+ *
742
+ * WHY `pwsh` IS PREFERRED, measured on this machine and not assumed:
743
+ *
744
+ * `screenshot.ps1` and `selfcheck.ps1` are launched by this relay, and on Windows they used to be
745
+ * launched with `powershell.exe` (5.1). That works when the scripts sit on a plain local volume.
746
+ * It does NOT work when they sit on the F: ReFS volume this project is developed on:
747
+ *
748
+ * powershell.exe -NoProfile -ExecutionPolicy Bypass -File F:\...\screenshot.ps1
749
+ * -> "The file ... is not digitally signed. You cannot run this script on the current system."
750
+ *
751
+ * The SAME file, the SAME arguments, from C:\Temp, runs. And `-ExecutionPolicy Bypass` does not
752
+ * help -- a WDAC/AppLocker-style rule blocks PowerShell 5.1 by PATH, independently of the
753
+ * execution policy. `pwsh` (7) is not subject to it and runs the identical script.
754
+ *
755
+ * The consequence was worse for the self-check than for the screenshot. The screenshot returned a
756
+ * loud 500; the self-check returned a FALLBACK REPORT (`{ verdict: 'blocked', checks: [{ id: 'S0',
757
+ * actual: 'Unexpected end of JSON input' }] }`), which is report-shaped enough that a caller
758
+ * checking `report.verdict !== undefined` saw a successful self-check. That is how it went unnoticed
759
+ * for eleven rounds.
760
+ *
761
+ * So: prefer `pwsh` when it exists, fall back to `powershell.exe` (which is what a worker without
762
+ * PowerShell 7 installed has).
763
+ */
764
+ const PS_EXE = (() => {
765
+ if (process.platform !== 'win32') return 'pwsh'
766
+ const pf = process.env.ProgramFiles ?? 'C:\\Program Files'
767
+ const candidates = [
768
+ join(pf, 'PowerShell', '7', 'pwsh.exe'),
769
+ join(process.env.LOCALAPPDATA ?? '', 'Microsoft', 'WindowsApps', 'pwsh.exe'),
770
+ ]
771
+ for (const c of candidates) {
772
+ try {
773
+ const info = statSync(c)
774
+ // A Microsoft Store EXECUTION ALIAS is a 0-byte reparse point. Spawning it opens the Store
775
+ // instead of running PowerShell, which would look like "the script produced nothing" again.
776
+ // Only a real image with bytes counts as an installed PowerShell 7.
777
+ if (info.isFile() && info.size > 0) return c
778
+ } catch { /* not there */ }
779
+ }
780
+ // FALLBACK: Windows always ships Windows PowerShell 5.1. Returning 'pwsh' here made a worker
781
+ // WITHOUT PowerShell 7 spawn nothing at all -- the self-check wrote no JSON and S0 read
782
+ // "Unexpected end of JSON input" (measured 2026-11-05 on a fresh worker). 5.1 is blocked by a
783
+ // PATH-based rule on some volumes (see the note above); when that happens the spawn error is now
784
+ // reported as its own S0 with the error text, never as a JSON parse failure.
785
+ return 'powershell.exe'
786
+ })()
787
+
788
+ /** `-ExecutionPolicy Bypass` is kept for the 5.1 fallback, where it does help. */
789
+ const PS_BASE_ARGS = process.platform === 'win32'
790
+ ? ['-NoProfile', '-ExecutionPolicy', 'Bypass']
791
+ : ['-NoProfile']
792
+
793
+ async function captureScreenshot() {
794
+ if (capturing !== undefined) return await capturing
795
+ capturing = (async () => {
796
+ try { statSync(SCREENSHOT_SCRIPT) } catch {
797
+ throw new Error(`screenshot script not found at ${SCREENSHOT_SCRIPT}; pass --screenshot-script`)
798
+ }
799
+ try { mkdirSync(SHOT_DIR, { recursive: true }) } catch { /* exists */ }
800
+
801
+ // Prune BEFORE capturing, not after. `GET /screenshot?fresh=1` captures and then reads the
802
+ // file straight away, so a prune running afterwards could delete the very image the host
803
+ // is about to download whenever the budget is 0. Trimming first makes that impossible: the
804
+ // new file does not exist yet. The directory therefore holds up to KEEP_SHOTS + 1 files
805
+ // between a capture and the next one, which is the price of never racing the download.
806
+ const pruned = pruneOldShots()
807
+
808
+ const out = join(SHOT_DIR, `shot-${Date.now()}-${++shotSeq}.png`)
809
+
810
+ const run = await new Promise((resolve) => {
811
+ const isWin = process.platform === 'win32'
812
+ // `-WindowStyle Hidden` is not cosmetic: a visible console is captured into the
813
+ // frame AND can take focus, corrupting exactly the GUI measurements a screenshot
814
+ // exists to support. An earlier capture of this worker had the script's own
815
+ // PowerShell window in it.
816
+ // `-WindowStyle Hidden` stays: a visible console is captured into the frame AND can take
817
+ // focus, corrupting the very measurements a screenshot exists to support.
818
+ const args = process.platform === 'win32'
819
+ ? [...PS_BASE_ARGS, '-WindowStyle', 'Hidden', '-File', SCREENSHOT_SCRIPT, '-Out', out]
820
+ : [...PS_BASE_ARGS, '-File', SCREENSHOT_SCRIPT, '-Out', out]
821
+ const ps = spawn(PS_EXE, args, {
822
+ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
823
+ })
824
+ let text = ''
825
+ ps.stdout.on('data', (d) => { text += d.toString('utf8') })
826
+ ps.stderr.on('data', (d) => { text += d.toString('utf8') })
827
+ ps.on('error', (e) => resolve({ error: e.message, text }))
828
+ ps.on('exit', (code) => resolve({ code, text }))
829
+ setTimeout(() => { try { ps.kill() } catch { /* gone */ } }, 60_000)
830
+ })
831
+
832
+ if (run.error !== undefined) throw new Error(`could not run capture: ${run.error}`)
833
+ let stat
834
+ try { stat = statSync(out) } catch { stat = undefined }
835
+ if (stat === undefined || stat.size === 0) {
836
+ throw new Error(`capture produced no image (exit ${run.code}); output: ${String(run.text).trim().slice(-300)}`)
837
+ }
838
+
839
+ // Geometry, because a screenshot is only interpretable next to it. readTextFile rather
840
+ // than a plain utf8 read: see the note on that helper.
841
+ const report = readTextFile(SCREENSHOT_REPORT)
842
+ const lines = report.split(/\r?\n/)
843
+ const displays = lines.filter((l) => l.startsWith('screen '))
844
+ const m = /w=(\d+)\s+h=(\d+)/.exec(lines.find((l) => l.startsWith('virtualscreen ')) ?? '')
845
+
846
+ return {
847
+ path: out,
848
+ width: m ? Number(m[1]) : 0,
849
+ height: m ? Number(m[2]) : 0,
850
+ bytes: stat.size,
851
+ displays,
852
+ capturedAt: new Date().toISOString(),
853
+ pruned,
854
+ }
855
+ })().finally(() => { capturing = undefined })
856
+ return await capturing
857
+ }
858
+
859
+ /**
860
+ * Delete captures this relay wrote until they fit the count and byte budgets.
861
+ *
862
+ * The rules live in `prune-shots.mjs`, which exists as its own file so they can be tested:
863
+ * this relay is a long-lived script with no harness of its own. The `--shot-dir` is
864
+ * configurable, so being explicit about which filenames may be deleted is what keeps a
865
+ * mistyped path from becoming data loss.
866
+ *
867
+ * Both budgets are applied in one call, count first: the count is what the operator asked to
868
+ * keep, and the byte ceiling trims further into it only when it has to. The newest capture is
869
+ * never removed, even when it alone is over the ceiling, because the master is about to download
870
+ * exactly that file (see `captureScreenshot`).
871
+ *
872
+ * @param {number} [maxBytes] byte ceiling; <= 0 means only the count budget applies
873
+ * @returns {{removed: number, kept: number, freedBytes: number, overBudgetBytes: number}}
874
+ */
875
+ function pruneOldShots(maxBytes = KEEP_SHOT_BYTES) {
876
+ return pruneShots(SHOT_DIR, KEEP_SHOTS, RELAY_SHOT_PATTERN, { maxBytes })
877
+ }
878
+
879
+ /**
880
+ * Delete EVERY capture this relay wrote.
881
+ *
882
+ * This is the master's explicit "clear the cache" action: unlike `pruneOldShots` it keeps
883
+ * nothing, including the newest file. `keep: 0` is what the count pass calls "keep none", so the
884
+ * deletion goes through the one path that is already tested for pattern safety and for never
885
+ * throwing -- a second delete loop for the same job is how the two drift apart.
886
+ *
887
+ * @returns {{removed: number, kept: number, freedBytes: number, overBudgetBytes: number}}
888
+ */
889
+ function clearShots() {
890
+ return pruneShots(SHOT_DIR, 0, RELAY_SHOT_PATTERN, {})
891
+ }
892
+
893
+ // ---------------------------------------------------------------------------
894
+ // auth
895
+ // ---------------------------------------------------------------------------
896
+ function tokenOk(provided) {
897
+ if (TOKEN === '') return true
898
+ if (typeof provided !== 'string' || provided === '') return false
899
+ const a = Buffer.from(provided)
900
+ const b = Buffer.from(TOKEN)
901
+ if (a.length !== b.length) return false
902
+ return timingSafeEqual(a, b)
903
+ }
904
+
905
+ function bearerOf(req) {
906
+ const h = req.headers['authorization']
907
+ if (typeof h === 'string' && h.toLowerCase().startsWith('bearer ')) return h.slice(7).trim()
908
+ try { return new URL(req.url, 'http://x').searchParams.get('token') ?? '' } catch { return '' }
909
+ }
910
+
911
+ if (TOKEN === '' && !ALLOW_NO_AUTH) {
912
+ console.error('[relay] refusing to start: no --token given (pass --allow-no-auth to override)')
913
+ process.exit(2)
914
+ }
915
+
916
+ // ---------------------------------------------------------------------------
917
+ // http + ws
918
+ // ---------------------------------------------------------------------------
919
+
920
+ /** Last capture, reused by /screenshot so an RPC followed by a download is one capture. */
921
+ let lastShot
922
+
923
+ // ---------------------------------------------------------------------------
924
+ // file transfer, over HTTP
925
+ //
926
+ // WHY THESE LIVE NEXT TO /screenshot AND NOT ON THE RPC CHANNEL: the WebSocket carries the ACP pump
927
+ // and the plugin's own control frames on one socket, so a file body on it would queue every GUI
928
+ // control frame behind the bytes -- the same reason the screenshot moved here. What is in this
929
+ // section is only the HTTP shape; every rule it obeys (ranges, `.part` staging, hashing, the commit
930
+ // check) lives in file-transfer.mjs, where a test can reach it.
931
+ // ---------------------------------------------------------------------------
932
+
933
+ /** Write a JSON response. `content-length` is set explicitly so a truncated reply cannot be mistaken
934
+ * for a complete one by a client that is reading a transfer protocol. */
935
+ function jsonOut(res, status, body) {
936
+ const text = JSON.stringify(body)
937
+ res.writeHead(status, {
938
+ 'content-type': 'application/json',
939
+ 'content-length': String(Buffer.byteLength(text)),
940
+ 'cache-control': 'no-store',
941
+ })
942
+ res.end(text)
943
+ }
944
+
945
+ /**
946
+ * One audit line per transfer request, on the worker.
947
+ *
948
+ * The master keeps the durable audit log; this is the worker's own record, and it exists because the
949
+ * resolved path is the only place the truth lives. A relative path from a master is meaningless
950
+ * later -- "which file did that 4 GiB write actually land in" is answerable only from this side, and
951
+ * there is no cage here (see resolveTransferPath), so the echo IS the safety mechanism.
952
+ */
953
+ function audit(req, direction, detail) {
954
+ log(`transfer ${direction} peer=${req.socket.remoteAddress ?? '?'} ${detail}`)
955
+ }
956
+
957
+ /**
958
+ * `GET`/`HEAD /file?path=<p>[&offset=<n>]`.
959
+ *
960
+ * The WHOLE-FILE sha256 is computed before the headers, in a streaming pre-pass, and that ordering is
961
+ * forced by the protocol rather than chosen: the digest is a HEADER, so it must be known before the
962
+ * first body byte, and a header cannot be filled in from the bytes that follow it. The alternatives
963
+ * were worse -- buffering the file (the one thing this feature may not do) or sending the digest as
964
+ * an HTTP trailer, which no client of this protocol reads. On a resume (`206`) the file is therefore
965
+ * read twice, once to hash and once to send; no byte is ever held in memory either way.
966
+ *
967
+ * `HEAD` IS THE EXCEPTION, and it has to be: the whole point of asking for headers alone is to learn
968
+ * the size cheaply, and hashing first would make a probe of a 2 GiB file read 2 GiB. The host's
969
+ * pre-flight reads only `x-file-size` and the 413, so `HEAD` skips the digest unless `hash=1` asks
970
+ * for it -- the header is then simply absent, which a caller cannot mistake for a wrong digest.
971
+ */
972
+ async function serveFileDownload(req, res, url) {
973
+ const resolved = resolveTransferPath(url.searchParams.get('path'), DECLARED_TASK_DIR)
974
+ if (!resolved.ok) { jsonOut(res, 400, { ok: false, error: resolved.error }); return }
975
+
976
+ const info = await statTarget(resolved.path)
977
+ if (!info.exists || info.isDir) {
978
+ jsonOut(res, 404, {
979
+ ok: false,
980
+ error: info.isDir ? `${resolved.path} is a directory` : `${resolved.path} does not exist`,
981
+ })
982
+ return
983
+ }
984
+ // The same ceiling as `PUT`, applied to reads. It is not about this worker's disk -- it is about
985
+ // the master: a hard limit the receiving side sets is the only one that holds when the other side
986
+ // is a token someone else holds, and "over the limit" must be the same answer in both directions.
987
+ if (info.size > MAX_TRANSFER_BYTES) {
988
+ jsonOut(res, 413, {
989
+ ok: false,
990
+ error: `${resolved.path} is ${info.size} bytes, over the ${MAX_TRANSFER_BYTES}-byte ceiling (--max-transfer-bytes)`,
991
+ limit: MAX_TRANSFER_BYTES,
992
+ })
993
+ return
994
+ }
995
+
996
+ const range = rangeOf(info.size, url.searchParams.get('offset'))
997
+ if (!range.ok) { jsonOut(res, range.status, { ok: false, error: range.error }); return }
998
+
999
+ const started = Date.now()
1000
+ // A `HEAD` that hashes the file is NOT a cheap probe: it reads every byte. The host's pre-flight
1001
+ // needs two things -- the size, and whether the ceiling already refuses it -- so the digest is
1002
+ // computed for `GET` (where the receiver verifies against it) and skipped for `HEAD` unless the
1003
+ // caller explicitly asks with `hash=1`.
1004
+ const wantsHash = req.method !== 'HEAD' || url.searchParams.get('hash') === '1'
1005
+ const sha256 = wantsHash ? (await sha256File(resolved.path)).sha256 : ''
1006
+ /** @type {Record<string,string>} */
1007
+ const headers = {
1008
+ // Deliberately generic: this is bytes, not an image or a document, and guessing a type would make
1009
+ // some clients helpfully transform them.
1010
+ 'content-type': 'application/octet-stream',
1011
+ 'content-length': String(range.length),
1012
+ 'cache-control': 'no-store',
1013
+ 'x-file-size': String(info.size),
1014
+ 'x-file-path': resolved.path,
1015
+ 'x-file-mtime': info.mtime ?? '',
1016
+ ...(wantsHash ? { 'x-file-sha256': sha256 } : {}),
1017
+ 'x-file-offset': String(range.offset),
1018
+ }
1019
+ if (range.contentRange !== undefined) headers['content-range'] = range.contentRange
1020
+ res.writeHead(range.status, headers)
1021
+ audit(req, 'download', `path=${resolved.path} offset=${range.offset} bytes=${range.length} sha256=${sha256 === '' ? 'not-requested' : sha256} ms=${Date.now() - started}`)
1022
+
1023
+ if (req.method === 'HEAD' || range.length === 0) { res.end(); return }
1024
+ try {
1025
+ // `end` is inclusive and only valid for a non-empty range, which the branch above guarantees.
1026
+ await pipeline(createReadStream(resolved.path, { start: range.offset, end: info.size - 1 }), res)
1027
+ } catch (error) {
1028
+ // A client that hung up mid-download is the ordinary way a transfer is cancelled, and the master
1029
+ // resumes with `offset=`. Nothing on disk changed, so this is a line, not a failure.
1030
+ log(`download of ${resolved.path} interrupted: ${error.message}`)
1031
+ res.destroy()
1032
+ }
1033
+ }
1034
+
1035
+ /**
1036
+ * `PUT /file?path=<p>&offset=<n>`.
1037
+ *
1038
+ * No method check beyond the route table below: a chunk that arrives out of order is answered with the
1039
+ * offset `.part` actually holds (see putChunk), which is what makes a retry after a dropped
1040
+ * connection continue instead of restart.
1041
+ */
1042
+ async function acceptFileChunk(req, res, url) {
1043
+ const resolved = resolveTransferPath(url.searchParams.get('path'), DECLARED_TASK_DIR)
1044
+ if (!resolved.ok) { jsonOut(res, 400, { ok: false, error: resolved.error }); return }
1045
+
1046
+ // A chunked body has no declared length, and the contract makes that a 411 rather than a guess:
1047
+ // without it the receiver cannot tell a complete chunk from a truncated one, and the ceiling cannot
1048
+ // be checked before the bytes are on disk.
1049
+ const chunked = String(req.headers['transfer-encoding'] ?? '').toLowerCase().includes('chunked')
1050
+ const started = Date.now()
1051
+ const result = await putChunk({
1052
+ target: resolved.path,
1053
+ offset: url.searchParams.get('offset') ?? 0,
1054
+ contentLength: chunked ? undefined : req.headers['content-length'],
1055
+ body: req,
1056
+ maxBytes: MAX_TRANSFER_BYTES,
1057
+ })
1058
+
1059
+ if (result.aborted === true) {
1060
+ // There is no client left to answer. `.part` keeps whatever arrived -- those bytes are the resume
1061
+ // point -- and this line is the only record that the transfer stopped short.
1062
+ audit(req, 'upload-interrupted', `path=${resolved.path} received=${result.received} ${result.json.error}`)
1063
+ res.destroy()
1064
+ return
1065
+ }
1066
+ audit(req, 'upload', `path=${resolved.path} offset=${result.json.offset} received=${result.json.received} partBytes=${result.json.partBytes} status=${result.status} ms=${Date.now() - started}`)
1067
+ jsonOut(res, result.status, result.json)
1068
+ }
1069
+
1070
+ /** `POST /file/commit` -- the only step that can publish a file under its real name. */
1071
+ async function acceptFileCommit(req, res) {
1072
+ const parsed = await readJsonBody(req)
1073
+ if (!parsed.ok) { jsonOut(res, parsed.status, parsed.json); return }
1074
+
1075
+ const resolved = resolveTransferPath(parsed.value.path, DECLARED_TASK_DIR)
1076
+ if (!resolved.ok) { jsonOut(res, 400, { ok: false, error: resolved.error }); return }
1077
+
1078
+ const started = Date.now()
1079
+ const result = await commitPart({
1080
+ target: resolved.path,
1081
+ sha256: parsed.value.sha256,
1082
+ size: parsed.value.size,
1083
+ overwrite: parsed.value.overwrite === true,
1084
+ })
1085
+ audit(req, 'commit', [
1086
+ `path=${resolved.path}`,
1087
+ `ok=${result.ok}`,
1088
+ `bytes=${result.json.bytes ?? result.json.actualSize ?? 0}`,
1089
+ `sha256=${result.json.sha256 ?? result.json.actual ?? ''}`,
1090
+ `status=${result.status}`,
1091
+ `ms=${Date.now() - started}`,
1092
+ ].join(' '))
1093
+ jsonOut(res, result.status, result.json)
1094
+ }
1095
+
1096
+ /** `DELETE /file?path=<p>` -- drops the staging file and never the target. */
1097
+ async function dropFilePart(req, res, url) {
1098
+ const resolved = resolveTransferPath(url.searchParams.get('path'), DECLARED_TASK_DIR)
1099
+ if (!resolved.ok) { jsonOut(res, 400, { ok: false, error: resolved.error }); return }
1100
+ const result = await deletePart(resolved.path)
1101
+ audit(req, 'abort', `path=${resolved.path} removed=${result.json.removed} part=${result.json.part}`)
1102
+ jsonOut(res, result.status, result.json)
1103
+ }
1104
+
1105
+ /** The four `/file` endpoints, dispatched by path and method. */
1106
+ async function handleFileRequest(req, res, url) {
1107
+ if (url.pathname === '/file/commit') {
1108
+ if (req.method !== 'POST') {
1109
+ res.setHeader('allow', 'POST')
1110
+ jsonOut(res, 405, { ok: false, error: `${req.method} is not allowed on /file/commit` })
1111
+ return
1112
+ }
1113
+ await acceptFileCommit(req, res)
1114
+ return
1115
+ }
1116
+
1117
+ switch (req.method) {
1118
+ case 'GET':
1119
+ case 'HEAD':
1120
+ await serveFileDownload(req, res, url)
1121
+ return
1122
+ case 'PUT':
1123
+ await acceptFileChunk(req, res, url)
1124
+ return
1125
+ case 'DELETE':
1126
+ await dropFilePart(req, res, url)
1127
+ return
1128
+ default:
1129
+ // Explicit, so a POST or PATCH to /file is a named refusal rather than the "/file" handler
1130
+ // silently doing something it was not asked to do.
1131
+ res.setHeader('allow', 'GET, HEAD, PUT, DELETE')
1132
+ jsonOut(res, 405, { ok: false, error: `${req.method} is not allowed on /file` })
1133
+ }
1134
+ }
1135
+
1136
+ const server = createServer((req, res) => {
1137
+ const url = (() => { try { return new URL(req.url, 'http://x') } catch { return undefined } })()
1138
+
1139
+ if (url !== undefined && url.pathname === '/healthz') {
1140
+ res.writeHead(200, { 'content-type': 'application/json' })
1141
+ res.end(JSON.stringify({
1142
+ ok: true,
1143
+ agent: LAUNCH.command,
1144
+ auth: TOKEN !== '',
1145
+ // The agent inherits THIS working directory, and dsh-base's sandbox-policy uses
1146
+ // `process.cwd()` as its workspaceRoot. A launcher that leaves the cwd at
1147
+ // C:\Windows\System32 therefore hands the sandbox System32 as the workspace, which
1148
+ // breaks child process creation (pwsh dies with 0xC0000142 / STATUS_DLL_INIT_FAILED).
1149
+ // Surfacing it here makes that misconfiguration visible instead of mysterious.
1150
+ cwd: TASK_DIR,
1151
+ // WHERE THAT DIRECTORY CAME FROM. `cwd` alone cannot distinguish "the operator armed this
1152
+ // directory" from "the relay inherited whatever the DSH process had", and that ambiguity is what
1153
+ // let a relative upload land in the profile directory while every surface reported success.
1154
+ taskDirSource: TASK_DIR_SOURCE,
1155
+ // The mode the child this relay spawns will actually run at, plus the mode this machine is
1156
+ // configured for. A mismatch between the two is the F38 failure, and it is visible from either
1157
+ // side without reading a file by hand.
1158
+ ...healthModeState(),
1159
+ pluginRpc: [`${PLUGIN_PREFIX}ping`, `${PLUGIN_PREFIX}selfcheck`, `${PLUGIN_PREFIX}screenshot`, `${PLUGIN_PREFIX}stat`, `${PLUGIN_PREFIX}list`],
1160
+ http: ['/healthz', '/screenshot', '/file', '/file/commit'],
1161
+ // A master that has just paired can see, before it tries anything, which byte ceiling this
1162
+ // worker will hold it to -- the number a `413` would otherwise announce mid-transfer.
1163
+ maxTransferBytes: MAX_TRANSFER_BYTES,
1164
+ }))
1165
+ return
1166
+ }
1167
+
1168
+ // The PNG comes back here rather than inside the RPC result: base64 would inflate a
1169
+ // multi-megabyte frame by a third and would have to pass through the ACP pump, which
1170
+ // is there for small control messages.
1171
+ if (url !== undefined && url.pathname === '/screenshot') {
1172
+ if (!tokenOk(bearerOf(req))) {
1173
+ res.writeHead(401, { 'content-type': 'application/json' })
1174
+ res.end(JSON.stringify({ ok: false, error: 'unauthorized' }))
1175
+ return
1176
+ }
1177
+ const fresh = url.searchParams.get('fresh') === '1'
1178
+ const started = Date.now()
1179
+ ;(async () => {
1180
+ let shot = lastShot
1181
+ if (fresh || shot === undefined) shot = lastShot = await captureScreenshot()
1182
+ const body = readFileSync(shot.path)
1183
+ res.writeHead(200, {
1184
+ 'content-type': 'image/png',
1185
+ 'content-length': String(body.length),
1186
+ 'cache-control': 'no-store',
1187
+ // Headers rather than a JSON envelope so the response IS the image, which makes
1188
+ // it usable with any HTTP client (and with a browser).
1189
+ 'x-shot-width': String(shot.width),
1190
+ 'x-shot-height': String(shot.height),
1191
+ 'x-shot-bytes': String(shot.bytes),
1192
+ 'x-shot-captured-at': shot.capturedAt,
1193
+ 'x-shot-displays': String(shot.displays.length),
1194
+ 'x-shot-ms': String(Date.now() - started),
1195
+ })
1196
+ res.end(body)
1197
+ })().catch((error) => {
1198
+ log(`/screenshot failed: ${error.message}`)
1199
+ if (!res.headersSent) {
1200
+ res.writeHead(500, { 'content-type': 'application/json' })
1201
+ res.end(JSON.stringify({ ok: false, error: error.message }))
1202
+ } else {
1203
+ try { res.end() } catch { /* already sent */ }
1204
+ }
1205
+ })
1206
+ return
1207
+ }
1208
+
1209
+ // The transfer surface. One token check for all four endpoints, exactly as /screenshot does it, so
1210
+ // a new endpoint cannot be added here without passing through the same gate.
1211
+ if (url !== undefined && (url.pathname === '/file' || url.pathname === '/file/commit')) {
1212
+ if (!tokenOk(bearerOf(req))) {
1213
+ jsonOut(res, 401, { ok: false, error: 'unauthorized' })
1214
+ return
1215
+ }
1216
+ handleFileRequest(req, res, url).catch((error) => {
1217
+ // A throw from here is a local failure -- a disk that refused, a path that vanished mid-flight.
1218
+ // It must be LOUD: answering 200 for a transfer that did not land is the silent-failure class
1219
+ // this whole feature is built to avoid, and the master's commit check cannot catch what it was
1220
+ // never told.
1221
+ log(`/file failed: ${error?.stack ?? error}`)
1222
+ if (!res.headersSent) jsonOut(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
1223
+ else { try { res.end() } catch { /* already gone */ } }
1224
+ })
1225
+ return
1226
+ }
1227
+
1228
+ res.writeHead(404); res.end('not found')
1229
+ })
1230
+
1231
+ const wss = new WebSocketServer({ noServer: true })
1232
+
1233
+ server.on('upgrade', (req, socket, head) => {
1234
+ if (!tokenOk(bearerOf(req))) {
1235
+ log('rejected: bad token from', req.socket.remoteAddress)
1236
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n')
1237
+ socket.destroy()
1238
+ return
1239
+ }
1240
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req))
1241
+ })
1242
+
1243
+ let connSeq = 0
1244
+
1245
+ wss.on('connection', (ws, req) => {
1246
+ const id = ++connSeq
1247
+ log(`#${id} connected from ${req.socket.remoteAddress}`)
1248
+
1249
+ const child = spawn(LAUNCH.command, LAUNCH.args, {
1250
+ stdio: ['pipe', 'pipe', 'pipe'],
1251
+ windowsHide: true,
1252
+ env: { ...process.env, ...LAUNCH.env },
1253
+ })
1254
+ log(`#${id} spawned pid=${child.pid}: ${LAUNCH.command} ${LAUNCH.args.join(' ')}`)
1255
+
1256
+ let childAlive = true
1257
+ let stdoutBuf = ''
1258
+ child.stdout.on('data', (chunk) => {
1259
+ stdoutBuf += chunk.toString('utf8')
1260
+ let nl
1261
+ while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
1262
+ const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
1263
+ stdoutBuf = stdoutBuf.slice(nl + 1)
1264
+ if (line.trim() === '') continue
1265
+ if (ws.readyState === ws.OPEN) { ws.send(line); vlog(`#${id} <- ${line.slice(0, 160)}`) }
1266
+ }
1267
+ })
1268
+ child.stderr.on('data', (c) => log(`#${id} [stderr] ${c.toString('utf8').trim().slice(0, 400)}`))
1269
+
1270
+ let closed = false
1271
+ const shutdown = (why) => {
1272
+ if (closed) return
1273
+ closed = true
1274
+ childAlive = false
1275
+ log(`#${id} shutdown: ${why}`)
1276
+ try { child.stdin.end() } catch { /* already closed */ }
1277
+ setTimeout(() => { try { child.kill() } catch { /* already gone */ } }, 500)
1278
+ try { ws.close() } catch { /* already closed */ }
1279
+ }
1280
+
1281
+ child.on('exit', (code, sig) => { log(`#${id} child exited code=${code} sig=${sig}`); shutdown('child exit') })
1282
+ child.on('error', (e) => { log(`#${id} child error: ${e.message}`); shutdown('child error') })
1283
+
1284
+ ws.on('message', (data, isBinary) => {
1285
+ // ACP's WebSocket transport is text-only; the SDK would silently drop a binary
1286
+ // frame, so make it loud here instead (DESIGN §6.6).
1287
+ if (isBinary) { log(`#${id} WARNING: binary frame dropped (ACP is text-only)`); return }
1288
+ const text = data.toString('utf8')
1289
+ for (const rawLine of text.split('\n')) {
1290
+ const line = rawLine.replace(/\r$/, '')
1291
+ if (line.trim() === '') continue
1292
+
1293
+ let msg
1294
+ try { msg = JSON.parse(line) } catch {
1295
+ log(`#${id} dropping unparseable frame: ${line.slice(0, 120)}`)
1296
+ continue
1297
+ }
1298
+
1299
+ if (typeof msg.method === 'string' && msg.method.startsWith(PLUGIN_PREFIX)) {
1300
+ if (msg.id === undefined) { vlog(`#${id} plugin notification ${msg.method}`); continue }
1301
+ void handleLocalRpc(msg).then((response) => {
1302
+ if (response !== null && ws.readyState === ws.OPEN) {
1303
+ ws.send(JSON.stringify(response))
1304
+ vlog(`#${id} plugin reply -> ${JSON.stringify(response).slice(0, 200)}`)
1305
+ }
1306
+ })
1307
+ continue
1308
+ }
1309
+
1310
+ if (!childAlive) { log(`#${id} dropping ACP frame: child is gone`); continue }
1311
+ try { child.stdin.write(line + '\n'); vlog(`#${id} -> ${line.slice(0, 160)}`) }
1312
+ catch (e) { log(`#${id} write to child failed: ${e.message}`); shutdown('write failed'); return }
1313
+ }
1314
+ })
1315
+
1316
+ ws.on('close', () => shutdown('ws closed'))
1317
+ ws.on('error', (e) => { log(`#${id} ws error: ${e.message}`); shutdown('ws error') })
1318
+ })
1319
+
1320
+ server.listen(PORT, HOST, () => {
1321
+ log(`listening on ${HOST}:${PORT} (health: GET /healthz)`)
1322
+ log(`agent: ${LAUNCH.command}`)
1323
+ log(` ${LAUNCH.args.join(' ')}`)
1324
+ log(`self-check script: ${SELFCHECK_SCRIPT} (cache ${SELFCHECK_CACHE_MS}ms)`)
1325
+ // Printed because it is the one setting that turns a working transfer into a 413 at an arbitrary
1326
+ // byte, and the operator's only other clue is that status code.
1327
+ log(`file transfer: task dir ${TASK_DIR} (from ${TASK_DIR_SOURCE}), ceiling ${MAX_TRANSFER_BYTES} bytes`)
1328
+ if (TOKEN === '') log('WARNING: auth disabled')
1329
+ // Leave the pairing string where an operator can find it. It is written on every start so it
1330
+ // cannot go stale against a rotated token -- a pairing string with the OLD token fails with
1331
+ // an authentication error that looks like a network problem.
1332
+ //
1333
+ // `--pairing-file ''` skips this entirely, which is what the plugin does: it spawns this relay
1334
+ // from inside the plugin's own directory, and a relay that wrote next to itself would have an
1335
+ // installed plugin modifying its installation.
1336
+ const pairing = PAIRING_FILE === '' ? '' : pairingString()
1337
+ if (PAIRING_FILE === '') {
1338
+ // Nothing to do, and deliberately not logged as a problem.
1339
+ } else if (pairing === '') {
1340
+ log('no pairing string: this relay has no token, so a pairing string would pair a master with an unauthenticated worker')
1341
+ } else {
1342
+ try {
1343
+ writeFileSync(PAIRING_FILE, `${pairing}\n`, { encoding: 'utf8', mode: 0o600 })
1344
+ log(`pairing string written to ${PAIRING_FILE} (${advertisableHost()}:${PORT})`)
1345
+ } catch (error) {
1346
+ // Not fatal: `--print-pairing` covers the case where the file cannot be written.
1347
+ log(`could not write ${PAIRING_FILE}: ${error instanceof Error ? error.message : String(error)}`)
1348
+ log('run the relay with --print-pairing to get the string instead')
1349
+ }
1350
+ }
1351
+ })