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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/README.zh.md +195 -0
  4. package/assets/icon.svg +47 -0
  5. package/cordis.patch.yml +41 -0
  6. package/lib/bridge/acp-bridge.js +905 -0
  7. package/lib/bridge/peer-connection.js +730 -0
  8. package/lib/client.js +2842 -0
  9. package/lib/core/acp-model-overlay.js +121 -0
  10. package/lib/core/approval-queue.js +216 -0
  11. package/lib/core/audit-log.js +322 -0
  12. package/lib/core/desktop-notify.js +173 -0
  13. package/lib/core/environment-prompt.js +76 -0
  14. package/lib/core/relay-supervisor.js +278 -0
  15. package/lib/core/review-engine.js +679 -0
  16. package/lib/core/sandbox-diagnosis.js +258 -0
  17. package/lib/core/sandbox-probe.js +310 -0
  18. package/lib/core/self-acp-spec.js +117 -0
  19. package/lib/core/selfcheck-runner.js +150 -0
  20. package/lib/core/settings-store.js +226 -0
  21. package/lib/core/shot-gc.js +334 -0
  22. package/lib/core/task-cwd.js +50 -0
  23. package/lib/core/task-memory.js +163 -0
  24. package/lib/core/task-registry.js +238 -0
  25. package/lib/core/toolcall-cache.js +206 -0
  26. package/lib/core/transfer-queue.js +58 -0
  27. package/lib/core/transfer.js +1144 -0
  28. package/lib/core/worker-arming.js +334 -0
  29. package/lib/index.js +2207 -0
  30. package/lib/provider/out-of-process-helpers.js +76 -0
  31. package/lib/provider/remote-provider.js +261 -0
  32. package/lib/tools/mediator-shim.mjs +180 -0
  33. package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
  34. package/lib/tools/remote-tools.js +680 -0
  35. package/lib/tools/selfcheck-fix-prompt.md +206 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +72 -4
  39. package/slave/acp-profile-approval.patch.yml +66 -0
  40. package/slave/file-transfer.mjs +552 -0
  41. package/slave/integrity-probe.ps1 +99 -0
  42. package/slave/make-launch-spec.mjs +184 -0
  43. package/slave/net-address.mjs +138 -0
  44. package/slave/node_modules/.package-lock.json +26 -0
  45. package/slave/node_modules/ws/LICENSE +20 -0
  46. package/slave/node_modules/ws/README.md +548 -0
  47. package/slave/node_modules/ws/browser.js +8 -0
  48. package/slave/node_modules/ws/index.js +22 -0
  49. package/slave/node_modules/ws/lib/buffer-util.js +131 -0
  50. package/slave/node_modules/ws/lib/constants.js +19 -0
  51. package/slave/node_modules/ws/lib/event-target.js +292 -0
  52. package/slave/node_modules/ws/lib/extension.js +203 -0
  53. package/slave/node_modules/ws/lib/limiter.js +55 -0
  54. package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
  55. package/slave/node_modules/ws/lib/receiver.js +743 -0
  56. package/slave/node_modules/ws/lib/sender.js +607 -0
  57. package/slave/node_modules/ws/lib/stream.js +161 -0
  58. package/slave/node_modules/ws/lib/subprotocol.js +62 -0
  59. package/slave/node_modules/ws/lib/validation.js +152 -0
  60. package/slave/node_modules/ws/lib/websocket-server.js +562 -0
  61. package/slave/node_modules/ws/lib/websocket.js +1421 -0
  62. package/slave/node_modules/ws/package.json +74 -0
  63. package/slave/node_modules/ws/wrapper.mjs +21 -0
  64. package/slave/pairing.mjs +175 -0
  65. package/slave/permission-pairing.mjs +240 -0
  66. package/slave/prune-shots.mjs +170 -0
  67. package/slave/relay.mjs +1351 -0
  68. package/slave/repair-workspace-integrity.ps1 +75 -0
  69. package/slave/screenshot.ps1 +114 -0
  70. package/slave/selfcheck.ps1 +350 -0
  71. package/slave/stage-app.ps1 +116 -0
  72. package/slave/workspace-integrity.mjs +157 -0
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Arming this machine as a remote worker.
3
+ *
4
+ * WHAT THIS OWNS, and what it deliberately does not. It owns the DECISION to accept remote
5
+ * execution, the credential, and the latch. It does not own the listener: `start`/`stop` are
6
+ * injected, so the state machine is complete and testable before a port is opened, and the
7
+ * listener cannot quietly acquire a policy of its own.
8
+ *
9
+ * THE SIX DECISIONS THIS ENCODES (§16.4):
10
+ * 1. LATCHED. Arming stays until it is explicitly disarmed. There is no timer that quietly
11
+ * stops accepting connections, because a remote party that is mid-task would experience
12
+ * that as a failure rather than as a policy.
13
+ * 2. A SPECIFIC LAN INTERFACE. `0.0.0.0` is refused, not merely discouraged -- see
14
+ * `slave/net-address.mjs`.
15
+ * 3. THE TOKEN IS SHOWN ONCE. `arm()` returns the plaintext exactly once; the status carries
16
+ * only a mask afterwards. It is persisted so a restart does not invalidate a pairing.
17
+ * 4. REFUSE WHEN THE SANDBOX DOES NOT CONTAIN. Not "warn and continue": a remote execution
18
+ * surface on an operator's workstation whose confinement is unverified is the thing this
19
+ * whole project exists to avoid.
20
+ * 5. LOCAL EMERGENCY STOP. `disarm()` never contacts the far end and never waits on it.
21
+ * 6. THE STATUS SAYS WHAT IS OPEN. Address, port, how long, and which credential is in use.
22
+ *
23
+ * WHY THE GATE RUNS THE PROBE SYNCHRONOUSLY AND EVERY TIME: the failure it looks for depends on
24
+ * the LAUNCH CONTEXT (a console-subsystem process allocates a console, a GUI-subsystem one does
25
+ * not), so a result cached from a previous run in a different context would be answering about
26
+ * a different machine.
27
+ */
28
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
29
+
30
+ import { assertBindable, candidateAddresses } from '../../slave/net-address.mjs'
31
+ import { encodePairing } from '../../slave/pairing.mjs'
32
+ import { isAbsoluteTaskPath } from './task-cwd.js'
33
+ import { probeSandbox } from './sandbox-probe.js'
34
+
35
+ /** Port used when the operator does not name one. Matches the reference worker's relay. */
36
+ export const DEFAULT_ARM_PORT = 7331
37
+
38
+ /** How long a stored token hint is. Enough to recognise a credential, not to use one. */
39
+ const HINT_CHARS = 4
40
+
41
+ /**
42
+ * @param {string} token
43
+ * @returns {string} a mask that reveals only the tail
44
+ */
45
+ export function maskToken(token) {
46
+ const value = String(token ?? '')
47
+ if (value === '') return ''
48
+ return `\u2022`.repeat(12) + value.slice(-HINT_CHARS)
49
+ }
50
+
51
+ /** A fresh credential. 32 bytes of CSPRNG output, hex, so it survives YAML and JSON. */
52
+ export function generateToken() {
53
+ return randomBytes(32).toString('hex')
54
+ }
55
+
56
+ /**
57
+ * Compare a presented token against the stored one without leaking its length or content
58
+ * through timing. Kept here rather than in the listener so the rule has one home.
59
+ *
60
+ * @param {string} stored
61
+ * @param {string} presented
62
+ * @returns {boolean}
63
+ */
64
+ export function tokenMatches(stored, presented) {
65
+ if (typeof stored !== 'string' || stored === '' || typeof presented !== 'string' || presented === '') return false
66
+ // Hash both sides first: `timingSafeEqual` throws on differing lengths, and the length itself
67
+ // is information. Comparing digests makes every comparison the same length.
68
+ const a = createHash('sha256').update(stored).digest()
69
+ const b = createHash('sha256').update(presented).digest()
70
+ return timingSafeEqual(a, b)
71
+ }
72
+
73
+ export class WorkerArming {
74
+ /**
75
+ * @param {object} deps
76
+ * @param {() => Record<string, any>} deps.interfaces `os.networkInterfaces()` in production
77
+ * @param {(state: object) => void} [deps.persist] write the armed state so it survives a restart
78
+ * @param {(bindAddress: string, port: number, token: string, taskCwd: string) => void} [deps.start]
79
+ * @param {() => void} [deps.stop]
80
+ * @param {() => { ok: boolean, reason: string, checks?: object[] }} [deps.probe] sandbox gate
81
+ * @param {() => number} [deps.now]
82
+ * @param {() => string} [deps.mint]
83
+ * @param {() => object | null} [deps.listenerStatus] the supervised listener's own status, if any
84
+ * @param {{ info?: Function, warn?: Function }} [deps.log]
85
+ * @param {object} [deps.stored] the persisted state, if any
86
+ */
87
+ constructor(deps = {}) {
88
+ this.interfaces = deps.interfaces ?? (() => ({}))
89
+ this.persist = deps.persist ?? (() => {})
90
+ this.start = deps.start ?? (() => {})
91
+ this.stop = deps.stop ?? (() => {})
92
+ this.probe = deps.probe ?? (() => probeSandbox())
93
+ this.listenerStatus = deps.listenerStatus ?? (() => null)
94
+ this.now = deps.now ?? (() => Date.now())
95
+ this.mint = deps.mint ?? generateToken
96
+ const log = deps.log ?? {}
97
+ this.log = { info: log.info ?? (() => {}), warn: log.warn ?? (() => {}) }
98
+
99
+ const stored = deps.stored ?? {}
100
+ this.armed = false
101
+ this.bindAddress = ''
102
+ this.port = DEFAULT_ARM_PORT
103
+ this.token = ''
104
+ // The directory this machine's tasks run in. It goes into the pairing string because the
105
+ // WORKER is the only side that can know a path on the worker -- the master learns it by paste
106
+ // or not at all. Kept as issued: the master owns the stored copy from then on.
107
+ this.taskCwd = ''
108
+ this.armedAt = 0
109
+ this.lastError = ''
110
+ this.lastProbe = { ok: false, reason: 'not checked yet' }
111
+
112
+ // The token is restored, NOT regenerated: decision 3 persists it so a restart does not
113
+ // invalidate a pairing the operator has already distributed. Regenerating here would be the
114
+ // quiet way to break every paired master on reboot.
115
+ if (stored !== null && typeof stored === 'object' && typeof stored.token === 'string' && stored.token !== '') {
116
+ this.token = stored.token
117
+ }
118
+ if (typeof stored?.bindAddress === 'string') this.bindAddress = stored.bindAddress
119
+ if (Number.isFinite(stored?.port)) this.port = Number(stored.port)
120
+ if (typeof stored?.taskCwd === 'string') this.taskCwd = stored.taskCwd
121
+ if (stored?.armed === true && this.token !== '' && this.bindAddress !== '') {
122
+ // THE GATE IS RE-RUN ON RESTORE, not skipped because it passed once.
123
+ //
124
+ // The sandbox is not a property of this plugin -- it is the mediator wired into the acp
125
+ // profile, and the mediator is materialized OUT of the DSH bundle. A DSH upgrade therefore
126
+ // invalidates it (the shim would drive the previous version's runner), and so does deleting
127
+ // the directory. Without this check, a machine that was armed last week would silently go on
128
+ // accepting remote execution after its confinement broke -- which is the exact situation
129
+ // decision 4 exists to prevent.
130
+ //
131
+ // It costs a probe, and only when the stored state says armed: a disarmed machine (the
132
+ // default) pays nothing at boot.
133
+ this.lastProbe = this.probe()
134
+ if (this.lastProbe.ok !== true) {
135
+ this.armed = false
136
+ this.lastError = `refused to restore remote execution: the sandbox no longer contains this machine (${this.lastProbe.reason})`
137
+ this.log.warn(this.lastError)
138
+ } else {
139
+ this.armed = true
140
+ this.armedAt = Number.isFinite(stored.armedAt) ? Number(stored.armedAt) : this.now()
141
+ try {
142
+ this.start(this.bindAddress, this.port, this.token, this.taskCwd)
143
+ this.log.info(`worker role restored: listening on ${this.bindAddress}:${this.port}`)
144
+ } catch (error) {
145
+ // A failed rebind must NOT leave the object claiming to be armed: the operator would
146
+ // see "accepting remote execution" while nothing is listening.
147
+ this.armed = false
148
+ this.lastError = `could not restore the listener: ${error instanceof Error ? error.message : String(error)}`
149
+ this.log.warn(this.lastError)
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ /** What the page shows. Never includes the token, only its mask. */
156
+ status() {
157
+ return {
158
+ armed: this.armed,
159
+ bindAddress: this.bindAddress,
160
+ port: this.port,
161
+ taskCwd: this.taskCwd,
162
+ tokenSet: this.token !== '',
163
+ tokenHint: maskToken(this.token),
164
+ armedAt: this.armedAt,
165
+ uptimeMs: this.armed && this.armedAt > 0 ? Math.max(0, this.now() - this.armedAt) : 0,
166
+ lastError: this.lastError,
167
+ // Every interface, including the ones that must not be chosen, so the page can explain
168
+ // rather than silently omit.
169
+ interfaces: candidateAddresses(this.interfaces()),
170
+ probe: { ok: this.lastProbe.ok, reason: this.lastProbe.reason },
171
+ // The listener's own view, including the session mode its launch spec carries. The arming
172
+ // object owns the decision to accept remote work; it does not own what the relay was started
173
+ // with, and merging the two here is what lets the page show BOTH instead of assuming they agree.
174
+ listener: this.listenerStatus(),
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Can this machine be armed at all, and is the requested address usable?
180
+ *
181
+ * Runs the sandbox probe EVERY time: the answer depends on the launch context, so a cached
182
+ * one is an answer about a different context.
183
+ *
184
+ * @param {{ bindAddress?: string, port?: number, taskCwd?: string }} [request]
185
+ * @returns {{ ok: boolean, blockers: string[], probe: object }}
186
+ */
187
+ preflight(request = {}) {
188
+ const blockers = []
189
+ this.lastProbe = this.probe()
190
+ if (this.lastProbe.ok !== true) {
191
+ blockers.push(`the sandbox does not contain this machine: ${this.lastProbe.reason}`)
192
+ }
193
+ const address = request.bindAddress ?? this.bindAddress
194
+ const bindable = assertBindable(address, this.interfaces())
195
+ if (!bindable.ok) blockers.push(bindable.error)
196
+ const port = Number(request.port ?? this.port ?? DEFAULT_ARM_PORT)
197
+ if (!Number.isInteger(port) || port < 1 || port > 65535) blockers.push(`port must be 1..65535, got ${port}`)
198
+ // A BLANK task directory is allowed -- the master can still be told one separately -- but a
199
+ // relative one is the same class of error as an unbindable address, and worse: it would go
200
+ // into the pairing string as if it were usable, and the master would have no reason to doubt
201
+ // it. Caught here rather than at the first delegation on the far machine.
202
+ const taskCwd = String(request.taskCwd ?? this.taskCwd).trim()
203
+ if (taskCwd !== '' && !isAbsoluteTaskPath(taskCwd)) {
204
+ blockers.push(`the task directory must be an absolute path on this machine, got "${taskCwd}"`)
205
+ }
206
+ return { ok: blockers.length === 0, blockers, probe: this.lastProbe }
207
+ }
208
+
209
+ /**
210
+ * Accept remote execution. Returns the plaintext token EXACTLY ONCE.
211
+ *
212
+ * @param {{ bindAddress: string, port?: number, taskCwd?: string }} request
213
+ * @returns {{ ok: boolean, token?: string, error?: string, blockers?: string[] }}
214
+ */
215
+ arm(request) {
216
+ if (this.armed) {
217
+ // Not an error worth hiding: the page may be showing a stale state, and re-minting a token
218
+ // here would break a pairing the operator has already distributed.
219
+ return { ok: false, error: `already armed on ${this.bindAddress}:${this.port}` }
220
+ }
221
+ const pre = this.preflight(request)
222
+ if (!pre.ok) return { ok: false, error: pre.blockers[0], blockers: pre.blockers }
223
+
224
+ const bindAddress = String(request.bindAddress).trim()
225
+ const port = Number(request.port ?? DEFAULT_ARM_PORT)
226
+ const taskCwd = String(request.taskCwd ?? '').trim()
227
+
228
+ // A NEW credential every time it is armed, because decision 3 is "generated WHEN ENABLED".
229
+ //
230
+ // The persisted one exists for a different event: a RESTART. Arming is the operator saying
231
+ // "accept remote execution now", and a credential that survived an explicit stop is one they
232
+ // have no reason to believe is still valid. The distinction matters because the two cases look
233
+ // identical in the settings file -- `armed` is false and a token is present either way -- so
234
+ // only the arm path can tell them apart.
235
+ //
236
+ // Caught by reading the code against its own comment: the comment said this, the line did
237
+ // `this.token === '' ? this.mint() : this.token`, and a disarm-then-arm left the old pairing
238
+ // working. A test now asserts the rotation.
239
+ const token = this.mint()
240
+
241
+ try {
242
+ this.start(bindAddress, port, token, taskCwd)
243
+ } catch (error) {
244
+ const message = error instanceof Error ? error.message : String(error)
245
+ this.lastError = `could not start the listener: ${message}`
246
+ return { ok: false, error: this.lastError }
247
+ }
248
+
249
+ this.armed = true
250
+ this.bindAddress = bindAddress
251
+ this.port = port
252
+ this.taskCwd = taskCwd
253
+ this.token = token
254
+ this.armedAt = this.now()
255
+ this.lastError = ''
256
+ this.#persist()
257
+ this.log.info(`worker role armed on ${bindAddress}:${port}`)
258
+ // Both forms, ONCE. The caller shows them and keeps neither; every later read of the status
259
+ // carries only the mask.
260
+ return { ok: true, token, pairing: this.pairingString() }
261
+ }
262
+
263
+ /**
264
+ * Stop accepting remote execution. Decision 5: local, immediate, and never dependent on the
265
+ * far end -- this is the switch that must work when the remote party is misbehaving.
266
+ *
267
+ * The token is KEPT in storage so a restart restores the same pairing; it is cleared in
268
+ * memory so the running process cannot accept it again.
269
+ *
270
+ * @param {{ forget?: boolean }} [opts] `forget` also invalidates the credential
271
+ */
272
+ disarm(opts = {}) {
273
+ const wasArmed = this.armed
274
+ this.armed = false
275
+ this.armedAt = 0
276
+ try {
277
+ this.stop()
278
+ } catch (error) {
279
+ // Report but do not re-arm: a listener that failed to stop must not leave the object
280
+ // claiming to be safe, and the operator needs to know which it is.
281
+ this.lastError = `the listener did not stop cleanly: ${error instanceof Error ? error.message : String(error)}`
282
+ this.log.warn(this.lastError)
283
+ }
284
+ if (opts.forget === true) this.token = ''
285
+ this.#persist()
286
+ if (wasArmed) this.log.info(`worker role disarmed (${opts.forget === true ? 'credential discarded' : 'credential kept'})`)
287
+ return { ok: this.lastError === '', wasArmed }
288
+ }
289
+
290
+ /**
291
+ * The pairing string for the credential in force, or '' when disarmed.
292
+ *
293
+ * Returned alongside the token at arm time and never again. The string carries the credential
294
+ * in recoverable form, so treating it as re-readable would undo decision 3 -- the point of
295
+ * "shown once" is that the secret is not sitting in a page that can be screenshotted or left
296
+ * open. Re-issuing is a deliberate act: `disarm({ forget: true })` then `arm()`.
297
+ *
298
+ * It carries the TASK DIRECTORY too. That is not decoration: the master cannot guess a path on
299
+ * this machine, and a worker paired without one cannot accept a task at all. Leaving it out is
300
+ * how pairing produced a worker that failed on its first delegation instead of at the paste
301
+ * (PITFALLS F16). `encodePairing` omits the field when it is empty, so a worker that has no
302
+ * opinion still produces a valid string -- the master must then be told one.
303
+ */
304
+ pairingString() {
305
+ if (!this.armed || this.token === '') return ''
306
+ return encodePairing({
307
+ url: `ws://${this.bindAddress}:${this.port}`,
308
+ token: this.token,
309
+ cwd: this.taskCwd,
310
+ })
311
+ }
312
+
313
+ /** Whether a presented credential is the one in force. */
314
+ accepts(presented) {
315
+ if (!this.armed) return false
316
+ return tokenMatches(this.token, presented)
317
+ }
318
+
319
+ #persist() {
320
+ try {
321
+ this.persist({
322
+ armed: this.armed,
323
+ bindAddress: this.bindAddress,
324
+ port: this.port,
325
+ taskCwd: this.taskCwd,
326
+ token: this.token,
327
+ armedAt: this.armedAt,
328
+ })
329
+ } catch (error) {
330
+ this.lastError = `could not persist the worker role: ${error instanceof Error ? error.message : String(error)}`
331
+ this.log.warn(this.lastError)
332
+ }
333
+ }
334
+ }