dsh-wsl-desktop 0.3.7 → 0.5.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/README.en.md CHANGED
@@ -20,16 +20,16 @@ Turns a WSL distribution into a **first-class execution world** inside DSH Deskt
20
20
 
21
21
  ## Target capabilities
22
22
 
23
- **Honest state after live acceptance** (`verify-post-restart.mjs` all green + 19 offline suites; each item below notes its evidence strength):
23
+ **Honest state after live acceptance** (`verify-post-restart.mjs`, latest real run: **58 PASS / 0 FAIL**, tail `NO AUTOMATED FAILURES; 3 manual step(s) pending`, `EXIT=0` — including the two live search rows and the persistent-shell rows; **3 browser-only manual steps remain**, listed in [The persistent shell](docs/PERSISTENT-SHELL.en.md#manual-verification-checklist-browser-only-not-automatable) — plus 20 offline suites; each item below notes its evidence strength):
24
24
 
25
25
  | # | Capability | Status |
26
26
  |---|---|---|
27
27
  | ① | The workspace can pick a Linux directory inside a WSL distro | Dialog and host calls implemented and live-verified (listDir/checkPath/resolveHome/workspace registration and cleanup); **in-browser interaction manually confirmed by the operator** (gating flow, directory browsing, and terminal panel all normal) |
28
- | ② | shell / file tools / subprocess / terminal inside that workspace work in WSL, and host commands can be invoked from WSL | **Live acceptance PASS**: binding (the create request names the preset), shell inside the distro, fs tools addressing Linux paths, out-of-bounds write rejection, subprocess POSIX environment, bash tool, tool-layer constraints; terminal transport covered by the PTY suite |
28
+ | ② | shell / file tools / subprocess / terminal inside that workspace work in WSL, and host commands can be invoked from WSL | **Live acceptance PASS**: binding (the create request names the preset), shell inside the distro, fs tools addressing Linux paths, out-of-bounds write rejection, subprocess POSIX environment, bash tool, tool-layer constraints; terminal transport covered by the PTY suite. **The model's `bash` is a persistent PTY shell inside the distribution, keeping cwd and environment variables across calls** (live-verified: two calls read back the same state; the gap list is in [The persistent shell](docs/PERSISTENT-SHELL.en.md)) |
29
29
  | ③ | Windows and WSL workspaces coexist in the same instance | **Live acceptance PASS**: Windows workspaces stay on the host preset (PowerShell available, no bash); WSL sessions run the confined realm in parallel |
30
30
  | ④ | Linux-side sandbox constraints on the WSL side | **Shell side fixed and live-verified**: at runtime, all `rw` mounts are enumerated and remounted read-only one by one (observed `/mnt/c`, `/dev/shm`, `/run/user/<uid>` going WRITABLE → READONLY); any failure rejects (exit code 97 preserved); `enforcement` honestly reports `partial`. **fs-side fence implemented and live-verified** (out-of-bounds write rejected, PASS — see [The fs tool fence](docs/FS-FENCE.md)) |
31
31
 
32
- **Which capabilities are qualified**: the evidence strength is the wording of the status column above. The `grep` / `glob` tools do not exist in a WSL session and the terminal has no `stdio.control` — both are stated under "Known limitations" rather than left out.
32
+ **Which capabilities are qualified**: the evidence strength is the wording of the status column above. The terminal has no `stdio.control` — it is stated under "Known limitations" rather than left out. The persistent shell's gaps are stated there too, with the authoritative list in [The persistent shell](docs/PERSISTENT-SHELL.en.md).
33
33
 
34
34
  ## Install / Update / Uninstall
35
35
 
@@ -69,10 +69,12 @@ After every DSH Desktop update, the plugin may break because of harness internal
69
69
 
70
70
  ## Known limitations
71
71
 
72
- **The grep / glob tools do not exist in a WSL session.** `tool-fs-search` is launched via `ctx.subprocess.spawn()` (`search-core.ts:238`), but its argv[0] is the **bundled Windows rg.exe** from `@vscode/ripgrep` (`search-core.ts:174-178`) — it does not exist inside the distro and cannot work with Linux arguments. So the WSL preset removes it from the execution world, and the model in a WSL session can only use bash's `grep` / `find` (both present in the distro). This is a capability regression, not an omission — making it equivalent would require giving the WSL world its own search implementation.
72
+ **The grep / glob tools are available in a WSL session, backed by the distribution's own `grep` / `find`.** The host's two rows are still removed, for the two measured facts: `tool-fs-search` is launched via `ctx.subprocess.spawn()` (`search-core.ts:238`), but its `argv[0]` is the **bundled Windows rg.exe** from `@vscode/ripgrep` (`search-core.ts:174-178`) — it does not exist inside the distro and cannot work with Linux arguments; the distro, meanwhile, ships `/usr/bin/grep` and `/usr/bin/find` (measured: `command -v rg` inside the distro answers nothing). So the `wsl-world` group mounts the two names back onto this plugin's own implementation (`lib/wsl/search-tool.js`), and the model calls `grep` / `glob` in a WSL session as usual — **with the distribution's semantics**: POSIX ERE (no `\d`, no `(?i)`) · `.gitignore` not parsed (only the six VCS directories are skipped) · dotted directories searched · `include` matching the base name only and without brace expansion · patterns interpreted relative to the search root, with `*` and `**/` crossing `/` · a record whose path contains `:` possibly dropped or silently mis-attributed · binary files never matched · a capped result not saved anywhere. **That list is a summary; the document is authoritative for the eight gaps** (gaps 5 and 6 are merged into one item here, and the last item, "a capped result not saved anywhere", is a limit rather than a gap). The eight gaps, the limits, what was not measured and how to verify it yourself are in [Search inside the distribution](docs/SEARCH.en.md).
73
73
 
74
74
  **The terminal goes through the PTY bridge, and PTC's `stdio.control` (fd channel) stays explicitly rejected** — `wsl.exe` cannot forward arbitrary descriptors. How the bridge is driven, and its measured preconditions, are in [docs/PTY-BRIDGE.md](docs/PTY-BRIDGE.md).
75
75
 
76
+ **The persistent shell's gap summary**: the model's `bash` is a persistent PTY shell inside the distribution, but it carries two load-bearing boundaries — **(1) the host's `ctx.sandbox` wraps the `wsl.exe` argv**, and this repository's own judgement is that the host sandbox **cannot confine** it (measured: it still runs after the wrapping, which is why the realm provides its own `sandbox` provider and turns that layer into an in-distro fence); **(2) the terminal path has no PID namespace**, and the GUI's web terminal is **unfenced by design** — what is confined is the model's `bash`, not what the user types in the terminal panel. **That list is a summary; the document is authoritative for the gaps**: the nine gaps, the limits, what was not measured, the 3-step manual checklist and how to verify it yourself are in [The persistent shell](docs/PERSISTENT-SHELL.en.md).
77
+
76
78
  **Confinement does not cover `/dev`, `/proc`, `/sys` or interop**, which is reported honestly in `enforcement: 'partial'`; and the session user's retained passwordless sudo grant can bypass the file fence — see [SECURITY.md](SECURITY.md).
77
79
 
78
80
  **File-change tracking inside a WSL workspace polls; it is not event-driven.** `fs.watch` throws `EISDIR` on the distribution's 9P share (for a file target as well as a directory one), so the host backend's chokidar watcher cannot initialize for any WSL target. The plugin polls on the host side instead: a directory compares its entry set (one `readdir` per 300ms; measured 0ms CPU per 3s and 2-3ms latency in this process) and a file — or a path that does not exist yet — watches its stat (`fs.watchFile`, measured 0ms CPU per 3s and 52ms). **A directory target is invalidated by entry-set changes only**: adds, removes and renames are seen; the CONTENT change of a file inside it is not — watch that file's own path to follow it. Mechanisms, measurements and what was not measured are in [WSL file watching](docs/WATCH.en.md).
@@ -80,7 +82,7 @@ After every DSH Desktop update, the plugin may break because of harness internal
80
82
  ## Verification
81
83
 
82
84
  ```powershell
83
- node scripts/verify-all.mjs # all offline suites (19)
85
+ node scripts/verify-all.mjs # all offline suites (20)
84
86
  node scripts/verify-all.mjs --live # additionally the suites needing the installed plugin + a running host
85
87
  ```
86
88
 
package/README.md CHANGED
@@ -18,16 +18,16 @@
18
18
 
19
19
  ## 目标能力
20
20
 
21
- **活体验收通过后的诚实状态**(`verify-post-restart.mjs` 全绿 + 离线 19 套件;下面每条都注明证据强度):
21
+ **活体验收通过后的诚实状态**(`verify-post-restart.mjs` 最近一次实跑:**58 PASS / 0 FAIL**、尾部 `NO AUTOMATED FAILURES; 3 manual step(s) pending`、`EXIT=0`——包括搜索的两条活体行与常驻 shell 的那几条;**剩 3 条浏览器专属的人工项**,清单见 [常驻 shell](docs/PERSISTENT-SHELL.md#人工核验清单浏览器专属不可自动化),加离线 20 套件;下面每条都注明证据强度):
22
22
 
23
23
  | # | 能力 | 状态 |
24
24
  |---|---|---|
25
25
  | ① | 工作区可选择 WSL 发行版里的 Linux 目录 | 对话框与宿主调用已实现并活体通过(listDir/checkPath/resolveHome/工作区注册与清理);**浏览器内交互已由操作者人工确认**(门控流、目录浏览、终端面板均正常) |
26
- | ② | 该工作区内的 shell / 文件工具 / subprocess / 终端 在 WSL 里工作,且可从 WSL 调用宿主机命令 | **活体验收 PASS**:绑定(创建请求命名 preset)、shell 进发行版、fs 工具 Linux 寻址、越界写拒绝、subprocess POSIX 环境、bash 工具、工具层约束;终端传输由 PTY 套件覆盖 |
26
+ | ② | 该工作区内的 shell / 文件工具 / subprocess / 终端 在 WSL 里工作,且可从 WSL 调用宿主机命令 | **活体验收 PASS**:绑定(创建请求命名 preset)、shell 进发行版、fs 工具 Linux 寻址、越界写拒绝、subprocess POSIX 环境、bash 工具、工具层约束;终端传输由 PTY 套件覆盖。**模型侧的 `bash` 是发行版内的常驻 PTY shell,cwd 与环境变量跨调用保留**(活体验证:两次调用读数一致;缺口清单见 [常驻 shell](docs/PERSISTENT-SHELL.md)) |
27
27
  | ③ | 同一实例里 Windows 与 WSL 工作区并存 | **活体验收 PASS**:Windows 工作区停留在宿主 preset(PowerShell 可用、无 bash),WSL 会话并行运行 confined realm |
28
28
  | ④ | WSL 侧的 Linux 沙箱约束 | **shell 侧已修并活体验证**:运行时枚举所有 `rw` 挂载逐个改只读(实测 `/mnt/c`、`/dev/shm`、`/run/user/<uid>` 从 WRITABLE 变 READONLY)、失败即拒(保留退出码 97)、`enforcement` 如实报 `partial`。**fs 侧围栏已实现并活体验证**(越界写拒绝 PASS,见 [fs 工具的围栏](docs/FS-FENCE.md))|
29
29
 
30
- **哪些能力有保留**(证据强度就是上表状态列的措辞):`grep` / `glob` 工具在 WSL 会话里不存在,终端不支持 `stdio.control`——两条都写在「已知限制」里,不藏。
30
+ **哪些能力有保留**(证据强度就是上表状态列的措辞):终端不支持 `stdio.control`——写在「已知限制」里,不藏。常驻 shell 的缺口同样写在「已知限制」里,权威清单在 [常驻 shell](docs/PERSISTENT-SHELL.md)。
31
31
 
32
32
  ## 安装 / 更新 / 卸载
33
33
 
@@ -67,10 +67,12 @@
67
67
 
68
68
  ## 已知限制
69
69
 
70
- **grep / glob 工具在 WSL 会话里不可用。** `tool-fs-search` 虽然通过 `ctx.subprocess.spawn()` 启动(`search-core.ts:238`),但它的 argv[0] 是 `@vscode/ripgrep` 提供的**打包 Windows rg.exe**(`search-core.ts:174-178`),在发行版里既不存在、也无法用 Linux 参数工作。所以 WSL 预设会把它从执行世界里移除,WSL 会话的模型只能用 bash 的 `grep` / `find`(两者在发行版里都在)。这算是一个能力回退,不是遗漏——要做成对等的,需要给 WSL 世界提供自己的搜索实现。
70
+ **grep / glob 工具在 WSL 会话里可用,后端是发行版自己的 `grep` / `find`。** 宿主那两行依然被移除,理由是实测的两件事:`tool-fs-search` 虽然通过 `ctx.subprocess.spawn()` 启动(`search-core.ts:238`),但它的 `argv[0]` 是 `@vscode/ripgrep` 提供的**打包 Windows rg.exe**(`search-core.ts:174-178`)——在发行版里既不存在、也无法用 Linux 参数工作;而发行版自带 `/usr/bin/grep` 与 `/usr/bin/find`(实测:发行版里 `command -v rg` 回答为空)。所以 `wsl-world` 组把这两个名字挂回本插件自己的实现(`lib/wsl/search-tool.js`),模型在 WSL 会话里照常调用 `grep` / `glob`——**但语义是发行版工具的**:POSIX ERE(没有 `\d`、`(?i)`)· 不解析 `.gitignore`(只排除六个 VCS 目录)· 点目录会被搜到 · `include` 只匹配 basename 且没有花括号 · 模式按搜索根相对解释、`*` 与 `**/` 跨 `/` · 路径含 `:` 的记录可能被丢弃或被静默错配 · 二进制文件永不被匹配 · 达到上限的截断不落盘。**这串要点是摘要,缺口的权威清单在文档里**(文档八条中第 5、6 条在这里并成了一条;末条「截断不落盘」是限额,不是缺口)。八条缺口、限额、未测项与怎么自己验,见 [发行版内搜索](docs/SEARCH.md)。
71
71
 
72
72
  **终端走 PTY 桥,PTC 的 `stdio.control`(fd 通道)明确拒绝**——`wsl.exe` 无法转发任意描述符。桥的驱动方式与实测前提见 [docs/PTY-BRIDGE.md](docs/PTY-BRIDGE.md)。
73
73
 
74
+ **常驻 shell 的缺口摘要**:模型侧的 `bash` 是发行版内的常驻 PTY shell,但它有两条要命的边界——**(1)宿主 `ctx.sandbox` 会包一层 `wsl.exe` 的 argv**,而本仓自己判定宿主沙箱对它**无法约束**(实测:包装之后仍然跑得起来,所以 realm 里自建了 `sandbox` 提供者,把这一层变成发行版内的约束);**(2)终端这条路没有 PID namespace**,而 GUI 的网页终端按设计**不受 fence 约束**——受约束的是模型的 `bash`,不是用户在终端面板里敲的那条。**这串只是摘要,缺口的权威清单在文档里**:九条缺口、限额、未测项、3 条人工核验清单与怎么自己验,见 [常驻 shell](docs/PERSISTENT-SHELL.md)。
75
+
74
76
  **约束覆盖不到 `/dev`、`/proc`、`/sys` 与 interop**,如实报在 `enforcement: 'partial'` 里;而会话用户保留的免密 sudo 授权可绕过文件围栏,闭合路径见 [SECURITY.md](SECURITY.md)。
75
77
 
76
78
  **WSL 工作区里的文件变更跟踪是轮询的,不是事件驱动的。** 发行版的 9P 共享上 `fs.watch` 会抛 `EISDIR`(文件与目录目标都抛),宿主后端的 chokidar 监听因此在 WSL 目标上根本无法初始化;插件改为在宿主侧轮询——目录看条目集合(每 300ms 一次 `readdir`,实测本进程 0ms CPU/3s、延迟 2–3ms),文件与尚不存在的路径走 `fs.watchFile`(每 300ms 一次 `stat`,实测 0ms CPU/3s、延迟 52ms)。**目录目标只对条目集合变化失效**:新增、删除、改名可见,目录内某个文件的**内容**变化不可见——要盯着某个文件,就按文件路径监听它。机制、实测与未测项见 [WSL 文件变更监听](docs/WATCH.md)。
@@ -78,7 +80,7 @@
78
80
  ## 校验
79
81
 
80
82
  ```powershell
81
- node scripts/verify-all.mjs # 全部离线套件(19 个)
83
+ node scripts/verify-all.mjs # 全部离线套件(20 个)
82
84
  node scripts/verify-all.mjs --live # 追加需要已安装插件 + 运行中宿主的套件
83
85
  ```
84
86
 
package/lib/index.js CHANGED
@@ -26,7 +26,8 @@ import {
26
26
  import { buildVariantPlugins } from './wsl/preset.js'
27
27
  import { setLocalSubprocess } from './wsl/host-refs.js'
28
28
  import { checkLinuxPath, defaultDistro, defaultWorkspaceUnc, listDistros, listLinuxDir, resolveDistroHome, runWslShell } from './wsl/world.js'
29
- import { joinWslUnc, parseWslUnc, windowsToMntPath } from './wsl/paths.js'
29
+ import { joinWslUnc, parseWslUnc, shellQuote, windowsToMntPath } from './wsl/paths.js'
30
+ import { sweepOrphanFifos } from './wsl/pty.js'
30
31
  import { WATCH_INTERVAL_MS } from './wsl/watch.js'
31
32
 
32
33
  /** Loader row identity, also used in diagnostics. */
@@ -107,6 +108,24 @@ function variantIdFor(preset) {
107
108
  const SUBPROCESS_MODULE = pathToFileURL(join(HERE, 'wsl', 'subprocess.js')).href
108
109
  const SHELL_MODULE = pathToFileURL(join(HERE, 'wsl', 'shell.js')).href
109
110
  const FS_MODULE = pathToFileURL(join(HERE, 'wsl', 'fs.js')).href
111
+ /**
112
+ * The realm's own confinement provider. It exists so the realm's `sandbox` service — the
113
+ * one `terminal-bash` resolves for every mode but `danger-full-access` — is answered from
114
+ * inside the distribution instead of by the host's `sandbox-local`, whose Windows rung
115
+ * grants an ACL on the policy's workspace root and fails on this realm's 9P UNC spelling
116
+ * (`GetNamedSecurityInfoW`, Win32 1). The row that mounts it is the one that makes the
117
+ * group's `sandbox: true` isolation load-bearing.
118
+ */
119
+ const SANDBOX_MODULE = pathToFileURL(join(HERE, 'wsl', 'sandbox.js')).href
120
+ /**
121
+ * The realm's own `grep`/`glob` module. Named here, beside the three providers, for the same
122
+ * reason they are: a row must name a module the LOADER can import, and a `file://` URL is the
123
+ * one spelling that survives every generation. The two search rows both name this module (the
124
+ * row's `config.tool` picks which of the two tools it registers), because the search names the
125
+ * model already knows have to be re-mounted by something this plugin owns — the host's search
126
+ * package is removed inside a WSL realm, not reused.
127
+ */
128
+ const SEARCH_MODULE = pathToFileURL(join(HERE, 'wsl', 'search-tool.js')).href
110
129
 
111
130
  /** Last preset materialization outcome, reported through the route. */
112
131
  let presetState = { status: 'pending' }
@@ -300,6 +319,8 @@ async function materializePresets(ctx, distro, generation, previous) {
300
319
  subprocessPath: SUBPROCESS_MODULE,
301
320
  shellPath: SHELL_MODULE,
302
321
  fsPath: FS_MODULE,
322
+ sandboxPath: SANDBOX_MODULE,
323
+ searchPath: SEARCH_MODULE,
303
324
  ...(distro !== undefined ? { distro } : {}),
304
325
  ...(sourceDir !== undefined ? { sourceDir } : {}),
305
326
  })
@@ -517,6 +538,10 @@ const METHODS = {
517
538
  * @param {object} value - observed facts.
518
539
  */
519
540
  const record = (name, value) => steps.push({ name, ...value })
541
+ // Declared OUTSIDE the try so the `finally` can release it: the disposal capability lives on
542
+ // the handle (`dispose()`), not on the agent, and a binding scoped to the try made that
543
+ // capability unreachable from the one block whose job is to release it.
544
+ let handle
520
545
  let agent
521
546
  try {
522
547
  const agents = hostCtx.get('agents')
@@ -534,7 +559,7 @@ const METHODS = {
534
559
  // path passes cwd. The default is now exercised by verify-world/check-2.
535
560
  const workspaceCwd = cwd ?? (await defaultWorkspaceUnc({ wslPath: routeWslPath })).uncPath
536
561
  const sessionId = `wsl-selftest-${Date.now()}`
537
- const handle = await agents.create({ sessionId, meta: { cwd: workspaceCwd, agentPreset: preset } })
562
+ handle = await agents.create({ sessionId, meta: { cwd: workspaceCwd, agentPreset: preset } })
538
563
  agent = handle?.agent ?? handle
539
564
  record('create', {
540
565
  ok: true,
@@ -823,10 +848,102 @@ const METHODS = {
823
848
  text: JSON.stringify(pwshTool.result ?? null).slice(0, 200),
824
849
  })
825
850
 
851
+ // The realm's own `grep`/`glob`: the host removes both names from a WSL world
852
+ // (their argv[0] is a packaged Windows ripgrep) and this plugin mounts them back
853
+ // on the distribution's own grep/find. NO offline suite can drive them — the row
854
+ // module imports harness packages — so these two rows are the only observation of
855
+ // the chain end to end, and `scripts/verify-post-restart.mjs` reads them.
856
+ //
857
+ // The step names are `tool.search.*` (singular `tool`, as the plan spells them),
858
+ // NOT the `tools.*` the sibling rows use: the suite pairs with these exact strings,
859
+ // and both files are the only two places they appear.
860
+ //
861
+ // The fixture names, the token and the line number are spelled AGAIN in
862
+ // `scripts/verify-post-restart.mjs`, which asserts on their Linux paths — the same
863
+ // pairing `dsh-wsl-selftest.txt` already has between the two files. Three fixture
864
+ // facts, each one earning its place:
865
+ //
866
+ // - the probe carries the token on LINE 3, so a hit pins the line NUMBER rather
867
+ // than merely "some line of the right file";
868
+ // - `.git/x/hidden.ts` carries the same token and sits where the glob pattern
869
+ // reaches, so a search that stopped excluding the six VCS names prints an extra
870
+ // path (the shape Task 1's measurement removed) instead of passing quietly;
871
+ // - `src/a/b/deep.ts` is TWO directories below the pattern's own prefix, which is
872
+ // what pins "the pattern is anchored to the search root": an unanchored
873
+ // `src/*.ts` matches NOTHING (measured), so this file either appears or the
874
+ // anchoring broke.
875
+ //
876
+ // Written through the shell executor, not `fs`, and that is deliberate: the
877
+ // fixtures must exist in the world the search PROCESS runs in. A file written
878
+ // through the Windows-side share and a `grep` started through wsl.exe are only
879
+ // guaranteed to agree while the share serves the same running distribution —
880
+ // measured on this machine, after a host restart the share kept serving a stale
881
+ // view that the distribution could not see at all.
882
+ let searchFixtureError = null
883
+ const searchToken = 'dsh-wsl-search-needle'
884
+ if (shell === undefined || typeof workspaceCwd !== 'string' || !workspaceCwd.startsWith('\\\\wsl.localhost\\')) {
885
+ // A host (Windows) session has no distribution shell and no search tools; the
886
+ // rows still record, so the suite's WSL run can tell "no fixture" from "no row".
887
+ searchFixtureError = 'the WSL shell executor is unavailable, so the search fixtures were not created'
888
+ } else {
889
+ const searchFixtureCommand = [
890
+ 'mkdir -p src/a/b src/.git/x',
891
+ `printf '%s\\n' 'the search probe, written by the selftest' 'second line, so the match is not on line one' '${searchToken}' > src/dsh-wsl-search-probe.ts`,
892
+ `printf '%s\\n' '${searchToken}' > src/.git/x/hidden.ts`,
893
+ `printf '%s\\n' 'the nested file a root-relative pattern must find' > src/a/b/deep.ts`,
894
+ ].join(' && ')
895
+ // The policy is mandatory, not decorative: a `shell.resolve` with none gets the
896
+ // session-less default root, which does NOT contain this scratch workspace —
897
+ // measured, every write then failed EROFS. Same object the watch mutations and
898
+ // the fs roundtrip above stamp, for the same reason.
899
+ const searchFixturePolicy = { mode: 'workspace-write', workspaceRoot: workspaceCwd }
900
+ const searchFixture = await runShellSpec(shell, shell.resolve({
901
+ command: searchFixtureCommand,
902
+ workdir: workspaceCwd,
903
+ sandboxPolicy: searchFixturePolicy,
904
+ }))
905
+ .then((execution) => execution.result())
906
+ .catch((error) => ({ exitCode: null, stderr: { text: String(error?.message ?? error) } }))
907
+ if (searchFixture.exitCode !== 0) {
908
+ searchFixtureError = `the search fixtures could not be written: exit ${searchFixture.exitCode ?? 'null'} ${(searchFixture.stderr?.text ?? '').slice(0, 200)}`.trim()
909
+ }
910
+ }
911
+ // Both rows fold the fixture outcome into `ok`: a row that could not be given its
912
+ // fixture must not read as a passing observation of the tool.
913
+ //
914
+ // Both also record at 800 bytes, where their siblings use 400: this page carries
915
+ // the probe AND the nested path (each an absolute path under the scratch) plus the
916
+ // value those paths came from, and a cut that removed the `lineNumber` would red
917
+ // the assertion for a reason that is not the tool's. Measured on the real
918
+ // distribution output with a real scratch spelling: 309 (grep) and 356 (glob)
919
+ // bytes — 444 bytes of headroom, which is the point of the larger budget.
920
+ const grepSearch = await invoke('grep', { pattern: searchToken })
921
+ record('tool.search.grep', {
922
+ ok: grepSearch.ok && searchFixtureError === null,
923
+ error: grepSearch.error ?? searchFixtureError,
924
+ text: JSON.stringify(grepSearch.result ?? null).slice(0, 800),
925
+ })
926
+ const globSearch = await invoke('glob', { pattern: 'src/**/*.ts' })
927
+ record('tool.search.glob', {
928
+ ok: globSearch.ok && searchFixtureError === null,
929
+ error: globSearch.error ?? searchFixtureError,
930
+ text: JSON.stringify(globSearch.result ?? null).slice(0, 800),
931
+ })
932
+
826
933
  // A tool resolves its policy from the session's logged permission state
827
934
  // (`tool-bash/src/index.ts:199`), which a programmatically created
828
935
  // session does not have. Recording one is what makes the tool layer's
829
936
  // confinement observable here.
937
+ //
938
+ // Do NOT delete this append, and do not move it below the persistent calls: it is
939
+ // LOAD-BEARING for the fence row (`tools.persistFence`) further down, in a way that is
940
+ // invisible from there. The backend's guard only refuses a mode CHANGE, so if the
941
+ // logged mode were left at `danger-full-access` the later append would be a no-op and
942
+ // be accepted — and the fence row would redden on a guard that is working perfectly.
943
+ // This append is what makes that change a transition. `tools.bashConfined` itself has
944
+ // no reader in `scripts/verify-post-restart.mjs` any more (the two rows that read it
945
+ // were replaced when the realm's `bash` became the persistent tool), so it stays as the
946
+ // observation of the confined path plus the owner of this append, not as an assertion.
830
947
  try {
831
948
  agent.session.append('sandbox/mode', { mode: 'workspace-write' })
832
949
  const confinedTool = await invoke('bash', {
@@ -843,6 +960,197 @@ const METHODS = {
843
960
  } catch (error) {
844
961
  record('tools.bashConfined', { ok: false, error: error instanceof Error ? error.message : String(error) })
845
962
  }
963
+
964
+ // The persistent shell, measured on the TWO calls that distinguish it from a
965
+ // one-shot `bash`: the first establishes a cwd and exports a variable, the second
966
+ // reads both back. A one-shot tool answers the second call with a fresh process, so
967
+ // this pair is what makes "state survives" observable instead of asserted. The third
968
+ // observation rides in the second call: a write OUTSIDE the workspace ($HOME is one
969
+ // level above this scratch), which the confined path refuses — if the file appears,
970
+ // whatever answered `bash` is not confined inside the distribution.
971
+ //
972
+ // It sits AFTER the `sandbox/mode` append above on purpose: the backend refuses a
973
+ // mode change while a persistent terminal session is open
974
+ // (`terminal-bash/src/index.ts:52-62`), so a probe placed earlier would turn that
975
+ // append into a refusal and redden the two confinement rows for the wrong reason.
976
+ //
977
+ // The step names are spelled AGAIN in scripts/verify-post-restart.mjs — the same
978
+ // two-file pairing `dsh-wsl-selftest.txt` and `tool.search.*` already have.
979
+ const persistToken = 'dsh-wsl-persist-probe'
980
+ const persistA = await invoke('bash', {
981
+ command: `mkdir -p .dsh-wsl-persist-cwd && cd .dsh-wsl-persist-cwd && export DSH_WSL_PERSIST_PROBE=${persistToken}`,
982
+ description: 'selftest: establish a state a persistent shell must keep',
983
+ })
984
+ record('tools.bashPersist.a', {
985
+ ok: persistA.ok,
986
+ error: persistA.error ?? null,
987
+ text: JSON.stringify(persistA.result ?? null).slice(0, 400),
988
+ })
989
+ const persistB = await invoke('bash', {
990
+ command: `pwd; echo "$DSH_WSL_PERSIST_PROBE"; echo probe > "$HOME/.dsh-wsl-persist-outside"; echo write-exit=$?`,
991
+ description: 'selftest: read the state back and try a write outside the workspace',
992
+ })
993
+ record('tools.bashPersist.b', {
994
+ ok: persistB.ok,
995
+ error: persistB.error ?? null,
996
+ text: JSON.stringify(persistB.result ?? null).slice(0, 800),
997
+ })
998
+
999
+ // F8's guard, observed from the realm: once a persistent terminal session exists, a
1000
+ // `sandbox/mode` change must be REFUSED with the backend's own sentence. It runs after
1001
+ // the persistent calls on purpose — before them there is no owner activity to guard.
1002
+ //
1003
+ // Wrapped in its own try, and the catch records rather than rethrows: the backend
1004
+ // refuses by THROWING, and `session.append` throws before committing
1005
+ // (`packages/core/session/src/index.ts:876-902`, pinned by its own suite), so an
1006
+ // unguarded append here would leave the session's logged mode untouched AND escape
1007
+ // into the outer catch, where it would kill every row below and report one `error`
1008
+ // step instead. The refusal is the OBSERVATION, so it is recorded as a step.
1009
+ try {
1010
+ agent.session.append('sandbox/mode', { mode: 'danger-full-access' })
1011
+ record('tools.persistFence', { refused: false, reason: 'the mode change was accepted while a persistent terminal session was open' })
1012
+ } catch (error) {
1013
+ record('tools.persistFence', { refused: true, reason: error instanceof Error ? error.message : String(error) })
1014
+ }
1015
+
1016
+ // The clock-domain row the controller added to this task (R47): the orphan sweep's age
1017
+ // decision must be made entirely INSIDE the distribution.
1018
+ //
1019
+ // Why this exists at all: the sweep was changed to one command with two answers
1020
+ // (`find … -print`, a marker, `find … -mmin +2 -print`) precisely so the age
1021
+ // comparison never crosses a clock domain (`lib/wsl/pty.js:417-489`). The offline pin
1022
+ // for that change is a SOURCE-SHAPE pin (`scripts/verify-pty-handle.mjs`), because no
1023
+ // fixture can make two clocks disagree — `touch -t` sets the time explicitly, and
1024
+ // skewing a distribution's clock is out of bounds. R41 accepted the shape pin on the
1025
+ // condition that the BEHAVIOURAL evidence lands here. Without it, a future "fix" that
1026
+ // reintroduced a second clock source (`stat -c %Y` in the distro, compared against
1027
+ // `Date.now()` on the host) would redden nothing.
1028
+ //
1029
+ // What it does NOT do: it does not FAKE skew. It proves the host's clock is not an
1030
+ // input — the strongest honest evidence available here — by planting two LEGACY names
1031
+ // in the distro (one fresh, one backdated), running the real sweep, and then running it
1032
+ // again with this process's `Date.now()` shifted by 45 days. A sweep that consulted the
1033
+ // host clock at all would change a verdict across that shift.
1034
+ //
1035
+ // Cleanup is unconditional and one command: the probe's own names carry this host's
1036
+ // pid, so a crashed run's residue is collectable by the next generation's sweep too.
1037
+ const sweepNames = { young: `/tmp/dsh-pty-${process.pid}-t3sweepyoung.fifo`, aged: `/tmp/dsh-pty-${process.pid}-t3sweepaged.fifo` }
1038
+ // The distribution comes from the workspace this session runs in — the same source
1039
+ // `shell.run` and the search fixtures use — not from a config default: on a host whose
1040
+ // default distribution differs from the workspace's, the probe would then plant its
1041
+ // names in one distribution and sweep another, and read `youngKept` off a directory
1042
+ // where nothing was ever planted. A host (Windows) session has no distribution at all,
1043
+ // and says so instead of reporting four absent rows.
1044
+ const sweepTarget = typeof workspaceCwd === 'string' ? parseWslUnc(workspaceCwd) : null
1045
+ const sweepDistro = sweepTarget?.distro
1046
+ // ONE runner for the probe's distribution calls, with the configured `wsl.exe` path
1047
+ // threaded through the same named select the rest of this file uses. FOUR source sites
1048
+ // (`sweepList`, `sweepPlant`, the clock read and the cleanup) issuing EIGHT invocations
1049
+ // per run — three listings, two plants, the clock read, the bridge probe below and the
1050
+ // cleanup — not four calls. `scripts/verify-world.mjs` pins this file's probe SITES by
1051
+ // count, and the `...sweepSelect` spread above is what carries the configured path
1052
+ // through this runner; that pin now covers THIS form structurally as well
1053
+ // (`insideCall` + `carrier` in the structural row beside the count table), so dropping
1054
+ // the spread reddens it instead of silently reverting to the default `wsl.exe`.
1055
+ const sweepSelect = { wslPath: routeWslPath }
1056
+ const sweepShell = (command) => runWslShell({ distro: sweepDistro, linuxCwd: '/', loginShell: false, command, ...sweepSelect })
1057
+ const sweepList = async () => String((await sweepShell('ls -1 /tmp/dsh-pty-*.fifo 2>/dev/null; echo END')).stdout ?? '')
1058
+ .split('\n').map((line) => line.trim()).filter((line) => line.startsWith('/tmp/dsh-pty-'))
1059
+ const sweepPlant = () => sweepShell(
1060
+ `rm -f ${shellQuote(sweepNames.young)} ${shellQuote(sweepNames.aged)}`
1061
+ + ` && mkfifo ${shellQuote(sweepNames.young)}`
1062
+ + ` && mkfifo ${shellQuote(sweepNames.aged)}`
1063
+ // 2020-01-01: older than any creation window, by six years.
1064
+ + ` && touch -t 202001010000 ${shellQuote(sweepNames.aged)}`,
1065
+ )
1066
+ // THE POSITIVE CONTROL for criterion 2's bridge half (R49(d), M4). It is read HERE,
1067
+ // while this selftest's own persistent PTY is still open — the session is disposed in
1068
+ // the outer `finally`, so this is the last point at which a bridge of this run is
1069
+ // guaranteed to exist. Without it the suite's "this run added no bridge" clause passes
1070
+ // by construction on an empty baseline: a pgrep that matches nothing is
1071
+ // indistinguishable from a bridge that was never created, and a broken pattern would
1072
+ // read as a clean run forever. Recorder discipline is the same as every other probe:
1073
+ // a host-world session records the REASON it could not observe, never an empty row.
1074
+ let bridgeProbe
1075
+ try {
1076
+ if (sweepDistro === undefined) throw new Error('this session runs in the host world, so there is no bridge to observe')
1077
+ const listing = await sweepShell(`pgrep -f -- '[P]TY bridge for the WSL execution world' || true`)
1078
+ const pids = String(listing.stdout ?? '')
1079
+ .split('\n').map((line) => line.trim()).filter((line) => /^\d+$/.test(line)).sort()
1080
+ bridgeProbe = { ok: true, distro: sweepDistro, pids, count: pids.length }
1081
+ } catch (error) {
1082
+ bridgeProbe = { ok: false, error: error instanceof Error ? error.message : String(error) }
1083
+ }
1084
+ record('tools.persistBridge', bridgeProbe)
1085
+ let sweepProbe
1086
+ try {
1087
+ // A Windows session cannot host this probe: the sweep is a distribution-side
1088
+ // operation and there is no distribution to sweep. Recorded as the REASON, never as
1089
+ // an empty row — the suite's row has to be able to tell "no distribution" from
1090
+ // "the probe did not run".
1091
+ if (sweepDistro === undefined) throw new Error('this session runs in the host world, so there is no distribution to sweep')
1092
+ // The measurement (not an assertion): how far apart the two clocks actually are on
1093
+ // THIS machine. Recorded so the report can state it instead of assuming agreement.
1094
+ const hostMs = Date.now()
1095
+ const clock = await sweepShell('date +%s; date -u +%Y-%m-%dT%H:%M:%SZ')
1096
+ const [distroSecondsText, distroIso] = String(clock.stdout ?? '').trim().split('\n')
1097
+ await sweepPlant()
1098
+ const planted = await sweepList()
1099
+ const removedFirst = await sweepOrphanFifos({ distro: sweepDistro }, { wslPath: routeWslPath })
1100
+ const afterFirst = await sweepList()
1101
+ // The host-side shift. `Date.now` is the only host clock this process offers the
1102
+ // sweep, so patching it is the whole of "the host's reading changed".
1103
+ //
1104
+ // DECLARED AS A SHARED-INPUT MUTATION, because that is what it is: `Date.now` is
1105
+ // PROCESS-GLOBAL on a live host, and anything else in this process that reads
1106
+ // `Date.now()` inside the patched window would be stamped 45 days into the future —
1107
+ // `session.append`'s event timestamps (`packages/core/session/src/index.ts:820`) are
1108
+ // the concrete example. Two things keep that exposure small and are the reason this
1109
+ // shape is acceptable at all: the window holds ONE await (the sweep), and it is
1110
+ // restored in `finally` on every path. The reading that follows the sweep is taken
1111
+ // OUTSIDE the window on purpose, so the window cannot grow back by an edit that
1112
+ // slides a line.
1113
+ await sweepPlant()
1114
+ const realNow = Date.now
1115
+ const HOST_SHIFT_MS = 45 * 24 * 3600 * 1000
1116
+ let removedShifted
1117
+ try {
1118
+ Date.now = () => realNow() + HOST_SHIFT_MS
1119
+ removedShifted = await sweepOrphanFifos({ distro: sweepDistro }, { wslPath: routeWslPath })
1120
+ } finally {
1121
+ Date.now = realNow
1122
+ }
1123
+ // Unpatched by construction: this is the observation of what the patched sweep left,
1124
+ // and it must not itself run under a fake clock.
1125
+ const afterShift = await sweepList()
1126
+ sweepProbe = {
1127
+ distro: sweepDistro,
1128
+ distroSeconds: Number(distroSecondsText),
1129
+ hostSeconds: hostMs / 1000,
1130
+ deltaSeconds: Math.round((hostMs / 1000 - Number(distroSecondsText)) * 1000) / 1000,
1131
+ distroIso: distroIso ?? null,
1132
+ planted,
1133
+ youngKept: afterFirst.includes(sweepNames.young),
1134
+ agedSwept: !afterFirst.includes(sweepNames.aged),
1135
+ removedFirst,
1136
+ afterFirst,
1137
+ hostShiftMs: HOST_SHIFT_MS,
1138
+ youngKeptAfterShift: afterShift.includes(sweepNames.young),
1139
+ agedSweptAfterShift: !afterShift.includes(sweepNames.aged),
1140
+ removedShifted,
1141
+ afterShift,
1142
+ }
1143
+ } catch (error) {
1144
+ sweepProbe = { error: error instanceof Error ? error.message : String(error) }
1145
+ } finally {
1146
+ // Unconditional: the fixtures are this host's, and a throw above must not leave them
1147
+ // for the operator to find. Best-effort by design — a cleanup fault must not mask
1148
+ // the observation, and each name is collectable by a later sweep regardless.
1149
+ if (sweepDistro !== undefined) {
1150
+ await sweepShell(`rm -f ${shellQuote(sweepNames.young)} ${shellQuote(sweepNames.aged)}`).catch(() => {})
1151
+ }
1152
+ }
1153
+ record('tools.persistSweep', sweepProbe)
846
1154
  }
847
1155
  return { steps }
848
1156
  } catch (error) {
@@ -853,11 +1161,43 @@ const METHODS = {
853
1161
  return { steps }
854
1162
  } finally {
855
1163
  // A selftest session is a live agent in the registry; release it.
1164
+ //
1165
+ // THE RELEASE WAS A NO-OP, and that is what the live gate measured (root cause A of the
1166
+ // 2026-10-19 17:23 run). `agent?.dispose?.()` is an OPTIONAL call on the AGENT, and the
1167
+ // harness's agent surface has no `dispose` — `dispose()` belongs to the HANDLE
1168
+ // (`AgentHandle`, `packages/core/agent/src/index.ts:160-163`), which is the capability that
1169
+ // stops and drains the loop, unregisters the agent and unwinds its scope. So the await
1170
+ // resolved immediately having done nothing, the session's PTY was never torn down, and the
1171
+ // suite that read the bridge/FIFO delta right after this returned was reading a session
1172
+ // that had never been released. (The optional call is WHY it failed silently: `?.` turned a
1173
+ // missing method into a no-op instead of a TypeError.)
1174
+ //
1175
+ // `?? fallback` is kept for hosts whose create() answers a bare agent (the same
1176
+ // dual-dispatch the create step above documents); on those the disposal is still
1177
+ // best-effort, and the recorded step says which shape ran so a reader can tell.
1178
+ let teardown
1179
+ const startedAt = Date.now()
856
1180
  try {
857
- await agent?.dispose?.()
858
- } catch {
859
- // Disposal failure does not change what the checks observed.
1181
+ const target = handle ?? agent
1182
+ const method = target?.dispose
1183
+ if (typeof method !== 'function') {
1184
+ teardown = { ok: false, error: 'the created value exposes no dispose() — nothing was released' }
1185
+ } else {
1186
+ await method.call(target)
1187
+ teardown = { ok: true, via: target === handle ? 'handle' : 'agent', elapsedMs: Date.now() - startedAt }
1188
+ }
1189
+ } catch (error) {
1190
+ // Disposal failure does not change what the checks observed, but it MUST be visible:
1191
+ // a throw here is the difference between "the session was released" and "the suite is
1192
+ // about to read the residue of a session that is still alive".
1193
+ teardown = { ok: false, error: error instanceof Error ? error.message : String(error), elapsedMs: Date.now() - startedAt }
860
1194
  }
1195
+ // RECORDED AFTER the await, which is the whole point: this step is the suite's permission
1196
+ // to read the bridge/FIFO delta. A row that reads without it is reading mid-teardown, and
1197
+ // measured on the 17:23 run that produced a bridge and a FIFO belonging to a session whose
1198
+ // disposal had not run at all (root cause A). It is recorded even when disposal FAILED —
1199
+ // `ok: false` is the suite's signal that the residue it finds is real, not a timing artefact.
1200
+ record('tools.persistTeardown', teardown)
861
1201
  }
862
1202
  },
863
1203
 
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The argv a PTY-attached spawn gets when the realm's sandbox confines it.
3
+ *
4
+ * This module builds NO fence of its own. The fence — mount namespace, workspace bind,
5
+ * private tmpfs, read-only sweep, postconditions, setpriv drop — is
6
+ * `buildConfinedCommand` (`./confinement.js`), the ONE builder the realm's shell
7
+ * executor also runs (`./shell.js`), and the runner it selects is the distribution's
8
+ * own (`sudo -n <dsh-wsl-confine> …` or `sudo -n unshare …`). What is added here is the
9
+ * presentation: the terminal spawn needs an ARGV, the builder produces the shell COMMAND
10
+ * that establishes the fence, so the command is handed to `bash -lc` and the caller's own
11
+ * program travels into the fence as `exec`'d argv.
12
+ *
13
+ * Why the caller's argv is re-quoted instead of passed through: both runners take the
14
+ * command as ONE shell word and hand it to the distribution's shell after their own
15
+ * privilege drop (`shell.js` passes its caller's shell source the same way). Re-quoting
16
+ * each element with `shellQuote` and prefixing `exec` makes the distribution run exactly
17
+ * the program and arguments the caller named — none of them can be re-split, globbed or
18
+ * re-read as syntax by the intervening shells — and `exec` keeps the interactive process
19
+ * as the PTY's direct child, so terminal signals and the foreground group reach it
20
+ * instead of stopping at a wrapper shell.
21
+ *
22
+ * The login flag matches the shell executor's own translation of a confined command into
23
+ * argv (`buildLinuxShellArgv` with `loginShell: true`). The inner command is `exec`'d, so
24
+ * no SECOND login shell is started: profiles run once, for the fence wrapper.
25
+ * @module dsh-wsl-desktop/wsl/confined-argv
26
+ */
27
+
28
+ import { buildConfinedCommand } from './confinement.js'
29
+ import { shellQuote } from './paths.js'
30
+
31
+ /**
32
+ * Wrap one terminal spawn's argv in the distribution's in-distro confinement.
33
+ *
34
+ * Pure: every policy fact it needs is an argument, and the fence text comes from
35
+ * `buildConfinedCommand` unchanged. It refuses exactly what that builder refuses (a
36
+ * `workspace-write` fence for a root under the private `/tmp`, a control character in the
37
+ * workspace spelling) and throws its errors as they are — the caller classifies them, and
38
+ * swallowing one would run a terminal that is not fenced while reporting a fence.
39
+ * @param {object} options - the spawn's argv and its resolved confinement inputs.
40
+ * @param {readonly string[]} options.argv - the program and arguments the terminal must run.
41
+ * @param {string} options.linuxCwd - absolute Linux working directory for the fence.
42
+ * @param {'read-only' | 'workspace-write'} options.mode - the confined mode.
43
+ * @param {string | undefined} options.workspaceLinuxRoot - writable root for `workspace-write`.
44
+ * @param {{ uid: string, gid: string, home: string, name?: string }} options.identity - the drop identity.
45
+ * @param {boolean | undefined} options.noNewPrivs - whether the direct runner may set NO_NEW_PRIVS.
46
+ * @param {'helper' | 'sudo-unshare'} options.runner - the resolved runner.
47
+ * @param {boolean} [options.isolateProcesses] - add a PID namespace (never for an interactive PTY).
48
+ * @returns {string[]} the argv to spawn on the PTY.
49
+ * @throws Error when `argv` is empty, and whatever `buildConfinedCommand` refuses.
50
+ */
51
+ export function buildConfinedTerminalArgv({ argv, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs, runner, isolateProcesses }) {
52
+ if (!Array.isArray(argv) || argv.length === 0) {
53
+ throw new Error('wsl-sandbox: 需要一个非空的 argv')
54
+ }
55
+ const program = argv.map((element) => shellQuote(element)).join(' ')
56
+ return [
57
+ 'bash',
58
+ '-lc',
59
+ buildConfinedCommand({ command: `exec ${program}`, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs, runner, isolateProcesses }),
60
+ ]
61
+ }
62
+
63
+ export default buildConfinedTerminalArgv