dsh-pwsh-plugin 0.0.0-stage → 0.2.13

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-pwsh-plugin contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,432 @@
1
- # Temporary Holding Version
1
+ # dsh-pwsh-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [English](README.md) · [简体中文](README.zh-CN.md)
4
+
5
+ A `pwsh_run` tool for DeepSeek Harness: PowerShell script text goes to a **persistent** pwsh 7 process
6
+ over stdin, so nothing is escaped on the way in, there is no 32K command-line cap, the ~400 ms cold start
7
+ is paid once, and variables / location / modules survive from one call to the next.
8
+
9
+ It sits **next to** the built-in `pwsh` tool, it does not replace it.
10
+
11
+ The same repository also carries `wsl/llrun`, a small C shim that gives the `bash` tool a real write
12
+ boundary inside WSL. See [docs/llrun.md](docs/llrun.md).
13
+
14
+ The plugin page shows this package's own icon, its localized title and summary, and — in a Chinese UI —
15
+ `README.zh-CN.md` rather than the English one. Each piece is declared where the harness looks for it:
16
+ `icon.svg` through `package.json`'s `icon` field, and the text through `locale/en.json` plus
17
+ `locale/zh.json` (only `meta.detail` can reach a document whose name is not literally `README`).
18
+
19
+ Requires **DSH 0.2.0-rc.2 or newer** (tested on 0.2.0-rc.2 and 0.2.1-rc.3) and **PowerShell 7**.
20
+
21
+ ## Why you want it
22
+
23
+ | Layer | What happens today |
24
+ |---|---|
25
+ | `bash` tool | Wraps the command as `/bin/bash -c "<cmd>"`. The outer double quotes expand `$` **before** PowerShell ever sees it, and heredocs die in the same layer. |
26
+ | built-in `pwsh` tool | Passes `$` and here-strings through correctly, but each call is a brand-new process, and the whole command rides the Windows command line: 32,000 characters is fine, 32,700 gives `spawn ENAMETOOLONG`. |
27
+ | this tool | Script body travels as a base64 blob on stdin, so neither problem applies. |
28
+
29
+ Verified 91,741 characters in a single call. Full measurements and the reasoning behind every design
30
+ choice are in [FINDINGS.md](FINDINGS.md).
31
+
32
+ ## Install
33
+
34
+ The package is a **DSH bundle**: it ships a `cordis.patch.yml` that inserts its own rows, so installing
35
+ it is all you do — you never edit your profile by hand.
36
+
37
+ It inserts two:
38
+
39
+ - `- id: pwsh-run` — the tool itself.
40
+ - `- id: custom-bash` — the `bash` tool replacement, Windows only (`disabled: !!js process.platform !== 'win32'`).
41
+ It points at `dsh-pwsh-plugin/wsl/custom-bash.mjs` inside the package, so the `llrun` binary that comes with
42
+ it is what the fence uses — nothing to copy, nothing to install into `/usr/local/bin`. `remove` takes both
43
+ rows and the package away, leaving your profile exactly as it was.
44
+
45
+ It does **not** disable the stock `tool-bash`: that row already carries `disabled: !!js process.platform ===
46
+ 'win32'`, and a hard `disabled: true` from here would switch the official bash off on Linux too.
47
+
48
+ **npm registry.** Also published to npm: `npm i dsh-pwsh-plugin`, or let DSH install the registry spec:
49
+
50
+ ```powershell
51
+ & $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
52
+ ```
53
+
54
+ > pnpm's `minimumReleaseAge` defaults to 1440 minutes, so a version published today is not resolved
55
+ today — pressing update installs the previous release. That is the supply-chain policy, not a failure.
56
+
57
+ **Sidebar.** Plugins → Add plugin → `https://github.com/citydirector/dsh-pwsh-plugin`, then switch it on.
58
+
59
+ **CLI.** The `desktop` profile can only be managed by the desktop application's own CLI:
60
+
61
+ ```powershell
62
+ $dsh = '<desktop install>\resources\runtime\cli\bin\dsh.cmd'
63
+ $env:DSH_HOME = '<DSH_HOME>' # the data root the desktop app itself uses
64
+
65
+ # <version> is any published version; the newest one is what the plugin itself would install
66
+ & $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
67
+ # or, from a local checkout
68
+ & $dsh plugin --profile desktop add 'file:<path to checkout>'
69
+ ```
70
+
71
+ `add` appends `dsh-pwsh-plugin` to `dsh.profile.bundles` in the profile's `package.json`; `remove` takes it
72
+ back out. Verify with `--dump-config` on a non-reserved profile, where the row shows up as
73
+ `# == dsh-pwsh-plugin` followed by `- id: pwsh-run / name: dsh-pwsh-plugin`.
74
+
75
+ Two things to know before you choose a spec:
76
+
77
+ - **A registry spec copies, `file:` hard-links.** `dsh-pwsh-plugin@X.Y.Z` installs one copy of that release —
78
+ editing the checkout changes nothing until you run `add` again. A `file:` install hard-links the checkout,
79
+ so edits are live immediately (`fsutil hardlink list` shows both paths), at the cost of pinning an absolute
80
+ path in `package.json`. Use `file:` while developing, a registry spec for keeps.
81
+ `github:citydirector/dsh-pwsh-plugin#<tag>` still works and installs the same copy, but it goes around
82
+ pnpm's supply-chain policy.
83
+ - **The bundle row cannot carry `?v=N`.** Its `name` must be the package name so it resolves from the
84
+ profile's `node_modules`, so there is no URL to change and ESM keeps serving the module it already cached.
85
+ Changing a file under `node_modules` is only picked up if the host happens to run a recomposition later;
86
+ that is not something to rely on while developing. Use the manual row below with a fresh `?v=N`, or restart.
87
+
88
+ **Manual install**, if you would rather not take a dependency:
89
+
90
+ ```yaml
91
+ - insert:
92
+ - id: pwsh-run
93
+ name: '<path>/plugin/index.mjs'
94
+ config:
95
+ pwshPath: 'pwsh'
96
+ ```
97
+
98
+ Three constraints that cost real time to find:
99
+
100
+ - **`desktop` is a reserved profile.** The portable `dsh.exe` answers
101
+ `profile "desktop" is managed exclusively by the Electron application` and exits 1. Only the desktop
102
+ install's `dsh.cmd` (which starts the desktop-host CLI with `manageDesktopProfile: true`) can touch it.
103
+ - **`DSH_HOME` must point at the portable data root.** The default is `~/.dsh`; point it elsewhere and you
104
+ get `Open DeepSeek Harness Desktop once to initialize its profile` — it is looking for
105
+ `~/.dsh/profiles/desktop/package.json`.
106
+ - **Do not double-register.** If you switch from a manual row to the bundle (or the other way round), delete
107
+ the hand-written entry **before** installing. Both layers insert `- id: pwsh-run` and the composed tree
108
+ will hold two of them; registration is keyed by name, and a second `pwsh_run` is a hard error at boot.
109
+ `--dump-config` shows the duplicate plainly — that is the check to run after switching.
110
+
111
+ The three files in `plugin/` (`index.mjs`, `session.mjs`, `session-server.ps1`) must stay together —
112
+ `index.mjs` reads the `.ps1` at load time and ships it to the child as `-EncodedCommand`.
113
+
114
+ ## The tools
115
+
116
+ `pwsh_run` runs a script. `pwsh_run_update` checks for and optionally installs a newer release of this
117
+ plugin — see [Updates](#updates).
118
+
119
+ | Argument | Required | Meaning |
120
+ |---|---|---|
121
+ | `script` | yes | PowerShell script text. Reaches the server **verbatim** — do not escape `$`, do not wrap it in `pwsh -c`, do not add outer quotes. |
122
+ | `workdir` | no | `Set-Location` first. The session is stateful, so later calls inherit it. An invalid path fails this call without running the script. |
123
+ | `timeoutMs` | no | Per-call timeout, default 120000. |
124
+
125
+ Output: stdout as-is, warnings prefixed `WARNING: `, verbose prefixed `VERBOSE: `, a thrown exception
126
+ carries `[script threw]` plus the innermost message. Errors (`Write-Error`, a child process's stderr)
127
+ come **after** stdout — the server merges every stream through `*>&1` and tags lines by type, so
128
+ **line-by-line interleaving of stdout and stderr is not preserved** (the built-in tool keeps two columns).
129
+ When order matters, merge them yourself: `2>&1` or `*>&1`.
130
+
131
+ ## Configuration
132
+
133
+ | Key | Default | Meaning |
134
+ |---|---|---|
135
+ | `pwshPath` | `pwsh` | Executable; resolved through `PATH` by default. |
136
+ | `timeoutMs` | `120000` | Default per-call timeout. |
137
+ | `idleMs` | `1800000` | Recycle an idle session (30 minutes). |
138
+ | `maxSessions` | `4` | How many sessions may live at once (one per agent workspace). |
139
+ | `maxOutputBytes` | `8388608` | Output collection cap per call (8 MB). Truncation is reported honestly, never silently. |
140
+ | `allowUnconfined` | `false` | `true` = start a session even when the sandbox policy service is missing. |
141
+ | `updateCheck` | `true` | Look for newer published versions periodically. Checking and installing are separate: this only checks; `pwsh_run_update` and auto-update do the install. |
142
+ | `autoUpdate` | `true` | Install a newer release by itself, and — where the host runs HMR — take effect without a restart. |
143
+ | `autoUpdateDelayMs` | `90000` | Wait before the first automatic check, so start-up is not competing with pnpm. |
144
+
145
+ `allowUnconfined` is the one deliberate escape hatch. In a normal deployment `ctx.sandboxPolicy` is always
146
+ present; when it is missing the composition is incomplete, and the plugin **refuses to start** rather than
147
+ quietly running an unconstrained session.
148
+
149
+ Hot reload (`patchReload: live`) re-runs `apply`. Tool registration and process cleanup both hang off
150
+ `ctx.effect`, so it does not trip `tool "pwsh_run" is already registered`.
151
+
152
+ ## Sandbox
153
+
154
+ The persistent process is wrapped **once, at launch**, in the restricted argv that `ctx.sandbox.confine`
155
+ returns. The Windows ACL sandbox uses a `WRITE_RESTRICTED` token, and a token is **process-wide** — so
156
+ after that single confine, every later read and write in the session obeys the same workspace boundary as
157
+ the built-in `pwsh` tool.
158
+
159
+ Policy is resolved against the **real session at launch time** (`session` must be passed into
160
+ `sandboxPolicy.resolve`): the session's `sandbox/mode` wins, and the boundary root comes from the
161
+ session's `header.cwd`.
162
+
163
+ A trap worth knowing: passing the ToolExecution instead (`agent` lives on `exec.agent`) means `resolve`
164
+ never sees a session and **silently falls back to the deployment default** (mode + configured
165
+ `workspaceRoot`), which put the boundary on the profile directory — the symptom was "cannot write inside
166
+ the workspace, but can write to the profile directory". The `policy-carries-session` case in
167
+ `test/sandboxtest.mjs` pins that shape down.
168
+
169
+ Measured with `workspace-write`, cwd = the plugin directory: the workspace accepts writes; the drive root,
170
+ `C:\Users\<user>\`, `C:\Windows\Temp\`, `C:\ProgramData\` and the profile directory are all
171
+ refused. The script still runs in `FullLanguage`, and `$global:` plus cwd survive across calls.
172
+ Under `danger-full-access` nothing is confined, exactly like the built-in tool.
173
+
174
+ **`read-only` does not start.** In that mode pwsh runs in ConstrainedLanguage and cannot even create a
175
+ runspace. The tool reports that clearly and points at the built-in `pwsh` tool, which works there.
176
+
177
+ ## Updates
178
+
179
+ Left alone, the plugin upgrades itself: 90 seconds after start, then every 12 hours, it reads the `latest`
180
+ dist-tag from the npm registry, and a newer release is installed on the spot. On a host that runs DSH's HMR service the new code is
181
+ **already live when the install returns** — no restart, no prompt, nothing for the user to do.
182
+
183
+ | How | What happens |
184
+ |---|---|
185
+ | Automatic (`autoUpdate: true`, default) | Checks, installs, and takes effect in place. A failed install backs off exponentially (5 min doubling to 2 h) and tries again; a version that installed successfully is not retried. **Never while a call is running** — it retries in 3 minutes. On a host **without** HMR it installs nothing — see below. |
186
+ | `pwsh_run_update` `action: "check"` | Reads the registry once and reports `state` / `running` / `newest release` / `install spec`. Installs nothing. |
187
+ | `pwsh_run_update` `action: "install"` | The same install, driven by hand instead of by the clock. Reports `install: postponed` if a call is running, and `install: restart-required` / `install: broken-release` when a pre-flight check says installing would achieve nothing (see below). |
188
+
189
+ The install goes through `ctx.get('pluginManager')` — the service behind the Plugins page — which runs pnpm
190
+ and rolls `package.json` back on failure. The plugin never edits its own files: it is a bundle dependency
191
+ pinned in `package.json` and `pnpm-lock.yaml`, and writing files behind the package manager's back would
192
+ leave the lockfile lying.
193
+
194
+ **Why it can take effect without a restart.** A plugin's `apply` and any timer it starts run inside the HMR
195
+ service's transaction context. Calling `installBundle` from there runs pnpm and lands the new files, then
196
+ fails to recompose — `HMR transactions cannot be nested` — which leaves the worst possible state: new files
197
+ new files on disk, old modules running. Clearing the context first (`hmr.executing.run(undefined, …)`) lets the
198
+ recomposition happen, and because the freshly installed files on disk no longer match what is loaded, HMR
199
+ evicts the old modules and imports the new ones. Measured: 137 ms from the trigger to the new code answering.
200
+
201
+ **Why it looks before it installs.** DSH's plugin manager refuses, before running pnpm, an install whose spec
202
+ exactly matches what the profile already records — `already-installed`. Two situations reach that, and neither
203
+ should be attempted at all:
204
+
205
+ - Somebody already installed this version (a terminal `dsh plugin add`, the Plugins page) and the process was
206
+ not restarted yet. The files are current; a restart is all that is missing.
207
+ - A release was tagged without bumping `version` in `package.json`. The tag changes, the declared version does
208
+ not, so every attempt installs the same bytes and is refused the same way — a silent loop that never ends
209
+ and never says anything.
210
+
211
+ So before installing, the updater compares three facts it already has: the spec the profile records, the
212
+ version the installed package declares, and the version the running process reports. A version already on disk
213
+ reports `install: restart-required`; a tag whose package still declares the old version reports
214
+ `install: broken-release` and says so plainly. `scripts/release-check.mjs` catches the second case before the
215
+ tag is pushed; this check catches it afterwards, on every machine that tries to update.
216
+
217
+ **Why it waits for an idle moment.** An install replaces the plugin, and stopping the plugin disposes every
218
+ persistent session it owns. Done while a script is in flight, that is a power cut: half-written output, a call
219
+ that ends with no exit status, session state gone. So the updater asks the plugin whether any call is running
220
+ (same synchronous check-and-set the tool uses, so a call cannot slip in between), and postpones the whole
221
+ attempt when one is. Calls that arrive mid-install wait for it instead of racing it.
222
+
223
+ On a host **without** HMR the automatic path does nothing at all. Installing there would put the new files
224
+ on disk while the running process keeps the old modules — an automatic update that needs a restart is not
225
+ an update, it is a version number that lies. (`pwsh_run_update action: "install"` still installs in that
226
+ situation, because a human asked and can decide when to restart; the result says `restart-required`.)
227
+ Turning the automatic path off (`autoUpdate: false`) restores the older behaviour everywhere: check only,
228
+ and put one line at the end of the next `pwsh_run` output so a human decides.
229
+
230
+ Without `pluginManager` — a profile where the `plugin-manager` service is disabled — `install` reports
231
+ `no-manager` and you upgrade from a terminal instead:
232
+
233
+ ```powershell
234
+ $env:DSH_HOME = '<DSH_HOME>'
235
+ & '<desktop install>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
236
+ ```
237
+
238
+ Note that DSH's stock composition disables the *tool* `plugin_manager` while leaving the *service* running
239
+ for desktop profiles; the two are different things, and only the service is used here.
240
+
241
+ The source is the npm registry entry for this package: `GET https://registry.npmjs.org/dsh-pwsh-plugin`
242
+ with `accept: application/vnd.npm.install-v1+json`. Its `dist-tags.latest` is the release to offer; it must
243
+ be a plain `X.Y.Z`. A `latest` marked `deprecated` is never offered, and without a `dist-tags` the check
244
+ falls back to the highest stable version in the version list. A `404` is reported as "not published on
245
+ <registry>" rather than "no newer release" — those are different facts, and only one of them is temporary.
246
+
247
+ The install spec is the registry form, `dsh-pwsh-plugin@X.Y.Z`, so what the plugin asks for and what
248
+ `dsh plugin add` accepts are the same string.
249
+
250
+ | Environment variable | Effect |
251
+ |---|---|
252
+ | `DSH_PWSH_UPDATE_REGISTRY` | Registry base URL, default `https://registry.npmjs.org`. Point it at a mirror, or at a local server to rehearse. |
253
+ | `DSH_PWSH_UPDATE_DELAY_MS` | First check delay, default 5000 |
254
+
255
+ ```powershell
256
+ & $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
257
+ ```
258
+
259
+ Installing by hand is the same operation the plugin performs, with the same policy applied by pnpm.
260
+
261
+ A failed query never throws into the tool call: it records `state: 'failed'`, logs one line through
262
+ `ctx.logger`, and keeps an earlier `latest` as `stale: 'available'` — one network hiccup should not make a
263
+ known new version vanish.
264
+
265
+ ## The `llrun` shim
266
+
267
+ The `bash` tool runs in WSL, where `/mnt/c` and `/mnt/d` are writable **anywhere**: 9p/drvfs sits outside
268
+ the DSH sandbox. `wsl/llrun` closes that with a user namespace, landlock (ABI 7, and a whitelist that
269
+ deliberately omits `/init` so no Windows executable can be launched) and a `/var/tmp` mount seal.
270
+ `wsl/custom-bash.mjs` wires it into the `bash` tool and picks the tier from the session's permission.
271
+
272
+ It needs no manual install: the binary ships inside the package and is the last probe candidate, so a fresh
273
+ `plugin add` is enough. Since releases are packed on Windows, that copy arrives as `0644` — the probe
274
+ restores the executable bit itself before giving up, as long as the file belongs to the current user.
275
+
276
+ Full description, the `-r` semantics table, the 54-assertion acceptance matrix and the wiring:
277
+ **[docs/llrun.md](docs/llrun.md)**. Run `bash wsl/acceptance.sh` from a plain WSL shell (not from inside
278
+ `custom-bash`, where nesting a second fence makes four assertions fail for the wrong reason) to re-verify on
279
+ your own machine — it derives every path from its own location and re-checks the disk for leaks.
280
+
281
+ ## Troubleshooting
282
+
283
+ | Symptom | Do this |
284
+ |---|---|
285
+ | `profile "desktop" is managed exclusively by the Electron application` | Use the desktop install's `dsh.cmd`, not the portable `dsh.exe`. |
286
+ | `Open DeepSeek Harness Desktop once to initialize its profile` | You are pointing `DSH_HOME` at the wrong place; set it to the data root the desktop app uses. |
287
+ | `tool "pwsh_run" is already registered` at boot | Your profile patch file has a hand-written `id: pwsh-run` **and** the bundle patch inserts one. Delete the hand-written entry. |
288
+ | `refusing to start: ctx.sandboxPolicy is absent` | The composition is missing the sandbox policy service. Fix the composition, or set `allowUnconfined: true` if you really mean it. |
289
+ | `could not confine the session: SetNamedSecurityInfoW failed (Win32 5)` | The workspace root is not fully controlled by you. The sandbox writes a DACL + an integrity label there, which needs ownership plus `WRITE_DAC` and `WRITE_OWNER`. Take ownership and grant yourself full control: `icacls "<root>" /grant "%USERNAME%":(OI)(CI)F`, or start the session from a directory under your user profile. |
290
+ | `refusing to start: read-only mode` | Expected. Use the built-in `pwsh` tool in read-only sessions. |
291
+ | Script cannot write where the built-in tool can | Check that `session` reached `sandboxPolicy.resolve` (see [Sandbox](#sandbox)); a missing session silently moves the boundary to the profile directory. |
292
+ | Update notice never appears | The source env var must be set on the **Windows** side; WSL does not forward `DSH_*`. |
293
+ | Output looks reordered | stdout and stderr are merged by type, not by time. Merge them yourself with `2>&1`. |
294
+ | `exit N` code is not reported | Runspace semantics — use `Write-Output` or read `$LASTEXITCODE`. |
295
+ | A child process keeps a file locked after the call | The session holds the `Process` object. Wait for `idleMs`, or force a rebuild with a timeout. |
296
+ | `Program 'node.exe' failed to run: Access is denied.` (or `拒绝访问`) | Expected under `workspace-write`: the restricted token cannot create anonymous pipes, so **any** launch that captures a child's stdio fails — `& exe` included. Use `Start-Process -RedirectStandardOutput/-RedirectStandardError <file>` (captures output, no exit code), `[Diagnostics.Process]::Start` with `UseShellExecute=$false` and no redirection (exit code, child inherits stdout), or a `.cmd` wrapper that writes stdout, stderr and `%ERRORLEVEL%` to three files. The tool appends this same advice to the output. |
297
+
298
+ ## Known limits
299
+
300
+ - **`read-only` mode is unsupported** (see above); use the built-in `pwsh` tool there.
301
+ - **Refuses to start when the sandbox policy service is absent**, unless `allowUnconfined: true`.
302
+ - **A timeout kills the whole session** (and reaps its child process tree). State is lost; the next call
303
+ rebuilds automatically.
304
+ - **`exit N` exit codes are not observable** (runspace semantics).
305
+ - **stdout / stderr interleaving is not preserved** — everything goes through `*>&1` and is tagged by type.
306
+ - **`[Console]::Error.WriteLine` is swallowed** (the server points `Console.Error` at an in-memory stream so
307
+ the protocol cannot be polluted). A child process's stderr, `Write-Error` and `throw` all come back fine.
308
+ - **Child std handles are detached from the protocol.** `Console::SetIn/SetOut` only changes the .NET layer, so
309
+ the server also rewrites the three Win32 slots: stdin goes to `NUL`, stdout and stderr go to a
310
+ `FILE_FLAG_DELETE_ON_CLOSE` temp file truncated before every call. A grandchild that writes to inherited
311
+ stdout can no longer inject a fake protocol line (measured: one `Write-Output '<<<BYE>>>'` from a child used
312
+ to kill the session), and one that reads inherited stdin gets EOF instead of the caller's request.
313
+ The replacement handles are created **inheritable** (`SECURITY_ATTRIBUTES.bInheritHandle`): a handle made with
314
+ a `NULL` security descriptor only exists in this process, so a child that trusts `STARTUPINFO.hStdInput`
315
+ receives a bare handle *number* that means nothing over there — `GetFileType` reports `UNKNOWN`, and once in
316
+ a few dozen launches that number is recycled by an unrelated object and the read returns its bytes.
317
+ stdout deliberately does **not** go to `NUL`: that is a character device, PowerShell then believes its output
318
+ is not redirected and the sandbox denial changes into a useless `StandardOutputEncoding` message.
319
+ If the temp directory is unwritable it falls back to `NUL` and says so once, in a warning on the first call.
320
+ - **A session is serial**: concurrent calls queue up and run one at a time (no interleaved output, but no
321
+ parallel speedup either).
322
+ - **No WSL is required.** `pwsh_run` never touches WSL; only `wsl/custom-bash.mjs` does. Without a usable
323
+ `bashPath`, applying that preset still succeeds (the executable is resolved lazily) and the `bash` tool
324
+ fails per call with `command "<path>" is not an executable file` — a clear failure, not a silent
325
+ downgrade. The default is the bare name `bash`; when PATH cannot resolve it, the Windows build retries
326
+ `C:\Windows\System32\bash.exe` (the same WSL shim) before giving up. That fallback exists because a
327
+ process can inherit an environment block that predates a PATH edit and then lacks `System32`, which
328
+ otherwise costs you the whole tool. An explicit absolute `bashPath` is honoured alone — a typo there
329
+ should fail loudly rather than be silently redirected.
330
+ - **UAC behaves differently from this machine.** These measurements come from a box with UAC off, where the
331
+ process runs elevated. With UAC on, sessions run as a standard user, and the ACL sandbox needs the
332
+ workspace root's DACL to give you **`WRITE_DAC` and `WRITE_OWNER`** — full control satisfies both, because
333
+ the integrity label it writes lives in the SACL. A directory under your user profile inherits exactly
334
+ that; a directory created at a drive root only inherits `Modify`, and `Modify` alone fails with
335
+ `SetNamedSecurityInfoW failed (Win32 5)`. Ownership is not the deciding factor: `icacls <root> /grant
336
+ "%USERNAME%":(OI)(CI)F` fixes a root you do not own. Confinement fails closed with that message instead of
337
+ running unconfined.
338
+ - No interactive programs and no TTY.
339
+ - **An update replaces the session, but never cuts a call short.** Replacing a plugin stops it, and stopping
340
+ this plugin disposes its sessions, so the install that lands a new version leaves the persistent pwsh
341
+ process gone and the next call rebuilds it: anything held only in that session (variables, `Set-Location`,
342
+ imported modules) is lost. Measured across a v0.2.3 → v0.2.4 install: `$PID` changed from 24820 to 15632 and a
343
+ `$global:` variable set before the install read back empty. What the updater does **not** do is interrupt
344
+ work in flight: both the automatic loop and `pwsh_run_update action: "install"` ask the plugin whether a call
345
+ is running, and if one is, the install is postponed (the loop retries in 3 minutes, the tool reports
346
+ `install: postponed` and leaves the profile untouched) until the session is idle. Calls that arrive while an
347
+ install is in progress wait for it to finish instead of starting a session that is about to be disposed.
348
+ The version is worth more than the session state, but it is not "nothing happened" — do not hold
349
+ unrecoverable state in it across an upgrade.
350
+
351
+ ## Development
352
+
353
+ ```
354
+ plugin/
355
+ index.mjs tool registration, sandbox wiring, timeouts
356
+ session.mjs the session object: framing, queue, base64 encode, boot argv
357
+ session-server.ps1 the PowerShell side: runspace, stream tagging, sentinels
358
+ update-check.mjs registry lookup and version comparison
359
+ update-apply.mjs hands the pinned spec to pluginManager, never downloads
360
+ auto-update.mjs the automatic path: install outside the HMR transaction, back off on failure
361
+ test/
362
+ support.mjs locates a DSH install; converts Windows paths to /mnt form
363
+ itest.mjs protocol and session core
364
+ plugintest.mjs the whole plugin path against a mock context
365
+ e2e-real-subprocess.mjs real DSH subprocess runtime
366
+ sandboxtest.mjs real LocalSandboxProvider, boundary + fail-closed cases
367
+ bashtest.mjs custom-bash fence triage (mock runtime)
368
+ bash-e2e.mjs custom-bash end to end (real runtime + real llrun)
369
+ updatetest.mjs update check against a local registry, install outcomes
370
+ autoupdatetest.mjs the automatic loop, its back-off, its idle gate, and the HMR-context escape it needs
371
+ wiringtest.mjs the update notice lands at the end of tool output, and installs wait for an idle session
372
+ denialtest.mjs the sandbox-denial hint fires on the right messages only
373
+ metatest.mjs the plugin-page metadata: locale, icon, and which document the page selects
374
+ wsl/
375
+ llrun.c llrun the C shim and its build
376
+ custom-bash.mjs the bash tool replacement the bundle wires in (Windows only)
377
+ acceptance.sh the 54-assertion matrix
378
+ docs/llrun.md the shim in full
379
+ FINDINGS.md every measurement and the reasoning behind the design
380
+ ```
381
+
382
+ ```bash
383
+ cd <checkout>
384
+ PWSH_BIN='pwsh' node test/itest.mjs # 17: protocol and session core
385
+ PWSH_BIN='pwsh' node test/plugintest.mjs # 15: full plugin path
386
+ node test/e2e-real-subprocess.mjs # 8: real DSH subprocess
387
+ node test/sandboxtest.mjs # 7: real sandbox boundary + fail-closed
388
+ node test/bashtest.mjs # 25: custom-bash fence triage, bash fallback, exec-bit repair
389
+ node test/bash-e2e.mjs # 11: custom-bash end to end
390
+ node test/updatetest.mjs # 96: update check, install outcomes, pre-flight, local git only
391
+ node test/autoupdatetest.mjs # 38: automatic install, back-off, idle gate, pre-flight, HMR-context escape
392
+ node test/wiringtest.mjs # 21: update notice wiring, idle gate, pre-flight wiring
393
+ node test/denialtest.mjs # 11: sandbox-denial hint (no spawn, runs anywhere)
394
+ node test/metatest.mjs # 23: plugin-page metadata (no spawn, runs anywhere)
395
+ ```
396
+
397
+ Eleven scripts, **305 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
398
+ DSH install to borrow `node_modules` from. Tests that need a DSH runtime locate one automatically
399
+ (`DSH_MODULES`, `DSH_MODULES_ASAR`, or by walking up from the working directory) and print a `SKIP` hint
400
+ instead of failing when none is found.
401
+
402
+ ### Releasing
403
+
404
+ Publishing from a tag is `[.github/workflows/release.yml](.github/workflows/release.yml)`: it runs
405
+ `scripts/release-check.mjs`, then `npm publish --provenance` under npm's **trusted publishing**, which trades
406
+ the runner's short-lived GitHub identity for a token scoped to this repository and this workflow file. No
407
+ `NPM_TOKEN` secret exists, and none should be added. Two consequences worth knowing: the workflow file name is
408
+ part of the trust contract (rename it and every publish fails with 403 until the npm setting is updated), and
409
+ provenance is mandatory there, so the tarball carries a signed statement of which commit built it.
410
+
411
+ The version in `package.json` is the one thing in this repository that is hard-coded, and the update check
412
+ compares it against the published version. Publish without bumping it and the check reports a newer
413
+ release forever: the installed files keep reading the old number, so every cycle requests the same version
414
+ and the back-off climbs to its ceiling. `npm version` writes the version, commits, and tags in one step, which is
415
+ why the three cannot drift apart:
416
+
417
+ ```powershell
418
+ npm version patch -m 'chore(release): %s' # bump, commit, annotated tag
419
+ git push origin main <tag>
420
+ ```
421
+
422
+ Run `node scripts/release-check.mjs` before publishing. It fails when the working tree is dirty, when HEAD
423
+ carries a tag that disagrees with `package.json`, when the tagged commit declares a different version, and
424
+ when you are about to re-point a tag that already exists (that would move an already-published version onto
425
+ new content). With no tag on HEAD it prints the exact name to create.
426
+
427
+ Use the Windows-side npm. In WSL, `/usr/local/bin/npm` resolves to a bun symlink and `npm version` fails
428
+ with `Script not found "version"`.
429
+
430
+ ## License
431
+
432
+ MIT — see [LICENSE](LICENSE).