@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.
- package/LICENSE +21 -0
- package/README.md +207 -2
- package/README.zh.md +195 -0
- package/assets/icon.svg +47 -0
- package/cordis.patch.yml +41 -0
- package/lib/bridge/acp-bridge.js +905 -0
- package/lib/bridge/peer-connection.js +730 -0
- package/lib/client.js +2842 -0
- package/lib/core/acp-model-overlay.js +121 -0
- package/lib/core/approval-queue.js +216 -0
- package/lib/core/audit-log.js +322 -0
- package/lib/core/desktop-notify.js +173 -0
- package/lib/core/environment-prompt.js +76 -0
- package/lib/core/relay-supervisor.js +278 -0
- package/lib/core/review-engine.js +679 -0
- package/lib/core/sandbox-diagnosis.js +258 -0
- package/lib/core/sandbox-probe.js +310 -0
- package/lib/core/self-acp-spec.js +117 -0
- package/lib/core/selfcheck-runner.js +150 -0
- package/lib/core/settings-store.js +226 -0
- package/lib/core/shot-gc.js +334 -0
- package/lib/core/task-cwd.js +50 -0
- package/lib/core/task-memory.js +163 -0
- package/lib/core/task-registry.js +238 -0
- package/lib/core/toolcall-cache.js +206 -0
- package/lib/core/transfer-queue.js +58 -0
- package/lib/core/transfer.js +1144 -0
- package/lib/core/worker-arming.js +334 -0
- package/lib/index.js +2207 -0
- package/lib/provider/out-of-process-helpers.js +76 -0
- package/lib/provider/remote-provider.js +261 -0
- package/lib/tools/mediator-shim.mjs +180 -0
- package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
- package/lib/tools/remote-tools.js +680 -0
- package/lib/tools/selfcheck-fix-prompt.md +206 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/slave/acp-profile-approval.patch.yml +66 -0
- package/slave/file-transfer.mjs +552 -0
- package/slave/integrity-probe.ps1 +99 -0
- package/slave/make-launch-spec.mjs +184 -0
- package/slave/net-address.mjs +138 -0
- package/slave/node_modules/.package-lock.json +26 -0
- package/slave/node_modules/ws/LICENSE +20 -0
- package/slave/node_modules/ws/README.md +548 -0
- package/slave/node_modules/ws/browser.js +8 -0
- package/slave/node_modules/ws/index.js +22 -0
- package/slave/node_modules/ws/lib/buffer-util.js +131 -0
- package/slave/node_modules/ws/lib/constants.js +19 -0
- package/slave/node_modules/ws/lib/event-target.js +292 -0
- package/slave/node_modules/ws/lib/extension.js +203 -0
- package/slave/node_modules/ws/lib/limiter.js +55 -0
- package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/slave/node_modules/ws/lib/receiver.js +743 -0
- package/slave/node_modules/ws/lib/sender.js +607 -0
- package/slave/node_modules/ws/lib/stream.js +161 -0
- package/slave/node_modules/ws/lib/subprotocol.js +62 -0
- package/slave/node_modules/ws/lib/validation.js +152 -0
- package/slave/node_modules/ws/lib/websocket-server.js +562 -0
- package/slave/node_modules/ws/lib/websocket.js +1421 -0
- package/slave/node_modules/ws/package.json +74 -0
- package/slave/node_modules/ws/wrapper.mjs +21 -0
- package/slave/pairing.mjs +175 -0
- package/slave/permission-pairing.mjs +240 -0
- package/slave/prune-shots.mjs +170 -0
- package/slave/relay.mjs +1351 -0
- package/slave/repair-workspace-integrity.ps1 +75 -0
- package/slave/screenshot.ps1 +114 -0
- package/slave/selfcheck.ps1 +350 -0
- package/slave/stage-app.ps1 +116 -0
- package/slave/workspace-integrity.mjs +157 -0
package/slave/relay.mjs
ADDED
|
@@ -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
|
+
})
|