@lone-wolf-akela/dsh-sandboxie-redirect 0.3.3 → 0.3.5
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/CHANGELOG.md +67 -0
- package/lib/boxes.mjs +32 -2
- package/lib/inner.mjs +27 -1
- package/lib/protocol.mjs +4 -0
- package/lib/run.mjs +56 -5
- package/lib/sandbox-env.mjs +88 -0
- package/lib/sbie.mjs +22 -0
- package/lib/tool.mjs +12 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,73 @@
|
|
|
3
3
|
Notable changes per release. The format is loose; the version numbers are what
|
|
4
4
|
the DSH compatibility gate reads.
|
|
5
5
|
|
|
6
|
+
## 0.3.5 — 2026-11-14
|
|
7
|
+
|
|
8
|
+
**Programmatic tool calling + copy-on-write: every command failed**
|
|
9
|
+
|
|
10
|
+
- PTC spawns its runtime with a deliberately scrubbed environment — only
|
|
11
|
+
`PATH`, `PATHEXT`, `SYSTEMROOT`, `WINDIR`, `TEMP` and `TMP`. The launcher
|
|
12
|
+
inherits it, and Sandboxie expands its `ProfileList` values
|
|
13
|
+
(`%SystemDrive%\ProgramData`, `%SystemDrive%\Users\…`) with
|
|
14
|
+
`ExpandEnvironmentStrings`, which only sees the *caller's* environment. Without
|
|
15
|
+
`SystemDrive` the expansion fails, the boxed relay dies during initialization
|
|
16
|
+
(`SBIE2306 Could not locate user directory: [C000003B / 33]` followed by
|
|
17
|
+
`SBIE2304`) and the launcher could only report `the boxed relay did not connect
|
|
18
|
+
within 15000 ms` — so *every* tool call in the session failed, including a
|
|
19
|
+
program that made none.
|
|
20
|
+
- `lib/sandbox-env.mjs` now restores `SystemDrive` into the launcher's own
|
|
21
|
+
process environment before the boxed relay is started, deriving it from the
|
|
22
|
+
caller's environment, from `GetWindowsDirectoryW`, or from Node's own drive —
|
|
23
|
+
in that order. The command's spawn specification is built from a snapshot taken
|
|
24
|
+
*before* the restoration, so the boxed command still receives exactly the
|
|
25
|
+
environment its caller chose; `test/unit.mjs` pins both halves.
|
|
26
|
+
- Reproduced offline by spawning the launcher through `CreateProcessW` with the
|
|
27
|
+
exact six-variable environment and no `SystemDrive`: the launcher timed out and
|
|
28
|
+
the boxed `node.exe` reported SBIE2306/SBIE2304. With the fix the same
|
|
29
|
+
invocation runs the command and returns its exit code.
|
|
30
|
+
|
|
31
|
+
**PTC runtime: Sandboxie injected browser flags into Electron's Node mode**
|
|
32
|
+
|
|
33
|
+
- Sandboxie classifies Electron images (DSH's executable included) as Chromium
|
|
34
|
+
and, for every non-child process of that image, prepends its
|
|
35
|
+
`CustomChromiumFlags` — by default `--disable-features=PrintCompositorLPAC` —
|
|
36
|
+
to the command line. The PTC runtime is Electron started with
|
|
37
|
+
`ELECTRON_RUN_AS_NODE=1`, so Node parses that browser flag as one of its own
|
|
38
|
+
options and exits 9 with `bad option: --disable-features=PrintCompositorLPAC`
|
|
39
|
+
before the program's first line runs.
|
|
40
|
+
- Managed boxes now carry `SpecialImage=none,DeepSeek Harness.exe`, which
|
|
41
|
+
suppresses the classification for DSH's own executable only; other Chromium
|
|
42
|
+
applications in the same box keep Sandboxie's compatibility flags. Boxes
|
|
43
|
+
created by older versions are repaired on the next launch.
|
|
44
|
+
- `test/unit.mjs` pins the box setting for both access modes.
|
|
45
|
+
|
|
46
|
+
**PTC runtime: the inherited control channel never reached the box**
|
|
47
|
+
|
|
48
|
+
- DSH hands the confined process a control channel on fd 7 (`SUBPROCESS_CONTROL_FD`,
|
|
49
|
+
an `overlapped` named pipe marked by `DSH_SUBPROCESS_CONTROL=pipe`). SbieSvc
|
|
50
|
+
ignores inherited handles, so the boxed relay started the command with only
|
|
51
|
+
stdin/stdout/stderr; the PTC runtime then died at startup with
|
|
52
|
+
`ERR_INVALID_FD_TYPE: Unsupported fd type: UNKNOWN` from
|
|
53
|
+
`openInheritedControlChannel()`.
|
|
54
|
+
- The launcher now opens fd 7 itself, forwards its bytes over the relay's TCP
|
|
55
|
+
channel (new `CONTROL` / `CONTROL_EOF` frames) and the relay re-creates an
|
|
56
|
+
`overlapped` fd 7 for the boxed child. The command's environment is unchanged.
|
|
57
|
+
- `test/run-tests.mjs` case 21 pins the roundtrip through a real box; case 20
|
|
58
|
+
remains the scrubbed-environment regression.
|
|
59
|
+
|
|
60
|
+
## 0.3.4 — 2026-10-13
|
|
61
|
+
|
|
62
|
+
**`sandbox_clear` mode `status` no longer answers in `inspect`'s words**
|
|
63
|
+
|
|
64
|
+
- `render` gave every action except `clear` the inspect head line, so a status
|
|
65
|
+
query read `Sandbox copies for <workspace>: 0 found.` — which looks like "this
|
|
66
|
+
workspace has no copy" when the call says nothing about copies at all. `status`
|
|
67
|
+
now has its own head, `Session projection state for <workspace>.`, above the
|
|
68
|
+
projection detail that actually carries the information.
|
|
69
|
+
- `test/tool-execute.mjs` pins the rendered text of all three actions, plus the
|
|
70
|
+
empty and truncated copy lines, so the wording cannot silently fall through
|
|
71
|
+
again.
|
|
72
|
+
|
|
6
73
|
## 0.3.3 — 2026-10-13
|
|
7
74
|
|
|
8
75
|
**`sandbox_clear` mode `status` never worked**
|
package/lib/boxes.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { spawnSync } from "node:child_process";
|
|
23
23
|
import fs from "node:fs";
|
|
24
24
|
import path from "node:path";
|
|
25
|
+
import { DSH_EXE } from "./dsh-locate.mjs";
|
|
25
26
|
import { boxNameCandidates, boxNameFor, boxRoot, canonicalWorkspace, isManagedBoxName, resolveBoxRoot, sbieSandboxRoot } from "./naming.mjs";
|
|
26
27
|
import { boxExists, listBoxNames, queryBoxSetting, reloadConfig, sbieExe, sbieIni, setBoxSetting } from "./sbie.mjs";
|
|
27
28
|
|
|
@@ -69,6 +70,23 @@ export function rootFor(box) {
|
|
|
69
70
|
/** How many name candidates to try before giving up on a collision. */
|
|
70
71
|
const CANDIDATE_LIMIT = 12;
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Sandboxie classifies Electron images — including DSH's own executable — as
|
|
75
|
+
* Chromium and, for every non-child process of that image, injects its
|
|
76
|
+
* configured `CustomChromiumFlags` into the command line. The stock default is
|
|
77
|
+
* `--disable-features=PrintCompositorLPAC`, a browser compatibility flag.
|
|
78
|
+
*
|
|
79
|
+
* That is fatal for Electron's Node mode: the PTC runtime is started with
|
|
80
|
+
* `ELECTRON_RUN_AS_NODE=1`, so Node parses the injected flag as one of its own
|
|
81
|
+
* CLI options and exits 9 with `bad option: --disable-features=PrintCompositorLPAC`
|
|
82
|
+
* before the runtime can do anything — every programmatic tool call dies.
|
|
83
|
+
*
|
|
84
|
+
* Classifying the DSH executable as `none` in OUR boxes suppresses the
|
|
85
|
+
* compatibility path for that one image only; other Chromium applications
|
|
86
|
+
* started in the same box keep Sandboxie's default flags.
|
|
87
|
+
*/
|
|
88
|
+
export const SPECIAL_IMAGE_SETTING = `none,${DSH_EXE}`;
|
|
89
|
+
|
|
72
90
|
/**
|
|
73
91
|
* The settings a managed box carries. Anything not listed keeps Sandboxie's
|
|
74
92
|
* default, which is "writes are redirected into the box" — the whole point.
|
|
@@ -86,7 +104,9 @@ export function boxSettings(workspace, mode) {
|
|
|
86
104
|
["AutoRecover", "n"],
|
|
87
105
|
// Headless must never raise a modal "large file" prompt.
|
|
88
106
|
["CopyLimitKb", "131072"],
|
|
89
|
-
["CopyLimitSilent", "y"]
|
|
107
|
+
["CopyLimitSilent", "y"],
|
|
108
|
+
// Keep Sandboxie from injecting Chromium flags into DSH's Electron runtime.
|
|
109
|
+
["SpecialImage", SPECIAL_IMAGE_SETTING]
|
|
90
110
|
];
|
|
91
111
|
}
|
|
92
112
|
|
|
@@ -137,11 +157,21 @@ export function ensureBox(request) {
|
|
|
137
157
|
const accessRule = `${workspace}\\*`;
|
|
138
158
|
|
|
139
159
|
if (boxExists(box)) {
|
|
160
|
+
let changed = false;
|
|
140
161
|
if (!boxOwnedBy(box, workspace, mode)) {
|
|
141
162
|
log(`box ${box}: workspace access rule missing or stale, rewriting`);
|
|
142
163
|
setBoxSetting(box, accessKey, accessRule);
|
|
143
|
-
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
// Boxes created by older plugin versions lack this entry; without it every
|
|
167
|
+
// PTC program fails before its first line (see SPECIAL_IMAGE_SETTING).
|
|
168
|
+
const specialImage = queryBoxSetting(box, "SpecialImage");
|
|
169
|
+
if (specialImage === undefined || !specialImage.toLowerCase().includes(SPECIAL_IMAGE_SETTING.toLowerCase())) {
|
|
170
|
+
log(`box ${box}: suppressing Chromium classification for ${DSH_EXE}`);
|
|
171
|
+
setBoxSetting(box, "SpecialImage", SPECIAL_IMAGE_SETTING);
|
|
172
|
+
changed = true;
|
|
144
173
|
}
|
|
174
|
+
if (changed) reloadConfig();
|
|
145
175
|
return { box, root, created: false, workspace, mode };
|
|
146
176
|
}
|
|
147
177
|
|
package/lib/inner.mjs
CHANGED
|
@@ -34,6 +34,7 @@ if (!Number.isInteger(options.port) || options.port <= 0) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
let child;
|
|
37
|
+
let control;
|
|
37
38
|
let finished = false;
|
|
38
39
|
|
|
39
40
|
const socket = net.connect({ host: options.host, port: options.port });
|
|
@@ -53,11 +54,22 @@ function finish(code) {
|
|
|
53
54
|
|
|
54
55
|
function startChild(spec) {
|
|
55
56
|
let childProcess;
|
|
57
|
+
// The harness's control channel reaches the launcher on fd 7 (DSH's
|
|
58
|
+
// SUBPROCESS_CONTROL_FD); SbieSvc cannot pass inherited handles into the box,
|
|
59
|
+
// so the launcher forwards the bytes and we re-create the same shape here: an
|
|
60
|
+
// `overlapped` named pipe at fd 7, which the PTC runtime opens with
|
|
61
|
+
// `net.Socket`. fds 3-6 stay unused (the Windows job runner reserves them),
|
|
62
|
+
// so the array must be padded rather than compacted.
|
|
63
|
+
const stdio = ["pipe", "pipe", "pipe"];
|
|
64
|
+
if (spec.control === true) {
|
|
65
|
+
while (stdio.length < 7) stdio.push("ignore");
|
|
66
|
+
stdio.push("overlapped");
|
|
67
|
+
}
|
|
56
68
|
try {
|
|
57
69
|
childProcess = spawn(spec.argv[0], spec.argv.slice(1), {
|
|
58
70
|
cwd: spec.cwd,
|
|
59
71
|
env: spec.env,
|
|
60
|
-
stdio
|
|
72
|
+
stdio,
|
|
61
73
|
windowsHide: true
|
|
62
74
|
});
|
|
63
75
|
} catch (error) {
|
|
@@ -67,6 +79,13 @@ function startChild(spec) {
|
|
|
67
79
|
return;
|
|
68
80
|
}
|
|
69
81
|
child = childProcess;
|
|
82
|
+
if (spec.control === true && childProcess.stdio[7] !== undefined && childProcess.stdio[7] !== null) {
|
|
83
|
+
control = childProcess.stdio[7];
|
|
84
|
+
control.on("data", (chunk) => send(Frame.CONTROL, chunk));
|
|
85
|
+
control.on("error", () => {
|
|
86
|
+
/* the launcher may close the channel first; the child's exit still decides */
|
|
87
|
+
});
|
|
88
|
+
}
|
|
70
89
|
child.on("error", (error) => {
|
|
71
90
|
send(Frame.FAIL, `cannot spawn ${spec.argv[0]}: ${error.message}`);
|
|
72
91
|
socket.end();
|
|
@@ -96,6 +115,12 @@ const decode = createFrameDecoder((type, body) => {
|
|
|
96
115
|
case Frame.STDIN_EOF:
|
|
97
116
|
if (child !== undefined && child.stdin.writable) child.stdin.end();
|
|
98
117
|
break;
|
|
118
|
+
case Frame.CONTROL:
|
|
119
|
+
if (control !== undefined && control.writable) control.write(body);
|
|
120
|
+
break;
|
|
121
|
+
case Frame.CONTROL_EOF:
|
|
122
|
+
if (control !== undefined) control.end();
|
|
123
|
+
break;
|
|
99
124
|
default:
|
|
100
125
|
break;
|
|
101
126
|
}
|
|
@@ -108,6 +133,7 @@ socket.on("error", (error) => {
|
|
|
108
133
|
finish(127);
|
|
109
134
|
});
|
|
110
135
|
socket.on("close", () => {
|
|
136
|
+
control?.destroy();
|
|
111
137
|
if (!finished) {
|
|
112
138
|
// The launcher went away (or finished): do not leave the command running.
|
|
113
139
|
child?.kill();
|
package/lib/protocol.mjs
CHANGED
|
@@ -14,6 +14,10 @@ export const Frame = {
|
|
|
14
14
|
SPEC: 2,
|
|
15
15
|
STDIN: 3,
|
|
16
16
|
STDIN_EOF: 4,
|
|
17
|
+
/** Raw bytes of the harness's inherited control channel, forwarded verbatim. */
|
|
18
|
+
CONTROL: 5,
|
|
19
|
+
/** The harness closed its control channel; the child's fd 7 ends. */
|
|
20
|
+
CONTROL_EOF: 6,
|
|
17
21
|
STDOUT: 16,
|
|
18
22
|
STDERR: 17,
|
|
19
23
|
EXIT: 18,
|
package/lib/run.mjs
CHANGED
|
@@ -30,7 +30,8 @@ import path from "node:path";
|
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { boxNameFor, canonicalWorkspace, ensureBox } from "./boxes.mjs";
|
|
32
32
|
import { Frame, createFrameDecoder, encodeFrame, encodeJsonFrame } from "./protocol.mjs";
|
|
33
|
-
import {
|
|
33
|
+
import { restoreSandboxieEnvironment } from "./sandbox-env.mjs";
|
|
34
|
+
import { runSandboxed, killProcess, sbieDir, sbieNotInstalledError, windowsDirectory } from "./sbie.mjs";
|
|
34
35
|
|
|
35
36
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
36
37
|
const relayScript = path.join(here, "inner.mjs");
|
|
@@ -186,6 +187,10 @@ export async function run(argv, io = {}) {
|
|
|
186
187
|
const stdout = io.stdout ?? process.stdout;
|
|
187
188
|
const stderr = io.stderr ?? process.stderr;
|
|
188
189
|
|
|
190
|
+
// The command keeps exactly the environment its caller chose (DSH trims it
|
|
191
|
+
// under programmatic tool calling); only Sandboxie's own bootstrap sees the
|
|
192
|
+
// system identity restored below.
|
|
193
|
+
const callerEnv = { ...process.env };
|
|
189
194
|
const parsed = parseRunnerArgv(argv);
|
|
190
195
|
const { options, command, workspace, mode } = parsed;
|
|
191
196
|
if (command.length === 0) throw new Error(`no command after "--"\n${usage()}`);
|
|
@@ -195,8 +200,10 @@ export async function run(argv, io = {}) {
|
|
|
195
200
|
|
|
196
201
|
if (sbieDir() === undefined) throw sbieNotInstalledError();
|
|
197
202
|
|
|
203
|
+
restoreSandboxieEnvironment(process.env, { windowsDirectory });
|
|
204
|
+
|
|
198
205
|
const ensured = ensureBox({ box, workspace, mode, logger: log });
|
|
199
|
-
const env = { ...
|
|
206
|
+
const env = { ...callerEnv, DSH_SANDBOX_ROOT: ensured.root, DSH_SBIE_BOX: ensured.box };
|
|
200
207
|
|
|
201
208
|
if (options.dryRun) {
|
|
202
209
|
stdout.write(
|
|
@@ -206,7 +213,11 @@ export async function run(argv, io = {}) {
|
|
|
206
213
|
}
|
|
207
214
|
|
|
208
215
|
const channel = await startRelay(ensured, options, log);
|
|
209
|
-
|
|
216
|
+
const control = openControlChannel(log);
|
|
217
|
+
return await relay(
|
|
218
|
+
{ server: channel.server, socket: channel.socket },
|
|
219
|
+
{ command, cwd: process.cwd(), env, stdin, stdout, stderr, box: ensured, control }
|
|
220
|
+
);
|
|
210
221
|
}
|
|
211
222
|
|
|
212
223
|
/**
|
|
@@ -259,15 +270,39 @@ function sleep(ms) {
|
|
|
259
270
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
260
271
|
}
|
|
261
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The harness hands the *launcher* the inherited control channel it would have
|
|
275
|
+
* handed the command: `DSH_SUBPROCESS_CONTROL=pipe` names an `overlapped`
|
|
276
|
+
* named pipe on fd 7 (DSH's `SUBPROCESS_CONTROL_FD`; the low fds belong to the
|
|
277
|
+
* Windows job runner). The boxed command is created by SbieSvc, which ignores
|
|
278
|
+
* inherited handles, so the bytes are pumped over the relay channel instead and
|
|
279
|
+
* the relay re-creates the same `overlapped` fd 7 for the child — the PTC
|
|
280
|
+
* runtime opens exactly that descriptor with `net.Socket`.
|
|
281
|
+
*
|
|
282
|
+
* Returns undefined when this launch has no control channel — the normal case
|
|
283
|
+
* for ordinary shell commands.
|
|
284
|
+
*/
|
|
285
|
+
function openControlChannel(log) {
|
|
286
|
+
if (process.env.DSH_SUBPROCESS_CONTROL !== "pipe") return undefined;
|
|
287
|
+
try {
|
|
288
|
+
const control = new net.Socket({ fd: 7, readable: true, writable: true });
|
|
289
|
+
return control;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
log(`control channel unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
262
296
|
/** Pump one command's stdio through the channel and settle on its exit code. */
|
|
263
297
|
function relay(channel, session) {
|
|
264
298
|
const { socket, server } = channel;
|
|
265
|
-
const { stdin, stdout, stderr } = session;
|
|
299
|
+
const { stdin, stdout, stderr, control } = session;
|
|
266
300
|
return new Promise((resolve, reject) => {
|
|
267
301
|
let settled = false;
|
|
268
302
|
const done = (code) => {
|
|
269
303
|
if (settled) return;
|
|
270
304
|
settled = true;
|
|
305
|
+
control?.destroy();
|
|
271
306
|
server.close();
|
|
272
307
|
socket.destroy();
|
|
273
308
|
resolve(code);
|
|
@@ -275,6 +310,7 @@ function relay(channel, session) {
|
|
|
275
310
|
const bail = (error) => {
|
|
276
311
|
if (settled) return;
|
|
277
312
|
settled = true;
|
|
313
|
+
control?.destroy();
|
|
278
314
|
server.close();
|
|
279
315
|
socket.destroy();
|
|
280
316
|
reject(error);
|
|
@@ -300,6 +336,9 @@ function relay(channel, session) {
|
|
|
300
336
|
stderr.write(`dsh-sbie-run: ${body.toString("utf8")}\n`);
|
|
301
337
|
done(127);
|
|
302
338
|
break;
|
|
339
|
+
case Frame.CONTROL:
|
|
340
|
+
if (control !== undefined && control.writable) control.write(body);
|
|
341
|
+
break;
|
|
303
342
|
default:
|
|
304
343
|
break;
|
|
305
344
|
}
|
|
@@ -310,7 +349,19 @@ function relay(channel, session) {
|
|
|
310
349
|
if (!settled) bail(new Error("the boxed relay closed the channel before reporting an exit code"));
|
|
311
350
|
});
|
|
312
351
|
|
|
313
|
-
|
|
352
|
+
if (control !== undefined) {
|
|
353
|
+
control.on("data", (chunk) => {
|
|
354
|
+
if (!socket.destroyed) socket.write(encodeFrame(Frame.CONTROL, chunk));
|
|
355
|
+
});
|
|
356
|
+
control.on("error", () => {
|
|
357
|
+
/* the harness may close the channel first; the command's exit still settles the call */
|
|
358
|
+
});
|
|
359
|
+
control.on("close", () => {
|
|
360
|
+
if (!socket.destroyed) socket.write(encodeFrame(Frame.CONTROL_EOF));
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
socket.write(encodeJsonFrame(Frame.SPEC, { argv: session.command, cwd: session.cwd, env: session.env, control: control !== undefined }));
|
|
314
365
|
|
|
315
366
|
const forward = (chunk) => {
|
|
316
367
|
if (socket.destroyed) return;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep Sandboxie able to resolve the user's profile folder when the launcher is
|
|
3
|
+
* started with a trimmed environment.
|
|
4
|
+
*
|
|
5
|
+
* Sandboxie derives the sandbox layout (`user\current`, `ProgramData`, the
|
|
6
|
+
* public profile, ...) from `HKLM\...\ProfileList`, whose `REG_EXPAND_SZ`
|
|
7
|
+
* values it expands with `ExpandEnvironmentStrings` — an API that only sees the
|
|
8
|
+
* *calling process's* environment. DSH's programmatic tool calling deliberately
|
|
9
|
+
* spawns its runtime with a credential-scrubbed environment that keeps only
|
|
10
|
+
* PATH, PATHEXT, SYSTEMROOT, WINDIR, TEMP and TMP. `%SystemDrive%\ProgramData`
|
|
11
|
+
* then cannot be expanded, Sandboxie logs
|
|
12
|
+
*
|
|
13
|
+
* SBIE2306 Could not locate user directory: [C000003B / 33]
|
|
14
|
+
* SBIE2304 Initialization of <program> process failed
|
|
15
|
+
*
|
|
16
|
+
* and the boxed relay dies during initialization, so the launcher only reports
|
|
17
|
+
* its connect timeout and every command in the session fails.
|
|
18
|
+
*
|
|
19
|
+
* `SystemDrive` is the one missing piece that is a *system* fact rather than a
|
|
20
|
+
* credential, so the launcher restores it for Sandboxie's bootstrap only. The
|
|
21
|
+
* command itself still receives exactly the environment its caller passed: the
|
|
22
|
+
* relay's spawn spec is built before this module touches `process.env`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** `"C:"` / `"c:"` / `"c:\\"` → `"C:"`; anything else → undefined. */
|
|
26
|
+
export function normalizeSystemDrive(value) {
|
|
27
|
+
if (typeof value !== "string") return undefined;
|
|
28
|
+
const match = /^\s*([A-Za-z]):\s*$/.exec(value);
|
|
29
|
+
return match === null ? undefined : `${match[1].toUpperCase()}:`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Derive the system drive from environment values, a Windows directory path, or
|
|
34
|
+
* a fallback path, in that order.
|
|
35
|
+
*
|
|
36
|
+
* @param env - the environment to read (defaults to the process environment).
|
|
37
|
+
* @param windowsDirectory - a `C:\Windows`-shaped path, or a function returning
|
|
38
|
+
* one (called only when the environment itself has no drive information).
|
|
39
|
+
*/
|
|
40
|
+
export function systemDriveFromEnv(env = process.env, windowsDirectory) {
|
|
41
|
+
const direct = normalizeSystemDrive(env.SystemDrive ?? env.SYSTEMDRIVE);
|
|
42
|
+
if (direct !== undefined) return direct;
|
|
43
|
+
|
|
44
|
+
const driveOf = (value) => {
|
|
45
|
+
if (typeof value !== "string") return undefined;
|
|
46
|
+
const match = /^\s*([A-Za-z]):[\\/]/.exec(value);
|
|
47
|
+
return match === null ? undefined : `${match[1].toUpperCase()}:`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
for (const key of ["SystemRoot", "SYSTEMROOT", "windir", "WINDIR"]) {
|
|
51
|
+
const drive = driveOf(env[key]);
|
|
52
|
+
if (drive !== undefined) return drive;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (windowsDirectory !== undefined) {
|
|
56
|
+
let resolved;
|
|
57
|
+
try {
|
|
58
|
+
resolved = typeof windowsDirectory === "function" ? windowsDirectory() : windowsDirectory;
|
|
59
|
+
} catch {
|
|
60
|
+
resolved = undefined;
|
|
61
|
+
}
|
|
62
|
+
const drive = driveOf(resolved);
|
|
63
|
+
if (drive !== undefined) return drive;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Last resort: the drive Node itself lives on (a system fact, not a caller
|
|
67
|
+
// credential) — only reached when even SystemRoot was stripped.
|
|
68
|
+
return driveOf(process.execPath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Restore the environment Sandboxie needs to locate the user profile, without
|
|
73
|
+
* touching the command's own spawn specification.
|
|
74
|
+
*
|
|
75
|
+
* @param env - the launcher's process environment (mutated in place; on Windows
|
|
76
|
+
* that updates the environment block `SbieSvc` reads for the boxed process).
|
|
77
|
+
* @param options.windowsDirectory - see {@link systemDriveFromEnv}.
|
|
78
|
+
* @returns the entries that were added, or undefined when nothing was missing.
|
|
79
|
+
*/
|
|
80
|
+
export function restoreSandboxieEnvironment(env, options = {}) {
|
|
81
|
+
if (env === undefined) return undefined;
|
|
82
|
+
if (normalizeSystemDrive(env.SystemDrive ?? env.SYSTEMDRIVE) !== undefined) return undefined;
|
|
83
|
+
|
|
84
|
+
const drive = systemDriveFromEnv(env, options.windowsDirectory);
|
|
85
|
+
if (drive === undefined) return undefined;
|
|
86
|
+
env.SystemDrive = drive;
|
|
87
|
+
return { SystemDrive: drive };
|
|
88
|
+
}
|
package/lib/sbie.mjs
CHANGED
|
@@ -146,6 +146,7 @@ export function sbieApi() {
|
|
|
146
146
|
queryBoxPath: dll.func("__stdcall", "SbieApi_QueryBoxPath", "int32", [
|
|
147
147
|
"void *", "void *", "void *", "void *", "void *", "void *", "void *"
|
|
148
148
|
]),
|
|
149
|
+
getWindowsDirectory: kernel32.func("GetWindowsDirectoryW", "uint32", ["void *", "uint32"]),
|
|
149
150
|
waitForSingleObject: kernel32.func("WaitForSingleObject", "uint32", ["void *", "uint32"]),
|
|
150
151
|
getExitCodeProcess: kernel32.func("GetExitCodeProcess", "bool", ["void *", koffi.out(koffi.pointer("uint32"))]),
|
|
151
152
|
closeHandle: kernel32.func("CloseHandle", "bool", ["void *"]),
|
|
@@ -171,6 +172,27 @@ export function startErrorText() {
|
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The Windows directory (`C:\Windows`) from the Win32 API.
|
|
177
|
+
*
|
|
178
|
+
* Used only when the launcher was started without any environment that names
|
|
179
|
+
* the system drive: Sandboxie expands profile paths like `%SystemDrive%\ProgramData`
|
|
180
|
+
* with the *caller's* environment, so a scrubbed caller would otherwise fail
|
|
181
|
+
* with `SBIE2306 ... [C000003B / 33]` before a boxed process can start.
|
|
182
|
+
*/
|
|
183
|
+
export function windowsDirectory() {
|
|
184
|
+
try {
|
|
185
|
+
const a = sbieApi();
|
|
186
|
+
const buffer = Buffer.alloc(520 * 2);
|
|
187
|
+
const length = a.getWindowsDirectory(buffer, 520);
|
|
188
|
+
if (typeof length !== "number" || length <= 0) return undefined;
|
|
189
|
+
const text = buffer.toString("utf16le", 0, length * 2);
|
|
190
|
+
return text.length > 0 ? text : undefined;
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
174
196
|
/**
|
|
175
197
|
* Start `commandLine` inside `box` through the Sandboxie service.
|
|
176
198
|
*
|
package/lib/tool.mjs
CHANGED
|
@@ -208,10 +208,18 @@ export async function apply(ctx) {
|
|
|
208
208
|
(copy) =>
|
|
209
209
|
`- ${copy.box}: ${copy.files} file(s), ${humanBytes(copy.bytes)}${copy.truncated ? " (counting stopped early)" : ""} at ${copy.root}`
|
|
210
210
|
);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
211
|
+
// Every action gets its OWN head line. `status` used to fall through
|
|
212
|
+
// to the inspect wording, so a status query answered "N found." —
|
|
213
|
+
// which reads as "this workspace has no copy" when the call says
|
|
214
|
+
// nothing about copies at all; the informative part is the note.
|
|
215
|
+
let head;
|
|
216
|
+
if (value.action === "clear") {
|
|
217
|
+
head = `Cleared ${value.cleared.length} sandbox copy/copies for ${value.workspace}.`;
|
|
218
|
+
} else if (value.action === "status") {
|
|
219
|
+
head = `Session projection state for ${value.workspace}.`;
|
|
220
|
+
} else {
|
|
221
|
+
head = `Sandbox copies for ${value.workspace}: ${value.copies.length} found.`;
|
|
222
|
+
}
|
|
215
223
|
return [{ type: "text", text: [head, ...lines, value.note].join("\n") }];
|
|
216
224
|
}
|
|
217
225
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lone-wolf-akela/dsh-sandboxie-redirect",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "DSH bundle adding a copy-on-write permission preset: shell commands run inside a per-workspace Sandboxie box, so writes outside the workspace land in the box's copy while the real disk is untouched.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|