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