@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,730 @@
1
+ /**
2
+ * Connection manager for one worker.
3
+ *
4
+ * WHY THIS EXISTS: `AcpBridge` is a passive client -- it does nothing until `connect()`
5
+ * is called, and `startTask()` / `callPlugin()` throw "not connected" otherwise. The
6
+ * first shipped version of this plugin built a bridge and used it directly, so EVERY path
7
+ * (self-check and dispatch alike) failed with `AcpBridge.callPlugin: not connected`. The
8
+ * unit and integration tests missed it because they injected bridges that were already
9
+ * connected; only a real profile load exposed it.
10
+ *
11
+ * This manager OWNS the bridge lifecycle instead of wrapping a long-lived instance, which
12
+ * is what makes recovery real:
13
+ *
14
+ * - lazy: the worker may be off or still booting when the host starts, so connecting at
15
+ * load time would be both pointless and fatal (one failure would strand the plugin);
16
+ * - single-flight: two concurrent delegations must not race into two connects;
17
+ * - self-healing: a transport failure DISPOSES the bridge rather than merely flagging it,
18
+ * because `AcpBridge` has no way to re-arm itself -- `connect()` on a live instance
19
+ * throws "already connected". Dropping it lets the next call build a fresh one.
20
+ *
21
+ * The surface matches what the provider already expects (`startTask` / `cancel` /
22
+ * `selfcheck` / `dispose` / `connected` / `stats`), so the provider cannot tell the
23
+ * difference between a manager and a raw bridge.
24
+ *
25
+ * @module dsh-plugin-remote-use/peer-connection
26
+ */
27
+
28
+ import { mkdirSync, writeFileSync } from 'node:fs'
29
+ import { DEFAULT_KEEP_SHOTS, DEFAULT_SHOT_CACHE_BYTES, clearShots, pruneShots, scanShotUsage } from '../core/shot-gc.js'
30
+ import {
31
+ DEFAULT_TRANSFER_MAX_BYTES,
32
+ TRANSFER_CHUNK_BYTES,
33
+ abortTransfer,
34
+ defaultReceiveDir,
35
+ downloadFile,
36
+ listFiles,
37
+ statFile,
38
+ uploadFile,
39
+ } from '../core/transfer.js'
40
+ import { join } from 'node:path'
41
+ import { tmpdir } from 'node:os'
42
+
43
+ /** Where downloaded worker screenshots land. The host's image reader must be able to open it. */
44
+ export function defaultShotDir() {
45
+ return join(tmpdir(), 'dsh-remote-screenshots')
46
+ }
47
+
48
+ /**
49
+ * Liveness probe interval.
50
+ *
51
+ * WHY A HEARTBEAT: `AcpBridge` does not watch its socket, and the relay closes it on
52
+ * restart, so `connected` keeps reporting true over a dead link. Without a probe the first
53
+ * call after any worker restart fails -- measured, and the failure surfaced as the
54
+ * misleading "nobody is logged in" before that was fixed. A cheap periodic ping turns a
55
+ * silent death into a prompt reconnect.
56
+ */
57
+ const DEFAULT_HEARTBEAT_MS = 15_000
58
+ const DEFAULT_HEARTBEAT_TIMEOUT_MS = 5_000
59
+
60
+ /**
61
+ * Turn a WebSocket URL into its HTTP counterpart on the same relay:
62
+ * `ws://h:p` -> `http://h:p`, `wss://h:p` -> `https://h:p`.
63
+ * @param {string} wsUrl
64
+ * @returns {string} '' when the URL is unusable
65
+ */
66
+ export function httpBaseOf(wsUrl) {
67
+ if (typeof wsUrl !== 'string' || wsUrl === '') return ''
68
+ try {
69
+ const u = new URL(wsUrl)
70
+ u.protocol = u.protocol === 'wss:' ? 'https:' : 'http:'
71
+ return `${u.protocol}//${u.host}`
72
+ } catch {
73
+ return ''
74
+ }
75
+ }
76
+
77
+ export class PeerConnection {
78
+ /**
79
+ * @param {object} opts
80
+ * @param {string} opts.peerId
81
+ * @param {() => object} opts.createBridge builds a fresh, unconnected bridge
82
+ * @param {(level: 'info'|'warn'|'error', message: string) => void} [opts.log]
83
+ * @param {() => number} [opts.now]
84
+ * @param {string} [opts.httpBase] relay HTTP origin, for binary endpoints
85
+ * @param {string} [opts.token] bearer for those endpoints
86
+ * @param {string} [opts.shotDir] where downloaded screenshots are written
87
+ * @param {number} [opts.shotCacheBytes] byte ceiling for those downloads; <= 0 disables it
88
+ * @param {string} [opts.receiveDir] where received FILES are written (never automatically pruned)
89
+ * @param {number} [opts.transferMaxBytes] host-side refusal ceiling for ONE transfer
90
+ * @param {typeof fetch} [opts.fetchImpl]
91
+ * @param {number} [opts.heartbeatMs] liveness probe interval; 0 disables it
92
+ * @param {number} [opts.heartbeatTimeoutMs]
93
+ */
94
+ constructor(opts) {
95
+ this.peerId = opts.peerId
96
+ this.createBridge = opts.createBridge
97
+ this.log = opts.log ?? (() => {})
98
+ this.now = opts.now ?? (() => Date.now())
99
+ this.httpBase = opts.httpBase ?? ''
100
+ this.token = opts.token ?? ''
101
+ this.shotDir = opts.shotDir ?? defaultShotDir()
102
+ /** How many of this worker's screenshots to keep on disk. See core/shot-gc.js. */
103
+ this.keepShots = Number.isFinite(opts.keepShots) ? Number(opts.keepShots) : DEFAULT_KEEP_SHOTS
104
+ /**
105
+ * Byte ceiling for this worker's HOST-side downloads. <= 0 (the default) means the count
106
+ * budget is the only bound, which is what an install upgrading into this feature expects: a
107
+ * byte budget that appeared on its own would start deleting captures nobody agreed to lose.
108
+ */
109
+ this.shotCacheBytes = Number.isFinite(opts.shotCacheBytes)
110
+ ? Number(opts.shotCacheBytes)
111
+ : DEFAULT_SHOT_CACHE_BYTES
112
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch
113
+ /**
114
+ * Where files pulled from this worker land.
115
+ *
116
+ * WHY NOT `shotDir`: that one is in the system temp directory and IS pruned by `pruneShots`. A
117
+ * received file is output the operator explicitly asked for, so it lives under the plugin's own
118
+ * state directory and is never deleted automatically -- silently removing requested output is a
119
+ * different act from reclaiming a cache, and the receive directory therefore has no GC.
120
+ */
121
+ this.receiveDir = opts.receiveDir ?? defaultReceiveDir()
122
+ /**
123
+ * Host-side ceiling for ONE transfer, checked BEFORE anything is sent (see core/transfer.js).
124
+ *
125
+ * Captured at construction, like `keepShots` and for the same reason: the refusal has to be
126
+ * answerable before a single byte is read, and a budget that changed mid-transfer would leave
127
+ * "was this allowed?" unanswerable after the fact.
128
+ */
129
+ this.transferMaxBytes = Number.isFinite(Number(opts.transferMaxBytes)) && opts.transferMaxBytes !== null && opts.transferMaxBytes !== undefined
130
+ ? Number(opts.transferMaxBytes)
131
+ : DEFAULT_TRANSFER_MAX_BYTES
132
+ /**
133
+ * How much of a file travels per PUT/GET, i.e. the resume granularity.
134
+ *
135
+ * It has to be forwarded explicitly: the core reads `opts.chunkBytes` (see its
136
+ * `chunkBytes` default), but an option a caller passes and the connection drops is a setting
137
+ * that silently does nothing -- the master would honour `transferChunkBytes` everywhere except
138
+ * on the wire.
139
+ */
140
+ this.transferChunkBytes = Number.isFinite(Number(opts.transferChunkBytes)) && Number(opts.transferChunkBytes) > 0
141
+ ? Math.floor(Number(opts.transferChunkBytes))
142
+ : TRANSFER_CHUNK_BYTES
143
+ this.heartbeatMs = Number.isFinite(opts.heartbeatMs) ? Math.max(0, Number(opts.heartbeatMs)) : DEFAULT_HEARTBEAT_MS
144
+ this.heartbeatTimeoutMs = Number.isFinite(opts.heartbeatTimeoutMs)
145
+ ? Math.max(1, Number(opts.heartbeatTimeoutMs))
146
+ : DEFAULT_HEARTBEAT_TIMEOUT_MS
147
+
148
+ /** @type {object | undefined} */
149
+ this.bridge = undefined
150
+ /** @type {Promise<void> | undefined} in-flight connect, shared by concurrent callers */
151
+ this.connecting = undefined
152
+ this.connects = 0
153
+ this.connectFailures = 0
154
+ this.lastError = ''
155
+ this.lastConnectedAt = 0
156
+ this.disposed = false
157
+
158
+ /** @type {ReturnType<typeof setInterval> | undefined} */
159
+ this.heartbeatTimer = undefined
160
+ this.heartbeatBeating = false
161
+ this.heartbeats = 0
162
+ this.heartbeatFailures = 0
163
+ this.lastHeartbeatAt = 0
164
+ }
165
+
166
+ /** True when a bridge exists and reports an established session. */
167
+ get connected() {
168
+ return this.bridge !== undefined && this.bridge.connected === true
169
+ }
170
+
171
+ /** Observable counters for the settings route and the audit trail. */
172
+ get stats() {
173
+ return {
174
+ peerId: this.peerId,
175
+ connected: this.connected,
176
+ connects: this.connects,
177
+ connectFailures: this.connectFailures,
178
+ lastError: this.lastError,
179
+ lastConnectedAt: this.lastConnectedAt,
180
+ heartbeatMs: this.heartbeatMs,
181
+ heartbeats: this.heartbeats,
182
+ heartbeatFailures: this.heartbeatFailures,
183
+ lastHeartbeatAt: this.lastHeartbeatAt,
184
+ ...(this.bridge?.stats !== undefined && this.bridge.stats !== null ? { bridge: this.bridge.stats } : {}),
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Ensure an established connection.
190
+ *
191
+ * A failure clears the shared promise so the NEXT call retries; without that, one
192
+ * transient error (worker still booting) would pin the rejection for every later call.
193
+ *
194
+ * @returns {Promise<void>}
195
+ */
196
+ async ensure() {
197
+ if (this.disposed) throw new Error(`PeerConnection(${this.peerId}): disposed`)
198
+ if (this.connected) return
199
+
200
+ if (this.connecting === undefined) {
201
+ this.connecting = (async () => {
202
+ // Build a bridge here rather than in the constructor: a previous attempt may have
203
+ // been discarded, and `connect()` cannot be retried on a used instance.
204
+ if (this.bridge === undefined) this.bridge = this.createBridge()
205
+ this.connects += 1
206
+ this.log('info', `connecting to worker "${this.peerId}" (attempt ${this.connects})`)
207
+ await this.bridge.connect()
208
+ this.lastConnectedAt = this.now()
209
+ this.lastError = ''
210
+ this.log('info', `connected to worker "${this.peerId}"`)
211
+ this.#startHeartbeat()
212
+ })().catch((error) => {
213
+ this.connectFailures += 1
214
+ this.lastError = error instanceof Error ? error.message : String(error)
215
+ this.log('warn', `worker "${this.peerId}" connect failed: ${this.lastError}`)
216
+ throw error
217
+ }).finally(() => {
218
+ // Always clear: on success the `connected` flag takes over, on failure the next
219
+ // caller must be free to start a fresh attempt.
220
+ this.connecting = undefined
221
+ })
222
+ }
223
+ return await this.connecting
224
+ }
225
+
226
+ /**
227
+ * Ask the worker for a readiness report, connecting first.
228
+ * @param {{ maxAgeMs?: number, timeoutMs?: number }} [opts]
229
+ */
230
+ async selfcheck(opts = {}) {
231
+ await this.ensure()
232
+ if (typeof this.bridge?.selfcheck !== 'function') {
233
+ throw new Error(`PeerConnection(${this.peerId}): bridge does not implement selfcheck`)
234
+ }
235
+ try {
236
+ return await this.bridge.selfcheck(opts)
237
+ } catch (error) {
238
+ await this.#invalidateIfTransportError(error)
239
+ throw error
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Run one task on the worker, connecting first. Per-call callbacks pass through
245
+ * unchanged; the bridge resolves them per task.
246
+ * @param {object} req
247
+ */
248
+ async startTask(req) {
249
+ await this.ensure()
250
+ try {
251
+ return await this.bridge.startTask(req)
252
+ } catch (error) {
253
+ await this.#invalidateIfTransportError(error)
254
+ throw error
255
+ }
256
+ }
257
+
258
+ /** Cancel the worker's current run. Never throws for a missing connection. */
259
+ async cancel() { if (!this.connected) {
260
+ // Nothing to cancel on a link that was never established: do not connect purely to
261
+ // deliver a cancel.
262
+ this.log('warn', `cancel for "${this.peerId}" ignored: not connected`)
263
+ return
264
+ }
265
+ try {
266
+ await this.bridge.cancel()
267
+ } catch (error) {
268
+ await this.#invalidateIfTransportError(error)
269
+ this.log('warn', `cancel on "${this.peerId}" failed: ${error instanceof Error ? error.message : String(error)}`)
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Capture the worker's desktop and save the PNG locally.
275
+ *
276
+ * WHY HTTP AND NOT THE RPC: the relay exposes both, but the bytes must not travel as
277
+ * base64 inside a JSON-RPC result -- that inflates a multi-megabyte PNG by a third and
278
+ * would have to pass through the ACP message pump, which exists for small control
279
+ * frames. The RPC returns metadata; `GET /screenshot` returns the image. One download
280
+ * gives both (geometry is in the response headers), so no RPC round trip is needed.
281
+ *
282
+ * The file is written where the HOST's image reader can open it, which is what lets the
283
+ * model actually look at the worker's screen rather than only read about it.
284
+ *
285
+ * @param {{ fresh?: boolean, timeoutMs?: number }} [opts]
286
+ * @returns {Promise<{ localPath: string, width: number, height: number, bytes: number,
287
+ * displays: number, capturedAt: string, elapsedMs: number }>}
288
+ */
289
+ async screenshot(opts = {}) {
290
+ if (this.httpBase === '') {
291
+ throw new Error(`PeerConnection(${this.peerId}): no HTTP base derived from the worker URL`)
292
+ }
293
+ if (typeof this.fetchImpl !== 'function') {
294
+ throw new Error('PeerConnection: no fetch implementation available')
295
+ }
296
+
297
+ const url = `${this.httpBase}/screenshot${opts.fresh === false ? '' : '?fresh=1'}`
298
+ const controller = new AbortController()
299
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? Number(opts.timeoutMs) : 60_000
300
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
301
+ if (typeof timer.unref === 'function') timer.unref()
302
+
303
+ let res
304
+ try {
305
+ res = await this.fetchImpl(url, {
306
+ headers: this.token === '' ? {} : { authorization: `Bearer ${this.token}` },
307
+ signal: controller.signal,
308
+ })
309
+ } catch (error) {
310
+ throw new Error(`worker "${this.peerId}" screenshot request failed: ${error instanceof Error ? error.message : String(error)}`)
311
+ } finally {
312
+ clearTimeout(timer)
313
+ }
314
+
315
+ if (res.status !== 200) {
316
+ let detail = ''
317
+ try { detail = String(await res.text()).slice(0, 200) } catch { /* body may be empty */ }
318
+ throw new Error(`worker "${this.peerId}" screenshot returned HTTP ${res.status}${detail === '' ? '' : `: ${detail}`}`)
319
+ }
320
+
321
+ const buf = Buffer.from(await res.arrayBuffer())
322
+ if (buf.length === 0) throw new Error(`worker "${this.peerId}" screenshot was empty`)
323
+
324
+ mkdirSync(this.shotDir, { recursive: true })
325
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
326
+ const localPath = join(this.shotDir, `${this.peerId}-${stamp}.png`)
327
+ writeFileSync(localPath, buf)
328
+
329
+ // Housekeeping. A worker screenshot is a full-resolution PNG (~9.5 MB measured), so an
330
+ // afternoon of GUI verification fills a disk. This runs AFTER the file is safely written
331
+ // and never throws: failing to delete an older capture must not fail this one. The outcome
332
+ // is reported so the page can show that space was reclaimed.
333
+ const pruned = pruneShots(this.shotDir, this.peerId, this.keepShots, { maxBytes: this.shotCacheBytes })
334
+
335
+ const num = (name) => {
336
+ const v = Number(res.headers.get(name))
337
+ return Number.isFinite(v) ? v : 0
338
+ }
339
+ return {
340
+ localPath,
341
+ width: num('x-shot-width'),
342
+ height: num('x-shot-height'),
343
+ bytes: buf.length,
344
+ displays: num('x-shot-displays'),
345
+ capturedAt: res.headers.get('x-shot-captured-at') ?? '',
346
+ elapsedMs: num('x-shot-ms'),
347
+ // Reported rather than silent: an operator who wonders where the disk went should be
348
+ // able to see that the plugin is reclaiming its own files, and how many it kept.
349
+ pruned,
350
+ }
351
+ }
352
+
353
+ /**
354
+ * The HTTP face of this worker's relay, wired for one transfer.
355
+ *
356
+ * WHY ONE PLACE: every file endpoint needs the same three things -- the derived origin, the bearer
357
+ * header and the fetch that is actually in use -- and the three must agree. Two copies of this
358
+ * (one per direction) is how a `wss://` worker ends up being uploaded to over plain `http://`, or
359
+ * how a test that injected `fetchImpl` silently falls back to the global one.
360
+ */
361
+ #httpChannel() {
362
+ if (this.httpBase === '') {
363
+ throw new Error(`PeerConnection(${this.peerId}): no HTTP base derived from the worker URL`)
364
+ }
365
+ if (typeof this.fetchImpl !== 'function') {
366
+ throw new Error('PeerConnection: no fetch implementation available')
367
+ }
368
+ const fetchImpl = this.fetchImpl
369
+ return {
370
+ base: this.httpBase,
371
+ authHeaders: () => (this.token === '' ? {} : { authorization: `Bearer ${this.token}` }),
372
+ head: (url, init) => fetchImpl(url, { ...init, method: 'HEAD' }),
373
+ put: (url, init) => fetchImpl(url, init),
374
+ post: (url, init) => fetchImpl(url, init),
375
+ get: (url, init) => fetchImpl(url, init),
376
+ del: (url, init) => fetchImpl(url, init),
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Send one local file to the worker.
382
+ *
383
+ * WHY IT DOES NOT GO THROUGH THE RPC: the payload would have to be base64 inside a JSON-RPC result
384
+ * (a third larger) and would cross the ACP message pump, which exists for small control frames --
385
+ * the same reasoning that put screenshots on HTTP. Bytes travel on the relay's HTTP side.
386
+ *
387
+ * WHY IT SKIPS `ensure()`: an upload must work while the agent is busy, and a worker whose link is
388
+ * down can still accept one, so requiring a session would refuse a transfer the relay would have
389
+ * taken. That is the same choice `screenshot()` makes.
390
+ *
391
+ * @param {{ localPath: string, remotePath: string, overwrite?: boolean, timeoutMs?: number,
392
+ * signal?: AbortSignal, onProgress?: Function }} req
393
+ * @returns {Promise<object>} the core's upload report, plus `peerId` and `httpBase`
394
+ */
395
+ async sendFile(req) {
396
+ const result = await uploadFile({
397
+ peerId: this.peerId,
398
+ localPath: req.localPath,
399
+ remotePath: req.remotePath,
400
+ channel: this.#httpChannel(),
401
+ overwrite: req.overwrite === true,
402
+ maxBytes: Number.isFinite(req.maxBytes) ? Number(req.maxBytes) : this.transferMaxBytes,
403
+ chunkBytes: Number.isFinite(req.chunkBytes) ? Number(req.chunkBytes) : this.transferChunkBytes,
404
+ timeoutMs: req.timeoutMs,
405
+ signal: req.signal,
406
+ onProgress: req.onProgress,
407
+ })
408
+ return { ...result, peerId: this.peerId, httpBase: this.httpBase }
409
+ }
410
+
411
+ /**
412
+ * Pull one remote file into `receiveDir` (or `destPath`), atomically.
413
+ *
414
+ * The default destination is derived from the REMOTE file's own name so a download the operator
415
+ * did not name still lands under the name the worker knows -- and gets a timestamp prefix, because
416
+ * two pulls of the same path must not silently overwrite each other. The `.part` staging file the
417
+ * core uses is what makes an interrupted pull resumable and the destination path trustworthy.
418
+ *
419
+ * @param {{ remotePath: string, destPath?: string, overwrite?: boolean, timeoutMs?: number,
420
+ * signal?: AbortSignal, onProgress?: Function }} req
421
+ * @returns {Promise<object>} the core's download report, plus `peerId` and `receiveDir`
422
+ */
423
+ async fetchFile(req) {
424
+ const remotePath = String(req.remotePath ?? '')
425
+ const destPath = typeof req.destPath === 'string' && req.destPath !== ''
426
+ ? req.destPath
427
+ : join(this.receiveDir, this.#receivedName(remotePath))
428
+ const result = await downloadFile({
429
+ peerId: this.peerId,
430
+ remotePath,
431
+ destPath,
432
+ channel: this.#httpChannel(),
433
+ overwrite: req.overwrite === true,
434
+ maxBytes: Number.isFinite(req.maxBytes) ? Number(req.maxBytes) : this.transferMaxBytes,
435
+ chunkBytes: Number.isFinite(req.chunkBytes) ? Number(req.chunkBytes) : this.transferChunkBytes,
436
+ timeoutMs: req.timeoutMs,
437
+ signal: req.signal,
438
+ onProgress: req.onProgress,
439
+ })
440
+ return { ...result, peerId: this.peerId, httpBase: this.httpBase, receiveDir: this.receiveDir }
441
+ }
442
+
443
+ /**
444
+ * A collision-free local name for an unnamed download.
445
+ *
446
+ * The worker's own basename is kept -- it is the name the operator will recognise -- but prefixed
447
+ * with the peer id and a stamp, so two workers sending `report.pdf`, or the same worker sending it
448
+ * twice, cannot land on one path. A trailing separator is dropped so a remote directory path cannot
449
+ * produce a name that is only a directory reference.
450
+ */
451
+ #receivedName(remotePath) {
452
+ const last = String(remotePath).replace(/[\\/]+$/u, '').split(/[\\/]/u).pop() ?? ''
453
+ // The remote basename reaches the FILESYSTEM here, so the shapes that are not writable names are
454
+ // replaced rather than passed through: an empty result or a traversal token would make a path
455
+ // that means something other than "a file in the receive directory", and on Windows the reserved
456
+ // device names (`NUL`, `CON`, `COM1`...) address hardware, not files -- a download that named
457
+ // itself `NUL` would fail with an error that looks nothing like a naming problem.
458
+ const safe = last === '' || last === '.' || last === '..' || /^(?:nul|con|prn|aux|com[1-9]|lpt[1-9])(?:\..*)?$/iu.test(last)
459
+ ? 'file'
460
+ : last
461
+ const stamp = new Date(this.now()).toISOString().replace(/[:.]/g, '-')
462
+ return `${this.peerId}-${stamp}-${safe}`
463
+ }
464
+
465
+ /**
466
+ * Ask the worker what it knows about one path.
467
+ *
468
+ * A small JSON question over the RPC channel the plugin already owns: existence and size are a
469
+ * handful of bytes, and reusing the channel means no new endpoint and no second thing to
470
+ * authenticate. `HEAD /file` is deliberately NOT used here -- it is kept for the upload pre-flight,
471
+ * where the response headers are the point.
472
+ *
473
+ * @param {string} path
474
+ * @param {{ timeoutMs?: number }} [opts]
475
+ */
476
+ async statFile(path, opts = {}) {
477
+ await this.ensure()
478
+ if (typeof this.bridge?.callPlugin !== 'function') {
479
+ throw new Error(`PeerConnection(${this.peerId}): bridge does not implement callPlugin`)
480
+ }
481
+ return await this.#withRpc((client) => statFile(client, path, opts), opts)
482
+ }
483
+
484
+ /**
485
+ * List one directory on the worker.
486
+ * @param {string} path
487
+ * @param {{ limit?: number, timeoutMs?: number }} [opts]
488
+ */
489
+ async listFiles(path, opts = {}) {
490
+ await this.ensure()
491
+ if (typeof this.bridge?.callPlugin !== 'function') {
492
+ throw new Error(`PeerConnection(${this.peerId}): bridge does not implement callPlugin`)
493
+ }
494
+ return await this.#withRpc((client) => listFiles(client, path, opts), opts)
495
+ }
496
+
497
+ /**
498
+ * Run one transfer RPC, discarding the link only when the failure was the link's fault.
499
+ *
500
+ * A worker that does not know the method yet (an older relay) is a HEALTHY worker with a missing
501
+ * capability: tearing the session down over it would turn "this relay is older than the feature"
502
+ * into a reconnect loop, which is precisely the reading `workerShotCache` already makes.
503
+ *
504
+ * The call's own options (`limit`) stay the CALLER's business -- only the timeout is pulled out for
505
+ * the transport. Collapsing the two once dropped `listFiles`'s limit on the floor, which produces
506
+ * an unfiltered directory listing that looks like it honoured a limit it never sent.
507
+ */
508
+ async #withRpc(run, opts = {}) {
509
+ const client = { rpc: (method, params, rpcOpts) => this.bridge.callPlugin(method, params, rpcOpts) }
510
+ const rpcOpts = Number.isFinite(opts.timeoutMs) ? { timeoutMs: Number(opts.timeoutMs) } : {}
511
+ try {
512
+ return await run(client, rpcOpts)
513
+ } catch (error) {
514
+ await this.#invalidateIfTransportError(error)
515
+ throw error
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Give up on a transfer by deleting the WORKER's staging file.
521
+ *
522
+ * The contract makes this safe to offer: `DELETE /file` removes `<path>.part` and never the target,
523
+ * so the worst outcome of cancelling is losing resume progress. A partial upload therefore cannot be
524
+ * mistaken for a finished one later, and the operator cannot accidentally delete real data by
525
+ * cancelling.
526
+ *
527
+ * @param {{ remotePath: string, timeoutMs?: number, signal?: AbortSignal }} req
528
+ */
529
+ async abortTransfer(req) {
530
+ const result = await abortTransfer({
531
+ peerId: this.peerId,
532
+ remotePath: req.remotePath,
533
+ channel: this.#httpChannel(),
534
+ timeoutMs: req.timeoutMs,
535
+ signal: req.signal,
536
+ })
537
+ return { ...result, peerId: this.peerId }
538
+ }
539
+
540
+ /**
541
+ * What this worker's screenshots occupy on the HOST, plus what the budget says it should.
542
+ *
543
+ * Kept separate from the relay call below because the two answer different questions: this one
544
+ * describes the copies the page and the model actually read, and the relay's describes the
545
+ * copies sitting on the worker. Both are worth showing -- an operator clearing 218 MB of host
546
+ * cache and finding the worker still full would rightly call the button a lie.
547
+ *
548
+ * @param {{ maxBytes?: number }} [opts]
549
+ */
550
+ hostShotCache(opts = {}) {
551
+ const usage = scanShotUsage(this.shotDir, this.peerId)
552
+ return {
553
+ ...usage,
554
+ dir: this.shotDir,
555
+ keep: this.keepShots,
556
+ maxBytes: Number.isFinite(opts.maxBytes) ? Number(opts.maxBytes) : this.shotCacheBytes,
557
+ }
558
+ }
559
+
560
+ /** Apply the budgets now and report what was reclaimed. Never throws. */
561
+ pruneHostShotCache(opts = {}) {
562
+ const maxBytes = Number.isFinite(opts.maxBytes) ? Number(opts.maxBytes) : this.shotCacheBytes
563
+ const pruned = pruneShots(this.shotDir, this.peerId, this.keepShots, { maxBytes })
564
+ return { ...pruned, dir: this.shotDir, maxBytes }
565
+ }
566
+
567
+ /** Delete this worker's host-side downloads. Never throws. */
568
+ clearHostShotCache() {
569
+ const cleared = clearShots(this.shotDir, this.peerId)
570
+ return { ...cleared, dir: this.shotDir }
571
+ }
572
+
573
+ /**
574
+ * Whether the worker's relay can answer the cache RPC at all.
575
+ *
576
+ * WHY A GETTER RATHER THAN LETTING THE CALLER TEST THE METHOD: the same fact -- "this worker
577
+ * cannot report its cache" -- has to be read the same way by the page's snapshot and by the
578
+ * page's Clear button. When the two decided differently (the snapshot treated it as an optional
579
+ * capability and reported `null`, while the action path called the method blindly and turned the
580
+ * TypeError into a 502) a worker whose relay predates this feature looked healthy on load and
581
+ * broken when the operator clicked. One question, one answer.
582
+ *
583
+ * A relay that is older than this feature is a HEALTHY worker with a missing report, not a
584
+ * failure: the link works, the captures work, only the enquiry is unavailable.
585
+ */
586
+ get supportsWorkerShotCache() {
587
+ return this.connected && typeof this.bridge?.callPlugin === 'function'
588
+ }
589
+
590
+ /**
591
+ * Ask the worker what ITS screenshot directory occupies, and optionally make it clean up.
592
+ *
593
+ * WHY IT GOES OVER THE RELAY RATHER THAN HTTP: the numbers are a handful of integers and they
594
+ * ride the JSON-RPC channel the plugin already owns, so this needs no new endpoint, no new
595
+ * authorization path, and no second thing to keep authenticated.
596
+ *
597
+ * A worker running a relay older than this feature answers "unknown method" -- which is NOT an
598
+ * error worth showing as a failure, because the link is fine and only the report is missing.
599
+ * The two cases are distinguished by `supported`, so the page can say "the worker does not
600
+ * report its cache" instead of reddening a healthy card. `ensure()` is deliberately skipped for
601
+ * the report path by the caller so a page load never waits on a connect it did not need.
602
+ *
603
+ * @param {{ maxBytes?: number, prune?: boolean, clear?: boolean }} [opts]
604
+ * @returns {Promise<{ supported: boolean, error: string, files: number, bytes: number,
605
+ * newestAt: number, oldestAt: number, keep: number, maxBytes: number,
606
+ * dir: string, pruned: object | null }>}
607
+ */
608
+ async workerShotCache(opts = {}) {
609
+ const unsupported = {
610
+ supported: false, error: '', files: 0, bytes: 0, newestAt: 0, oldestAt: 0,
611
+ keep: 0, maxBytes: 0, dir: '', pruned: null,
612
+ }
613
+ if (!this.connected || typeof this.bridge?.callPlugin !== 'function') {
614
+ return { ...unsupported, error: 'not connected' }
615
+ }
616
+ const params = {}
617
+ if (Number.isFinite(opts.maxBytes)) params.maxBytes = Number(opts.maxBytes)
618
+ if (opts.prune === true) params.prune = true
619
+ if (opts.clear === true) params.clear = true
620
+ try {
621
+ const result = await this.bridge.callPlugin('dsh-remote/shotcache', params)
622
+ return {
623
+ supported: true,
624
+ error: '',
625
+ files: Number(result?.files ?? 0),
626
+ bytes: Number(result?.bytes ?? 0),
627
+ newestAt: Number(result?.newestAt ?? 0),
628
+ oldestAt: Number(result?.oldestAt ?? 0),
629
+ keep: Number(result?.keepShots ?? 0),
630
+ maxBytes: Number(result?.maxBytes ?? 0),
631
+ dir: String(result?.dir ?? ''),
632
+ pruned: result?.pruned ?? null,
633
+ }
634
+ } catch (error) {
635
+ const message = error instanceof Error ? error.message : String(error)
636
+ // -32601 is JSON-RPC "method not found": an older worker, not a broken one.
637
+ const unknownMethod = /-32601|unknown plugin method|method not found/i.test(message)
638
+ // An unknown method is a property of the worker, not of the link, so the link is kept.
639
+ if (!unknownMethod) await this.#invalidateIfTransportError(error)
640
+ return { ...unsupported, error: unknownMethod ? '' : message }
641
+ }
642
+ }
643
+
644
+ /** Tear down the connection. Idempotent. */
645
+ async dispose() {
646
+ if (this.disposed) return
647
+ this.disposed = true
648
+ this.#stopHeartbeat()
649
+ const bridge = this.bridge
650
+ this.bridge = undefined
651
+ if (bridge === undefined) return
652
+ try {
653
+ await bridge.dispose()
654
+ } catch (error) {
655
+ this.log('warn', `dispose of "${this.peerId}" failed: ${error instanceof Error ? error.message : String(error)}`)
656
+ }
657
+ }
658
+
659
+ // -------------------------------------------------------------------------
660
+ // liveness
661
+ // -------------------------------------------------------------------------
662
+
663
+ /**
664
+ * Probe the link periodically so a socket that died silently is noticed BETWEEN calls
665
+ * rather than by a user-facing failure.
666
+ *
667
+ * The relay answers `dsh-remote/ping` from its own event loop, so this costs the worker's
668
+ * agent nothing and gives a definitive alive/dead answer.
669
+ */
670
+ #startHeartbeat() {
671
+ this.#stopHeartbeat()
672
+ if (this.heartbeatMs <= 0 || this.disposed) return
673
+ this.heartbeatTimer = setInterval(() => { void this.#beat() }, this.heartbeatMs)
674
+ // Never hold the process open just for a liveness probe.
675
+ if (typeof this.heartbeatTimer?.unref === 'function') this.heartbeatTimer.unref()
676
+ }
677
+
678
+ #stopHeartbeat() {
679
+ if (this.heartbeatTimer !== undefined) {
680
+ clearInterval(this.heartbeatTimer)
681
+ this.heartbeatTimer = undefined
682
+ }
683
+ }
684
+
685
+ async #beat() {
686
+ if (this.disposed || !this.connected || this.heartbeatBeating) return
687
+ if (typeof this.bridge?.callPlugin !== 'function') return
688
+ this.heartbeatBeating = true
689
+ try {
690
+ await this.bridge.callPlugin('dsh-remote/ping', {}, { timeoutMs: this.heartbeatTimeoutMs })
691
+ this.heartbeats += 1
692
+ this.lastHeartbeatAt = this.now()
693
+ } catch (error) {
694
+ this.heartbeatFailures += 1
695
+ const message = error instanceof Error ? error.message : String(error)
696
+ this.log('warn', `worker "${this.peerId}" heartbeat failed (${message}); treating the link as dead`)
697
+ // A failed liveness probe IS evidence the link is gone, so this deliberately does not
698
+ // go through the message-matching in #invalidateIfTransportError.
699
+ await this.#discardLink()
700
+ } finally {
701
+ this.heartbeatBeating = false
702
+ }
703
+ }
704
+
705
+ /** Throw away the current bridge so the next `ensure()` builds a fresh one. */
706
+ async #discardLink() {
707
+ this.#stopHeartbeat()
708
+ const dead = this.bridge
709
+ this.bridge = undefined
710
+ if (dead === undefined) return
711
+ try { await dead.dispose() } catch { /* the socket is already gone */ }
712
+ }
713
+
714
+ /**
715
+ * Drop a connection that is no longer usable, so the next `ensure()` rebuilds it.
716
+ *
717
+ * Only transport-level failures count. A JSON-RPC error from a healthy session (a
718
+ * failing tool, a refused approval) must NOT tear the link down, or one bad task would
719
+ * force a reconnect on every subsequent call.
720
+ */
721
+ async #invalidateIfTransportError(error) {
722
+ if (!this.connected) return
723
+ const message = error instanceof Error ? error.message : String(error)
724
+ const transportish = /not connected|connection closed|EPIPE|ECONNRESET|ECONNREFUSED|ECONNABORTED|socket|websocket|network|hang ?up/i.test(message)
725
+ if (!transportish) return
726
+
727
+ this.log('warn', `worker "${this.peerId}" link is no longer usable (${message}); discarding it so the next call reconnects`)
728
+ await this.#discardLink()
729
+ }
730
+ }