@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/CHANGELOG.md +74 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/README.zh.md +150 -0
- package/bin/dsh-sbie-run.mjs +23 -0
- package/cordis.patch.yml +79 -0
- package/lib/boxes.mjs +220 -0
- package/lib/cli.mjs +164 -0
- package/lib/client.js +299 -0
- package/lib/core.mjs +59 -0
- package/lib/host.mjs +356 -0
- package/lib/inner.mjs +116 -0
- package/lib/koffi.mjs +95 -0
- package/lib/log.mjs +62 -0
- package/lib/naming.mjs +159 -0
- package/lib/note.mjs +43 -0
- package/lib/protocol.mjs +63 -0
- package/lib/provider.mjs +210 -0
- package/lib/redirect.mjs +167 -0
- package/lib/run.mjs +343 -0
- package/lib/sbie.mjs +268 -0
- package/lib/tool-schema.mjs +109 -0
- package/lib/tool.mjs +323 -0
- package/lib/words.mjs +46 -0
- package/package.json +94 -0
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
|
+
}
|