@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,76 @@
1
+ /**
2
+ * Local mirror of the out-of-process helper vocabulary from
3
+ * `@deepseek-ai/dsh-subagent` (its `lib/types/out-of-process.js`).
4
+ *
5
+ * WHY A MIRROR: the plugin must stay unit-testable without a live DSH, and this
6
+ * constant is a frozen contract that cannot drift meaningfully. To keep the mirror
7
+ * honest rather than a guess, `verifyAgainstUpstream()` compares it with the real
8
+ * package when that package is importable, and the plugin calls it at load.
9
+ *
10
+ * The upstream values are authoritative; if DSH ever changes the capability set,
11
+ * verification fails loudly instead of silently advertising a capability we cannot
12
+ * honor (DESIGN §2.2 C1).
13
+ *
14
+ * @module dsh-plugin-remote-use/out-of-process-helpers
15
+ */
16
+
17
+ /**
18
+ * The capability advertisement of an out-of-process backend: NONE.
19
+ *
20
+ * A child in another process cannot honor parent-enforced start features
21
+ * (`agentOptions` / `outputSchema` / `maxDepth` / `toolFilter` / `persona`), so the
22
+ * subagent service rejects a request needing any of them BEFORE `start` runs. This
23
+ * must never be widened without implementing the corresponding enforcement.
24
+ */
25
+ export const NO_START_CAPABILITIES = Object.freeze({
26
+ agentOptions: false,
27
+ outputSchema: false,
28
+ depthLimit: false,
29
+ toolFilter: false,
30
+ persona: false,
31
+ })
32
+
33
+ /**
34
+ * Maximum UTF-8 size a provider-authored diagnostic may have upstream.
35
+ * Kept here so our diagnostics are shaped like the ones DSH produces.
36
+ */
37
+ export const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4096
38
+
39
+ /**
40
+ * Compare the local mirror with the real package.
41
+ * @returns {Promise<{ok: boolean, reason?: string, upstream?: object}>}
42
+ */
43
+ export async function verifyAgainstUpstream() {
44
+ let upstream
45
+ try {
46
+ upstream = await import('@deepseek-ai/dsh-subagent')
47
+ } catch (error) {
48
+ // Not importable in a bare unit-test environment; that is expected and not a fault.
49
+ return { ok: true, reason: `upstream not importable here: ${error instanceof Error ? error.message : String(error)}` }
50
+ }
51
+ const real = upstream?.NO_START_CAPABILITIES
52
+ if (real === undefined) return { ok: true, reason: 'upstream no longer exports NO_START_CAPABILITIES' }
53
+ const keys = new Set([...Object.keys(real), ...Object.keys(NO_START_CAPABILITIES)])
54
+ for (const k of keys) {
55
+ if (real[k] !== NO_START_CAPABILITIES[k]) {
56
+ return { ok: false, reason: `capability "${k}" differs: upstream=${real[k]} local=${NO_START_CAPABILITIES[k]}`, upstream: real }
57
+ }
58
+ }
59
+ return { ok: true, upstream: real }
60
+ }
61
+
62
+ /**
63
+ * Truncate a diagnostic to the upstream byte limit without splitting a UTF-8
64
+ * sequence, mirroring how DSH limits provider-authored detail.
65
+ * @param {string} text
66
+ * @returns {string}
67
+ */
68
+ export function limitDiagnostic(text) {
69
+ const bytes = new TextEncoder().encode(text)
70
+ if (bytes.byteLength <= MAX_SUBAGENT_DIAGNOSTIC_BYTES) return text
71
+ const suffix = '\n[diagnostic truncated]'
72
+ const suffixBytes = new TextEncoder().encode(suffix).byteLength
73
+ let cut = MAX_SUBAGENT_DIAGNOSTIC_BYTES - suffixBytes
74
+ while (cut > 0 && (bytes[cut] & 0b1100_0000) === 0b1000_0000) cut -= 1
75
+ return new TextDecoder().decode(bytes.subarray(0, cut)) + suffix
76
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * The remote delegation provider.
3
+ *
4
+ * This is the piece that makes "call another machine like a subagent" true: it
5
+ * implements DSH's `SubagentProvider` seam, so the model sees a normal delegation
6
+ * tool and the service routes the request here. Nothing in DSH is modified.
7
+ *
8
+ * HARD CONSTRAINTS (DESIGN §2.2 — these are code facts, not choices):
9
+ * C1 `capabilities` must be all false. The service validates the request against
10
+ * them BEFORE `start()` and rejects anything it cannot honor, so advertising a
11
+ * capability we do not implement would fail loudly at dispatch.
12
+ * C2 No `prepareContinuable`. A continuable child is always materialized by the
13
+ * LOCAL `ctx.agents.create/resume`, so an out-of-process provider cannot
14
+ * support it. The tool row must therefore use `backgroundMode: one-shot`.
15
+ * C3 `run.localAgent` must stay `undefined` -> no `subagent/catalog` entry ->
16
+ * remote runs are invisible to `list_agents`. `TaskRegistry` compensates.
17
+ * C6 `inheritsParentContext` must be false: the worker cannot see our session.
18
+ *
19
+ * @module dsh-plugin-remote-use/remote-provider
20
+ */
21
+
22
+ import { NO_START_CAPABILITIES } from './out-of-process-helpers.js'
23
+ import { assertUsableCwd } from '../core/task-cwd.js'
24
+
25
+ // The rule lives in `core/task-cwd.js` because the WORKER's arming path needs the same answer
26
+ // (it advertises the directory in its pairing string). Re-exported here because this module is
27
+ // where it is ENFORCED on the dispatch path, and that is where callers look for it.
28
+ export { assertUsableCwd }
29
+
30
+ /** Delegation is refused with this code when the worker's self-check is not green. */
31
+ export const GATE_REFUSED = 'REMOTE_WORKER_NOT_READY'
32
+
33
+ /**
34
+ * @typedef {object} PeerRuntime
35
+ * @property {string} peerId
36
+ * @property {string} name
37
+ * @property {import('../bridge/acp-bridge.js').AcpBridge} bridge
38
+ * @property {() => Promise<object>} selfcheck returns the gate report
39
+ * @property {string} defaultCwd
40
+ */
41
+
42
+ export class RemoteProvider {
43
+ /**
44
+ * @param {object} opts
45
+ * @param {PeerRuntime} opts.peer
46
+ * @param {import('../core/task-registry.js').TaskRegistry} opts.registry
47
+ * @param {(req: object) => Promise<{optionId:string, source:string, reason:string, needsHuman:boolean}>} opts.decideApproval
48
+ * Bound review-engine decision function. MUST implement the cache-miss and
49
+ * tier-check rules; passing a permissive stub here is the vulnerability
50
+ * found in DESIGN §10.0.1.
51
+ * @param {number} [opts.serialQueue] max concurrent tasks per peer (DESIGN §7.3: 1)
52
+ * @param {string} [opts.workspaceRoot] used by the tier check
53
+ */
54
+ constructor(opts) {
55
+ this.name = `remote-${opts.peer.peerId}`
56
+ /** C1: never advertise what an out-of-process worker cannot honor. */
57
+ this.capabilities = { ...NO_START_CAPABILITIES }
58
+ /** C6: the worker has no access to the delegating session. */
59
+ this.inheritsParentContext = false
60
+
61
+ this.peer = opts.peer
62
+ this.registry = opts.registry
63
+ this.decideApproval = opts.decideApproval
64
+ this.serialQueue = opts.serialQueue ?? 1
65
+ this.workspaceRoot = opts.workspaceRoot ?? ''
66
+ /** @type {Array<() => void>} */
67
+ this.waiters = []
68
+ this.running = 0
69
+ }
70
+
71
+ /**
72
+ * Establish a remote child. Fulfils only after the worker has accepted the task,
73
+ * so the caller always owns a live run or nothing (the seam's publication rule).
74
+ *
75
+ * @param {object} request DSH's ResolvedSubagentStartRequest
76
+ * @returns {Promise<{id:string, localAgent:undefined, result:Promise<object>, dispose:() => Promise<void>}>}
77
+ */
78
+ async start(request) {
79
+ const prompt = blocksToText(request.prompt)
80
+ const label = request.label ?? ''
81
+ const cwd = this.peer.defaultCwd
82
+
83
+ // Fail early on a bad cwd. DSH's ACP server rejects a non-absolute `cwd` with
84
+ // -32602, and the error surfaces as an opaque turn failure -- better to say what
85
+ // is wrong here, before a session is even created.
86
+ const cwdProblem = assertUsableCwd(cwd)
87
+ if (cwdProblem !== '') {
88
+ const err = new Error(`remote worker "${this.peer.peerId}": ${cwdProblem}`)
89
+ err.code = 'REMOTE_BAD_CWD'
90
+ throw err
91
+ }
92
+
93
+ // ---- gate: refuse rather than fail halfway (DESIGN §5.10.6) -------------
94
+ const report = await this.peer.selfcheck()
95
+ const gate = this.#evaluateGate(report)
96
+ if (gate.blocked) {
97
+ // A refusal must surface as a rejected start, so no run is published.
98
+ const err = new Error(gate.message)
99
+ err.code = GATE_REFUSED
100
+ err.details = gate.details
101
+ throw err
102
+ }
103
+
104
+ // ---- serial queue: GUI is an exclusive resource (DESIGN §7.3) -----------
105
+ await this.#acquire()
106
+
107
+ const task = this.registry.create({ peerId: this.peer.peerId, label, prompt, cwd })
108
+ this.registry.markRunning(task.taskId)
109
+
110
+ let disposed = false
111
+ const release = () => { if (!disposed) { disposed = true; this.#release() } }
112
+
113
+ // The run's result must NEVER reject: a rejected promise after publication
114
+ // would strand the caller's foreground/background bookkeeping. The catch also
115
+ // settles the registry -- otherwise a bridge failure would leave a phantom
116
+ // "running" task that `remote_agents` keeps reporting forever.
117
+ const result = this.#runTask(task, { prompt, cwd })
118
+ .catch((error) => {
119
+ const diagnostic = error instanceof Error ? error.message : String(error)
120
+ this.registry.settle(task.taskId, { state: 'error', stopReason: 'error', diagnostic })
121
+ return {
122
+ output: [{ type: 'text', text: '' }],
123
+ stopReason: 'error',
124
+ diagnostic,
125
+ }
126
+ })
127
+ .finally(release)
128
+
129
+ return {
130
+ id: task.taskId,
131
+ localAgent: undefined, // C3
132
+ result,
133
+ dispose: async () => {
134
+ // Idempotent as a WHOLE: a second dispose must neither release the queue
135
+ // slot again nor send a second cancel to the worker.
136
+ if (disposed) return
137
+ release()
138
+ try { await this.peer.bridge.cancel() } catch { /* cancellation is best effort */ }
139
+ },
140
+ }
141
+ }
142
+
143
+ // NOTE: intentionally no `prepareContinuable` (C2).
144
+
145
+ /**
146
+ * Drive the task to a `SubagentResult`.
147
+ * @param {import('../core/task-registry.js').RemoteTask} task
148
+ * @param {{ prompt: string, cwd: string }} input
149
+ */
150
+ async #runTask(task, input) {
151
+ const outcome = await this.peer.bridge.startTask({
152
+ cwd: input.cwd,
153
+ prompt: input.prompt,
154
+ onUpdate: (evt) => {
155
+ if (evt.kind === 'tool_call' && evt.tool !== undefined) {
156
+ this.registry.noteToolCall(task.taskId, evt.tool.toolName ?? '?')
157
+ }
158
+ if (evt.kind === 'usage' && evt.usage !== undefined) {
159
+ this.registry.setUsage(task.taskId, evt.usage)
160
+ }
161
+ },
162
+ onApproval: async (ask) => {
163
+ this.registry.setWaitingForApproval(task.taskId, true)
164
+ try {
165
+ const decision = await this.decideApproval({
166
+ taskId: task.taskId,
167
+ toolCallId: ask.toolCallId,
168
+ toolName: ask.toolName,
169
+ rawInput: ask.rawInput,
170
+ })
171
+ return decision.optionId
172
+ } finally {
173
+ this.registry.setWaitingForApproval(task.taskId, false)
174
+ }
175
+ },
176
+ })
177
+
178
+ const state = outcome.stopReason === 'completed' ? 'completed' : outcome.stopReason
179
+ this.registry.settle(task.taskId, {
180
+ state,
181
+ stopReason: outcome.stopReason,
182
+ diagnostic: outcome.diagnostic,
183
+ // The answer is retained in the registry, not just returned through `result`. A background
184
+ // caller has already stopped awaiting `result` by the time this line runs, so this is the
185
+ // only place the text can reach it (PITFALLS F15). `stopReason: 'error'` still carries
186
+ // whatever the worker managed to say before it failed.
187
+ output: outcome.output,
188
+ })
189
+
190
+ return {
191
+ output: [{ type: 'text', text: outcome.output ?? '' }],
192
+ ...(outcome.diagnostic === undefined ? {} : { diagnostic: outcome.diagnostic }),
193
+ stopReason: outcome.stopReason,
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Turn a self-check report into a dispatch decision.
199
+ *
200
+ * `no-interactive-session` is NOT a fault: the worker is healthy but nobody is
201
+ * logged in. The two must produce different diagnostics or operators chase the
202
+ * wrong problem (DESIGN §5.10.6).
203
+ * @param {object} report
204
+ */
205
+ #evaluateGate(report) {
206
+ if (report === null || report === undefined) {
207
+ return { blocked: true, message: 'remote worker did not return a self-check report', details: {} }
208
+ }
209
+ const checks = Array.isArray(report.checks) ? report.checks : []
210
+ const failed = checks.filter((c) => c.severity === 'blocking' && c.status === 'fail')
211
+
212
+ const sessionMissing = report.sessionSegment === 'unavailable'
213
+ || checks.some((c) => c.id === 'S8' && c.status === 'unchecked')
214
+
215
+ if (sessionMissing) {
216
+ return {
217
+ blocked: true,
218
+ message: 'remote worker has no interactive session: someone must be logged in on it '
219
+ + '(the desktop must be unlocked for input injection to work)',
220
+ details: { reason: 'no-interactive-session', checks: failed.map((c) => c.id) },
221
+ }
222
+ }
223
+ if (failed.length > 0) {
224
+ return {
225
+ blocked: true,
226
+ message: `remote worker is not ready: ${failed.map((c) => `${c.id} ${c.name} (expected ${c.expected}, actual ${c.actual})`).join('; ')}`,
227
+ details: { reason: 'selfcheck-failed', checks: failed.map((c) => c.id) },
228
+ }
229
+ }
230
+ return { blocked: false, message: '', details: {} }
231
+ }
232
+
233
+ /** FIFO gate so one worker runs one task at a time. */
234
+ async #acquire() {
235
+ if (this.running < this.serialQueue) { this.running += 1; return }
236
+ await new Promise((resolve) => this.waiters.push(resolve))
237
+ this.running += 1
238
+ }
239
+
240
+ #release() {
241
+ this.running = Math.max(0, this.running - 1)
242
+ const next = this.waiters.shift()
243
+ if (next !== undefined) next()
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Flatten DSH content blocks to plain text for the ACP prompt.
249
+ * @param {unknown} blocks
250
+ * @returns {string}
251
+ */
252
+ export function blocksToText(blocks) {
253
+ if (typeof blocks === 'string') return blocks
254
+ if (!Array.isArray(blocks)) return ''
255
+ return blocks
256
+ .map((b) => (b !== null && typeof b === 'object' && typeof (/** @type {any} */ (b).text) === 'string'
257
+ ? /** @type {any} */ (b).text
258
+ : ''))
259
+ .filter((s) => s !== '')
260
+ .join('\n')
261
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * sandbox-mediator-shim.mjs -- run the Windows ACL sandbox runner from a console-subsystem
3
+ * mediator.
4
+ *
5
+ * THE PROBLEM
6
+ * `dsh-sandbox-local`'s default mediator is `process.execPath`, which in this deployment is
7
+ * `DeepSeek Harness.exe` -- a GUI-subsystem image. Windows never auto-allocates a console
8
+ * to a GUI-subsystem image, so the WRITE_RESTRICTED child the runner creates has no console
9
+ * to inherit and dies during DLL initialization with 0xC0000142 (STATUS_DLL_INIT_FAILED).
10
+ * Every shell command fails, with no output at all.
11
+ *
12
+ * Measured on this machine, same runner and args, only the interpreter varied (8/8 valid):
13
+ * node.exe (console subsystem) 4/4 exit 0, child produced its output
14
+ * DeepSeek Harness.exe (GUI subsystem) 4/4 exit 0xC0000142, no output
15
+ *
16
+ * WHY A SHIM AND NOT `runnerCommand` POINTED AT THE RUNNER DIRECTLY
17
+ * `sandbox-local.confine()` prepends `bwrapProfileArgs(policy)` to a configured
18
+ * `runnerCommand` unconditionally -- a bubblewrap dialect (`--ro-bind`, `--bind`,
19
+ * `--tmpfs`, ...) that the Windows runner rejects with
20
+ * `windows-acl-run: unknown argument: --ro-bind`. `runnerCommand` is the "operator
21
+ * asserts this is a bwrap-compatible runner" path, not a way to swap the win32 rung.
22
+ *
23
+ * So the shim is named as the runner and translates the two dialects. Being launched as
24
+ * `node.exe` is the whole point: a console-subsystem image is auto-allocated a console,
25
+ * which is what the restricted child needs.
26
+ *
27
+ * CONTRACT
28
+ * in : <bwrap profile args> -- <command argv...>
29
+ * out: spawns `node.exe <windows-acl runner> --workspace .. --temp .. --mode .. -- <argv>`
30
+ * with the shim's own stdio inherited, and mirrors the exit code.
31
+ *
32
+ * LIMITATIONS -- deliberate and stated rather than hidden:
33
+ * * `--bind <root>` is emitted ONLY in workspace-write mode, so in read-only mode the
34
+ * workspace root is not on the command line. The shim then falls back to
35
+ * DSH_SANDBOX_WORKSPACE, then to the current directory. Read-only issues no workspace
36
+ * write grant, so the value only needs to name an existing directory.
37
+ * * `--unshare-pid` and `--die-with-parent` have no Windows equivalent and are dropped.
38
+ * Process containment comes from the runner's own job object.
39
+ * * A bubblewrap argument this shim does not recognise is a HARD ERROR, never ignored:
40
+ * silently dropping a confinement argument would weaken the sandbox without saying so.
41
+ */
42
+ import { spawn } from 'node:child_process'
43
+ import { existsSync, statSync } from 'node:fs'
44
+ import { tmpdir } from 'node:os'
45
+ import { dirname, join } from 'node:path'
46
+ import { fileURLToPath } from 'node:url'
47
+
48
+ const HERE = dirname(fileURLToPath(import.meta.url))
49
+ const RUNNER = process.env.DSH_SANDBOX_RUNNER
50
+ ?? join(HERE, 'node_modules', '@deepseek-ai', 'dsh-sandbox-windows-acl', 'lib', 'runner.js')
51
+
52
+ function die(message) {
53
+ // The runner's own failure signature prefix, so dsh-sandbox-local's
54
+ // `runnerFailureSignatures` match catches this and reports it as a sandbox failure
55
+ // rather than as an opaque non-zero exit.
56
+ process.stderr.write(`windows-acl-run: shim: ${message}\n`)
57
+ process.exit(1)
58
+ }
59
+
60
+ const argv = process.argv.slice(2)
61
+ const sep = argv.indexOf('--')
62
+ if (sep < 0) die('missing "--" separator')
63
+ const profile = argv.slice(0, sep)
64
+ const command = argv.slice(sep + 1)
65
+ if (command.length === 0) die('no command after "--"')
66
+
67
+ if (!existsSync(RUNNER)) die(`runner not found at ${RUNNER}`)
68
+
69
+ // ---- translate the bubblewrap dialect -------------------------------------
70
+ let mode = 'read-only'
71
+ let workspace = ''
72
+ let temp = ''
73
+ const dropped = []
74
+
75
+ for (let i = 0; i < profile.length; i += 1) {
76
+ const a = profile[i]
77
+ const next = () => {
78
+ const v = profile[i + 1]
79
+ if (v === undefined) die(`missing value after ${a}`)
80
+ i += 1
81
+ return v
82
+ }
83
+ switch (a) {
84
+ case '--ro-bind': {
85
+ // `--ro-bind <src> <dest>`: read-only bind. The Windows rung is always
86
+ // read-everything, so this is represented by the mode, not by path grants.
87
+ next(); next()
88
+ break
89
+ }
90
+ case '--bind': {
91
+ // `--bind <src> <dest>`: writable bind -- the workspace, and the only place the
92
+ // workspace root appears on the command line.
93
+ const src = next()
94
+ next()
95
+ mode = 'workspace-write'
96
+ workspace = src
97
+ break
98
+ }
99
+ case '--tmpfs': {
100
+ // bwrapProfileArgs hardcodes the Linux path `/tmp`, which does not exist here. Take
101
+ // the value only when it names a real directory; otherwise fall back to the platform
102
+ // temp. This one IS a silent substitution, so it is recorded on stderr for anyone
103
+ // debugging with DSH_SANDBOX_SHIM_DEBUG=1 -- it never affects what is writable,
104
+ // because the runner grants its own private temp child under whatever root it gets.
105
+ const value = next()
106
+ if (existsSync(value)) temp = value
107
+ else if (process.env.DSH_SANDBOX_SHIM_DEBUG === '1') {
108
+ process.stderr.write(`windows-acl-shim-debug: ignoring tmpfs ${value} (absent on this platform); using ${tmpdir()}\n`)
109
+ }
110
+ break
111
+ }
112
+ case '--dev':
113
+ case '--proc': {
114
+ next()
115
+ break
116
+ }
117
+ case '--unshare-pid':
118
+ case '--die-with-parent': {
119
+ dropped.push(a)
120
+ break
121
+ }
122
+ default:
123
+ die(`unrecognised profile argument "${a}"`)
124
+ }
125
+ }
126
+
127
+ if (workspace === '') {
128
+ // Read-only mode carries no --bind, so the workspace root is not on the command line.
129
+ // The workspace grant is inert in read-only, so any stable existing directory satisfies
130
+ // the runner's argument contract. `process.cwd()` is NOT used: it depends on whoever
131
+ // launched the mediator (an SSH session put it at C:\Users\worker, which then appeared to
132
+ // contain the temp directory). The mediator's own directory is deterministic.
133
+ workspace = process.env.DSH_SANDBOX_WORKSPACE ?? HERE
134
+ }
135
+ if (temp === '') temp = tmpdir()
136
+
137
+ for (const p of [workspace, temp]) {
138
+ try {
139
+ if (!statSync(p).isDirectory()) die(`not a directory: ${p}`)
140
+ } catch {
141
+ die(`does not exist: ${p}`)
142
+ }
143
+ }
144
+ // The runner refuses a temp root inside the workspace; keep that invariant explicit here so
145
+ // the failure names the shim's translation rather than surfacing as an opaque runner error.
146
+ //
147
+ // The comparison is BOUNDARY-AWARE: a plain prefix test made C:\Users\worker appear to
148
+ // contain C:\Users\worker\AppData\Local\Temp, which is a different relationship from
149
+ // containment by path segment.
150
+ function containsPath(parent, child) {
151
+ const norm = (p) => p.replace(/[\\/]+$/, '').toLowerCase()
152
+ const a = norm(parent)
153
+ const b = norm(child)
154
+ if (a === b) return true
155
+ const sep = a.endsWith('\\') || a.endsWith('/') ? '' : (a.includes('\\') ? '\\' : '/')
156
+ return b.startsWith(a + sep)
157
+ }
158
+ if (containsPath(workspace, temp)) {
159
+ die(`temp root ${temp} is inside the workspace ${workspace}`)
160
+ }
161
+
162
+ const runnerArgs = [
163
+ RUNNER,
164
+ '--workspace', workspace,
165
+ '--temp', temp,
166
+ '--mode', mode,
167
+ '--',
168
+ ...command,
169
+ ]
170
+
171
+ // `process.execPath` is node.exe for this shim, i.e. a console-subsystem image -- which is
172
+ // exactly the property that makes the restricted child work. stdio is inherited so DSH
173
+ // still receives the command's output through the pipes it set up.
174
+ const child = spawn(process.execPath, runnerArgs, { stdio: 'inherit', windowsHide: false })
175
+
176
+ child.on('error', (error) => die(`could not spawn runner: ${error.message}`))
177
+ child.on('exit', (code, signal) => {
178
+ if (signal !== null) process.exit(1)
179
+ process.exit(code ?? 1)
180
+ })