@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 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
- reloadConfig();
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: ["pipe", "pipe", "pipe"],
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 { runSandboxed, killProcess, sbieDir, sbieNotInstalledError } from "./sbie.mjs";
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 = { ...process.env, DSH_SANDBOX_ROOT: ensured.root, DSH_SBIE_BOX: ensured.box };
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
- return await relay({ server: channel.server, socket: channel.socket }, { command, cwd: process.cwd(), env, stdin, stdout, stderr, box: ensured });
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
- socket.write(encodeJsonFrame(Frame.SPEC, { argv: session.command, cwd: session.cwd, env: session.env }));
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
- const head =
212
- value.action === "clear"
213
- ? `Cleared ${value.cleared.length} sandbox copy/copies for ${value.workspace}.`
214
- : `Sandbox copies for ${value.workspace}: ${value.copies.length} found.`;
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",
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",