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/CHANGELOG.md +294 -0
- package/README.md +2 -0
- package/agent/wsl-agent.sh +413 -27
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +37 -1
- package/docs/CONFIGURATION.md +4 -1
- package/docs/LIMITATIONS.md +67 -1
- package/docs/PARITY.md +5 -4
- 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 +87 -0
- package/lib/agent-exec.js +22 -12
- package/lib/agent-protocol.js +65 -7
- package/lib/agent.js +279 -64
- package/lib/bwrap.js +91 -0
- package/lib/fs-decisions.js +132 -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 +271 -26
- package/lib/paths.js +14 -0
- package/lib/sandbox.js +32 -27
- package/lib/shell-env.js +6 -1
- package/package.json +6 -3
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:
|
|
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:
|
|
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
|
|
70
|
-
compares outcomes,
|
|
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,
|
|
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("|");
|