@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
package/lib/index.js ADDED
@@ -0,0 +1,2207 @@
1
+ /**
2
+ * Host half of dsh-plugin-remote-use.
3
+ *
4
+ * Assembles the pieces that let the master drive a paired LAN worker as if it were
5
+ * a subagent:
6
+ *
7
+ * model --> tool `subagent_remote` (a normal `dsh-tool-subagent` row)
8
+ * --> RemoteProvider (implements DSH's SubagentProvider seam)
9
+ * --> AcpBridge (ACP over WebSocket)
10
+ * --> worker relay --> worker `dsh --profile acp`
11
+ *
12
+ * NOTHING IN DSH IS MODIFIED: the seam is the published `ctx.subagents` provider
13
+ * registry, and the control tools are ordinary `ctx.tools` registrations.
14
+ *
15
+ * CONFIG MUST BE SCHEMASTERY, NOT ZOD: dsh-app-boot only recognises a Config whose
16
+ * schema carries `Symbol.for("schemastery") === true`; a zod schema makes the
17
+ * package report `status: "unsupported"` to `Config.listConfigs`, so the Settings
18
+ * page cannot render its form (DESIGN §12.2).
19
+ *
20
+ * @module dsh-plugin-remote-use
21
+ */
22
+
23
+ // NOTE: `@deepseek-ai/schemastery` is deliberately NOT a static import here -- see the
24
+ // defensive resolution below for why a missing schema must not abort the module.
25
+ import { AcpBridge } from './bridge/acp-bridge.js'
26
+ import { PeerConnection, httpBaseOf } from './bridge/peer-connection.js'
27
+ import { TaskRegistry } from './core/task-registry.js'
28
+ import { ToolCallCache } from './core/toolcall-cache.js'
29
+ import { TaskMemory } from './core/task-memory.js'
30
+ import { assertUsableCwd } from './core/task-cwd.js'
31
+ import { OPTION, decideApproval, inferRequiredTier } from './core/review-engine.js'
32
+ import { ApprovalQueue, QUEUE_LEAD_MS } from './core/approval-queue.js'
33
+ import { notifyDesktop, loggerMethods } from './core/desktop-notify.js'
34
+ import { dirname, join } from 'node:path'
35
+ import { fileURLToPath } from 'node:url'
36
+ import { networkInterfaces } from 'node:os'
37
+ import { AuditLog } from './core/audit-log.js'
38
+ import { WorkerArming } from './core/worker-arming.js'
39
+ import { RelaySupervisor } from './core/relay-supervisor.js'
40
+ import { probeSandbox } from './core/sandbox-probe.js'
41
+ import { diagnoseSandbox } from './core/sandbox-diagnosis.js'
42
+ import { parseWorkerModel, writeWorkerModelOverlay } from './core/acp-model-overlay.js'
43
+ import { runSelfcheck, summarizeReport } from './core/selfcheck-runner.js'
44
+ import { buildEnvironmentPrompt } from './core/environment-prompt.js'
45
+ import { resolveAcpLaunchSpec } from './core/self-acp-spec.js'
46
+ // Decoding lives with the relay that encodes, so the two can never disagree about the format.
47
+ import { decodePairing, deriveWorkerId, describePairing } from '../slave/pairing.mjs'
48
+ import {
49
+ mergeSettings,
50
+ normalizeWorker,
51
+ publicWorker,
52
+ readSettings,
53
+ removeWorker as removeWorkerFromList,
54
+ resolveDshHome,
55
+ upsertWorker,
56
+ writeSettings,
57
+ } from './core/settings-store.js'
58
+ import { RemoteProvider } from './provider/remote-provider.js'
59
+ import { limitDiagnostic, verifyAgainstUpstream } from './provider/out-of-process-helpers.js'
60
+ import { makeRemoteTools } from './tools/remote-tools.js'
61
+
62
+ // NOT the package name, deliberately. This value is only the label DSH gives the plugin's fiber
63
+ // (`ctx.registry.plugin(...)`), and the convention across DSH's own first-party bundles is the
64
+ // SCOPE-STRIPPED name: @deepseek-ai/dsh-client-hmr exports `name = "client-hmr"`. Two of this
65
+ // operator's plugins that run correctly in a live profile do the same
66
+ // (`@hope_phenom/dsh-plugin-code-quality` exports `dsh-plugin-code-quality`). The loader does NOT
67
+ // key anything on it -- unlike the client registration id in lib/client.js, which MUST equal the
68
+ // package name and is the one that actually broke the Web UI.
69
+ export const name = 'dsh-plugin-remote-use'
70
+
71
+ /** Optional services: stay inert in profiles that lack them. */
72
+ export const inject = ['subagents', 'tools', 'systemPrompt']
73
+
74
+ /**
75
+ * Resolve `@deepseek-ai/schemastery` defensively.
76
+ *
77
+ * WHY THIS IS NOT A STATIC IMPORT: the host is expected to intercept `@deepseek-ai/*`
78
+ * for third-party plugins (that is why `dsh-cost-meter` can list `@deepseek-ai/dsh-credentials`
79
+ * as a peer dependency). But this plugin is linked into the profile, and Node resolves
80
+ * modules through the package's REAL path — `profiles/node_modules/@deepseek-ai/schemastery`
81
+ * is not necessarily on that path, and on this machine it is one of the 482 dangling
82
+ * junctions left by an old npm install. A failed static import would abort the whole
83
+ * module, so the plugin would not load at all.
84
+ *
85
+ * Falling back to a plain object costs only the Settings-page form: dsh-app-boot marks a
86
+ * non-Schemastery `Config` as `unsupported`, and configuration still arrives from the
87
+ * patch layer, which is where worker entries are written anyway.
88
+ */
89
+ const SCHEMA_UNAVAILABLE = 'SCHEMA_UNAVAILABLE'
90
+ let Schema = null
91
+ try {
92
+ Schema = (await import('@deepseek-ai/schemastery')).default ?? null
93
+ } catch (error) {
94
+ console.warn(`[remote-use] @deepseek-ai/schemastery not importable (${error instanceof Error ? error.message : String(error)}); `
95
+ + 'falling back to a plain Config object. Workers must be configured through the profile patch layer, '
96
+ + 'and the Settings page will not render a form for this plugin.')
97
+ }
98
+
99
+ /** The default worker shape, shared by the schema and the fallback. */
100
+ const WORKER_DEFAULTS = {
101
+ name: '',
102
+ token: '',
103
+ enabled: true,
104
+ serialQueue: 1,
105
+ }
106
+
107
+ /**
108
+ * Tools this plugin owns — the exact set the per-session gate acts on.
109
+ *
110
+ * Listed literally rather than derived from `makeRemoteTools()` so the gate cannot silently
111
+ * widen if that function ever grows a tool: a new tool would appear in the list only after
112
+ * someone adds it here, and until then the guard would refuse it (fail-closed).
113
+ */
114
+ export const REMOTE_TOOL_NAMES = Object.freeze([
115
+ 'remote_agents',
116
+ 'remote_interrupt',
117
+ 'remote_selfcheck',
118
+ 'remote_screenshot',
119
+ // Moves files across the same paired link the rest of these use. It rides the relay's HTTP surface
120
+ // rather than the ACP session, so a copy neither waits for the worker's GUI slot nor is governed by
121
+ // its session sandbox mode -- see DESIGN's transfer section for why that is deliberate.
122
+ 'remote_file',
123
+ // The delegation entry point. It is in this list for the same reason as the others: the list
124
+ // drives BOTH the per-session deny mask (hides these tools from a session that has not run
125
+ // `/remote-use on`) and the fail-closed execution guard. Registering a remote tool without
126
+ // listing it here would leave it visible AND callable in a session that never asked for remote
127
+ // work -- proven by mutation: removing this line fails four guards.
128
+ 'remote_subagent',
129
+ ])
130
+
131
+ /** The slash command that switches remote work on and off for one session. */
132
+ export const ENABLE_COMMAND = 'remote-use'
133
+
134
+ /**
135
+ * Injected only while a session has remote work switched ON.
136
+ *
137
+ * WHY THIS EXISTS AT ALL: enabling the plugin registers the delegation tools, but nothing in
138
+ * DSH tells the parent agent *when* delegating is appropriate -- the tool description is
139
+ * generic ("delegate a self-contained task to a subagent"), and DSH's own
140
+ * `SUBAGENT_DELEGATION` context is addressed to the CHILD, not to the delegating agent.
141
+ * Without a policy the model has no way to know that this worker is for GUI verification and
142
+ * not a second development machine.
143
+ *
144
+ * Placed at TEAM_POLICY order so the rule is read BEFORE the tool guidance it qualifies,
145
+ * which is the same placement rationale `dsh-tools` gives for its PTC-only statement.
146
+ */
147
+ export const DEFAULT_DELEGATION_POLICY = [
148
+ '## Remote LAN workers (enabled for this session)',
149
+ '',
150
+ 'A paired worker is available. It exists for GUI verification and testing on a second',
151
+ 'machine, because GUI automation takes over the mouse, keyboard and focus of whichever',
152
+ 'machine runs it.',
153
+ '',
154
+ '- Do all code editing, file changes, builds, dependency installs and repository work HERE.',
155
+ '- Delegate ONLY what needs a real desktop: launching and driving the target application,',
156
+ ' confirming what the interface shows, reproducing something that only happens on a real',
157
+ ' display.',
158
+ '- Do NOT develop on a worker. It cannot see this workspace, so anything written there does',
159
+ ' not come back.',
160
+ '',
161
+ 'Call remote_selfcheck before delegating (a worker that fails its readiness check is',
162
+ 'refused by the dispatch gate). Use remote_agents to watch progress, remote_screenshot to',
163
+ 'look at the worker screen, and remote_interrupt to stop a task.',
164
+ ].join('\n')
165
+
166
+ /**
167
+ * Plugin configuration.
168
+ *
169
+ * A worker entry needs the relay's address and shared secret. `cwd` is the worker-side
170
+ * absolute path a task runs in — it must exist on the worker and is also what the
171
+ * sandbox scopes writes to.
172
+ */
173
+ export const Config = Schema !== null
174
+ ? Schema.object({
175
+ workers: Schema.array(Schema.object({
176
+ id: Schema.string().required(),
177
+ name: Schema.string().default(''),
178
+ url: Schema.string().required(),
179
+ token: Schema.string().default(''),
180
+ cwd: Schema.string().required(),
181
+ enabled: Schema.boolean().default(true),
182
+ /** Per-worker serial concurrency. GUI is an exclusive resource: 1. */
183
+ serialQueue: Schema.number().default(1),
184
+ })).default([]),
185
+ approvalTimeoutMs: Schema.number().default(180_000),
186
+ /** Tier a task needs unless the delegation narrows it. */
187
+ requiredTier: Schema.string().default('workspace-write'),
188
+ /** Prefix a remote tool's name; one row per worker uses `<toolPrefix>` + worker id. */
189
+ toolPrefix: Schema.string().default('subagent_remote'),
190
+ injectTaskSummary: Schema.boolean().default(true),
191
+ /** Liveness probe interval per worker, in ms. 0 disables it. */
192
+ heartbeatMs: Schema.number().default(15000),
193
+ /**
194
+ * How long a page load may spend probing readiness before it answers without a report.
195
+ * Raise it for a slow link; the page renders immediately either way.
196
+ */
197
+ reportBudgetMs: Schema.number().default(4000),
198
+ /** How long the page's explicit "re-check now" may spend. */
199
+ selfcheckBudgetMs: Schema.number().default(25000),
200
+ /**
201
+ * How many screenshots to keep on disk PER WORKER, newest first.
202
+ *
203
+ * A worker screenshot is a full-resolution PNG -- ~9.5 MB measured on the reference
204
+ * worker -- and every capture used to be kept forever. Set 0 to keep none, or a negative
205
+ * number to disable pruning entirely (for an operator who archives them elsewhere).
206
+ */
207
+ keepShots: Schema.number().default(20),
208
+ /**
209
+ * Byte ceiling for the screenshots this HOST keeps per worker, and for the ones the worker
210
+ * keeps in its own shot directory.
211
+ *
212
+ * WHY A SECOND BUDGET: the count above bounds the NUMBER of captures, not the disk they take.
213
+ * Measured 2026-10-15: the host's screenshot directory had reached 218 MB over 30 captures
214
+ * while `keepShots` was 20 -- the setting looked like a disk bound and was not one. The
215
+ * operator's actual question is "how much space is this using", so the answer needs a byte
216
+ * number. Both budgets apply; whichever bites first wins.
217
+ *
218
+ * 0 (the default) means NO byte budget, deliberately: an install that upgrades into a byte
219
+ * ceiling would start deleting captures the operator never agreed to lose. Negative is
220
+ * treated the same as 0, so a typo can never widen deletion.
221
+ */
222
+ shotCacheBytes: Schema.number().default(0),
223
+ /**
224
+ * The sandbox mode THIS machine's remote sessions run under, written into the launch spec when
225
+ * the worker role is armed.
226
+ *
227
+ * WHY IT DEFAULTS TO FULL ACCESS: a GUI workflow is many calls, and the failure that keeps it
228
+ * from starting under a confined token -- `0xC0000142` from a GUI-subsystem child -- is SILENT:
229
+ * the command returns success, no denial is reported, and the only trace is a dialog on someone's
230
+ * desktop (PITFALLS F21/F33). A decision that cannot be seen cannot be escalated, so it must not
231
+ * be left to a per-call judgement. Measured: with the same evidence, one run judged that failure
232
+ * "a denial" and requested a wider tier, another judged it "environmental" and refused -- a
233
+ * decision that flips is not a decision.
234
+ *
235
+ * It is a DEPLOYMENT value, not a runtime one: it is read when the spec is generated, and a
236
+ * change governs the next arming. Set `workspace-write` for a worker that only runs confined
237
+ * work; unknown values are refused when the spec is built rather than silently coerced.
238
+ */
239
+ workerSessionMode: Schema.string().default('danger-full-access'),
240
+ /**
241
+ * The model this machine's ACP sessions run with, as `provider/model`.
242
+ *
243
+ * WHY IT IS A DEPLOYMENT VALUE AND NOT A PER-CALL PARAMETER: `dsh-acp` advertises
244
+ * `promptCapabilities.image` at `initialize` from the CONFIGURED route, and fixes it for the
245
+ * connection. A per-session `session/set_config_option` changes the route but not the
246
+ * advertised capability, so images stay refused. A wrong or missing id therefore disables
247
+ * image input silently -- both for prompts the master sends and for `read_image` inside the
248
+ * worker's own agent (DESIGN 14.1).
249
+ *
250
+ * Empty means "whatever the `acp` profile configures" (the pre-0.2.24 behaviour). A value here
251
+ * is applied by a plugin-owned `--patch` overlay at launch, so the operator's profile is not
252
+ * touched; see `core/acp-model-overlay.js`.
253
+ */
254
+ workerModel: Schema.string().default(''),
255
+ /**
256
+ * The largest single file the transfer tools will move, in either direction.
257
+ *
258
+ * WHY THERE ARE TWO CEILINGS: this one is the HOST's policy and it is checked BEFORE anything is
259
+ * sent (a `stat`/`HEAD` pre-flight), so an oversized transfer is refused early instead of eating
260
+ * a disk first. The relay carries its own hard `--max-transfer-bytes`, because this value lives in
261
+ * the master's config and a worker must not depend on a remote machine's politeness.
262
+ *
263
+ * DELIBERATELY NOT ON THE SETTINGS PAGE. Every page-editable key has to be registered in four
264
+ * places (schema, save route, MANAGED_KEYS, currentSettings) and each omission fails silently --
265
+ * see PITFALLS F37. These are deployment constants an operator sets once in YAML, so they stay
266
+ * out of that blast radius.
267
+ */
268
+ transferMaxBytes: Schema.number().default(8 * 1024 * 1024 * 1024),
269
+ /**
270
+ * How much of a file travels per HTTP request, and therefore the resume granularity.
271
+ *
272
+ * 4 MiB is the compromise: large enough that a 1 GiB transfer is ~256 requests rather than
273
+ * thousands, small enough that a dropped connection costs at most one chunk of re-sending.
274
+ */
275
+ transferChunkBytes: Schema.number().default(4 * 1024 * 1024),
276
+ /**
277
+ * Rotate the audit trail once the live file reaches this many bytes.
278
+ *
279
+ * The trail is append-only, so nothing stops it growing; without a cap it grows for as long as
280
+ * the plugin is installed. At the measured ~670 bytes a row this is years of ordinary use.
281
+ * Set 0 (or negative) to disable rotation and keep everything.
282
+ */
283
+ auditMaxBytes: Schema.number().default(8 * 1024 * 1024),
284
+ /**
285
+ * How many rotated audit files to keep beside the live one. The oldest beyond this is deleted.
286
+ * Negative keeps every archive (bounded growth is then the operator's business, not ours).
287
+ */
288
+ auditKeepFiles: Schema.number().default(5),
289
+ /**
290
+ * The division-of-labour policy injected while a session has remote work switched on.
291
+ * Empty string disables the injection entirely (the tools are still gated).
292
+ */
293
+ delegationPolicy: Schema.string().default(DEFAULT_DELEGATION_POLICY),
294
+ })
295
+ : {
296
+ // Rendered as "unsupported" in the Settings page; `normalizeConfig` applies the very
297
+ // same defaults, so behaviour is identical for a patch-configured install.
298
+ [SCHEMA_UNAVAILABLE]: true,
299
+ workers: [],
300
+ approvalTimeoutMs: 180_000,
301
+ requiredTier: 'workspace-write',
302
+ toolPrefix: 'subagent_remote',
303
+ injectTaskSummary: true,
304
+ heartbeatMs: 15_000,
305
+ reportBudgetMs: 4_000,
306
+ selfcheckBudgetMs: 25_000,
307
+ keepShots: 20,
308
+ // No byte budget by default -- see the schema note: a ceiling that appeared on upgrade would
309
+ // delete captures nobody agreed to lose.
310
+ shotCacheBytes: 0,
311
+ // The deployment default for a remote session, and the reason is in the schema above: the GUI
312
+ // failure it prevents is silent, so it cannot be left to a per-call decision.
313
+ workerSessionMode: 'danger-full-access',
314
+ // Empty means "leave the acp profile's model alone" -- the pre-0.2.24 behaviour.
315
+ workerModel: '',
316
+ transferMaxBytes: 8 * 1024 * 1024 * 1024,
317
+ transferChunkBytes: 4 * 1024 * 1024,
318
+ auditMaxBytes: 8 * 1024 * 1024,
319
+ auditKeepFiles: 5,
320
+ delegationPolicy: DEFAULT_DELEGATION_POLICY,
321
+ _defaults: WORKER_DEFAULTS,
322
+ }
323
+
324
+ /**
325
+ * Plugin body.
326
+ *
327
+ * Every registration goes through `ctx.effect`/`ctx.on` so disabling the plugin
328
+ * releases all of it, including the subagent providers and the tool rows.
329
+ *
330
+ * @param {object} ctx
331
+ * @param {ReturnType<typeof Config>} config
332
+ */
333
+ export function apply(ctx, config) {
334
+ const log = makeLogger(ctx)
335
+ // The settings file is the page's storage and takes precedence over the profile patch for
336
+ // the keys it contains. Absent (the usual case) it changes nothing.
337
+ const stored = readSettings()
338
+ if (stored.error !== '') log.warn(`settings file ignored: ${stored.error}`)
339
+ const merged = mergeSettings(normalizeConfig(config), stored.settings)
340
+ let cfg = merged.config
341
+ if (stored.settings !== null) {
342
+ log.info(`settings file applies: ${Object.entries(merged.sources).filter(([, s]) => s === 'settings').map(([k]) => k).join(', ') || 'nothing'}`)
343
+ }
344
+ const registry = new TaskRegistry({})
345
+
346
+ // A single shared cache keyed by `${workerId}:${toolCallId}`; approvals arrive with
347
+ // only a tool call id, so this is the ONLY source of a tool's name and arguments
348
+ // (DESIGN §10.0.1).
349
+ const cache = new ToolCallCache({ maxEntries: 1024 })
350
+ /** @type {Map<string, object>} */
351
+ const peers = new Map()
352
+ /**
353
+ * Per-worker teardown, so the page can re-pair without a restart.
354
+ *
355
+ * `ctx.effect` returns its own disposer, so wiring can be undone on demand rather than
356
+ * only when the fiber dies. Before this existed, changing a worker meant editing YAML and
357
+ * restarting DSH -- which is exactly the friction the settings page is meant to remove.
358
+ * @type {Map<string, () => unknown>}
359
+ */
360
+ const unwire = new Map()
361
+
362
+ /**
363
+ * The audit trail and the human-approval queue are PLUGIN-level, not per worker.
364
+ *
365
+ * A request waiting for a person concerns the plugin as a whole, and a trail that began
366
+ * afresh for each worker would not be a trail. Both outlive `reconfigure`, which only ever
367
+ * diffs connections.
368
+ */
369
+ const audit = new AuditLog({
370
+ log: (level, message) => log[level](message),
371
+ // Bounded on purpose: an append-only file has no natural stopping point, so an uncapped trail
372
+ // grows for as long as the plugin is installed (see `auditMaxBytes`).
373
+ maxBytes: cfg.auditMaxBytes,
374
+ keepFiles: cfg.auditKeepFiles,
375
+ })
376
+ const queue = new ApprovalQueue({
377
+ // Settle just before the bridge's own timeout, so the outcome the queue records is the
378
+ // one the worker actually received. If the queue were the slower of the two, the bridge
379
+ // would reject first and the page would keep a ghost entry nobody could act on.
380
+ timeoutMs: Math.max(1_000, cfg.approvalTimeoutMs - QUEUE_LEAD_MS),
381
+ onSettle: (outcome) => {
382
+ // `gate` says WHICH gate handled it; `by` says who was behind it. They differ for a
383
+ // timeout: nobody answered, so calling that a human gate is how a silent refusal read as a
384
+ // human decision in the trail.
385
+ const noAnswer = outcome.by === 'timeout' || outcome.by === 'disposed' || outcome.by === 'no-answerer'
386
+ audit.record({ kind: 'approval', gate: noAnswer ? 'timeout' : 'human', ...outcome })
387
+ log.info(`approval ${outcome.toolCallId} tool=${outcome.toolName} `
388
+ + `required=${outcome.requiredTier} -> ${outcome.optionId} (gate=${noAnswer ? 'timeout' : 'human'} by=${outcome.by}) `
389
+ + `waited=${outcome.waitedMs}ms`)
390
+ },
391
+ })
392
+ // Refuse anything still waiting when the plugin unloads, rather than leaving the worker
393
+ // blocked until its bridge timeout.
394
+ ctx.effect(() => () => queue.dispose())
395
+
396
+ /**
397
+ * The worker role: this machine accepting remote execution.
398
+ *
399
+ * Created here rather than lazily because the page must be able to REPORT the state on first
400
+ * load. An operator who cannot see whether a listener is open has no way to tell whether they
401
+ * left one open.
402
+ *
403
+ * The supervisor is handed in as `start`/`stop` so the state machine owns the decisions and the
404
+ * listener owns none -- see `core/worker-arming.js`.
405
+ */
406
+ const acpProfile = join(resolveDshHome(), 'profiles', 'acp', 'cordis.patch.yml')
407
+ /**
408
+ * Read-only diagnosis of the sandbox mediator, computed on demand.
409
+ *
410
+ * WHY IT IS SEPARATE FROM THE PROBE, AND WHY IT RUNS AT PAGE LOAD: the probe answers "does
411
+ * confinement work", but it only runs when somebody clicks Check -- so a machine whose mediator
412
+ * directory had been removed kept showing nothing at all, and was discovered only when arming
413
+ * was attempted (measured 2026-11-04, PITFALLS F44). This answers the cheaper question
414
+ * ("are the artifacts even there") on every page load and hands back the exact command that
415
+ * fixes it. It writes nothing and spawns nothing: the fix has to be run by a person or an agent,
416
+ * because it writes outside this plugin's own state.
417
+ */
418
+ const sandboxReport = () => diagnoseSandbox({ env: process.env })
419
+ /**
420
+ * The worker-model overlay, written on demand and passed to the ACP child as `--patch`.
421
+ *
422
+ * Written when the launch spec is built (arm or restore), so the value that reaches the child is
423
+ * the one on disk at that moment. A failure to write THROWS: launching without the requested
424
+ * model would silently turn image capability off, which is the defect this exists to prevent.
425
+ */
426
+ const modelPatchFiles = () => {
427
+ const parsed = parseWorkerModel(cfg.workerModel)
428
+ if (parsed.ok !== true) {
429
+ throw new Error(`workerModel "${cfg.workerModel}" is not "provider/model": ${parsed.reason}`)
430
+ }
431
+ if (parsed.provider === '') return []
432
+ const written = writeWorkerModelOverlay({ home: resolveDshHome(), value: cfg.workerModel })
433
+ if (written.ok !== true) throw new Error(`could not write the worker model overlay: ${written.reason}`)
434
+ return [written.path]
435
+ }
436
+ /**
437
+ * Validate the configured model against this machine's catalogue, before arming.
438
+ *
439
+ * Resolving the id here turns a typo from "images silently off" into a refusal. The catalogue
440
+ * lives in `llm`; when that service is absent (a stripped deployment) the value is accepted with
441
+ * a warning rather than refused, because the plugin cannot verify it and the ACP child will
442
+ * still apply it.
443
+ *
444
+ * @returns {Promise<{ ok: boolean, warning: string, error: string }>}
445
+ */
446
+ const validateWorkerModel = async () => {
447
+ const parsed = parseWorkerModel(cfg.workerModel)
448
+ if (parsed.ok !== true) {
449
+ return { ok: false, warning: '', error: `workerModel "${cfg.workerModel}" is not "provider/model": ${parsed.reason}` }
450
+ }
451
+ if (parsed.provider === '') return { ok: true, warning: '', error: '' }
452
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : undefined
453
+ if (llm === undefined || typeof llm.resolveModelInfo !== 'function') {
454
+ return { ok: true, warning: 'the model catalogue is not available here, so the model id was not verified', error: '' }
455
+ }
456
+ try {
457
+ const info = await llm.resolveModelInfo(parsed.provider, parsed.model)
458
+ const images = Array.isArray(info?.inputModalities) && info.inputModalities.includes('image')
459
+ return images
460
+ ? { ok: true, warning: '', error: '' }
461
+ : { ok: true, warning: `model ${parsed.provider}/${parsed.model} does not declare image input; the master cannot send images to this worker`, error: '' }
462
+ } catch (error) {
463
+ return {
464
+ ok: false,
465
+ warning: '',
466
+ error: `the model ${parsed.provider}/${parsed.model} could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
467
+ }
468
+ }
469
+ }
470
+ /**
471
+ * Run the worker's OWN readiness self-check, here, on the worker.
472
+ *
473
+ * WHY THE WORKER RUNS IT: S1-S12 are facts about THIS machine's session, display and input; the
474
+ * master can only read the report. Running it in the plugin host means the result reflects the
475
+ * same interactive session the relay uses, and the operator gets a machine-specific fix prompt
476
+ * without a round trip.
477
+ *
478
+ * WHY IT IS NOT AN ARMING GATE: the session checks depend on somebody being logged in and
479
+ * unlocked -- an operational state, not a fault -- and S8 moves the mouse. So it runs only when
480
+ * the page asks for it (the "先检查" button) and it is ADVISORY; the master's dispatch gate
481
+ * remains the hard gate.
482
+ */
483
+ const selfcheckScript = fileURLToPath(new URL('../slave/selfcheck.ps1', import.meta.url))
484
+ let lastReadiness = null
485
+ const runReadinessCheck = async ({ taskCwd } = {}) => {
486
+ const workspace = String(taskCwd ?? '').trim()
487
+ const run = await runSelfcheck({ scriptPath: selfcheckScript, workspace })
488
+ const prompt = buildEnvironmentPrompt({
489
+ report: run.ok === true ? run.report : null,
490
+ reason: run.ok === true ? '' : run.reason,
491
+ facts: {
492
+ scriptPath: selfcheckScript,
493
+ home: resolveDshHome(),
494
+ taskCwd: workspace,
495
+ pluginDir: dirname(dirname(selfcheckScript)),
496
+ },
497
+ })
498
+ if (run.ok !== true) return { ok: false, reason: run.reason, prompt }
499
+ return { ok: true, ...summarizeReport(run.report), prompt }
500
+ }
501
+ const supervisor = new RelaySupervisor({
502
+ log: { info: (m) => log.info(m), warn: (m) => log.warn(m), error: (m) => log.error(m) },
503
+ // Read at start time, so a change on the settings page governs the next arming.
504
+ keepShots: () => cfg.keepShots,
505
+ // The byte budget travels too, so the WORKER's own shot directory is bounded the same way the
506
+ // host's is. Without this the page's threshold would govern only the host's copies, and the
507
+ // larger accumulation -- the one on the worker, which nothing else ever swept -- would be the
508
+ // one the operator could not control. That asymmetry is exactly what the 218 MB incident was.
509
+ shotCacheBytes: () => cfg.shotCacheBytes,
510
+ launchSpec: () => resolveAcpLaunchSpec({ profile: 'acp', permissionMode: cfg.workerSessionMode, patchFiles: modelPatchFiles() }),
511
+ // The task directory is NOT read through a closure here. `WorkerArming` passes it explicitly
512
+ // to `start()`, because a restore calls `start()` while `arming` is still being constructed --
513
+ // a closure referring to it would throw "Cannot access 'arming' before initialization". The
514
+ // value is still the SAME one the pairing string advertises, so the master and the relay cannot
515
+ // disagree about it.
516
+ })
517
+ const arming = new WorkerArming({
518
+ interfaces: () => networkInterfaces(),
519
+ // The WORKER runs under the acp profile, so that is the profile whose sandbox has to
520
+ // contain -- not the desktop one the interactive session uses. Checking the wrong file
521
+ // would pass on a machine whose remote execution is unconfined.
522
+ probe: () => probeSandbox({ profile: acpProfile }),
523
+ start: (bindAddress, port, token, taskCwd) => supervisor.start(bindAddress, port, token, taskCwd),
524
+ stop: () => supervisor.stop(),
525
+ // The page shows the configured mode beside the mode the RUNNING listener was started with.
526
+ // Without this the two can disagree with no surface saying so -- the defect this change fixes.
527
+ listenerStatus: () => supervisor.status(),
528
+ // Persisted through the same settings path as everything else, so it survives a restart and
529
+ // appears in the documented precedence order.
530
+ persist: (state) => { persistSettings({ ...currentSettings(), workerRole: state }) },
531
+ log: { info: (m) => log.info(m), warn: (m) => log.warn(m) },
532
+ stored: cfg.workerRole,
533
+ })
534
+ // Stop the listener on unload WITHOUT persisting: a teardown write would clear the stored
535
+ // credential and silently invalidate a pairing the operator had already distributed.
536
+ ctx.effect(() => () => { supervisor.stop() })
537
+
538
+ /**
539
+ * Configured versus effective session mode, for the page.
540
+ *
541
+ * `workerSessionMode` is a deployment value: the spec is written when the worker role is armed.
542
+ * Changing it while a listener runs therefore does NOT change that listener, and the old page only
543
+ * showed the configured value -- so "I set it to full access" and "it is still running
544
+ * workspace-write" looked identical from here (PITFALLS F38/F41).
545
+ */
546
+ function workerSessionStatus() {
547
+ const listener = supervisor.status()
548
+ const running = listener.running === true
549
+ const rawEffective = running && typeof listener.sessionMode === 'string' ? listener.sessionMode : ''
550
+ // An empty spec value means dsh-base's own default. Resolving it here is what lets a
551
+ // `workspace-write` setting match a listener whose spec omits the variable.
552
+ const effective = running ? (rawEffective === '' ? 'workspace-write' : rawEffective) : ''
553
+ const configured = cfg.workerSessionMode
554
+ return {
555
+ configured,
556
+ effective,
557
+ effectiveFrom: running ? listener.sessionModeFrom : 'nothing',
558
+ running,
559
+ armed: arming.status().armed,
560
+ // A stopped listener is not stale: the next arming builds a fresh spec from the setting.
561
+ reArmRequired: running && effective !== configured,
562
+ settingsFile: stored.file,
563
+ }
564
+ }
565
+
566
+ // Validate our frozen capability mirror against the live package. A mismatch means
567
+ // DSH changed the contract and our advertisement is no longer honest.
568
+ void verifyAgainstUpstream().then((r) => {
569
+ if (!r.ok) {
570
+ log.error('capability mirror does not match @deepseek-ai/dsh-subagent: ' + (r.reason ?? 'unknown')
571
+ + ' -- refusing to advertise capabilities until this is reconciled')
572
+ } else if (r.reason !== undefined) {
573
+ log.info('capability mirror not verified: ' + r.reason)
574
+ }
575
+ })
576
+
577
+ // ---- per-worker wiring -------------------------------------------------
578
+ /**
579
+ * Bring one worker online: build its connection, publish its runtime, and register its
580
+ * subagent provider. Undone by the disposer recorded in `unwire`.
581
+ */
582
+ function wire(worker) {
583
+ const peerId = worker.id
584
+ /**
585
+ * Task memory is PER WORKER, not shared.
586
+ *
587
+ * It used to be one map keyed by taskId whose teardown did `memories.clear()`. That was
588
+ * harmless only because a worker was never removed at runtime; with live re-pairing it
589
+ * would wipe every OTHER worker's approval memory, making the review engine forget
590
+ * decisions it had already made. Per-worker ownership removes the footgun structurally.
591
+ * @type {Map<string, TaskMemory>}
592
+ */
593
+ const memories = new Map()
594
+
595
+ // The connection is LAZY: nothing connects until something needs the worker. An
596
+ // eager connect at load time would fail whenever the worker is off or still booting,
597
+ // and there is no useful moment to retry from. PeerConnection owns the bridge's
598
+ // lifecycle so a dead link is rebuilt rather than reused.
599
+ const connection = new PeerConnection({
600
+ peerId,
601
+ log: (level, message) => log[level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info'](message),
602
+ // Binary payloads (screenshots) come from the relay's HTTP side, not through the ACP
603
+ // message pump. Derived from the same URL so there is one thing to configure.
604
+ httpBase: httpBaseOf(worker.url),
605
+ token: worker.token,
606
+ // Probe the link periodically so a relay restart is noticed between calls instead of
607
+ // failing the next one. 0 disables probing.
608
+ heartbeatMs: cfg.heartbeatMs,
609
+ keepShots: cfg.keepShots,
610
+ // The host-side byte ceiling. Captured at construction like `keepShots`, which is why
611
+ // `policyStamp` includes it: a connection that kept the old ceiling would enforce a budget
612
+ // the page no longer shows.
613
+ shotCacheBytes: cfg.shotCacheBytes,
614
+ // The two transfer ceilings go to the CONNECTION as well as to the tool. The tool passes them
615
+ // per call, so a direct `connection.sendFile` (or any future caller) would otherwise fall back
616
+ // to the connection's own defaults and quietly ignore what the operator configured -- a setting
617
+ // honoured on one path and not the other is the F32 shape.
618
+ transferMaxBytes: cfg.transferMaxBytes,
619
+ transferChunkBytes: cfg.transferChunkBytes,
620
+ createBridge: () => new AcpBridge({
621
+ url: worker.url,
622
+ token: worker.token,
623
+ approvalTimeoutMs: cfg.approvalTimeoutMs,
624
+ // Explicit mapping rather than passing the event payload straight through: the
625
+ // cache's contract keys off `title` (which IS the tool name in DSH), and a shape
626
+ // mismatch here would silently cache `toolName: undefined`, which in turn makes
627
+ // `allowedTools` never match and leaves the audit trail without a tool name.
628
+ onUpdate: (evt) => {
629
+ if (evt.tool === undefined) return
630
+ const entry = { title: evt.tool.toolName, rawInput: evt.tool.rawInput, status: evt.tool.status }
631
+ if (evt.kind === 'tool_call') cache.note(evt.tool.toolCallId, entry)
632
+ else if (evt.kind === 'tool_call_update') cache.update(evt.tool.toolCallId, entry)
633
+ },
634
+ // Constructor-level fallback. `RemoteProvider` passes a per-task decider to
635
+ // `startTask`, and that one wins when present; this keeps the bridge honest if some
636
+ // other caller uses the bridge without one.
637
+ //
638
+ // ⚠️ The decider is ADAPTED, never passed through. It returns a rich decision
639
+ // ({optionId, source, reason, needsHuman}) because the provider wants the extra
640
+ // fields for its audit line, while `AcpBridge`'s contract is
641
+ // `Promise<'allow-once'|'reject-once'>`. Handing the decider over directly made
642
+ // `normalizeSelected` compare an OBJECT against the string 'allow-once' -- never
643
+ // true -- so this path silently rejected everything. Fail closed, but wrong, and it
644
+ // left `allow-once` unreachable for any caller that did not come through the
645
+ // provider, which is the only path the end-to-end security tests exercise.
646
+ onApproval: asBridgeOption(makeApprovalDecider({ cfg, worker, memories, log, queue, audit })),
647
+ }),
648
+ })
649
+
650
+ const workerRuntime = {
651
+ peerId,
652
+ name: worker.name === '' ? peerId : worker.name,
653
+ // The provider and the tools only use startTask/cancel/selfcheck/dispose/connected,
654
+ // all of which PeerConnection implements, so they cannot tell it is not a bridge.
655
+ bridge: connection,
656
+ connection,
657
+ defaultCwd: worker.cwd,
658
+ // Echoed back so `reconfigure` can tell whether a live worker still matches its
659
+ // configuration. `PeerConnection` captures these at construction, so a change to any
660
+ // of them requires tearing the connection down rather than mutating it.
661
+ url: worker.url,
662
+ token: worker.token,
663
+ cwd: worker.cwd,
664
+ serialQueue: worker.serialQueue,
665
+ /**
666
+ * Ask the worker for a fresh readiness report. Falls back to a synthesised
667
+ * "unreachable" report so the gate refuses instead of letting dispatch through.
668
+ */
669
+ async selfcheck() {
670
+ try {
671
+ return await connection.selfcheck()
672
+ } catch (error) {
673
+ // `sessionSegment` is about the WORKER'S SESSION, not about reachability. Saying
674
+ // 'unavailable' here made the dispatch gate report "nobody is logged in" whenever
675
+ // the relay was merely down -- a wrong diagnosis that sends the operator to check
676
+ // the login state instead of the link. 'unknown' is honest: the check never ran,
677
+ // so the blocking S0 failure below is what should decide the gate.
678
+ return {
679
+ verdict: 'blocked',
680
+ sessionSegment: 'unknown',
681
+ blockingFailures: 1,
682
+ warnings: 0,
683
+ unchecked: 0,
684
+ checks: [{
685
+ id: 'S0', name: 'worker reachable', severity: 'blocking', status: 'fail',
686
+ expected: 'relay responds', actual: limitDiagnostic(error instanceof Error ? error.message : String(error)),
687
+ fix: 'check the worker is powered on, the relay is running, and the token matches',
688
+ }],
689
+ }
690
+ }
691
+ },
692
+ }
693
+ peers.set(peerId, workerRuntime)
694
+
695
+ // The provider is captured per worker, but `decideApproval` closes over the LIVE `cfg`,
696
+ // so a settings change that alters `requiredTier` takes effect on the next decision
697
+ // without re-wiring anything.
698
+ const dispose = ctx.effect(() => {
699
+ const decider = makeApprovalDecider({ cfg, worker, memories, log, queue, audit })
700
+ const provider = new RemoteProvider({
701
+ peer: workerRuntime,
702
+ registry,
703
+ decideApproval: decider,
704
+ serialQueue: worker.serialQueue,
705
+ workspaceRoot: worker.cwd,
706
+ })
707
+ const disposeProvider = ctx.subagents.registerProvider(provider)
708
+ log.info(`registered subagent provider "${provider.name}" -> ${worker.url}`)
709
+
710
+ // Hand the SAME provider instance to the model-facing tools.
711
+ //
712
+ // `remote_subagent` (lib/tools/remote-tools.js) has to reach the delegation path, and the
713
+ // provider is where that path lives: it owns the readiness gate, the serial queue, the
714
+ // TaskRegistry entry and result settling. Building a second one would duplicate the queue and
715
+ // bypass the gate. The tools read it off the peer runtime they already receive.
716
+ workerRuntime.provider = provider
717
+
718
+ ctx.effect(() => () => { void connection.dispose() }, 'remote-use: connection teardown')
719
+ return () => {
720
+ // Clear it FIRST, so a tool called during teardown reports "not ready" instead of calling
721
+ // into a provider whose registration has just been withdrawn.
722
+ if (workerRuntime.provider === provider) delete workerRuntime.provider
723
+ disposeProvider()
724
+ // Only THIS worker's memory: see the note on `memories` in `wire`.
725
+ for (const m of memories.values()) m.dispose()
726
+ memories.clear()
727
+ log.info(`unregistered subagent provider "${provider.name}"`)
728
+ }
729
+ }, `remote-use: provider ${peerId}`)
730
+
731
+ unwire.set(peerId, dispose)
732
+ }
733
+
734
+ /** Take one worker offline: dispose its provider, its memory, and its connection. */
735
+ function unwireWorker(peerId) {
736
+ const dispose = unwire.get(peerId)
737
+ unwire.delete(peerId)
738
+ peers.delete(peerId)
739
+ if (dispose === undefined) return false
740
+ dispose()
741
+ return true
742
+ }
743
+
744
+ /**
745
+ * Make the live wiring match `workers`.
746
+ *
747
+ * Diffed by id, and a worker whose connection details changed is torn down and rebuilt:
748
+ * `PeerConnection` captures its url, token and httpBase at construction, so editing any
749
+ * of them cannot be applied to a running instance.
750
+ *
751
+ * @param {object[]} workers the desired list
752
+ * @returns {{ added: string[], removed: string[], changed: string[] }}
753
+ */
754
+ function reconfigure(workers) {
755
+ const desired = new Map()
756
+ for (const worker of workers) {
757
+ if (worker?.enabled === false) continue
758
+ if (typeof worker?.id === 'string' && worker.id !== '') desired.set(worker.id, worker)
759
+ }
760
+
761
+ const removed = []
762
+ for (const peerId of [...unwire.keys()]) {
763
+ if (!desired.has(peerId)) { unwireWorker(peerId); removed.push(peerId) }
764
+ }
765
+
766
+ const added = []
767
+ const changed = []
768
+ for (const [peerId, worker] of desired) {
769
+ const existing = peers.get(peerId)
770
+ if (existing === undefined) {
771
+ wire(worker)
772
+ added.push(peerId)
773
+ continue
774
+ }
775
+ // Compare the fields `PeerConnection` and `RemoteProvider` captured at construction.
776
+ const differs = existing.url !== worker.url
777
+ || existing.token !== worker.token
778
+ || existing.cwd !== worker.cwd
779
+ || existing.serialQueue !== worker.serialQueue
780
+ || existing.name !== worker.name
781
+ if (differs) {
782
+ unwireWorker(peerId)
783
+ wire(worker)
784
+ changed.push(peerId)
785
+ }
786
+ }
787
+ return { added, removed, changed }
788
+ }
789
+
790
+ // Bring up whatever the merged configuration asks for.
791
+ reconfigure(cfg.workers)
792
+
793
+ // ---- page-facing configuration control ---------------------------------
794
+ //
795
+ // Everything the Settings page may change, plus the persistence and live-apply that make
796
+ // a change take effect without editing YAML and restarting DSH.
797
+
798
+ /** The live configuration's managed subset, i.e. exactly what gets persisted. */
799
+ function currentSettings() {
800
+ return {
801
+ workers: cfg.workers.map((w) => ({ ...w })),
802
+ requiredTier: cfg.requiredTier,
803
+ approvalTimeoutMs: cfg.approvalTimeoutMs,
804
+ heartbeatMs: cfg.heartbeatMs,
805
+ keepShots: cfg.keepShots,
806
+ // Sits beside `keepShots` because they are the two halves of one bound. This is the line the
807
+ // generic guard exists for: it was briefly missing here while present in the schema, the save
808
+ // route and `publicSettings`, which is exactly the shape that saves correctly and reverts.
809
+ shotCacheBytes: cfg.shotCacheBytes,
810
+ // MUST be listed here. A key the route can SAVE but this function omits is written to
811
+ // disk and then read back as `undefined`, so the page shows the default again -- the
812
+ // setting appears to work once and silently revert. Caught by the round-trip test.
813
+ //
814
+ // RE-CAUGHT 2026-10-15: `auditMaxBytes` and `auditKeepFiles` reached the schema, the save
815
+ // route and the form, but NOT this list -- so both silently reverted for a whole release,
816
+ // and no round-trip test existed for them to notice. All three are covered by one now.
817
+ auditMaxBytes: cfg.auditMaxBytes,
818
+ auditKeepFiles: cfg.auditKeepFiles,
819
+ workerSessionMode: cfg.workerSessionMode,
820
+ // Same rule as the mode above: a key the save route accepts but this projection omits is
821
+ // written to disk and read back as `undefined`, so the page would show the default again.
822
+ workerModel: cfg.workerModel,
823
+ delegationPolicy: cfg.delegationPolicy,
824
+ // Same rule: `WorkerArming.persist` writes this through `persistSettings`, so omitting it
825
+ // here would make arming appear to succeed and then vanish on the next read.
826
+ workerRole: cfg.workerRole,
827
+ }
828
+ }
829
+
830
+ /**
831
+ * The policy fields a running worker captured at construction.
832
+ *
833
+ * A change to any of these needs a REBUILD rather than a mutation: `PeerConnection` reads
834
+ * `heartbeatMs` and `keepShots` once, and the approval decider captures the tier policy once.
835
+ * Omitting one here means the page accepts the edit and nothing happens until a restart.
836
+ */
837
+ function policyStamp(settings) {
838
+ // `keepShots` and `shotCacheBytes` are in the stamp even though the page's threshold controls
839
+ // apply live: a rebuild is harmless here and it keeps "the page accepted the edit" and "the
840
+ // running connections agree" from being able to drift apart.
841
+ return `${settings.requiredTier}|${settings.approvalTimeoutMs}|${settings.heartbeatMs}|${settings.keepShots}|${settings.shotCacheBytes}`
842
+ }
843
+
844
+ /**
845
+ * Persist a full settings object and make the live wiring match it.
846
+ *
847
+ * A change to the approval policy or the heartbeat interval REBUILDS every worker, because
848
+ * both are captured when a provider or connection is constructed. Rebuilding also discards
849
+ * remembered approvals, which is the behaviour we want: a decision made under the old
850
+ * policy must not silently carry into the new one.
851
+ *
852
+ * @returns {{ ok: boolean, error?: string, added?: string[], removed?: string[], changed?: string[], rebuilt?: boolean }}
853
+ */
854
+ function persistSettings(settings) {
855
+ const normalized = normalizeConfig(settings)
856
+ const written = writeSettings(normalized)
857
+ if (!written.ok) return { ok: false, error: `could not write ${written.file}: ${written.error}` }
858
+
859
+ const before = policyStamp(cfg)
860
+ cfg = mergeSettings(cfg, normalized).config
861
+
862
+ if (policyStamp(cfg) !== before) {
863
+ for (const peerId of [...unwire.keys()]) unwireWorker(peerId)
864
+ const diff = reconfigure(cfg.workers)
865
+ log.info(`settings saved: policy changed, rebuilt every worker (${diff.added.length} added, ${diff.removed.length} removed)`)
866
+ return { ok: true, ...diff, rebuilt: true }
867
+ }
868
+
869
+ const diff = reconfigure(cfg.workers)
870
+ log.info(`settings saved: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} rebuilt`)
871
+ return { ok: true, ...diff, rebuilt: false }
872
+ }
873
+
874
+ // ---- model-facing tools ------------------------------------------------
875
+ // The tools are registered UNCONDITIONALLY. They used to be skipped when there were no
876
+ // workers, which was fine while the worker list was fixed at load time -- but the page can
877
+ // now add the first worker at runtime, and a session that started with none would then have
878
+ // no tools to drive it with.
879
+ for (const def of makeRemoteTools({
880
+ registry,
881
+ peers,
882
+ audit,
883
+ // The two ceilings are read ONCE here, like `keepShots`: a transfer that started under one policy
884
+ // should not change its mind halfway through because the config was reloaded.
885
+ transfer: { maxBytes: cfg.transferMaxBytes, chunkBytes: cfg.transferChunkBytes },
886
+ })) {
887
+ ctx.effect(() => ctx.tools.register(def), `remote-use: tool ${def.name}`)
888
+ }
889
+
890
+ // ---- per-session enablement (default OFF) ------------------------------
891
+ //
892
+ // Installing the plugin must not route every session through a worker. The tools exist
893
+ // globally, but each session starts with them ANDDENIED in its own scope; `/remote-use on`
894
+ // lifts that for the session that asked.
895
+ //
896
+ // WHY THE RESTRICTION IS PER-AGENT AND NOT GLOBAL: `tools.restrict()` refuses a
897
+ // context-global call outright -- "a context-global restriction would mask every agent".
898
+ // So the only correct shape is to deny inside each agent's own scope, which also means the
899
+ // restriction is disposed with the agent rather than leaking across sessions.
900
+ /** Agents the operator switched on. ABSENT means off, which is the default. */
901
+ const enabledAgents = new Set()
902
+ /** agent -> the disposer that lifts its restriction. Present means "currently denied". */
903
+ const restrictions = new Map()
904
+
905
+ /** Hide the remote tools from one session's model. Idempotent. */
906
+ function denyFor(agent) {
907
+ if (agent === undefined || restrictions.has(agent)) return
908
+ try {
909
+ // `agent.ctx` scopes the effect, so the restriction lives and dies with the agent.
910
+ restrictions.set(agent, agent.ctx.tools.restrict({ deny: [...REMOTE_TOOL_NAMES] }))
911
+ } catch (error) {
912
+ // Never let this abort plugin startup. The guard below is the backstop that still
913
+ // refuses execution, so a failed restriction degrades to "visible but unusable"
914
+ // rather than to "silently allowed".
915
+ log.warn(`could not restrict remote tools for one session (${error instanceof Error ? error.message : String(error)}); the execution guard still applies`)
916
+ }
917
+ }
918
+
919
+ /** Show them again for one session. */
920
+ function allowFor(agent) {
921
+ const lift = restrictions.get(agent)
922
+ restrictions.delete(agent)
923
+ if (typeof lift === 'function') lift()
924
+ }
925
+
926
+ ctx.on('agent/created', ({ agent } = {}) => {
927
+ if (agent === undefined) return
928
+ if (!enabledAgents.has(agent)) denyFor(agent)
929
+ })
930
+
931
+ // FAIL-CLOSED BACKSTOP.
932
+ //
933
+ // The restriction above is applied when an agent is created, so an agent that already
934
+ // existed when this plugin was applied -- a hot reload, or a config change -- would keep
935
+ // the tools visible. `dsh-tools` exposes no way to enumerate live agents, so that case
936
+ // cannot be closed by iterating. A plain-context guard applies to EVERY agent instead, and
937
+ // refuses execution unless the session is switched on. The tool list can then be wrong
938
+ // without the consequence being wrong.
939
+ ctx.effect(() => ctx.tools.guard((exec) => {
940
+ if (!REMOTE_TOOL_NAMES.includes(exec?.name)) return undefined
941
+ const agent = exec?.agent
942
+ if (agent !== undefined && enabledAgents.has(agent)) return undefined
943
+ return `remote-use is off for this session: run /${ENABLE_COMMAND} on to route work to a LAN worker.`
944
+ }), 'remote-use: enablement guard')
945
+
946
+ // ---- the switch itself -------------------------------------------------
947
+ ctx.inject(['commands'], (child) => {
948
+ const handler = (invocation) => {
949
+ const agent = invocation?.agent
950
+ const argument = String(invocation?.rawInput ?? '').trim().toLowerCase()
951
+
952
+ if (argument === '' || argument === 'status') {
953
+ const state = agent !== undefined && enabledAgents.has(agent) ? 'on' : 'off'
954
+ return {
955
+ kind: 'success',
956
+ text: `remote-use is ${state} for this session.`
957
+ + (state === 'off'
958
+ ? ` Nothing routes to a worker and the remote tools are hidden. Use /${ENABLE_COMMAND} on to enable them here.`
959
+ : ' Work needing a real desktop is delegated; code, builds and repository work stay here.'),
960
+ }
961
+ }
962
+ if (agent === undefined) {
963
+ return { kind: 'error', text: `/${ENABLE_COMMAND}: this invocation carries no session, so the switch cannot be scoped to it.` }
964
+ }
965
+ if (argument === 'on') {
966
+ enabledAgents.add(agent)
967
+ allowFor(agent)
968
+ return {
969
+ kind: 'success',
970
+ text: `remote-use is on for this session. The remote tools are available and the delegation policy is in effect;`
971
+ + ` code, builds and repository work still belong here. /${ENABLE_COMMAND} off reverts.`,
972
+ }
973
+ }
974
+ if (argument === 'off') {
975
+ enabledAgents.delete(agent)
976
+ denyFor(agent)
977
+ return {
978
+ kind: 'success',
979
+ text: `remote-use is off for this session. The remote tools are hidden and any further call is refused.`,
980
+ }
981
+ }
982
+ return { kind: 'error', text: `/${ENABLE_COMMAND}: unknown argument "${argument}". Use \`on\`, \`off\` or \`status\`.` }
983
+ }
984
+
985
+ child.effect(() => child.commands.register({
986
+ name: ENABLE_COMMAND,
987
+ description: 'Route GUI verification and testing to a paired LAN worker for THIS session (default off)',
988
+ input: { hint: 'on | off | status' },
989
+ handler,
990
+ }), `remote-use: /${ENABLE_COMMAND} command`)
991
+ })
992
+
993
+ // ---- the policy, only while switched on --------------------------------
994
+ //
995
+ // Order places the rule BEFORE the tool guidance it qualifies, matching the rationale
996
+ // dsh-tools gives for its own PTC-only statement. Rendering '' outside `on` means the
997
+ // section is absent rather than empty when the session has not opted in.
998
+ if (cfg.delegationPolicy !== '') {
999
+ ctx.effect(() => ctx.systemPrompt.section({
1000
+ name: 'remote-use:policy',
1001
+ order: ctx.systemPrompt.getSectionOrder('TEAM_POLICY'),
1002
+ text: (context) => {
1003
+ const agent = context?.agent ?? context?.scope
1004
+ return agent !== undefined && enabledAgents.has(agent) ? cfg.delegationPolicy : ''
1005
+ },
1006
+ }), 'remote-use: delegation policy section')
1007
+ }
1008
+
1009
+ // ---- runtime context: stop the model from forgetting remote work --------
1010
+ if (cfg.injectTaskSummary) {
1011
+ ctx.effect(() => ctx.systemPrompt.section({
1012
+ name: 'remote-use:in-flight',
1013
+ order: ctx.systemPrompt.getSectionOrder('TOOL_SUBAGENT'),
1014
+ // The approval notice rides in the same section as the task list because it is the same
1015
+ // fact from the model's side: something of its own is stuck, and only a person can unstick
1016
+ // it. Left on the settings page alone, a blocked task just looks like a hang.
1017
+ text: () => {
1018
+ const parts = [registry.summary()]
1019
+ if (queue !== undefined) parts.push(approvalNotice(queue.list(), queue.timeoutMs))
1020
+ return parts.filter((part) => part !== '').join('\n\n')
1021
+ },
1022
+ }), 'remote-use: task summary section')
1023
+ }
1024
+
1025
+ // ---- settings data route (availability, diagnostics, pairing) ----------
1026
+ ctx.inject(['webServer'], (child) => {
1027
+ child.effect(() => child.webServer.register(makeSettingsRoute({
1028
+ peers,
1029
+ registry,
1030
+ cache,
1031
+ // The approval queue and its trail are plugin-level: created once for the whole
1032
+ // apply(), not per worker, because a request waiting on a person is not about one
1033
+ // worker and a trail that restarted per worker would not be a trail.
1034
+ queue,
1035
+ audit,
1036
+ sessionsEnabled: () => enabledAgents.size,
1037
+ armingStatus: () => arming.status(),
1038
+ // Read-only, cheap, and needed on the FIRST page load: the mediator can be missing while the
1039
+ // role is simply disarmed, and a page that waits for a click to say so hides a broken worker.
1040
+ sandbox: () => sandboxReport(),
1041
+ // The last environment self-check, and the way the page runs a new one. `readinessCheck` is
1042
+ // awaited only when the request asks for it (the "先检查" button), because S8 moves the
1043
+ // mouse and the session checks cost a process per run.
1044
+ readiness: () => lastReadiness,
1045
+ readinessCheck: (opts) => runReadinessCheck(opts),
1046
+ setReadiness: (value) => { lastReadiness = value },
1047
+ // Resolves the configured worker model against this machine's catalogue. Async because the
1048
+ // catalogue lookup is; the route awaits it before arming, which is what turns a typo into a
1049
+ // refusal instead of "images silently off".
1050
+ validateWorkerModel: () => validateWorkerModel(),
1051
+ armWorker: (request) => arming.arm(request),
1052
+ preflightWorker: (request) => arming.preflight(request),
1053
+ disarmWorker: (opts) => arming.disarm(opts),
1054
+ workerSession: () => workerSessionStatus(),
1055
+ log,
1056
+ currentSettings,
1057
+ persistSettings,
1058
+ settingsPath: stored.file,
1059
+ reportBudgetMs: cfg.reportBudgetMs,
1060
+ selfcheckBudgetMs: cfg.selfcheckBudgetMs,
1061
+ })), 'remote-use: settings route')
1062
+ })
1063
+ }
1064
+
1065
+ // ---------------------------------------------------------------------------
1066
+ // helpers
1067
+ // ---------------------------------------------------------------------------
1068
+
1069
+ /** @param {object} ctx */
1070
+ function makeLogger(ctx) {
1071
+ const emit = (level, message) => {
1072
+ const target = ctx?.logger?.[level]
1073
+ if (typeof target === 'function') target.call(ctx.logger, message)
1074
+ else if (level === 'error' || level === 'warn') console.warn(`[remote-use] ${message}`)
1075
+ else console.log(`[remote-use] ${message}`)
1076
+ }
1077
+ return {
1078
+ info: (m) => emit('info', m),
1079
+ warn: (m) => emit('warn', m),
1080
+ error: (m) => emit('error', m),
1081
+ }
1082
+ }
1083
+
1084
+ /** Fill defaults by hand so unit tests can call this without the schema runtime. */
1085
+ export function normalizeConfig(config) {
1086
+ const workers = Array.isArray(config?.workers) ? config.workers : []
1087
+ return {
1088
+ workers: workers.map((w) => ({
1089
+ id: String(w.id ?? ''),
1090
+ name: String(w.name ?? ''),
1091
+ url: String(w.url ?? ''),
1092
+ token: String(w.token ?? ''),
1093
+ cwd: String(w.cwd ?? ''),
1094
+ enabled: w.enabled !== false,
1095
+ serialQueue: Number.isFinite(w.serialQueue) ? Math.max(1, Number(w.serialQueue)) : 1,
1096
+ })),
1097
+ approvalTimeoutMs: Number.isFinite(config?.approvalTimeoutMs) ? Number(config.approvalTimeoutMs) : 180_000,
1098
+ requiredTier: String(config?.requiredTier ?? 'workspace-write'),
1099
+ toolPrefix: String(config?.toolPrefix ?? 'subagent_remote'),
1100
+ injectTaskSummary: config?.injectTaskSummary !== false,
1101
+ heartbeatMs: Number.isFinite(config?.heartbeatMs) ? Math.max(0, Number(config.heartbeatMs)) : 15_000,
1102
+ reportBudgetMs: Number.isFinite(config?.reportBudgetMs) ? Math.max(1, Number(config.reportBudgetMs)) : 4_000,
1103
+ selfcheckBudgetMs: Number.isFinite(config?.selfcheckBudgetMs) ? Math.max(1, Number(config.selfcheckBudgetMs)) : 25_000,
1104
+ // NOT clamped at 0: a negative value is the documented way to disable pruning, and each
1105
+ // capture may free tens of megabytes.
1106
+ keepShots: Number.isFinite(config?.keepShots) ? Number(config.keepShots) : 20,
1107
+ // 0/negative is "no byte budget", the same documented direction as `keepShots`: the value is
1108
+ // NOT clamped, because clamping a negative to 0 would silently create a floor that deletes
1109
+ // everything down to nothing, which is the opposite of what a negative means everywhere else.
1110
+ shotCacheBytes: Number.isFinite(config?.shotCacheBytes) ? Number(config.shotCacheBytes) : 0,
1111
+ // NOT validated here: an unknown mode must fail when the launch spec is BUILT, loudly, rather
1112
+ // than be coerced to a default here. Coercing is the dangerous direction -- the default is the
1113
+ // unrestricted mode, so a typo would silently grant the widest access instead of none.
1114
+ workerSessionMode: typeof config?.workerSessionMode === 'string' && config.workerSessionMode !== ''
1115
+ ? config.workerSessionMode
1116
+ : 'danger-full-access',
1117
+ // Trimmed, not validated here: an invalid value must fail loudly when the launch spec is built
1118
+ // (and be refused at save), not be coerced here.
1119
+ workerModel: typeof config?.workerModel === 'string' ? config.workerModel.trim() : '',
1120
+ // Not clamped at 0 for the same reason as `keepShots`: 0 or negative is the documented way to
1121
+ // say "do not rotate / keep every archive", which is a choice, not an invalid value.
1122
+ auditMaxBytes: Number.isFinite(config?.auditMaxBytes) ? Number(config.auditMaxBytes) : 8 * 1024 * 1024,
1123
+ auditKeepFiles: Number.isFinite(config?.auditKeepFiles) ? Number(config.auditKeepFiles) : 5,
1124
+ // CLAMPED, unlike the two audit caps above, because these are not "a budget you can switch off":
1125
+ // `transferMaxBytes: 0` would refuse every file (nonsense dressed as a setting), and a chunk of
1126
+ // 0 would make the resume loop never advance.
1127
+ transferMaxBytes: Number.isFinite(config?.transferMaxBytes) && Number(config.transferMaxBytes) > 0
1128
+ ? Math.floor(Number(config.transferMaxBytes))
1129
+ : 8 * 1024 * 1024 * 1024,
1130
+ transferChunkBytes: Number.isFinite(config?.transferChunkBytes) && Number(config.transferChunkBytes) > 0
1131
+ ? Math.floor(Number(config.transferChunkBytes))
1132
+ : 4 * 1024 * 1024,
1133
+ // `undefined` means "not configured" and takes the default; an explicit empty string is
1134
+ // the operator switching the injection OFF, which must survive. `??` would be wrong here
1135
+ // in the other direction; the check is deliberately about the type.
1136
+ delegationPolicy: typeof config?.delegationPolicy === 'string' ? config.delegationPolicy : DEFAULT_DELEGATION_POLICY,
1137
+ // The worker role is carried through as an opaque blob rather than normalised field by
1138
+ // field: `WorkerArming` owns its shape and validates it on construction. Rebuilding it here
1139
+ // would be a second place that has to agree about the layout of a credential.
1140
+ workerRole: config?.workerRole !== null && typeof config?.workerRole === 'object' ? config.workerRole : {},
1141
+ }
1142
+ }
1143
+
1144
+ /**
1145
+ * Build the approval decider used by both the provider (per task) and the bridge
1146
+ * (constructor-level fallback).
1147
+ *
1148
+ * ⚠️ `requiredTier` is derived PER APPROVAL, not taken from config once. Using a
1149
+ * fixed tier would still stop `danger-full-access`, but it would miss the quieter
1150
+ * failure mode: a read-only operation asking for `workspace-write`. Deriving it from
1151
+ * the actual call (tool name + target path vs the worker's cwd) is what makes the
1152
+ * tier check mean what DESIGN §8.3 says it means.
1153
+ *
1154
+ * ROUTING RULE: branch on `decision.needsHuman`, never on `source === 'human'`.
1155
+ * `source` is an audit classification; a `danger-full-access` request is classified
1156
+ * `tier-check` but still needs a human, so keying off `source` would silently start
1157
+ * auto-approving it one day.
1158
+ *
1159
+ * @param {{ cfg: object, worker: object, memories: Map<string, any>, log: object }} deps
1160
+ * @returns {(ask: { taskId?: string, toolCallId: string, toolName?: string, rawInput?: unknown }) => { optionId: string, source: string, reason: string, needsHuman: boolean }}
1161
+ */
1162
+ export function makeApprovalDecider(deps) {
1163
+ const { cfg, worker, memories, log, queue, audit } = deps
1164
+
1165
+ /**
1166
+ * Append one audit row. Never throws into the approval path: a trail that can veto an
1167
+ * operation is worse than one that reports it could not write.
1168
+ */
1169
+ const record = (entry) => {
1170
+ if (audit === undefined) return
1171
+ try {
1172
+ audit.record(entry)
1173
+ } catch { /* audited elsewhere; never change a decision because logging failed */ }
1174
+ }
1175
+
1176
+ return async function decide(ask) {
1177
+ const taskId = typeof ask?.taskId === 'string' && ask.taskId !== '' ? ask.taskId : 'unattributed'
1178
+ let memory = memories.get(taskId)
1179
+ if (memory === undefined) {
1180
+ memory = new TaskMemory(taskId)
1181
+ memories.set(taskId, memory)
1182
+ }
1183
+
1184
+ const requiredTier = inferRequiredTier(ask?.rawInput, {
1185
+ workspaceRoot: worker.cwd,
1186
+ toolName: ask?.toolName,
1187
+ defaultTier: cfg.requiredTier,
1188
+ })
1189
+ // What the worker ASKED for. `inferRequiredTier` answers "the minimum that would do", which is
1190
+ // what the policy checks -- but the row a HUMAN reads must say what is being requested, or a
1191
+ // danger-full-access request shows up as "needs workspace-write" (PITFALLS F22).
1192
+ const requestedTier = readRequestedTier(ask?.rawInput)
1193
+
1194
+ const engine = decideApproval(
1195
+ { taskId, toolCallId: ask?.toolCallId, toolName: ask?.toolName, rawInput: ask?.rawInput },
1196
+ { memory, policy: { requiredTier, workspaceRoot: worker.cwd } },
1197
+ )
1198
+
1199
+ const toolCallId = String(ask?.toolCallId ?? '')
1200
+ const toolName = String(ask?.toolName ?? '')
1201
+
1202
+ // The engine decided on its own (cache miss, tier check, task memory, policy). Record it
1203
+ // and answer at once: there is nothing a human could add.
1204
+ if (!engine.needsHuman || queue === undefined) {
1205
+ // `needsHuman` with no queue means nobody can be asked, which is a refusal by
1206
+ // construction -- the engine already set the rejection option.
1207
+ const by = engine.needsHuman ? 'no-answerer' : 'engine'
1208
+ log.info(`approval ${toolCallId} tool=${toolName} required=${requiredTier} `
1209
+ + `-> ${engine.optionId} (gate=engine by=${by} source=${engine.source}) ${engine.reason}`)
1210
+ record({
1211
+ kind: 'approval',
1212
+ gate: 'engine',
1213
+ by,
1214
+ peerId: worker.id,
1215
+ taskId,
1216
+ toolCallId,
1217
+ toolName,
1218
+ requiredTier,
1219
+ optionId: engine.optionId,
1220
+ engineSource: engine.source,
1221
+ reason: engine.reason,
1222
+ })
1223
+ return engine
1224
+ }
1225
+
1226
+ // The engine cannot justify this one, so ask. The request is held open until a human
1227
+ // answers or the queue's budget expires -- and expiry is a REFUSAL, which is what makes
1228
+ // this a widening of what is POSSIBLE rather than a loosening of what is ALLOWED.
1229
+ log.info(`approval ${toolCallId} tool=${toolName} required=${requiredTier} `
1230
+ + `[NEEDS HUMAN] queued (${engine.reason})`)
1231
+ const waiting = queue.request({
1232
+ peerId: worker.id,
1233
+ taskId,
1234
+ toolCallId,
1235
+ toolName,
1236
+ rawInput: ask?.rawInput,
1237
+ requiredTier,
1238
+ requestedTier,
1239
+ reason: engine.reason,
1240
+ source: engine.source,
1241
+ })
1242
+ // Announce it before awaiting: the await lasts the whole budget, and a notification sent
1243
+ // after it would arrive once there is nothing left to decide.
1244
+ announceApproval({ queue, log, peerId: worker.id, taskId, toolName, requiredTier, requestedTier })
1245
+ const optionId = await waiting
1246
+
1247
+ // The queue's `onSettle` writes the audit row for this one: it holds the `by` detail
1248
+ // (human / timeout / disposed) that the decision alone cannot express.
1249
+ return optionId === OPTION.ALLOW_ONCE
1250
+ ? { ...engine, optionId: OPTION.ALLOW_ONCE, source: 'human', needsHuman: false, reason: `allowed once by a human (${engine.reason})` }
1251
+ : { ...engine, optionId: OPTION.REJECT_ONCE, source: 'human', needsHuman: false, reason: `refused (${engine.reason})` }
1252
+ }
1253
+ }
1254
+
1255
+ /**
1256
+ * Adapt a review-engine decider to `AcpBridge`'s optionId contract.
1257
+ *
1258
+ * WHY THIS EXISTS: the decider answers with a rich object (`{optionId, source, reason,
1259
+ * needsHuman}`) because the provider wants those fields for its audit line, while the bridge
1260
+ * wants the optionId STRING. `AcpBridge.normalizeSelected` compares its input against
1261
+ * `'allow-once'`, so handing the decider over unchanged compared an object to a string, which
1262
+ * is never true, and every request silently fell back to the rejection option.
1263
+ *
1264
+ * Fail closed, so never a security hole -- but wrong, and it made `allow-once` unreachable
1265
+ * for any caller that did not go through the provider (the only path the end-to-end tests
1266
+ * cover).
1267
+ *
1268
+ * The result is normalised to exactly one of the two ACP option ids rather than passed
1269
+ * through, so a future decision shape cannot smuggle anything else to the bridge.
1270
+ *
1271
+ * @param {(ask: object) => Promise<{ optionId?: string }> | { optionId?: string }} decide
1272
+ * @returns {(ask: object) => Promise<string>}
1273
+ */
1274
+ export function asBridgeOption(decide) {
1275
+ return async (ask) => {
1276
+ const decision = await decide(ask)
1277
+ return decision?.optionId === OPTION.ALLOW_ONCE ? OPTION.ALLOW_ONCE : OPTION.REJECT_ONCE
1278
+ }
1279
+ }
1280
+
1281
+ /** Largest POST body the Settings page may send. It only carries a small action object. */
1282
+ const SETTINGS_MAX_BODY = 16 * 1024
1283
+
1284
+ /**
1285
+ * Readiness budget for a page load.
1286
+ *
1287
+ * MEASURED PROBLEM: `selfcheck()` connects first, and against an unreachable worker that
1288
+ * costs a full TCP connect timeout (~20 s). A page load must not pay that: for the whole
1289
+ * time the request was in flight the browser showed its initial state, which read as
1290
+ * "nothing is configured" even though the worker list plainly had one entry. Link state is
1291
+ * knowable immediately, so it is returned regardless and a missing report is reported as
1292
+ * missing rather than as an absence of workers.
1293
+ */
1294
+ const REPORT_BUDGET_MS = 4_000
1295
+
1296
+ /** An explicit "re-check now" may take longer: the operator asked for it and is waiting. */
1297
+ const SELFCHECK_BUDGET_MS = 25_000
1298
+
1299
+ /** Sentinel distinguishing "the probe did not finish" from a legitimate `undefined`. */
1300
+ const TIMED_OUT = Symbol('timed-out')
1301
+
1302
+ /**
1303
+ * Await `promise`, but give up after `budgetMs`.
1304
+ *
1305
+ * The underlying probe is NOT cancelled -- a pending TCP connect cannot be. Its rejection is
1306
+ * swallowed so a late failure cannot surface as an unhandled rejection; the connection's own
1307
+ * `lastError` still records it for the next read.
1308
+ */
1309
+ async function within(promise, budgetMs) {
1310
+ let timer
1311
+ const expiry = new Promise((resolve) => {
1312
+ timer = setTimeout(() => resolve(TIMED_OUT), budgetMs)
1313
+ if (typeof timer?.unref === 'function') timer.unref()
1314
+ })
1315
+ promise.catch(() => {})
1316
+ try {
1317
+ return await Promise.race([promise, expiry])
1318
+ } finally {
1319
+ clearTimeout(timer)
1320
+ }
1321
+ }
1322
+
1323
+ /**
1324
+ * Run a readiness probe under a budget.
1325
+ * @param {object} peer
1326
+ * @param {number} budgetMs
1327
+ * @param {{ maxAgeMs?: number, timeoutMs?: number }} [opts] passed through to `selfcheck`
1328
+ * @returns {Promise<{ report: object | null, error: string }>}
1329
+ */
1330
+ async function probeWithin(peer, budgetMs, opts) {
1331
+ const raced = await within(peer.selfcheck(opts), budgetMs)
1332
+ if (raced === TIMED_OUT) {
1333
+ return { report: null, error: `readiness probe did not finish within ${budgetMs} ms` }
1334
+ }
1335
+ return { report: raced, error: '' }
1336
+ }
1337
+
1338
+ /** Read and parse a JSON request body without letting a huge one exhaust memory. */
1339
+ function readJsonBody(req, limit = SETTINGS_MAX_BODY) {
1340
+ return new Promise((resolve, reject) => {
1341
+ let size = 0
1342
+ const chunks = []
1343
+ req.on('data', (chunk) => {
1344
+ size += chunk.length
1345
+ if (size > limit) {
1346
+ reject(new Error(`request body exceeds ${limit} bytes`))
1347
+ req.destroy()
1348
+ return
1349
+ }
1350
+ chunks.push(chunk)
1351
+ })
1352
+ req.on('error', reject)
1353
+ req.on('end', () => {
1354
+ const text = Buffer.concat(chunks).toString('utf8').trim()
1355
+ if (text === '') return resolve({})
1356
+ try {
1357
+ resolve(JSON.parse(text))
1358
+ } catch (error) {
1359
+ reject(new Error(`body is not valid JSON: ${error instanceof Error ? error.message : String(error)}`))
1360
+ }
1361
+ })
1362
+ })
1363
+ }
1364
+
1365
+ /**
1366
+ * The message to refuse a worker edit with, or '' when its task directory is usable.
1367
+ *
1368
+ * WHY THIS IS A REFUSAL AND NOT A WARNING: `RemoteProvider.start` rejects such a worker before it
1369
+ * creates a session (`REMOTE_BAD_CWD`), so the configuration can never accept a task. Accepting it
1370
+ * would defer a certain failure to the first delegation, which is exactly how a paired worker came
1371
+ * to look fine on the settings page while every dispatch failed (PITFALLS F16).
1372
+ *
1373
+ * The pairing case gets its own wording because the fix is different in kind: nothing is wrong
1374
+ * with what the operator typed -- the string they pasted simply does not carry a directory, and
1375
+ * only they can know a path on the far machine.
1376
+ *
1377
+ * @param {unknown} cwd the directory that WOULD be saved
1378
+ * @param {{ pairing: boolean }} ctx
1379
+ * @returns {string}
1380
+ */
1381
+ function rejectUnusableCwd(cwd, ctx) {
1382
+ if (ctx.pairing && String(cwd ?? '').trim() === '') {
1383
+ return 'the pairing string carries no task directory, so supply one: the absolute path ON THE '
1384
+ + 'WORKER where its tasks should run, for example C:\\dsh-remote\\work. It is both where the '
1385
+ + 'task runs and the scope it may write to, so the plugin will not invent one. Nothing has '
1386
+ + 'been saved.'
1387
+ }
1388
+ return assertUsableCwd(cwd)
1389
+ }
1390
+
1391
+ /**
1392
+ * The tier a tool call ASKED for, when it asked explicitly.
1393
+ *
1394
+ * WHY THIS IS NOT `inferRequiredTier`: that one answers "what is the minimum that would do", which
1395
+ * is what the policy checks. This one answers "what did the worker request", which is what a human
1396
+ * is being asked to approve -- and the two disagree in the case that matters. Measured 2026-10-14:
1397
+ * a request for `danger-full-access` reached the queue and the row the operator was deciding read
1398
+ * `requiredTier: workspace-write`, because the row carried the inference. DSH states the request in
1399
+ * the tool call's own arguments (`sandbox_permissions`, alongside `justification`).
1400
+ *
1401
+ * @param {unknown} rawInput
1402
+ * @returns {string} the requested tier, or '' when the call asked for nothing wider
1403
+ */
1404
+ export function readRequestedTier(rawInput) {
1405
+ if (rawInput === null || typeof rawInput !== 'object') return ''
1406
+ const value = rawInput.sandbox_permissions ?? rawInput.sandboxPermissions
1407
+ if (typeof value === 'string') return value.trim()
1408
+ // Defensive: a nested shape would otherwise be silently dropped, which is how the field went
1409
+ // missing in the first place.
1410
+ if (value !== null && typeof value === 'object' && typeof value.mode === 'string') return value.mode.trim()
1411
+ return ''
1412
+ }
1413
+
1414
+ /**
1415
+ * Tell the MODEL that a person is being waited on.
1416
+ *
1417
+ * WHY THIS IS NOT JUST THE SETTINGS PAGE: the page only reaches someone who is looking at it, and
1418
+ * the model is the one party guaranteed to be talking to that person right now. Measured
1419
+ * 2026-10-14: six approval requests expired unanswered over two days and the operator had never
1420
+ * seen a prompt -- the request had nowhere to go but a page nobody had open.
1421
+ *
1422
+ * English, because it lands in the same injected section as `TaskRegistry.summary()`: the reader
1423
+ * is the model, not the operator. The desktop notification (see `announceApproval`) is the
1424
+ * operator-facing half and is written in Chinese.
1425
+ *
1426
+ * @param {object[]} pending entries from `ApprovalQueue.list()`
1427
+ * @param {number} timeoutMs the queue's budget, used to say how long is left
1428
+ * @returns {string} an injectable block, or '' when nothing is waiting
1429
+ */
1430
+ export function approvalNotice(pending, timeoutMs) {
1431
+ const rows = Array.isArray(pending) ? pending.filter((row) => row !== null && typeof row === 'object') : []
1432
+ if (rows.length === 0) return ''
1433
+ const budget = Number.isFinite(Number(timeoutMs)) ? Number(timeoutMs) : 0
1434
+ const lines = rows.map((row) => {
1435
+ const left = Math.max(0, Math.round((budget - Number(row.ageMs ?? 0)) / 1000))
1436
+ const tool = String(row.toolName ?? '?')
1437
+ // The REQUESTED tier, falling back to the inferred minimum: the model repeats this to the
1438
+ // operator, so it has to be the thing they are being asked about (PITFALLS F22).
1439
+ const tier = String(row.requestedTier ?? '') !== '' ? String(row.requestedTier) : String(row.requiredTier ?? '')
1440
+ const task = String(row.taskId ?? '')
1441
+ const peer = String(row.peerId ?? '')
1442
+ return `- ${tool}${tier === '' ? '' : ` needs ${tier}`}${task === '' ? '' : ` for ${task}`}`
1443
+ + `${peer === '' ? '' : ` (peer ${peer})`} -- refused automatically in ~${left}s if nobody answers`
1444
+ })
1445
+ return [
1446
+ `APPROVALS WAITING ON A HUMAN (${rows.length}): the worker stays BLOCKED until someone answers.`,
1447
+ ...lines,
1448
+ 'Tell the user to open Settings -> Remote use -> "Approvals waiting on you" and click "Allow once". '
1449
+ + 'If nobody answers, the request is refused and the task will report that failure; it is not a bug to retry.',
1450
+ ].join('\n')
1451
+ }
1452
+
1453
+ /**
1454
+ * Fire the operator-facing desktop notification for an approval that just started waiting.
1455
+ *
1456
+ * Best-effort by contract: it runs on the approval path, so a missing notifier, a blocked
1457
+ * PowerShell or a full disk must all be silent no-ops. The approval is reachable either way --
1458
+ * on the page, and through the model's injected context.
1459
+ *
1460
+ * @param {object} opts
1461
+ * @param {object} opts.queue
1462
+ * @param {(level: string, message: string) => void} opts.log
1463
+ * @param {(opts: object) => boolean} [opts.notify] injectable so tests never spawn anything
1464
+ */
1465
+ export function announceApproval(opts) {
1466
+ const { queue, log } = opts
1467
+ const notify = opts.notify ?? notifyDesktop
1468
+ // Pulled off the object defensively: this runs on the approval path, so a logger that is missing
1469
+ // a method must not be the thing that turns a waiting request into an exception.
1470
+ const { info, warn } = loggerMethods(log)
1471
+ try {
1472
+ const rows = queue.list()
1473
+ const row = rows.length === 0 ? undefined : rows[rows.length - 1]
1474
+ if (row === undefined) return false
1475
+ const seconds = Math.max(0, Math.round((queue.timeoutMs - Number(row.ageMs ?? 0)) / 1000))
1476
+ const asked = String(row.requestedTier ?? '') !== '' ? String(row.requestedTier) : String(row.requiredTier ?? '?')
1477
+ const sent = notify({
1478
+ title: '远程任务:等待你批准',
1479
+ message: `${String(row.toolName ?? '?')} 申请 ${asked}`
1480
+ + `(${String(row.peerId ?? '')}${row.taskId === '' ? '' : ` · ${String(row.taskId)}`})`
1481
+ + `;请在设置页点「允许一次」,约 ${seconds} 秒后自动按拒绝处理。`,
1482
+ log,
1483
+ })
1484
+ if (sent !== true) info('approval is waiting; no desktop notifier available (settings page + model notice only)')
1485
+ return sent === true
1486
+ } catch (error) {
1487
+ warn(`could not announce the approval: ${error instanceof Error ? error.message : String(error)}`)
1488
+ return false
1489
+ }
1490
+ }
1491
+
1492
+ /**
1493
+ * Where a decision on the settings route came from.
1494
+ *
1495
+ * WHY THE PAGE DECLARES IT: `queue.decide` used to record every caller as `human`, which made the
1496
+ * audit claim a person had approved a request that this repo's own harness had approved over the
1497
+ * route. The first fix keyed off the `Origin` header -- on the theory that a browser sends it on
1498
+ * every POST -- and **that theory was measured false in this app's own transport**: a real click on
1499
+ * the settings page recorded `by=api`, because the request reached the route with no usable Origin
1500
+ * (PITFALLS F22). So the page now says so itself, in the body.
1501
+ *
1502
+ * THIS IS EVIDENCE, NOT A BOUNDARY. Any caller can claim `via: 'page'`; the alternative was a field
1503
+ * that never once said `page`, which is worse for the question the trail exists to answer. The
1504
+ * threat model for this route is settled elsewhere (see `crossSitePostProblem`): a local process
1505
+ * that can POST here can already do anything to this machine. Never branch an authorization
1506
+ * decision on this value.
1507
+ *
1508
+ * @param {{ headers?: Record<string, unknown> }} req
1509
+ * @param {{ via?: unknown }} [body]
1510
+ * @returns {'page'|'api'}
1511
+ */
1512
+ export function requestProvenance(req, body) {
1513
+ if (body !== null && typeof body === 'object' && body.via === 'page') return 'page'
1514
+ // Kept as a corroborating signal: a browser that DOES send a same-origin Origin is still a page
1515
+ // even if an older client build forgot the body marker.
1516
+ const headers = req?.headers ?? {}
1517
+ const origin = typeof headers.origin === 'string' ? headers.origin.trim() : ''
1518
+ return origin === '' || origin.toLowerCase() === 'null' ? 'api' : 'page'
1519
+ }
1520
+
1521
+ /**
1522
+ * Refuse a cross-site POST to the settings route.
1523
+ *
1524
+ * WHY THIS EXISTS: DSH's web server authenticates the APP SHELL (a bare `GET /` answers 401) but not
1525
+ * a plugin's own API route -- how a route is reached is the plugin's own responsibility. Nothing
1526
+ * checked where a request came from, and `readJsonBody` parses the body as JSON **whatever the
1527
+ * `Content-Type`**, so a page on any site could POST here as a "simple request" (no CORS preflight,
1528
+ * `Content-Type: text/plain`) and have the action EXECUTE. It could not read the reply, but that is
1529
+ * not needed: `interrupt` takes no id, `worker.disarm` takes none either, and `worker.arm` opens a
1530
+ * listener that accepts remote execution. `DESIGN.md` §5.9 promised this check from the first commit
1531
+ * ("自写 `sameOrigin()` 校验、body 上限、原子写"); the body limit and the atomic write were
1532
+ * implemented and the origin check never was (PITFALLS F17).
1533
+ *
1534
+ * WHY ONLY POST, AND WHY A MISSING `Origin` IS ALLOWED:
1535
+ * * a cross-origin READ is already impossible -- no response carries `Access-Control-Allow-Origin`,
1536
+ * so a page cannot see the reply at all; GET therefore needs no gate;
1537
+ * * a browser sends `Origin` on EVERY POST, same-origin ones included, so a MISSING header cannot
1538
+ * come from a hostile page. It means curl, a script, or this repo's own harness -- callers that
1539
+ * can already do anything to this machine. Refusing them would break the harness and buy nothing.
1540
+ *
1541
+ * The comparison uses the request's own `Host`, not a configured address, so it holds whether the
1542
+ * operator opened the page as `127.0.0.1:19387` or `localhost:19387`.
1543
+ *
1544
+ * @param {{ headers?: Record<string, unknown> }} req
1545
+ * @returns {string} a refusal message, or '' when the request may proceed
1546
+ */
1547
+ export function crossSitePostProblem(req) {
1548
+ const headers = req?.headers ?? {}
1549
+ const origin = typeof headers.origin === 'string' ? headers.origin.trim() : ''
1550
+ // Not a browser. See the doc comment: this cannot be a hostile page.
1551
+ if (origin === '') return ''
1552
+
1553
+ const host = typeof headers.host === 'string' ? headers.host.trim().toLowerCase() : ''
1554
+ if (host === '') {
1555
+ return 'this request carries an Origin but no Host, so it cannot be checked; refusing rather than guessing'
1556
+ }
1557
+ if (origin.toLowerCase() === 'null') {
1558
+ // A sandboxed iframe, a `data:` page or a redirected POST: no origin to compare, so no trust.
1559
+ return 'cross-site request refused: the Origin is opaque (null)'
1560
+ }
1561
+
1562
+ let parsed
1563
+ try {
1564
+ parsed = new URL(origin)
1565
+ } catch {
1566
+ return `cross-site request refused: Origin is not a URL (${origin.slice(0, 60)})`
1567
+ }
1568
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1569
+ return `cross-site request refused: Origin scheme ${parsed.protocol}`
1570
+ }
1571
+ if (parsed.host.toLowerCase() !== host) {
1572
+ return `cross-site request refused: Origin ${parsed.host} is not this server (${host})`
1573
+ }
1574
+ // Browsers also declare how the request relates to its page. Honour it when it is present.
1575
+ const site = typeof headers['sec-fetch-site'] === 'string' ? headers['sec-fetch-site'].trim().toLowerCase() : ''
1576
+ if (site !== '' && site !== 'same-origin') {
1577
+ return `cross-site request refused: Sec-Fetch-Site: ${site}`
1578
+ }
1579
+ return ''
1580
+ }
1581
+
1582
+ /**
1583
+ * The Settings page's data route.
1584
+ *
1585
+ * GET reports worker reachability, the readiness report, and the in-flight task list.
1586
+ * POST performs the page's actions (`selfcheck`, `screenshot`, `interrupt`, `ping`).
1587
+ *
1588
+ * Every failure is returned as `{ ok: false, error }` with a 4xx/5xx status rather than
1589
+ * thrown: the browser half renders the message, and an unhandled rejection here would show
1590
+ * the page a bare 500 with nothing to display.
1591
+ *
1592
+ * @param {{ peers: Map<string, any>, registry: TaskRegistry, cache: ToolCallCache,
1593
+ * currentSettings?: () => object, persistSettings?: (s: object) => object,
1594
+ * settingsPath?: string, reportBudgetMs?: number, selfcheckBudgetMs?: number }} deps
1595
+ */
1596
+ export function makeSettingsRoute(deps) {
1597
+ const { peers, registry, cache, queue, audit } = deps
1598
+ // How many sessions currently have remote work switched on. Injected rather than imported:
1599
+ // the enabled set belongs to `apply()`'s scope, one level up.
1600
+ const sessionsEnabled = typeof deps.sessionsEnabled === 'function' ? deps.sessionsEnabled : () => 0
1601
+ // The worker role lives in apply()'s scope; the route reports it and drives it through these.
1602
+ const armingStatus = typeof deps.armingStatus === 'function' ? deps.armingStatus : () => null
1603
+ const armWorker = typeof deps.armWorker === 'function' ? deps.armWorker : () => ({ ok: false, error: 'this build cannot arm the worker role' })
1604
+ const preflightWorker = typeof deps.preflightWorker === 'function' ? deps.preflightWorker : () => ({ ok: false, blockers: ['this build cannot check the worker role'], probe: { ok: false, reason: '' } })
1605
+ const disarmWorker = typeof deps.disarmWorker === 'function' ? deps.disarmWorker : () => ({ ok: false, error: 'this build cannot disarm the worker role' })
1606
+ // Configured versus effective session mode. Derived on every read (the listener can start or stop
1607
+ // between two page loads), so it is passed as a getter rather than captured.
1608
+ const workerSession = typeof deps.workerSession === 'function' ? deps.workerSession : () => null
1609
+ // The sandbox diagnosis is read-only, so unlike the probe it can run on every page load. Defaulted
1610
+ // to null so a test that constructs this route directly does not have to know about it.
1611
+ const sandbox = typeof deps.sandbox === 'function' ? deps.sandbox : () => null
1612
+ // Resolved lazily (it may need the model catalogue) and awaited by the arming actions only.
1613
+ const validateWorkerModel = typeof deps.validateWorkerModel === 'function'
1614
+ ? deps.validateWorkerModel
1615
+ : async () => ({ ok: true, warning: '', error: '' })
1616
+ // The environment self-check: cached for the page, refreshed only when a check asks for it.
1617
+ const readiness = typeof deps.readiness === 'function' ? deps.readiness : () => null
1618
+ const readinessCheck = typeof deps.readinessCheck === 'function' ? deps.readinessCheck : null
1619
+ const setReadiness = typeof deps.setReadiness === 'function' ? deps.setReadiness : () => {}
1620
+ // The route logs sparingly, but pairing is security-relevant and wants a trace. Injected
1621
+ // rather than imported, and defaulted so this function stays usable on its own in tests.
1622
+ const log = deps.log ?? { info: () => {}, warn: () => {}, error: () => {} }
1623
+ const currentSettings = deps.currentSettings ?? (() => ({ workers: [] }))
1624
+ const persistSettings = deps.persistSettings ?? (() => ({ ok: false, error: 'this build cannot persist settings' }))
1625
+ const settingsFile = deps.settingsPath ?? ''
1626
+ // Injectable so a test can drive the timeout path without waiting the production budget.
1627
+ const reportBudgetMs = Number.isFinite(deps.reportBudgetMs) ? Number(deps.reportBudgetMs) : REPORT_BUDGET_MS
1628
+ const selfcheckBudgetMs = Number.isFinite(deps.selfcheckBudgetMs) ? Number(deps.selfcheckBudgetMs) : SELFCHECK_BUDGET_MS
1629
+
1630
+ /**
1631
+ * The managed configuration as the PAGE sees it: every worker present, tokens replaced by
1632
+ * `tokenSet`/`tokenHint`. The page must be able to render the form without ever holding a
1633
+ * bearer credential for remote execution.
1634
+ */
1635
+ const publicSettings = () => {
1636
+ const settings = currentSettings()
1637
+ return {
1638
+ workers: (Array.isArray(settings.workers) ? settings.workers : []).map(publicWorker),
1639
+ requiredTier: settings.requiredTier,
1640
+ approvalTimeoutMs: settings.approvalTimeoutMs,
1641
+ heartbeatMs: settings.heartbeatMs,
1642
+ keepShots: settings.keepShots,
1643
+ // Shown next to `keepShots` so the page can render both halves of the bound. 0/negative
1644
+ // means "no byte ceiling" and must reach the page as the number, not as `false`.
1645
+ shotCacheBytes: settings.shotCacheBytes,
1646
+ // The mode this machine will arm its own remote sessions with. Shown on the page because
1647
+ // "which permission level is this worker actually deployed at" was, until now, answerable
1648
+ // only by reading a spec file that the running relay may never have read.
1649
+ workerSessionMode: settings.workerSessionMode,
1650
+ // The model the NEXT arming will launch this machine's ACP sessions with, as
1651
+ // `provider/model`; empty means "whatever the acp profile configures". A deployment value:
1652
+ // the image capability is advertised at startup from this route and cannot change per call.
1653
+ workerModel: settings.workerModel,
1654
+ // The same fact split into configured/effective. `workerSessionMode` above is the value the
1655
+ // NEXT arming will use; this carries what the listener that is running NOW was started with,
1656
+ // and whether those two disagree. Derived, never persisted.
1657
+ workerSession: workerSession(),
1658
+ auditMaxBytes: settings.auditMaxBytes,
1659
+ auditKeepFiles: settings.auditKeepFiles,
1660
+ // The delegation policy is editable from the page because getting the wording right is
1661
+ // an operator judgement rather than a code change: it is the text that tells the model
1662
+ // when to hand work to a worker at all. An empty string is a MEANINGFUL value (inject
1663
+ // nothing) and must survive the round trip.
1664
+ delegationPolicy: typeof settings.delegationPolicy === 'string' ? settings.delegationPolicy : DEFAULT_DELEGATION_POLICY,
1665
+ // Per session and not persisted, so the page can say how many sessions currently have
1666
+ // remote work switched on instead of leaving the operator to remember where they typed
1667
+ // `/remote-use on`. Passed in as a getter: the enabled set lives in `apply()`, and this
1668
+ // function is a different scope.
1669
+ sessionsEnabled: sessionsEnabled(),
1670
+ // The worker role as the PAGE sees it. WorkerArming.status() already carries only a
1671
+ // masked credential; nothing here may add the raw one.
1672
+ workerRole: armingStatus(),
1673
+ // Whether this machine's sandbox mediator is ready, and -- when it is not -- the exact command
1674
+ // that fixes it. Shown without clicking Check, because a silently missing mediator is exactly
1675
+ // the state that stays invisible when the answer is only computed on demand.
1676
+ sandbox: sandbox(),
1677
+ // The last environment self-check, so a page reload keeps showing the result until the
1678
+ // operator runs another one. Null until the first check.
1679
+ readiness: readiness(),
1680
+ // The page cannot drive the switch itself -- it has no session identity, and the switch
1681
+ // is per session -- so it points at the real control instead of faking a toggle.
1682
+ enableCommand: `/${ENABLE_COMMAND} on`,
1683
+ }
1684
+ }
1685
+
1686
+ return {
1687
+ kind: 'exact',
1688
+ path: '/api/dsh/dsh-plugin-remote-use',
1689
+ async handler(req, res) {
1690
+ const send = (status, body) => {
1691
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1692
+ res.end(JSON.stringify(body))
1693
+ }
1694
+
1695
+ /** Collect one row per configured worker, including its link state and readiness. */
1696
+ const snapshot = async () => {
1697
+ const workers = []
1698
+ for (const peer of peers.values()) {
1699
+ // Started BEFORE the readiness probe, not after: the probe can spend seconds waiting on
1700
+ // a slow worker, and the cache report is a local directory listing plus (at most) one
1701
+ // small RPC. Overlapping them keeps a page load from paying for both in series.
1702
+ //
1703
+ // Wrapped in an async IIFE that resolves to null on ANY failure, rather than
1704
+ // `method(...).catch(...)`: a connection that does not implement the method at all throws
1705
+ // synchronously, before a `.catch` could ever be attached, and that synchronously-thrown
1706
+ // TypeError used to abort the whole snapshot -- one worker without the capability took
1707
+ // the entire page down. The capability is genuinely optional (a bridge stub, or a
1708
+ // connection built before this feature existed), so it is treated as optional here.
1709
+ //
1710
+ // The capability is asked of the CONNECTION, the same way the `cache` action asks it, so
1711
+ // the page cannot show a worker as unable to report and then let a button on that same
1712
+ // card try anyway.
1713
+ const workerCachePromise = (async () => {
1714
+ try {
1715
+ if (peer.connection?.supportsWorkerShotCache !== true) return null
1716
+ return await peer.connection.workerShotCache({ maxBytes: Number(currentSettings().shotCacheBytes ?? 0) })
1717
+ } catch {
1718
+ return null
1719
+ }
1720
+ })()
1721
+
1722
+ let report = null
1723
+ let error = ''
1724
+ try {
1725
+ const probed = await probeWithin(peer, reportBudgetMs)
1726
+ report = probed.report
1727
+ error = probed.error
1728
+ } catch (e) {
1729
+ error = e instanceof Error ? e.message : String(e)
1730
+ }
1731
+ workers.push({
1732
+ peerId: peer.peerId,
1733
+ name: peer.name,
1734
+ cwd: peer.defaultCwd,
1735
+ report,
1736
+ error,
1737
+ activeTasks: registry.activeCountForPeer(peer.peerId),
1738
+ // Whether this worker's CONFIGURATION can work at all, separate from whether the
1739
+ // machine is reachable or ready. Without it the page showed a row that looked
1740
+ // configured while the only symptom was a `[REMOTE_BAD_CWD]` refusal on the first
1741
+ // delegation -- days later, and on a different screen (PITFALLS F16).
1742
+ configProblem: assertUsableCwd(peer.defaultCwd),
1743
+ // Surface the LINK state separately from the readiness report. Without it a
1744
+ // "not connected" failure looks like a worker fault, which is how the missing
1745
+ // connect() stayed invisible until a real profile load.
1746
+ connection: peer.connection?.stats,
1747
+ // Both halves of "where did the disk go". `hostCache` is a local scan and always
1748
+ // available; `workerCache` needs the worker to be up AND running a relay that knows
1749
+ // the `dsh-remote/shotcache` RPC, so it is reported as unavailable rather than as a
1750
+ // zero -- a zero would read as "the worker holds nothing", which is the opposite of
1751
+ // the case an operator needs to see when the worker is simply too old to say.
1752
+ //
1753
+ // Both are probed for the method rather than called blindly: this is a capability that
1754
+ // a connection may genuinely lack (a stub, or one built before the feature), and an
1755
+ // optional capability that throws when absent turns one worker into a broken page.
1756
+ hostCache: typeof peer.connection?.hostShotCache === 'function'
1757
+ ? peer.connection.hostShotCache()
1758
+ : null,
1759
+ workerCache: await workerCachePromise,
1760
+ })
1761
+ }
1762
+ return {
1763
+ ok: true,
1764
+ workers,
1765
+ // `listForUi`, not `list`: the registry retains each settled task's answer text, and the
1766
+ // page neither renders it nor should receive it on every poll (PITFALLS F15).
1767
+ tasks: registry.listForUi(50),
1768
+ toolCallCache: { size: cache.size, misses: cache.misses },
1769
+ // The vocabulary the page needs to render its own controls, without any secret.
1770
+ settings: publicSettings(),
1771
+ settingsFile,
1772
+ // Approvals waiting for a person, and the trail of what was decided. The pending
1773
+ // list is what makes `danger-full-access` reachable at all: without it the engine's
1774
+ // "needs a human" had nowhere to go but refusal.
1775
+ //
1776
+ // `available: false` rather than an empty list when the queue is absent: the page
1777
+ // must be able to tell "nothing is waiting" from "this build cannot ask".
1778
+ approvals: queue === undefined
1779
+ ? { available: false, pending: [], recent: [], missed: [], missedCount: 0, timeoutMs: 0 }
1780
+ : {
1781
+ available: true,
1782
+ pending: queue.list(),
1783
+ recent: queue.recent(10),
1784
+ // Derived from the append-only trail rather than the queue's in-memory history, so
1785
+ // a request nobody saw is still visible after a restart -- which is the whole point.
1786
+ missed: audit === undefined ? [] : audit.missed(20),
1787
+ missedCount: audit === undefined ? 0 : audit.missedCount,
1788
+ timeoutMs: queue.timeoutMs,
1789
+ },
1790
+ audit: audit === undefined
1791
+ ? { available: false, path: '', error: '', recent: [], bytes: 0, maxBytes: 0, keepFiles: 0, archives: [] }
1792
+ // The caps travel with the snapshot so the page can SHOW that growth is bounded and
1793
+ // where it stands. A limit nobody can see is indistinguishable from no limit.
1794
+ : {
1795
+ available: true,
1796
+ path: audit.file,
1797
+ error: audit.lastError,
1798
+ recent: audit.tail(20),
1799
+ bytes: audit.bytes,
1800
+ maxBytes: audit.maxBytes,
1801
+ keepFiles: audit.keepFiles,
1802
+ archives: audit.archives(),
1803
+ },
1804
+ }
1805
+ }
1806
+
1807
+ if (req.method === 'GET') return send(200, await snapshot())
1808
+ if (req.method !== 'POST') return send(405, { ok: false, error: 'method-not-allowed' })
1809
+
1810
+ // BEFORE the body is read and before any action is dispatched (PITFALLS F17). A 403 here
1811
+ // means nothing ran: no settings written, no listener opened, no task interrupted.
1812
+ const crossSite = crossSitePostProblem(req)
1813
+ if (crossSite !== '') return send(403, { ok: false, error: crossSite })
1814
+
1815
+ let body
1816
+ try {
1817
+ body = await readJsonBody(req)
1818
+ } catch (error) {
1819
+ return send(400, { ok: false, error: error instanceof Error ? error.message : String(error) })
1820
+ }
1821
+ const action = String(body?.action ?? '')
1822
+
1823
+ /** Resolve one peer by id, or reply with a 404 the page can show. */
1824
+ const peerOr = (peerId) => {
1825
+ const peer = peers.get(String(peerId ?? ''))
1826
+ if (peer === undefined) {
1827
+ send(404, { ok: false, error: `no such worker "${peerId}"` })
1828
+ return undefined
1829
+ }
1830
+ return peer
1831
+ }
1832
+
1833
+ try {
1834
+ switch (action) {
1835
+ case 'selfcheck': {
1836
+ // `maxAgeMs: 0` forces a fresh probe. The page's whole point is to answer
1837
+ // "can it run right now", and a cached report cannot answer that.
1838
+ const peer = peerOr(body?.peerId)
1839
+ if (peer === undefined) return
1840
+ // A longer budget than a page load: this is an explicit request and the
1841
+ // operator is watching it, so a connect attempt is allowed to finish.
1842
+ const probed = await probeWithin(peer, selfcheckBudgetMs, { maxAgeMs: 0 })
1843
+ if (probed.report === null && probed.error !== '') {
1844
+ return send(504, { ok: false, error: probed.error, peerId: peer.peerId })
1845
+ }
1846
+ return send(200, { ok: true, peerId: peer.peerId, report: probed.report })
1847
+ }
1848
+
1849
+ case 'screenshot': {
1850
+ const peer = peerOr(body?.peerId)
1851
+ if (peer === undefined) return
1852
+ // The capture path is independent of the ACP link, so it still works when the
1853
+ // worker's agent is busy -- which is exactly when a screenshot is most wanted.
1854
+ //
1855
+ // `peer` here is the worker RUNTIME, not the connection. The runtime exposes
1856
+ // `connection` (and `bridge`, the same object) plus a `selfcheck()` that falls
1857
+ // back to a synthesised report; everything else lives on the connection. Calling
1858
+ // `peer.screenshot()` directly was a TypeError at the button, while the model's
1859
+ // `remote_screenshot` tool -- which goes through `connection` -- worked fine.
1860
+ const shot = await peer.connection.screenshot({ fresh: true })
1861
+ return send(200, { ok: true, peerId: peer.peerId, screenshot: shot })
1862
+ }
1863
+
1864
+ case 'ping': {
1865
+ const peer = peerOr(body?.peerId)
1866
+ if (peer === undefined) return
1867
+ await peer.connection.ensure()
1868
+ return send(200, { ok: true, peerId: peer.peerId, connected: peer.connection.connected })
1869
+ }
1870
+
1871
+ case 'cache': {
1872
+ // Cache housekeeping for ONE worker, host side and worker side in one action, because
1873
+ // the operator's mental model is "this worker's screenshots" rather than "a directory
1874
+ // on this machine plus a directory on that machine". Reporting and acting are the same
1875
+ // call: `scope` picks which side, `op` picks report/clear.
1876
+ const peer = peerOr(body?.peerId)
1877
+ if (peer === undefined) return
1878
+ const scope = String(body?.scope ?? '') === 'worker' ? 'worker' : 'host'
1879
+ // An explicit budget from the page wins; otherwise the saved setting.
1880
+ //
1881
+ // WHY `Number.isFinite` AND NOT A TRUTHINESS TEST: 0 is a MEANINGFUL budget -- it is
1882
+ // how both the page and the CLI spell "no byte ceiling" -- so a truthiness check would
1883
+ // turn "no ceiling" into "use the saved ceiling", and the operator could never clear a
1884
+ // limit from the page. Note the flip side, deliberately accepted: `Number('')` and
1885
+ // `Number(null)` are both 0, so a request carrying an empty value also means "no
1886
+ // ceiling". The page never sends either (a blank field is sent as an explicit 0), and
1887
+ // "no ceiling" is the safe reading of an empty value, so this is the intended
1888
+ // behaviour rather than an accident to route around.
1889
+ const requested = Number(body?.maxBytes)
1890
+ const maxBytes = Number.isFinite(requested) ? requested : Number(currentSettings().shotCacheBytes ?? 0)
1891
+ const clear = String(body?.op ?? '') === 'clear'
1892
+
1893
+ if (scope === 'host') {
1894
+ const hostCache = clear
1895
+ ? peer.connection.clearHostShotCache()
1896
+ : peer.connection.pruneHostShotCache({ maxBytes })
1897
+ return send(200, {
1898
+ ok: true,
1899
+ peerId: peer.peerId,
1900
+ scope,
1901
+ cleared: clear,
1902
+ supported: true,
1903
+ bytes: clear ? hostCache.bytes : peer.connection.hostShotCache({ maxBytes }).bytes,
1904
+ result: hostCache,
1905
+ hostCache: peer.connection.hostShotCache({ maxBytes }),
1906
+ })
1907
+ }
1908
+
1909
+ // Worker side. A worker whose relay predates this RPC answers `supported: false`
1910
+ // rather than failing: the link is healthy and only the report is missing, so
1911
+ // treating it as an error would send the operator to debug a working connection.
1912
+ //
1913
+ // The capability is asked of the CONNECTION rather than probed here, so this path and
1914
+ // the GET snapshot cannot disagree about whether a given worker can report. Before
1915
+ // that, the snapshot reported `null` for such a worker while this path called the
1916
+ // method blindly, turned the TypeError into a 502, and made a healthy worker look
1917
+ // broken the moment someone pressed Clear.
1918
+ const workerCache = peer.connection.supportsWorkerShotCache === true
1919
+ ? await peer.connection.workerShotCache({ maxBytes, prune: !clear, clear })
1920
+ : { supported: false, error: '', files: 0, bytes: 0, newestAt: 0, oldestAt: 0, keep: 0, maxBytes, dir: '', pruned: null }
1921
+ // The HOST half of this reply is always real: it is this machine's own directory, and it
1922
+ // was just read. Only the WORKER half can be "cannot report", so the page is never told
1923
+ // that this machine is unable to describe its own disk.
1924
+ if (workerCache.supported !== true && workerCache.error !== '') {
1925
+ return send(502, { ok: false, error: workerCache.error, peerId: peer.peerId, scope, supported: false })
1926
+ }
1927
+ return send(200, {
1928
+ ok: true,
1929
+ peerId: peer.peerId,
1930
+ scope,
1931
+ cleared: clear,
1932
+ supported: true,
1933
+ workerSupported: workerCache.supported,
1934
+ workerCache,
1935
+ })
1936
+ }
1937
+
1938
+ case 'interrupt': {
1939
+ // Emergency stop. `peerId` narrows it to one worker; omitting it stops every
1940
+ // in-flight task, which is the button a page needs when something has gone
1941
+ // wrong and the user does not want to reason about which worker is which.
1942
+ const only = body?.peerId === undefined ? undefined : String(body.peerId)
1943
+ const active = registry.active()
1944
+ const cancelled = []
1945
+ const failures = []
1946
+ for (const task of active) {
1947
+ if (only !== undefined && task.peerId !== only) continue
1948
+ const peer = peers.get(task.peerId)
1949
+ if (peer === undefined) {
1950
+ failures.push({ taskId: task.taskId, error: `worker "${task.peerId}" is not connected` })
1951
+ continue
1952
+ }
1953
+ try {
1954
+ await peer.bridge.cancel()
1955
+ cancelled.push(task.taskId)
1956
+ } catch (error) {
1957
+ failures.push({ taskId: task.taskId, error: error instanceof Error ? error.message : String(error) })
1958
+ }
1959
+ }
1960
+ return send(200, { ok: true, cancelled, failures, considered: active.length })
1961
+ }
1962
+
1963
+ case 'worker.save': {
1964
+ // Upsert one worker. An absent/blank token means "keep the stored one": the page
1965
+ // never receives tokens, so requiring re-entry to rename a worker would be both
1966
+ // annoying and pointless.
1967
+ const edited = upsertWorker(currentSettings().workers, body?.worker)
1968
+ if (edited.error !== '') return send(400, { ok: false, error: edited.error })
1969
+ // Same rule as pairing, and for the same reason: `upsertWorker` REPLACES the row, so a
1970
+ // form submitted with the field cleared would silently take a working worker offline.
1971
+ // `normalizeWorker` is what produced `edited`, so this is the value that would be saved.
1972
+ const cwdProblem = rejectUnusableCwd(normalizeWorker(body?.worker)?.cwd, { pairing: false })
1973
+ if (cwdProblem !== '') return send(400, { ok: false, error: cwdProblem })
1974
+ const applied = persistSettings({ ...currentSettings(), workers: edited.workers })
1975
+ if (applied.ok !== true) return send(500, { ok: false, error: applied.error })
1976
+ return send(200, { ok: true, applied, settings: publicSettings() })
1977
+ }
1978
+
1979
+ case 'worker.pair': {
1980
+ // Pair from a pasted pairing string: url, token and name all arrive at once, so an
1981
+ // operator never types a bearer token by hand.
1982
+ //
1983
+ // The string is a CREDENTIAL, so it is decoded here and immediately reduced to a
1984
+ // worker entry -- it is never logged, echoed back, or put in an error message. A
1985
+ // bad string is refused rather than partially applied: a worker that connects to
1986
+ // the right host with the wrong token fails much later, far from the paste.
1987
+ const decoded = decodePairing(body?.pairing)
1988
+ if (decoded.ok !== true) return send(400, { ok: false, error: decoded.error })
1989
+
1990
+ const incoming = { ...decoded.worker }
1991
+ // Pairing is a paste; asking the operator to also invent an id would defeat the
1992
+ // point, so one is derived from the worker's own name (or its host) unless the
1993
+ // page supplied one.
1994
+ //
1995
+ // RE-PAIRING AN EXISTING ADDRESS UPDATES THAT WORKER. This is the normal path when a
1996
+ // worker switches from a hand-run relay to the plugin-managed role: the address stays
1997
+ // the same and the token is new. Deriving a fresh id would add a SECOND row for the
1998
+ // same relay, leaving the old token configured forever and making it impossible to
1999
+ // tell which row is live. An explicitly supplied id still wins, so an operator can
2000
+ // intentionally create a second entry.
2001
+ if (typeof body?.id === 'string' && body.id !== '') {
2002
+ incoming.id = body.id
2003
+ } else {
2004
+ const sameUrl = currentSettings().workers.find((w) => w.url === decoded.worker.url)
2005
+ if (sameUrl !== undefined) {
2006
+ incoming.id = sameUrl.id
2007
+ // The pairing string carries url/token/name/cwd only. Re-pairing an existing
2008
+ // worker must not silently reset fields it says nothing about -- a custom serial
2009
+ // queue or a deliberately disabled row would be lost, and a blank cwd in the
2010
+ // string would erase the task directory the operator already configured.
2011
+ if (incoming.name === undefined || incoming.name === '') incoming.name = sameUrl.name
2012
+ if (incoming.cwd === undefined || incoming.cwd === '') incoming.cwd = sameUrl.cwd
2013
+ if (incoming.enabled === undefined) incoming.enabled = sameUrl.enabled
2014
+ if (incoming.serialQueue === undefined) incoming.serialQueue = sameUrl.serialQueue
2015
+ } else {
2016
+ incoming.id = deriveWorkerId(decoded.worker)
2017
+ }
2018
+ if (incoming.id === undefined || incoming.id === '') {
2019
+ return send(400, { ok: false, error: 'could not derive a worker id; supply one explicitly' })
2020
+ }
2021
+ }
2022
+ // The pasted string knows url/token/name; the operator may still want a local cwd.
2023
+ if (typeof body?.cwd === 'string' && body.cwd !== '') incoming.cwd = body.cwd
2024
+
2025
+ // Refuse rather than save-and-warn. `RemoteProvider.start` rejects a worker with no
2026
+ // usable task directory outright (`REMOTE_BAD_CWD`), so this configuration can never
2027
+ // dispatch; saving it would only move a certain failure away from the paste and into
2028
+ // the next delegation, which is how this was found (PITFALLS F16). Nothing is
2029
+ // persisted, so the operator fixes it in the same card and pastes nothing again.
2030
+ const cwdProblem = rejectUnusableCwd(incoming.cwd, { pairing: true })
2031
+ if (cwdProblem !== '') return send(400, { ok: false, error: cwdProblem })
2032
+
2033
+ const edited = upsertWorker(currentSettings().workers, incoming)
2034
+ if (edited.error !== '') return send(400, { ok: false, error: edited.error })
2035
+ const applied = persistSettings({ ...currentSettings(), workers: edited.workers })
2036
+ if (applied.ok !== true) return send(500, { ok: false, error: applied.error })
2037
+ // Report the host only, never the token.
2038
+ log.info(`paired worker from a pairing string: ${describePairing(body?.pairing)}`)
2039
+ return send(200, { ok: true, applied, settings: publicSettings() })
2040
+ }
2041
+
2042
+ case 'worker.remove': {
2043
+ const { workers, removed } = removeWorkerFromList(currentSettings().workers, String(body?.peerId ?? ''))
2044
+ if (!removed) return send(404, { ok: false, error: `no such worker "${body?.peerId}"` })
2045
+ const applied = persistSettings({ ...currentSettings(), workers })
2046
+ if (applied.ok !== true) return send(500, { ok: false, error: applied.error })
2047
+ return send(200, { ok: true, applied, settings: publicSettings() })
2048
+ }
2049
+
2050
+ case 'settings.save': {
2051
+ // Only the managed, non-worker keys. Workers go through `worker.save` so a token
2052
+ // can never be dropped by a form that did not carry one.
2053
+ const incoming = body?.settings ?? {}
2054
+ const priorMode = currentSettings().workerSessionMode
2055
+ const next = { ...currentSettings() }
2056
+ if (incoming.requiredTier !== undefined) next.requiredTier = String(incoming.requiredTier)
2057
+ if (Number.isFinite(incoming.approvalTimeoutMs)) next.approvalTimeoutMs = Number(incoming.approvalTimeoutMs)
2058
+ if (Number.isFinite(incoming.heartbeatMs)) next.heartbeatMs = Number(incoming.heartbeatMs)
2059
+ if (Number.isFinite(incoming.keepShots)) next.keepShots = Number(incoming.keepShots)
2060
+ // Accepted as a raw number of BYTES. The page parses `512MB`-style text before it
2061
+ // sends, so an unusable string is refused at the field rather than silently becoming
2062
+ // 0 here -- and 0 is a meaningful value ("no ceiling"), which is why it is not used
2063
+ // as the error signal.
2064
+ if (Number.isFinite(incoming.shotCacheBytes)) next.shotCacheBytes = Number(incoming.shotCacheBytes)
2065
+ // An unknown mode is accepted here and refused when the spec is built -- see the schema.
2066
+ if (typeof incoming.workerSessionMode === 'string' && incoming.workerSessionMode !== '') {
2067
+ next.workerSessionMode = incoming.workerSessionMode
2068
+ }
2069
+ // Refused here rather than stored: an invalid `provider/model` would only surface as
2070
+ // "images silently off" after the next arming, and a value the page cannot round-trip
2071
+ // is not a setting the operator can reason about.
2072
+ if (typeof incoming.workerModel === 'string') {
2073
+ const parsed = parseWorkerModel(incoming.workerModel)
2074
+ if (parsed.ok !== true) {
2075
+ return send(400, { ok: false, error: `workerModel "${incoming.workerModel}" is not "provider/model": ${parsed.reason}` })
2076
+ }
2077
+ next.workerModel = incoming.workerModel.trim()
2078
+ }
2079
+ if (Number.isFinite(incoming.auditMaxBytes)) next.auditMaxBytes = Number(incoming.auditMaxBytes)
2080
+ if (Number.isFinite(incoming.auditKeepFiles)) next.auditKeepFiles = Number(incoming.auditKeepFiles)
2081
+ // An empty string is a real choice here: it switches the prompt injection off while
2082
+ // leaving the tools gated. `??` would drop it, so the check is about the type.
2083
+ if (typeof incoming.delegationPolicy === 'string') next.delegationPolicy = incoming.delegationPolicy
2084
+ const applied = persistSettings(next)
2085
+ if (applied.ok !== true) return send(500, { ok: false, error: applied.error })
2086
+ const settingsNow = publicSettings()
2087
+ // A listener already running cannot have its launch spec rewritten underneath it. Say
2088
+ // so where the operator changed the value, instead of leaving the page showing the new
2089
+ // mode while the old one keeps running.
2090
+ if (settingsNow.workerSession?.reArmRequired === true && next.workerSessionMode !== priorMode) {
2091
+ log.warn(`workerSessionMode is now ${next.workerSessionMode}, but the running listener was `
2092
+ + `started with ${settingsNow.workerSession.effective}; re-arm (disarm + arm) to apply it. `
2093
+ + `The change from ${priorMode} has NOT reached the live session.`)
2094
+ }
2095
+ return send(200, { ok: true, applied, settings: settingsNow })
2096
+ }
2097
+
2098
+ case 'worker.preflight': {
2099
+ // Decision 6: the page must be able to show the port, the reachable range, the
2100
+ // current confinement state and how to close it BEFORE anything is opened. This runs
2101
+ // the same gate `arm` does and starts nothing, so opening the panel costs a probe
2102
+ // rather than a decision.
2103
+ const result = preflightWorker({ bindAddress: body?.bindAddress, port: body?.port, taskCwd: body?.taskCwd })
2104
+ const status = armingStatus()
2105
+ const sandboxNow = sandbox()
2106
+ // Checked here as well as at arm, so the panel can say "this model id does not resolve"
2107
+ // BEFORE the operator spends a click on a refusal.
2108
+ const modelCheck = await validateWorkerModel()
2109
+ // The environment self-check runs only when the page asks for it: it spawns PowerShell
2110
+ // and S8 moves the mouse, so it must not ride on every page load.
2111
+ let readinessNow = readiness()
2112
+ if (body?.checkEnvironment === true && readinessCheck !== null) {
2113
+ readinessNow = await readinessCheck({ taskCwd: body?.taskCwd })
2114
+ setReadiness(readinessNow)
2115
+ }
2116
+ return send(200, {
2117
+ ok: result.ok === true && modelCheck.ok === true,
2118
+ blockers: [
2119
+ ...(result.blockers ?? []),
2120
+ ...(modelCheck.ok === true ? [] : [modelCheck.error]),
2121
+ ],
2122
+ probe: result.probe ?? { ok: false, reason: '' },
2123
+ interfaces: status?.interfaces ?? [],
2124
+ port: status?.port ?? 0,
2125
+ // The fix, beside the refusal. The blockers stay machine-readable; this is the part a
2126
+ // person can act on without reverse-engineering which of three layers is missing.
2127
+ sandbox: sandboxNow,
2128
+ prescription: sandboxNow?.prescription?.text ?? '',
2129
+ model: modelCheck,
2130
+ // Environment readiness (advisory) and the prompt that fixes it, on the worker.
2131
+ readiness: readinessNow,
2132
+ })
2133
+ }
2134
+
2135
+ case 'worker.arm': {
2136
+ // Decision 4 is enforced inside `arm()`: if the sandbox does not contain this
2137
+ // machine the call is refused, not warned about.
2138
+ //
2139
+ // The token comes back HERE and only here. It is returned once so the operator can
2140
+ // paste it into a pairing; every later read of the page sees the mask instead.
2141
+ const modelCheck = await validateWorkerModel()
2142
+ if (modelCheck.ok !== true) {
2143
+ return send(409, { ok: false, error: modelCheck.error, blockers: [modelCheck.error] })
2144
+ }
2145
+ const result = armWorker({ bindAddress: body?.bindAddress, port: body?.port, taskCwd: body?.taskCwd })
2146
+ if (result.ok !== true) {
2147
+ const sandboxNow = sandbox()
2148
+ return send(409, {
2149
+ ok: false,
2150
+ error: result.error,
2151
+ blockers: result.blockers ?? [],
2152
+ sandbox: sandboxNow,
2153
+ prescription: sandboxNow?.prescription?.text ?? '',
2154
+ })
2155
+ }
2156
+ log.info('worker role armed by the page')
2157
+ return send(200, {
2158
+ ok: true,
2159
+ token: result.token,
2160
+ pairing: result.pairing,
2161
+ workerRole: armingStatus(),
2162
+ // Not a failure: the model exists but does not declare image input, which is a real
2163
+ // deployment choice. The page shows it rather than letting it stay silent.
2164
+ warning: modelCheck.warning,
2165
+ })
2166
+ }
2167
+
2168
+ case 'worker.disarm': {
2169
+ // The local emergency stop. It never contacts a master and never waits on one.
2170
+ const result = disarmWorker({ forget: body?.forget === true })
2171
+ log.info(`worker role disarmed by the page (forget=${body?.forget === true})`)
2172
+ return send(200, { ok: true, workerRole: armingStatus(), error: result?.ok === false ? String(result.error ?? '') : '' })
2173
+ }
2174
+
2175
+ case 'approvals.clear': {
2176
+ // Clearing the VIEW, never the trail: the audit file is append-only evidence, so this
2177
+ // records a watermark row and the reads start after it. The trail therefore keeps every
2178
+ // miss AND keeps the fact that somebody cleared the list -- which is itself worth
2179
+ // auditing, because it is the one action that makes past refusals stop being visible.
2180
+ if (audit === undefined) return send(503, { ok: false, error: 'this build has no audit trail' })
2181
+ const cleared = audit.clear(requestProvenance(req, body))
2182
+ log.info(`approval misses cleared from the page (${cleared})`)
2183
+ return send(200, { ok: true, cleared, missedCount: audit.missedCount, at: audit.clearedAt })
2184
+ }
2185
+
2186
+ case 'approvals.decide': {
2187
+ // The whole point of the queue: a `danger-full-access` request the engine refused
2188
+ // to justify can be allowed once, by a person, instead of failing outright.
2189
+ //
2190
+ // A decision for an entry that already settled is reported as a failure rather
2191
+ // than quietly accepted -- the page is showing a row that no longer exists, and
2192
+ // pretending otherwise would let a stale click look like a fresh approval.
2193
+ if (queue === undefined) return send(503, { ok: false, error: 'this build has no approval queue' })
2194
+ const result = queue.decide(String(body?.id ?? ''), body?.decision, requestProvenance(req, body))
2195
+ if (result.ok !== true) return send(409, { ok: false, error: result.error })
2196
+ return send(200, { ok: true, optionId: result.optionId, pending: queue.list() })
2197
+ }
2198
+
2199
+ default:
2200
+ return send(400, { ok: false, error: `unknown action "${action}"` })
2201
+ }
2202
+ } catch (error) {
2203
+ return send(502, { ok: false, error: error instanceof Error ? error.message : String(error) })
2204
+ }
2205
+ },
2206
+ }
2207
+ }