dsh-plugin-wsl-env 0.2.0 → 0.4.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/docs/PARITY.md CHANGED
@@ -11,7 +11,7 @@ distro. Everything users love — native ext4 semantics, inotify, native
11
11
  `rg`, low latency — is a derivative. This plugin's 9p-UNC plus one-shot
12
12
  `wsl.exe` model can only approach it. The plan therefore lands a resident
13
13
  in-distro agent and migrates the `fs` and `shell` seams onto it, keeping the
14
- current paths as the documented fallback (`wsl.agent: 'off'` must equal
14
+ current paths as the documented fallback (`wsl.agent: false` must equal
15
15
  today's behaviour byte for byte).
16
16
 
17
17
  Not applicable, recorded so the gap is a decision and not an omission:
@@ -64,7 +64,8 @@ shaped a phase of the plan.
64
64
 
65
65
  ## Fallback invariant
66
66
 
67
- Every phase must keep `wsl.agent: 'off'` (and, per capability, any runtime
67
+ Every phase must keep `wsl.agent: false` (and, per capability, any runtime
68
68
  fallback) behaviourally identical to the pre-phase release. Probes assert
69
- this: `agent.sh` runs the same operation with the agent off and on and
70
- compares outcomes, not internals.
69
+ this: `agent.sh` runs the same operations through the resident and through
70
+ the one-shot `wsl.exe` path and compares outcomes — exit code, stdout bytes,
71
+ stderr bytes, working directory — not internals.
@@ -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,87 @@
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
+ * @param {boolean} [request.realTmp] - default TRUE: workspace-write binds the
52
+ * distro's real `/tmp` read-write into the resident's profile, so a write
53
+ * the fence approved lands where every reader reads. `false` restores the
54
+ * ephemeral tmpfs — for tests and probes, not production: a `/tmp` write
55
+ * behind an ephemeral mount reports success into a directory no reader can
56
+ * see, which is the bug this default exists to prevent.
57
+ * @returns {WslAgent} the confined resident for this key.
58
+ */
59
+ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindowsDrive = false, realTmp = true }) {
60
+ const key = `${wslPath}\0${distro}\0${policy.mode}\0${policy.workspaceRoot}\0${maskWindowsDrive ? "masked" : "visible"}\0${realTmp ? "real" : "ephemeral"}`;
61
+ let agent = confinedAgents.get(key);
62
+ if (agent === undefined) {
63
+ agent = new WslAgent({
64
+ distro,
65
+ wslPath,
66
+ scriptPath: toLinuxPath(AGENT_SCRIPT_PATH),
67
+ // The prefix is a whole command from the distro's point of view — program,
68
+ // profile, `--` — because the transport inserts it between
69
+ // `wsl.exe --exec` and the `sh <script>` pair (`lib/agent.js`'s
70
+ // `spawnDefault`). Both confinement sites share that assembly in
71
+ // `lib/bwrap.js`, whose module doc records what happened when they did not.
72
+ argvPrefix: bwrapArgvPrefix(
73
+ { mode: policy.mode, workspaceRoot: toLinuxPath(policy.workspaceRoot, { distro }) },
74
+ { maskWindowsDrive, realTmp },
75
+ ),
76
+ });
77
+ confinedAgents.set(key, agent);
78
+ }
79
+ return agent;
80
+ }
81
+
82
+ /** Test hook: forget every confined resident (the plain agent has no factory state). */
83
+ export function resetConfinedAgents() {
84
+ confinedAgents.clear();
85
+ }
86
+
87
+ 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("|");