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