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