dsh-pwsh-plugin 0.2.14 → 0.2.17

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/FINDINGS.md CHANGED
@@ -1822,3 +1822,94 @@ No authorization header was set for the request.
1822
1822
 
1823
1823
  `updatetest` 118 项、`autoupdatetest` 46 项、`wiringtest` 24 项全部改走这个假 registry。
1824
1824
 
1825
+ ## 三十四、把更新交回 DSH(2026-10-29)
1826
+
1827
+ ### 决定
1828
+
1829
+ 博士原话:「我们换成了 NPM 发版,使用 DSH 自带的版本管理和更新功能,就不再需要旧的自动更新机制。」
1830
+
1831
+ 于是 `plugin/update-check.mjs`、`plugin/update-apply.mjs`、`plugin/auto-update.mjs` 与对应的三份测试
1832
+ (`updatetest` 118 项、`autoupdatetest` 46 项、`wiringtest` 24 项)整体删除;`pwsh_run_update` 工具、
1833
+ `updateCheck` / `autoUpdate` / `autoUpdateDelayMs` 三个配置项、安装闸门(`activeRuns` / `installing` /
1834
+ `installGate` / `waitForInstall`)、输出末尾的更新提示一并去掉。`test/support.mjs` 里的假 registry
1835
+ (`startRegistry` / `packageDocument`)随之删除 —— 它只服务那三份测试。
1836
+
1837
+ ### 宿主这条路是完整的(源码逐行读过)
1838
+
1839
+ `@deepseek-ai/dsh-plugin-manager`:
1840
+
1841
+ | 方法 | 行为 |
1842
+ |---|---|
1843
+ | `listVersions(name)` | `askRegistries(name, ["versions","time","dist-tags"])` → `newerVersions(listing, installed)`,按 semver 过滤并倒排 |
1844
+ | `updateBundle(name, version)` | 先 `semver.gt(version, from)` 校验,再查 `["name","version","engines","peerDependencies","deprecated","dsh"]`,判 `facts.bundle` 与兼容性,最后 `installBundle` |
1845
+ | `updateRefusal(bundle, name)` | 能不能更新的全部条件 |
1846
+
1847
+ `updateRefusal` 的四道门,缺一不可:
1848
+
1849
+ 1. `bundle.installed`、`availability === 'profile'`、非 `official`、有 `source`;
1850
+ 2. 没有 `readOnlyReason`(带插件管理能力的包不可被更新);
1851
+ 3. `bundle.version` 是合法 semver;
1852
+ 4. `parsedForRegistry(bundle.source)` 的 `kind === 'registry'`、`name === bundle.name`、且 `range` 不含 `:`。
1853
+
1854
+ → **只有从 registry 装、且 source 是裸名才可更新。** `github:` / `file:` / git range 一律不行。我们 profile
1855
+ 现在记的是 `"dsh-pwsh-plugin": "0.2.14"`,四道门全过。
1856
+
1857
+ 客户端侧(`dsh-client-ui-plugin-manager`):`checkVersions` → `listVersions`;`runUpdate` → `updateBundle`;
1858
+ `pkg.updatable` 直接来自 `updateRefusal`。文案「可更新」「可更新到 {version}」「{count} 个插件可更新」
1859
+ 「将从 {from} 更新到 {to},期间插件可能短暂停止」「更新失败,已恢复到 {version}」都是现成的。
1860
+
1861
+ ### 拆掉之后仍然成立的三件事
1862
+
1863
+ - **`minimumReleaseAge` 仍然生效**,只是现在由宿主在装的时候撞上、由宿主在页面上说明。我们不再需要自己
1864
+ 回读磁盘、自己判 `cooling-down`。
1865
+ - **`already-installed` 的静默循环消失了。** 那个循环原本只存在于「插件自己按 tag 反复装同一个版本」这条
1866
+ 路径上;版本由人按、由页面驱动时,第一次就会被拒并如实显示。
1867
+ - **升级仍会换掉常驻会话。** 停掉插件就 dispose 所有会话,这一点与谁发起安装无关。README 的「已知边界」
1868
+ 保留了这条,只删掉「绝不打断在跑的调用」那半句 —— 那是我们自己加的闸门,现在没有执行者了。
1869
+
1870
+ ### 代价(诚实说明)
1871
+
1872
+ 宿主发起更新时不会先问有没有 `pwsh_run` 调用在跑。若正好有脚本在飞,升级会把它连同会话一起掐断,表现与
1873
+ 断电相同:输出写了一半、调用结束时拿不到状态。这条现在只能靠人看时机 —— 页面上的「期间插件可能短暂停止」
1874
+ 是宿主能给出的全部提示。
1875
+
1876
+ ### 删后的断言数
1877
+
1878
+ 十七份 → 八份,305 项 → **117 项**(itest 17 / plugintest 15 / e2e 8 / sandbox 7 / bash 25 / bash-e2e 11 /
1879
+ denial 11 / meta 23)。
1880
+ ## 三十五、WSL 里的 SSH agent:非交互 shell 不读 .bashrc(2026-10-30)
1881
+
1882
+ `bash` 工具跑的是 `bash -s`,非交互。Ubuntu 的默认 `~/.bashrc` 第 8-12 行有一句提前返回
1883
+ (`case $- in *i*) ;; *) return;; esac`),所以文件末尾那句 `eval $(wsl2-ssh-agent)` 永远执行不到,
1884
+ `SSH_AUTH_SOCK` 保持为空。在首行插一个 `echo` 标记即可确认:`bash -s` 的输出里没有它,
1885
+ `bash -i -s` 有。三条调用对照:`bash -i -s` 与 `bash -l -s`(经 `.profile`)有钥匙,`bash -s` 没有。
1886
+
1887
+ 围栏与此无关。python 直连 socket 的探测在裸 shell 和 llrun 内都返回 `CONNECTED`,`SO_PEERCRED` 的
1888
+ pid/uid/gid 相同,identities 请求回 541 字节的 type 12。四格矩阵(有无围栏 × 有无显式
1889
+ `SSH_AUTH_SOCK`)说明成败只取决于变量有没有给出来。
1890
+
1891
+ 围栏确实挡了一件事:它屏蔽 `/init`,于是 `wsl2-ssh-agent` 在围栏内起不来 —— 那个桥要调
1892
+ `powershell.exe` 去读 Windows 侧的 `\\.\pipe\openssh-ssh-agent`,在围栏内是 `Permission denied`。
1893
+ 所以守护进程只能在围栏外活,靠一个 systemd 用户单元持续拉起:
1894
+ `/home/lihao/.config/systemd/user/skadi-ssh-agent.service`,内容是
1895
+ `ExecStart=%h/.local/bin/wsl2-ssh-agent -foreground`、`ExecStartPre` 先删残留 socket、
1896
+ `Restart=on-failure`。实测四种保活方式里只有它稳:`eval "$(wsl2-ssh-agent)"` 与
1897
+ `setsid nohup ... &` 都在父 shell 退出后几十秒内消失,`systemd-run --user --scope` 直接不返回
1898
+ (占满超时),`--unit=... --collect` 的 PPID 是 1。
1899
+
1900
+ 插件侧的做法是给每条命令前置三行:
1901
+
1902
+ ```sh
1903
+ if [ -z "$SSH_AUTH_SOCK" ] && [ -S "$HOME/.ssh/wsl2-ssh-agent.sock" ]; then
1904
+ export SSH_AUTH_SOCK="$HOME/.ssh/wsl2-ssh-agent.sock"
1905
+ fi
1906
+ ```
1907
+
1908
+ 判据是 `-S`(真 socket)而不是 `-e`,且不覆盖用户自己设的值 —— 脚本内 `export` 仍然胜出,这条有断言
1909
+ 钉住。`sshAgentSocket: false` 关掉注入,其他字符串替换路径。端到端实测在围栏内拿到 8 把钥匙。
1910
+
1911
+ 顺带修掉一个实现缺陷:无围栏 + `workdir` 的分支原本用 `args.command` 重建脚本,会把前置探测丢掉;
1912
+ 现在基于已带前置的 `script` 再包 `cd`。断言 `agent-survives-workdir` 钉住这一条。
1913
+
1914
+ 计数因此从 117 涨到 **123**(bash 25 → 31)。`docs/llrun.md` 里的 25 与两份 README 的 117 同批改过。
1915
+
package/README.md CHANGED
@@ -68,7 +68,7 @@ bypasses the cache but neither npm nor pnpm will fetch that URL.
68
68
  $dsh = '<desktop install>\resources\runtime\cli\bin\dsh.cmd'
69
69
  $env:DSH_HOME = '<DSH_HOME>' # the data root the desktop app itself uses
70
70
 
71
- # <version> is any published version; the newest one is what the plugin itself would install
71
+ # <version> is any published version; the newest one is what the Plugins page offers
72
72
  & $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
73
73
  # or, from a local checkout
74
74
  & $dsh plugin --profile desktop add 'file:<path to checkout>'
@@ -119,8 +119,7 @@ The three files in `plugin/` (`index.mjs`, `session.mjs`, `session-server.ps1`)
119
119
 
120
120
  ## The tools
121
121
 
122
- `pwsh_run` runs a script. `pwsh_run_update` checks for and optionally installs a newer release of this
123
- plugin — see [Updates](#updates).
122
+ `pwsh_run` runs a script. Versions are managed by DSH itself — see [Updates](#updates).
124
123
 
125
124
  | Argument | Required | Meaning |
126
125
  |---|---|---|
@@ -144,9 +143,6 @@ When order matters, merge them yourself: `2>&1` or `*>&1`.
144
143
  | `maxSessions` | `4` | How many sessions may live at once (one per agent workspace). |
145
144
  | `maxOutputBytes` | `8388608` | Output collection cap per call (8 MB). Truncation is reported honestly, never silently. |
146
145
  | `allowUnconfined` | `false` | `true` = start a session even when the sandbox policy service is missing. |
147
- | `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. |
148
- | `autoUpdate` | `true` | Install a newer release by itself, and — where the host runs HMR — take effect without a restart. |
149
- | `autoUpdateDelayMs` | `90000` | Wait before the first automatic check, so start-up is not competing with pnpm. |
150
146
 
151
147
  `allowUnconfined` is the one deliberate escape hatch. In a normal deployment `ctx.sandboxPolicy` is always
152
148
  present; when it is missing the composition is incomplete, and the plugin **refuses to start** rather than
@@ -182,91 +178,34 @@ runspace. The tool reports that clearly and points at the built-in `pwsh` tool,
182
178
 
183
179
  ## Updates
184
180
 
185
- Left alone, the plugin upgrades itself: 90 seconds after start, then every 12 hours, it reads the `latest`
186
- 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
187
- **already live when the install returns** — no restart, no prompt, nothing for the user to do.
181
+ This plugin carries no updater of its own. It is an ordinary registry package, so **DSH manages its
182
+ version**: the Plugins page lists it, shows when a newer release exists, and installs it — the same
183
+ `listVersions` / `updateBundle` path used for every other plugin.
188
184
 
189
- | How | What happens |
190
- |---|---|
191
- | 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. |
192
- | `pwsh_run_update` `action: "check"` | Reads the registry once and reports `state` / `running` / `newest release` / `install spec`. Installs nothing. |
193
- | `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). |
194
-
195
- The install goes through `ctx.get('pluginManager')` — the service behind the Plugins page — which runs pnpm
196
- and rolls `package.json` back on failure. The plugin never edits its own files: it is a bundle dependency
197
- pinned in `package.json` and `pnpm-lock.yaml`, and writing files behind the package manager's back would
198
- leave the lockfile lying.
199
-
200
- **Why it can take effect without a restart.** A plugin's `apply` and any timer it starts run inside the HMR
201
- service's transaction context. Calling `installBundle` from there runs pnpm and lands the new files, then
202
- fails to recompose — `HMR transactions cannot be nested` — which leaves the worst possible state: new files
203
- new files on disk, old modules running. Clearing the context first (`hmr.executing.run(undefined, …)`) lets the
204
- recomposition happen, and because the freshly installed files on disk no longer match what is loaded, HMR
205
- evicts the old modules and imports the new ones. Measured: 137 ms from the trigger to the new code answering.
206
-
207
- **Why it looks before it installs.** DSH's plugin manager refuses, before running pnpm, an install whose spec
208
- exactly matches what the profile already records — `already-installed`. Two situations reach that, and neither
209
- should be attempted at all:
210
-
211
- - Somebody already installed this version (a terminal `dsh plugin add`, the Plugins page) and the process was
212
- not restarted yet. The files are current; a restart is all that is missing.
213
- - A release was tagged without bumping `version` in `package.json`. The tag changes, the declared version does
214
- not, so every attempt installs the same bytes and is refused the same way — a silent loop that never ends
215
- and never says anything.
216
-
217
- So before installing, the updater compares three facts it already has: the spec the profile records, the
218
- version the installed package declares, and the version the running process reports. A version already on disk
219
- reports `install: restart-required`; a tag whose package still declares the old version reports
220
- `install: broken-release` and says so plainly. `scripts/release-check.mjs` catches the second case before the
221
- tag is pushed; this check catches it afterwards, on every machine that tries to update.
222
-
223
- **Why it waits for an idle moment.** An install replaces the plugin, and stopping the plugin disposes every
224
- persistent session it owns. Done while a script is in flight, that is a power cut: half-written output, a call
225
- that ends with no exit status, session state gone. So the updater asks the plugin whether any call is running
226
- (same synchronous check-and-set the tool uses, so a call cannot slip in between), and postpones the whole
227
- attempt when one is. Calls that arrive mid-install wait for it instead of racing it.
228
-
229
- On a host **without** HMR the automatic path does nothing at all. Installing there would put the new files
230
- on disk while the running process keeps the old modules — an automatic update that needs a restart is not
231
- an update, it is a version number that lies. (`pwsh_run_update action: "install"` still installs in that
232
- situation, because a human asked and can decide when to restart; the result says `restart-required`.)
233
- Turning the automatic path off (`autoUpdate: false`) restores the older behaviour everywhere: check only,
234
- and put one line at the end of the next `pwsh_run` output so a human decides.
235
-
236
- Without `pluginManager` — a profile where the `plugin-manager` service is disabled — `install` reports
237
- `no-manager` and you upgrade from a terminal instead:
185
+ That path is available because the profile records a bare registry spec (`"dsh-pwsh-plugin": "0.2.14"`).
186
+ DSH refuses to update a bundle installed from anything else: a `github:` or `file:` source, a git range,
187
+ a package that is not a third-party dependency of this profile, or one that declares no semver version.
238
188
 
239
- ```powershell
240
- $env:DSH_HOME = '<DSH_HOME>'
241
- & '<desktop install>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
242
- ```
243
-
244
- Note that DSH's stock composition disables the *tool* `plugin_manager` while leaving the *service* running
245
- for desktop profiles; the two are different things, and only the service is used here.
189
+ Two consequences worth knowing:
246
190
 
247
- The source is the npm registry entry for this package: `GET https://registry.npmjs.org/dsh-pwsh-plugin`
248
- with `accept: application/vnd.npm.install-v1+json`. Its `dist-tags.latest` is the release to offer; it must
249
- be a plain `X.Y.Z`. A `latest` marked `deprecated` is never offered, and without a `dist-tags` the check
250
- falls back to the highest stable version in the version list. A `404` is reported as "not published on
251
- <registry>" rather than "no newer release" — those are different facts, and only one of them is temporary.
191
+ - **`minimumReleaseAge` still applies.** pnpm's supply-chain policy defaults to 1440 minutes, so a version
192
+ published today is not resolved today — pressing update installs the previous release, and the page says
193
+ which version it actually installed. This is the policy working, not a failure.
194
+ - **A version is only as good as its tag.** `package.json` is the one hard-coded version in this repository.
195
+ Publish a tag without bumping it and every update attempt installs the same bytes; `scripts/release-check.mjs`
196
+ fails the release workflow before that happens.
252
197
 
253
- The install spec is the registry form, `dsh-pwsh-plugin@X.Y.Z`, so what the plugin asks for and what
254
- `dsh plugin add` accepts are the same string.
255
-
256
- | Environment variable | Effect |
257
- |---|---|
258
- | `DSH_PWSH_UPDATE_REGISTRY` | Registry base URL, default `https://registry.npmjs.org`. Point it at a mirror, or at a local server to rehearse. |
259
- | `DSH_PWSH_UPDATE_DELAY_MS` | First check delay, default 5000 |
198
+ Updating by hand is the same operation DSH performs:
260
199
 
261
200
  ```powershell
262
- & $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
201
+ $env:DSH_HOME = '<DSH_HOME>'
202
+ & '<desktop install>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
263
203
  ```
264
204
 
265
- Installing by hand is the same operation the plugin performs, with the same policy applied by pnpm.
205
+ **An update replaces the persistent session.** Stopping the plugin disposes every session it owns, so after
206
+ an update the next call rebuilds the pwsh process: variables, `Set-Location` and imported modules held only
207
+ in that session are gone. Do not keep unrecoverable state in a session across an upgrade.
266
208
 
267
- A failed query never throws into the tool call: it records `state: 'failed'`, logs one line through
268
- `ctx.logger`, and keeps an earlier `latest` as `stale: 'available'` — one network hiccup should not make a
269
- known new version vanish.
270
209
 
271
210
  ## The `llrun` shim
272
211
 
@@ -279,6 +218,13 @@ It needs no manual install: the binary ships inside the package and is the last
279
218
  `plugin add` is enough. Since releases are packed on Windows, that copy arrives as `0644` — the probe
280
219
  restores the executable bit itself before giving up, as long as the file belongs to the current user.
281
220
 
221
+ The same row also injects `SSH_AUTH_SOCK`. `bash -s` is non-interactive, so the `eval $(wsl2-ssh-agent)`
222
+ line at the bottom of `~/.bashrc` never runs and the variable stays empty — the fence is not the cause, a
223
+ socket named explicitly works inside it just as well. A three-line probe exports
224
+ `$HOME/.ssh/wsl2-ssh-agent.sock` when it is a real socket and nothing is set yet; `sshAgentSocket: false`
225
+ turns it off. The agent daemon itself must live **outside** the fence (it needs `/init`), typically as a
226
+ systemd user unit.
227
+
282
228
  Full description, the `-r` semantics table, the 54-assertion acceptance matrix and the wiring:
283
229
  **[docs/llrun.md](docs/llrun.md)**. Run `bash wsl/acceptance.sh` from a plain WSL shell (not from inside
284
230
  `custom-bash`, where nesting a second fence makes four assertions fail for the wrong reason) to re-verify on
@@ -295,7 +241,6 @@ your own machine — it derives every path from its own location and re-checks t
295
241
  | `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. |
296
242
  | `refusing to start: read-only mode` | Expected. Use the built-in `pwsh` tool in read-only sessions. |
297
243
  | 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. |
298
- | Update notice never appears | The source env var must be set on the **Windows** side; WSL does not forward `DSH_*`. |
299
244
  | Output looks reordered | stdout and stderr are merged by type, not by time. Merge them yourself with `2>&1`. |
300
245
  | `exit N` code is not reported | Runspace semantics — use `Write-Output` or read `$LASTEXITCODE`. |
301
246
  | 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. |
@@ -303,56 +248,14 @@ your own machine — it derives every path from its own location and re-checks t
303
248
 
304
249
  ## Known limits
305
250
 
306
- - **`read-only` mode is unsupported** (see above); use the built-in `pwsh` tool there.
307
- - **Refuses to start when the sandbox policy service is absent**, unless `allowUnconfined: true`.
308
- - **A timeout kills the whole session** (and reaps its child process tree). State is lost; the next call
309
- rebuilds automatically.
310
- - **`exit N` exit codes are not observable** (runspace semantics).
311
- - **stdout / stderr interleaving is not preserved** — everything goes through `*>&1` and is tagged by type.
312
- - **`[Console]::Error.WriteLine` is swallowed** (the server points `Console.Error` at an in-memory stream so
313
- the protocol cannot be polluted). A child process's stderr, `Write-Error` and `throw` all come back fine.
314
- - **Child std handles are detached from the protocol.** `Console::SetIn/SetOut` only changes the .NET layer, so
315
- the server also rewrites the three Win32 slots: stdin goes to `NUL`, stdout and stderr go to a
316
- `FILE_FLAG_DELETE_ON_CLOSE` temp file truncated before every call. A grandchild that writes to inherited
317
- stdout can no longer inject a fake protocol line (measured: one `Write-Output '<<<BYE>>>'` from a child used
318
- to kill the session), and one that reads inherited stdin gets EOF instead of the caller's request.
319
- The replacement handles are created **inheritable** (`SECURITY_ATTRIBUTES.bInheritHandle`): a handle made with
320
- a `NULL` security descriptor only exists in this process, so a child that trusts `STARTUPINFO.hStdInput`
321
- receives a bare handle *number* that means nothing over there — `GetFileType` reports `UNKNOWN`, and once in
322
- a few dozen launches that number is recycled by an unrelated object and the read returns its bytes.
323
- stdout deliberately does **not** go to `NUL`: that is a character device, PowerShell then believes its output
324
- is not redirected and the sandbox denial changes into a useless `StandardOutputEncoding` message.
325
- If the temp directory is unwritable it falls back to `NUL` and says so once, in a warning on the first call.
326
- - **A session is serial**: concurrent calls queue up and run one at a time (no interleaved output, but no
327
- parallel speedup either).
328
- - **No WSL is required.** `pwsh_run` never touches WSL; only `wsl/custom-bash.mjs` does. Without a usable
329
- `bashPath`, applying that preset still succeeds (the executable is resolved lazily) and the `bash` tool
330
- fails per call with `command "<path>" is not an executable file` — a clear failure, not a silent
331
- downgrade. The default is the bare name `bash`; when PATH cannot resolve it, the Windows build retries
332
- `C:\Windows\System32\bash.exe` (the same WSL shim) before giving up. That fallback exists because a
333
- process can inherit an environment block that predates a PATH edit and then lacks `System32`, which
334
- otherwise costs you the whole tool. An explicit absolute `bashPath` is honoured alone — a typo there
335
- should fail loudly rather than be silently redirected.
336
- - **UAC behaves differently from this machine.** These measurements come from a box with UAC off, where the
337
- process runs elevated. With UAC on, sessions run as a standard user, and the ACL sandbox needs the
338
- workspace root's DACL to give you **`WRITE_DAC` and `WRITE_OWNER`** — full control satisfies both, because
339
- the integrity label it writes lives in the SACL. A directory under your user profile inherits exactly
340
- that; a directory created at a drive root only inherits `Modify`, and `Modify` alone fails with
341
- `SetNamedSecurityInfoW failed (Win32 5)`. Ownership is not the deciding factor: `icacls <root> /grant
342
- "%USERNAME%":(OI)(CI)F` fixes a root you do not own. Confinement fails closed with that message instead of
343
- running unconfined.
344
- - No interactive programs and no TTY.
345
- - **An update replaces the session, but never cuts a call short.** Replacing a plugin stops it, and stopping
346
- this plugin disposes its sessions, so the install that lands a new version leaves the persistent pwsh
347
- process gone and the next call rebuilds it: anything held only in that session (variables, `Set-Location`,
348
- imported modules) is lost. Measured across a v0.2.3 → v0.2.4 install: `$PID` changed from 24820 to 15632 and a
349
- `$global:` variable set before the install read back empty. What the updater does **not** do is interrupt
350
- work in flight: both the automatic loop and `pwsh_run_update action: "install"` ask the plugin whether a call
351
- is running, and if one is, the install is postponed (the loop retries in 3 minutes, the tool reports
352
- `install: postponed` and leaves the profile untouched) until the session is idle. Calls that arrive while an
353
- install is in progress wait for it to finish instead of starting a session that is about to be disposed.
354
- The version is worth more than the session state, but it is not "nothing happened" — do not hold
355
- unrecoverable state in it across an upgrade.
251
+ The full list — session semantics, detached child std handles, UAC differences, and what an update does to a
252
+ session — lives in **[docs/runtime-notes.md](docs/runtime-notes.md)**. The short version:
253
+
254
+ - `read-only` mode is unsupported; use the built-in `pwsh` there.
255
+ - A timeout kills the whole session and reaps its child process tree.
256
+ - `exit N` codes and stdout/stderr interleaving are not observable.
257
+ - A session is serial, and there is no TTY.
258
+ - An update replaces the session; anything held only in it is lost.
356
259
 
357
260
  ## Development
358
261
 
@@ -361,9 +264,6 @@ plugin/
361
264
  index.mjs tool registration, sandbox wiring, timeouts
362
265
  session.mjs the session object: framing, queue, base64 encode, boot argv
363
266
  session-server.ps1 the PowerShell side: runspace, stream tagging, sentinels
364
- update-check.mjs registry lookup and version comparison
365
- update-apply.mjs hands the pinned spec to pluginManager, never downloads
366
- auto-update.mjs the automatic path: install outside the HMR transaction, back off on failure
367
267
  test/
368
268
  support.mjs locates a DSH install; converts Windows paths to /mnt form
369
269
  itest.mjs protocol and session core
@@ -372,9 +272,6 @@ test/
372
272
  sandboxtest.mjs real LocalSandboxProvider, boundary + fail-closed cases
373
273
  bashtest.mjs custom-bash fence triage (mock runtime)
374
274
  bash-e2e.mjs custom-bash end to end (real runtime + real llrun)
375
- updatetest.mjs update check against a local registry, install outcomes
376
- autoupdatetest.mjs the automatic loop, its back-off, its idle gate, and the HMR-context escape it needs
377
- wiringtest.mjs the update notice lands at the end of tool output, and installs wait for an idle session
378
275
  denialtest.mjs the sandbox-denial hint fires on the right messages only
379
276
  metatest.mjs the plugin-page metadata: locale, icon, and which document the page selects
380
277
  wsl/
@@ -382,6 +279,7 @@ wsl/
382
279
  custom-bash.mjs the bash tool replacement the bundle wires in (Windows only)
383
280
  acceptance.sh the 54-assertion matrix
384
281
  docs/llrun.md the shim in full
282
+ docs/runtime-notes.md the full list of known limits, kept out of this page
385
283
  FINDINGS.md every measurement and the reasoning behind the design
386
284
  ```
387
285
 
@@ -391,16 +289,13 @@ PWSH_BIN='pwsh' node test/itest.mjs # 17: protocol and session core
391
289
  PWSH_BIN='pwsh' node test/plugintest.mjs # 15: full plugin path
392
290
  node test/e2e-real-subprocess.mjs # 8: real DSH subprocess
393
291
  node test/sandboxtest.mjs # 7: real sandbox boundary + fail-closed
394
- node test/bashtest.mjs # 25: custom-bash fence triage, bash fallback, exec-bit repair
292
+ node test/bashtest.mjs # 31: custom-bash fence triage, bash fallback, exec-bit repair
395
293
  node test/bash-e2e.mjs # 11: custom-bash end to end
396
- node test/updatetest.mjs # 96: update check, install outcomes, pre-flight, local git only
397
- node test/autoupdatetest.mjs # 38: automatic install, back-off, idle gate, pre-flight, HMR-context escape
398
- node test/wiringtest.mjs # 21: update notice wiring, idle gate, pre-flight wiring
399
294
  node test/denialtest.mjs # 11: sandbox-denial hint (no spawn, runs anywhere)
400
295
  node test/metatest.mjs # 23: plugin-page metadata (no spawn, runs anywhere)
401
296
  ```
402
297
 
403
- Eleven scripts, **305 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
298
+ Eight scripts, **123 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
404
299
  DSH install to borrow `node_modules` from. Tests that need a DSH runtime locate one automatically
405
300
  (`DSH_MODULES`, `DSH_MODULES_ASAR`, or by walking up from the working directory) and print a `SKIP` hint
406
301
  instead of failing when none is found.
@@ -414,11 +309,10 @@ the runner's short-lived GitHub identity for a token scoped to this repository a
414
309
  part of the trust contract (rename it and every publish fails with 403 until the npm setting is updated), and
415
310
  provenance is mandatory there, so the tarball carries a signed statement of which commit built it.
416
311
 
417
- The version in `package.json` is the one thing in this repository that is hard-coded, and the update check
418
- compares it against the published version. Publish without bumping it and the check reports a newer
419
- release forever: the installed files keep reading the old number, so every cycle requests the same version
420
- and the back-off climbs to its ceiling. `npm version` writes the version, commits, and tags in one step, which is
421
- why the three cannot drift apart:
312
+ The version in `package.json` is the one thing in this repository that is hard-coded, and it is what DSH
313
+ compares against the published version. Publish a tag without bumping it and every update attempt installs
314
+ the same bytes: the page keeps offering the "new" version, pnpm keeps landing the old one. `npm version`
315
+ writes the version, commits, and tags in one step, which is why the three cannot drift apart:
422
316
 
423
317
  ```powershell
424
318
  npm version patch -m 'chore(release): %s' # bump, commit, annotated tag
@@ -435,4 +329,4 @@ with `Script not found "version"`.
435
329
 
436
330
  ## License
437
331
 
438
- MIT — see [LICENSE](LICENSE).
332
+ MIT — see [LICENSE](LICENSE).