dsh-pwsh-plugin 0.2.14 → 0.2.16

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,59 @@ 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
+
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:
238
-
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
- ```
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.
243
188
 
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
 
@@ -295,7 +234,6 @@ your own machine — it derives every path from its own location and re-checks t
295
234
  | `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
235
  | `refusing to start: read-only mode` | Expected. Use the built-in `pwsh` tool in read-only sessions. |
297
236
  | 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
237
  | Output looks reordered | stdout and stderr are merged by type, not by time. Merge them yourself with `2>&1`. |
300
238
  | `exit N` code is not reported | Runspace semantics — use `Write-Output` or read `$LASTEXITCODE`. |
301
239
  | 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 +241,14 @@ your own machine — it derives every path from its own location and re-checks t
303
241
 
304
242
  ## Known limits
305
243
 
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.
244
+ The full list — session semantics, detached child std handles, UAC differences, and what an update does to a
245
+ session — lives in **[docs/runtime-notes.md](docs/runtime-notes.md)**. The short version:
246
+
247
+ - `read-only` mode is unsupported; use the built-in `pwsh` there.
248
+ - A timeout kills the whole session and reaps its child process tree.
249
+ - `exit N` codes and stdout/stderr interleaving are not observable.
250
+ - A session is serial, and there is no TTY.
251
+ - An update replaces the session; anything held only in it is lost.
356
252
 
357
253
  ## Development
358
254
 
@@ -361,9 +257,6 @@ plugin/
361
257
  index.mjs tool registration, sandbox wiring, timeouts
362
258
  session.mjs the session object: framing, queue, base64 encode, boot argv
363
259
  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
260
  test/
368
261
  support.mjs locates a DSH install; converts Windows paths to /mnt form
369
262
  itest.mjs protocol and session core
@@ -372,9 +265,6 @@ test/
372
265
  sandboxtest.mjs real LocalSandboxProvider, boundary + fail-closed cases
373
266
  bashtest.mjs custom-bash fence triage (mock runtime)
374
267
  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
268
  denialtest.mjs the sandbox-denial hint fires on the right messages only
379
269
  metatest.mjs the plugin-page metadata: locale, icon, and which document the page selects
380
270
  wsl/
@@ -382,6 +272,7 @@ wsl/
382
272
  custom-bash.mjs the bash tool replacement the bundle wires in (Windows only)
383
273
  acceptance.sh the 54-assertion matrix
384
274
  docs/llrun.md the shim in full
275
+ docs/runtime-notes.md the full list of known limits, kept out of this page
385
276
  FINDINGS.md every measurement and the reasoning behind the design
386
277
  ```
387
278
 
@@ -393,14 +284,11 @@ node test/e2e-real-subprocess.mjs # 8: real DSH subprocess
393
284
  node test/sandboxtest.mjs # 7: real sandbox boundary + fail-closed
394
285
  node test/bashtest.mjs # 25: custom-bash fence triage, bash fallback, exec-bit repair
395
286
  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
287
  node test/denialtest.mjs # 11: sandbox-denial hint (no spawn, runs anywhere)
400
288
  node test/metatest.mjs # 23: plugin-page metadata (no spawn, runs anywhere)
401
289
  ```
402
290
 
403
- Eleven scripts, **305 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
291
+ Eight scripts, **117 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
404
292
  DSH install to borrow `node_modules` from. Tests that need a DSH runtime locate one automatically
405
293
  (`DSH_MODULES`, `DSH_MODULES_ASAR`, or by walking up from the working directory) and print a `SKIP` hint
406
294
  instead of failing when none is found.
@@ -414,11 +302,10 @@ the runner's short-lived GitHub identity for a token scoped to this repository a
414
302
  part of the trust contract (rename it and every publish fails with 403 until the npm setting is updated), and
415
303
  provenance is mandatory there, so the tarball carries a signed statement of which commit built it.
416
304
 
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:
305
+ The version in `package.json` is the one thing in this repository that is hard-coded, and it is what DSH
306
+ compares against the published version. Publish a tag without bumping it and every update attempt installs
307
+ the same bytes: the page keeps offering the "new" version, pnpm keeps landing the old one. `npm version`
308
+ writes the version, commits, and tags in one step, which is why the three cannot drift apart:
422
309
 
423
310
  ```powershell
424
311
  npm version patch -m 'chore(release): %s' # bump, commit, annotated tag
@@ -435,4 +322,4 @@ with `Script not found "version"`.
435
322
 
436
323
  ## License
437
324
 
438
- MIT — see [LICENSE](LICENSE).
325
+ MIT — see [LICENSE](LICENSE).
package/README.zh-CN.md CHANGED
@@ -60,7 +60,7 @@
60
60
  $dsh = '<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd'
61
61
  $env:DSH_HOME = '<DSH_HOME>' # 桌面 App 自己用的那个数据根
62
62
 
63
- # <version> 是任意已发布版本;最新的那个就是插件自己会装的那个
63
+ # <version> 是任意已发布版本;最新的那个就是插件页会给出的那个
64
64
  & $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
65
65
  # 或者从本地检出装
66
66
  & $dsh plugin --profile desktop add 'file:<检出目录>'
@@ -104,7 +104,7 @@ $env:DSH_HOME = '<DSH_HOME>' # 桌面 App 自己用的那个数据根
104
104
 
105
105
  ## 工具接口
106
106
 
107
- `pwsh_run` 跑脚本;`pwsh_run_update` 查更新、必要时安装本插件的新版本 —— 见[自动更新检查](#自动更新检查)。
107
+ `pwsh_run` 跑脚本。版本由 DSH 自己管 —— 见[更新](#更新)。
108
108
 
109
109
  | 参数 | 必填 | 说明 |
110
110
  |---|---|---|
@@ -127,9 +127,6 @@ $env:DSH_HOME = '<DSH_HOME>' # 桌面 App 自己用的那个数据根
127
127
  | `maxSessions` | `4` | 同时存活的会话上限(每个 agent 工作区一个)。 |
128
128
  | `maxOutputBytes` | `8388608` | 单次调用的输出收集上限(8MB)。截断会明确报出来,不静默。 |
129
129
  | `allowUnconfined` | `false` | `true` = 沙箱策略服务缺席时也起会话。 |
130
- | `updateCheck` | `true` | 定期查有没有新发布的版本。查与装分开:这里只查,装由 `pwsh_run_update` 与自动更新做。 |
131
- | `autoUpdate` | `true` | 自己装上更新,宿主有 HMR 时**不需要重启**即生效。 |
132
- | `autoUpdateDelayMs` | `90000` | 自动检查前的等待,避免与启动流程抢 pnpm。 |
133
130
 
134
131
  `allowUnconfined` 是唯一一条「明知故犯」的开关。正常部署下 `ctx.sandboxPolicy` 一定在;缺席说明组合
135
132
  不完整,这时插件**拒绝启动**,而不是悄悄跑一个不受约束的会话。
@@ -160,81 +157,29 @@ session,**静默退回部署默认值**(mode + 配置里的 `workspaceRoot`
160
157
 
161
158
  ## 更新
162
159
 
163
- 默认情况下插件自己升级:启动 90 秒后查一次 npm registry 的 `latest` 标签,之后每 12 小时一次;查到
164
- 更高的版本就直接装上。宿主跑着 DSH 的 HMR 服务时,**装上的那一刻新代码就已经在跑** —— 不用重启,不用弹窗,用户什么
165
- 都不用做。
160
+ 本插件**没有自己的更新器**。它就是一个普通的 registry 包,所以**版本交给 DSH 管**:「插件」页面会列出它、
161
+ 标出有没有更新的版本、并按与其它插件相同的 `listVersions` / `updateBundle` 路径装上。
166
162
 
167
- | 方式 | 行为 |
168
- |---|---|
169
- | 自动(`autoUpdate: true`,默认) | 查 → 装 → 就地生效。装失败按指数退避重试(5 分钟起、翻倍、封顶 2 小时);装成功过的版本不再重试。**有调用在跑时绝不装** —— 3 分钟后再看。宿主**没有** HMR 时它什么都不装,见下。 |
170
- | `pwsh_run_update` 的 `action: "check"` | 只读一次 registry,报出 `state` / `running` / `newest release` / `install spec`。不装任何东西。 |
171
- | `pwsh_run_update` 的 `action: "install"` | 同一条安装路径,只是由人按下去而不是由时钟按下去。有调用在跑时报 `install: postponed`;预检判定「装了也没用」时报 `install: restart-required` / `install: broken-release`(见下)。 |
172
-
173
- 安装走 `ctx.get('pluginManager')` —— 也就是「插件」页面背后那个服务 —— 由它跑 pnpm,失败时回滚
174
- `package.json`。本插件**从不自己改自己的文件**:它是 profile 的 bundle 依赖,版本钉在
175
- `package.json` 与 `pnpm-lock.yaml` 里,绕开包管理器写文件会立刻让 lockfile 与磁盘对不上。
176
-
177
- **为什么能不重启就生效。** 插件的 `apply` 以及它起的定时器,都跑在 HMR 服务的事务上下文里
178
- (`AsyncLocalStorage`)。在那里直接调 `installBundle`,pnpm 会跑完、磁盘会换成新版,紧接着的重组合
179
- 却被拒掉(`HMR transactions cannot be nested`),于是停在最难看清的状态:磁盘是新的、进程里是旧的。
180
- 先把上下文清空(`hmr.executing.run(undefined, …)`)再调,重组合才会真的发生;而磁盘上新装的文件与已
181
- 加载的模块已经对不上,HMR 于是驱逐旧模块、import 新模块。实测:从触发到新代码应答 137 ms。
182
-
183
- **为什么先看一眼再装。** DSH 的插件管理器在跑 pnpm **之前**就会拒掉一类安装:要装的规格与 profile
184
- 里为该包记下的规格完全一致 —— 也就是 `already-installed`。有两种情形会走到这一步,而这两种本来就不该
185
- 发起安装:
186
-
187
- - 这个版本已经装好了(终端里的 `dsh plugin add`、或者「插件」页面),只是进程还没重启。文件是新的,
188
- 缺的只是一次重启。
189
- - 发版时忘了把 `package.json` 里的 `version` 抬上去。标签变了、声明的版本没变,于是每一次尝试装的都是
190
- 同一份字节、被同一句话拒掉 —— 一个永不结束、也从不吭声的循环。
191
-
192
- 所以安装之前,更新器先比对手上已有的三个事实:profile 记下的规格、已装包自己声明的版本、运行中的进程
193
- 报出的版本。磁盘上已经是新版的报 `install: restart-required`;标签存在但包内版本没抬的报
194
- `install: broken-release` 并直说原因。`scripts/release-check.mjs` 在推标签**之前**拦第二种,运行时这道
195
- 预检则在**之后**、在每一台试图升级的机器上再拦一次。
196
-
197
- **为什么非要等安静。** 安装会替换插件,而停掉本插件就要 dispose 掉它所有的常驻会话。若此刻有脚本正在
198
- 跑,那与直接断电无异:输出写了一半、调用结束时拿不到状态、会话状态全失。所以更新器先问插件「现在有没有
199
- 调用在跑」(与工具本身用同一套同步的检查加置位,中途塞不进新调用),有就整轮推迟。安装进行中到达的调用
200
- 则等它装完,而不是跟它抢。
201
-
202
- 宿主**没有** HMR 时,自动路径什么都不做。那种环境装下去只会「磁盘是新版、进程是旧版」—— 需要重启的
203
- 自动更新不算更新,只是一个会说谎的版本号。(`pwsh_run_update` 的 `install` 在这种情况下照样装,因为
204
- 是人按的、人能决定什么时候重启;结果里会诚实地写 `restart-required`。)
205
- 把自动路径关掉(`autoUpdate: false`)则在任何宿主上都回到旧行为:只查不装,在下次 `pwsh_run` 输出末尾
206
- 挂一行,让人来定。
207
-
208
- 若 profile 关掉了 `plugin-manager` 服务,`install` 会报 `no-manager`,改用终端升级:
209
-
210
- ```powershell
211
- $env:DSH_HOME = '<DSH_HOME>'
212
- & '<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
213
- ```
163
+ 这条路成立的前提是 profile 里记的是裸 registry 规格(`"dsh-pwsh-plugin": "0.2.14"`)。换成别的来源就更新不了:
164
+ `github:` / `file:` / git range、不是本 profile 的第三方依赖、或者本身不声明 semver 版本,DSH 都会拒。
214
165
 
215
- 注意 DSH 的默认组合关掉的是**工具** `plugin_manager`,而桌面 profile 上的**服务** `plugin-manager`
216
- 是活的 —— 两者不是一回事,这里只用服务。
166
+ 两件值得知道的事:
217
167
 
218
- 发布源就是这个包在 npm registry 上的条目:`GET https://registry.npmjs.org/dsh-pwsh-plugin`,请求头带
219
- `accept: application/vnd.npm.install-v1+json`。它的 `dist-tags.latest` 就是要推的版本,必须是纯
220
- `X.Y.Z`;被标了 `deprecated` 的 `latest` 永不推送;没有 `dist-tags` 时回落到版本清单里的最高正式版。
221
- 返回 `404` 时报的是「not published on <registry>」而不是「没有新版本」—— 这两件事不同,只有一件是临时的。
168
+ - **`minimumReleaseAge` 照样生效。** pnpm 的供应链策略默认 1440 分钟,今天发布的版本今天解析不到 ——
169
+ 点「更新」会装到上一个版本,页面上会写清实际装的是哪个。这是策略在起作用,不是出错。
170
+ - **版本号与标签必须对齐。** `package.json` 是本仓库唯一写死的版本;打了标签却没抬版本号,每一次更新装的
171
+ 都是同一份字节。`scripts/release-check.mjs` 会在 workflow 里先把这种情况拦下来。
222
172
 
223
- 安装规格用 registry 形式 `dsh-pwsh-plugin@X.Y.Z`,所以插件请求的字符串与 `dsh plugin add` 接受的是同一个。
224
-
225
- | 环境变量 | 作用 |
226
- |---|---|
227
- | `DSH_PWSH_UPDATE_REGISTRY` | registry 基址,默认 `https://registry.npmjs.org`。可指向镜像,或指向本机服务做预演 |
228
- | `DSH_PWSH_UPDATE_DELAY_MS` | 首次检查延迟,默认 5000 |
173
+ 手动更新与 DSH 做的是同一个操作:
229
174
 
230
175
  ```powershell
231
- & $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
176
+ $env:DSH_HOME = '<DSH_HOME>'
177
+ & '<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
232
178
  ```
233
179
 
234
- 手动安装与插件自己做的事是同一条路径,同样由 pnpm 施加同样的策略。
180
+ **更新会换掉常驻会话。** 停掉插件就要 dispose 掉它所有的常驻会话,所以升级后下一次调用是重建的 pwsh 进程:
181
+ 只存在那个会话里的变量、`Set-Location`、已导入模块都会丢。不要把它当作跨升级保存状态的地方。
235
182
 
236
- 查询失败不会抛进工具调用:记 `state: 'failed'`,经 `ctx.logger` 留一行,先前查到过的 `latest` 保留成
237
- `stale: 'available'` —— 一次网络抖动不该让已知的新版本消失。
238
183
 
239
184
  ## `llrun` 垫片
240
185
 
@@ -261,7 +206,6 @@ Windows 上打,那份文件到手时是 `0644` —— 探测发现它属于当
261
206
  | `refusing to start: read-only mode` | 预期行为。read-only 会话里用内置 `pwsh` 工具。 |
262
207
  | `could not confine the session: SetNamedSecurityInfoW failed (Win32 5)` | 工作区根目录的 DACL 没给你 `WRITE_DAC` + `WRITE_OWNER`(沙箱要在那里写 DACL 与完整性标签)。全控制权限两样都满足,`Modify` 不够。执行 `icacls "<根目录>" /grant "%USERNAME%":(OI)(CI)F`,或从用户目录下的目录起会话。 |
263
208
  | 内置工具能写的地方本工具写不进 | 确认 `session` 真的传到了 `sandboxPolicy.resolve`(见「沙箱」);漏传会把边界静默挪到 profile 目录。 |
264
- | 永远看不到更新提示 | 源环境变量必须在 **Windows** 侧设;WSL 不透传 `DSH_*`。 |
265
209
  | 输出顺序看着不对 | stdout 与 stderr 按类型合并,不按时间。自己用 `2>&1` 合。 |
266
210
  | `exit N` 的退出码拿不到 | runspace 语义 —— 用 `Write-Output` 或读 `$LASTEXITCODE`。 |
267
211
  | 调用结束后子进程还占着文件 | 会话持有 `Process` 对象。等 `idleMs`,或者用一次超时强制重建。 |
@@ -269,44 +213,14 @@ Windows 上打,那份文件到手时是 `0644` —— 探测发现它属于当
269
213
 
270
214
  ## 已知边界
271
215
 
272
- - **不支持 `read-only` 模式**(见上),用内置 `pwsh`。
273
- - **沙箱策略服务缺席时拒绝启动**,除非 `allowUnconfined: true`。
274
- - **超时会杀掉整个会话**(连带回收子进程树)。状态丢失,下次调用自动重建。
275
- - **`exit N` 的退出码不可观测**(runspace 语义)。
276
- - **stdout / stderr 的逐行时序不保留** —— 全部流经 `*>&1` 合并后按类型打标。
277
- - **`[Console]::Error.WriteLine` 会被吞掉**(服务端为防协议污染把 `Console.Error` 指向内存流)。
278
- 子进程的 stderr、`Write-Error`、`throw` 都正常回来。
279
- - **子进程的 std handle 已与协议流断开。** `Console::SetIn/SetOut` 只改 .NET 层,服务端还改写了三个 Win32 槽位:
280
- stdin 接 `NUL`,stdout / stderr 接一个带 `FILE_FLAG_DELETE_ON_CLOSE` 的临时文件、每次调用前截回零。
281
- 于是子进程往继承来的 stdout 写东西不会再伪造出协议行(实测:子进程一句 `Write-Output '<<<BYE>>>'`
282
- 曾让会话当场死亡),读继承来的 stdin 也只会读到 EOF,而不是调用方的请求。
283
- 替上去的句柄必须是**可继承**的(`SECURITY_ATTRIBUTES.bInheritHandle`):用 `NULL` 安全描述符建的句柄
284
- 只存在于本进程,子进程按 `STARTUPINFO.hStdInput` 拿到的只是一个在那边没有意义的句柄**编号** ——
285
- `GetFileType` 报 `UNKNOWN`,几十次里还有一次那个编号被别的对象复用,读出来是别人的字节。
286
- stdout **故意不接 `NUL`**:那是字符设备,PowerShell 会认为输出没被重定向,沙箱拒绝的报错会变成一句
287
- 没用的 `StandardOutputEncoding` 提示。临时目录不可写时退回 `NUL`,并在第一次调用的警告里说明一次。
288
- - **会话是串行的**:并发调用排成队列逐个执行(不会串输出,但也没有并行加速)。
289
- - **不依赖 WSL。** `pwsh_run` 全程不碰 WSL,只有 `wsl/custom-bash.mjs` 才碰。没有可用的 `bashPath` 时,
290
- 该预设照样加载成功(可执行文件是惰性解析的),`bash` 工具每次调用报
291
- `command "<路径>" is not an executable file` —— 清晰失败,不会静默降级。默认值是裸名 `bash`;
292
- PATH 解不出来时,Windows 上会再试 `C:\Windows\System32\bash.exe`(同一个 WSL 垫片)才放弃。
293
- 设这道后备是因为进程可能继承到一份早于 PATH 修改的环境块,里面没有 `System32` ——
294
- 没有它就等于整个工具作废。显式写出的绝对 `bashPath` 只认那一个:写错了就该响亮地失败,
295
- 不该被悄悄换成另一份。
296
- - **UAC 开启时的行为与本机不同。** 本机实测环境是 UAC 关闭、进程提权。UAC 开着时会话是标准用户,
297
- 此时 ACL 沙箱要求工作区根目录的 DACL 给你 **`WRITE_DAC` 与 `WRITE_OWNER`** —— 全控制两者都满足
298
- (它写的完整性标签在 SACL 里)。用户目录下的目录天然继承这两样;在盘符根新建的目录只继承
299
- `Modify`,单靠 `Modify` 会以 `SetNamedSecurityInfoW failed (Win32 5)` 失败。属主不是决定因素:
300
- `icacls <根目录> /grant "%USERNAME%":(OI)(CI)F` 能救。届时是 fail-closed 报错,不会退化成无边界。
301
- - 不支持交互式程序与 TTY。
302
- - **升级会换掉会话,但绝不打断正在跑的调用。** 替换插件会先把它停掉,而停掉本插件就会销毁它的会话 ——
303
- 所以真正装上新版的那一次,常驻 pwsh 进程已经不在了,下次调用重新拉起。只存在于该会话里的东西(变量、
304
- `Set-Location` 过的目录、import 进来的模块)随之丢失。实测 v0.2.3 → v0.2.4 的一次安装:`$PID` 由
305
- 24820 变成 15632,安装前设的 `$global:` 变量读回来是空的。更新器**不会**做的事是打断在跑的活:
306
- 自动循环与 `pwsh_run_update` 的 `install` 都会先问插件「现在有没有调用在跑」,有就整轮推迟
307
- (循环 3 分钟后再看,工具报 `install: postponed` 且不动 profile),直到会话安静下来。安装进行中到达的
308
- 调用会先等它装完,而不是建一个马上要被销毁的会话。版本比会话状态值钱,但这并不等于「什么都没发生」
309
- —— 别把不可重建的状态放在里面跨过升级。
216
+ 完整清单(会话语义、已断开的子进程 std handle、UAC 差异、升级对会话的影响)见
217
+ **[docs/runtime-notes.zh-CN.md](docs/runtime-notes.zh-CN.md)**。要点:
218
+
219
+ - 不支持 `read-only`,那里用内置 `pwsh`。
220
+ - 超时会杀掉整个会话并回收其子进程树。
221
+ - `exit N` 退出码与 stdout/stderr 的逐行时序都不可观测。
222
+ - 会话是串行的,不提供 TTY。
223
+ - 升级会换掉会话,只存在于其中的东西随之丢失。
310
224
 
311
225
  ## 开发
312
226
 
@@ -315,9 +229,6 @@ plugin/
315
229
  index.mjs 工具注册、沙箱接线、超时
316
230
  session.mjs 会话对象:分帧、队列、base64、启动 argv
317
231
  session-server.ps1 PowerShell 侧:runspace、流打标、哨兵
318
- update-check.mjs registry 查询与版本比较
319
- update-apply.mjs 把钉住的规格交给 pluginManager,不自己下载
320
- auto-update.mjs 自动路径:在 HMR 事务之外安装,失败退避
321
232
  test/
322
233
  support.mjs 定位 DSH 安装;Windows 路径转 /mnt 形式
323
234
  itest.mjs 协议与会话本体
@@ -326,9 +237,6 @@ test/
326
237
  sandboxtest.mjs 真 LocalSandboxProvider,边界 + fail-closed 用例
327
238
  bashtest.mjs custom-bash 围栏分诊(mock runtime)
328
239
  bash-e2e.mjs custom-bash 端到端(真 runtime + 真 llrun)
329
- updatetest.mjs 更新检查与安装结果(本地 git 仓库)
330
- autoupdatetest.mjs 自动更新循环、退避、空闲闸门,以及它依赖的 HMR 上下文逃逸
331
- wiringtest.mjs 更新提示真的接到工具输出末尾、安装要等会话空闲
332
240
  denialtest.mjs 沙箱拒绝提示只在该触发的报错上触发
333
241
  metatest.mjs 插件页元数据:locale、icon,以及页面到底读哪份文档
334
242
  wsl/
@@ -336,6 +244,7 @@ wsl/
336
244
  custom-bash.mjs bundle 接进组合的 bash 工具替代实现(仅 Windows)
337
245
  acceptance.sh 54 项验收矩阵
338
246
  docs/llrun.md 垫片的完整说明
247
+ docs/runtime-notes.zh-CN.md 完整已知边界清单,从这一页挪出去
339
248
  FINDINGS.md 全部实测数据与设计理由
340
249
  ```
341
250
 
@@ -347,14 +256,11 @@ node test/e2e-real-subprocess.mjs # 8:真 DSH subprocess
347
256
  node test/sandboxtest.mjs # 7:真沙箱边界 + fail-closed
348
257
  node test/bashtest.mjs # 25:custom-bash 围栏分诊、bash 后备、执行位自修复
349
258
  node test/bash-e2e.mjs # 11:custom-bash 端到端
350
- node test/updatetest.mjs # 96:更新检查、安装结果、安装前预检(只碰本地 git)
351
- node test/autoupdatetest.mjs # 38:自动安装、退避、空闲闸门、安装前预检、HMR 上下文逃逸
352
- node test/wiringtest.mjs # 21:更新提示接线、空闲闸门、安装前预检的接线
353
259
  node test/denialtest.mjs # 11:沙箱拒绝提示(不起子进程,哪儿都能跑)
354
260
  node test/metatest.mjs # 23:插件页元数据(不起子进程,哪儿都能跑)
355
261
  ```
356
262
 
357
- 十一份脚本合计 **305 项**,都不需要 DSH 在跑 —— 只要 node 24、PowerShell 7,以及一个能借
263
+ 八份脚本合计 **117 项**,都不需要 DSH 在跑 —— 只要 node 24、PowerShell 7,以及一个能借
358
264
  `node_modules` 的 DSH 安装。需要 DSH 运行时的测试会自动找(`DSH_MODULES`、`DSH_MODULES_ASAR`,
359
265
  或从工作目录逐级向上),找不到时打一行 `SKIP` 提示而不是失败。
360
266
 
@@ -366,9 +272,8 @@ runner 用自己那份短命的 GitHub 身份换一个只对「这个仓库 +
366
272
  `NPM_TOKEN`,也不该加。两个连带后果值得记住:workflow 的文件名是信任契约的一部分(改名后每次发布都会
367
273
  403,直到在 npm 上改设置);provenance 在那里是强制的,所以 tarball 会带上「由哪个提交构建」的签名声明。
368
274
 
369
- `package.json` 里的 version 是本仓库唯一写死的东西,而更新检查拿它跟 registry 上的已发布版本比。
370
- 不升版本就发版,检查会永远报有新版:装上去的文件读到的还是旧数字,于是每轮都请求同一个版本,
371
- 退避一路爬到上限。
275
+ `package.json` 里的 version 是本仓库唯一写死的东西,DSH 拿它跟 registry 上的已发布版本比。
276
+ 不升版本就发版,每次更新装的都是同一份字节:页面一直提示有新版,pnpm 一直装回旧的。
372
277
  `npm version` 一次完成「改版本 + 提交 + 打 annotated tag」,所以这三者不会漂开:
373
278
 
374
279
  ```powershell
@@ -385,4 +290,4 @@ npm 要用 Windows 侧那份。WSL 里 `/usr/local/bin/npm` 指向 bun 的软链
385
290
 
386
291
  ## 许可
387
292
 
388
- MIT —— 见 [LICENSE](LICENSE)。
293
+ MIT —— 见 [LICENSE](LICENSE)。