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 +91 -0
- package/README.md +44 -150
- package/README.zh-CN.md +35 -125
- package/cordis.patch.yml +4 -0
- package/docs/llrun.md +21 -1
- package/docs/runtime-notes.md +57 -0
- package/docs/runtime-notes.zh-CN.md +45 -0
- package/docs/update-audit.md +17 -0
- package/package.json +3 -5
- package/plugin/index.mjs +3 -201
- package/wsl/custom-bash.mjs +47 -2
- package/plugin/auto-update.mjs +0 -210
- package/plugin/update-apply.mjs +0 -274
- package/plugin/update-check.mjs +0 -270
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
|
|
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.
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
-
|
|
311
|
-
-
|
|
312
|
-
-
|
|
313
|
-
|
|
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 #
|
|
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
|
-
|
|
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
|
|
418
|
-
compares
|
|
419
|
-
|
|
420
|
-
|
|
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).
|