@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,258 @@
1
+ /**
2
+ * What this machine is missing before it can be armed as a worker -- and the exact fix.
3
+ *
4
+ * WHY THIS IS READ-ONLY, AND WHY THAT IS THE WHOLE DESIGN.
5
+ *
6
+ * The arming gate refuses a machine whose sandbox cannot be proven. Proving it needs a
7
+ * `sandbox` entry in the ACP profile that names a materialized mediator; neither is something
8
+ * DSH ships, and the profile is created lazily, so a factory-fresh worker has neither. The
9
+ * previous answer was a paragraph of documentation, and it rotted silently: measured
10
+ * 2026-11-04 on the development machine, BOTH mediator directories were gone while the page
11
+ * kept saying nothing at all, because the probe only runs when somebody clicks "check".
12
+ *
13
+ * The tempting fix is to materialize the mediator and write the profile from the gate. That
14
+ * would let a page visit mutate the operator's environment -- writing outside the plugin's own
15
+ * state (`%DSH_HOME%\profiles\acp`, a mediator directory at a machine path), which is the kind
16
+ * of silent action this plugin spends most of its comments avoiding. Worse, it would take over
17
+ * "which DSH build is this mediator for", a question this plugin cannot answer better than the
18
+ * probe can.
19
+ *
20
+ * So the gate stays a gate, and this module answers a different question: WHAT EXACTLY IS
21
+ * MISSING, AND WHAT IS THE ONE COMMAND THAT FIXES IT. The command is executed by a person or by
22
+ * an agent they point at it (`lib/tools/prepare-sandbox-mediator.mjs`), and the probe below is
23
+ * still what decides whether arming is allowed. Nothing here writes, spawns, or caches.
24
+ */
25
+ import { existsSync } from 'node:fs'
26
+ import { basename, dirname, join } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ import { installCandidates } from './self-acp-spec.js'
30
+ import { shimNamedByProfile, subsystemOf } from './sandbox-probe.js'
31
+ import { resolveDshHome } from './settings-store.js'
32
+
33
+ /** Profile the worker role launches (`dsh --profile acp`). */
34
+ export const WORKER_PROFILE = 'acp'
35
+
36
+ /** Where the mediator goes when the profile does not already name one. */
37
+ export const DEFAULT_MEDIATOR_LEAF = 'sandbox-mediator'
38
+
39
+ /** @param {string} home */
40
+ export function acpProfilePath(home) {
41
+ return join(home, 'profiles', WORKER_PROFILE, 'cordis.patch.yml')
42
+ }
43
+
44
+ /** The shipped fixer, addressed absolutely so a prompt can be pasted anywhere. */
45
+ export function prepareScriptPath() {
46
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'tools', 'prepare-sandbox-mediator.mjs')
47
+ }
48
+
49
+ /**
50
+ * The DSH installation this process belongs to.
51
+ *
52
+ * `process.execPath` is tried FIRST because it is the one fact that cannot be stale: the running
53
+ * host IS the installation the worker will spawn. The environment candidates are the fallback for
54
+ * a process started some other way (a test runner, `node tools/...`).
55
+ */
56
+ export function resolveInstallDir(deps = {}) {
57
+ const env = deps.env ?? process.env
58
+ const exists = deps.exists ?? existsSync
59
+ const execPath = deps.execPath ?? process.execPath
60
+ const exe = 'DeepSeek Harness.exe'
61
+ const fromExec = typeof execPath === 'string' && basename(execPath).toLowerCase() === exe.toLowerCase()
62
+ ? dirname(execPath)
63
+ : ''
64
+ const candidates = [fromExec, env.DSH_INSTALL_DIR, ...installCandidates(env)]
65
+ const seen = new Set()
66
+ for (const candidate of candidates) {
67
+ if (typeof candidate !== 'string' || candidate === '') continue
68
+ const key = candidate.toLowerCase()
69
+ if (seen.has(key)) continue
70
+ seen.add(key)
71
+ if (exists(join(candidate, exe))) return candidate
72
+ }
73
+ return ''
74
+ }
75
+
76
+ /**
77
+ * A CONSOLE-subsystem node.exe, which is the entire point of the mediator.
78
+ *
79
+ * A GUI-subsystem image (`DeepSeek Harness.exe`, PE subsystem 2) never gets a console, and the
80
+ * restricted child it creates dies during DLL initialization -- every shell command fails with no
81
+ * output. So a candidate whose PE subsystem is not 3 is skipped rather than used: picking it would
82
+ * reproduce the defect while looking configured.
83
+ */
84
+ export function resolveNodeExe(deps = {}) {
85
+ const env = deps.env ?? process.env
86
+ const exists = deps.exists ?? existsSync
87
+ const subsystem = deps.subsystem ?? subsystemOf
88
+ const execPath = deps.execPath ?? process.execPath
89
+ const installDir = deps.installDir ?? ''
90
+ const candidates = []
91
+ if (typeof env.DSH_NODE_EXE === 'string' && env.DSH_NODE_EXE !== '') candidates.push(env.DSH_NODE_EXE)
92
+ if (installDir !== '') {
93
+ candidates.push(join(installDir, 'resources', 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin', 'node.exe'))
94
+ }
95
+ candidates.push(join(env.ProgramFiles ?? 'C:\\Program Files', 'nodejs', 'node.exe'))
96
+ if (typeof env.LOCALAPPDATA === 'string' && env.LOCALAPPDATA !== '') {
97
+ candidates.push(join(env.LOCALAPPDATA, 'Programs', 'nodejs', 'node.exe'))
98
+ }
99
+ if (typeof execPath === 'string' && /^node(?:\.exe)?$/iu.test(basename(execPath))) candidates.push(execPath)
100
+ for (const dir of String(env.PATH ?? env.Path ?? '').split(';')) {
101
+ const trimmed = dir.trim()
102
+ if (trimmed !== '') candidates.push(join(trimmed, 'node.exe'))
103
+ }
104
+ const seen = new Set()
105
+ for (const candidate of candidates) {
106
+ if (typeof candidate !== 'string' || candidate === '') continue
107
+ const key = candidate.toLowerCase()
108
+ if (seen.has(key)) continue
109
+ seen.add(key)
110
+ if (!exists(candidate)) continue
111
+ if (subsystem(candidate) !== 3) continue
112
+ return candidate
113
+ }
114
+ return ''
115
+ }
116
+
117
+ /**
118
+ * Read-only answer to "is the sandbox mediator ready on this machine, and if not, why".
119
+ *
120
+ * `ready` means MATERIALIZED, not PROVEN: the profile names a shim that exists next to a runner.
121
+ * Whether it actually confines is `probeSandbox`'s question, and the two must stay separate --
122
+ * conflating them is how "the files are there" would read as "the sandbox works".
123
+ *
124
+ * States:
125
+ * ready -- materialized and named by the profile; the probe still has the final say
126
+ * missing -- the profile names a mediator (or the default path is in use) and it is absent
127
+ * unwired -- the mediator may exist, but the profile does not name a usable sandbox runner
128
+ * unsupported -- this host/platform/installation cannot have one (nothing to prescribe but the reason)
129
+ *
130
+ * @param {object} [deps]
131
+ * @param {Record<string, string | undefined>} [deps.env]
132
+ * @param {string} [deps.home] DSH home; defaults to DSH_HOME or ~/.dsh
133
+ * @param {string} [deps.profilePath]
134
+ * @param {string} [deps.installDir]
135
+ * @param {string} [deps.nodeExe]
136
+ * @param {string} [deps.platform]
137
+ * @param {(path: string) => boolean} [deps.exists]
138
+ * @param {(path: string) => number} [deps.subsystem]
139
+ * @returns {{ state: string, ready: boolean, reason: string, home: string, profilePath: string,
140
+ * profileNamesShim: boolean, profileReason: string, mediatorDir: string, shim: string,
141
+ * runner: string, nodeExe: string, installDir: string, platform: string }}
142
+ */
143
+ export function inspectSandbox(deps = {}) {
144
+ const env = deps.env ?? process.env
145
+ const exists = deps.exists ?? existsSync
146
+ const platform = deps.platform ?? process.platform
147
+ const home = deps.home ?? resolveDshHome(env)
148
+ const profilePath = deps.profilePath ?? acpProfilePath(home)
149
+ const installDir = deps.installDir ?? resolveInstallDir({ env, exists, execPath: deps.execPath })
150
+ const nodeExe = deps.nodeExe ?? resolveNodeExe({
151
+ env, exists, execPath: deps.execPath, installDir, subsystem: deps.subsystem,
152
+ })
153
+ const named = deps.named ?? shimNamedByProfile(profilePath)
154
+ const namedShim = named.ok === true ? String(named.shim ?? '') : ''
155
+ const mediatorDir = namedShim !== '' ? dirname(namedShim) : join(home, DEFAULT_MEDIATOR_LEAF)
156
+ const shim = namedShim !== '' ? namedShim : join(mediatorDir, 'shim.mjs')
157
+ const runner = join(mediatorDir, 'node_modules', '@deepseek-ai', 'dsh-sandbox-windows-acl', 'lib', 'runner.js')
158
+ const materialized = exists(shim) && exists(runner)
159
+
160
+ let state = 'ready'
161
+ let reason = ''
162
+ if (platform !== 'win32') {
163
+ state = 'unsupported'
164
+ reason = `the sandbox mediator is a Windows mechanism and this host is ${platform}`
165
+ } else if (installDir === '') {
166
+ state = 'unsupported'
167
+ reason = 'could not find "DeepSeek Harness.exe"; pass --install with the installation directory'
168
+ } else if (nodeExe === '') {
169
+ state = 'unsupported'
170
+ reason = 'could not find a console-subsystem node.exe (the mediator interpreter); pass DSH_NODE_EXE or install Node'
171
+ } else if (named.ok !== true) {
172
+ state = 'unwired'
173
+ reason = `the ACP profile does not name a usable sandbox runner: ${String(named.reason ?? '')}`
174
+ } else if (!materialized) {
175
+ state = 'missing'
176
+ reason = `the profile names ${shim}, but it is not materialized there (shim=${exists(shim)} runner=${exists(runner)})`
177
+ }
178
+
179
+ return {
180
+ state,
181
+ ready: state === 'ready',
182
+ reason,
183
+ home,
184
+ profilePath,
185
+ profileNamesShim: named.ok === true,
186
+ profileReason: String(named.reason ?? ''),
187
+ mediatorDir,
188
+ shim,
189
+ runner,
190
+ nodeExe,
191
+ installDir,
192
+ platform,
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The fix, as one command plus the text an operator (or an agent) needs to run it correctly.
198
+ *
199
+ * The command is quoting-safe for PowerShell, which is the shell this project's own tooling uses;
200
+ * the note says what to drop for CMD. Every path is filled in from THIS machine, because a prompt
201
+ * with a placeholder path is the documentation that already failed.
202
+ *
203
+ * @param {ReturnType<typeof inspectSandbox>} diagnosis
204
+ * @param {{ scriptPath?: string, shell?: 'pwsh' | 'cmd' }} [deps]
205
+ * @returns {{ command: string, text: string }}
206
+ */
207
+ export function buildPrescription(diagnosis, deps = {}) {
208
+ const script = deps.scriptPath ?? prepareScriptPath()
209
+ const shell = deps.shell ?? 'pwsh'
210
+ const args = [
211
+ `"${script}"`,
212
+ `--dsh-home "${diagnosis.home}"`,
213
+ `--profile ${WORKER_PROFILE}`,
214
+ ...(diagnosis.installDir === '' ? [] : [`--install "${diagnosis.installDir}"`]),
215
+ ]
216
+ const command = shell === 'cmd'
217
+ ? `"${diagnosis.nodeExe || 'node'}" ${args.join(' ')}`
218
+ : `& "${diagnosis.nodeExe || 'node'}" ${args.join(' ')}`
219
+
220
+ const lines = [
221
+ '这台机器要当远程工作节点,但沙箱 mediator 还没就绪:',
222
+ ` ${diagnosis.reason}`,
223
+ '',
224
+ '请在这台机器上执行下面这一条命令(也可以把这段文字连同命令交给本机的 DSH 会话,让它去做):',
225
+ '',
226
+ command,
227
+ '',
228
+ '它只做三件事:① 从本机 app.asar 物化沙箱 mediator;② 必要时往该 profile 的 cordis.patch.yml 追加 sandbox 行(已有可用条目就不动);③ 跑一次六项沙箱校验。',
229
+ '',
230
+ '本机事实:',
231
+ `- DSH home : ${diagnosis.home}`,
232
+ `- profile : ${diagnosis.profilePath}`,
233
+ `- mediator : ${diagnosis.mediatorDir}`,
234
+ `- node.exe : ${diagnosis.nodeExe === '' ? '(未找到)' : diagnosis.nodeExe}`,
235
+ '',
236
+ '注意:',
237
+ '- 命令必须在这台机器上运行,不是在主机上。',
238
+ '- DSH 自己的沙箱会拦工作区外的写入;命令被拦时,请改在终端里运行,或在全权会话里运行。',
239
+ '- 可重复执行:已就绪时它不会改动任何文件。',
240
+ '',
241
+ '跑完回到 设置 → 远程工作节点 → 本机角色,点「先检查」,六项都应通过。',
242
+ ]
243
+ return { command, text: lines.join('\n') }
244
+ }
245
+
246
+ /**
247
+ * The report the settings page renders.
248
+ *
249
+ * `prescription` is returned even when `ready` is true: the probe can still fail for a reason the
250
+ * file checks cannot see (a corrupted runner, a policy change), and in that case the same command
251
+ * is the right next step. The page decides when to show it.
252
+ *
253
+ * @param {object} [deps]
254
+ */
255
+ export function diagnoseSandbox(deps = {}) {
256
+ const diagnosis = inspectSandbox(deps)
257
+ return { ...diagnosis, prescription: buildPrescription(diagnosis, deps) }
258
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Does this machine's Windows sandbox actually confine a command?
3
+ *
4
+ * WHY THIS LIVES IN THE PACKAGE rather than in the tool that first needed it: the arming gate
5
+ * (§16, "refuse to arm until the sandbox works") and `tools/check-sandbox-ready.mjs` must give
6
+ * the same answer. Two implementations of "does the sandbox work" would drift, and the drift
7
+ * would be invisible until a machine was armed on the strength of the wrong one.
8
+ *
9
+ * WHY IT IS BEHAVIOURAL. A configuration check -- "the profile names a mediator" -- is
10
+ * necessary but not sufficient: a mediator that is named and broken passes it. The failure that
11
+ * motivated all of this depends on the LAUNCH CONTEXT (a console-subsystem process allocates or
12
+ * inherits a console; a GUI-subsystem one does not), so the only reliable answer comes from
13
+ * running a command through the configured path and watching what happens.
14
+ *
15
+ * WHAT IT DOES NOT PROVE: that DSH will use this mediator. `probeSandbox` reports the config
16
+ * finding separately for exactly that reason, and the caller is expected to refuse on either.
17
+ */
18
+ import { spawnSync } from 'node:child_process'
19
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
20
+ import { tmpdir } from 'node:os'
21
+ import { dirname, join } from 'node:path'
22
+
23
+ /** PE subsystem: 2 is GUI (never allocates a console), 3 is console (allocates one). */
24
+ export function subsystemOf(file) {
25
+ try {
26
+ const buf = readFileSync(file)
27
+ const peOffset = buf.readUInt32LE(0x3C)
28
+ return buf.readUInt16LE(peOffset + 0x5C)
29
+ } catch {
30
+ return 0
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Which shim does the DSH profile name as the sandbox runner?
36
+ *
37
+ * THE PROFILE IS THE SOURCE OF TRUTH, and this function exists because treating a hardcoded default
38
+ * as the requirement was a real defect: a worker whose mediator had been materialized somewhere else
39
+ * -- and whose acp profile correctly pointed at it -- was refused with
40
+ *
41
+ * the mediator works but DSH will not use it: runnerCommand names a different shim than
42
+ * C:\dsh-sandbox-mediator\shim.mjs
43
+ *
44
+ * even though running a command through that very shim worked. The deployment location is not the
45
+ * plugin's business; what matters is that the profile names a shim and that the shim confines.
46
+ *
47
+ * A structural scan rather than a YAML parse: the deployment has no YAML dependency on this path, and
48
+ * the shape it looks for is six lines long.
49
+ *
50
+ * @param {string} profilePath
51
+ * @returns {{ ok: boolean, shim: string, reason: string }}
52
+ */
53
+ export function shimNamedByProfile(profilePath) {
54
+ if (typeof profilePath !== 'string' || profilePath === '' || !existsSync(profilePath)) {
55
+ return { ok: false, shim: '', interpreter: '', reason: `no profile patch at ${profilePath}` }
56
+ }
57
+ let text
58
+ try {
59
+ text = readFileSync(profilePath, 'utf8')
60
+ } catch (error) {
61
+ return { ok: false, shim: '', interpreter: '', reason: `cannot read ${profilePath}: ${error.message}` }
62
+ }
63
+ // A file that carries an empty flow array AND list entries is INVALID YAML for this consumer:
64
+ // `[]` is a complete document, so anything appended after it starts a second document without a
65
+ // `---` separator, and the ACP child dies with "end of the stream or a document separator is
66
+ // expected". Measured 2026-11-05: the probe passed (it reads the file structurally), the machine
67
+ // armed, and every ACP connection then closed -- the master reported only `S0 worker reachable:
68
+ // fail`. DSH's shipped profile template IS `[]`, so this happens the first time an entry is
69
+ // appended to a fresh profile. Detected here because this is the one place that reads the file
70
+ // on the arming path; leaving it to the ACP child is too late (arming would already be allowed).
71
+ if (/(?:^|\n)[ \t]*\[\][ \t]*(?:\n|$)/u.test(text) && /(?:^|\n)[ \t]*-[ \t]/u.test(text)) {
72
+ return {
73
+ ok: false,
74
+ shim: '',
75
+ interpreter: '',
76
+ reason: 'the profile contains an empty list marker `[]` AND entries, which is invalid YAML (two documents) -- the ACP child refuses to start; remove the `[]` line, or run prepare-sandbox-mediator.mjs, which repairs it',
77
+ }
78
+ }
79
+ const entry = /-\s*id:\s*sandbox\b[\s\S]{0,600}?runnerCommand:([\s\S]{0,400}?)(?=\n\S|\n-|$)/u.exec(text)
80
+ if (entry === null) {
81
+ // Actionable on purpose: this message is what an operator sees when arming is refused, and the
82
+ // sandbox case previously said only that something did not match.
83
+ return {
84
+ ok: false,
85
+ shim: '',
86
+ interpreter: '',
87
+ reason: `the profile has no sandbox entry with a runnerCommand -- add "- id: sandbox" with a runnerCommand naming a console-subsystem mediator and runnerFailureSignatures (see README, Windows sandbox)`,
88
+ }
89
+ }
90
+ const normalised = entry[1].replace(/\\\\/gu, '\\').replace(/["']/gu, '')
91
+ const match = /([A-Za-z]:\\[^\r\n"']*shim\.mjs)/u.exec(normalised)
92
+ if (match === null) {
93
+ return { ok: false, shim: '', interpreter: '', reason: 'runnerCommand does not name a shim.mjs' }
94
+ }
95
+ if (!/runnerFailureSignatures/u.test(text)) {
96
+ return {
97
+ ok: false,
98
+ shim: '',
99
+ interpreter: '',
100
+ reason: 'runnerCommand is set without runnerFailureSignatures, which sandbox-local rejects at construction -- add the runner\'s error prefix, e.g. "windows-acl-run: "',
101
+ }
102
+ }
103
+ // The INTERPRETER the profile names, i.e. the first runnerCommand element when it is a node.exe.
104
+ // The probe runs the shim through it rather than through `process.execPath`: on a GUI-subsystem
105
+ // host (the DSH desktop app) that executable cannot host the restricted child, so probing it
106
+ // would answer a question about the HOST while the profile configures a different one -- and the
107
+ // answer could be "refuse" on a machine DSH would have run correctly (see probeSandbox).
108
+ const interpreter = /([A-Za-z]:\\[^\r\n"']*node\.exe)/iu.exec(normalised)?.[1] ?? ''
109
+ return { ok: true, shim: match[1], interpreter, reason: '' }
110
+ }
111
+
112
+ /**
113
+ * Does the DSH profile name THIS shim as the sandbox runner?
114
+ *
115
+ * Kept as its own primitive because "the profile names exactly this path" is a useful question --
116
+ * for tests, and for a caller that wants to assert a specific wiring. It is deliberately NOT how
117
+ * probeSandbox decides which shim to exercise: there the profile's own answer wins.
118
+ *
119
+ * @param {string} profilePath
120
+ * @param {string} shimPath
121
+ * @returns {{ ok: boolean, reason: string }}
122
+ */
123
+ export function profileNamesShim(profilePath, shimPath) {
124
+ const named = shimNamedByProfile(profilePath)
125
+ if (!named.ok) return { ok: false, reason: named.reason }
126
+ // The exact path matters here: a stale mediator from a previous install would still look right by
127
+ // filename while driving an old runner.
128
+ if (!named.shim.includes(shimPath)) {
129
+ return { ok: false, reason: `runnerCommand names a different shim than ${shimPath}` }
130
+ }
131
+ return { ok: true, reason: '' }
132
+ }
133
+
134
+ /** The Windows shell the probe payload runs in, preferring the newer one. */
135
+ function shellPath() {
136
+ const pwsh = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
137
+ return existsSync(pwsh) ? pwsh : 'powershell.exe'
138
+ }
139
+
140
+ /**
141
+ * The payload writes the file ITSELF rather than relying on a shell redirect.
142
+ *
143
+ * A `cmd /c echo x > "path"` probe was tried first and reported "The filename, directory name,
144
+ * or volume label syntax is incorrect" -- the quoting does not survive being passed as argv, so
145
+ * the command failed for a reason unrelated to the sandbox. Worse, the outside-write check then
146
+ * "passed" for that same bogus reason, because it saw a non-zero exit and no file and concluded
147
+ * the sandbox had refused. A probe that fails for the wrong reason is worse than no probe: it
148
+ * reports the answer you were hoping for.
149
+ */
150
+ function writePayload(path) {
151
+ return `try { Set-Content -LiteralPath ${JSON.stringify(path)} -Value probe-ok -ErrorAction Stop; Write-Output wrote-ok } catch { Write-Output ("refused: " + $_.Exception.Message); exit 9 }`
152
+ }
153
+
154
+ /**
155
+ * Run one command through the configured mediator path.
156
+ *
157
+ * The path is the configured `runnerCommand`: `node.exe <shim.mjs>` fed the bubblewrap profile.
158
+ * Probing the raw runner instead answers a different question, and answered it in a misleading
159
+ * direction: the raw runner invoked as `node.exe` works in a no-console launch, while DSH's own
160
+ * default (the GUI-subsystem image) does not.
161
+ */
162
+ function runConfined(deps, opts, command) {
163
+ const profile = ['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent']
164
+ if (opts.readOnly !== true) profile.push('--tmpfs', '/tmp', '--bind', opts.workspace, opts.workspace)
165
+ const run = deps.spawn(deps.interpreter, [deps.shim, ...profile, '--', ...command], {
166
+ encoding: 'utf8', timeout: opts.timeoutMs ?? 30_000,
167
+ })
168
+ return { code: run.status, stdout: String(run.stdout ?? ''), stderr: String(run.stderr ?? ''), error: run.error?.message ?? '' }
169
+ }
170
+
171
+ /**
172
+ * Probe the sandbox through the path DSH is configured to use.
173
+ *
174
+ * @param {object} [deps]
175
+ * @param {string} [deps.mediator] where the shim and runner were materialized
176
+ * @param {string} [deps.profile] the DSH profile patch to read
177
+ * @param {(file: string, args: string[], opts: object) => object} [deps.spawn] injected for tests
178
+ * @param {string} [deps.workspace] the workspace to grant; a temp directory by default
179
+ * @returns {{ ok: boolean, checks: Array<{name: string, ok: boolean, detail: string}>,
180
+ * config: { ok: boolean, reason: string }, reason: string }}
181
+ */
182
+ export function probeSandbox(deps = {}) {
183
+ const profile = deps.profile ?? join(process.env.USERPROFILE ?? '', '.dsh', 'profiles', 'desktop', 'cordis.patch.yml')
184
+ const injectedSpawn = deps.spawn
185
+ const spawn = injectedSpawn ?? ((file, args, opts) => spawnSync(file, args, opts))
186
+
187
+ // WHICH SHIM IS EXERCISED, in precedence order:
188
+ //
189
+ // 1. deps.mediator -- an explicit override. Tests and diagnostics use it to point at a known
190
+ // location regardless of what the profile says.
191
+ // 2. the PROFILE -- ★ the source of truth. This is the fix: the plugin used to require the
192
+ // hardcoded default, so a perfectly working mediator materialized anywhere
193
+ // else was refused. See shimNamedByProfile's note.
194
+ // 3. the default -- only when the profile names nothing, so the failure is reported against
195
+ // the location an operator would have been told to use.
196
+ const named = shimNamedByProfile(profile)
197
+ const defaultMediator = 'C:\\dsh-sandbox-mediator'
198
+ const mediator = deps.mediator ?? (named.ok ? dirname(named.shim) : defaultMediator)
199
+ const shim = join(mediator, 'shim.mjs')
200
+ const runner = join(mediator, 'node_modules', '@deepseek-ai', 'dsh-sandbox-windows-acl', 'lib', 'runner.js')
201
+ // WHICH EXECUTABLE RUNS THE SHIM: the one the profile names.
202
+ //
203
+ // This used to be `process.execPath` unconditionally, which answered a question about the HOST
204
+ // (a GUI-subsystem desktop process, which may have no console) while the profile configured a
205
+ // console-subsystem node.exe. The two can disagree, and the disagreement always goes the same
206
+ // way: a machine DSH would have sandboxed correctly is refused. An explicit `deps.mediator`
207
+ // (tests, diagnostics) keeps the old behaviour, because there is no profile to read.
208
+ const interpreterFromProfile = deps.mediator === undefined && named.ok === true && named.interpreter !== ''
209
+ const interpreter = interpreterFromProfile ? named.interpreter : process.execPath
210
+ const workspace = deps.workspace ?? join(tmpdir(), 'dsh-sandbox-probe-ws')
211
+ const tempRoot = join(tmpdir(), 'dsh-sandbox-probe-tmp')
212
+
213
+ const checks = []
214
+ const add = (name, ok, detail = '') => checks.push({ name, ok, detail })
215
+
216
+ // `config` answers "does the profile name a usable shim", and it now names the one it found, so a
217
+ // refusal can say which file to fix instead of only that something did not match.
218
+ const config = deps.mediator === undefined
219
+ ? { ok: named.ok, reason: named.reason }
220
+ : profileNamesShim(profile, shim)
221
+
222
+ if (!existsSync(shim) || !existsSync(runner)) {
223
+ add('the mediator is materialized', false,
224
+ `shim=${existsSync(shim)} runner=${existsSync(runner)} at ${mediator}`)
225
+ return {
226
+ ok: false,
227
+ checks,
228
+ config,
229
+ reason: config.ok
230
+ ? `the profile names ${shim} but it is not materialized there`
231
+ : `the sandbox mediator is not materialized: ${config.reason}`,
232
+ }
233
+ }
234
+ add('the mediator is materialized', true)
235
+
236
+ // A named interpreter that is not installed is its own failure, not a reason to fall back:
237
+ // `dsh-sandbox-local` would spawn it too, so the run would fail on the worker side as well --
238
+ // just with no output, which is the symptom this whole file exists to make visible.
239
+ if (!existsSync(interpreter)) {
240
+ add('the mediator interpreter exists', false,
241
+ `runnerCommand names ${interpreter}, which is not present on this machine`)
242
+ return {
243
+ ok: false,
244
+ checks,
245
+ config,
246
+ reason: 'the mediator interpreter is missing: runnerCommand must name a console-subsystem node.exe',
247
+ }
248
+ }
249
+ add('the mediator interpreter exists', true,
250
+ interpreterFromProfile
251
+ ? interpreter
252
+ : deps.mediator === undefined
253
+ ? 'process.execPath (the profile names no interpreter)'
254
+ : "process.execPath (an explicit mediator was given, so the profile's interpreter is not used)")
255
+
256
+ rmSync(workspace, { recursive: true, force: true })
257
+ rmSync(tempRoot, { recursive: true, force: true })
258
+ mkdirSync(workspace, { recursive: true })
259
+ mkdirSync(tempRoot, { recursive: true })
260
+
261
+ const payload = [shellPath(), '-NoProfile', '-NonInteractive', '-Command']
262
+ const confined = (opts, command) => runConfined({ shim, spawn, interpreter }, { workspace, ...opts }, command)
263
+
264
+ try {
265
+ const alive = confined({}, [...payload, 'Write-Output sandbox-probe-alive'])
266
+ add('the mediator can execute a command at all', alive.code === 0 && alive.stdout.includes('sandbox-probe-alive'),
267
+ `exit=${alive.code} ${alive.stderr.trim().slice(0, 120)}`)
268
+
269
+ const inside = join(workspace, 'inside.txt')
270
+ rmSync(inside, { force: true })
271
+ const insideRun = confined({}, [...payload, writePayload(inside)])
272
+ add('a write INSIDE the workspace succeeds (the control)',
273
+ existsSync(inside) && insideRun.stdout.includes('wrote-ok'),
274
+ `exit=${insideRun.code} exists=${existsSync(inside)}`)
275
+
276
+ const outside = join(dirname(tempRoot), 'dsh-sandbox-probe-outside.txt')
277
+ rmSync(outside, { force: true })
278
+ const outsideRun = confined({}, [...payload, writePayload(outside)])
279
+ // Require BOTH: a reported refusal (not an unrelated failure) AND no file.
280
+ add('a write OUTSIDE the workspace is refused',
281
+ !existsSync(outside) && outsideRun.stdout.includes('refused:'),
282
+ existsSync(outside) ? 'the file EXISTS: workspace-write is not confining anything' : `exit=${outsideRun.code}`)
283
+
284
+ const readOnlyWrite = join(workspace, 'readonly.txt')
285
+ rmSync(readOnlyWrite, { force: true })
286
+ const readOnlyRun = confined({ readOnly: true }, [...payload, writePayload(readOnlyWrite)])
287
+ add('read-only cannot write the workspace either',
288
+ !existsSync(readOnlyWrite) && readOnlyRun.stdout.includes('refused:'),
289
+ existsSync(readOnlyWrite) ? 'read-only wrote the workspace: the tiers are not distinct' : `exit=${readOnlyRun.code}`)
290
+
291
+ const readOnlyRead = confined({ readOnly: true }, [...payload, 'Write-Output read-only-alive'])
292
+ add('read-only can still read',
293
+ readOnlyRead.code === 0 && readOnlyRead.stdout.includes('read-only-alive'), `exit=${readOnlyRead.code}`)
294
+
295
+ // Clean up BEFORE the verdict so a failure cannot leave probe files behind.
296
+ rmSync(workspace, { recursive: true, force: true })
297
+ rmSync(tempRoot, { recursive: true, force: true })
298
+ rmSync(outside, { force: true })
299
+
300
+ const failed = checks.filter((c) => !c.ok)
301
+ if (failed.length > 0) return { ok: false, checks, config, reason: `the sandbox did not confine: ${failed[0].name}` }
302
+ if (!config.ok) return { ok: false, checks, config, reason: `the mediator works but DSH will not use it: ${config.reason}` }
303
+ return { ok: true, checks, config, reason: '' }
304
+ } catch (error) {
305
+ rmSync(workspace, { recursive: true, force: true })
306
+ rmSync(tempRoot, { recursive: true, force: true })
307
+ add('the probe ran to completion', false, error instanceof Error ? error.message : String(error))
308
+ return { ok: false, checks, config, reason: `the probe could not complete: ${error instanceof Error ? error.message : String(error)}` }
309
+ }
310
+ }