@lone-wolf-akela/dsh-sandboxie-redirect 0.3.1 → 0.3.2

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,32 @@
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.2 — 2026-10-13
7
+
8
+ **DeepSeek Harness installation lookup**
9
+
10
+ - The launcher no longer assumes DSH is installed in
11
+ `%LOCALAPPDATA%\Programs\DeepSeek Harness`. `koffi.node` ships *inside* the
12
+ installation, so a relocated DSH failed every command with "koffi native addon
13
+ not found". The new `lib/dsh-locate.mjs` finds the installation instead: the
14
+ interpreter's own path walked upwards (the host starts the launcher with the
15
+ Node the app ships, so that path points back at the installation), then the
16
+ installer's uninstall entry — `InstallLocation`, `DisplayIcon` and
17
+ `UninstallString` are all accepted, and it is found by display name because
18
+ the key is a GUID — then the conventional folders and `PATH`.
19
+ - `DSH_SBIE_KOFFI` and `DSH_SBIE_KOFFI_ROOT` remain the explicit overrides, and
20
+ the failure message now says what it looked for and where.
21
+ - New `test/dsh-locate.mjs` (18 offline checks) pins the source order and
22
+ includes a regression case for an installation outside every default folder.
23
+
24
+ **Packaging**
25
+
26
+ - `bin` no longer carries a leading `./`. npm's `@npmcli/package-json`
27
+ normalizes that away and then reports `"bin[dsh-sbie-run]" script name
28
+ bin/dsh-sbie-run.mjs was invalid and removed` on every publish; the emitted
29
+ path was already the normalized one, so this removes a misleading warning and
30
+ nothing else. `test/unit.mjs` pins the form.
31
+
6
32
  ## 0.3.1 — 2026-10-12
7
33
 
8
34
  **Sandboxie installation lookup**
package/README.md CHANGED
@@ -42,6 +42,8 @@ A [DSH](https://github.com/deepseek-ai/deepseek-harness) bundle that adds a four
42
42
 
43
43
  **Any installation folder works — there is nothing to configure.** The plugin does not assume Sandboxie lives in one fixed directory: it reads Sandboxie's own registry entries first (the `SbieSvc` service's `ImagePath` is the authority — an installation that can create a box always has it, followed by the driver service and the installer's uninstall entry), then the conventional folders and `PATH`, and only as a last resort sweeps well-known parent folders and each fixed drive's root for a folder named after Sandboxie that holds `SbieDll.dll`. Another drive, a per-user folder and a portable directory all work. Only when every one of those comes up empty does it report "not installed", and that message names `DSH_SBIE_DIR` (see [Environment](#environment)) as the way to point at it directly.
44
44
 
45
+ **DSH's own installation folder is not assumed either.** The `koffi.node` the launcher needs ships *inside* DSH, under its `resources\app.asar.unpacked`, and the launcher is a plain Node process with no `process.resourcesPath`. So the plugin locates DSH itself: the Node running the launcher (the desktop app's own runtime lives inside the installation, so that path points back at it), then the installer's uninstall entry, then the conventional folders and `PATH`. DSH installed on another drive works too.
46
+
45
47
  The package is a DSH *bundle*: installing it also adds its configuration layer, so no profile file needs hand-editing.
46
48
 
47
49
  ```powershell
@@ -130,7 +132,7 @@ All optional; each exists as an escape hatch rather than a configuration surface
130
132
  | `DSH_SBIE_ROOT` | Overrides the sandbox root. Normally unnecessary: Sandboxie's configured `FileRootPath` is followed. Default `%SystemDrive%\Sandbox\%USERNAME%`. |
131
133
  | `DSH_SBIE_LOG_DIR` | Where diagnostics land. Default `$DSH_HOME/state/dsh-sandboxie-redirect`. |
132
134
  | `DSH_SBIE_INI` | The Sandboxie configuration file to read. Default `%SystemDrive%\Windows\Sandboxie.ini`. |
133
- | `DSH_SBIE_KOFFI` / `DSH_SBIE_KOFFI_ROOT` | A `koffi.node` (or a directory holding one) for the launcher, when the app's copy cannot be found. |
135
+ | `DSH_SBIE_KOFFI` / `DSH_SBIE_KOFFI_ROOT` | A `koffi.node` (or a directory holding one) for the launcher. Normally unnecessary: DSH's own installation folder is located automatically (see [Installation](#installation)). |
134
136
  | `DSH_SBIE_BOX` / `DSH_SANDBOX_ROOT` | **Set by the plugin** inside a command; authoritative for "which box am I in" — never derive the name from the workspace path. |
135
137
 
136
138
  Diagnostics: `provider.log`, `host.log`, `tool.log` under the log directory above. The host log is capped at 512 KB and records only state transitions.
package/README.zh.md CHANGED
@@ -41,6 +41,8 @@
41
41
 
42
42
  **装在哪儿都行,不需要配置。** 插件不假设 Sandboxie 在某个固定目录:它先读 Sandboxie 自己的注册表项(`SbieSvc` 服务的 `ImagePath` 最权威——能建沙盒的安装必然有它,其次是驱动服务与卸载项),再看 `%ProgramFiles%\Sandboxie-Plus` 等常规位置与 `PATH`,最后才按名字扫一遍常见父目录与各固定磁盘根目录,找含 `SbieDll.dll` 的文件夹。装在别的盘、用户目录、便携目录都可以。只有全部落空时才会报 "not installed",此时用 `DSH_SBIE_DIR` 直接指定(见[环境变量](#环境变量))。
43
43
 
44
+ **DSH 自己的安装目录同样不假定。** 启动器要用的 `koffi.node` 是随 DSH 一起装在它 `resources\app.asar.unpacked` 里的,而启动器是普通 Node 进程(没有 `process.resourcesPath`)。插件会自己定位 DSH:先看运行启动器的那个 node(桌面应用自带的运行时就在安装目录里,这条路径本身就指回安装位置),再看安装器留下的卸载项,最后才是常规目录与 `PATH`。DSH 装在别的盘也能正常工作。
45
+
44
46
  本包是 DSH 的 **bundle**(自带配置层),装进去就自动挂载,**不需要手改 profile 文件**。
45
47
 
46
48
  ```powershell
@@ -129,7 +131,7 @@ node bin\dsh-sbie-run.mjs --manage delete-box <沙盒名> # 删除指定沙
129
131
  | `DSH_SBIE_ROOT` | 覆盖沙盒根。通常不需要:Sandboxie 配置的 `FileRootPath` 会被自动跟随。默认 `%SystemDrive%\Sandbox\%USERNAME%`。 |
130
132
  | `DSH_SBIE_LOG_DIR` | 诊断日志目录。默认 `$DSH_HOME/state/dsh-sandboxie-redirect`。 |
131
133
  | `DSH_SBIE_INI` | 要读取的 Sandboxie 配置文件。默认 `%SystemDrive%\Windows\Sandboxie.ini`。 |
132
- | `DSH_SBIE_KOFFI` / `DSH_SBIE_KOFFI_ROOT` | 启动器要用的 `koffi.node`(或含它的目录),当找不到应用自带的那份时使用。 |
134
+ | `DSH_SBIE_KOFFI` / `DSH_SBIE_KOFFI_ROOT` | 启动器要用的 `koffi.node`(或含它的目录)。通常不需要:DSH 的安装位置会被自动定位(见[安装](#安装))。 |
133
135
  | `DSH_SBIE_BOX` / `DSH_SANDBOX_ROOT` | **由插件在命令内注入**;"我在哪个盒里"以它们为准,永远不要用工作区路径去推导盒名。 |
134
136
 
135
137
  诊断文件:上述日志目录下的 `provider.log`、`host.log`、`tool.log`。`host.log` 上限 512 KB,只记状态迁移。
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Find where DeepSeek Harness itself is installed.
3
+ *
4
+ * `koffi.node` does not come from npm: it ships *inside* the DSH installation,
5
+ * as an unpacked native addon under `resources\app.asar.unpacked`. The launcher
6
+ * that needs it is a plain Node process, so it has no `process.resourcesPath`
7
+ * and no idea where the app that started it lives. `lib/koffi.mjs` used to
8
+ * assume `%LOCALAPPDATA%\Programs\DeepSeek Harness` (and the two `%ProgramFiles%`
9
+ * spellings), which fails the moment DSH is installed anywhere else — exactly
10
+ * like the Sandboxie folder assumption this plugin already had to drop.
11
+ *
12
+ * Four sources, most trustworthy first:
13
+ *
14
+ * 1. `process.resourcesPath` — present when this runs inside Electron; the
15
+ * direct answer.
16
+ * 2. `process.execPath` walked upwards. The host picks the launcher's Node
17
+ * through `redirect.mjs`'s `resolveNodePath()`, which prefers the runtime
18
+ * shipped *inside* the app; when that is what is running, its own path
19
+ * points straight back at the installation.
20
+ * 3. The uninstall entry the installer writes. It lives under a generated key
21
+ * (`…\Uninstall\<GUID>`), so it is found by searching that hive for the
22
+ * display name, and `InstallLocation` / `DisplayIcon` / `UninstallString`
23
+ * are all accepted as evidence — the per-user installer only guarantees
24
+ * some of them.
25
+ * 4. The conventional folders and `PATH`, for an installation that was
26
+ * unpacked by hand.
27
+ *
28
+ * WHY THIS MODULE IMPORTS ONLY NODE BUILTINS. `lib/koffi.mjs` imports it, and
29
+ * `lib/sbie-locate.mjs` imports `lib/koffi.mjs`; reaching back into either from
30
+ * here would close a cycle through the very loader this module exists to feed.
31
+ * The few small helpers it needs are therefore repeated rather than shared, and
32
+ * every side effect is injectable so the whole resolver is testable offline.
33
+ */
34
+ import { spawnSync } from "node:child_process";
35
+ import fs from "node:fs";
36
+ import path from "node:path";
37
+
38
+ /** The executable that names a DSH installation folder. */
39
+ export const DSH_EXE = "DeepSeek Harness.exe";
40
+ /** The name the installer's uninstall entry carries (`DeepSeek Harness 0.2.0-rc.2`). */
41
+ export const DSH_DISPLAY_NAME = "DeepSeek Harness";
42
+ /** The folder name an installation uses by default (`%LOCALAPPDATA%\Programs\DeepSeek Harness`). */
43
+ export const DSH_DIR_NAME = "DeepSeek Harness";
44
+
45
+ /**
46
+ * The uninstall hives an installer may have used. A per-user install lands in
47
+ * HKCU; a machine-wide one in HKLM, in the view matching the installer's
48
+ * bitness. Nothing fixed can be queried, because the subkey is a GUID.
49
+ */
50
+ export const DSH_UNINSTALL_KEYS = [
51
+ "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
52
+ "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
53
+ "HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
54
+ ];
55
+
56
+ /** Environment lookup that is case-insensitive, like Windows' own. */
57
+ function envLookup(env, name) {
58
+ if (env === undefined || env === null) return undefined;
59
+ const wanted = name.toLowerCase();
60
+ for (const key of Object.keys(env)) {
61
+ if (key.toLowerCase() !== wanted) continue;
62
+ const value = env[key];
63
+ if (typeof value === "string" && value.length > 0) return value;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /** Replace `%NAME%` references; unknown names are left alone (they simply fail validation later). */
69
+ export function expandEnvRefs(value, env = process.env) {
70
+ return String(value).replace(/%([^%]+)%/g, (whole, name) => envLookup(env, name) ?? whole);
71
+ }
72
+
73
+ /** De-duplicate case-insensitively, keeping each first occurrence's spelling. */
74
+ export function uniqueDirs(dirs) {
75
+ const seen = new Set();
76
+ const unique = [];
77
+ for (const dir of dirs ?? []) {
78
+ if (typeof dir !== "string" || dir.length === 0) continue;
79
+ const key = dir.replace(/[\\/]+$/, "").toLowerCase();
80
+ if (key.length === 0 || seen.has(key)) continue;
81
+ seen.add(key);
82
+ unique.push(dir);
83
+ }
84
+ return unique;
85
+ }
86
+
87
+ function existsWith(exists, candidate) {
88
+ try {
89
+ return exists(candidate) === true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ const has = (exists, ...parts) => existsWith(exists, path.join(...parts));
96
+
97
+ /**
98
+ * Whether `dir` is a DSH installation folder: it holds the app bundle (packed or
99
+ * unpacked) under `resources`, or the launcher executable itself.
100
+ */
101
+ export function isDshInstallRoot(dir, options = {}) {
102
+ const exists = options.exists ?? fs.existsSync;
103
+ if (typeof dir !== "string" || dir.length === 0) return false;
104
+ return (
105
+ has(exists, dir, "resources", "app.asar") ||
106
+ has(exists, dir, "resources", "app.asar.unpacked") ||
107
+ has(exists, dir, DSH_EXE)
108
+ );
109
+ }
110
+
111
+ /**
112
+ * The folders under which the koffi addon may be searched, given either a DSH
113
+ * installation folder or DSH's `resources` folder (or its `app.asar.unpacked`
114
+ * itself). Empty when the candidate is neither — which is what makes an
115
+ * unvalidated guess safe.
116
+ *
117
+ * `app.asar.unpacked` comes first because that is where the addon actually is;
118
+ * the plain `resources` folder is included as well, because the search under it
119
+ * can still reach a payload that lives beside the packed bundle.
120
+ */
121
+ export function resourcesDirsFor(candidate, options = {}) {
122
+ const exists = options.exists ?? fs.existsSync;
123
+ if (typeof candidate !== "string" || candidate.length === 0) return [];
124
+ const base = path.basename(candidate).toLowerCase();
125
+
126
+ // Already the unpacked payload — which is where the addon actually lives.
127
+ if (base === "app.asar.unpacked") return [candidate];
128
+
129
+ // Already DSH's `resources` folder.
130
+ if (base === "resources" && (has(exists, candidate, "app.asar") || existsWith(exists, path.join(candidate, "app.asar.unpacked")))) {
131
+ return [path.join(candidate, "app.asar.unpacked"), candidate];
132
+ }
133
+
134
+ // An installation folder — the packed bundle, the unpacked payload or the
135
+ // executable is evidence enough.
136
+ const resources = path.join(candidate, "resources");
137
+ const unpacked = path.join(resources, "app.asar.unpacked");
138
+ if (has(exists, resources, "app.asar") || existsWith(exists, unpacked)) return [unpacked, resources];
139
+ if (has(exists, candidate, DSH_EXE)) return [unpacked, resources];
140
+ return [];
141
+ }
142
+
143
+ /**
144
+ * Installation folders implied by the interpreter running this code: walk up
145
+ * from `node.exe` (or the Electron binary) and keep every ancestor that looks
146
+ * like a DSH installation. The app ships its Node under
147
+ * `resources\runtime\<pool>\dependencies\node\bin\node.exe`, so this is a
148
+ * pointer, not a guess, whenever the host chose that Node.
149
+ */
150
+ export function execPathInstallRoots(execPath, options = {}) {
151
+ if (typeof execPath !== "string" || execPath.length === 0) return [];
152
+ const maxUp = options.maxUp ?? 8;
153
+ const roots = [];
154
+ let dir = path.dirname(path.resolve(execPath));
155
+ for (let index = 0; index < maxUp; index += 1) {
156
+ if (isDshInstallRoot(dir, options)) roots.push(dir);
157
+ const parent = path.dirname(dir);
158
+ if (parent === dir) break; // reached the drive root
159
+ dir = parent;
160
+ }
161
+ return roots;
162
+ }
163
+
164
+ /** One `reg query` value line: `<name>\t<type>\t<data>`, tabs printed as two or more spaces. */
165
+ export function parseRegValueLine(line) {
166
+ const match = /^\s{2,}(\S.*?)\s{2,}(REG_[A-Z_]+)\s{2,}(.*)$/.exec(String(line));
167
+ if (match === null) return undefined;
168
+ return { name: match[1].trim().toLowerCase(), type: match[2], value: match[3].trim() };
169
+ }
170
+
171
+ /** Strip the quotes Windows installers put around paths, and any trailing argument. */
172
+ function unquoteExecutable(value) {
173
+ const text = String(value).trim();
174
+ if (text.startsWith('"')) {
175
+ const end = text.indexOf('"', 1);
176
+ if (end !== -1) return text.slice(1, end);
177
+ }
178
+ return text.replace(/\s+\/\S+$/, "");
179
+ }
180
+
181
+ /**
182
+ * Installation folders named by an uninstall hive's output.
183
+ *
184
+ * The search prints the whole matching key, so every value of interest is read
185
+ * by name rather than by position. `DisplayIcon` carries `,0` (the icon index)
186
+ * and `UninstallString` carries the uninstaller's switches, so both are reduced
187
+ * to a folder.
188
+ */
189
+ export function parseUninstallPaths(stdout, env = process.env) {
190
+ const dirs = [];
191
+ for (const line of String(stdout ?? "").split(/\r?\n/)) {
192
+ const parsed = parseRegValueLine(line);
193
+ if (parsed === undefined) continue;
194
+ const value = expandEnvRefs(parsed.value, env);
195
+ if (parsed.name === "installlocation") {
196
+ dirs.push(value.replace(/^"(.*)"$/s, "$1").replace(/[\\/]+$/, ""));
197
+ } else if (parsed.name === "displayicon") {
198
+ const exe = unquoteExecutable(value.replace(/,\s*-?\d+\s*$/, ""));
199
+ if (/[\\/]/.test(exe)) dirs.push(path.dirname(exe));
200
+ } else if (parsed.name === "uninstallstring") {
201
+ const exe = unquoteExecutable(value);
202
+ if (/[\\/]/.test(exe)) dirs.push(path.dirname(exe));
203
+ }
204
+ }
205
+ return uniqueDirs(dirs);
206
+ }
207
+
208
+ /**
209
+ * Installation folders named by the uninstall hives, through `reg.exe`.
210
+ *
211
+ * One spawn per hive with a data-only search for the display name, which prints
212
+ * only matching keys; parsing needs no block structure because of that. Every
213
+ * failure (no entry, no reg.exe) means "this machine told us nothing", never an
214
+ * error.
215
+ */
216
+ export function readDshRegistryRoots(options = {}) {
217
+ const env = options.env ?? process.env;
218
+ const spawn = options.spawnSync ?? spawnSync;
219
+ const dirs = [];
220
+ for (const key of DSH_UNINSTALL_KEYS) {
221
+ let result;
222
+ try {
223
+ result = spawn("reg.exe", ["query", key, "/s", "/f", DSH_DISPLAY_NAME, "/d"], {
224
+ encoding: "utf8",
225
+ windowsHide: true,
226
+ timeout: 10000
227
+ });
228
+ } catch {
229
+ continue;
230
+ }
231
+ if (result?.status !== 0) continue;
232
+ dirs.push(...parseUninstallPaths(result.stdout, env));
233
+ }
234
+ return uniqueDirs(dirs);
235
+ }
236
+
237
+ /**
238
+ * The folders a DSH installation conventionally occupies. A per-user install
239
+ * goes under `%LOCALAPPDATA%\Programs`; a machine-wide one under either
240
+ * `%ProgramFiles%` spelling, which is why all three are probed.
241
+ */
242
+ export function defaultDshRoots(env = process.env) {
243
+ const roots = [];
244
+ const localAppData = envLookup(env, "LOCALAPPDATA");
245
+ if (localAppData !== undefined) roots.push(path.join(localAppData, "Programs", DSH_DIR_NAME));
246
+ for (const name of ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"]) {
247
+ const base = envLookup(env, name);
248
+ if (base !== undefined) roots.push(path.join(base, DSH_DIR_NAME));
249
+ }
250
+ return roots;
251
+ }
252
+
253
+ /** `PATH` entries that name DeepSeek Harness; a portable install is often launched from one. */
254
+ export function pathDshRoots(env = process.env) {
255
+ const raw = envLookup(env, "PATH");
256
+ if (raw === undefined) return [];
257
+ const roots = [];
258
+ for (const entry of raw.split(";")) {
259
+ const dir = entry.trim().replace(/^"(.*)"$/s, "$1").trim().replace(/[\\/]+$/, "");
260
+ if (dir.length === 0) continue;
261
+ if (!/deepseek[\s_-]*harness/i.test(dir)) continue;
262
+ roots.push(dir);
263
+ }
264
+ return roots;
265
+ }
266
+
267
+ /**
268
+ * Every installation folder worth probing, in trust order: what the running
269
+ * interpreter points at, what the installer recorded, then the conventional
270
+ * locations and `PATH`. Later entries are unvalidated on purpose — validation
271
+ * happens in `resourcesDirsFor`, so a wrong guess contributes nothing.
272
+ */
273
+ export function dshInstallRoots(options = {}) {
274
+ const env = options.env ?? process.env;
275
+ return uniqueDirs([
276
+ ...execPathInstallRoots(options.execPath ?? process.execPath, options),
277
+ ...(options.registryRoots ?? readDshRegistryRoots(options)),
278
+ ...defaultDshRoots(env),
279
+ ...pathDshRoots(env)
280
+ ]);
281
+ }
282
+
283
+ /**
284
+ * The folders to hand to the koffi search, existing ones only, de-duplicated,
285
+ * in trust order.
286
+ *
287
+ * `process.resourcesPath` is consulted first when it exists: inside Electron it
288
+ * IS the answer, and it costs nothing to prefer it.
289
+ */
290
+ export function dshResourcesRoots(options = {}) {
291
+ const env = options.env ?? process.env;
292
+ const exists = options.exists ?? fs.existsSync;
293
+ const resourcesPath = typeof options.resourcesPath === "string"
294
+ ? options.resourcesPath
295
+ : (typeof process.resourcesPath === "string" ? process.resourcesPath : undefined);
296
+ const candidates = [];
297
+ if (resourcesPath !== undefined && resourcesPath.length > 0) candidates.push(resourcesPath);
298
+ candidates.push(...dshInstallRoots({ ...options, env }));
299
+ const dirs = [];
300
+ for (const candidate of candidates) dirs.push(...resourcesDirsFor(candidate, { exists }));
301
+ return uniqueDirs(dirs).filter((dir) => existsWith(exists, dir));
302
+ }
package/lib/koffi.mjs CHANGED
@@ -5,34 +5,56 @@
5
5
  * the harness install path differs per machine and per version, and the plain
6
6
  * `koffi` JS wrapper package is not always present. The addon itself is
7
7
  * self-contained, so we require the `.node` file directly.
8
+ *
9
+ * WHERE DSH ITSELF IS. The addon ships *inside* the installation, and this
10
+ * module runs in the launcher — a plain Node process with no
11
+ * `process.resourcesPath` — so the folder has to be discovered rather than
12
+ * known. `lib/dsh-locate.mjs` does that: the interpreter's own path walked
13
+ * upwards, the installer's uninstall entry, then the conventional locations and
14
+ * `PATH`. Hardcoding `%LOCALAPPDATA%\Programs\DeepSeek Harness` here is what
15
+ * made a relocated DSH report "koffi.node not found".
8
16
  */
9
17
  import { createRequire } from "node:module";
10
18
  import fs from "node:fs";
11
19
  import path from "node:path";
12
20
  import os from "node:os";
13
21
  import { fileURLToPath } from "node:url";
22
+ import { dshResourcesRoots } from "./dsh-locate.mjs";
14
23
 
15
24
  const require = createRequire(import.meta.url);
16
25
  const here = path.dirname(fileURLToPath(import.meta.url));
17
26
 
18
27
  let cached;
19
28
 
20
- function candidateRoots() {
29
+ /**
30
+ * The directories the addon search starts from, most specific first: an
31
+ * explicit override, a `vendor` folder beside the package, the DSH installation
32
+ * this process belongs to, then the managed runtime pool.
33
+ *
34
+ * Everything is injectable so the ordering can be tested offline; only the
35
+ * final directory check touches the real filesystem.
36
+ */
37
+ export function koffiRootCandidates(options = {}) {
38
+ const env = options.env ?? process.env;
39
+ const isDirectory = options.isDirectory ?? ((candidate) => {
40
+ try {
41
+ return fs.statSync(candidate).isDirectory();
42
+ } catch {
43
+ return false;
44
+ }
45
+ });
21
46
  const roots = [];
22
47
  const push = (p) => {
23
48
  if (typeof p === "string" && p.length > 0 && !roots.includes(p)) roots.push(p);
24
49
  };
25
- push(process.env.DSH_SBIE_KOFFI_ROOT);
50
+ push(env.DSH_SBIE_KOFFI_ROOT);
26
51
  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) => {
52
+ for (const dir of dshResourcesRoots({ ...options, env })) push(dir);
53
+ push(env.DSH_HOME && path.join(env.DSH_HOME, "dsh-runtimes"));
54
+ push(path.join(options.homeDir ?? os.homedir(), ".dsh", "dsh-runtimes"));
55
+ return roots.filter((root) => {
34
56
  try {
35
- return fs.statSync(r).isDirectory();
57
+ return isDirectory(root) === true;
36
58
  } catch {
37
59
  return false;
38
60
  }
@@ -70,7 +92,7 @@ function findKoffiNode(root, maxDepth = 9) {
70
92
  }
71
93
 
72
94
  /** Resolve and load koffi once. Throws with an actionable message when absent. */
73
- export function loadKoffi() {
95
+ export function loadKoffi(options = {}) {
74
96
  if (cached !== undefined) return cached;
75
97
  if (process.env.DSH_SBIE_KOFFI) {
76
98
  cached = require(process.env.DSH_SBIE_KOFFI);
@@ -81,7 +103,7 @@ export function loadKoffi() {
81
103
  return cached;
82
104
  } catch {}
83
105
  const tried = [];
84
- for (const root of candidateRoots()) {
106
+ for (const root of koffiRootCandidates(options)) {
85
107
  tried.push(root);
86
108
  const found = findKoffiNode(root);
87
109
  if (found !== undefined) {
@@ -90,6 +112,6 @@ export function loadKoffi() {
90
112
  }
91
113
  }
92
114
  throw new Error(
93
- `koffi native addon not found. Set DSH_SBIE_KOFFI to a koffi.node path. Searched: ${tried.join(", ") || "(no candidate roots)"}`
115
+ `koffi native addon not found. It ships inside the DeepSeek Harness installation (resources\\app.asar.unpacked), so this usually means DSH could not be located: set DSH_SBIE_KOFFI to a koffi.node path, or DSH_SBIE_KOFFI_ROOT to a folder to search. Searched: ${tried.join(", ") || "(no candidate roots)"}`
94
116
  );
95
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lone-wolf-akela/dsh-sandboxie-redirect",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",
@@ -42,7 +42,7 @@
42
42
  "./package.json": "./package.json"
43
43
  },
44
44
  "bin": {
45
- "dsh-sbie-run": "./bin/dsh-sbie-run.mjs"
45
+ "dsh-sbie-run": "bin/dsh-sbie-run.mjs"
46
46
  },
47
47
  "files": [
48
48
  "lib",
@@ -76,10 +76,10 @@
76
76
  }
77
77
  },
78
78
  "scripts": {
79
- "test": "node test/unit.mjs && node test/sbie-locate.mjs && node test/host-projection.mjs && node test/validate-patch.mjs",
79
+ "test": "node test/unit.mjs && node test/sbie-locate.mjs && node test/dsh-locate.mjs && node test/host-projection.mjs && node test/validate-patch.mjs",
80
80
  "test:acceptance": "node test/run-tests.mjs",
81
81
  "test:soak": "node test/soak.mjs 30",
82
- "test:locate": "node test/sbie-locate.mjs",
82
+ "test:locate": "node test/sbie-locate.mjs && node test/dsh-locate.mjs",
83
83
  "validate:patch": "node test/validate-patch.mjs",
84
84
  "pack:check": "npm pack --dry-run"
85
85
  },