dsh-wsl-desktop 0.4.0 → 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`: its rows that have run are green, but **the two live search rows have not run yet** and await the operator's live acceptance this round, plus 20 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 terminal has no `stdio.control` — it is 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
 
@@ -73,6 +73,8 @@ After every DSH Desktop update, the plugin may break because of harness internal
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).
package/README.md CHANGED
@@ -18,16 +18,16 @@
18
18
 
19
19
  ## 目标能力
20
20
 
21
- **活体验收通过后的诚实状态**(`verify-post-restart.mjs` 已跑过的行全绿,但**搜索的两条活体行尚未运行**、待操作者本轮活体验收,加离线 20 套件;下面每条都注明证据强度):
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
- **哪些能力有保留**(证据强度就是上表状态列的措辞):终端不支持 `stdio.control`——写在「已知限制」里,不藏。
30
+ **哪些能力有保留**(证据强度就是上表状态列的措辞):终端不支持 `stdio.control`——写在「已知限制」里,不藏。常驻 shell 的缺口同样写在「已知限制」里,权威清单在 [常驻 shell](docs/PERSISTENT-SHELL.md)。
31
31
 
32
32
  ## 安装 / 更新 / 卸载
33
33
 
@@ -71,6 +71,8 @@
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)。
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,15 @@ 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
110
120
  /**
111
121
  * The realm's own `grep`/`glob` module. Named here, beside the three providers, for the same
112
122
  * reason they are: a row must name a module the LOADER can import, and a `file://` URL is the
@@ -309,6 +319,7 @@ async function materializePresets(ctx, distro, generation, previous) {
309
319
  subprocessPath: SUBPROCESS_MODULE,
310
320
  shellPath: SHELL_MODULE,
311
321
  fsPath: FS_MODULE,
322
+ sandboxPath: SANDBOX_MODULE,
312
323
  searchPath: SEARCH_MODULE,
313
324
  ...(distro !== undefined ? { distro } : {}),
314
325
  ...(sourceDir !== undefined ? { sourceDir } : {}),
@@ -527,6 +538,10 @@ const METHODS = {
527
538
  * @param {object} value - observed facts.
528
539
  */
529
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
530
545
  let agent
531
546
  try {
532
547
  const agents = hostCtx.get('agents')
@@ -544,7 +559,7 @@ const METHODS = {
544
559
  // path passes cwd. The default is now exercised by verify-world/check-2.
545
560
  const workspaceCwd = cwd ?? (await defaultWorkspaceUnc({ wslPath: routeWslPath })).uncPath
546
561
  const sessionId = `wsl-selftest-${Date.now()}`
547
- const handle = await agents.create({ sessionId, meta: { cwd: workspaceCwd, agentPreset: preset } })
562
+ handle = await agents.create({ sessionId, meta: { cwd: workspaceCwd, agentPreset: preset } })
548
563
  agent = handle?.agent ?? handle
549
564
  record('create', {
550
565
  ok: true,
@@ -919,6 +934,16 @@ const METHODS = {
919
934
  // (`tool-bash/src/index.ts:199`), which a programmatically created
920
935
  // session does not have. Recording one is what makes the tool layer's
921
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.
922
947
  try {
923
948
  agent.session.append('sandbox/mode', { mode: 'workspace-write' })
924
949
  const confinedTool = await invoke('bash', {
@@ -935,6 +960,197 @@ const METHODS = {
935
960
  } catch (error) {
936
961
  record('tools.bashConfined', { ok: false, error: error instanceof Error ? error.message : String(error) })
937
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)
938
1154
  }
939
1155
  return { steps }
940
1156
  } catch (error) {
@@ -945,11 +1161,43 @@ const METHODS = {
945
1161
  return { steps }
946
1162
  } finally {
947
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()
948
1180
  try {
949
- await agent?.dispose?.()
950
- } catch {
951
- // 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 }
952
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)
953
1201
  }
954
1202
  },
955
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