dsh-plugin-wsl-env 0.15.0 → 0.17.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +144 -15
  2. package/CONTRIBUTING.md +25 -33
  3. package/README.md +10 -10
  4. package/SECURITY.md +6 -4
  5. package/SUPPORT.md +8 -8
  6. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  7. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  8. package/agent/wsl-agent.sh +51 -12
  9. package/cordis.patch.yml +23 -10
  10. package/docs/ARCHITECTURE.md +56 -41
  11. package/docs/COMPATIBILITY.md +121 -0
  12. package/docs/CONFIGURATION.md +275 -273
  13. package/docs/LIMITATIONS.md +114 -38
  14. package/docs/MIGRATION.md +16 -13
  15. package/docs/PARITY.md +4 -3
  16. package/docs/PERFORMANCE.md +11 -9
  17. package/docs/QUICKSTART.md +46 -21
  18. package/docs/README.zh.md +7 -7
  19. package/docs/RELEASING.md +257 -0
  20. package/docs/REQUIREMENTS.zh.md +77 -0
  21. package/docs/ROADMAP-1.0.md +105 -61
  22. package/docs/ROADMAP.md +15 -9
  23. package/docs/UPSTREAM-FILE-REFERENCE-SEAM.md +3 -3
  24. package/docs/UPSTREAM-FSIO-EXPORT.md +7 -7
  25. package/docs/UPSTREAM-OPEN-IN-APP.md +57 -0
  26. package/docs/UPSTREAM-SESSION-NOTICE.md +4 -4
  27. package/docs/UPSTREAM-TERMINAL-TITLE.md +10 -12
  28. package/docs/VALUE.zh.md +144 -0
  29. package/docs/adr/0002-native-agent.md +348 -0
  30. package/docs/root-fs-routing.md +186 -0
  31. package/docs/screenshots/picker.png +0 -0
  32. package/docs/screenshots/preview.png +0 -0
  33. package/docs/screenshots/sandbox.png +0 -0
  34. package/docs/screenshots/terminal.png +0 -0
  35. package/lib/agent-exec.d.ts +7 -3
  36. package/lib/agent-protocol.d.ts +18 -5
  37. package/lib/agent-protocol.js +11 -6
  38. package/lib/agent-shared.js +1 -0
  39. package/lib/agent.d.ts +28 -9
  40. package/lib/agent.js +74 -14
  41. package/lib/fs-routing.d.ts +2 -1
  42. package/lib/fs-routing.js +13 -1
  43. package/lib/fs-substrate.d.ts +11 -9
  44. package/lib/fs-substrate.js +6 -9
  45. package/lib/fsio-agent.d.ts +2 -3
  46. package/lib/fsio-agent.js +2 -3
  47. package/lib/git-exec.d.ts +117 -0
  48. package/lib/git-exec.js +105 -0
  49. package/lib/git-route.d.ts +43 -13
  50. package/lib/git-route.js +54 -15
  51. package/lib/index.d.ts +20 -13
  52. package/lib/index.js +18 -11
  53. package/lib/session-runtime.d.ts +5 -3
  54. package/lib/session-runtime.js +5 -4
  55. package/lib/subprocess.d.ts +21 -7
  56. package/lib/subprocess.js +63 -9
  57. package/locale/en.json +1 -1
  58. package/locale/zh.json +1 -1
  59. package/package.json +13 -4
  60. package/scripts/diagnose.sh +1 -1
  61. package/scripts/doctor.mjs +23 -3
@@ -0,0 +1,186 @@
1
+ # 根平面按坐标路由的 fs — 设计文档
2
+
3
+ > 状态:已实现,随 0.7.2 发布。源码是 `src/fs-routing.ts`,
4
+ > `lib/fs-routing.js` 是其编译产物;当前开发依赖及 peer 范围以
5
+ > `package.json` 为准。
6
+ > 时效:§5 的验证与回退小节于 2026-10-31 对照现行测试与探针清单复核
7
+ > (组合测试 12 项、单测 4 项、真机套件 15 项含 `fs-routing.sh`);
8
+ > 其余章节保留设计时的历史表述。
9
+ > 关联:promotion-map 附录 C(workspace-files 接管评审)、附录 B(剩余 9P
10
+ > 消费者)、`docs/upstream/rfc-wsl-workspaces.md` 增补(根平面路由 fs 的
11
+ > 上游 seam 提案)。
12
+
13
+ ## 1. 问题
14
+
15
+ desktop 组合的根 `ctx.fs`(`fs-sandbox` 行,`SandboxedFileSystem extends
16
+ LocalFileSystem`)是 session-less 的:它不知道会话,只知道部署默认的
17
+ workspace root。对一个 `\\wsl.localhost\<distro>` 工作区,它的每一次调用都
18
+ 落在 9P share 上——读慢 200 倍、watch(chokidar v4 → `fs.watch`)在 9P 上
19
+ 没有可用事件、写路径的 NTFS 原语(`GetFileSecurityW`/`ReplaceFileW`/硬链接)
20
+ 直接失败。而 preset isolate 里的 `wsl-fs` 帮不到它:根消费者不经过 preset。
21
+
22
+ 受影响的根消费者(调研确认):
23
+
24
+ - `workspace-files` 的 **change feed**(`WorkspaceChangeFeed` →
25
+ `ctx.fs.watch`):WSL 工作区上 GUI 文件树不自动刷新;
26
+ - `workspace-changes` 的 fs 侧与未来任何根消费者(默认继承同样的 9P 命运);
27
+ - LSP(未挂载)——若挂载,其 fs 调用也会落在这里(但 LSP 的完整解法是
28
+ 成对远程 provider,见 rfc 增补)。
29
+
30
+ ## 2. 设计
31
+
32
+ `WslRoutingFileSystem extends LocalFileSystem`,替换根 `fs-sandbox` 行。
33
+ 每个操作按**目标的坐标系**路由:
34
+
35
+ ```
36
+ targetKey(或 path/cwd)是 \\wsl.localhost\<distro> UNC
37
+ → distro substrate(懒加载的 WslFileSystem 实例:常驻 agent 上的
38
+ canonical/stat/读/listDir/watch,使用目标路径所在的发行版文件系统)
39
+ 否则(盘符、相对路径解析到盘符)
40
+ → super(宿主 LocalFileSystem:NTFS 原生,含 chokidar watch)
41
+ ```
42
+
43
+ 一个 `WslFileSystem` 实例内部再按 UNC 的 distro 选择自己的 substrate、agent 与
44
+ home 缓存,所以同一个根平面可以同时服务多个发行版;`distro: ''`/`auto` 是默认,
45
+ 显式名字才是 pin(此时外来 distro 仍由 `restrictToDistro` 拒绝)。
46
+
47
+ 判定是**纯坐标**:不看会话、不看 preset。这正是"根平面 session-less"约束下
48
+ 唯一自洽的形态——它不放弃 per-session 语义,因为根平面本来就没有;工具写入
49
+ 的 per-session 围栏在 preset 的 `wsl-fs`/`fs-sandbox` 里,不在这里。
50
+
51
+ ### 2.1 操作路由表
52
+
53
+ | 操作 | 判据 | distro 侧 | 宿主侧 |
54
+ |---|---|---|---|
55
+ | resolve / lstat | path 或 cwd 的坐标 | substrate resolve/lstat | super |
56
+ | stat / listDir / readText / streamText / readBytes / readByteRange | targetKey 坐标 | substrate 对应操作 | super |
57
+ | watch | targetKey 坐标 | distro inotify 或轮询(粗粒度失效) | chokidar |
58
+ | writeText / editText | targetKey 坐标 | distro 写(substrate 守卫 + confined agent 内核强制) | super |
59
+ | contains / processPath | parent/targetKey 坐标 | Linux 路径语义(`contains` 按两个目标的 `processPath` 做 POSIX 包含判定) | super |
60
+
61
+ 未覆写的出口:本类覆写的是上表这 13 个方法,`fileUrl` 与
62
+ `processPathFromHostPath` 沿用宿主实现——「按坐标路由」不等于接口全覆盖。
63
+
64
+ - `fileUrl` 由继承的实现从 `processPath` 派生
65
+ (`pathToFileURL(this.processPath(target)).href`,`dsh-fs-local/lib/index.js:795-796`):
66
+ distro 目标的 Linux 路径走的是**宿主平台**的编码路径,在 Windows 上 Node
67
+ 先 `path.win32.resolve` 它,`file:///home/...` 不是可靠的形态。接口把 URI
68
+ 编码留给后端,正是因为执行平台可能与宿主不同
69
+ (`dsh-fs/lib/types/index.d.ts:117-123`);真有消费者时需要按 POSIX 语义覆写。
70
+ - `processPathFromHostPath` 也是宿主实现(绝对宿主路径取 `resolve`)。被路由的
71
+ `WslFileSystem` 自己有一份 distro 映射(`src/index.ts:1412`),那份映射不经
72
+ 本路由。
73
+
74
+ ### 2.2 有意的行为差异(对照原根 fs-sandbox)
75
+
76
+ - **根围栏退役**:fs-sandbox 的 write 围栏(deployment workspaceRoot 词法
77
+ 检查)只护写路径,而根平面当前没有写消费者(tool-fs 的写在 preset)。
78
+ distro 侧写入自带更强的围栏(Linux 前缀包含 + confined agent 内核强制)。
79
+ 文档明示:若未来出现根平面写消费者,围栏语义需随行。
80
+ - **UNC 目标的 `processPath` 变为 Linux 形态**:`processPath` 返回 distro
81
+ 路径,而读它的根消费者仍在——正是本路由所服务的 workspace-files change
82
+ feed:它在每个 `change` 事件里发出 `ctx.fs.processPath(target)`
83
+ (`@deepseek-ai/dsh-api-workspace-files/lib/index.js:92`),distro 工作区的事件路径因此
84
+ 是 Linux 形态。GUI 文件服务现在也用随包的 `workspace-files` 行,`stat`/读
85
+ 同样经 `ctx.fs.processPath`,两种形态一致;此前的 `workspace-files-wsl`
86
+ 接管把 `stat` 的 `absolutePath` 合成 UNC,已随该行删除。
87
+ `fileUrl` 不在覆写之列(§2.1 注):它由 `processPath` 派生,同样不能假定为
88
+ `file:///home/...`。
89
+ - **watch 的事件粒度**:无限深目录且有 `inotifywait` 时,distro 侧优先使用
90
+ inotify;文件目标、限深目录或缺少工具时回退轮询。回调是粗粒度失效而非
91
+ 逐文件事件;轮询的 mtime 盲点见 [LIMITATIONS.md](LIMITATIONS.md)。
92
+
93
+ ### 2.3 懒加载与失败语义
94
+
95
+ distro 实例(WslFileSystem)在第一次 UNC 路由时构建;其内部的 distro 解析
96
+ (`wsl.exe -l -q`)与常驻 agent 启动都是懒的。失败语义沿用各层既有行为:
97
+ distro 停止 → substrate 报 `FS_IO_ERROR`(fail-closed,不回 9P);bwrap
98
+ 缺失 → confined 写拒绝并给出 bootstrap 指引;`sandbox: false` 是文档化的
99
+ 退出项。
100
+
101
+ 构建后端有三个 cordis 约束,改这里时必须保留:
102
+
103
+ - **状态不能用 `#private` 字段。** 服务方法由 cordis 以影子接收者调用
104
+ (traceable proxy 的 `createShadowMethod`),私有成员不跨代理;`this.#distro`
105
+ 会抛 `Cannot read private member … from an object whose class did not
106
+ declare it`。宿主基类自己的 `config` / `locks` 就是普通字段。
107
+ - **后端要在一个隔离了 `fs` 的子作用域里构造。** 它本身是 `Service`,构造时
108
+ 会注册 `fs`;用路由自己的作用域会得到 `service "fs" has been registered`。
109
+ `isolate("fs")` 只重映射 `fs`,后端读取的 `sandboxPolicy` 仍从父作用域
110
+ 解析。构造上下文要在构造函数里捕获:服务方法里的 `this.ctx` 是**调用方**
111
+ 的上下文(cordis 用于依赖追踪),而首个路由调用可能来自任意消费者 fiber。
112
+ - **路由类要声明 `static inject = ["sandboxPolicy"]`。** 被路由的后端在构造
113
+ 时读取部署默认模式 `ctx.sandboxPolicy.defaultMode`;该服务由**兄弟行**
114
+ 提供,不是祖先,fiber 不声明依赖就取不到,会报
115
+ `cannot get property "sandboxPolicy" without inject`。被本类替换的
116
+ `fs-sandbox` 行同样声明了这一依赖。
117
+
118
+ ## 3. 挂载
119
+
120
+ cordis.patch.yml:禁根 `fs-sandbox` 行 + 插入 `fs-routing` 行(本插件
121
+ export `./fs-routing`)。config 承载两侧旋钮:宿主侧 `cwd`/`diffBasisMaxBytes`
122
+ (原 LocalConfig),distro 侧 `distro`/`wslPath`/`distroCwd`/`sandbox`/
123
+ `maskWindowsDrive`/`restrictToDistro`/`watchMaxDepth`(原 wsl-fs 旋钮)。
124
+ 分工要看准:`cwd` 只作用于宿主后端,distro 后端的工作目录是 `distroCwd`
125
+ (空 = distro 用户 home,即原 `wsl-fs` 的 `cwd`),而 `diffBasisMaxBytes`
126
+ 两侧共用。逐键语义与默认值见 `docs/CONFIGURATION.md` 的 `fs-routing` 行。
127
+
128
+ ## 4. 与其它件的关系
129
+
130
+ - **workspace-files 行不再替换**:GUI 文件树/预览使用随包的
131
+ `workspace-files` 行,经本路由读写 WSL 路径;比原来的直连 agent 多 4–6 次
132
+ 往返(见 [LIMITATIONS](LIMITATIONS.md))。本路由同时服务它的 `changes`
133
+ 流——那条流经根 fs 做 resolve/stat/watch/contains,并在每个事件里取
134
+ `processPath`(§2.2)。
135
+ - **`@` 补全**:仍需上游 seam(遍历漏斗是模块内函数,不经 ctx.fs)——本路由
136
+ 帮不到它,RFC 增补①的提案不变。
137
+ - **晋升**:若上游收编,本模块即 RFC 增补②"coordinate-routing root
138
+ filesystem"的实现底稿。
139
+
140
+ ## 5. 验证与回退
141
+
142
+ 仓库里可跑的:
143
+
144
+ - `node test/composition.test.mjs`:把本插件的 `cordis.patch.yml` 过真实的
145
+ `applyEntryPatches`,再让 `fs-routing` 行真的经 Loader 启动,断言组合树里
146
+ `fs-routing` 是真实的顶层行、`fs-sandbox` 按名禁用,以及
147
+ `context.fs instanceof WslRoutingFileSystem`。同一检查随后**经 `ctx.fs`
148
+ 服务代理**调用 `distro()`,断言后端只构建一次、根 `fs` 不被后端的服务
149
+ 注册替换,并对整张路由表逐项断言坐标分派(distro 侧在 backend surface
150
+ 打桩,宿主侧落到临时目录)。0.2.1 的 `working-directory` 行经根
151
+ `ctx.fs` 解析会话 cwd 时,正是这条路径第一次被真实消费者执行——
152
+ cordis 的影子接收者会让 `#private` 字段抛错,二次注册 `fs` 会让后端构造
153
+ 抛错,缺 `sandboxPolicy` 注入会让构造抛错,三处回归都由该检查钉住。
154
+ 12/12 通过。
155
+ - `node test/fs-routing.test.mjs`:覆盖纯谓词与路径助手(`targetIsDistro`/
156
+ `distroContains`/`inputIsDistro`/`linuxJoin`);类本体改由上一项的组合测试经
157
+ 服务代理覆盖。该文件自 2026-10-19 起已在 `test:unit` 与 `test:coverage`
158
+ 清单里(`docs/REQUIREMENTS.zh.md` 的 OI-7,已解决)。
159
+ - 真机探针全套(`test/probe/run-all-when-closed.sh --include-fs`,14 项常规
160
+ + `run.sh` 共 15 项)覆盖 agent、watch、substrate 等底座,并包含本路由的
161
+ `test/probe/fs-routing.sh`:临时 profile 以本路由为根 `fs`,经 UNC 坐标读
162
+ distro 内 ext4 文件、经盘符路径读 Windows 文件,并拒绝另一 distro 的 UNC。
163
+
164
+ 真机上判断目标是哪个后端:在 distro 工作区里停掉 distro
165
+ (`wsl.exe --terminate <distro>`),根级对 UNC 目标的调用应 fail-closed 报
166
+ `FS_IO_ERROR`(§2.3),而不是回落到 9P;同一次验证里宿主盘符路径照常工作。
167
+
168
+ `changes` 与 `stat` 的 `absolutePath` 现在都来自 `ctx.fs.processPath`,都是
169
+ Linux 形态;客户端归一化后可比对。此前的 `workspace-files-wsl` 接管让
170
+ `stat` 返回 UNC、与 `changes` 不一致;该接管已删除,这一形态差异不再存在。
171
+
172
+ 回退:`fs-routing` 是顶层行,可以在你自己的 profile 层
173
+ (`$DSH_HOME/profiles/<name>/cordis.patch.yml`,按 id 覆盖插件行)里恢复随发的
174
+ 根 fs:
175
+
176
+ ```yaml
177
+ - id: fs-routing
178
+ disabled: true
179
+ - id: fs-sandbox
180
+ disabled: false
181
+ ```
182
+
183
+ 此后 UNC 目标回到 9P(慢、无可用 watch 事件),也就是路由落地前的行为。这个
184
+ 补丁的形状在本仓核对过:把它叠在插件补丁之后,`fs-sandbox` 的 `disabled`
185
+ 回到 `false`、`fs-routing` 变为 `disabled: true`(用
186
+ `@deepseek-ai/cordis-plugin-include` 的 `applyEntryPatches` 实测)。
Binary file
Binary file
Binary file
Binary file
@@ -40,6 +40,11 @@ export interface AgentExecRequest {
40
40
  env?: Record<string, string>;
41
41
  /** Per-stream capture ceiling. */
42
42
  maxOutputBytes?: number;
43
+ /**
44
+ * The child's entire stdin (protocol v6); the child sees EOF at the
45
+ * payload's end. Omitted or empty keeps the v5 shape.
46
+ */
47
+ stdin?: Uint8Array;
43
48
  /** The caller's cancellation. */
44
49
  signal?: AbortSignal;
45
50
  }
@@ -152,9 +157,8 @@ export declare function agentExecutionHandle({ agent, cwd, argv, timeoutMs, maxO
152
157
  maxOutputBytes?: number;
153
158
  /**
154
159
  * Environment for this command: the caller's entries with the managed
155
- * `DSH_*` snapshot merged last. The agent applies it as `SETENV` frames
156
- * immediately before the command's `EXEC` (see `WslAgent.exec`); dropping it
157
- * here is what kept every managed fact out of the resident-agent path.
160
+ * `DSH_*` snapshot merged last. The agent applies it to this request only
161
+ * (see `WslAgent.exec`).
158
162
  */
159
163
  env?: Record<string, string>;
160
164
  /** The caller's cancellation. */
@@ -14,7 +14,7 @@
14
14
  * @module dsh-plugin-wsl/agent-protocol
15
15
  */
16
16
  /** The protocol version this host side speaks; the agent must match. */
17
- export declare const PROTOCOL_VERSION = 5;
17
+ export declare const PROTOCOL_VERSION = 6;
18
18
  /**
19
19
  * Upper bound on one `read` op's payload, set by the HOST looping over windowed
20
20
  * requests. Keeping each frame's decoded payload here means the agent's RES
@@ -65,17 +65,30 @@ export interface ExecFrameRequest {
65
65
  * default) keeps the historical unbounded capture.
66
66
  */
67
67
  maxOutputBytes?: number;
68
+ /**
69
+ * The child's entire stdin, delivered before the first read. Protocol v6:
70
+ * when present the EXEC header gains a `1` seventh field and one extra
71
+ * base64 line follows the argv words; the agent feeds those bytes to the
72
+ * child and closes its stdin (the child sees EOF at the payload's end). The
73
+ * payload rides one protocol line, so it is bounded by `MAX_FRAME_BYTES` —
74
+ * the shape of every known consumer (`git check-ignore --stdin` pathspecs),
75
+ * none of which streams an unbounded feed. Absent or empty means the child
76
+ * reads an empty stdin and immediate EOF, exactly the v5 behaviour.
77
+ */
78
+ stdin?: Uint8Array;
68
79
  }
69
80
  /**
70
81
  * Build the request lines for one EXEC frame: a header line naming the cwd,
71
- * the whole-request timeout in whole seconds (0 = none), the argv word count
72
- * and the per-stream output capture budget in bytes (0 = unlimited), followed
73
- * by one base64 line per argv word.
82
+ * the whole-request timeout in whole seconds (0 = none), the argv word count,
83
+ * the per-stream output capture budget in bytes (0 = unlimited) and — since
84
+ * protocol v6 — the stdin flag (1 when a payload line follows the argv
85
+ * words), followed by one base64 line per argv word and, when flagged, one
86
+ * base64 stdin payload line.
74
87
  *
75
88
  * @param request - the execution request.
76
89
  * @returns the lines to write to the agent's stdin, in order.
77
90
  */
78
- export declare function encodeExecFrame({ id, cwd, argv, timeoutMs, maxOutputBytes }: ExecFrameRequest): string[];
91
+ export declare function encodeExecFrame({ id, cwd, argv, timeoutMs, maxOutputBytes, stdin }: ExecFrameRequest): string[];
79
92
  /**
80
93
  * Build a SETENV request line. Note the agent's decode strips trailing
81
94
  * newlines from the value — the same leniency the shell itself applies.
@@ -14,7 +14,7 @@
14
14
  * @module dsh-plugin-wsl/agent-protocol
15
15
  */
16
16
  /** The protocol version this host side speaks; the agent must match. */
17
- export const PROTOCOL_VERSION = 5;
17
+ export const PROTOCOL_VERSION = 6;
18
18
  /**
19
19
  * Upper bound on one `read` op's payload, set by the HOST looping over windowed
20
20
  * requests. Keeping each frame's decoded payload here means the agent's RES
@@ -54,20 +54,25 @@ export function decodeB64(value) {
54
54
  }
55
55
  /**
56
56
  * Build the request lines for one EXEC frame: a header line naming the cwd,
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.
57
+ * the whole-request timeout in whole seconds (0 = none), the argv word count,
58
+ * the per-stream output capture budget in bytes (0 = unlimited) and — since
59
+ * protocol v6 — the stdin flag (1 when a payload line follows the argv
60
+ * words), followed by one base64 line per argv word and, when flagged, one
61
+ * base64 stdin payload line.
60
62
  *
61
63
  * @param request - the execution request.
62
64
  * @returns the lines to write to the agent's stdin, in order.
63
65
  */
64
- export function encodeExecFrame({ id, cwd, argv, timeoutMs = 0, maxOutputBytes = 0 }) {
66
+ export function encodeExecFrame({ id, cwd, argv, timeoutMs = 0, maxOutputBytes = 0, stdin }) {
65
67
  const timeoutSeconds = timeoutMs > 0 ? Math.ceil(timeoutMs / 1000) : 0;
66
68
  const cap = maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0;
69
+ const hasStdin = stdin !== undefined && stdin.length > 0;
67
70
  const lines = [
68
- `EXEC|${id}|${encodeB64(cwd)}|${timeoutSeconds}|${argv.length}|${cap}`,
71
+ `EXEC|${id}|${encodeB64(cwd)}|${timeoutSeconds}|${argv.length}|${cap}|${hasStdin ? 1 : 0}`,
69
72
  ...argv.map((word) => encodeB64(word)),
70
73
  ];
74
+ if (hasStdin)
75
+ lines.push(encodeB64(stdin));
71
76
  return lines;
72
77
  }
73
78
  /**
@@ -60,6 +60,7 @@ export function sharedAgent(distro) {
60
60
  scriptPath: prepared.path,
61
61
  expectedDigest: prepared.digest,
62
62
  signalable: false,
63
+ serialRequests: false,
63
64
  spawnTransport: ({ wslPath, distro: target, argvPrefix }) => spawn(wslPath, ["-d", target, "--exec", ...argvPrefix, prepared.path], {
64
65
  stdio: ["pipe", "pipe", "pipe"],
65
66
  windowsHide: true,
package/lib/agent.d.ts CHANGED
@@ -65,7 +65,7 @@ export interface PendingRequest {
65
65
  seq: number;
66
66
  /** Which metric bucket this request belongs to. */
67
67
  kind: "fs" | "exec";
68
- /** When the frame was written, for the queue measurement. */
68
+ /** When the request was submitted, for the queue measurement. */
69
69
  sentAt: number;
70
70
  /** When the agent ACKed it, for the execution measurement. */
71
71
  ackedAt?: number;
@@ -87,7 +87,9 @@ export interface PendingRequest {
87
87
  armWatchdog: (() => void) | null;
88
88
  /** Whether the agent has ACKed this request (it is the running one). */
89
89
  started: boolean;
90
- /** The caller cancelled it; the entry stays until the agent answers. */
90
+ /** Whether the request frame has been sent to the agent. */
91
+ sent: boolean;
92
+ /** The caller cancelled it; a sent entry stays until the agent answers. */
91
93
  cancelled: boolean;
92
94
  /** The caller was already settled; a late answer only cleans up. */
93
95
  settled: boolean;
@@ -100,7 +102,7 @@ export interface PendingRequest {
100
102
  export interface RequestMetrics {
101
103
  /** Requests that settled (successful, failed or cancelled). */
102
104
  requests: number;
103
- /** Total queue wait (sent -> ACK), ms. */
105
+ /** Total queue wait (submission -> ACK), ms. */
104
106
  queueMs: number;
105
107
  /** Largest single queue wait, ms. */
106
108
  queueMaxMs: number;
@@ -147,6 +149,8 @@ export interface WslAgentPrepared {
147
149
  expectedDigest?: string;
148
150
  /** Whether USR1 can reach the agent (false for the native KILL-frame path). */
149
151
  signalable?: boolean;
152
+ /** Whether the agent handles only one request at a time. */
153
+ serialRequests?: boolean;
150
154
  /** The process factory for this entry. */
151
155
  spawnTransport?: (options: {
152
156
  wslPath: string;
@@ -236,6 +240,8 @@ export declare class WslAgent {
236
240
  * opts in explicitly.
237
241
  */
238
242
  signalable: boolean;
243
+ /** Whether requests must wait on the host until the previous answer. */
244
+ serialRequests: boolean;
239
245
  /** The maximum requests allowed in flight on this resident. */
240
246
  maxPending: number;
241
247
  /** The live resident process, when any. */
@@ -311,6 +317,8 @@ export declare class WslAgent {
311
317
  * prefix but no PID namespace and must opt in.
312
318
  */
313
319
  signalable?: boolean;
320
+ /** Whether the agent handles only one request at a time; defaults to shell behavior. */
321
+ serialRequests?: boolean;
314
322
  /** Hard ceiling in bytes on one stdout protocol line; defaults to {@link MAX_FRAME_BYTES}. */
315
323
  frameCapBytes?: number;
316
324
  /** Injectable process factory for tests. */
@@ -412,6 +420,10 @@ export declare class WslAgent {
412
420
  * @param request - the request to register.
413
421
  */
414
422
  private track;
423
+ /** Send a tracked request, recording dispatch before synchronous responses. */
424
+ private sendPending;
425
+ /** Dispatch the next shell request only after the preceding answer. */
426
+ private dispatchQueued;
415
427
  /**
416
428
  * Run one command inside the distro through the agent.
417
429
  *
@@ -419,7 +431,7 @@ export declare class WslAgent {
419
431
  * @returns the settled result.
420
432
  * @throws {AgentUnavailableError} when the agent is out for this process.
421
433
  */
422
- exec({ cwd, argv, timeoutMs, maxOutputBytes, env, signal }: {
434
+ exec({ cwd, argv, timeoutMs, maxOutputBytes, env, stdin, signal }: {
423
435
  /** Linux path to run in. */
424
436
  cwd: string;
425
437
  /** The argv, already bwrap-wrapped when the caller confines. */
@@ -441,7 +453,15 @@ export declare class WslAgent {
441
453
  * keeps the unbounded capture.
442
454
  */
443
455
  maxOutputBytes?: number;
444
- /** Cancels the request (KILL + reject). */
456
+ /**
457
+ * The child's entire stdin, delivered before its first read (protocol
458
+ * v6); the child sees EOF at the payload's end. Omitted or empty keeps
459
+ * the v5 shape — an empty stdin and immediate EOF. Note the shell agent
460
+ * decodes the payload through command substitution, which strips
461
+ * trailing newlines; the native agent delivers the bytes verbatim.
462
+ */
463
+ stdin?: Uint8Array;
464
+ /** Cancels the request, sending KILL if it was already dispatched. */
445
465
  signal?: AbortSignal;
446
466
  }): Promise<{
447
467
  exitCode: number;
@@ -476,7 +496,7 @@ export declare class WslAgent {
476
496
  * dequeue ACK; `<= 0` or omitted means none.
477
497
  */
478
498
  timeoutMs?: number;
479
- /** Cancels the request (KILL + reject). */
499
+ /** Cancels the request, sending KILL if it was already dispatched. */
480
500
  signal?: AbortSignal;
481
501
  }): Promise<{
482
502
  exitCode: number;
@@ -505,7 +525,7 @@ export declare class WslAgent {
505
525
  * @param signal - the signal to deliver (`SIGUSR1` is the cancel trap).
506
526
  */
507
527
  /**
508
- * The cumulative request metrics: queue wait (sent -> ACK) and execution
528
+ * The cumulative request metrics: queue wait (submission -> ACK) and execution
509
529
  * time (ACK -> settle) per request kind, plus the peak in-flight count.
510
530
  *
511
531
  * @returns a snapshot the caller can keep.
@@ -592,8 +612,7 @@ export declare class WslAgent {
592
612
  }
593
613
  /**
594
614
  * Rewrite the request id inside a stored frame so a resent request does not
595
- * collide with ids the new process has already seen. SETENV lines pass
596
- * through unchanged (they are id-less and idempotent).
615
+ * collide with ids the new process has already seen.
597
616
  *
598
617
  * @param lines - the stored frame.
599
618
  * @param newId - the new request id.
package/lib/agent.js CHANGED
@@ -41,7 +41,7 @@
41
41
  */
42
42
  import { spawn } from "node:child_process";
43
43
  import { AgentUnavailableError, BASH_TIMEOUT_REASON, CwdError, SpawnError } from "./agent-errors.js";
44
- import { AGENT_NAME, MAX_FRAME_BYTES, PROTOCOL_VERSION, encodeExecFrame, encodeFsFrame, encodeKill, encodeSetEnv, parseAgentLine } from "./agent-protocol.js";
44
+ import { AGENT_NAME, MAX_FRAME_BYTES, PROTOCOL_VERSION, encodeExecFrame, encodeFsFrame, encodeKill, parseAgentLine } from "./agent-protocol.js";
45
45
  /**
46
46
  * A zeroed per-kind metrics record.
47
47
  * @returns the record.
@@ -172,6 +172,7 @@ export class WslAgent {
172
172
  this.wslPath = config.wslPath ?? "wsl.exe";
173
173
  this.argvPrefix = config.argvPrefix ?? [];
174
174
  this.signalable = config.signalable ?? this.argvPrefix.length === 0;
175
+ this.serialRequests = config.serialRequests ?? true;
175
176
  this.idleMs = config.idleMs ?? 600000;
176
177
  this.expectedDigest = config.expectedDigest;
177
178
  this.frameCapBytes = config.frameCapBytes ?? MAX_FRAME_BYTES;
@@ -294,6 +295,8 @@ export class WslAgent {
294
295
  this.expectedDigest = entry.expectedDigest;
295
296
  if (entry.signalable !== undefined)
296
297
  this.signalable = entry.signalable;
298
+ if (entry.serialRequests !== undefined)
299
+ this.serialRequests = entry.serialRequests;
297
300
  if (entry.spawnTransport !== undefined)
298
301
  this.configuredSpawn = entry.spawnTransport;
299
302
  }
@@ -472,6 +475,7 @@ export class WslAgent {
472
475
  this.clearPending(pending);
473
476
  this.pending.delete(message.id);
474
477
  this.rearmIdleTimer();
478
+ this.dispatchQueued();
475
479
  // A cancelled request keeps its entry only until this answer arrives.
476
480
  if (pending.settled)
477
481
  break;
@@ -487,6 +491,7 @@ export class WslAgent {
487
491
  this.clearPending(pending);
488
492
  this.pending.delete(message.id);
489
493
  this.rearmIdleTimer();
494
+ this.dispatchQueued();
490
495
  if (pending.settled)
491
496
  break;
492
497
  pending.settled = true;
@@ -554,7 +559,14 @@ export class WslAgent {
554
559
  this.clearPending(pending);
555
560
  continue;
556
561
  }
557
- if (isReplayableFrame(pending.lines)) {
562
+ if (!pending.sent) {
563
+ this.clearPending(pending);
564
+ if (pending.settled)
565
+ continue;
566
+ pending.settled = true;
567
+ pending.reject(new AgentUnavailableError(`agent exited before request ${pending.id} was sent; it never ran`));
568
+ }
569
+ else if (isReplayableFrame(pending.lines)) {
558
570
  if (pending.watchdog) {
559
571
  clearTimeout(pending.watchdog);
560
572
  pending.watchdog = null;
@@ -572,12 +584,18 @@ export class WslAgent {
572
584
  this.start()
573
585
  .then(() => {
574
586
  for (const pending of relayable) {
587
+ if (pending.settled || pending.signal?.aborted)
588
+ continue;
575
589
  const id = `r${this.nextRequestId++}`;
576
590
  pending.id = id;
591
+ pending.seq = Number(id.slice(1));
592
+ pending.lines = relabelFrame(pending.lines, id);
593
+ pending.sent = false;
594
+ pending.started = false;
577
595
  this.pending.set(id, pending);
578
596
  // The rebuilt transport can itself be gone by now; a frame that
579
597
  // cannot be written must fail its caller, not strand the entry.
580
- if (!this.writeLines(relabelFrame(pending.lines, id))) {
598
+ if (!this.serialRequests && !this.sendPending(pending)) {
581
599
  this.clearPending(pending);
582
600
  this.pending.delete(id);
583
601
  if (pending.settled)
@@ -586,6 +604,7 @@ export class WslAgent {
586
604
  pending.reject(new AgentUnavailableError(`agent exited before request ${id} could be re-sent; its state is unknown`));
587
605
  }
588
606
  }
607
+ this.dispatchQueued();
589
608
  })
590
609
  .catch((error) => {
591
610
  for (const pending of relayable) {
@@ -645,6 +664,7 @@ export class WslAgent {
645
664
  signal: signal ?? null,
646
665
  onAbort: null,
647
666
  started: false,
667
+ sent: false,
648
668
  cancelled: false,
649
669
  settled: false,
650
670
  answered: false,
@@ -681,6 +701,14 @@ export class WslAgent {
681
701
  }
682
702
  pending.onAbort = () => {
683
703
  pending.cancelled = true;
704
+ if (!pending.sent) {
705
+ this.pending.delete(pending.id);
706
+ this.clearPending(pending);
707
+ pending.settled = true;
708
+ pending.reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
709
+ this.rearmIdleTimer();
710
+ return;
711
+ }
684
712
  // The frame is the fallback for an agent that cannot be signalled (a
685
713
  // confined resident, a build without the USR1 trap); the signal is what
686
714
  // interrupts the command the agent is blocked on right now.
@@ -709,7 +737,10 @@ export class WslAgent {
709
737
  // dropping it used to strand the request: nothing would ever answer, the
710
738
  // watchdog (armed on an ACK that will not come) had no process to kill,
711
739
  // and with no timeout the caller waited forever.
712
- if (!this.writeLines(lines)) {
740
+ if (this.serialRequests) {
741
+ this.dispatchQueued();
742
+ }
743
+ else if (!this.sendPending(pending)) {
713
744
  this.clearPending(pending);
714
745
  this.pending.delete(pending.id);
715
746
  this.noteRejected(kind);
@@ -718,6 +749,30 @@ export class WslAgent {
718
749
  }
719
750
  this.rearmIdleTimer();
720
751
  }
752
+ /** Send a tracked request, recording dispatch before synchronous responses. */
753
+ sendPending(pending) {
754
+ pending.sent = true;
755
+ return this.writeLines(pending.lines);
756
+ }
757
+ /** Dispatch the next shell request only after the preceding answer. */
758
+ dispatchQueued() {
759
+ if (!this.serialRequests || [...this.pending.values()].some((pending) => pending.sent))
760
+ return;
761
+ const pending = this.pending.values().next().value;
762
+ if (pending === undefined)
763
+ return;
764
+ if (this.sendPending(pending))
765
+ return;
766
+ this.clearPending(pending);
767
+ this.pending.delete(pending.id);
768
+ if (!pending.settled) {
769
+ pending.settled = true;
770
+ pending.measured = true;
771
+ this.noteRejected(pending.kind);
772
+ pending.reject(new AgentUnavailableError("the agent exited before the request could be sent; it never ran"));
773
+ }
774
+ this.dispatchQueued();
775
+ }
721
776
  /**
722
777
  * Run one command inside the distro through the agent.
723
778
  *
@@ -725,13 +780,20 @@ export class WslAgent {
725
780
  * @returns the settled result.
726
781
  * @throws {AgentUnavailableError} when the agent is out for this process.
727
782
  */
728
- async exec({ cwd, argv, timeoutMs = 0, maxOutputBytes = 0, env, signal }) {
783
+ async exec({ cwd, argv, timeoutMs = 0, maxOutputBytes = 0, env, stdin, signal }) {
784
+ const assignments = Object.entries(env ?? {}).map(([key, value]) => {
785
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || value.includes("\0")) {
786
+ throw new Error(`invalid environment variable: ${key}`);
787
+ }
788
+ return `${key}=${value}`;
789
+ });
790
+ if (assignments.length > 0 && argv.length === 0)
791
+ throw new Error("cannot execute an empty command");
729
792
  await this.start();
730
793
  const id = `r${this.nextRequestId++}`;
731
- const lines = [
732
- ...(env ? Object.entries(env).map(([key, value]) => encodeSetEnv(key, value)) : []),
733
- ...encodeExecFrame({ id, cwd, argv, timeoutMs, maxOutputBytes }),
734
- ];
794
+ const commandArgv = argv[0]?.includes("=") ? ["/bin/sh", "-c", "exec \"$@\"", "env", ...argv] : argv;
795
+ const requestArgv = assignments.length > 0 ? ["env", "--", ...assignments, ...commandArgv] : argv;
796
+ const lines = encodeExecFrame({ id, cwd, argv: requestArgv, timeoutMs, maxOutputBytes, stdin });
735
797
  return new Promise((resolve, reject) => {
736
798
  this.track({ id, kind: "exec", lines, resolve, reject, timeoutMs, signal });
737
799
  });
@@ -786,7 +848,7 @@ export class WslAgent {
786
848
  * @param signal - the signal to deliver (`SIGUSR1` is the cancel trap).
787
849
  */
788
850
  /**
789
- * The cumulative request metrics: queue wait (sent -> ACK) and execution
851
+ * The cumulative request metrics: queue wait (submission -> ACK) and execution
790
852
  * time (ACK -> settle) per request kind, plus the peak in-flight count.
791
853
  *
792
854
  * @returns a snapshot the caller can keep.
@@ -1024,8 +1086,7 @@ const REPLAYABLE_FS_OPS = new Set(["stat", "lstat", "list", "realpath", "read"])
1024
1086
  * Whether a stored request frame's first execution provably had no side
1025
1087
  * effects, so the frame may ride the one permitted rebuild. An EXEC never
1026
1088
  * qualifies — a command's effects are unknowable after the agent died — and
1027
- * neither does an FS `write`. (A SETENV prefix line never decides: the frame
1028
- * header sits at `lines[0]` only for FS frames, which carry no SETENV.)
1089
+ * neither does an FS `write`.
1029
1090
  *
1030
1091
  * @param lines - the stored frame.
1031
1092
  * @returns true when the frame may be replayed.
@@ -1039,8 +1100,7 @@ function isReplayableFrame(lines) {
1039
1100
  }
1040
1101
  /**
1041
1102
  * Rewrite the request id inside a stored frame so a resent request does not
1042
- * collide with ids the new process has already seen. SETENV lines pass
1043
- * through unchanged (they are id-less and idempotent).
1103
+ * collide with ids the new process has already seen.
1044
1104
  *
1045
1105
  * @param lines - the stored frame.
1046
1106
  * @param newId - the new request id.
@@ -30,6 +30,7 @@
30
30
  *
31
31
  * @module dsh-plugin-wsl/fs-routing
32
32
  */
33
+ import z from "@deepseek-ai/schemastery";
33
34
  import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
34
35
  import { WslFileSystem } from "./index.js";
35
36
  import type { Context } from "@deepseek-ai/cordis";
@@ -91,7 +92,7 @@ export declare function inputIsDistro(value: string | undefined): boolean;
91
92
  * mode the distro side reports through `sandboxMode`).
92
93
  */
93
94
  export declare class WslRoutingFileSystem extends LocalFileSystem {
94
- static Config: import("@deepseek-ai/schemastery").default<import("@deepseek-ai/dsh-fs-local").Config>;
95
+ static Config: z<RoutingConfig>;
95
96
  /**
96
97
  * The routed backend's constructor reads the deployment default mode from
97
98
  * `ctx.sandboxPolicy`, exactly as the shipped `fs-sandbox` row this class