dsh-pwsh-plugin 0.0.0-stage → 0.2.12

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,422 @@
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
+ **Sidebar.** Plugins → Add plugin → `https://github.com/citydirector/dsh-pwsh-plugin`, then switch it on.
49
+
50
+ **CLI.** The `desktop` profile can only be managed by the desktop application's own CLI:
51
+
52
+ ```powershell
53
+ $dsh = '<desktop install>\resources\runtime\cli\bin\dsh.cmd'
54
+ $env:DSH_HOME = '<DSH_HOME>' # the data root the desktop app itself uses
55
+
56
+ # <tag> is any released tag; the newest one is what the plugin itself would install
57
+ & $dsh plugin --profile desktop add 'github:citydirector/dsh-pwsh-plugin#<tag>'
58
+ # or, from a local checkout
59
+ & $dsh plugin --profile desktop add 'file:<path to checkout>'
60
+ ```
61
+
62
+ `add` appends `dsh-pwsh-plugin` to `dsh.profile.bundles` in the profile's `package.json`; `remove` takes it
63
+ back out. Verify with `--dump-config` on a non-reserved profile, where the row shows up as
64
+ `# == dsh-pwsh-plugin` followed by `- id: pwsh-run / name: dsh-pwsh-plugin`.
65
+
66
+ Two things to know before you choose a spec:
67
+
68
+ - **`github:` copies, `file:` hard-links.** A `github:` install is a snapshot of the tag — editing the checkout
69
+ changes nothing until you run `add` again. A `file:` install hard-links the checkout, so edits are live
70
+ immediately (`fsutil hardlink list` shows both paths), at the cost of pinning an absolute path in
71
+ `package.json`. Use `file:` while developing, `github:` for keeps.
72
+ - **The bundle row cannot carry `?v=N`.** Its `name` must be the package name so it resolves from the
73
+ profile's `node_modules`, so there is no URL to change and ESM keeps serving the module it already cached.
74
+ Changing a file under `node_modules` is only picked up if the host happens to run a recomposition later;
75
+ that is not something to rely on while developing. Use the manual row below with a fresh `?v=N`, or restart.
76
+
77
+ **Manual install**, if you would rather not take a dependency:
78
+
79
+ ```yaml
80
+ - insert:
81
+ - id: pwsh-run
82
+ name: '<path>/plugin/index.mjs'
83
+ config:
84
+ pwshPath: 'pwsh'
85
+ ```
86
+
87
+ Three constraints that cost real time to find:
88
+
89
+ - **`desktop` is a reserved profile.** The portable `dsh.exe` answers
90
+ `profile "desktop" is managed exclusively by the Electron application` and exits 1. Only the desktop
91
+ install's `dsh.cmd` (which starts the desktop-host CLI with `manageDesktopProfile: true`) can touch it.
92
+ - **`DSH_HOME` must point at the portable data root.** The default is `~/.dsh`; point it elsewhere and you
93
+ get `Open DeepSeek Harness Desktop once to initialize its profile` — it is looking for
94
+ `~/.dsh/profiles/desktop/package.json`.
95
+ - **Do not double-register.** If you switch from a manual row to the bundle (or the other way round), delete
96
+ the hand-written entry **before** installing. Both layers insert `- id: pwsh-run` and the composed tree
97
+ will hold two of them; registration is keyed by name, and a second `pwsh_run` is a hard error at boot.
98
+ `--dump-config` shows the duplicate plainly — that is the check to run after switching.
99
+
100
+ The three files in `plugin/` (`index.mjs`, `session.mjs`, `session-server.ps1`) must stay together —
101
+ `index.mjs` reads the `.ps1` at load time and ships it to the child as `-EncodedCommand`.
102
+
103
+ ## The tools
104
+
105
+ `pwsh_run` runs a script. `pwsh_run_update` checks for and optionally installs a newer release of this
106
+ plugin — see [Updates](#updates).
107
+
108
+ | Argument | Required | Meaning |
109
+ |---|---|---|
110
+ | `script` | yes | PowerShell script text. Reaches the server **verbatim** — do not escape `$`, do not wrap it in `pwsh -c`, do not add outer quotes. |
111
+ | `workdir` | no | `Set-Location` first. The session is stateful, so later calls inherit it. An invalid path fails this call without running the script. |
112
+ | `timeoutMs` | no | Per-call timeout, default 120000. |
113
+
114
+ Output: stdout as-is, warnings prefixed `WARNING: `, verbose prefixed `VERBOSE: `, a thrown exception
115
+ carries `[script threw]` plus the innermost message. Errors (`Write-Error`, a child process's stderr)
116
+ come **after** stdout — the server merges every stream through `*>&1` and tags lines by type, so
117
+ **line-by-line interleaving of stdout and stderr is not preserved** (the built-in tool keeps two columns).
118
+ When order matters, merge them yourself: `2>&1` or `*>&1`.
119
+
120
+ ## Configuration
121
+
122
+ | Key | Default | Meaning |
123
+ |---|---|---|
124
+ | `pwshPath` | `pwsh` | Executable; resolved through `PATH` by default. |
125
+ | `timeoutMs` | `120000` | Default per-call timeout. |
126
+ | `idleMs` | `1800000` | Recycle an idle session (30 minutes). |
127
+ | `maxSessions` | `4` | How many sessions may live at once (one per agent workspace). |
128
+ | `maxOutputBytes` | `8388608` | Output collection cap per call (8 MB). Truncation is reported honestly, never silently. |
129
+ | `allowUnconfined` | `false` | `true` = start a session even when the sandbox policy service is missing. |
130
+ | `updateCheck` | `true` | Look for release tags periodically. Checking and installing are separate: this only checks; `pwsh_run_update` and auto-update do the install. |
131
+ | `autoUpdate` | `true` | Install a newer release by itself, and — where the host runs HMR — take effect without a restart. |
132
+ | `autoUpdateDelayMs` | `90000` | Wait before the first automatic check, so start-up is not competing with pnpm. |
133
+
134
+ `allowUnconfined` is the one deliberate escape hatch. In a normal deployment `ctx.sandboxPolicy` is always
135
+ present; when it is missing the composition is incomplete, and the plugin **refuses to start** rather than
136
+ quietly running an unconstrained session.
137
+
138
+ Hot reload (`patchReload: live`) re-runs `apply`. Tool registration and process cleanup both hang off
139
+ `ctx.effect`, so it does not trip `tool "pwsh_run" is already registered`.
140
+
141
+ ## Sandbox
142
+
143
+ The persistent process is wrapped **once, at launch**, in the restricted argv that `ctx.sandbox.confine`
144
+ returns. The Windows ACL sandbox uses a `WRITE_RESTRICTED` token, and a token is **process-wide** — so
145
+ after that single confine, every later read and write in the session obeys the same workspace boundary as
146
+ the built-in `pwsh` tool.
147
+
148
+ Policy is resolved against the **real session at launch time** (`session` must be passed into
149
+ `sandboxPolicy.resolve`): the session's `sandbox/mode` wins, and the boundary root comes from the
150
+ session's `header.cwd`.
151
+
152
+ A trap worth knowing: passing the ToolExecution instead (`agent` lives on `exec.agent`) means `resolve`
153
+ never sees a session and **silently falls back to the deployment default** (mode + configured
154
+ `workspaceRoot`), which put the boundary on the profile directory — the symptom was "cannot write inside
155
+ the workspace, but can write to the profile directory". The `policy-carries-session` case in
156
+ `test/sandboxtest.mjs` pins that shape down.
157
+
158
+ Measured with `workspace-write`, cwd = the plugin directory: the workspace accepts writes; the drive root,
159
+ `C:\Users\<user>\`, `C:\Windows\Temp\`, `C:\ProgramData\` and the profile directory are all
160
+ refused. The script still runs in `FullLanguage`, and `$global:` plus cwd survive across calls.
161
+ Under `danger-full-access` nothing is confined, exactly like the built-in tool.
162
+
163
+ **`read-only` does not start.** In that mode pwsh runs in ConstrainedLanguage and cannot even create a
164
+ runspace. The tool reports that clearly and points at the built-in `pwsh` tool, which works there.
165
+
166
+ ## Updates
167
+
168
+ Left alone, the plugin upgrades itself: 90 seconds after start, then every 12 hours, it reads the release
169
+ tags, and a newer `vX.Y.Z` is installed on the spot. On a host that runs DSH's HMR service the new code is
170
+ **already live when the install returns** — no restart, no prompt, nothing for the user to do.
171
+
172
+ | How | What happens |
173
+ |---|---|
174
+ | 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. |
175
+ | `pwsh_run_update` `action: "check"` | Reads the tags once and reports `state` / `running` / `newest release` / `install spec`. Installs nothing. |
176
+ | `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). |
177
+
178
+ The install goes through `ctx.get('pluginManager')` — the service behind the Plugins page — which runs pnpm
179
+ and rolls `package.json` back on failure. The plugin never edits its own files: it is a bundle dependency
180
+ pinned in `package.json` and `pnpm-lock.yaml`, and writing files behind the package manager's back would
181
+ leave the lockfile lying.
182
+
183
+ **Why it can take effect without a restart.** A plugin's `apply` and any timer it starts run inside the HMR
184
+ service's transaction context. Calling `installBundle` from there runs pnpm and lands the new files, then
185
+ fails to recompose — `HMR transactions cannot be nested` — which leaves the worst possible state: new files
186
+ new files on disk, old modules running. Clearing the context first (`hmr.executing.run(undefined, …)`) lets the
187
+ recomposition happen, and because the freshly installed files on disk no longer match what is loaded, HMR
188
+ evicts the old modules and imports the new ones. Measured: 137 ms from the trigger to the new code answering.
189
+
190
+ **Why it looks before it installs.** DSH's plugin manager refuses, before running pnpm, an install whose spec
191
+ exactly matches what the profile already records — `already-installed`. Two situations reach that, and neither
192
+ should be attempted at all:
193
+
194
+ - Somebody already installed this version (a terminal `dsh plugin add`, the Plugins page) and the process was
195
+ not restarted yet. The files are current; a restart is all that is missing.
196
+ - A release was tagged without bumping `version` in `package.json`. The tag changes, the declared version does
197
+ not, so every attempt installs the same bytes and is refused the same way — a silent loop that never ends
198
+ and never says anything.
199
+
200
+ So before installing, the updater compares three facts it already has: the spec the profile records, the
201
+ version the installed package declares, and the version the running process reports. A version already on disk
202
+ reports `install: restart-required`; a tag whose package still declares the old version reports
203
+ `install: broken-release` and says so plainly. `scripts/release-check.mjs` catches the second case before the
204
+ tag is pushed; this check catches it afterwards, on every machine that tries to update.
205
+
206
+ **Why it waits for an idle moment.** An install replaces the plugin, and stopping the plugin disposes every
207
+ persistent session it owns. Done while a script is in flight, that is a power cut: half-written output, a call
208
+ that ends with no exit status, session state gone. So the updater asks the plugin whether any call is running
209
+ (same synchronous check-and-set the tool uses, so a call cannot slip in between), and postpones the whole
210
+ attempt when one is. Calls that arrive mid-install wait for it instead of racing it.
211
+
212
+ On a host **without** HMR the automatic path does nothing at all. Installing there would put the new files
213
+ on disk while the running process keeps the old modules — an automatic update that needs a restart is not
214
+ an update, it is a version number that lies. (`pwsh_run_update action: "install"` still installs in that
215
+ situation, because a human asked and can decide when to restart; the result says `restart-required`.)
216
+ Turning the automatic path off (`autoUpdate: false`) restores the older behaviour everywhere: check only,
217
+ and put one line at the end of the next `pwsh_run` output so a human decides.
218
+
219
+ Without `pluginManager` — a profile where the `plugin-manager` service is disabled — `install` reports
220
+ `no-manager` and you upgrade from a terminal instead:
221
+
222
+ ```powershell
223
+ $env:DSH_HOME = '<DSH_HOME>'
224
+ & '<desktop install>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'github:citydirector/dsh-pwsh-plugin#<tag>'
225
+ ```
226
+
227
+ Note that DSH's stock composition disables the *tool* `plugin_manager` while leaving the *service* running
228
+ for desktop profiles; the two are different things, and only the service is used here.
229
+
230
+ The default source is `github:citydirector/dsh-pwsh-plugin`, read via `GET /repos/<repo>/tags`. A tag must
231
+ match `^v\d+\.\d+\.\d+$` (prereleases are never offered), and the highest version wins.
232
+
233
+ | Environment variable | Effect |
234
+ |---|---|
235
+ | `DSH_PWSH_UPDATE_SOURCE` | `owner/repo` → GitHub; `git+<url>` → plain `git ls-remote --tags` (`git+file:///path` rehearses locally) |
236
+ | `DSH_PWSH_UPDATE_API` | GitHub API base, default `https://api.github.com` |
237
+ | `DSH_PWSH_UPDATE_DELAY_MS` | First check delay, default 5000 |
238
+
239
+ To rehearse locally, make a tagged repository and point the source at it:
240
+
241
+ ```powershell
242
+ $root = Join-Path $env:TEMP ('pwsh-rel-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
243
+ git init -q -b main $root
244
+ git -C $root -c user.email=t@l -c user.name=t commit -q --allow-empty -m release
245
+ git -C $root tag v0.2.1
246
+ $env:DSH_PWSH_UPDATE_SOURCE = 'git+file:///' + ($root -replace '\\','/')
247
+ node -e "import('./plugin/update-check.mjs').then(async (m) => console.log(JSON.stringify(await new m.UpdateChecker({ version: '0.1.0' }).check())))"
248
+ # -> {"state":"available","latest":"v0.2.1","checkedAt":"..."}
249
+ Remove-Item -Recurse -Force $root
250
+ ```
251
+
252
+ Rehearse on the **Windows** side (`pwsh_run` or the built-in `pwsh` tool). Exporting
253
+ `DSH_PWSH_UPDATE_SOURCE` from a WSL `bash` call never reaches a Windows process — WSL only forwards the
254
+ variables named in `WSLENV`, so every other `DSH_*` disappears and the plugin queries the default GitHub
255
+ repository instead. Crossing over needs `WSLENV=DSH_PWSH_UPDATE_SOURCE`; do **not** add `/p`, which would
256
+ rewrite `/tmp/...` into a `\\wsl.localhost\...` path and break it.
257
+
258
+ A failed query never throws into the tool call: it records `state: 'failed'`, logs one line through
259
+ `ctx.logger`, and keeps an earlier `latest` as `stale: 'available'` — one network hiccup should not make a
260
+ known new version vanish.
261
+
262
+ ## The `llrun` shim
263
+
264
+ The `bash` tool runs in WSL, where `/mnt/c` and `/mnt/d` are writable **anywhere**: 9p/drvfs sits outside
265
+ the DSH sandbox. `wsl/llrun` closes that with a user namespace, landlock (ABI 7, and a whitelist that
266
+ deliberately omits `/init` so no Windows executable can be launched) and a `/var/tmp` mount seal.
267
+ `wsl/custom-bash.mjs` wires it into the `bash` tool and picks the tier from the session's permission.
268
+
269
+ It needs no manual install: the binary ships inside the package and is the last probe candidate, so a fresh
270
+ `plugin add` is enough. Since releases are packed on Windows, that copy arrives as `0644` — the probe
271
+ restores the executable bit itself before giving up, as long as the file belongs to the current user.
272
+
273
+ Full description, the `-r` semantics table, the 54-assertion acceptance matrix and the wiring:
274
+ **[docs/llrun.md](docs/llrun.md)**. Run `bash wsl/acceptance.sh` from a plain WSL shell (not from inside
275
+ `custom-bash`, where nesting a second fence makes four assertions fail for the wrong reason) to re-verify on
276
+ your own machine — it derives every path from its own location and re-checks the disk for leaks.
277
+
278
+ ## Troubleshooting
279
+
280
+ | Symptom | Do this |
281
+ |---|---|
282
+ | `profile "desktop" is managed exclusively by the Electron application` | Use the desktop install's `dsh.cmd`, not the portable `dsh.exe`. |
283
+ | `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. |
284
+ | `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. |
285
+ | `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. |
286
+ | `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. |
287
+ | `refusing to start: read-only mode` | Expected. Use the built-in `pwsh` tool in read-only sessions. |
288
+ | 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. |
289
+ | Update notice never appears | The source env var must be set on the **Windows** side; WSL does not forward `DSH_*`. |
290
+ | Output looks reordered | stdout and stderr are merged by type, not by time. Merge them yourself with `2>&1`. |
291
+ | `exit N` code is not reported | Runspace semantics — use `Write-Output` or read `$LASTEXITCODE`. |
292
+ | 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. |
293
+ | `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. |
294
+
295
+ ## Known limits
296
+
297
+ - **`read-only` mode is unsupported** (see above); use the built-in `pwsh` tool there.
298
+ - **Refuses to start when the sandbox policy service is absent**, unless `allowUnconfined: true`.
299
+ - **A timeout kills the whole session** (and reaps its child process tree). State is lost; the next call
300
+ rebuilds automatically.
301
+ - **`exit N` exit codes are not observable** (runspace semantics).
302
+ - **stdout / stderr interleaving is not preserved** — everything goes through `*>&1` and is tagged by type.
303
+ - **`[Console]::Error.WriteLine` is swallowed** (the server points `Console.Error` at an in-memory stream so
304
+ the protocol cannot be polluted). A child process's stderr, `Write-Error` and `throw` all come back fine.
305
+ - **Child std handles are detached from the protocol.** `Console::SetIn/SetOut` only changes the .NET layer, so
306
+ the server also rewrites the three Win32 slots: stdin goes to `NUL`, stdout and stderr go to a
307
+ `FILE_FLAG_DELETE_ON_CLOSE` temp file truncated before every call. A grandchild that writes to inherited
308
+ stdout can no longer inject a fake protocol line (measured: one `Write-Output '<<<BYE>>>'` from a child used
309
+ to kill the session), and one that reads inherited stdin gets EOF instead of the caller's request.
310
+ The replacement handles are created **inheritable** (`SECURITY_ATTRIBUTES.bInheritHandle`): a handle made with
311
+ a `NULL` security descriptor only exists in this process, so a child that trusts `STARTUPINFO.hStdInput`
312
+ receives a bare handle *number* that means nothing over there — `GetFileType` reports `UNKNOWN`, and once in
313
+ a few dozen launches that number is recycled by an unrelated object and the read returns its bytes.
314
+ stdout deliberately does **not** go to `NUL`: that is a character device, PowerShell then believes its output
315
+ is not redirected and the sandbox denial changes into a useless `StandardOutputEncoding` message.
316
+ If the temp directory is unwritable it falls back to `NUL` and says so once, in a warning on the first call.
317
+ - **A session is serial**: concurrent calls queue up and run one at a time (no interleaved output, but no
318
+ parallel speedup either).
319
+ - **No WSL is required.** `pwsh_run` never touches WSL; only `wsl/custom-bash.mjs` does. Without a usable
320
+ `bashPath`, applying that preset still succeeds (the executable is resolved lazily) and the `bash` tool
321
+ fails per call with `command "<path>" is not an executable file` — a clear failure, not a silent
322
+ downgrade. The default is the bare name `bash`; when PATH cannot resolve it, the Windows build retries
323
+ `C:\Windows\System32\bash.exe` (the same WSL shim) before giving up. That fallback exists because a
324
+ process can inherit an environment block that predates a PATH edit and then lacks `System32`, which
325
+ otherwise costs you the whole tool. An explicit absolute `bashPath` is honoured alone — a typo there
326
+ should fail loudly rather than be silently redirected.
327
+ - **UAC behaves differently from this machine.** These measurements come from a box with UAC off, where the
328
+ process runs elevated. With UAC on, sessions run as a standard user, and the ACL sandbox needs the
329
+ workspace root's DACL to give you **`WRITE_DAC` and `WRITE_OWNER`** — full control satisfies both, because
330
+ the integrity label it writes lives in the SACL. A directory under your user profile inherits exactly
331
+ that; a directory created at a drive root only inherits `Modify`, and `Modify` alone fails with
332
+ `SetNamedSecurityInfoW failed (Win32 5)`. Ownership is not the deciding factor: `icacls <root> /grant
333
+ "%USERNAME%":(OI)(CI)F` fixes a root you do not own. Confinement fails closed with that message instead of
334
+ running unconfined.
335
+ - No interactive programs and no TTY.
336
+ - **An update replaces the session, but never cuts a call short.** Replacing a plugin stops it, and stopping
337
+ this plugin disposes its sessions, so the install that lands a new version leaves the persistent pwsh
338
+ process gone and the next call rebuilds it: anything held only in that session (variables, `Set-Location`,
339
+ imported modules) is lost. Measured across a v0.2.3 → v0.2.4 install: `$PID` changed from 24820 to 15632 and a
340
+ `$global:` variable set before the install read back empty. What the updater does **not** do is interrupt
341
+ work in flight: both the automatic loop and `pwsh_run_update action: "install"` ask the plugin whether a call
342
+ is running, and if one is, the install is postponed (the loop retries in 3 minutes, the tool reports
343
+ `install: postponed` and leaves the profile untouched) until the session is idle. Calls that arrive while an
344
+ install is in progress wait for it to finish instead of starting a session that is about to be disposed.
345
+ The version is worth more than the session state, but it is not "nothing happened" — do not hold
346
+ unrecoverable state in it across an upgrade.
347
+
348
+ ## Development
349
+
350
+ ```
351
+ plugin/
352
+ index.mjs tool registration, sandbox wiring, timeouts
353
+ session.mjs the session object: framing, queue, base64 encode, boot argv
354
+ session-server.ps1 the PowerShell side: runspace, stream tagging, sentinels
355
+ update-check.mjs release tag lookup and comparison
356
+ update-apply.mjs hands the pinned spec to pluginManager, never downloads
357
+ auto-update.mjs the automatic path: install outside the HMR transaction, back off on failure
358
+ test/
359
+ support.mjs locates a DSH install; converts Windows paths to /mnt form
360
+ itest.mjs protocol and session core
361
+ plugintest.mjs the whole plugin path against a mock context
362
+ e2e-real-subprocess.mjs real DSH subprocess runtime
363
+ sandboxtest.mjs real LocalSandboxProvider, boundary + fail-closed cases
364
+ bashtest.mjs custom-bash fence triage (mock runtime)
365
+ bash-e2e.mjs custom-bash end to end (real runtime + real llrun)
366
+ updatetest.mjs update check against a local git repository, install outcomes
367
+ autoupdatetest.mjs the automatic loop, its back-off, its idle gate, and the HMR-context escape it needs
368
+ wiringtest.mjs the update notice lands at the end of tool output, and installs wait for an idle session
369
+ denialtest.mjs the sandbox-denial hint fires on the right messages only
370
+ metatest.mjs the plugin-page metadata: locale, icon, and which document the page selects
371
+ wsl/
372
+ llrun.c llrun the C shim and its build
373
+ custom-bash.mjs the bash tool replacement the bundle wires in (Windows only)
374
+ acceptance.sh the 54-assertion matrix
375
+ docs/llrun.md the shim in full
376
+ FINDINGS.md every measurement and the reasoning behind the design
377
+ ```
378
+
379
+ ```bash
380
+ cd <checkout>
381
+ PWSH_BIN='pwsh' node test/itest.mjs # 17: protocol and session core
382
+ PWSH_BIN='pwsh' node test/plugintest.mjs # 15: full plugin path
383
+ node test/e2e-real-subprocess.mjs # 8: real DSH subprocess
384
+ node test/sandboxtest.mjs # 7: real sandbox boundary + fail-closed
385
+ node test/bashtest.mjs # 25: custom-bash fence triage, bash fallback, exec-bit repair
386
+ node test/bash-e2e.mjs # 11: custom-bash end to end
387
+ node test/updatetest.mjs # 96: update check, install outcomes, pre-flight, local git only
388
+ node test/autoupdatetest.mjs # 38: automatic install, back-off, idle gate, pre-flight, HMR-context escape
389
+ node test/wiringtest.mjs # 21: update notice wiring, idle gate, pre-flight wiring
390
+ node test/denialtest.mjs # 11: sandbox-denial hint (no spawn, runs anywhere)
391
+ node test/metatest.mjs # 23: plugin-page metadata (no spawn, runs anywhere)
392
+ ```
393
+
394
+ Eleven scripts, **272 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
395
+ DSH install to borrow `node_modules` from. Tests that need a DSH runtime locate one automatically
396
+ (`DSH_MODULES`, `DSH_MODULES_ASAR`, or by walking up from the working directory) and print a `SKIP` hint
397
+ instead of failing when none is found.
398
+
399
+ ### Releasing
400
+
401
+ The version in `package.json` is the one thing in this repository that is hard-coded, and the update check
402
+ compares it against the published tags. Tag a commit without bumping it and the check reports a newer
403
+ release forever: the installed files keep reading the old number, so every cycle reinstalls the same tag and
404
+ the back-off climbs to its ceiling. `npm version` writes the version, commits, and tags in one step, which is
405
+ why the three cannot drift apart:
406
+
407
+ ```powershell
408
+ npm version patch -m 'chore(release): %s' # bump, commit, annotated tag
409
+ git push origin main <tag>
410
+ ```
411
+
412
+ Run `node scripts/release-check.mjs` before publishing. It fails when the working tree is dirty, when HEAD
413
+ carries a tag that disagrees with `package.json`, when the tagged commit declares a different version, and
414
+ when you are about to re-point a tag that already exists (that would move an already-published version onto
415
+ new content). With no tag on HEAD it prints the exact name to create.
416
+
417
+ Use the Windows-side npm. In WSL, `/usr/local/bin/npm` resolves to a bun symlink and `npm version` fails
418
+ with `Script not found "version"`.
419
+
420
+ ## License
421
+
422
+ MIT — see [LICENSE](LICENSE).