dsh-plugin-wsl-env 0.2.0 → 0.3.0

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.
@@ -19,7 +19,7 @@ smaller integrations.
19
19
  | Service | Class | File | What it does |
20
20
  |---|---|---|---|
21
21
  | `ctx.shell` | `WslShellExecutor` | [`lib/index.js`](../lib/index.js) | Runs each command as `wsl.exe -d <distro> --cd <linux dir> --exec <login shell> -lc <cmd>`, wrapped in a distro-side `bwrap` sandbox. |
22
- | `ctx.fs` | `WslFileSystem` | [`lib/index.js`](../lib/index.js) | Maps Linux paths onto the distro's UNC share, so the host filesystem stack and the packaged ripgrep work on real distro files. |
22
+ | `ctx.fs` | `WslFileSystem` | [`lib/index.js`](../lib/index.js) | Serves the file tools from real distro files, on one of two I/O substrates (see below): the Windows-side host stack over the distro's UNC share (default), or the resident in-distro agent on ext4. |
23
23
  | `ctx.subprocess` | `WslSubprocessRuntime` | [`lib/subprocess.js`](../lib/subprocess.js) | Opens the GUI terminal inside the distro, in the session workspace. |
24
24
 
25
25
  `WslShellExecutor` extends the shipped `LocalBashExecutor`, and `WslFileSystem`
@@ -92,6 +92,32 @@ Three coordinate systems appear in the code:
92
92
  DSH package and performs no I/O. Its containment helpers decide whether a target
93
93
  path sits under a writable root. The filesystem fence uses them.
94
94
 
95
+ ## The filesystem substrate
96
+
97
+ `wsl-fs` serves `ctx.fs` on one of two I/O substrates, chosen by
98
+ `substrate: share | agent` (default `agent`):
99
+
100
+ | | `agent` (default) | `share` (opt-out) |
101
+ |---|---|---|
102
+ | Reads, writes, identities | the resident in-distro agent, on ext4 | the Windows-side host stack (`dsh-fs-local`) over the distro's 9p share |
103
+ | Symlinks | native; every identity is a distro-side `realpath` | the share cannot traverse them, so a missing read is retried once through the distro's canonical path |
104
+ | Mode bits | native; the publication rename is the kernel's | the share drops a host-side chmod, so publication re-applies the mode from inside the distro before an atomic rename |
105
+ | Guarded creates | the guard is forwarded; publication is a no-replace link, closing the check-then-write window for creates. An overwrite or edit carries its version into the write op and the agent re-verifies it one syscall before the rename — a concurrent writer wins, the stale write refuses with `FS_STALE_VERSION` | checked, then published without a guard (the share has no hard links) |
106
+ | Kernel enforcement | stage two: a mutation runs on a confined resident — one long-lived agent per (distro, policy), whose bwrap profile binds exactly what the mode grants — so the kernel refuses what the check would have, and a check bug cannot become a write outside the workspace. Escalated writes (`danger-full-access`) ride the plain resident. A kernel refusal carries `FS_SANDBOX_DENIED`, the same dialect as the command path. | none: the fence is the host-side check |
107
+ | New files | 0600 — the host backend's own POSIX publication semantics, as on a Linux host | the distro's umask default (0644) |
108
+ | Agent outage | `FS_IO_ERROR` naming the substrate; no silent fallback to the share. A distro without usable bwrap refuses confined mutations with the bootstrap command, the same closed failure the command path has | not applicable |
109
+
110
+ Both substrates fence writes with the same host-side policy check
111
+ (`checkedTarget`), and neither changes what `sandboxMode` reports. The wire
112
+ protocol the agent substrate speaks is documented in
113
+ [`agent/wsl-agent.sh`](../agent/wsl-agent.sh); the orchestration layers are
114
+ [`lib/fsio-agent.js`](../lib/fsio-agent.js) (fsio's mechanics over the FS
115
+ frames), [`lib/fs-substrate.js`](../lib/fs-substrate.js) (the provider-shaped
116
+ operations, including the write/edit guard orchestration), and
117
+ [`lib/fsio-text.js`](../lib/fsio-text.js) (the pure text mechanics, replicated
118
+ from upstream pending the export proposed in
119
+ [UPSTREAM-FSIO-EXPORT.md](UPSTREAM-FSIO-EXPORT.md)).
120
+
95
121
  ## Sandbox
96
122
 
97
123
  ### Why the Windows runner cannot be used
@@ -238,6 +264,16 @@ lib/wsl.js wsl.exe interop primitives (no DSH imports)
238
264
  lib/listing.js pure directory listing and breadcrumb helpers (no DSH imports)
239
265
  lib/index.js WslShellExecutor (ctx.shell) and WslFileSystem (ctx.fs)
240
266
  lib/sandbox.js the distro-side bwrap confinement shared by both providers
267
+ lib/bwrap.js the bwrap command line both confinement sites assemble
268
+ (no imports, so a bare checkout can assert it)
269
+ lib/fsio-text.js the peer's pure text mechanics, replicated pending the
270
+ upstream fsio export (see UPSTREAM-FSIO-EXPORT.md)
271
+ lib/fsio-agent.js fsio's mechanics over the agent's FS frames
272
+ lib/fs-substrate.js the agent substrate's provider-shaped operations
273
+ lib/agent.js the resident in-distro agent's host side
274
+ lib/agent-confined.js the confined residents' factory (one per policy)
275
+ lib/agent-protocol.js the wire protocol codecs (pure)
276
+ agent/wsl-agent.sh the resident in-distro agent (POSIX sh, coreutils only)
241
277
  lib/picker.js WslDirectoryPicker (ctx.directoryPicker)
242
278
  lib/subprocess.js WslSubprocessRuntime (ctx.subprocess), the terminal window
243
279
  lib/auto-preset.js per-session environment selection
@@ -16,12 +16,15 @@ are schema defaults, listed because they are the ones worth knowing.
16
16
  | | `loginShell` | `true` *(shipped)* | use `<shell> -lc`, which sources your profile, instead of a bare `-c` |
17
17
  | | `agent` | `true` | run commands over the resident in-distro agent (one long-lived process; in-distro timeout); any agent failure falls back to the one-shot `wsl.exe` path |
18
18
  | | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox |
19
+ | | `maskWindowsDrive` | `false` | shadow `/mnt` with an empty tmpfs in every confined profile: the Windows drive's files cannot be read or exfiltrated and its executables cannot be launched through interop — the hole that lets a "sandboxed" command reach your full Windows token. A narrowing, not a closure (a command can still write an executable into the workspace and run it — binfmt dispatches on content, not location), so enforcement stays `partial` and the result's sandbox fact reports `windowsDrive: "masked"`. The complete closure is distro-level: `[interop] enabled=false` in `wsl.conf` |
19
20
  | | `cwd` | `''` | default working directory; empty means the distro user's home |
20
21
  | | `timeoutMs` / `maxTimeoutMs` | `120000` / `600000` *(shipped)* | per-call time limit, and the ceiling a call may ask for |
21
22
  | `wsl-fs` | `distro` | `''` *(shipped)* | as above |
22
23
  | | `restrictToDistro` | `true` *(shipped)* | refuse a path in **another distro**'s share. `/mnt/c` is a directory *inside* the pinned distro, so it is not affected. The refusal carries `FS_OUTSIDE_DISTRO`; that is not a sandbox decision, so the file tools do not offer a wider permission for it. Set `false` to allow another distro's share |
23
24
  | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
24
- | | `resolveSymlinks` | `true` | follow Linux symlinks that the share cannot traverse, such as `/etc/os-release` and `/bin` |
25
+ | | `maskWindowsDrive` | `false` | as on `wsl-shell`: confined fs mutations publish through a resident whose profile shadows `/mnt` (same honest limits) |
26
+ | | `substrate` | `agent` *(shipped)* | which I/O substrate serves the file tools: `agent` — the resident in-distro agent (`lib/fs-substrate.js`), where reads, writes and identities run on ext4: native symlinks, native mode bits, and the mutation guard survives to the write's atomic publication, kernel-enforced by the confined resident when the policy is confined. `share` — the opt-out: the Windows-side host stack over the 9p share, with the symlink-retry and publication hooks. Both substrates fence writes with the same host-side policy check. See [LIMITATIONS.md](LIMITATIONS.md) for what changes between them |
27
+ | | `resolveSymlinks` | `true` | *(share substrate only)* follow Linux symlinks that the share cannot traverse, such as `/etc/os-release` and `/bin` — the agent substrate follows them natively |
25
28
  | | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
26
29
  | `directory-picker-wsl` | `preferredDistro` | `''` *(shipped)* | the distro listed first in the picker |
27
30
  | | `includeHostHome` | `true` *(shipped)* | also list the Windows home directory |
@@ -3,9 +3,69 @@
3
3
  What the plugin does not do, and why. A limitation that is documented is a design
4
4
  boundary; one that is not is a bug report waiting to happen.
5
5
 
6
+ ## The filesystem substrate (`wsl-fs` `substrate`)
7
+
8
+ `substrate: "agent"` (the default) serves the file tools through the resident
9
+ in-distro agent, on ext4. `substrate: "share"` is the opt-out: the Windows-side
10
+ host stack over the distro's 9p share. What differs:
11
+
12
+ - **A new file publishes with mode 0600 on the agent substrate.** That is the
13
+ host backend's own POSIX publication semantics (a private 0600 staging file,
14
+ renamed into place), which a Linux-host DSH session has always had. The share
15
+ substrate was the outlier: the share drops mode sets, so new files took the
16
+ distro's umask default (0644). Overwrites and edits keep the original bits on
17
+ both substrates. Run `chmod` (or ask the model to) when a new file must be
18
+ executable.
19
+ - **There is no silent fallback to the share.** When the agent substrate's
20
+ resident process is out — the distro stopped, or the agent crashed past its
21
+ one rebuild — file operations fail with `FS_IO_ERROR` naming the substrate,
22
+ rather than quietly degrading to share semantics that cannot follow symlinks
23
+ or keep mode bits. The share substrate dies with the distro anyway; the
24
+ agent only adds its own failure mode, which is the one honest way to fail.
25
+ - **The mutation guard survives to the write.** `createIfAbsent` publishes with
26
+ a no-replace link inside the distro, which closes the check-then-write window
27
+ for creates outright. An overwrite or edit carries the version it was based
28
+ on into the write op, and the agent re-verifies it against a fresh stat one
29
+ syscall before the rename: a concurrent writer wins and the stale write
30
+ refuses with `FS_STALE_VERSION`. That narrows the share's window from a host
31
+ round trip to kernel-adjacent — the residual stat-then-rename pair is the
32
+ same sliver a Linux-host session's own rename has.
33
+ - **Stage two routes mutations through a confined resident.** A write or edit
34
+ under a confined policy runs on its own long-lived agent, spawned inside the
35
+ bwrap profile that binds exactly what the mode grants — so the kernel, not
36
+ just the host-side check, refuses a write outside the workspace, and the
37
+ refusal carries `FS_SANDBOX_DENIED` (the command path's dialect). Costs to
38
+ know: `bubblewrap` becomes required for confined writes (a distro without it
39
+ refuses them with the bootstrap command, rather than downgrading); a policy
40
+ change addresses a different resident, so the first write after switching the
41
+ Permissions selector pays one agent startup; and a write the policy grants to
42
+ the distro's `/tmp` lands in the confined mount's tmpfs, not the real `/tmp`
43
+ — the same hand-off rule as the commands' `/tmp`: pass files through the
44
+ session workspace.
45
+ - **`resolveSymlinks` is not consulted.** The share cannot traverse Linux
46
+ symlinks, so the share substrate retries missing reads through the distro's
47
+ canonical path; the agent substrate never misses them, so the config key has
48
+ no effect there.
49
+ - **`glob` and `grep` are unchanged on both substrates.** They are tool-layer
50
+ searches running the packaged Windows ripgrep against the share — correct
51
+ results, slow, and `.gitignore` interpreted with Windows rules. Bringing them
52
+ in-distro is future work, not part of the substrate switch.
53
+ - **`watch()` is the same poll loop on both substrates** (a `find -newer` scan
54
+ inside the distro), so the coarse-invalidation caveats below apply equally.
55
+ An inotify backend is planned, not shipped.
56
+
57
+ Everything below applies to the share substrate unless it says otherwise.
58
+
6
59
  - **The sandbox does not govern WSL interop.** A confined command can still run a
7
60
  Windows program, which escapes the Linux-side boundary. The plugin reports this as
8
61
  `enforcement: partial`. See [ARCHITECTURE.md](ARCHITECTURE.md#reported-enforcement).
62
+ The `maskWindowsDrive` key on `wsl-shell` and `wsl-fs` narrows the hole: the
63
+ confined profile shadows `/mnt` with an empty tmpfs, so the drive's files cannot be
64
+ read or exfiltrated and its executables cannot be launched. It is not a closure — a
65
+ command that can write the workspace can write an executable there and run it
66
+ (binfmt interop dispatches on file content, not location) — so enforcement stays
67
+ `partial`, the result's sandbox fact reports `windowsDrive: "masked"`, and the only
68
+ complete closure remains distro-level (`[interop] enabled=false` in `wsl.conf`).
9
69
  - **`bubblewrap` must be installed**, or both providers fail immediately. The
10
70
  usability probe re-runs after a failure, so installing bubblewrap while the app
11
71
  runs is believed on the next command; only a success is cached.
@@ -0,0 +1,60 @@
1
+ # Peer parity — the fsio replication, verified
2
+
3
+ The agent substrate's text mechanics (`lib/fsio-text.js`) and its provider
4
+ orchestration messages (`lib/fs-substrate.js`, `lib/fsio-agent.js`) are a
5
+ deliberate replica of the pinned peer's fsio module, so the model reads the
6
+ same refusal dialect on both substrates. This file records the verification of
7
+ that claim against the real peers, which are not imported at build time.
8
+
9
+ - Peer: `@deepseek-ai/dsh-fs-local@0.2.0-rc.2` (the exact version
10
+ `package.json` pins), plus `@deepseek-ai/dsh-fs@0.2.0-rc.2` for the
11
+ `FsVersion` brand the local package imports.
12
+ - Method: `npm pack` both pins, extract, and compare the shipped
13
+ `lib/index.js` (unminified ESM) function-by-function and string-by-string
14
+ against this repository. Verified 2026-10-03.
15
+
16
+ ## Verdict
17
+
18
+ **The replication is faithful.** Every load-bearing line matches the peer's
19
+ shipped code; the differences are the substrate's declared extensions, listed
20
+ at the end.
21
+
22
+ ## Verified identical
23
+
24
+ | Item | Evidence (peer `lib/index.js`) | Ours |
25
+ |---|---|---|
26
+ | `normalizeLineEndings` | `content.replaceAll("\r\n", "\n")` | identical |
27
+ | `detectLineEndings` | 4096-char sample; `crlf > lf - crlf` | identical |
28
+ | `restoreLineEndings` | LF unchanged; CRLF re-normalizes then `\n` → `\r\n` | identical (`replaceAll` vs `split/join` — equivalent) |
29
+ | occurrence counting | non-overlapping left-to-right | `split(needle).length - 1` — equivalent |
30
+ | `applyLiteralEdit` | empty needle, not-found, and ambiguous refusals; single-pass `split/join` without rescanning | identical |
31
+ | edit refusal strings | `old_string must be a non-empty string` (`FS_EDIT_NOT_FOUND`); ``old_string was not found in "${displayPath}"``; ``old_string matched ${n} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`` (`FS_AMBIGUOUS_EDIT`) | identical |
32
+ | invalid UTF-8 | ``cannot ${verb} "${displayPath}": invalid UTF-8 text`` (`FS_NOT_TEXT`) | identical |
33
+ | binary detection | 8192-byte sample, NUL check; ``cannot read "${displayPath}": binary file`` (`FS_NOT_TEXT`) | identical |
34
+ | size caps | ``cannot read "${displayPath}": ${size} bytes exceeds the ${maxBytes}-byte limit`` and ``content exceeds the ${maxBytes}-byte limit`` (`FS_TOO_LARGE`) | identical |
35
+ | `writeText` guards | not-regular (`FS_NOT_REGULAR_FILE`), `file no longer exists` / `file changed since it was read` (`FS_STALE_VERSION`), ``cannot overwrite existing "${displayPath}" without reading it first`` (`FS_NOT_OBSERVED`) | identical |
36
+ | `editText` guards | missing file refuses with `FS_STALE_VERSION` "file changed since it was read" (NOT `FS_NOT_FOUND`); not-regular; stale-version | identical — an earlier draft of this audit suspected a divergence here; the peer matches us |
37
+ | `listDir` refusal | ``cannot list "${displayPath}": not a directory`` (`FS_NOT_DIRECTORY`) | identical |
38
+ | diff-basis gate | `existing !== null && Buffer.byteLength(content) < diffBasisMaxBytes` — keyed on the NEW content's size, `null` above the cap | identical — the gate's new-size keying is inherited by design, not a divergence |
39
+ | outcome shapes | `operation: "update" \| "create"`, the `missing:${targetKey}` sentinel version, `before`/`after` | identical |
40
+ | version join | `` `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}` `` (local `index.js:146`, branded by `dsh-fs`'s `FsVersion`) | `versionOf` — identical shape |
41
+
42
+ ## Deliberate divergences (declared, not drift)
43
+
44
+ 1. **Distro-side version re-verification.** A `replace` write carrying an
45
+ expected version is re-checked by the agent against a fresh stat one
46
+ syscall before the rename (`FS_STALE_VERSION` on mismatch). The peer's
47
+ check-then-write window stops at the host; ours narrows to kernel-adjacent.
48
+ 2. **Per-stream truncation flags.** The EXEC frame carries an output budget;
49
+ the RES line reports which streams the agent cut, surfaced as honest
50
+ `truncated` flags. The peer's collect-budget machinery is the spill-file
51
+ path we deliberately do not run.
52
+ 3. **The transport around the ops** (ACK, KILL, the confined resident) is
53
+ protocol this package owns; the peer has no agent.
54
+
55
+ ## Maintenance
56
+
57
+ When `package.json` bumps the peer pins, re-run this comparison — extract the
58
+ new tarballs and diff the regions above. The fastest canary: stat a file, list
59
+ its directory, and compare the versions (`test/fsio-agent.test.mjs` asserts the
60
+ intra-distro half of this); the strings above are the other half.
package/docs/README.zh.md CHANGED
@@ -57,6 +57,7 @@ dsh --profile wsl # 4. 启动
57
57
  | `wsl-fs` | `distro` | `''` | 同上 |
58
58
  | | `restrictToDistro` | `true` | 拒绝指向**其它子系统**共享的路径;`/mnt/c` 属于本子系统内部,不受影响。拒绝码为 `FS_OUTSIDE_DISTRO`,它不是沙箱拒绝,放宽权限也无法解除 |
59
59
  | | `sandbox` | `true` | 写入时按策略检查 `writeText` 和 `editText` |
60
+ | | `substrate` | `agent` | 文件工具使用哪种 I/O 基座:`agent` —— 常驻子系统内代理,读写与路径解析直接在 ext4 上完成(原生符号链接、原生权限位,写入 guard 存活到发布时刻,受限策略下由内核强制);`share` —— 退出项:Windows 侧宿主文件栈走 9p 共享。两种基座使用同一策略围栏;差异详见 [LIMITATIONS.md](LIMITATIONS.md) |
60
61
  | `directory-picker-wsl` | `includeHostHome` | `true` | 同时列出 Windows 家目录 |
61
62
  | `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个子系统 |
62
63
 
@@ -110,6 +111,7 @@ npm run probe:sandbox # 在子系统里实测 bubblewrap 能约束什么
110
111
  npm run probe # 文件系统探针,需要真实子系统(仅 Windows + WSL)
111
112
  npm run probe:sandbox-shell # 启动真实 harness,驱动受限执行器
112
113
  npm run probe:terminal # 通过终端 provider 打开一个 PTY
114
+ npm run probe:substrate # 用真实 wsl.exe 传输驱动 agent 文件基座(在子系统内运行)
113
115
  npm run probe:missing-wsl # 启动一个 wslPath 无法启动的 profile(仅 Windows + WSL)
114
116
  npm run probe:picker # 列出选择器的根级、拒绝路径与上限(仅 Windows + WSL)
115
117
  npm run probe:mode # 哪些 POSIX 权限事实能穿过共享层(仅需 Windows node)
@@ -0,0 +1,87 @@
1
+ # Upstream proposal: export the fsio module from `dsh-fs-local`
2
+
3
+ Status: draft, targeting `deepseek-harness` @ `packages/fs/fs-local` (version
4
+ line `0.2.0-rc.x`). This is the Shape-A upstream half of the in-distro I/O
5
+ refactor: with the fsio module exported, `dsh-plugin-wsl-env` deletes its
6
+ in-plugin replicas (`lib/fsio-text.js`) and imports the originals.
7
+
8
+ ## The change
9
+
10
+ The fsio module already exists, is fully documented, and is pure (no peers
11
+ beyond types). It is only not reachable: `package.json` exports `"."` and
12
+ `"./package.json"`, and `lib/index.js` does not re-export the helpers. Two
13
+ lines of surface, no behaviour change:
14
+
15
+ ```diff
16
+ --- a/packages/fs/fs-local/package.json
17
+ +++ b/packages/fs/fs-local/package.json
18
+ @@
19
+ "exports": {
20
+ ".": {
21
+ "types": "./lib/types/index.d.ts",
22
+ "default": "./lib/index.js"
23
+ },
24
+ + "./fsio": {
25
+ + "types": "./lib/types/fsio.d.ts",
26
+ + "default": "./lib/fsio.js"
27
+ + },
28
+ "./src/*": "./src/*",
29
+ "./package.json": "./package.json"
30
+ },
31
+ ```
32
+
33
+ ```diff
34
+ --- a/packages/fs/fs-local/lib/index.js
35
+ +++ b/packages/fs/fs-local/lib/index.js
36
+ @@
37
+ -export { LocalFileSystem, LocalFileSystem as default };
38
+ +export {
39
+ + LocalFileSystem,
40
+ + LocalFileSystem as default,
41
+ + applyLiteralEdit,
42
+ + detectLineEndings,
43
+ + normalizeLineEndings,
44
+ + restoreLineEndings,
45
+ +};
46
+ ```
47
+
48
+ (The module layout differs between the repo's `src/` and the built `lib/`;
49
+ the export list is what matters. `readForEdit` and `readTextForDiff` stay
50
+ internal — they are I/O orchestration, which the plugin drives against its own
51
+ substrate — only the pure text mechanics are needed.)
52
+
53
+ ## Why the plugin needs it
54
+
55
+ `dsh-plugin-wsl-env`'s agent substrate (`lib/fsio-agent.js`,
56
+ `lib/fs-substrate.js`) re-runs fsio's orchestration inside a WSL distro: reads,
57
+ writes, identities and edits execute where the files live, and the host side
58
+ keeps only the validation and edit mechanics. Those mechanics —
59
+ `applyLiteralEdit`, the line-ending trio, the binary NUL sample, the fatal UTF-8
60
+ decode — are replicated today (`lib/fsio-text.js`) because the package does not
61
+ export them. The replica is byte-for-byte faithful, including error messages,
62
+ because the tool layer matches on both the code and the text the model reads;
63
+ but a replica is a fork surface: every upstream change to the edit algorithm
64
+ has to be re-ported by hand.
65
+
66
+ With the export in place the plugin deletes `lib/fsio-text.js` and imports:
67
+
68
+ ```js
69
+ import { applyLiteralEdit, detectLineEndings, normalizeLineEndings, restoreLineEndings } from "@deepseek-ai/dsh-fs-local/fsio";
70
+ ```
71
+
72
+ ## Why this is safe upstream
73
+
74
+ - No new API surface beyond re-exporting functions that already exist and are
75
+ documented in `lib/types/fsio.d.ts`.
76
+ - No behaviour change: the class's own call sites are untouched.
77
+ - The functions are pure (text in, text out, structured errors), so exporting
78
+ them cannot widen the I/O contract.
79
+ - Semver: additive export, a minor bump.
80
+
81
+ ## Follow-up (separate proposal, not required)
82
+
83
+ A larger seam — parameterizing `LocalFileSystem`'s fsio call sites so a
84
+ subclass can substitute an I/O substrate — would let integrations inherit the
85
+ whole backend instead of re-running its orchestration. The export above is the
86
+ prerequisite and is sufficient on its own; the parameterization is worth
87
+ discussing only after a consumer exists in the wild.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Confined residents: one long-lived agent per (distro, policy) pair, running
3
+ * its WHOLE lifetime inside the bwrap profile that policy asks for — so every
4
+ * filesystem op it performs is enforced by the kernel's mount table, not just
5
+ * by the host-side check that chose it.
6
+ *
7
+ * This is stage two of the substrate's enforcement story. Stage one routed
8
+ * every op through the plain agent with the policy check deciding what may
9
+ * pass; stage two routes *mutations* to a confined resident whose view of the
10
+ * filesystem already matches the granted rights, so a bug in the check cannot
11
+ * become a write outside the workspace — the kernel refuses it.
12
+ *
13
+ * Keying is the whole design: the profile is fixed for an agent's lifetime,
14
+ * so a change in the policy (the Permissions selector, a session move) simply
15
+ * addresses a different key, and the previous resident retires through its own
16
+ * idle timer. Escalated writes (`danger-full-access`) keep addressing the
17
+ * plain agent, which is what an approved escalation means.
18
+ *
19
+ * The argv it hands the transport is a whole command — program, profile, `--` —
20
+ * assembled by the peer-free `lib/bwrap.js`, which both confinement sites share.
21
+ * That import is why this module needs no DSH peer either: a bare checkout can
22
+ * construct the factory and assert the command it produces, which is what
23
+ * `test/agent.test.mjs` does. It used to import the sandbox's own profile
24
+ * builder and assemble the prefix itself, missing the program; the probe inlined
25
+ * a third copy, so nothing compared production's command with a working one and
26
+ * every confined mutation failed the resident's handshake.
27
+ *
28
+ * @module dsh-plugin-wsl/agent-confined
29
+ */
30
+ import { fileURLToPath } from "node:url";
31
+ import { bwrapArgvPrefix } from "./bwrap.js";
32
+ import { toLinuxPath } from "./paths.js";
33
+ import { WslAgent } from "./agent.js";
34
+
35
+ const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
36
+ const confinedAgents = new Map();
37
+
38
+ /**
39
+ * The confined agent for one distro and one resolved policy, created on first
40
+ * use and shared for the process lifetime. A success is remembered; a failed
41
+ * start is not (the agent's own rebuild rules take over from there).
42
+ *
43
+ * @param {object} request - the confinement request.
44
+ * @param {string} request.distro - the distro to run in.
45
+ * @param {string} [request.wslPath] - the `wsl.exe` path.
46
+ * @param {{mode: string, workspaceRoot: string}} request.policy - the RESOLVED
47
+ * file-effect policy: the profile binds exactly what the mode grants.
48
+ * @param {boolean} [request.maskWindowsDrive] - shadow `/mnt` with an empty
49
+ * tmpfs in the resident's profile; part of the cache key, since it changes
50
+ * the mount table the resident lives under.
51
+ * @returns {WslAgent} the confined resident for this key.
52
+ */
53
+ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindowsDrive = false }) {
54
+ const key = `${wslPath}\0${distro}\0${policy.mode}\0${policy.workspaceRoot}\0${maskWindowsDrive ? "masked" : "visible"}`;
55
+ let agent = confinedAgents.get(key);
56
+ if (agent === undefined) {
57
+ agent = new WslAgent({
58
+ distro,
59
+ wslPath,
60
+ scriptPath: toLinuxPath(AGENT_SCRIPT_PATH),
61
+ // The prefix is a whole command from the distro's point of view — program,
62
+ // profile, `--` — because the transport inserts it between
63
+ // `wsl.exe --exec` and the `sh <script>` pair (`lib/agent.js`'s
64
+ // `spawnDefault`). Both confinement sites share that assembly in
65
+ // `lib/bwrap.js`, whose module doc records what happened when they did not.
66
+ argvPrefix: bwrapArgvPrefix(
67
+ { mode: policy.mode, workspaceRoot: toLinuxPath(policy.workspaceRoot, { distro }) },
68
+ { maskWindowsDrive },
69
+ ),
70
+ });
71
+ confinedAgents.set(key, agent);
72
+ }
73
+ return agent;
74
+ }
75
+
76
+ /** Test hook: forget every confined resident (the plain agent has no factory state). */
77
+ export function resetConfinedAgents() {
78
+ confinedAgents.clear();
79
+ }
80
+
81
+ export default confinedAgent;
package/lib/agent-exec.js CHANGED
@@ -6,13 +6,15 @@
6
6
  * budget, a deadline that kills the Windows process tree. This module builds
7
7
  * the SAME handle shape around one agent `exec` — the shape `dsh-tool-bash`
8
8
  * consumes, so the tool layer cannot tell which path ran. The deliberate
9
- * deviations, both memory-for-simplicity trades documented here rather than
10
- * hidden:
9
+ * deviations, documented here rather than hidden:
11
10
  *
12
- * - **No spill files.** The agent returns each stream as one buffer; the
13
- * handle serves reads from memory. The upstream budget machinery exists to
14
- * bound host memory for unbounded streams; the agent path accepts holding
15
- * the stream (the protocol line already carries it whole).
11
+ * - **No spill files; the capture cap is the agent's.** The EXEC frame
12
+ * carries the caller's per-stream budget and the AGENT cuts stdout and
13
+ * stderr at it, so a chatty command costs bounded distro memory AND bounded
14
+ * host memory; the RES line's flags say which streams were cut and the
15
+ * handle reports them as `truncated: true`. What the budget spares is the
16
+ * upstream spill machinery: the capture is still whole-in-memory, just
17
+ * whole-at-a-bounded-size.
16
18
  * - **Killed commands report `SIGTERM`**, not a raw exit status: the
17
19
  * in-distro timeout and `kill()` both mean "we stopped it", and upstream
18
20
  * reports its own kills the same way.
@@ -54,13 +56,15 @@ function memoryReader(buffer) {
54
56
  * @param {string[]} request.argv - the distro-side argv, bwrap-wrapped when confined.
55
57
  * @param {number} [request.timeoutMs] - whole-command budget; the agent TERMs
56
58
  * at the ceiling second and KILLs after its grace.
59
+ * @param {number} [request.maxOutputBytes] - per-stream capture ceiling the
60
+ * agent enforces; the handle reports a cut stream as `truncated: true`.
57
61
  * @param {AbortSignal} [request.signal] - the caller's cancellation.
58
62
  * @param {(proc: object) => void} [request.onStarted] - provider-facts hook,
59
63
  * called synchronously before the handle can settle.
60
64
  * @returns {object} the execution handle (`status`, `exitCode`, `signal`,
61
65
  * `observed`, `done`, `readOutput`, `kill`, `result`).
62
66
  */
63
- export function agentExecutionHandle({ agent, cwd, argv, timeoutMs = 0, signal, onStarted }) {
67
+ export function agentExecutionHandle({ agent, cwd, argv, timeoutMs = 0, maxOutputBytes = 0, signal, onStarted }) {
64
68
  // Our own controller is the kill channel; the caller's signal races it.
65
69
  const controller = new AbortController();
66
70
  let timedOut = false;
@@ -77,7 +81,7 @@ export function agentExecutionHandle({ agent, cwd, argv, timeoutMs = 0, signal,
77
81
  }, timeoutMs).unref?.();
78
82
  }
79
83
 
80
- const settle = agent.exec({ cwd, argv, timeoutMs, signal: controller.signal }).then(
84
+ const settle = agent.exec({ cwd, argv, timeoutMs, maxOutputBytes, signal: controller.signal }).then(
81
85
  (result) => ({ ok: true, result }),
82
86
  (error) => {
83
87
  // A workdir the distro cannot enter is the command's own failure, not
@@ -102,6 +106,9 @@ export function agentExecutionHandle({ agent, cwd, argv, timeoutMs = 0, signal,
102
106
  let stdout = Buffer.alloc(0);
103
107
  /** @type {Buffer} */
104
108
  let stderr = Buffer.alloc(0);
109
+ /** The agent's per-stream capture-cap flags, surfaced as `truncated`. */
110
+ let stdoutTruncated = false;
111
+ let stderrTruncated = false;
105
112
  let exitOutcome = { exitCode: null, signal: "SIGTERM" };
106
113
 
107
114
  let settleError;
@@ -109,6 +116,8 @@ export function agentExecutionHandle({ agent, cwd, argv, timeoutMs = 0, signal,
109
116
  if (ok) {
110
117
  stdout = result.stdout;
111
118
  stderr = result.stderr;
119
+ stdoutTruncated = result.truncated?.stdout === true;
120
+ stderrTruncated = result.truncated?.stderr === true;
112
121
  // The distro timeout and kill() both mean "we stopped it"; a genuine
113
122
  // exit keeps its code.
114
123
  if (timedOut || killed || callerAborted) exitOutcome = { exitCode: null, signal: "SIGTERM" };
@@ -175,16 +184,17 @@ export function agentExecutionHandle({ agent, cwd, argv, timeoutMs = 0, signal,
175
184
  if (settleError !== undefined) throw settleError;
176
185
  // `truncated` is REQUIRED by the bash tool's canonical result — it
177
186
  // copies the field unconditionally, and an `undefined` fails the wire's
178
- // lossless-JSON snapshot ("value is not lossless JSON").
179
- const output = (text) => ({ text, lossy: false, truncated: false });
187
+ // lossless-JSON snapshot ("value is not lossless JSON"). The agent's
188
+ // capture-cap flags are what make it TRUE instead of a perma-lie.
189
+ const output = (text, truncated) => ({ text, lossy: false, truncated });
180
190
  return {
181
191
  exitCode: proc.exitCode,
182
192
  signal: proc.signal,
183
193
  timedOut,
184
194
  aborted: callerAborted && !timedOut,
185
195
  timeoutMs,
186
- stdout: output(stdout.toString("utf8")),
187
- stderr: output(stderr.toString("utf8")),
196
+ stdout: output(stdout.toString("utf8"), stdoutTruncated),
197
+ stderr: output(stderr.toString("utf8"), stderrTruncated),
188
198
  };
189
199
  });
190
200
  },
@@ -11,7 +11,14 @@
11
11
  */
12
12
 
13
13
  /** The protocol version this host side speaks; the agent must match. */
14
- export const PROTOCOL_VERSION = 1;
14
+ export const PROTOCOL_VERSION = 3;
15
+
16
+ /**
17
+ * Upper bound on one `read` op's payload, set by the HOST looping over windowed
18
+ * requests. Keeping each frame's decoded payload here means the agent's RES
19
+ * line stays modest even for a large file; the host accumulates chunks.
20
+ */
21
+ export const FS_READ_CHUNK_BYTES = 1024 * 1024;
15
22
 
16
23
  /** The agent identifies itself with this name in its HELLO line. */
17
24
  export const AGENT_NAME = "wsl-agent";
@@ -47,20 +54,26 @@ export function decodeB64(value) {
47
54
 
48
55
  /**
49
56
  * Build the request lines for one EXEC frame: a header line naming the cwd,
50
- * the whole-request timeout in whole seconds (0 = none) and the argv word
51
- * count, followed by one base64 line per argv word.
57
+ * the whole-request timeout in whole seconds (0 = none), the argv word count
58
+ * and the per-stream output capture budget in bytes (0 = unlimited), followed
59
+ * by one base64 line per argv word.
52
60
  * @param {object} request - the execution request.
53
61
  * @param {string} request.id - caller-chosen request id (unique per agent).
54
62
  * @param {string} request.cwd - Linux path to cd into before exec.
55
63
  * @param {string[]} request.argv - the argv to execute, already bwrap-wrapped.
56
64
  * @param {number} [request.timeoutMs] - in-distro timeout; sub-second
57
65
  * remainders round up, and `<= 0` disables the timeout.
66
+ * @param {number} [request.maxOutputBytes] - per-stream capture ceiling the
67
+ * AGENT enforces: stdout and stderr are each cut at this many bytes and the
68
+ * RES line reports what was cut. 0 (the default) keeps the historical
69
+ * unbounded capture.
58
70
  * @returns {string[]} the lines to write to the agent's stdin, in order.
59
71
  */
60
- export function encodeExecFrame({ id, cwd, argv, timeoutMs = 0 }) {
72
+ export function encodeExecFrame({ id, cwd, argv, timeoutMs = 0, maxOutputBytes = 0 }) {
61
73
  const timeoutSeconds = timeoutMs > 0 ? Math.ceil(timeoutMs / 1000) : 0;
74
+ const cap = maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0;
62
75
  const lines = [
63
- `EXEC|${id}|${encodeB64(cwd)}|${timeoutSeconds}|${argv.length}`,
76
+ `EXEC|${id}|${encodeB64(cwd)}|${timeoutSeconds}|${argv.length}|${cap}`,
64
77
  ...argv.map((word) => encodeB64(word)),
65
78
  ];
66
79
  return lines;
@@ -82,6 +95,38 @@ export function encodeKill(id) {
82
95
  return `KILL|${id}`;
83
96
  }
84
97
 
98
+ /**
99
+ * Build the request lines for one FS frame — the filesystem-substrate request
100
+ * that lets the model's file tools run inside the distro. The shape mirrors
101
+ * EXEC: a header naming the op, the timeout in whole seconds and the argument
102
+ * count, followed by one base64 line per argument.
103
+ *
104
+ * Ops (see `agent/wsl-agent.sh` for the server side and the wire grammar):
105
+ * `stat`, `lstat`, `list`, `realpath`, `read`, `write`. Responses reuse the
106
+ * EXEC `ACK`/`RES`/`ERR` lines, so the request machinery (pending map, KILL,
107
+ * the watchdog) is shared unchanged.
108
+ *
109
+ * The argument contract: callers pass PLAINTEXT values here; this frame is the
110
+ * single base64 boundary, and the agent decodes each argument once. A write's
111
+ * content is no exception — the agent pipes its still-encoded wire line
112
+ * straight to `base64 -d`, so raw bytes never pass through a shell variable.
113
+ *
114
+ * @param {object} request - the filesystem request.
115
+ * @param {string} request.id - caller-chosen request id (unique per agent).
116
+ * @param {string} request.op - one of the op names above.
117
+ * @param {string[]} request.args - op arguments, base64-line encoded here.
118
+ * @param {number} [request.timeoutMs] - in-distro timeout; sub-second
119
+ * remainders round up, and `<= 0` disables the timeout.
120
+ * @returns {string[]} the lines to write to the agent's stdin, in order.
121
+ */
122
+ export function encodeFsFrame({ id, op, args, timeoutMs = 0 }) {
123
+ const timeoutSeconds = timeoutMs > 0 ? Math.ceil(timeoutMs / 1000) : 0;
124
+ return [
125
+ `FS|${id}|${op}|${timeoutSeconds}|${args.length}`,
126
+ ...args.map((word) => encodeB64(word)),
127
+ ];
128
+ }
129
+
85
130
  /** The line parser's verdict for one line of agent stdout. */
86
131
 
87
132
  /**
@@ -90,6 +135,9 @@ export function encodeKill(id) {
90
135
  * @returns {object} one of:
91
136
  * - `{ type: "hello", name, version }`
92
137
  * - `{ type: "pong" }`
138
+ * - `{ type: "ack", id }` — the agent dequeued the request for execution;
139
+ * the host arms its stuck-request watchdog here, so queue time never
140
+ * counts against a budget
93
141
  * - `{ type: "result", id, exitCode, stdout, stderr }` — outputs as Buffers
94
142
  * - `{ type: "agentError", id, reason, message }` — `id` is `""` when the
95
143
  * agent reports a request-less problem (a protocol violation)
@@ -104,9 +152,19 @@ export function parseAgentLine(line) {
104
152
  return { type: "hello", name, version: Number(version) };
105
153
  }
106
154
  if (line === "PONG") return { type: "pong" };
155
+ if (line.startsWith("ACK|")) return { type: "ack", id: line.slice(4) };
107
156
  if (line.startsWith("RES|")) {
108
- const [, id, exitCode, stdout, stderr] = line.split("|");
109
- return { type: "result", id, exitCode: Number(exitCode), stdout: decodeB64(stdout ?? ""), stderr: decodeB64(stderr ?? "") };
157
+ const [, id, exitCode, stdout, stderr, outTruncated, errTruncated] = line.split("|");
158
+ return {
159
+ type: "result",
160
+ id,
161
+ exitCode: Number(exitCode),
162
+ stdout: decodeB64(stdout ?? ""),
163
+ stderr: decodeB64(stderr ?? ""),
164
+ // The capture-cap flags the agent appends when the EXEC frame carried a
165
+ // budget; frames without them (fakes, FS ops) are simply not truncated.
166
+ truncated: { stdout: outTruncated === "1", stderr: errTruncated === "1" },
167
+ };
110
168
  }
111
169
  if (line.startsWith("ERR|")) {
112
170
  const [, id, reason, message] = line.split("|");