@lone-wolf-akela/dsh-sandboxie-redirect 0.3.0

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/lib/core.mjs ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Reach the DSH core packages from a plugin that lives outside the app bundle.
3
+ *
4
+ * Plugins under `$DSH_HOME/plugins` get no module resolution into the harness:
5
+ * bare specifiers fail (measured). The harness's own modules live inside
6
+ * `app.asar`, which plain Node cannot read — but the Electron host can, because
7
+ * Electron patches `fs`. So: resolve from inside the asar with a `createRequire`
8
+ * rooted at the harness node_modules, then import the resulting file URL.
9
+ *
10
+ * Measured working inside the host (Electron 44 / Node 24.18): `resolve`,
11
+ * `import`, and even `readdirSync` inside `app.asar`.
12
+ */
13
+ import { createRequire } from "node:module";
14
+ import { pathToFileURL } from "node:url";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ function resourcesRoot() {
19
+ if (typeof process.resourcesPath === "string" && process.resourcesPath.length > 0) return process.resourcesPath;
20
+ return path.join(path.dirname(process.execPath), "resources");
21
+ }
22
+
23
+ /** Candidate module roots, most specific first. */
24
+ export function coreModuleRoots(resources = resourcesRoot()) {
25
+ return [
26
+ path.join(resources, "app.asar", "dsh", "node_modules"),
27
+ path.join(resources, "app.asar", "node_modules"),
28
+ path.join(resources, "app.asar.unpacked", "dsh", "node_modules")
29
+ ].filter((dir) => {
30
+ try {
31
+ return fs.statSync(dir).isDirectory();
32
+ } catch {
33
+ return false;
34
+ }
35
+ });
36
+ }
37
+
38
+ const cache = new Map();
39
+
40
+ /**
41
+ * Import one harness package by name from inside the app bundle.
42
+ * @param specifier - a bare package name (or subpath export) the harness ships.
43
+ */
44
+ export async function importCore(specifier) {
45
+ const cached = cache.get(specifier);
46
+ if (cached !== undefined) return cached;
47
+ const failures = [];
48
+ for (const root of coreModuleRoots()) {
49
+ try {
50
+ const resolved = createRequire(path.join(root, "noop.js")).resolve(specifier);
51
+ const module = await import(pathToFileURL(resolved).href);
52
+ cache.set(specifier, module);
53
+ return module;
54
+ } catch (error) {
55
+ failures.push(`${root}: ${error?.code ?? ""} ${error?.message ?? error}`);
56
+ }
57
+ }
58
+ throw new Error(`cannot resolve harness package ${specifier} from ${resourcesRoot()} (${failures.join(" | ")})`);
59
+ }
package/lib/host.mjs ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Host half of the "which sandbox is this conversation bound to?" surface.
3
+ *
4
+ * The browser half cannot derive the box name: it has no session→workspace
5
+ * path in any of its snapshots. So this half publishes one tiny session
6
+ * projection whose client view carries the name and root, and the browser
7
+ * reads it with the standard `useProjection` hook — no bespoke RPC.
8
+ *
9
+ * WHAT IT REPORTS — and why it is not simply the workspace's name:
10
+ *
11
+ * The name is only knowable once the box EXISTS. Collisions are resolved by
12
+ * walking `boxNameCandidates()` against Sandboxie's own state, so which of the
13
+ * candidates a workspace ends up owning cannot be decided from the path alone;
14
+ * and before the first copy-on-write command there is no box at all. Reporting
15
+ * a guess would put a name in the header that may never be used — worse than
16
+ * showing nothing, because the whole point of the chip is to say whether a
17
+ * sandbox is in play and which one it is.
18
+ *
19
+ * So the projection reports nothing until this workspace actually owns a box,
20
+ * and it re-checks on the session's own event drive: every committed event
21
+ * (the tool call and its result included) folds through `apply`, and returning
22
+ * a NEW state reference is what makes the harness recompute the client view.
23
+ * That is how the chip appears right after the first copy-on-write command —
24
+ * with no polling, and without writing anything into the log.
25
+ *
26
+ * THE ONE HARD RULE FOR THIS FILE: never call `session.append()`.
27
+ *
28
+ * An earlier version appended a per-session `sandbox/box` event. That is a hard
29
+ * error, not a style choice: the persistence read path refuses a stored event
30
+ * whose type this build does not know unless the envelope carries
31
+ * `ignorable: true`, and live `Session.append()` cannot set that marker — so
32
+ * every session this plugin touched failed to reload ("contains event type ...
33
+ * unknown to this harness and not marked ignorable"). The harness's own plugin
34
+ * practices say the same: "Do not append session events with a new `type`. …
35
+ * Derive state from existing events, or keep plugin-owned data in a storage
36
+ * service." Logs written by that version were repaired in place (see
37
+ * `tools/stamp-ignorable.mjs`) and still fold through the legacy branch below.
38
+ *
39
+ * Everything here is best-effort: a failure costs the header chip, never the
40
+ * sandbox itself, so nothing is allowed to throw.
41
+ */
42
+ import fs from "node:fs";
43
+ import path from "node:path";
44
+ import { boxNameCandidates, boxNameFor, canonicalWorkspace, resolveBoxRoot } from "./naming.mjs";
45
+ import { importCore } from "./core.mjs";
46
+ import { logFile } from "./log.mjs";
47
+
48
+ export const name = "dsh-sandboxie-redirect-host";
49
+ export const inject = ["sessionProjections"];
50
+
51
+ /** Projection key the browser half reads. */
52
+ export const BOX_PROJECTION = "sandboxBox";
53
+ /**
54
+ * The retired event type. Never appended — recognized only so logs written by
55
+ * the version that did append it keep folding to the box they recorded.
56
+ */
57
+ export const LEGACY_BOX_EVENT = "sandbox/box";
58
+
59
+ /** How many name candidates to consider, matching the launcher's resolution. */
60
+ const CANDIDATE_LIMIT = 12;
61
+ /** Re-reading the configuration file on every event would be rude; this is a hint, not an audit. */
62
+ const INI_MEMO_MS = 1500;
63
+
64
+ function report(line) {
65
+ try {
66
+ process.stderr.write(`dsh-sandboxie-redirect: ${line}\n`);
67
+ } catch {}
68
+ }
69
+
70
+ /**
71
+ * Diagnostics go to a file as well as to stderr, because the host's stderr is
72
+ * not readable anywhere later — and "why did the chip stop updating?" is
73
+ * precisely the question that cannot be answered without them. They live in the
74
+ * plugin's state directory, not inside the installed package.
75
+ */
76
+ const HOST_LOG = logFile("host.log");
77
+ /** Last view value written to the log, so only real changes are recorded. */
78
+ let lastLoggedView;
79
+ /** Whether this process already applied the size cap. */
80
+ let logOpened = false;
81
+ /** Keep the diagnostic file bounded: a session replay is thousands of lines. */
82
+ const HOST_LOG_MAX_BYTES = 512 * 1024;
83
+ function log(line) {
84
+ try {
85
+ if (logOpened === false) {
86
+ logOpened = true;
87
+ try { if (fs.statSync(HOST_LOG).size > HOST_LOG_MAX_BYTES) fs.rmSync(HOST_LOG, { force: true }); } catch {}
88
+ }
89
+ fs.appendFileSync(HOST_LOG, `${new Date().toISOString()} ${line}\n`);
90
+ } catch {}
91
+ report(line);
92
+ }
93
+
94
+ /** The Sandboxie configuration file whose box sections are the authority on "this box exists". */
95
+ export function sandboxIniPath() {
96
+ const override = process.env.DSH_SBIE_INI;
97
+ if (typeof override === "string" && override.length > 0) return override;
98
+ return path.join(process.env.SystemDrive ?? "C:", "Windows", "Sandboxie.ini");
99
+ }
100
+
101
+ /**
102
+ * Parse a Sandboxie configuration file into `lowercased section name -> body`.
103
+ * The file is UTF-16LE; a plain `utf8` read yields interleaved NULs and every
104
+ * section header would be missed.
105
+ * @param text - the decoded file contents.
106
+ */
107
+ export function parseIniSections(text) {
108
+ const sections = new Map();
109
+ let current;
110
+ for (const raw of String(text).split(/\r?\n/)) {
111
+ const line = raw.trim();
112
+ if (line.startsWith("[") && line.endsWith("]") && line.length > 2) {
113
+ current = line.slice(1, -1).trim().toLowerCase();
114
+ if (!sections.has(current)) sections.set(current, []);
115
+ continue;
116
+ }
117
+ if (current !== undefined && line.length > 0) sections.get(current).push(line);
118
+ }
119
+ return sections;
120
+ }
121
+
122
+ /** Whether one box section grants this workspace read/write access. */
123
+ export function sectionOwnsWorkspace(body, cwd) {
124
+ const needle = `${canonicalWorkspace(cwd)}\\*`.toLowerCase();
125
+ return body.some((line) => {
126
+ const lowered = line.toLowerCase();
127
+ return (lowered.startsWith("openfilepath=") || lowered.startsWith("readfilepath=")) && lowered.includes(needle);
128
+ });
129
+ }
130
+
131
+ /**
132
+ * The `FileRootPath` a box resolves to: the box's own setting, else the global
133
+ * one. `undefined` when Sandboxie declares neither (the common case), which
134
+ * leaves the caller on the default layout.
135
+ *
136
+ * `FileRootPath=` with an empty value counts as "not declared" — Sandboxie
137
+ * writes such lines itself, and treating one as a declaration would mask the
138
+ * `[GlobalSettings]` value that the machine is actually using.
139
+ * @param box - the box name.
140
+ * @param sections - parsed configuration sections.
141
+ */
142
+ export function fileRootPathFor(box, sections) {
143
+ const read = (name) => {
144
+ const body = sections?.get?.(String(name).toLowerCase());
145
+ if (body === undefined) return undefined;
146
+ for (const line of body) {
147
+ const index = line.indexOf("=");
148
+ if (index === -1) continue;
149
+ if (line.slice(0, index).trim().toLowerCase() !== "filerootpath") continue;
150
+ const value = line.slice(index + 1).trim();
151
+ if (value.length > 0) return value;
152
+ }
153
+ return undefined;
154
+ };
155
+ return read(box) ?? read("GlobalSettings");
156
+ }
157
+
158
+ /**
159
+ * Where Sandboxie will actually keep this box's copy: its configured
160
+ * `FileRootPath` when there is one, else the default layout (or the override).
161
+ */
162
+ export function rootForBox(box, sections = readIniSections()) {
163
+ return resolveBoxRoot(box, { template: fileRootPathFor(box, sections) });
164
+ }
165
+
166
+ /**
167
+ * The box this workspace actually owns, or null when it has none yet.
168
+ * Mirrors the launcher's resolution: walk the same candidates in the same
169
+ * order and accept the first that exists AND is ours, so another workspace's
170
+ * box is never reported as this one's.
171
+ * @param cwd - the session workspace.
172
+ * @param sections - parsed configuration sections.
173
+ */
174
+ export function ownedBoxFor(cwd, sections) {
175
+ if (typeof cwd !== "string" || cwd.length === 0) return null;
176
+ for (const candidate of boxNameCandidates(cwd, "workspace-write", CANDIDATE_LIMIT)) {
177
+ const body = sections.get(candidate.toLowerCase());
178
+ if (body === undefined) continue;
179
+ if (!sectionOwnsWorkspace(body, cwd)) continue;
180
+ return { box: candidate, root: rootForBox(candidate, sections) };
181
+ }
182
+ return null;
183
+ }
184
+
185
+ let iniMemo;
186
+
187
+ /**
188
+ * Drop the memo so the next read hits the file.
189
+ *
190
+ * Called by the shell provider the moment it decides a call will be redirected:
191
+ * the box may be about to be created, so a "no box yet" answer cached a moment
192
+ * earlier must not be the one the next committed event sees — otherwise the
193
+ * header chip would wait for the memo to expire. Also the test hook.
194
+ */
195
+ export function invalidateBoxCache() {
196
+ iniMemo = undefined;
197
+ }
198
+
199
+ /** Read the configuration sections, memoized briefly. Null when unreadable. */
200
+ export function readIniSections(now = Date.now()) {
201
+ if (iniMemo !== undefined && now - iniMemo.at < INI_MEMO_MS) return iniMemo.sections;
202
+ let sections = null;
203
+ try {
204
+ sections = parseIniSections(fs.readFileSync(sandboxIniPath(), "utf16le"));
205
+ } catch {
206
+ sections = null;
207
+ }
208
+ iniMemo = { at: now, sections };
209
+ return sections;
210
+ }
211
+
212
+ /** The workspace's owned box as of now, or null. */
213
+ export function currentBoxFor(cwd) {
214
+ const sections = readIniSections();
215
+ if (sections === null) return null;
216
+ return ownedBoxFor(cwd, sections);
217
+ }
218
+
219
+ /** The name this workspace WOULD use; exported for the deterministic-name tests. */
220
+ export function boxForHeader(header) {
221
+ const cwd = header?.cwd;
222
+ if (typeof cwd !== "string" || cwd.length === 0) return null;
223
+ try {
224
+ const box = boxNameFor(cwd, "workspace-write");
225
+ return { box, root: rootForBox(box) };
226
+ } catch (error) {
227
+ report(`could not derive the box for ${cwd}: ${error?.message ?? error}`);
228
+ return null;
229
+ }
230
+ }
231
+
232
+ // Re-exported for the resolution tests; keeps a single import from ./naming.mjs
233
+ // so the bundle-free test loader only has one specifier to rewrite.
234
+ export { boxNameCandidates };
235
+
236
+ /** A stored legacy event's payload, when it is the shape this plugin wrote. */
237
+ export function boxFromLegacyEvent(event) {
238
+ const data = event?.data;
239
+ if (typeof data?.box !== "string" || data.box.length === 0) return undefined;
240
+ return { box: data.box, root: typeof data.root === "string" && data.root.length > 0 ? data.root : rootForBox(data.box) };
241
+ }
242
+
243
+ /** Seed one session's projection state from its header, resolving any box that already exists. */
244
+ export function seedState(header) {
245
+ const cwd = header?.cwd;
246
+ if (typeof cwd !== "string" || cwd.length === 0) return null;
247
+ const existing = currentBoxFor(cwd);
248
+ return { cwd, box: existing?.box ?? null, root: existing?.root ?? null };
249
+ }
250
+
251
+ export async function apply(ctx) {
252
+ // The schemas are validation for the wire. They must never be ABSENT: the
253
+ // projections service calls `wire.viewSchema.parse(...)` unconditionally when
254
+ // it publishes (and when it snapshots), so registering a wire with an
255
+ // undefined `viewSchema` throws on every publish — the chip then freezes at
256
+ // whatever `init` last built, which is exactly the "the name disappeared and
257
+ // never came back" failure. `importCore("zod")` is optional, so fall back to a
258
+ // pass-through schema rather than to nothing; either way the shape is small
259
+ // enough that validation adds little.
260
+ let z;
261
+ let schemaSource = "zod";
262
+ try {
263
+ ({ z } = await importCore("zod"));
264
+ } catch (error) {
265
+ z = undefined;
266
+ schemaSource = "pass-through";
267
+ log(`schemas unavailable, using a pass-through view schema: ${error?.message ?? error}`);
268
+ }
269
+ /** Minimal stand-in for a schema: keeps `.parse` callable when zod is missing. */
270
+ const PASS_THROUGH = { parse: (value) => value };
271
+ const stateSchema = z?.object({ cwd: z.string(), box: z.string().nullable(), root: z.string().nullable() }).nullable() ?? PASS_THROUGH;
272
+ const viewSchema = z?.object({ box: z.string().nullable(), root: z.string().nullable() }) ?? PASS_THROUGH;
273
+ // One source of truth for the version. A hardcoded copy inside a log line once
274
+ // sent me chasing a phantom "old build is still loaded" for two restarts.
275
+ const STATE_VERSION = 5;
276
+ log(`attaching projection registration (stateVersion ${STATE_VERSION}, schemas=${schemaSource})`);
277
+
278
+ // Register through `ctx.inject`, exactly as note.mjs does for the services it
279
+ // needs, so the registration lands on the LIVE registry once that service
280
+ // exists. This half is wired from the sandbox provider's CONSTRUCTOR, where
281
+ // `sessionProjections` may not resolve yet; a registration that lands anywhere
282
+ // else is never driven — no cell is created, `apply` is never called, no view
283
+ // is ever produced, and the chip silently never renders. That is exactly the
284
+ // observed signature: host.log had no `apply` and no `view` lines at all,
285
+ // while the box existed and the shipped units kept updating.
286
+ ctx.inject(["sessionProjections"], (scope) => {
287
+ try {
288
+ scope.sessionProjections.register({
289
+ key: BOX_PROJECTION,
290
+ // v5 forces every session's cell to be rebuilt from `init`. A v4 cell can
291
+ // FREEZE: the registry advances a cell by folding committed events into
292
+ // `apply`, and a cell that is ever built (or restored from the cache) while
293
+ // the box is absent stays at `{box:null}` — the symptom being a chip that
294
+ // vanished and never returned even though `init` reports the box. A version
295
+ // bump is the documented way to discard such cells; it is paired with
296
+ // per-call logging in `apply` so the next occurrence is recorded, not
297
+ // guessed at.
298
+ // v1 stored an event-derived value
299
+ // v2 froze the box at the session's first event
300
+ // v3 carried the workspace so the box could be re-checked as it appears
301
+ // v4 tolerates the retired event without trusting it
302
+ // v5 rebuilds cells so a stale one cannot outlive a fix
303
+ stateVersion: STATE_VERSION,
304
+ stateSchema,
305
+ init: (header) => seedState(header),
306
+ apply: (state, event) => {
307
+ // The retired event is tolerated, never trusted. It is history: the name
308
+ // it recorded was that workspace's box AT THE TIME, so reporting it would
309
+ // make the chip claim a sandbox that may no longer exist — exactly what
310
+ // the chip must never do. Only the live check below decides, so a
311
+ // repaired log behaves identically to a clean one.
312
+ if (event.type === LEGACY_BOX_EVENT) return state;
313
+ const cwd = state?.cwd;
314
+ if (typeof cwd !== "string" || cwd.length === 0) return state;
315
+ // Re-verify in BOTH directions on every committed event. The box appears
316
+ // on the first redirected command, and it disappears when it is deleted
317
+ // (SandMan, or `--manage clean`) — exactly what a user who just removed
318
+ // the sandbox expects the chip to reflect. Latching the first non-null
319
+ // answer would leave the chip claiming a deleted box forever.
320
+ const sections = readIniSections();
321
+ // An unreadable configuration means "cannot tell", never "there is no
322
+ // box": clearing on a transient read failure would drop the chip for a
323
+ // box that still exists.
324
+ if (sections === null) return state;
325
+ const live = ownedBoxFor(cwd, sections);
326
+ const bound = state?.box ?? null;
327
+ // TRANSITIONS only. Logging every call is what finally located the
328
+ // frozen-chip bug — a registration that was never driven looked exactly
329
+ // like a fold that ran and found nothing — but one session replay is
330
+ // thousands of calls, so the evidence is kept where it is decisive.
331
+ if ((live === null ? null : live.box) === bound) return state;
332
+ log(`transition seq=${event.seq} type=${event.type} ${bound ?? "none"} -> ${live?.box ?? "none"}`);
333
+ if (live === null) return { cwd, box: null, root: null };
334
+ return { cwd, box: live.box, root: live.root };
335
+ },
336
+ wire: {
337
+ viewSchema,
338
+ // Logged only when the value actually changes: this runs for every
339
+ // publish AND every snapshot, and it is the last place the host can
340
+ // observe what the client is handed.
341
+ view: (state) => {
342
+ const view = { box: state?.box ?? null, root: state?.root ?? null };
343
+ if (view.box !== lastLoggedView) {
344
+ lastLoggedView = view.box;
345
+ log(`view box=${view.box ?? "none"}`);
346
+ }
347
+ return view;
348
+ }
349
+ }
350
+ });
351
+ log("projection registered");
352
+ } catch (error) {
353
+ log(`projection registration failed: ${error?.message ?? error}`);
354
+ }
355
+ });
356
+ }
package/lib/inner.mjs ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The boxed half of the runner.
3
+ *
4
+ * Started by `SbieDll_RunSandboxed`, so this process already lives inside the
5
+ * Sandboxie box, but it starts with SbieSvc's handles rather than the
6
+ * launcher's. It therefore:
7
+ * 1. dials back to the launcher over loopback TCP (the one channel measured
8
+ * to cross the box boundary — named pipes do not),
9
+ * 2. receives the exact argv/cwd/env,
10
+ * 3. spawns the real command with ordinary pipes. Because this process is
11
+ * boxed, the child inherits the box: that is Sandboxie's core rule.
12
+ *
13
+ * If the launcher dies, the socket closes and the child is killed with it —
14
+ * the stand-in for bwrap's `--die-with-parent`.
15
+ */
16
+ import { spawn } from "node:child_process";
17
+ import net from "node:net";
18
+ import { Frame, createFrameDecoder, encodeFrame, encodeJsonFrame } from "./protocol.mjs";
19
+
20
+ function parseArgs(argv) {
21
+ const out = { host: "127.0.0.1", port: 0, token: "" };
22
+ for (let i = 0; i < argv.length; i += 1) {
23
+ if (argv[i] === "--port") out.port = Number(argv[++i]);
24
+ else if (argv[i] === "--token") out.token = argv[++i];
25
+ else if (argv[i] === "--host") out.host = argv[++i];
26
+ }
27
+ return out;
28
+ }
29
+
30
+ const options = parseArgs(process.argv.slice(2));
31
+ if (!Number.isInteger(options.port) || options.port <= 0) {
32
+ process.stderr.write("dsh-sbie-relay: missing --port\n");
33
+ process.exit(2);
34
+ }
35
+
36
+ let child;
37
+ let finished = false;
38
+
39
+ const socket = net.connect({ host: options.host, port: options.port });
40
+ socket.setNoDelay(true);
41
+
42
+ function send(type, payload) {
43
+ if (socket.destroyed) return;
44
+ socket.write(encodeFrame(type, payload));
45
+ }
46
+
47
+ function finish(code) {
48
+ if (finished) return;
49
+ finished = true;
50
+ process.exitCode = code;
51
+ setTimeout(() => process.exit(code), 10).unref?.();
52
+ }
53
+
54
+ function startChild(spec) {
55
+ let childProcess;
56
+ try {
57
+ childProcess = spawn(spec.argv[0], spec.argv.slice(1), {
58
+ cwd: spec.cwd,
59
+ env: spec.env,
60
+ stdio: ["pipe", "pipe", "pipe"],
61
+ windowsHide: true
62
+ });
63
+ } catch (error) {
64
+ send(Frame.FAIL, `cannot spawn ${spec.argv[0]}: ${error.message}`);
65
+ socket.end();
66
+ finish(127);
67
+ return;
68
+ }
69
+ child = childProcess;
70
+ child.on("error", (error) => {
71
+ send(Frame.FAIL, `cannot spawn ${spec.argv[0]}: ${error.message}`);
72
+ socket.end();
73
+ finish(127);
74
+ });
75
+ child.stdout.on("data", (chunk) => send(Frame.STDOUT, chunk));
76
+ child.stderr.on("data", (chunk) => send(Frame.STDERR, chunk));
77
+ child.on("exit", (code, signal) => {
78
+ send(Frame.EXIT, JSON.stringify({ code: code === null ? null : code, signal: signal ?? null }));
79
+ socket.end();
80
+ finish(code === null ? 1 : code);
81
+ });
82
+ }
83
+
84
+ socket.on("connect", () => {
85
+ socket.write(encodeJsonFrame(Frame.HELLO, { token: options.token }));
86
+ });
87
+
88
+ const decode = createFrameDecoder((type, body) => {
89
+ switch (type) {
90
+ case Frame.SPEC:
91
+ startChild(JSON.parse(body.toString("utf8")));
92
+ break;
93
+ case Frame.STDIN:
94
+ if (child !== undefined && child.stdin.writable) child.stdin.write(body);
95
+ break;
96
+ case Frame.STDIN_EOF:
97
+ if (child !== undefined && child.stdin.writable) child.stdin.end();
98
+ break;
99
+ default:
100
+ break;
101
+ }
102
+ });
103
+
104
+ socket.on("data", decode);
105
+ socket.on("error", (error) => {
106
+ process.stderr.write(`dsh-sbie-relay: channel error: ${error.message}\n`);
107
+ child?.kill();
108
+ finish(127);
109
+ });
110
+ socket.on("close", () => {
111
+ if (!finished) {
112
+ // The launcher went away (or finished): do not leave the command running.
113
+ child?.kill();
114
+ finish(child === undefined ? 127 : 0);
115
+ }
116
+ });
package/lib/koffi.mjs ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Locate a usable koffi FFI binding without owning a dependency tree.
3
+ *
4
+ * DSH ships koffi as a prebuilt Node-API addon under `app.asar.unpacked`, but
5
+ * the harness install path differs per machine and per version, and the plain
6
+ * `koffi` JS wrapper package is not always present. The addon itself is
7
+ * self-contained, so we require the `.node` file directly.
8
+ */
9
+ import { createRequire } from "node:module";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const here = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ let cached;
19
+
20
+ function candidateRoots() {
21
+ const roots = [];
22
+ const push = (p) => {
23
+ if (typeof p === "string" && p.length > 0 && !roots.includes(p)) roots.push(p);
24
+ };
25
+ push(process.env.DSH_SBIE_KOFFI_ROOT);
26
+ push(path.join(here, "..", "vendor"));
27
+ push(process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Programs", "DeepSeek Harness", "resources", "app.asar.unpacked"));
28
+ push(process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Programs", "DeepSeek Harness", "resources"));
29
+ push(process.env.PROGRAMFILES && path.join(process.env.PROGRAMFILES, "DeepSeek Harness", "resources", "app.asar.unpacked"));
30
+ push(process.env["PROGRAMFILES(X86)"] && path.join(process.env["PROGRAMFILES(X86)"], "DeepSeek Harness", "resources", "app.asar.unpacked"));
31
+ push(process.env.DSH_HOME && path.join(process.env.DSH_HOME, "dsh-runtimes"));
32
+ push(path.join(os.homedir(), ".dsh", "dsh-runtimes"));
33
+ return roots.filter((r) => {
34
+ try {
35
+ return fs.statSync(r).isDirectory();
36
+ } catch {
37
+ return false;
38
+ }
39
+ });
40
+ }
41
+
42
+ /** Depth-bounded search for a `koffi.node` under `root`. */
43
+ function findKoffiNode(root, maxDepth = 9) {
44
+ const queue = [{ dir: root, depth: 0 }];
45
+ let best;
46
+ while (queue.length > 0) {
47
+ const { dir, depth } = queue.shift();
48
+ if (depth > maxDepth) continue;
49
+ let entries;
50
+ try {
51
+ entries = fs.readdirSync(dir, { withFileTypes: true });
52
+ } catch {
53
+ continue;
54
+ }
55
+ for (const entry of entries) {
56
+ const full = path.join(dir, entry.name);
57
+ if (entry.isFile()) {
58
+ if (entry.name === "koffi.node") {
59
+ // Prefer a win32-x64 build over any other architecture artifact.
60
+ if (best === undefined || /win32[_-]?x64|x64/i.test(full)) best = full;
61
+ }
62
+ } else if (entry.isDirectory()) {
63
+ if (entry.name === "node_modules" || entry.name.startsWith("@") || /koffi|win32|x64|dependencies|resources|dsh/i.test(entry.name) || depth === 0) {
64
+ queue.push({ dir: full, depth: depth + 1 });
65
+ }
66
+ }
67
+ }
68
+ }
69
+ return best;
70
+ }
71
+
72
+ /** Resolve and load koffi once. Throws with an actionable message when absent. */
73
+ export function loadKoffi() {
74
+ if (cached !== undefined) return cached;
75
+ if (process.env.DSH_SBIE_KOFFI) {
76
+ cached = require(process.env.DSH_SBIE_KOFFI);
77
+ return cached;
78
+ }
79
+ try {
80
+ cached = require("koffi");
81
+ return cached;
82
+ } catch {}
83
+ const tried = [];
84
+ for (const root of candidateRoots()) {
85
+ tried.push(root);
86
+ const found = findKoffiNode(root);
87
+ if (found !== undefined) {
88
+ cached = require(found);
89
+ return cached;
90
+ }
91
+ }
92
+ throw new Error(
93
+ `koffi native addon not found. Set DSH_SBIE_KOFFI to a koffi.node path. Searched: ${tried.join(", ") || "(no candidate roots)"}`
94
+ );
95
+ }
package/lib/log.mjs ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Where the plugin's diagnostics live.
3
+ *
4
+ * NOT in the package directory. Once installed as a bundle the package sits in
5
+ * the profile's `node_modules` — and pnpm may serve it straight out of its
6
+ * content-addressed store — so appending logs there pollutes the install tree,
7
+ * trips store integrity checks and can fail outright when the tree is
8
+ * read-only. Diagnostics therefore go to a per-user state directory, with the
9
+ * OS temp directory as the last resort.
10
+ *
11
+ * Node builtins only: this module is imported at the top level of halves that
12
+ * must load in the Electron host (see the module-level import rule in
13
+ * AGENTS.md).
14
+ */
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+
19
+ /** State root: `$DSH_HOME/state`, which is `~/.dsh/state` by default. */
20
+ function stateRoot() {
21
+ const home = process.env.DSH_HOME;
22
+ if (typeof home === "string" && home.length > 0) return path.join(home, "state");
23
+ return path.join(os.homedir(), ".dsh", "state");
24
+ }
25
+
26
+ /**
27
+ * Candidate directories, in order: an explicit override (used by the tests so
28
+ * they never touch a real user's state), the state root, then temp.
29
+ */
30
+ function candidates() {
31
+ const override = process.env.DSH_SBIE_LOG_DIR;
32
+ const list = [];
33
+ if (typeof override === "string" && override.length > 0) list.push(override);
34
+ list.push(path.join(stateRoot(), "dsh-sandboxie-redirect"));
35
+ list.push(path.join(os.tmpdir(), "dsh-sandboxie-redirect"));
36
+ return list;
37
+ }
38
+
39
+ /**
40
+ * The absolute path of one diagnostic file, creating its directory on the way.
41
+ *
42
+ * Never throws: a plugin that cannot write a log still has to run. When every
43
+ * candidate directory is unusable the last path is returned anyway, so the
44
+ * caller's own try/catch around the write is what finally swallows the error.
45
+ */
46
+ export function logFile(name) {
47
+ const tried = candidates();
48
+ for (const dir of tried) {
49
+ try {
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ if (fs.statSync(dir).isDirectory()) return path.join(dir, name);
52
+ } catch {
53
+ /* try the next candidate */
54
+ }
55
+ }
56
+ return path.join(tried[tried.length - 1], name);
57
+ }
58
+
59
+ /** The directory the diagnostics currently land in, for docs and diagnostics. */
60
+ export function logDirectory() {
61
+ return path.dirname(logFile("host.log"));
62
+ }