dsh-plugin-wsl-env 0.6.0 → 0.7.3
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 +229 -0
- package/README.md +6 -6
- package/agent/wsl-agent.sh +57 -4
- package/cordis.patch.yml +48 -0
- package/docs/ARCHITECTURE.md +33 -20
- package/docs/CONFIGURATION.md +2 -2
- package/docs/LIMITATIONS.md +52 -54
- package/docs/PARITY.md +18 -4
- package/docs/README.zh.md +7 -7
- package/lib/agent-confined.js +3 -3
- package/lib/agent-protocol.js +6 -4
- package/lib/agent-shared.js +14 -1
- package/lib/agent.js +52 -5
- package/lib/file-reference-wsl.js +284 -0
- package/lib/fs-decisions.js +22 -25
- package/lib/fs-routing.js +176 -0
- package/lib/git-route.js +142 -0
- package/lib/index.js +77 -307
- package/lib/listing.js +47 -0
- package/lib/picker.js +121 -17
- package/lib/search-exec.js +114 -0
- package/lib/search-route.js +95 -0
- package/lib/subprocess.js +153 -16
- package/lib/terminal-activity.js +121 -0
- package/lib/workspace-files-route.js +205 -0
- package/lib/workspace-files-wsl.js +341 -0
- package/lib/wsl.js +0 -39
- package/package.json +36 -16
- package/lib/fs-publish.js +0 -42
package/docs/LIMITATIONS.md
CHANGED
|
@@ -3,35 +3,31 @@
|
|
|
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`
|
|
6
|
+
## The filesystem substrate (`wsl-fs`)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
The file tools run on one substrate: the resident in-distro agent, on ext4 —
|
|
9
|
+
reads, writes and identities happen where the files live. The former
|
|
10
|
+
Windows-side share backend (`substrate: "share"`) is retired: a profile still
|
|
11
|
+
carrying the value is refused at boot with the migration, and no file tool
|
|
12
|
+
crosses the 9p share. What to know about the one substrate there is:
|
|
11
13
|
|
|
12
|
-
- **A new file publishes with mode 0600
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 failure message names the opt-out: set `substrate: "share"` in the
|
|
26
|
-
provider config to keep working on the Windows-side share while the agent
|
|
27
|
-
is out.
|
|
14
|
+
- **A new file publishes with mode 0600.** That is the host backend's own POSIX
|
|
15
|
+
publication semantics (a private 0600 staging file, renamed into place), which
|
|
16
|
+
a Linux-host DSH session has always had; the share dropped mode sets and took
|
|
17
|
+
the umask default instead, and that behaviour is gone with the share.
|
|
18
|
+
Overwrites and edits keep the original bits. Run `chmod` (or ask the model to)
|
|
19
|
+
when a new file must be executable.
|
|
20
|
+
- **When the agent is out, file operations fail closed.** The distro stopped, or
|
|
21
|
+
the resident crashed past its one rebuild: `FS_IO_ERROR` naming the substrate,
|
|
22
|
+
with the recovery path in the message. There is no fallback I/O to degrade
|
|
23
|
+
into — deliberately; the old failure message pointed at the share, and the
|
|
24
|
+
share is what this plugin exists to keep the model off.
|
|
28
25
|
- **The mutation guard survives to the write.** `createIfAbsent` publishes with
|
|
29
26
|
a no-replace link inside the distro, which closes the check-then-write window
|
|
30
27
|
for creates outright. An overwrite or edit carries the version it was based
|
|
31
28
|
on into the write op, and the agent re-verifies it against a fresh stat one
|
|
32
29
|
syscall before the rename: a concurrent writer wins and the stale write
|
|
33
|
-
refuses with `FS_STALE_VERSION`.
|
|
34
|
-
round trip to kernel-adjacent — the residual stat-then-rename pair is the
|
|
30
|
+
refuses with `FS_STALE_VERSION`. The residual stat-then-rename pair is the
|
|
35
31
|
same sliver a Linux-host session's own rename has.
|
|
36
32
|
- **Stage two routes mutations through a confined resident.** A write or edit
|
|
37
33
|
under a confined policy runs on its own long-lived agent, spawned inside the
|
|
@@ -45,19 +41,15 @@ host stack over the distro's 9p share. What differs:
|
|
|
45
41
|
the distro's `/tmp` lands in the confined mount's tmpfs, not the real `/tmp`
|
|
46
42
|
— the same hand-off rule as the commands' `/tmp`: pass files through the
|
|
47
43
|
session workspace.
|
|
48
|
-
- **`
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
inside the distro), so the coarse-invalidation caveats below apply equally.
|
|
58
|
-
An inotify backend is planned, not shipped.
|
|
59
|
-
|
|
60
|
-
Everything below applies to the share substrate unless it says otherwise.
|
|
44
|
+
- **`glob` and `grep` run the distro's own rg** (`lib/search-route.js`): the
|
|
45
|
+
tool's arguments are forwarded verbatim, so results, output format and exit
|
|
46
|
+
codes are rg's own — at ext4 speed, with the distro's `.gitignore` rules.
|
|
47
|
+
A distro without rg surfaces rg's own "command not found" (exit 127);
|
|
48
|
+
`npm run bootstrap -- <distro> --install` installs it. A search whose
|
|
49
|
+
directory is a Windows folder keeps the host binary, which is native there.
|
|
50
|
+
- **`watch()` is one poll loop** (a `find -newer` scan inside the distro), so
|
|
51
|
+
the coarse-invalidation caveats below apply. An inotify backend is planned,
|
|
52
|
+
not shipped.
|
|
61
53
|
|
|
62
54
|
- **The sandbox does not govern WSL interop.** A confined command can still run a
|
|
63
55
|
Windows program, which escapes the Linux-side boundary. The plugin reports this as
|
|
@@ -77,35 +69,27 @@ Everything below applies to the share substrate unless it says otherwise.
|
|
|
77
69
|
while the file tools' fence grants the distro's *real* `/tmp`. Both are writable, so
|
|
78
70
|
neither side is refused, but a file the write tool puts in `/tmp` is invisible to
|
|
79
71
|
`bash` and vice versa. Hand a file between them through the session workspace.
|
|
80
|
-
- A distro that is stopped or unregistered while the app runs
|
|
81
|
-
|
|
82
|
-
the
|
|
83
|
-
gone.
|
|
72
|
+
- A distro that is stopped or unregistered while the app runs takes the file
|
|
73
|
+
tools' substrate with it: reads and writes report the substrate-unavailable
|
|
74
|
+
error naming the cause, and recover on the next call once the distro starts.
|
|
84
75
|
- `workspace-write` binds the workspace root as writable, and bubblewrap refuses a
|
|
85
76
|
bind whose source directory does not exist, so a session whose workspace directory was
|
|
86
77
|
deleted fails with a runner error; the plugin does not recreate it. A workdir that
|
|
87
78
|
does not exist — a configured `cwd` pointing at a deleted directory, for instance — is
|
|
88
79
|
a separate case and is now reported as an error naming that directory, because
|
|
89
80
|
`wsl.exe` would otherwise run the command in `/` and exit 0.
|
|
90
|
-
- New files get the distro's umask default (0644). A `chmod` from the Windows side on
|
|
91
|
-
a share path is silently ignored; overwrites and edits keep the original permission
|
|
92
|
-
bits. Run `chmod +x` inside the distro when you need an executable.
|
|
93
|
-
- The check before a write is "check, then write", which is not atomic. This backend
|
|
94
|
-
checks the caller's guard itself, because the Windows backend publishes a guarded
|
|
95
|
-
new file with a hard link, and the share does not support hard links. This is the
|
|
96
|
-
race that `dsh-fs-sandbox` already documents — and one reason the share substrate
|
|
97
|
-
is the documented FALLBACK rather than the main path: its publication rides the
|
|
98
|
-
unconfined agent, so it never receives the confined resident's kernel-enforced
|
|
99
|
-
containment. The agent substrate does.
|
|
100
81
|
- `watch()` observes from inside the distro (a `find -newer` poll loop, see
|
|
101
82
|
`lib/watcher.js`), so events are coarse invalidation, not per-file notifications,
|
|
102
83
|
and they arrive on the poll cadence, not instantly. A file created with an mtime
|
|
103
84
|
older than the watcher's stamp (`cp -p`, `tar -x`) is not seen until something
|
|
104
85
|
else touches the tree. Watching a 9p share from the Windows side remains
|
|
105
86
|
unsupported — the loop runs where the kernel can actually report change.
|
|
106
|
-
- `
|
|
107
|
-
|
|
108
|
-
|
|
87
|
+
- `editText` reads the whole file into memory before writing it back.
|
|
88
|
+
- The directory picker lists distro levels from inside the distro — the
|
|
89
|
+
resident's `ls -1ALp` in one round trip, a one-shot `wsl.exe --exec ls` when
|
|
90
|
+
it is out, never the 9p walk the host used to pay per entry. The listing is
|
|
91
|
+
line-parsed, so a filename containing a newline would render as two rows;
|
|
92
|
+
`/` cannot appear in a name, so nothing else can split one.
|
|
109
93
|
- Terminal routing is per-session by workspace coordinates (`subprocess-wsl.hostSessions`,
|
|
110
94
|
default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
|
|
111
95
|
`powershell.exe` in its own directory. The shell MENU remains the single configured
|
|
@@ -120,5 +104,19 @@ Everything below applies to the share substrate unless it says otherwise.
|
|
|
120
104
|
Meanwhile, double-click a tab's title to rename it, or add `shellCandidates`
|
|
121
105
|
to the `terminal-controller` row in your own profile layer — a manually
|
|
122
106
|
selected shell is titled after itself.
|
|
123
|
-
-
|
|
124
|
-
|
|
107
|
+
- Distro terminals ARE reclaimed when idle — the observation is distro-side:
|
|
108
|
+
each terminal is launched with a `DSH_TERMINAL_ID` marker, and the handle's
|
|
109
|
+
`inspectActivity` counts the marked processes through the resident agent
|
|
110
|
+
(`lib/terminal-activity.js`). Exactly one (the shell at its prompt) is
|
|
111
|
+
`idle`; more than one (a command, a pipeline, a nested shell) is `busy`;
|
|
112
|
+
`unknown` — the agent out, the distro stopped — pauses reclamation, because
|
|
113
|
+
a probe that cannot answer must not authorize a close. Consequences to
|
|
114
|
+
know: a nested shell keeps the terminal busy by design (closing it would
|
|
115
|
+
lose that shell), so does any process the shell's startup files spawn with
|
|
116
|
+
the inherited marker (an `ssh-agent` launched in `.bashrc` reads as busy);
|
|
117
|
+
the idle threshold is the controller's `unattendedTimeoutMs` (2 h by
|
|
118
|
+
default); and the host-side shell-activity integration is still not armed
|
|
119
|
+
for these launches — `wsl.exe` on win32 fails its gate — which is why the
|
|
120
|
+
observation is a scan rather than shell cooperation.
|
|
121
|
+
- Windows-folder host terminals (the `powershell.exe` branch) keep the shipped
|
|
122
|
+
behaviour: `unknown` activity, no reclamation.
|
package/docs/PARITY.md
CHANGED
|
@@ -21,6 +21,13 @@ Not applicable, recorded so the gap is a decision and not an omission:
|
|
|
21
21
|
- Settings Sync, Remote-SSH tunnel chaining, Codespaces: composition-layer
|
|
22
22
|
responsibilities, not this plugin's.
|
|
23
23
|
|
|
24
|
+
> **Update (2026-10-04).** The plan's transitional invariant — `wsl.agent:
|
|
25
|
+
> false` must equal the pre-agent share-backed behaviour — is superseded: the
|
|
26
|
+
> share substrate is refused at construction, and the search spawn rides the
|
|
27
|
+
> distro's rg. The share is no longer a fallback path of this plugin; the
|
|
28
|
+
> only fallbacks left are transport-level (one-shot `wsl.exe` where the
|
|
29
|
+
> resident agent is out), never share I/O.
|
|
30
|
+
|
|
24
31
|
## Contract audit (Phase 0)
|
|
25
32
|
|
|
26
33
|
Findings from reading the harness packages inside the app bundle; each one
|
|
@@ -40,10 +47,17 @@ shaped a phase of the plan.
|
|
|
40
47
|
distro's share, rewrite the spawn to run a distro-side `rg` (falling back
|
|
41
48
|
to `grep -rE` + `find` when absent) with identical stdout conventions.
|
|
42
49
|
Under the agent, that exec rides the agent's `exec` capability.
|
|
43
|
-
**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
**LANDED (2026-10-04), as the design above has it:** `WslSubprocessRuntime`
|
|
51
|
+
.`spawn` performs the rewrite, the decision living in `lib/search-route.js`,
|
|
52
|
+
and the exec rides the agent's `exec` capability — Phase 1's resident —
|
|
53
|
+
through the minimal handle facade of `lib/search-exec.js` (the spawn seam
|
|
54
|
+
returns its handle synchronously, so the facade degrades inside `done`),
|
|
55
|
+
with the one-shot `wsl.exe --exec` handle as the fallback when the agent is
|
|
56
|
+
out. The `grep -rE` fallback is NOT implemented — grep does not read
|
|
57
|
+
`.gitignore`, so it is not an rg substitute; a missing distro rg surfaces
|
|
58
|
+
rg's own exit 127 with a bootstrap pointer instead. With the share
|
|
59
|
+
substrate retired the same day, no model-facing search crosses the 9p
|
|
60
|
+
share at all.
|
|
47
61
|
3. **`dsh-fs-local` watches with chokidar over the share** — the behaviour
|
|
48
62
|
this plugin replaces: chokidar on 9p is the unreliability that made
|
|
49
63
|
`WslFileSystem.watch` refuse. The in-distro watcher replaces it wholesale.
|
package/docs/README.zh.md
CHANGED
|
@@ -57,18 +57,18 @@ 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
|
|
60
|
+
| | `substrate` | `agent` | 文件工具使用哪种 I/O 基座。只剩常驻子系统内代理一种:读写与路径解析直接在 ext4 上完成(原生符号链接、原生权限位,写入 guard 存活到发布时刻,受限策略下由内核强制)。原 `"share"` 退出项——Windows 侧宿主文件栈走 9p 共享——已在启动时拒绝,文件工具不再经过共享 |
|
|
61
61
|
| `directory-picker-wsl` | `includeHostHome` | `true` | 同时列出 Windows 家目录 |
|
|
62
62
|
| `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个子系统 |
|
|
63
63
|
|
|
64
|
-
[`cordis.patch.yml`](../cordis.patch.yml) 是每个随包值的注释参考。[docs/CONFIGURATION.md](CONFIGURATION.md) 列出其余键,包括 `shell`、`loginShell`、`cwd`、`timeoutMs`、`
|
|
64
|
+
[`cordis.patch.yml`](../cordis.patch.yml) 是每个随包值的注释参考。[docs/CONFIGURATION.md](CONFIGURATION.md) 列出其余键,包括 `shell`、`loginShell`、`cwd`、`timeoutMs`、`preferredDistro` 和 `maxEntries`;[examples/profile.cordis.patch.yml](../examples/profile.cordis.patch.yml) 是一份可照抄的本机层。
|
|
65
65
|
|
|
66
66
|
## 配方
|
|
67
67
|
|
|
68
68
|
- **Git 凭据共享**:让子系统里的 git 使用 Windows 侧的 Git Credential Manager,避免每次输密码:
|
|
69
69
|
`git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
|
|
70
70
|
(路径按 Windows 侧 Git 的安装位置调整;WSL2 的 localhost 转发是平台行为,子系统内监听的端口 Windows 直接可达。)
|
|
71
|
-
- **路径与性能**:模型看到并操作的是子系统内的 Linux 路径(`/home/...`),写入子系统自身的 ext4;`/mnt/c` 通向 Windows 磁盘但走 9p,大批量小文件操作明显慢——重 IO 的项目请放在子系统文件系统内。`npm run bootstrap -- <子系统>` 会一并报告 ripgrep
|
|
71
|
+
- **路径与性能**:模型看到并操作的是子系统内的 Linux 路径(`/home/...`),写入子系统自身的 ext4;`/mnt/c` 通向 Windows 磁盘但走 9p,大批量小文件操作明显慢——重 IO 的项目请放在子系统文件系统内。`npm run bootstrap -- <子系统>` 会一并报告 ripgrep、git、inotifywait(搜索、快照与监视的后端)是否就位。
|
|
72
72
|
- **WSLENV 透传**:WSL 只导入 `WSLENV` 中列出的变量。本插件按前缀放行托管的 `DSH_*` 命名空间,其中带 Windows 路径的两个(`DSH_HOME`、`DSH_PROFILE_DIR`)加 `/p` 让 WSL 翻译成 `/mnt/c/...`。`PATH` 故意不透传——否则 Windows 的 PATH 会覆盖子系统自身的 PATH。
|
|
73
73
|
|
|
74
74
|
## 架构
|
|
@@ -86,12 +86,12 @@ dsh --profile wsl # 4. 启动
|
|
|
86
86
|
|
|
87
87
|
preset-wsl(wsl agent preset;其服务运行在 isolate realm 内)
|
|
88
88
|
├─ wsl-shell ctx.shell — wsl.exe --exec <登录 shell>,在子系统内经 bubblewrap 约束
|
|
89
|
-
└─ wsl-fs ctx.fs —
|
|
89
|
+
└─ wsl-fs ctx.fs — 子系统的真实文件;常驻代理基座落在 ext4
|
|
90
90
|
```
|
|
91
91
|
|
|
92
92
|
**为什么分两层。** `wsl-shell` 和 `wsl-fs` 位于 `wsl` agent preset 内,`auto-preset` 在会话工作区位于子系统内时自动绑定它:Windows 目录的会话继续使用原生 provider,子系统会话拿到 WSL provider——环境是会话的属性,而不是进程的。终端控制器是例外:它经根上下文解析执行世界,永远看不到 preset 的 isolate realm,所以 `subprocess-wsl` 必须挂在组合层。
|
|
93
93
|
|
|
94
|
-
**命令与文件。** 命令以 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <登录 shell> -lc <命令>` 执行,外层是子系统内的 bubblewrap profile,参数与 DSH 自家 Linux runner 完全一致——约束语义与报错文案都和 Linux
|
|
94
|
+
**命令与文件。** 命令以 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <登录 shell> -lc <命令>` 执行,外层是子系统内的 bubblewrap profile,参数与 DSH 自家 Linux runner 完全一致——约束语义与报错文案都和 Linux 主机相同。文件工具读写子系统的真实文件,基座只有常驻子系统内代理一种——读写与路径解析落在 ext4,原生符号链接与权限位;文件搜索的 spawn 以同样的方式改写:子系统工作区的搜索跑子系统自己的 `rg`(`wsl.exe --exec`),绝不让 Windows 侧二进制走 9p 共享。写入与命令沙箱使用同一份策略检查。
|
|
95
95
|
|
|
96
96
|
## 沙箱
|
|
97
97
|
|
|
@@ -120,8 +120,8 @@ preset-wsl(wsl agent preset;其服务运行在 isolate realm 内)
|
|
|
120
120
|
| 终端打开后仍然是 `cmd.exe` | 这一层里的 `terminal-controller` 行没有生效 | 用 `dsh --profile wsl --dump-config` 确认能看到 `shell: { path: wsl.exe, name: WSL }` |
|
|
121
121
|
| 改了 `lib/` 但不生效 | ES module 缓存 | 重启应用 |
|
|
122
122
|
| 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows 路径;在子系统内开发时用运行时镜像,见[开发](#开发) |
|
|
123
|
-
| `glob`/`grep` 很慢 |
|
|
124
|
-
| 终端活动显示 `unknown` |
|
|
123
|
+
| `glob`/`grep` 很慢 | 子系统内没有 rg 时,搜索只能报 rg 自己的 "command not found";Windows 目录的搜索走宿主原生 rg,不受影响 | 运行 `npm run bootstrap -- <子系统> --install`(安装 ripgrep);子系统工作区的搜索始终跑子系统内的 rg,绝不遍历 9p 共享 |
|
|
124
|
+
| 终端活动显示 `unknown` | 仅在常驻代理不可用时出现——distro 终端从子系统内部观测(`/proc` 中扫描 `DSH_TERMINAL_ID` 标记:shell 独处为 `idle`,运行任何命令为 `busy`) | 确认子系统在运行;空闲终端会在控制器的无人值守超时(默认 2 小时)后自动回收,`terminalIdleReclaim: false` 恢复手动关闭 |
|
|
125
125
|
| 结果里出现 `FS_*` 码 | 码本身说明了是谁拒绝的、以及怎样解除 | 见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#error-codes) 的错误码表 |
|
|
126
126
|
|
|
127
127
|
## 开发
|
package/lib/agent-confined.js
CHANGED
|
@@ -27,12 +27,11 @@
|
|
|
27
27
|
*
|
|
28
28
|
* @module dsh-plugin-wsl/agent-confined
|
|
29
29
|
*/
|
|
30
|
-
import { fileURLToPath } from "node:url";
|
|
31
30
|
import { bwrapArgvPrefix } from "./bwrap.js";
|
|
32
31
|
import { toLinuxPath } from "./paths.js";
|
|
32
|
+
import { agentScriptDigest, agentScriptPath } from "./agent-shared.js";
|
|
33
33
|
import { WslAgent } from "./agent.js";
|
|
34
34
|
|
|
35
|
-
const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
|
|
36
35
|
const confinedAgents = new Map();
|
|
37
36
|
|
|
38
37
|
/**
|
|
@@ -63,7 +62,8 @@ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindows
|
|
|
63
62
|
agent = new WslAgent({
|
|
64
63
|
distro,
|
|
65
64
|
wslPath,
|
|
66
|
-
scriptPath: toLinuxPath(
|
|
65
|
+
scriptPath: toLinuxPath(agentScriptPath),
|
|
66
|
+
expectedDigest: agentScriptDigest,
|
|
67
67
|
// The prefix is a whole command from the distro's point of view — program,
|
|
68
68
|
// profile, `--` — because the transport inserts it between
|
|
69
69
|
// `wsl.exe --exec` and the `sh <script>` pair (`lib/agent.js`'s
|
package/lib/agent-protocol.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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 = 4;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Upper bound on one `read` op's payload, set by the HOST looping over windowed
|
|
@@ -133,7 +133,9 @@ export function encodeFsFrame({ id, op, args, timeoutMs = 0 }) {
|
|
|
133
133
|
* Parse one line of the agent's stdout.
|
|
134
134
|
* @param {string} line - a line without its terminator.
|
|
135
135
|
* @returns {object} one of:
|
|
136
|
-
* - `{ type: "hello", name, version }`
|
|
136
|
+
* - `{ type: "hello", name, version, digest }` — `digest` is the sha256 of
|
|
137
|
+
* the script file the agent is executing (empty when it could not hash
|
|
138
|
+
* it); the host compares it against its own shipped copy
|
|
137
139
|
* - `{ type: "pong" }`
|
|
138
140
|
* - `{ type: "ack", id }` — the agent dequeued the request for execution;
|
|
139
141
|
* the host arms its stuck-request watchdog here, so queue time never
|
|
@@ -148,8 +150,8 @@ export function encodeFsFrame({ id, op, args, timeoutMs = 0 }) {
|
|
|
148
150
|
*/
|
|
149
151
|
export function parseAgentLine(line) {
|
|
150
152
|
if (line.startsWith("HELLO|")) {
|
|
151
|
-
const [, name, version] = line.split("|");
|
|
152
|
-
return { type: "hello", name, version: Number(version) };
|
|
153
|
+
const [, name, version, digest] = line.split("|");
|
|
154
|
+
return { type: "hello", name, version: Number(version), digest: digest ?? "" };
|
|
153
155
|
}
|
|
154
156
|
if (line === "PONG") return { type: "pong" };
|
|
155
157
|
if (line.startsWith("ACK|")) return { type: "ack", id: line.slice(4) };
|
package/lib/agent-shared.js
CHANGED
|
@@ -10,13 +10,26 @@
|
|
|
10
10
|
* @module dsh-plugin-wsl/agent-shared
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
13
15
|
import { fileURLToPath } from "node:url";
|
|
14
16
|
import { toLinuxPath } from "./paths.js";
|
|
15
17
|
import { WslAgent } from "./agent.js";
|
|
16
18
|
|
|
17
19
|
const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
|
|
20
|
+
// The script the distro reads must be byte-identical to the copy this
|
|
21
|
+
// package shipped: the deployed layout reads it from the /mnt/c mirror, and
|
|
22
|
+
// the handshake digest is what catches a stale or half-synced runtime copy.
|
|
23
|
+
// Both resident factories (plain and confined) pin it.
|
|
24
|
+
const AGENT_SCRIPT_DIGEST = createHash("sha256").update(readFileSync(AGENT_SCRIPT_PATH)).digest("hex");
|
|
18
25
|
const sharedAgents = new Map();
|
|
19
26
|
|
|
27
|
+
/** The agent script as a host-side path, for factories that need both spellings. */
|
|
28
|
+
export const agentScriptPath = AGENT_SCRIPT_PATH;
|
|
29
|
+
|
|
30
|
+
/** The sha256 of the shipped agent script; every resident verifies its HELLO against it. */
|
|
31
|
+
export const agentScriptDigest = AGENT_SCRIPT_DIGEST;
|
|
32
|
+
|
|
20
33
|
/**
|
|
21
34
|
* The shared agent for one distro, created on first use.
|
|
22
35
|
* @param distro - the distro name.
|
|
@@ -25,7 +38,7 @@ const sharedAgents = new Map();
|
|
|
25
38
|
export function sharedAgent(distro) {
|
|
26
39
|
let agent = sharedAgents.get(distro);
|
|
27
40
|
if (agent === undefined) {
|
|
28
|
-
agent = new WslAgent({ distro, scriptPath: toLinuxPath(AGENT_SCRIPT_PATH) });
|
|
41
|
+
agent = new WslAgent({ distro, scriptPath: toLinuxPath(AGENT_SCRIPT_PATH), expectedDigest: AGENT_SCRIPT_DIGEST });
|
|
29
42
|
sharedAgents.set(distro, agent);
|
|
30
43
|
}
|
|
31
44
|
return agent;
|
package/lib/agent.js
CHANGED
|
@@ -44,6 +44,11 @@ const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
|
44
44
|
/** Extra time the host watchdog waits past the request's own in-distro timeout. */
|
|
45
45
|
const WATCHDOG_GRACE_MS = 13000;
|
|
46
46
|
|
|
47
|
+
/** How far past its host's idle shutdown the agent's own lease runs. The
|
|
48
|
+
* lease fires only when the host is gone in every way that matters (a wedged
|
|
49
|
+
* relay that never delivers EOF); the host's close() normally wins the race. */
|
|
50
|
+
const LEASE_GRACE_MS = 120000;
|
|
51
|
+
|
|
47
52
|
/** The Windows essentials `wsl.exe` itself needs to start and report cleanly. */
|
|
48
53
|
const WINDOWS_ESSENTIALS = /^(systemroot|systemdrive|windir|path|pathext|comspec|os|computername)$/i;
|
|
49
54
|
|
|
@@ -95,11 +100,34 @@ export function pinnedWindowsEnv(parent) {
|
|
|
95
100
|
* @param {string} options.scriptPath - the agent script as a Linux path inside
|
|
96
101
|
* the distro (a `/mnt/<drive>/...` translation of the package's
|
|
97
102
|
* `agent/wsl-agent.sh`).
|
|
103
|
+
* @param {number} [options.leaseMs] - the agent's own client lease, forwarded
|
|
104
|
+
* through the managed `DSH_` namespace so the script can self-terminate
|
|
105
|
+
* when a wedged relay stops carrying traffic without ever delivering EOF.
|
|
98
106
|
* @returns {import("node:child_process").ChildProcess} the spawned process.
|
|
99
107
|
*/
|
|
100
|
-
|
|
108
|
+
/**
|
|
109
|
+
* The spawn environment for one resident, with the client lease folded in.
|
|
110
|
+
* The pinned environment forwards only the managed `DSH_` namespace, and it
|
|
111
|
+
* forwards exactly the names the parent `WSLENV` lists — so the lease rides
|
|
112
|
+
* both: the value on the variable, the name on `WSLENV`. A non-positive
|
|
113
|
+
* lease leaves the environment as the pin built it (the script treats an
|
|
114
|
+
* unset variable as no lease).
|
|
115
|
+
* @param {Record<string, string>} env - the pinned environment.
|
|
116
|
+
* @param {number} leaseMs - the agent's client lease in ms; 0 disables.
|
|
117
|
+
* @returns {Record<string, string>} the environment to spawn with.
|
|
118
|
+
*/
|
|
119
|
+
export function agentLeaseEnv(env, leaseMs) {
|
|
120
|
+
if (!(leaseMs > 0)) return env;
|
|
121
|
+
return {
|
|
122
|
+
...env,
|
|
123
|
+
DSH_AGENT_LEASE_MS: String(leaseMs),
|
|
124
|
+
WSLENV: [env.WSLENV, "DSH_AGENT_LEASE_MS"].filter(Boolean).join(":"),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function spawnDefault({ wslPath, distro, scriptPath, argvPrefix = [], leaseMs = 0 }) {
|
|
101
129
|
return spawn(wslPath, ["-d", distro, "--exec", ...argvPrefix, "sh", scriptPath], {
|
|
102
|
-
env: pinnedWindowsEnv(process.env),
|
|
130
|
+
env: agentLeaseEnv(pinnedWindowsEnv(process.env), leaseMs),
|
|
103
131
|
stdio: ["pipe", "pipe", "pipe"],
|
|
104
132
|
windowsHide: true,
|
|
105
133
|
});
|
|
@@ -114,9 +142,13 @@ export class WslAgent {
|
|
|
114
142
|
* @param {string} config.distro - the distro this agent serves.
|
|
115
143
|
* @param {string} config.scriptPath - the agent script as a Linux path
|
|
116
144
|
* inside the distro (see {@link spawnDefault}).
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
145
|
+
* @param {string} [config.wslPath] - the `wsl.exe` path.
|
|
146
|
+
* @param {number} [config.idleMs] - idle shutdown delay; 0 disables.
|
|
147
|
+
* @param {string} [config.expectedDigest] - the sha256 of the agent script
|
|
148
|
+
* as this package shipped it; a HELLO whose digest differs is refused the
|
|
149
|
+
* same way a version mismatch is (the script is read in place, so a stale
|
|
150
|
+
* or half-synced runtime copy is the failure this catches).
|
|
151
|
+
* @param {string[]} [config.argvPrefix] - arguments inserted between
|
|
120
152
|
* `wsl.exe --exec` and the `sh script` pair — the confinement wrapper
|
|
121
153
|
* (`bwrap … --`) a confined agent runs its whole lifetime under, so every
|
|
122
154
|
* filesystem op it performs is kernel-enforced. Empty for the plain agent.
|
|
@@ -129,6 +161,7 @@ export class WslAgent {
|
|
|
129
161
|
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
130
162
|
this.argvPrefix = config.argvPrefix ?? [];
|
|
131
163
|
this.idleMs = config.idleMs ?? 600000;
|
|
164
|
+
this.expectedDigest = config.expectedDigest;
|
|
132
165
|
/** Hard ceiling on one stdout protocol line; a longer line is a broken or
|
|
133
166
|
* hostile agent, not data to hold — readline used to accumulate it all. */
|
|
134
167
|
this.frameCapBytes = config.frameCapBytes ?? MAX_FRAME_BYTES;
|
|
@@ -179,6 +212,7 @@ export class WslAgent {
|
|
|
179
212
|
distro: this.distro,
|
|
180
213
|
scriptPath: this.scriptPath,
|
|
181
214
|
argvPrefix: this.argvPrefix,
|
|
215
|
+
leaseMs: this.idleMs > 0 ? this.idleMs + LEASE_GRACE_MS : 0,
|
|
182
216
|
}));
|
|
183
217
|
const handshakeTimer = setTimeout(() => {
|
|
184
218
|
this.lastError = `agent handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`;
|
|
@@ -252,9 +286,22 @@ export class WslAgent {
|
|
|
252
286
|
switch (message.type) {
|
|
253
287
|
case "hello": {
|
|
254
288
|
if (message.name !== AGENT_NAME || message.version !== PROTOCOL_VERSION) {
|
|
289
|
+
// Settle BEFORE killing: the exit event must not overwrite the real
|
|
290
|
+
// reason with the generic handshake message (the fake transports
|
|
291
|
+
// emit exit synchronously inside kill(); a real wsl.exe races it).
|
|
255
292
|
this.lastError = `agent handshake mismatch: got ${message.name}@${message.version}, want ${AGENT_NAME}@${PROTOCOL_VERSION}`;
|
|
293
|
+
settleStart(new Error(this.lastError));
|
|
256
294
|
this.process?.kill();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (this.expectedDigest !== undefined && message.digest !== this.expectedDigest) {
|
|
298
|
+
// The script is read in place from the distro-visible path — in the
|
|
299
|
+
// deployed layout a /mnt/c mirror — so a digest that differs from
|
|
300
|
+
// the copy this package shipped means the runtime copy is stale,
|
|
301
|
+
// half-synced, or edited. Fail closed with the fix in the message.
|
|
302
|
+
this.lastError = `agent script digest mismatch: the file the distro read at ${this.scriptPath} hashes to ${message.digest || "(nothing)"}, but this package shipped ${this.expectedDigest} — resync the runtime copy (npm run sync:windows) or reinstall the plugin`;
|
|
257
303
|
settleStart(new Error(this.lastError));
|
|
304
|
+
this.process?.kill();
|
|
258
305
|
return;
|
|
259
306
|
}
|
|
260
307
|
clearTimeout(handshakeTimer);
|