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