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.
- package/CHANGELOG.md +165 -0
- package/README.md +2 -0
- package/agent/wsl-agent.sh +368 -26
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +37 -1
- package/docs/CONFIGURATION.md +4 -1
- package/docs/LIMITATIONS.md +60 -0
- package/docs/PEER-PARITY.md +60 -0
- package/docs/README.zh.md +2 -0
- package/docs/UPSTREAM-FSIO-EXPORT.md +87 -0
- package/lib/agent-confined.js +81 -0
- package/lib/agent-exec.js +22 -12
- package/lib/agent-protocol.js +65 -7
- package/lib/agent.js +211 -61
- package/lib/bwrap.js +74 -0
- package/lib/fs-substrate.js +285 -0
- package/lib/fsio-agent.js +481 -0
- package/lib/fsio-text.js +153 -0
- package/lib/index.js +239 -6
- package/lib/sandbox.js +32 -27
- package/package.json +5 -2
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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) |
|
|
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
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
-
| | `
|
|
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 |
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -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,
|
|
10
|
-
* hidden:
|
|
9
|
+
* deviations, documented here rather than hidden:
|
|
11
10
|
*
|
|
12
|
-
* - **No spill files
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
},
|
package/lib/agent-protocol.js
CHANGED
|
@@ -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 =
|
|
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)
|
|
51
|
-
*
|
|
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 {
|
|
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("|");
|