@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 +49 -0
- package/README.md +18 -7
- package/README.zh.md +18 -7
- package/lib/cli.mjs +8 -3
- package/lib/dsh-locate.mjs +302 -0
- package/lib/koffi.mjs +35 -13
- package/lib/run.mjs +2 -2
- package/lib/sbie-locate.mjs +464 -0
- package/lib/sbie.mjs +48 -15
- package/package.json +4 -3
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
|
+
[](https://www.npmjs.com/package/@lone-wolf-akela/dsh-sandboxie-redirect)
|
|
4
|
+
[](https://github.com/lone-wolf-akela/dsh-sandboxie-redirect/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](#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.
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/@lone-wolf-akela/dsh-sandboxie-redirect)
|
|
4
|
+
[](https://github.com/lone-wolf-akela/dsh-sandboxie-redirect/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](#安装)
|
|
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.
|
|
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
|
-
|
|
149
|
+
环境准备与开发回路见 [CONTRIBUTING.md](CONTRIBUTING.md),架构与踩过的坑见 [AGENTS.md](AGENTS.md)。常用命令:
|
|
140
150
|
|
|
141
151
|
```powershell
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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(
|
|
50
|
+
push(env.DSH_SBIE_KOFFI_ROOT);
|
|
26
51
|
push(path.join(here, "..", "vendor"));
|
|
27
|
-
|
|
28
|
-
push(
|
|
29
|
-
push(
|
|
30
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
37
|
+
/** The Sandboxie installation directory, or undefined when it cannot be found. */
|
|
32
38
|
export function sbieDir() {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
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.
|
|
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": "
|
|
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
|
},
|