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 +5 -3
- package/README.md +5 -3
- package/lib/index.js +253 -5
- package/lib/wsl/confined-argv.js +63 -0
- package/lib/wsl/preset.js +114 -17
- package/lib/wsl/pty.js +572 -14
- package/lib/wsl/sandbox.js +185 -0
- package/lib/wsl/subprocess.js +5 -4
- package/lib/wsl/world.js +11 -0
- package/package.json +1 -1
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
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|