@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,529 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * prepare-sandbox-mediator.mjs -- the one command the worker-side sandbox gate prescribes.
4
+ *
5
+ * WHO RUNS THIS, AND WHY IT IS NOT AUTOMATIC. The settings page diagnoses what is missing and
6
+ * prints this script's invocation with every path filled in (lib/core/sandbox-diagnosis.js). It
7
+ * does NOT run it: materializing a mediator writes ~1300 files to a machine path and (when the
8
+ * profile is not wired yet) appends a row to `%DSH_HOME%\profiles\acp\cordis.patch.yml`, which is
9
+ * the OPERATOR's file, not the plugin's own state. A page visit must not do that on its own; a
10
+ * person or an agent they point at the printed command can. The probe is still the thing that
11
+ * decides whether arming is allowed.
12
+ *
13
+ * WHAT IT DOES, IN ORDER
14
+ * 1. read the profile; if it already names a shim, that directory is the target (the profile is
15
+ * the source of truth -- see sandbox-probe.js), so the operator's existing wiring is honored
16
+ * and nothing is rewritten;
17
+ * 2. materialize the mediator there if `shim.mjs` + the runner closure are missing. The closure
18
+ * is read OUT of the running installation's `app.asar`, because plain node.exe cannot read
19
+ * inside it: this script re-runs itself under `DeepSeek Harness.exe` with
20
+ * `ELECTRON_RUN_AS_NODE=1` for that step;
21
+ * 3. drop the shipped `mediator-shim.mjs` in as `shim.mjs` (the console-subsystem mediator that
22
+ * translates bwrap arguments -- see that file's header);
23
+ * 4. ONLY when the profile has no `sandbox` entry at all, append one naming this node.exe and
24
+ * this shim. An existing entry is never rewritten: a second entry for the same id would be a
25
+ * merge nobody can predict from reading the file;
26
+ * 5. run the six-check probe and report. Exit code: 0 ready, 1 not ready, 2 could not run.
27
+ *
28
+ * IDEMPOTENT. A second run over a healthy machine touches no files (`--dry-run` proves it).
29
+ *
30
+ * USAGE
31
+ * node prepare-sandbox-mediator.mjs [--dsh-home <dir>] [--profile acp]
32
+ * [--install <dir>] [--target <dir>] [--dry-run]
33
+ */
34
+ import { spawnSync } from 'node:child_process'
35
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
36
+ import { dirname, join, relative, resolve } from 'node:path'
37
+ import { fileURLToPath } from 'node:url'
38
+
39
+ import { inspectSandbox } from '../core/sandbox-diagnosis.js'
40
+ import { probeSandbox } from '../core/sandbox-probe.js'
41
+ import { resolveDshHome } from '../core/settings-store.js'
42
+
43
+ const HERE = dirname(fileURLToPath(import.meta.url))
44
+ const SHIM_SOURCE = join(HERE, 'mediator-shim.mjs')
45
+ const SELF = fileURLToPath(import.meta.url)
46
+
47
+ /** Packages the runner needs at run time. A hand-written list was tried and failed twice. */
48
+ const SEEDS = [
49
+ '@deepseek-ai/dsh-sandbox-windows-acl',
50
+ '@deepseek-ai/dsh-subprocess',
51
+ '@deepseek-ai/dsh-win32-process',
52
+ '@deepseek-ai/dsh-lazy-require',
53
+ '@deepseek-ai/dsh-skill',
54
+ '@deepseek-ai/cordis',
55
+ 'koffi',
56
+ '@koromix/koffi-win32-x64',
57
+ 'yaml',
58
+ ]
59
+
60
+ /** The managed block this script appends when the profile has no `sandbox` row at all. */
61
+ export const MANAGED_HEADER = '# --- dsh-plugin-remote-use managed (auto-generated; delete this block to let the plugin prescribe again) ---'
62
+ export const MANAGED_FOOTER = '# --- end dsh-plugin-remote-use managed ---'
63
+
64
+ /**
65
+ * @param {{ nodeExe: string, shim: string }} opts
66
+ * @returns {string} a YAML list of one `sandbox` entry
67
+ */
68
+ export function sandboxEntry({ nodeExe, shim }) {
69
+ const quoted = (p) => String(p).replace(/\\/gu, '\\\\')
70
+ return [
71
+ MANAGED_HEADER,
72
+ '# The Windows sandbox build its mediator from `process.execPath`, which is',
73
+ '# "DeepSeek Harness.exe" -- a GUI-subsystem image. Windows never allocates a console to one,',
74
+ '# so the restricted child dies during DLL initialization and EVERY command fails silently.',
75
+ '# node.exe is a console-subsystem image, and shim.mjs translates bwrap arguments to the',
76
+ '# Windows runner. Re-run prepare-sandbox-mediator.mjs if the mediator directory is removed.',
77
+ '- id: sandbox',
78
+ " name: '@deepseek-ai/dsh-sandbox-local'",
79
+ ' config:',
80
+ ' runnerCommand:',
81
+ ` - "${quoted(nodeExe)}"`,
82
+ ` - "${quoted(shim)}"`,
83
+ ' runnerFailureSignatures:',
84
+ ' - "windows-acl-run: "',
85
+ MANAGED_FOOTER,
86
+ ].join('\n')
87
+ }
88
+
89
+ /** Does the profile already contain a sandbox row (wired or not)? */
90
+ export function hasSandboxEntry(text) {
91
+ return /(?:^|\n)\s*-\s*id:\s*sandbox\s*(?:\n|$)/u.test(String(text ?? ''))
92
+ }
93
+
94
+ /**
95
+ * Remove a standalone `[]` line.
96
+ *
97
+ * DSH's profile template IS the empty flow array: `[]` is a complete YAML document, and appending
98
+ * entries after it produces TWO documents with no `---` separator. The ACP child then refuses the
99
+ * whole profile ("end of the stream or a document separator is expected") while the arming probe,
100
+ * which reads the file structurally, is happy. Measured 2026-11-05: the worker armed, and every
101
+ * ACP connection afterwards closed -- the master saw only `S0 worker reachable: fail` (PITFALLS F45).
102
+ *
103
+ * A file that is ONLY `[]` is valid and is left alone by the caller.
104
+ *
105
+ * @param {string} text
106
+ * @returns {{ text: string, removed: boolean }}
107
+ */
108
+ export function stripEmptyListMarker(text) {
109
+ const lines = String(text ?? '').split(/\r?\n/)
110
+ const kept = lines.filter((line) => !/^[ \t]*\[\][ \t]*$/u.test(line))
111
+ return { text: kept.join('\n'), removed: kept.length !== lines.length }
112
+ }
113
+
114
+ /**
115
+ * Ask the REAL consumer whether this profile parses.
116
+ *
117
+ * `--dump-config` composes the profile (including this overlay) and exits non-zero on a parse or
118
+ * schema error. It is the same loader the ACP child uses, which is the point: the arming probe
119
+ * cannot answer this question, and a profile that parses in one place and not the other is how a
120
+ * worker arms and then cannot serve a single connection.
121
+ *
122
+ * @param {{ installDir: string, profile: string }} opts
123
+ * @returns {{ ok: boolean, detail: string }}
124
+ */
125
+ export function validateProfile(opts) {
126
+ const exe = join(opts.installDir, 'DeepSeek Harness.exe')
127
+ const cli = join(opts.installDir, 'resources', 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js')
128
+ const run = spawnSync(exe, ['--expose-internals', cli, '--profile', opts.profile, '--dump-config'], {
129
+ encoding: 'utf8',
130
+ timeout: 60_000,
131
+ windowsHide: true,
132
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
133
+ })
134
+ if (run.error !== undefined) return { ok: false, detail: run.error.message }
135
+ if (run.status !== 0) {
136
+ return { ok: false, detail: String(run.stderr ?? '').trim().split(/\r?\n/u).slice(-4).join(' / ') }
137
+ }
138
+ return { ok: true, detail: '' }
139
+ }
140
+
141
+ /**
142
+ * Append the managed block, preserving the file's own text byte for byte.
143
+ *
144
+ * NO BOM: a BOM written here would be the third time this project made a YAML file unreadable
145
+ * (PITFALLS E8). `writeFileSync` with 'utf8' writes none.
146
+ *
147
+ * @param {string} text
148
+ * @param {string} entry
149
+ * @returns {string}
150
+ */
151
+ export function appendEntry(text, entry) {
152
+ // `[]` is the profile template's EMPTY LIST -- the patch list itself. Appending after it makes a
153
+ // second YAML document; see stripEmptyListMarker.
154
+ const { text: stripped } = stripEmptyListMarker(text)
155
+ const base = stripped.replace(/\s+$/u, '')
156
+ if (base === '') return `${entry}\n`
157
+ return `${base}\n\n${entry}\n`
158
+ }
159
+
160
+ /** @param {string} file */
161
+ function readText(file) {
162
+ try {
163
+ return readFileSync(file, 'utf8').replace(/^\uFEFF/u, '')
164
+ } catch {
165
+ return undefined
166
+ }
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // extraction from app.asar
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Copy the runner's dependency closure out of `app.asar`.
175
+ *
176
+ * WHY A CLOSURE AND NOT A LIST: adding packages one at a time produced two consecutive invalid
177
+ * experiments, each reported as evidence about the sandbox. Resolving `dependencies` from each
178
+ * copied manifest makes that class of failure impossible.
179
+ *
180
+ * MUST RUN WHERE `fs` CAN READ app.asar -- i.e. under Electron. That is ensured by the caller.
181
+ *
182
+ * @param {string} asarRoot
183
+ * @param {string} outDir
184
+ * @returns {{ ok: boolean, copied: number, missing: string[], natives: number }}
185
+ */
186
+ export function extractAsar(asarRoot, outDir) {
187
+ const unpacked = asarRoot.replace(/app\.asar$/u, 'app.asar.unpacked')
188
+ const roots = [asarRoot]
189
+ if (existsSync(unpacked)) roots.push(unpacked)
190
+ mkdirSync(join(outDir, 'node_modules'), { recursive: true })
191
+
192
+ const locateCache = new Map()
193
+ /** @param {string} base */
194
+ const locate = (base) => {
195
+ if (locateCache.has(base)) return locateCache.get(base)
196
+ const hits = []
197
+ const count = (dir) => {
198
+ let n = 0
199
+ let entries
200
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return 0 }
201
+ for (const entry of entries) {
202
+ if (entry.isDirectory()) n += count(join(dir, entry.name))
203
+ else n += 1
204
+ }
205
+ return n
206
+ }
207
+ const walk = (dir, depth) => {
208
+ if (depth > 7) return
209
+ let entries
210
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
211
+ for (const entry of entries) {
212
+ if (!entry.isDirectory()) continue
213
+ const p = join(dir, entry.name)
214
+ if (entry.name === base) {
215
+ try {
216
+ if (statSync(join(p, 'package.json')).isFile()) hits.push({ path: p, files: count(p) })
217
+ } catch { /* no manifest */ }
218
+ continue
219
+ }
220
+ walk(p, depth + 1)
221
+ }
222
+ }
223
+ for (const root of roots) walk(root, 0)
224
+ hits.sort((a, b) => b.files - a.files)
225
+ locateCache.set(base, hits)
226
+ return hits
227
+ }
228
+
229
+ const copyTree = (src, dest) => {
230
+ mkdirSync(dest, { recursive: true })
231
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
232
+ const s = join(src, entry.name)
233
+ const d = join(dest, entry.name)
234
+ if (entry.isDirectory()) copyTree(s, d)
235
+ else writeFileSync(d, readFileSync(s))
236
+ }
237
+ }
238
+
239
+ const copied = new Set()
240
+ const missing = []
241
+ const queue = [...SEEDS]
242
+ while (queue.length > 0) {
243
+ const name = queue.shift()
244
+ if (copied.has(name) || missing.includes(name)) continue
245
+ if (name.startsWith('node:') || name.startsWith('.')) continue
246
+ const base = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name
247
+ const chosen = locate(base)[0]
248
+ if (chosen === undefined) {
249
+ missing.push(name)
250
+ process.stdout.write(` MISSING ${name}\n`)
251
+ continue
252
+ }
253
+ const dest = join(outDir, 'node_modules', ...name.split('/'))
254
+ try {
255
+ copyTree(chosen.path, dest)
256
+ copied.add(name)
257
+ } catch (error) {
258
+ missing.push(name)
259
+ process.stdout.write(` FAILED ${name}: ${error instanceof Error ? error.message : String(error)}\n`)
260
+ continue
261
+ }
262
+ let manifest = {}
263
+ try { manifest = JSON.parse(readFileSync(join(chosen.path, 'package.json'), 'utf8')) } catch { /* unreadable */ }
264
+ for (const field of ['dependencies', 'peerDependencies']) {
265
+ for (const dep of Object.keys(manifest[field] ?? {})) {
266
+ if (!copied.has(dep) && !missing.includes(dep) && !queue.includes(dep)) queue.push(dep)
267
+ }
268
+ }
269
+ for (const dep of Object.keys(manifest.optionalDependencies ?? {})) {
270
+ if (!copied.has(dep) && !queue.includes(dep)) queue.push(dep)
271
+ }
272
+ }
273
+
274
+ const natives = []
275
+ const findNative = (dir) => {
276
+ let entries
277
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
278
+ for (const entry of entries) {
279
+ const p = join(dir, entry.name)
280
+ if (entry.isDirectory()) findNative(p)
281
+ else if (entry.name.endsWith('.node')) natives.push(p)
282
+ }
283
+ }
284
+ findNative(join(outDir, 'node_modules'))
285
+
286
+ const runner = join(outDir, 'node_modules', '@deepseek-ai', 'dsh-sandbox-windows-acl', 'lib', 'runner.js')
287
+ // `missing` includes optional dependencies for OTHER platforms, which is normal and must not fail
288
+ // the run: koffi lists every OS/arch variant as optional, and only this machine's is present.
289
+ // Only a seed that is absent is a real failure -- the seeds are the entry points this mediator
290
+ // cannot start without.
291
+ const missingRequired = missing.filter((name) => SEEDS.includes(name))
292
+ return {
293
+ ok: existsSync(runner) && natives.length > 0 && missingRequired.length === 0,
294
+ copied: copied.size,
295
+ missing,
296
+ missingRequired,
297
+ natives: natives.length,
298
+ }
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // main
303
+ // ---------------------------------------------------------------------------
304
+
305
+ function argOf(argv, name, fallback) {
306
+ const i = argv.indexOf(`--${name}`)
307
+ return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : fallback
308
+ }
309
+
310
+ /**
311
+ * @param {string[]} [argv]
312
+ * @param {object} [deps] injectable for tests: env, exists, spawn, probe, write
313
+ */
314
+ export function main(argv = process.argv.slice(2), deps = {}) {
315
+ const env = deps.env ?? process.env
316
+ const exists = deps.exists ?? existsSync
317
+ const say = deps.say ?? ((line) => process.stdout.write(`${line}\n`))
318
+ const dryRun = argv.includes('--dry-run')
319
+
320
+ // The internal mode: only reachable when this file re-runs itself under Electron.
321
+ const extractIndex = argv.indexOf('--extract-asar')
322
+ if (extractIndex >= 0) {
323
+ const asarRoot = argv[extractIndex + 1] ?? ''
324
+ const outDir = argv[extractIndex + 2] ?? ''
325
+ if (asarRoot === '' || outDir === '') {
326
+ say('--extract-asar needs <app.asar> <outDir>')
327
+ return 2
328
+ }
329
+ say(`extracting into ${outDir}`)
330
+ const result = extractAsar(asarRoot, outDir)
331
+ say(`copied ${result.copied} packages, ${result.natives} native binaries`
332
+ + (result.missingRequired.length > 0 ? `, MISSING REQUIRED: ${result.missingRequired.join(', ')}` : '')
333
+ + (result.missing.length > result.missingRequired.length
334
+ ? `, absent optional (other platforms): ${result.missing.length - result.missingRequired.length}`
335
+ : ''))
336
+ return result.ok ? 0 : 1
337
+ }
338
+
339
+ const profile = argOf(argv, 'profile', 'acp')
340
+ if (!/^[\w.-]+$/u.test(profile)) {
341
+ say(`refusing profile name ${JSON.stringify(profile)}: expected a plain profile name`)
342
+ return 2
343
+ }
344
+ const installArg = argOf(argv, 'install', undefined)
345
+ const targetArg = argOf(argv, 'target', undefined)
346
+
347
+ const home = argOf(argv, 'dsh-home', undefined) ?? resolveDshHome(env)
348
+ const profilePath = join(home, 'profiles', profile, 'cordis.patch.yml')
349
+
350
+ // REPAIR BEFORE DIAGNOSING: the `[]` + entries shape is invalid YAML, and leaving it in place
351
+ // would make the diagnosis report "unwired" (the guard in sandbox-probe.js) and this script
352
+ // refuse to touch a file that it is the one able to fix.
353
+ let profileText = readText(profilePath)
354
+ const marker = profileText === undefined ? { text: undefined, removed: false } : stripEmptyListMarker(profileText)
355
+ if (marker.removed && /(?:^|\n)[ \t]*-[ \t]/u.test(String(profileText))) {
356
+ if (dryRun) {
357
+ say(' profile : would remove the invalid `[]` marker (two YAML documents -> the ACP child refuses to start)')
358
+ } else {
359
+ try { writeFileSync(`${profilePath}.bak-bracefix-${Date.now()}`, String(profileText), 'utf8') } catch { /* best effort */ }
360
+ writeFileSync(profilePath, marker.text, 'utf8')
361
+ profileText = marker.text
362
+ say(' profile : removed the invalid `[]` line (two YAML documents -> the ACP child refused to start)')
363
+ }
364
+ }
365
+
366
+ const diagnosis = inspectSandbox({
367
+ env,
368
+ home,
369
+ profilePath,
370
+ installDir: installArg === undefined ? undefined : installArg,
371
+ exists,
372
+ // Injected so a test can describe a machine without a real PE image to inspect.
373
+ subsystem: deps.subsystem,
374
+ })
375
+
376
+ say('sandbox mediator preparation')
377
+ say(` dsh home : ${diagnosis.home}`)
378
+ say(` profile : ${profilePath}`)
379
+ say(` install : ${diagnosis.installDir === '' ? '(not found)' : diagnosis.installDir}`)
380
+ say(` node.exe : ${diagnosis.nodeExe === '' ? '(not found)' : diagnosis.nodeExe}`)
381
+ say(` state now : ${diagnosis.state}${diagnosis.reason === '' ? '' : ` -- ${diagnosis.reason}`}`)
382
+ say('')
383
+
384
+ if (diagnosis.platform !== 'win32') {
385
+ say('this platform has no Windows sandbox mediator to prepare')
386
+ return 2
387
+ }
388
+ if (diagnosis.installDir === '' || diagnosis.nodeExe === '') {
389
+ say('cannot prepare: the installation or a console-subsystem node.exe was not found')
390
+ say('pass --install <dir> and/or set DSH_NODE_EXE to a node.exe')
391
+ return 2
392
+ }
393
+
394
+ // 1. target = what the profile already names (the profile is the source of truth).
395
+ const alreadyHasEntry = profileText !== undefined && hasSandboxEntry(profileText)
396
+ const target = diagnosis.profileNamesShim
397
+ ? diagnosis.mediatorDir
398
+ : (targetArg ?? join(diagnosis.home, 'sandbox-mediator'))
399
+ say(` target : ${target}`)
400
+
401
+ if (diagnosis.profileNamesShim && targetArg !== undefined && targetArg.toLowerCase() !== target.toLowerCase()) {
402
+ say(` note : ignoring --target; the profile already names ${diagnosis.shim}`)
403
+ }
404
+ if (!diagnosis.profileNamesShim && alreadyHasEntry) {
405
+ say('')
406
+ say('the profile already has a `sandbox` entry this script will not rewrite:')
407
+ say(` ${diagnosis.profileReason}`)
408
+ say('fix or remove that block by hand, then run this again')
409
+ return 1
410
+ }
411
+
412
+ // 2. materialize the runner closure if it is not there.
413
+ const shim = join(target, 'shim.mjs')
414
+ const runner = join(target, 'node_modules', '@deepseek-ai', 'dsh-sandbox-windows-acl', 'lib', 'runner.js')
415
+ const haveRunner = exists(runner)
416
+ const haveShim = exists(shim)
417
+ let materialized = haveRunner && haveShim
418
+ if (materialized) {
419
+ say(' mediator : already materialized -- leaving it alone')
420
+ } else if (dryRun) {
421
+ say(` mediator : ${haveRunner ? 'shim.mjs is missing' : 'MISSING'} (would materialize from app.asar)`)
422
+ } else if (haveRunner) {
423
+ // A previous run that stopped after extraction, or someone deleted the shim. Re-installing the
424
+ // shipped shim is cheap and cannot regress the runner, so there is no reason to re-extract here.
425
+ writeFileSync(shim, readFileSync(SHIM_SOURCE))
426
+ materialized = true
427
+ say(` shim : installed at ${shim} (the runner was already present)`)
428
+ } else {
429
+ const asarRoot = join(diagnosis.installDir, 'resources', 'app.asar')
430
+ if (!exists(asarRoot)) {
431
+ say(`cannot prepare: ${asarRoot} does not exist`)
432
+ return 2
433
+ }
434
+ mkdirSync(target, { recursive: true })
435
+ const spawn = deps.spawn ?? spawnSync
436
+ const exe = join(diagnosis.installDir, 'DeepSeek Harness.exe')
437
+ say(` extracting : ${relative(diagnosis.installDir, asarRoot)} -> ${target}`)
438
+ // Re-run THIS file under Electron so `fs` can read app.asar. stdio is inherited: the
439
+ // operator sees the package-by-package trace, and a failure is visible where it happened.
440
+ const run = spawn(exe, [SELF, '--extract-asar', asarRoot, target], {
441
+ stdio: 'inherit',
442
+ windowsHide: true,
443
+ timeout: 300_000,
444
+ env: { ...env, ELECTRON_RUN_AS_NODE: '1' },
445
+ })
446
+ if (run.error !== undefined || (run.status ?? 1) !== 0) {
447
+ say(` extraction failed (${run.error?.message ?? `exit ${run.status}`})`)
448
+ return 2
449
+ }
450
+ if (!exists(runner)) {
451
+ say(` extraction finished but ${runner} is missing`)
452
+ return 2
453
+ }
454
+ writeFileSync(shim, readFileSync(SHIM_SOURCE))
455
+ materialized = true
456
+ say(` shim : ${shim}`)
457
+ say(` runner : ${runner}`)
458
+ try {
459
+ writeFileSync(join(target, 'mediator.json'), JSON.stringify({
460
+ preparedAt: new Date().toISOString(),
461
+ installDir: diagnosis.installDir,
462
+ asar: asarRoot,
463
+ nodeExe: diagnosis.nodeExe,
464
+ shim,
465
+ preparedBy: 'prepare-sandbox-mediator.mjs',
466
+ }, null, 2), 'utf8')
467
+ } catch { /* provenance is a convenience, not a requirement */ }
468
+ }
469
+
470
+ // 3. wire the profile ONLY when it has no sandbox row at all.
471
+ if (!diagnosis.profileNamesShim && !alreadyHasEntry) {
472
+ const entry = sandboxEntry({ nodeExe: diagnosis.nodeExe, shim })
473
+ if (dryRun) {
474
+ say(' profile : would append the managed sandbox block')
475
+ } else {
476
+ mkdirSync(dirname(profilePath), { recursive: true })
477
+ const before = readText(profilePath)
478
+ if (before !== undefined) {
479
+ try {
480
+ writeFileSync(`${profilePath}.bak-sandbox-${Date.now()}`, before, 'utf8')
481
+ } catch { /* a backup is best effort; refusing to write over it is not */ }
482
+ }
483
+ writeFileSync(profilePath, appendEntry(before ?? '', entry), 'utf8')
484
+ say(` profile : appended the managed sandbox block to ${profilePath}`)
485
+ }
486
+ } else if (diagnosis.profileNamesShim) {
487
+ say(' profile : already names this shim -- not rewritten')
488
+ }
489
+
490
+ if (dryRun) {
491
+ say('')
492
+ say('dry run: nothing was written')
493
+ return 0
494
+ }
495
+
496
+ // 3b. PROVE THE PROFILE PARSES, with the loader the ACP child actually uses.
497
+ //
498
+ // The sandbox probe below cannot answer this: it reads the file structurally, so a profile with
499
+ // two YAML documents passes it and fails at ACP startup (PITFALLS F45). `--dump-config` is the
500
+ // real consumer, it exits non-zero on a parse error, and it costs about a second.
501
+ const validation = validateProfile({ installDir: diagnosis.installDir, profile })
502
+ if (!validation.ok) {
503
+ say('')
504
+ say(`the profile does not parse: ${validation.detail}`)
505
+ say('the ACP child would refuse to start; do not arm this machine until this is fixed')
506
+ return 1
507
+ }
508
+ say(' profile : parses (--dump-config OK)')
509
+
510
+ // 4. verify with the same probe the gate uses. Nothing less than that counts.
511
+ say('')
512
+ say('verifying with the arming gate\'s own probe:')
513
+ const probe = deps.probe ?? ((opts) => probeSandbox(opts))
514
+ const result = probe({ profile: profilePath })
515
+ for (const check of result.checks ?? []) {
516
+ say(` [${check.ok ? 'PASS' : 'FAIL'}] ${check.name}${check.detail === '' ? '' : ` -- ${check.detail}`}`)
517
+ }
518
+ say('')
519
+ if (result.ok === true) {
520
+ say('RESULT: READY -- arm this machine from 设置 → 远程工作节点 → 本机角色')
521
+ return 0
522
+ }
523
+ say(`RESULT: NOT READY -- ${result.reason}`)
524
+ return 1
525
+ }
526
+
527
+ const invokedDirectly = process.argv[1] !== undefined
528
+ && resolve(SELF).toLowerCase() === resolve(process.argv[1]).toLowerCase()
529
+ if (invokedDirectly) process.exit(main())