@lone-wolf-akela/dsh-sandboxie-redirect 0.3.0 → 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,55 @@
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
+
32
+ ## 0.3.1 — 2026-10-12
33
+
34
+ **Sandboxie installation lookup**
35
+
36
+ - The engine is no longer looked for in a fixed four-path list. The new
37
+ `lib/sbie-locate.mjs` reads Sandboxie's own registry entries first — the
38
+ `SbieSvc` service's `ImagePath` above all, which every installation able to
39
+ create a box must have — then the driver service and the installer's uninstall
40
+ entry, then the conventional folders and `PATH`, and only as a last resort
41
+ walks well-known parent folders and fixed-drive roots for a directory named
42
+ after Sandboxie. An installation on another drive, in a per-user folder or in
43
+ a portable directory is now found instead of reported as "not installed".
44
+ - `DSH_SBIE_DIR` still wins when set, and the "not installed" error now names it
45
+ as well as `SbieDll.dll`, so the remaining unusual cases are actionable.
46
+ - `--manage list` prints the installation folder it resolved and how it found it
47
+ (registry / conventional folder / `PATH` / disk scan).
48
+ - The registry is read through `koffi`/`advapi32` when available — exact wide
49
+ characters, no subprocess — and through `reg.exe` when it is not.
50
+ - New `test/sbie-locate.mjs` (25 checks, no Sandboxie and no registry needed)
51
+ covers the registry value shapes, the candidate ordering, and the staged
52
+ resolution, including that the disk scan runs only when nothing cheap answers.
53
+ It is part of `npm test`.
54
+
6
55
  ## 0.3.0 — 2026-10-07
7
56
 
8
57
  The distribution changed shape, so this is a breaking release for the earlier
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # dsh-sandboxie-redirect
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@lone-wolf-akela/dsh-sandboxie-redirect?label=npm&color=cb3837)](https://www.npmjs.com/package/@lone-wolf-akela/dsh-sandboxie-redirect)
4
+ [![CI](https://github.com/lone-wolf-akela/dsh-sandboxie-redirect/actions/workflows/ci.yml/badge.svg)](https://github.com/lone-wolf-akela/dsh-sandboxie-redirect/actions/workflows/ci.yml)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![platform: Windows 10/11](https://img.shields.io/badge/platform-Windows%2010%2F11-0078d4.svg)](#installation)
7
+
3
8
  > **中文版**:[README.zh.md](README.zh.md)
4
9
 
5
10
  A [DSH](https://github.com/deepseek-ai/deepseek-harness) bundle that adds a fourth permission preset, **Copy-on-write (写时复制)**: shell commands run inside a Sandboxie box dedicated to the workspace, so writes inside the workspace land on the real disk as usual, while writes, edits and deletions anywhere else keep succeeding — but only inside that box's copy. The three shipped presets (read-only, workspace-write, danger-full-access) behave exactly as before.
@@ -35,6 +40,10 @@ A [DSH](https://github.com/deepseek-ai/deepseek-harness) bundle that adds a four
35
40
 
36
41
  **Prerequisite:** [Sandboxie-Plus](https://sandboxie-plus.com/) ([source](https://github.com/sandboxie-plus/Sandboxie)). The installer must have created its service; every box this plugin uses is created and managed by Sandboxie.
37
42
 
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
+
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
+
38
47
  The package is a DSH *bundle*: installing it also adds its configuration layer, so no profile file needs hand-editing.
39
48
 
40
49
  ```powershell
@@ -50,7 +59,7 @@ Other supported sources, for a local checkout or an offline machine:
50
59
 
51
60
  ```powershell
52
61
  dsh plugin --profile <profile> add C:\path\to\dsh-sandboxie-redirect # a checkout (linked)
53
- dsh plugin --profile <profile> add .\lone-wolf-akela-dsh-sandboxie-redirect-0.3.0.tgz
62
+ dsh plugin --profile <profile> add .\lone-wolf-akela-dsh-sandboxie-redirect-0.3.1.tgz
54
63
  ```
55
64
 
56
65
  > **Which command for which profile?** The CLI refuses to touch a profile that a running desktop app owns (`profile "desktop" is managed exclusively by the Electron application`). For that profile, install from inside the app: **Settings → Plugins → add a bundle**, and give the package name above. For any other profile, the CLI works normally.
@@ -118,11 +127,12 @@ All optional; each exists as an escape hatch rather than a configuration surface
118
127
 
119
128
  | Variable | Effect |
120
129
  |---|---|
130
+ | `DSH_SBIE_DIR` | The Sandboxie installation folder (the one holding `SbieDll.dll`). Normally unnecessary: the automatic lookup reads Sandboxie's registry entries, the conventional folders and `PATH` first. Use it when Sandboxie lives somewhere unusual and that lookup misses it. |
121
131
  | `DSH_SBIE_NODE` | The `node.exe` that runs the launcher. Set it if the plugin cannot find one (the desktop app's own runtime, then `$DSH_HOME/dsh-runtimes`, are searched automatically). |
122
132
  | `DSH_SBIE_ROOT` | Overrides the sandbox root. Normally unnecessary: Sandboxie's configured `FileRootPath` is followed. Default `%SystemDrive%\Sandbox\%USERNAME%`. |
123
133
  | `DSH_SBIE_LOG_DIR` | Where diagnostics land. Default `$DSH_HOME/state/dsh-sandboxie-redirect`. |
124
134
  | `DSH_SBIE_INI` | The Sandboxie configuration file to read. Default `%SystemDrive%\Windows\Sandboxie.ini`. |
125
- | `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)). |
126
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. |
127
137
 
128
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.
@@ -137,15 +147,16 @@ Diagnostics: `provider.log`, `host.log`, `tool.log` under the log directory abov
137
147
 
138
148
  ## Development
139
149
 
140
- See [AGENTS.md](AGENTS.md) for the architecture and the hard-won constraints. The short version:
150
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and the development loop, and [AGENTS.md](AGENTS.md) for the architecture and the hard-won constraints. The short version:
141
151
 
142
152
  ```powershell
143
- node test\unit.mjs # pure logic
144
- node test\host-projection.mjs # the title-bar projection, without the harness bundle
145
- node test\validate-patch.mjs # this bundle's layer: structure, and the preset merge actually evaluated
146
- node test\run-tests.mjs # acceptance: real boxes, real redirection (outside any box, medium IL)
153
+ npm test # unit + host projection + bundle-layer validation (no DSH, no Sandboxie)
154
+ npm run test:acceptance # acceptance: real boxes, real redirection (outside any box, medium IL)
155
+ npm run pack:check # what the published tarball would contain
147
156
  ```
148
157
 
158
+ Security issues: see [SECURITY.md](SECURITY.md).
159
+
149
160
  ## License
150
161
 
151
162
  MIT — see [LICENSE](LICENSE).
package/README.zh.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # dsh-sandboxie-redirect
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@lone-wolf-akela/dsh-sandboxie-redirect?label=npm&color=cb3837)](https://www.npmjs.com/package/@lone-wolf-akela/dsh-sandboxie-redirect)
4
+ [![CI](https://github.com/lone-wolf-akela/dsh-sandboxie-redirect/actions/workflows/ci.yml/badge.svg)](https://github.com/lone-wolf-akela/dsh-sandboxie-redirect/actions/workflows/ci.yml)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![platform: Windows 10/11](https://img.shields.io/badge/platform-Windows%2010%2F11-0078d4.svg)](#安装)
7
+
3
8
  > **English**: [README.md](README.md)
4
9
 
5
10
  给 [DSH](https://github.com/deepseek-ai/deepseek-harness) 增加第四种权限预设 **「写时复制」** 的 bundle:shell 命令跑在本工作区专属的 Sandboxie 沙盒里,工作区内的写入照常落到真实磁盘;工作区外的写入、修改、删除不会失败,但只落进该沙盒的副本。原有三个预设(只读 / 仅工作区 / 完全权限)行为不变。
@@ -34,6 +39,10 @@
34
39
 
35
40
  **前置条件**:[Sandboxie-Plus](https://sandboxie-plus.com/)([源码](https://github.com/sandboxie-plus/Sandboxie))。安装后它的服务须在运行;本插件的所有沙盒都由 Sandboxie 创建与管理。
36
41
 
42
+ **装在哪儿都行,不需要配置。** 插件不假设 Sandboxie 在某个固定目录:它先读 Sandboxie 自己的注册表项(`SbieSvc` 服务的 `ImagePath` 最权威——能建沙盒的安装必然有它,其次是驱动服务与卸载项),再看 `%ProgramFiles%\Sandboxie-Plus` 等常规位置与 `PATH`,最后才按名字扫一遍常见父目录与各固定磁盘根目录,找含 `SbieDll.dll` 的文件夹。装在别的盘、用户目录、便携目录都可以。只有全部落空时才会报 "not installed",此时用 `DSH_SBIE_DIR` 直接指定(见[环境变量](#环境变量))。
43
+
44
+ **DSH 自己的安装目录同样不假定。** 启动器要用的 `koffi.node` 是随 DSH 一起装在它 `resources\app.asar.unpacked` 里的,而启动器是普通 Node 进程(没有 `process.resourcesPath`)。插件会自己定位 DSH:先看运行启动器的那个 node(桌面应用自带的运行时就在安装目录里,这条路径本身就指回安装位置),再看安装器留下的卸载项,最后才是常规目录与 `PATH`。DSH 装在别的盘也能正常工作。
45
+
37
46
  本包是 DSH 的 **bundle**(自带配置层),装进去就自动挂载,**不需要手改 profile 文件**。
38
47
 
39
48
  ```powershell
@@ -49,7 +58,7 @@ dsh --profile <profile>
49
58
 
50
59
  ```powershell
51
60
  dsh plugin --profile <profile> add C:\path\to\dsh-sandboxie-redirect # 本地 checkout(link 方式)
52
- dsh plugin --profile <profile> add .\lone-wolf-akela-dsh-sandboxie-redirect-0.3.0.tgz
61
+ dsh plugin --profile <profile> add .\lone-wolf-akela-dsh-sandboxie-redirect-0.3.1.tgz
53
62
  ```
54
63
 
55
64
  > **哪个 profile 用哪条路?** 正在运行的桌面应用独占的 profile(`desktop`)会被 CLI 拒绝操作(`profile "desktop" is managed exclusively by the Electron application`)。这种 profile 请在**应用内**装:**设置 → 插件 → 添加 bundle**,填上面的包名。其它 profile 用 CLI 即可。
@@ -117,11 +126,12 @@ node bin\dsh-sbie-run.mjs --manage delete-box <沙盒名> # 删除指定沙
117
126
 
118
127
  | 变量 | 作用 |
119
128
  |---|---|
129
+ | `DSH_SBIE_DIR` | Sandboxie 安装目录(含 `SbieDll.dll` 的那个文件夹)。通常不需要:上面的自动查找会先从注册表、常规位置与 `PATH` 里找。装在非常规目录且自动查找没命中时用它。 |
120
130
  | `DSH_SBIE_NODE` | 跑启动器的 `node.exe`。插件会自动去找(桌面应用自带的运行时,其次 `$DSH_HOME/dsh-runtimes`),找不到时用它指定。 |
121
131
  | `DSH_SBIE_ROOT` | 覆盖沙盒根。通常不需要:Sandboxie 配置的 `FileRootPath` 会被自动跟随。默认 `%SystemDrive%\Sandbox\%USERNAME%`。 |
122
132
  | `DSH_SBIE_LOG_DIR` | 诊断日志目录。默认 `$DSH_HOME/state/dsh-sandboxie-redirect`。 |
123
133
  | `DSH_SBIE_INI` | 要读取的 Sandboxie 配置文件。默认 `%SystemDrive%\Windows\Sandboxie.ini`。 |
124
- | `DSH_SBIE_KOFFI` / `DSH_SBIE_KOFFI_ROOT` | 启动器要用的 `koffi.node`(或含它的目录),当找不到应用自带的那份时使用。 |
134
+ | `DSH_SBIE_KOFFI` / `DSH_SBIE_KOFFI_ROOT` | 启动器要用的 `koffi.node`(或含它的目录)。通常不需要:DSH 的安装位置会被自动定位(见[安装](#安装))。 |
125
135
  | `DSH_SBIE_BOX` / `DSH_SANDBOX_ROOT` | **由插件在命令内注入**;"我在哪个盒里"以它们为准,永远不要用工作区路径去推导盒名。 |
126
136
 
127
137
  诊断文件:上述日志目录下的 `provider.log`、`host.log`、`tool.log`。`host.log` 上限 512 KB,只记状态迁移。
@@ -136,15 +146,16 @@ node bin\dsh-sbie-run.mjs --manage delete-box <沙盒名> # 删除指定沙
136
146
 
137
147
  ## 开发
138
148
 
139
- 架构与踩过的坑见 [AGENTS.md](AGENTS.md)。常用命令:
149
+ 环境准备与开发回路见 [CONTRIBUTING.md](CONTRIBUTING.md),架构与踩过的坑见 [AGENTS.md](AGENTS.md)。常用命令:
140
150
 
141
151
  ```powershell
142
- node test\unit.mjs # 纯逻辑
143
- node test\host-projection.mjs # 标题栏投影(不依赖 harness 包)
144
- node test\validate-patch.mjs # 本 bundle 的层:结构 + 真的执行预设合并表达式
145
- node test\run-tests.mjs # 验收:真沙盒、真重定向(须在盒外、Medium IL)
152
+ npm test # 单元 + 标题栏投影 + bundle 层校验(不需要 DSH、不需要 Sandboxie)
153
+ npm run test:acceptance # 验收:真沙盒、真重定向(须在盒外、Medium IL)
154
+ npm run pack:check # 看发布的 tarball 里会有什么
146
155
  ```
147
156
 
157
+ 安全问题见 [SECURITY.md](SECURITY.md)。
158
+
148
159
  ## 许可
149
160
 
150
161
  MIT —— 见 [LICENSE](LICENSE)。
package/lib/cli.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { boxNameCandidates, canonicalWorkspace, listManagedBoxes, managedBoxesFor, removeBox, rootFor } from "./boxes.mjs";
11
- import { boxExists, reloadConfig, sbieDir, sbieIni } from "./sbie.mjs";
11
+ import { boxExists, reloadConfig, sbieDir, sbieDirSource, sbieIni, sbieNotInstalledError } from "./sbie.mjs";
12
12
 
13
13
  function print(line) {
14
14
  process.stdout.write(`${line}\n`);
@@ -62,10 +62,15 @@ function directorySize(dir) {
62
62
  }
63
63
 
64
64
  function list() {
65
- if (sbieDir() === undefined) {
66
- print("Sandboxie-Plus is not installed.");
65
+ const installDir = sbieDir();
66
+ if (installDir === undefined) {
67
+ print(sbieNotInstalledError().message);
67
68
  return 1;
68
69
  }
70
+ // Reported because "which Sandboxie is this plugin driving?" is the first
71
+ // question when a machine has more than one installation, and the answer is
72
+ // no longer a single fixed path.
73
+ print(`Sandboxie: ${installDir} (found via ${sbieDirSource()})`);
69
74
  const boxes = listManagedBoxes();
70
75
  if (boxes.length === 0) {
71
76
  print("No managed boxes (Dsh_* / Dshr_*) exist yet.");
@@ -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/lib/run.mjs CHANGED
@@ -30,7 +30,7 @@ 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 } from "./sbie.mjs";
33
+ import { runSandboxed, killProcess, sbieDir, sbieNotInstalledError } from "./sbie.mjs";
34
34
 
35
35
  const here = path.dirname(fileURLToPath(import.meta.url));
36
36
  const relayScript = path.join(here, "inner.mjs");
@@ -193,7 +193,7 @@ export async function run(argv, io = {}) {
193
193
  const box = options.box ?? process.env.DSH_SBIE_BOX ?? boxNameFor(workspace, mode);
194
194
  const log = options.verbose ? (message) => process.stderr.write(`dsh-sbie-run: ${message}\n`) : () => {};
195
195
 
196
- if (sbieDir() === undefined) throw new Error("Sandboxie-Plus is not installed (SbieDll.dll not found)");
196
+ if (sbieDir() === undefined) throw sbieNotInstalledError();
197
197
 
198
198
  const ensured = ensureBox({ box, workspace, mode, logger: log });
199
199
  const env = { ...process.env, DSH_SANDBOX_ROOT: ensured.root, DSH_SBIE_BOX: ensured.box };
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Find where Sandboxie-Plus is installed.
3
+ *
4
+ * The engine is reached through `SbieDll.dll` and `SbieIni.exe`, both of which
5
+ * live in the installation folder — and that folder is NOT always the default
6
+ * `%ProgramFiles%\Sandboxie-Plus`. A user may install to another drive, to a
7
+ * per-user folder, or into a portable directory. Probing a fixed list of four
8
+ * paths therefore reports "Sandboxie-Plus is not installed" on machines where
9
+ * it plainly is.
10
+ *
11
+ * The authoritative source is Sandboxie's own service registration:
12
+ * `SbieDll_RunSandboxed` is only usable while `SbieSvc` runs, and the service
13
+ * key's `ImagePath` names the real installation folder. The driver service and
14
+ * the installer's uninstall entry name it too. Those are read first, then the
15
+ * conventional folders, then `PATH`, and only if all of that fails is the disk
16
+ * walked — a bounded, one-level scan of well-known parent folders and of each
17
+ * fixed drive's root, looking for a directory whose name names Sandboxie.
18
+ *
19
+ * Everything here is dependency-light on purpose: the module is imported by the
20
+ * launcher (a plain Node process) and is unit-testable with no Sandboxie, no
21
+ * registry, and no Windows at all, because every side effect is injectable.
22
+ * `koffi` is loaded lazily and only when the registry is actually consulted.
23
+ */
24
+ import { spawnSync } from "node:child_process";
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { loadKoffi } from "./koffi.mjs";
28
+
29
+ /** The DLL whose presence in a folder is what makes it an installation folder. */
30
+ export const SBIE_DLL = "SbieDll.dll";
31
+
32
+ /** Windows' predefined `HKEY_LOCAL_MACHINE`. */
33
+ const HKEY_LOCAL_MACHINE = 0x80000002;
34
+ /** `RegGetValueW` restrictions: accept `REG_SZ` and `REG_EXPAND_SZ` (expanded for us). */
35
+ const RRF_RT_REG_SZ = 0x00000002;
36
+ const RRF_RT_REG_EXPAND_SZ = 0x00000004;
37
+ /** The largest registry value this module bothers to hold (a path never comes close). */
38
+ const REGISTRY_VALUE_BYTES = 4096;
39
+ /** `GetDriveTypeW` answer for a fixed disk. */
40
+ const DRIVE_FIXED = 3;
41
+
42
+ /**
43
+ * Registry locations that name the installation folder, most authoritative
44
+ * first. `kind: "file"` means the value names a file inside the folder (so the
45
+ * folder is its directory); `kind: "dir"` means the value IS the folder.
46
+ *
47
+ * `HKLM\SYSTEM\CurrentControlSet\Services\SbieSvc` is the load-bearing one: a
48
+ * Sandboxie that cannot register that service cannot start a boxed process at
49
+ * all, so the folder it names is the folder this plugin must use whenever the
50
+ * plugin can work.
51
+ */
52
+ export const SBIE_REGISTRY_SOURCES = [
53
+ {
54
+ key: "HKLM\\SYSTEM\\CurrentControlSet\\Services\\SbieSvc",
55
+ values: [{ name: "ImagePath", kind: "file" }]
56
+ },
57
+ {
58
+ key: "HKLM\\SYSTEM\\CurrentControlSet\\Services\\SbieDrv",
59
+ values: [{ name: "ImagePath", kind: "file" }]
60
+ },
61
+ {
62
+ key: "HKLM\\SOFTWARE\\Sandboxie-Plus",
63
+ values: [
64
+ { name: "InstallDir", kind: "dir" },
65
+ { name: "InstallLocation", kind: "dir" },
66
+ { name: "SbieHome", kind: "dir" },
67
+ { name: "HomeDir", kind: "dir" }
68
+ ]
69
+ },
70
+ {
71
+ key: "HKLM\\SOFTWARE\\WOW6432Node\\Sandboxie-Plus",
72
+ values: [
73
+ { name: "InstallDir", kind: "dir" },
74
+ { name: "InstallLocation", kind: "dir" },
75
+ { name: "SbieHome", kind: "dir" },
76
+ { name: "HomeDir", kind: "dir" }
77
+ ]
78
+ },
79
+ {
80
+ // Sandboxie-Plus ships an Inno Setup installer; both registry views are read
81
+ // because the installer's bitness decides which one it wrote.
82
+ key: "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Sandboxie-Plus_is1",
83
+ values: [
84
+ { name: "InstallLocation", kind: "dir" },
85
+ { name: "Inno Setup: App Path", kind: "dir" },
86
+ { name: "DisplayIcon", kind: "file" }
87
+ ]
88
+ },
89
+ {
90
+ key: "HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Sandboxie-Plus_is1",
91
+ values: [
92
+ { name: "InstallLocation", kind: "dir" },
93
+ { name: "Inno Setup: App Path", kind: "dir" },
94
+ { name: "DisplayIcon", kind: "file" }
95
+ ]
96
+ }
97
+ ];
98
+
99
+ /** Environment lookup that is case-insensitive, like Windows' own. */
100
+ function envLookup(env, name) {
101
+ if (env === undefined || env === null) return undefined;
102
+ const wanted = name.toLowerCase();
103
+ for (const key of Object.keys(env)) {
104
+ if (key.toLowerCase() !== wanted) continue;
105
+ const value = env[key];
106
+ if (typeof value === "string" && value.length > 0) return value;
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ /**
112
+ * Replace `%NAME%` references with the environment's values. Unknown names are
113
+ * left alone: a candidate that keeps an unresolved placeholder simply fails the
114
+ * `SbieDll.dll` check and costs nothing, whereas guessing a value could point
115
+ * at the wrong folder.
116
+ */
117
+ export function expandEnvRefs(value, env = process.env) {
118
+ return String(value).replace(/%([^%]+)%/g, (whole, name) => envLookup(env, name) ?? whole);
119
+ }
120
+
121
+ /**
122
+ * Turn one registry value into the folder it names.
123
+ *
124
+ * Handles the shapes these keys actually hold: surrounding quotes, the
125
+ * `\??\`/`\\?\` prefixes the driver service uses, `%ProgramFiles%`-style
126
+ * references (already expanded on the `RegGetValueW` path, expanded here on the
127
+ * `reg.exe` one), and trailing separators. Returns `undefined` when the value
128
+ * cannot name a folder.
129
+ *
130
+ * @param raw - the registry value.
131
+ * @param kind - `"file"` (a file inside the folder) or `"dir"` (the folder).
132
+ * @param env - environment used to expand `%NAME%` references.
133
+ */
134
+ export function directoryFromRegistryValue(raw, kind = "dir", env = process.env) {
135
+ if (typeof raw !== "string") return undefined;
136
+ let text = expandEnvRefs(raw, env).trim();
137
+ if (text.length === 0) return undefined;
138
+ const quoted = /^"(.*)"$/s.exec(text);
139
+ if (quoted !== null) text = quoted[1].trim();
140
+ // `\??\C:\…` (driver ImagePath), `\\?\C:\…` (long-path form), `\SystemRoot\…`.
141
+ text = text.replace(/^(?:\\\?\?\\|\\\\\?\\|\\SystemRoot\\)/i, "");
142
+ if (text.length === 0) return undefined;
143
+ const trimmed = text.replace(/[\\/]+$/, "");
144
+ if (trimmed.length === 0) return undefined;
145
+ if (kind !== "file") return trimmed;
146
+ // A file value must carry a directory; a bare name would resolve to ".".
147
+ if (!/[\\/]/.test(trimmed)) return undefined;
148
+ const dir = path.dirname(trimmed).replace(/[\\/]+$/, "");
149
+ return dir.length === 0 || dir === "." ? undefined : dir;
150
+ }
151
+
152
+ /**
153
+ * The value of one `reg query` line, or `undefined`.
154
+ *
155
+ * The format is fixed: `<value>\t<type>\t<data>`, with two or more spaces in
156
+ * place of tabs. A value name may itself contain spaces (`Inno Setup: App
157
+ * Path`), so the name/type split is found by the `REG_*` token rather than by
158
+ * splitting on the first run of whitespace.
159
+ *
160
+ * @param stdout - `reg query <key> /v <name>` output.
161
+ * @param name - the value name that was asked for.
162
+ */
163
+ export function parseRegQueryValue(stdout, name) {
164
+ const wanted = String(name).toLowerCase();
165
+ for (const line of String(stdout ?? "").split(/\r?\n/)) {
166
+ const match = /^\s{2,}(\S.*?)\s{2,}(REG_[A-Z_]+)\s{2,}(.*)$/.exec(line);
167
+ if (match === null) continue;
168
+ if (match[1].trim().toLowerCase() !== wanted) continue;
169
+ return match[3].trim();
170
+ }
171
+ return undefined;
172
+ }
173
+
174
+ /** Read the registry through koffi/advapi32 (wide characters, no subprocess). */
175
+ function readRegistryValuesNative() {
176
+ const koffi = loadKoffi();
177
+ const advapi = koffi.load("advapi32.dll");
178
+ const regGetValue = advapi.func("__stdcall", "RegGetValueW", "int32", [
179
+ "uintptr", // HKEY
180
+ "str16", // LPCWSTR lpSubKey
181
+ "str16", // LPCWSTR lpValue
182
+ "uint32", // DWORD dwFlags
183
+ "void *", // LPDWORD pdwType
184
+ "void *", // PVOID pvData
185
+ koffi.inout(koffi.pointer("uint32")) // LPDWORD pcbData
186
+ ]);
187
+ const found = [];
188
+ for (const source of SBIE_REGISTRY_SOURCES) {
189
+ const subkey = source.key.replace(/^HKLM\\/i, "");
190
+ for (const entry of source.values) {
191
+ const buffer = Buffer.alloc(REGISTRY_VALUE_BYTES);
192
+ const size = [buffer.length];
193
+ const status = regGetValue(HKEY_LOCAL_MACHINE, subkey, entry.name, RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, null, buffer, size);
194
+ if (status !== 0) continue; // ERROR_FILE_NOT_FOUND / no such key: simply not this source
195
+ const value = buffer.toString("utf16le").replace(/\0[\s\S]*$/s, "").trim();
196
+ if (value.length > 0) found.push({ value, kind: entry.kind, source: source.key });
197
+ }
198
+ }
199
+ return found;
200
+ }
201
+
202
+ /**
203
+ * Read the registry through `reg.exe`; used only when koffi cannot be used at all.
204
+ *
205
+ * One spawn per KEY rather than per value: `reg query <key>` prints every value
206
+ * of the key, and the parser picks the ones that interest us. Spawning costs
207
+ * tens of milliseconds each, so this halves the fallback's latency.
208
+ */
209
+ function readRegistryValuesRegExe() {
210
+ const found = [];
211
+ for (const source of SBIE_REGISTRY_SOURCES) {
212
+ let result;
213
+ try {
214
+ result = spawnSync("reg.exe", ["query", source.key], {
215
+ encoding: "utf8",
216
+ windowsHide: true,
217
+ timeout: 5000
218
+ });
219
+ } catch {
220
+ continue;
221
+ }
222
+ if (result?.status !== 0) continue;
223
+ for (const entry of source.values) {
224
+ const value = parseRegQueryValue(result.stdout, entry.name);
225
+ if (value !== undefined && value.length > 0) found.push({ value, kind: entry.kind, source: source.key });
226
+ }
227
+ }
228
+ return found;
229
+ }
230
+
231
+ /**
232
+ * Every registry value that names an installation folder, in source order.
233
+ *
234
+ * `koffi` is tried first because it decodes the wide-character values exactly
235
+ * and spawns nothing; `reg.exe` is the fallback, since a machine where the
236
+ * plugin works always has it even if koffi cannot be located.
237
+ */
238
+ export function readSbieRegistryValues() {
239
+ try {
240
+ return readRegistryValuesNative();
241
+ } catch {
242
+ /* no koffi, or advapi32 unavailable: reg.exe still answers */
243
+ }
244
+ try {
245
+ return readRegistryValuesRegExe();
246
+ } catch {
247
+ return [];
248
+ }
249
+ }
250
+
251
+ /** Folders from registry values, in order, with `undefined`s dropped. */
252
+ export function registryCandidateDirs(values, env = process.env) {
253
+ const dirs = [];
254
+ for (const entry of values ?? []) {
255
+ const dir = directoryFromRegistryValue(entry?.value, entry?.kind, env);
256
+ if (dir !== undefined) dirs.push(dir);
257
+ }
258
+ return dirs;
259
+ }
260
+
261
+ /**
262
+ * The conventional installation folders. `%ProgramFiles%` and friends cover a
263
+ * relocated Windows as well as a relocated Sandboxie; the literal last entry is
264
+ * the historical default, kept so a machine with no environment at all still
265
+ * gets the old behaviour.
266
+ */
267
+ export function defaultSbieDirs(env = process.env) {
268
+ const dirs = [];
269
+ const under = (base) => {
270
+ if (typeof base !== "string" || base.length === 0) return undefined;
271
+ return path.join(base, "Sandboxie-Plus");
272
+ };
273
+ for (const name of ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"]) {
274
+ const dir = under(envLookup(env, name));
275
+ if (dir !== undefined) dirs.push(dir);
276
+ }
277
+ const localAppData = envLookup(env, "LOCALAPPDATA");
278
+ if (localAppData !== undefined) dirs.push(path.join(localAppData, "Programs", "Sandboxie-Plus"));
279
+ const systemDrive = envLookup(env, "SystemDrive");
280
+ if (systemDrive !== undefined) dirs.push(path.join(systemDrive, "Sandboxie-Plus"));
281
+ dirs.push("C:\\Program Files\\Sandboxie-Plus");
282
+ return dirs;
283
+ }
284
+
285
+ /**
286
+ * Directories from `PATH` that are plausibly an installation folder. Only names
287
+ * that mention Sandboxie are considered, so a long `PATH` does not turn into a
288
+ * long list of candidates.
289
+ */
290
+ export function pathCandidateDirs(env = process.env) {
291
+ const raw = envLookup(env, "PATH");
292
+ if (raw === undefined) return [];
293
+ const dirs = [];
294
+ for (const entry of raw.split(";")) {
295
+ const dir = entry.trim().replace(/^"(.*)"$/s, "$1").trim();
296
+ if (dir.length === 0) continue;
297
+ if (!/sbie|sandboxie/i.test(dir)) continue;
298
+ dirs.push(dir.replace(/[\\/]+$/, ""));
299
+ }
300
+ return dirs;
301
+ }
302
+
303
+ /** Parent folders whose immediate children may be an installation folder. */
304
+ export function scanParentDirs(env = process.env) {
305
+ const dirs = [];
306
+ for (const name of ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"]) {
307
+ const base = envLookup(env, name);
308
+ if (base !== undefined) dirs.push(base);
309
+ }
310
+ const localAppData = envLookup(env, "LOCALAPPDATA");
311
+ if (localAppData !== undefined) dirs.push(path.join(localAppData, "Programs"));
312
+ const systemDrive = envLookup(env, "SystemDrive");
313
+ if (systemDrive !== undefined) {
314
+ dirs.push(`${systemDrive}\\`);
315
+ dirs.push(path.join(systemDrive, "Programs"));
316
+ dirs.push(path.join(systemDrive, "Apps"));
317
+ }
318
+ return dirs;
319
+ }
320
+
321
+ /**
322
+ * Roots of the machine's fixed drives, so an installation on `D:` is found.
323
+ *
324
+ * Only `DRIVE_FIXED` disks are returned: probing a disconnected network drive,
325
+ * an empty optical drive or a card reader can block for seconds, and Sandboxie
326
+ * on such a volume is not a case worth that cost.
327
+ */
328
+ export function fixedDriveRoots() {
329
+ try {
330
+ const koffi = loadKoffi();
331
+ const kernel32 = koffi.load("kernel32.dll");
332
+ const getLogicalDrives = kernel32.func("GetLogicalDrives", "uint32", []);
333
+ const getDriveType = kernel32.func("GetDriveTypeW", "uint32", ["str16"]);
334
+ const mask = getLogicalDrives();
335
+ const roots = [];
336
+ for (let index = 0; index < 26; index += 1) {
337
+ if ((mask & (1 << index)) === 0) continue;
338
+ const root = `${String.fromCharCode(65 + index)}:\\`;
339
+ if (getDriveType(root) === DRIVE_FIXED) roots.push(root);
340
+ }
341
+ return roots;
342
+ } catch {
343
+ return [];
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Walk the immediate children of `parents` (and of `drives`' roots) for a
349
+ * directory whose NAME names Sandboxie.
350
+ *
351
+ * One level only, and by name only: this is the last resort after the
352
+ * authoritative sources, so it must be cheap and must never wander into a
353
+ * user's tree. A directory that matches but holds no `SbieDll.dll` is simply
354
+ * filtered out by the caller's existence check. Junctions and symlinks count as
355
+ * directories, so a linked installation is not missed by the name sweep.
356
+ */
357
+ export function scanSbieDirs(options = {}) {
358
+ const readdir = options.readdir ?? fs.readdirSync;
359
+ const parents = [...(options.parents ?? scanParentDirs(options.env)), ...(options.drives ?? [])];
360
+ const seen = new Set();
361
+ const found = [];
362
+ for (const parent of parents) {
363
+ if (typeof parent !== "string" || parent.length === 0) continue;
364
+ let entries;
365
+ try {
366
+ entries = readdir(parent, { withFileTypes: true });
367
+ } catch {
368
+ continue;
369
+ }
370
+ for (const entry of entries) {
371
+ const isDirectory = entry?.isDirectory?.() === true || entry?.isSymbolicLink?.() === true;
372
+ if (!isDirectory) continue;
373
+ const name = String(entry.name ?? "");
374
+ if (!/sandboxie/i.test(name)) continue;
375
+ const dir = path.join(parent, name);
376
+ const key = dir.toLowerCase();
377
+ if (seen.has(key)) continue;
378
+ seen.add(key);
379
+ found.push(dir);
380
+ }
381
+ }
382
+ return found;
383
+ }
384
+
385
+ /** De-duplicate candidates case-insensitively, keeping the first occurrence's spelling. */
386
+ export function uniqueDirs(dirs) {
387
+ const seen = new Set();
388
+ const unique = [];
389
+ for (const dir of dirs ?? []) {
390
+ if (typeof dir !== "string" || dir.length === 0) continue;
391
+ const key = dir.replace(/[\\/]+$/, "").toLowerCase();
392
+ if (key.length === 0 || seen.has(key)) continue;
393
+ seen.add(key);
394
+ unique.push(dir);
395
+ }
396
+ return unique;
397
+ }
398
+
399
+ /**
400
+ * Every cheap candidate, in the order they are trusted: the explicit override,
401
+ * then what Sandboxie's own registry entries say, then the conventional
402
+ * folders, then `PATH`. The disk scan is deliberately NOT part of this list —
403
+ * it is expensive and is only reached when nothing here holds `SbieDll.dll`.
404
+ *
405
+ * This is the same list `resolveSbieDir` walks (minus the scan), so the
406
+ * "not installed" message can be built from it and cannot claim to have looked
407
+ * somewhere the resolver never looks.
408
+ */
409
+ export function sbieDirCandidates(options = {}) {
410
+ const env = options.env ?? process.env;
411
+ const override = typeof env?.DSH_SBIE_DIR === "string" && env.DSH_SBIE_DIR.length > 0 ? [env.DSH_SBIE_DIR] : [];
412
+ const registryValues = options.registryValues ?? readSbieRegistryValues();
413
+ return uniqueDirs([
414
+ ...override,
415
+ ...registryCandidateDirs(registryValues, env),
416
+ ...defaultSbieDirs(env),
417
+ ...pathCandidateDirs(env)
418
+ ]);
419
+ }
420
+
421
+ /**
422
+ * The installation folder, with the reason it was chosen.
423
+ *
424
+ * @param options.env - environment (default `process.env`).
425
+ * @param options.exists - existence probe (default `fs.existsSync`).
426
+ * @param options.registryValues - registry values to trust, when the caller has
427
+ * them; the real registry is read only when this is omitted.
428
+ * @param options.readdir / options.parents / options.drives - the disk scan's
429
+ * inputs, injectable so the whole resolver is testable off-Windows.
430
+ * @param options.scan - `false` disables the last-resort scan.
431
+ * @returns `{dir, source}` or `undefined`.
432
+ */
433
+ export function resolveSbieDir(options = {}) {
434
+ const env = options.env ?? process.env;
435
+ const exists = options.exists ?? fs.existsSync;
436
+ const hasDll = (dir) => {
437
+ try {
438
+ return exists(path.join(dir, SBIE_DLL)) === true;
439
+ } catch {
440
+ return false;
441
+ }
442
+ };
443
+
444
+ const override = typeof env?.DSH_SBIE_DIR === "string" && env.DSH_SBIE_DIR.length > 0 ? env.DSH_SBIE_DIR : undefined;
445
+ if (override !== undefined && hasDll(override)) return { dir: override, source: "DSH_SBIE_DIR" };
446
+
447
+ const registryValues = options.registryValues ?? readSbieRegistryValues();
448
+ for (const dir of registryCandidateDirs(registryValues, env)) {
449
+ if (hasDll(dir)) return { dir, source: "registry" };
450
+ }
451
+ for (const dir of defaultSbieDirs(env)) {
452
+ if (hasDll(dir)) return { dir, source: "default-folder" };
453
+ }
454
+ for (const dir of pathCandidateDirs(env)) {
455
+ if (hasDll(dir)) return { dir, source: "PATH" };
456
+ }
457
+
458
+ if (options.scan === false) return undefined;
459
+ const drives = options.drives ?? fixedDriveRoots();
460
+ for (const dir of scanSbieDirs({ ...options, env, drives })) {
461
+ if (hasDll(dir)) return { dir, source: "disk-scan" };
462
+ }
463
+ return undefined;
464
+ }
package/lib/sbie.mjs CHANGED
@@ -16,29 +16,62 @@
16
16
  * Writing that file directly would need an administrator token.
17
17
  */
18
18
  import { spawnSync } from "node:child_process";
19
- import fs from "node:fs";
20
19
  import path from "node:path";
21
20
  import { loadKoffi } from "./koffi.mjs";
21
+ import { SBIE_DLL, resolveSbieDir, sbieDirCandidates } from "./sbie-locate.mjs";
22
22
 
23
- export const SBIE_DIR_CANDIDATES = [
24
- process.env.DSH_SBIE_DIR,
25
- process.env.ProgramFiles && path.join(process.env.ProgramFiles, "Sandboxie-Plus"),
26
- process.env["ProgramFiles(x86)"] && path.join(process.env["ProgramFiles(x86)"], "Sandboxie-Plus"),
27
- process.env.ProgramW6432 && path.join(process.env.ProgramW6432, "Sandboxie-Plus"),
28
- "C:\\Program Files\\Sandboxie-Plus"
29
- ].filter((value) => typeof value === "string" && value.length > 0);
23
+ /**
24
+ * The resolver behind `sbieDir()`: `lib/sbie-locate.mjs` reads Sandboxie's own
25
+ * registry entries (the `SbieSvc` service's `ImagePath` above all), then the
26
+ * conventional folders, then `PATH`, and finally walks well-known parent
27
+ * folders and fixed-drive roots for a directory named after Sandboxie. A fixed
28
+ * four-path list used to live here, which reported "not installed" on every
29
+ * machine whose Sandboxie is not in `%ProgramFiles%\Sandboxie-Plus`.
30
+ *
31
+ * The result is memoized per process because `sbieExe()` resolves for every
32
+ * call; the launcher is a fresh process per command, so a Sandboxie installed
33
+ * while the host runs is picked up on the next command either way.
34
+ */
35
+ let resolved;
30
36
 
31
- /** The Sandboxie installation directory, or undefined when not installed. */
37
+ /** The Sandboxie installation directory, or undefined when it cannot be found. */
32
38
  export function sbieDir() {
33
- for (const dir of SBIE_DIR_CANDIDATES) {
34
- if (fs.existsSync(path.join(dir, "SbieDll.dll"))) return dir;
35
- }
36
- return undefined;
39
+ if (resolved === undefined) resolved = resolveSbieDir() ?? null;
40
+ return resolved === null ? undefined : resolved.dir;
41
+ }
42
+
43
+ /** How the installation directory was found (`"registry"`, `"disk-scan"`, …). `undefined` when it was not. */
44
+ export function sbieDirSource() {
45
+ sbieDir();
46
+ return resolved === null ? undefined : resolved.source;
47
+ }
48
+
49
+ /** Forget the memoized lookup. For tests and for diagnostics. */
50
+ export function resetSbieDirCache() {
51
+ resolved = undefined;
52
+ }
53
+
54
+ /**
55
+ * The one "not installed" error, naming what can be done about it.
56
+ *
57
+ * A fixed candidate list used to be the whole story, so the message could just
58
+ * say "not found". Now the search is broad, and the remaining case — a truly
59
+ * unusual installation, or one the machine's own registry does not describe —
60
+ * needs the escape hatch named in the message itself, because the user who hits
61
+ * it has no other way to learn it exists.
62
+ */
63
+ export function sbieNotInstalledError() {
64
+ const tried = sbieDirCandidates();
65
+ const shown = tried.slice(0, 6).join(", ");
66
+ const more = tried.length > 6 ? `, … (${tried.length - 6} more)` : "";
67
+ return new Error(
68
+ `Sandboxie-Plus is not installed (${SBIE_DLL} not found). Searched the service and uninstall registry entries, ${shown}${more}, and the fixed drives' Sandboxie folders. Set DSH_SBIE_DIR to the folder containing ${SBIE_DLL} if Sandboxie lives somewhere unusual.`
69
+ );
37
70
  }
38
71
 
39
72
  export function sbieExe(name) {
40
73
  const dir = sbieDir();
41
- if (dir === undefined) throw new Error("Sandboxie-Plus is not installed (SbieDll.dll not found)");
74
+ if (dir === undefined) throw sbieNotInstalledError();
42
75
  return path.join(dir, name);
43
76
  }
44
77
 
@@ -63,7 +96,7 @@ let api;
63
96
  export function sbieApi() {
64
97
  if (api !== undefined) return api;
65
98
  const dir = sbieDir();
66
- if (dir === undefined) throw new Error("Sandboxie-Plus is not installed (SbieDll.dll not found)");
99
+ if (dir === undefined) throw sbieNotInstalledError();
67
100
  const koffi = loadKoffi();
68
101
  const kernel32 = koffi.load("kernel32.dll");
69
102
  const dll = koffi.load(path.join(dir, "SbieDll.dll"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lone-wolf-akela/dsh-sandboxie-redirect",
3
- "version": "0.3.0",
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,9 +76,10 @@
76
76
  }
77
77
  },
78
78
  "scripts": {
79
- "test": "node test/unit.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 && node test/dsh-locate.mjs",
82
83
  "validate:patch": "node test/validate-patch.mjs",
83
84
  "pack:check": "npm pack --dry-run"
84
85
  },