dsh-wsl-desktop 0.3.6 → 0.4.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 +9 -6
- package/README.md +8 -5
- package/lib/index.js +196 -1
- package/lib/wsl/confinement.js +5 -4
- package/lib/wsl/fs.js +28 -0
- package/lib/wsl/preset.js +18 -3
- package/lib/wsl/search-tool.js +436 -0
- package/lib/wsl/search.js +288 -0
- package/lib/wsl/watch.js +220 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -14,13 +14,13 @@ Turns a WSL distribution into a **first-class execution world** inside DSH Deskt
|
|
|
14
14
|
## Contents
|
|
15
15
|
|
|
16
16
|
- **This page**: [Target capabilities](#target-capabilities) · [Install / Update / Uninstall](#install--update--uninstall) · [Quick start](#quick-start) · [Desktop update discipline](#desktop-update-discipline-mandatory) · [Security and trust boundaries](#security-and-trust-boundaries) · [Known limitations](#known-limitations) · [Verification](#verification) · [Development](#development) · [Release](#release)
|
|
17
|
-
- **In-depth material**: [Security and trust boundaries](SECURITY.md) · [Architecture and the execution world](docs/ARCHITECTURE.en.md) · [Confinement (Linux side)](docs/CONFINEMENT.en.md) · [The fs fence](docs/FS-FENCE.en.md) · [Terminal (PTY bridge)](docs/PTY-BRIDGE.en.md) · [Verification and the SKIP ruling](docs/VERIFICATION.en.md) · [Engineering notes](docs/ENGINEERING-NOTES.en.md) · [Distro support matrix](docs/DISTRO-SUPPORT.md) · [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) · [Architecture and flow diagrams](docs/diagrams.md)
|
|
17
|
+
- **In-depth material**: [Security and trust boundaries](SECURITY.md) · [Architecture and the execution world](docs/ARCHITECTURE.en.md) · [Confinement (Linux side)](docs/CONFINEMENT.en.md) · [The fs fence](docs/FS-FENCE.en.md) · [Terminal (PTY bridge)](docs/PTY-BRIDGE.en.md) · [WSL file watching](docs/WATCH.en.md) · [Verification and the SKIP ruling](docs/VERIFICATION.en.md) · [Engineering notes](docs/ENGINEERING-NOTES.en.md) · [Distro support matrix](docs/DISTRO-SUPPORT.md) · [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) · [Architecture and flow diagrams](docs/diagrams.md)
|
|
18
18
|
|
|
19
|
-
> The
|
|
19
|
+
> The bilingual documents linked above (their file names carry an `.en` marker) are enforced as pairs rather than trusted. The three older documents — the distro support matrix, the upstream proposals and the diagram index — are **Chinese-only**, because no English text for them exists: translating them is a separate piece of work, not a rename.
|
|
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`: 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):
|
|
24
24
|
|
|
25
25
|
| # | Capability | Status |
|
|
26
26
|
|---|---|---|
|
|
@@ -29,7 +29,7 @@ Turns a WSL distribution into a **first-class execution world** inside DSH Deskt
|
|
|
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
|
|
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.
|
|
33
33
|
|
|
34
34
|
## Install / Update / Uninstall
|
|
35
35
|
|
|
@@ -69,16 +69,18 @@ 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
|
|
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
76
|
**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
77
|
|
|
78
|
+
**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).
|
|
79
|
+
|
|
78
80
|
## Verification
|
|
79
81
|
|
|
80
82
|
```powershell
|
|
81
|
-
node scripts/verify-all.mjs # all offline suites (
|
|
83
|
+
node scripts/verify-all.mjs # all offline suites (20)
|
|
82
84
|
node scripts/verify-all.mjs --live # additionally the suites needing the installed plugin + a running host
|
|
83
85
|
```
|
|
84
86
|
|
|
@@ -106,6 +108,7 @@ lib/wsl/world.js Distro discovery, the wsl.exe execution core, directory
|
|
|
106
108
|
lib/wsl/shell.js ShellExecutor implementation (WSL bash + confinement hookup)
|
|
107
109
|
lib/wsl/fs.js LocalFileSystem subclass (9P backend + path dialects)
|
|
108
110
|
lib/wsl/publish.js The overwrite publication's wait bound (retry + measured tail; pure module, independently testable)
|
|
111
|
+
lib/wsl/watch.js Polling strategies for file watching (pure module, independently testable)
|
|
109
112
|
lib/wsl/subprocess.js SubprocessRuntime implementation (pipes + terminal; delegates to the host subprocess)
|
|
110
113
|
lib/wsl/pty.js Terminal handle: two processes driving the in-distro PTY bridge
|
|
111
114
|
lib/wsl/terminal-bridge.py The PTY bridge running inside the distro (python3 stdlib, zero install)
|
package/README.md
CHANGED
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
## 目录
|
|
15
15
|
|
|
16
16
|
- **本页**:[目标能力](#目标能力) · [安装 / 更新 / 卸载](#安装--更新--卸载) · [快速开始](#快速开始) · [桌面更新纪律](#桌面更新纪律强制) · [安全与信任边界](#安全与信任边界) · [已知限制](#已知限制) · [校验](#校验) · [开发](#开发) · [发版](#发版)
|
|
17
|
-
- **深入材料**:[安全与信任边界](SECURITY.md) · [架构与执行世界](docs/ARCHITECTURE.md) · [Linux 侧约束](docs/CONFINEMENT.md) · [fs 工具的围栏](docs/FS-FENCE.md) · [终端 PTY 桥](docs/PTY-BRIDGE.md) · [校验与 SKIP 裁定](docs/VERIFICATION.md) · [工程笔记](docs/ENGINEERING-NOTES.md) · [发行版支持矩阵](docs/DISTRO-SUPPORT.md) · [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) · [架构与流程图](docs/diagrams.md)
|
|
17
|
+
- **深入材料**:[安全与信任边界](SECURITY.md) · [架构与执行世界](docs/ARCHITECTURE.md) · [Linux 侧约束](docs/CONFINEMENT.md) · [fs 工具的围栏](docs/FS-FENCE.md) · [终端 PTY 桥](docs/PTY-BRIDGE.md) · [WSL 文件变更监听](docs/WATCH.md) · [校验与 SKIP 裁定](docs/VERIFICATION.md) · [工程笔记](docs/ENGINEERING-NOTES.md) · [发行版支持矩阵](docs/DISTRO-SUPPORT.md) · [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) · [架构与流程图](docs/diagrams.md)
|
|
18
18
|
|
|
19
19
|
## 目标能力
|
|
20
20
|
|
|
21
|
-
**活体验收通过后的诚实状态**(`verify-post-restart.mjs`
|
|
21
|
+
**活体验收通过后的诚实状态**(`verify-post-restart.mjs` 已跑过的行全绿,但**搜索的两条活体行尚未运行**、待操作者本轮活体验收,加离线 20 套件;下面每条都注明证据强度):
|
|
22
22
|
|
|
23
23
|
| # | 能力 | 状态 |
|
|
24
24
|
|---|---|---|
|
|
@@ -27,7 +27,7 @@
|
|
|
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
|
-
|
|
30
|
+
**哪些能力有保留**(证据强度就是上表状态列的措辞):终端不支持 `stdio.control`——写在「已知限制」里,不藏。
|
|
31
31
|
|
|
32
32
|
## 安装 / 更新 / 卸载
|
|
33
33
|
|
|
@@ -67,16 +67,18 @@
|
|
|
67
67
|
|
|
68
68
|
## 已知限制
|
|
69
69
|
|
|
70
|
-
**grep / glob 工具在 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
74
|
**约束覆盖不到 `/dev`、`/proc`、`/sys` 与 interop**,如实报在 `enforcement: 'partial'` 里;而会话用户保留的免密 sudo 授权可绕过文件围栏,闭合路径见 [SECURITY.md](SECURITY.md)。
|
|
75
75
|
|
|
76
|
+
**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)。
|
|
77
|
+
|
|
76
78
|
## 校验
|
|
77
79
|
|
|
78
80
|
```powershell
|
|
79
|
-
node scripts/verify-all.mjs # 全部离线套件(
|
|
81
|
+
node scripts/verify-all.mjs # 全部离线套件(20 个)
|
|
80
82
|
node scripts/verify-all.mjs --live # 追加需要已安装插件 + 运行中宿主的套件
|
|
81
83
|
```
|
|
82
84
|
|
|
@@ -104,6 +106,7 @@ lib/wsl/world.js 发行版发现、wsl.exe 执行核、目录事实
|
|
|
104
106
|
lib/wsl/shell.js ShellExecutor 实现(WSL bash + 约束接入)
|
|
105
107
|
lib/wsl/fs.js LocalFileSystem 子类(9P 后端 + 路径方言)
|
|
106
108
|
lib/wsl/publish.js 覆盖发布的等待边界(重试 + 实测尾部;纯模块,可独立测试)
|
|
109
|
+
lib/wsl/watch.js 文件变更监听的轮询策略(纯模块,可独立测试)
|
|
107
110
|
lib/wsl/subprocess.js SubprocessRuntime 实现(管道 + 终端;委托宿主 subprocess)
|
|
108
111
|
lib/wsl/pty.js 终端句柄:两个进程驱动发行版内的 PTY 桥
|
|
109
112
|
lib/wsl/terminal-bridge.py 发行版内运行的 PTY 桥(python3 标准库,零安装)
|
package/lib/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
17
|
-
import { existsSync } from 'node:fs'
|
|
17
|
+
import { existsSync, watch as nativeFsWatch } from 'node:fs'
|
|
18
18
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
19
19
|
import { homedir } from 'node:os'
|
|
20
20
|
import { dirname, join } from 'node:path'
|
|
@@ -27,6 +27,7 @@ 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
29
|
import { joinWslUnc, parseWslUnc, windowsToMntPath } from './wsl/paths.js'
|
|
30
|
+
import { WATCH_INTERVAL_MS } from './wsl/watch.js'
|
|
30
31
|
|
|
31
32
|
/** Loader row identity, also used in diagnostics. */
|
|
32
33
|
export const name = 'wsl-desktop'
|
|
@@ -106,6 +107,15 @@ function variantIdFor(preset) {
|
|
|
106
107
|
const SUBPROCESS_MODULE = pathToFileURL(join(HERE, 'wsl', 'subprocess.js')).href
|
|
107
108
|
const SHELL_MODULE = pathToFileURL(join(HERE, 'wsl', 'shell.js')).href
|
|
108
109
|
const FS_MODULE = pathToFileURL(join(HERE, 'wsl', 'fs.js')).href
|
|
110
|
+
/**
|
|
111
|
+
* The realm's own `grep`/`glob` module. Named here, beside the three providers, for the same
|
|
112
|
+
* reason they are: a row must name a module the LOADER can import, and a `file://` URL is the
|
|
113
|
+
* one spelling that survives every generation. The two search rows both name this module (the
|
|
114
|
+
* row's `config.tool` picks which of the two tools it registers), because the search names the
|
|
115
|
+
* model already knows have to be re-mounted by something this plugin owns — the host's search
|
|
116
|
+
* package is removed inside a WSL realm, not reused.
|
|
117
|
+
*/
|
|
118
|
+
const SEARCH_MODULE = pathToFileURL(join(HERE, 'wsl', 'search-tool.js')).href
|
|
109
119
|
|
|
110
120
|
/** Last preset materialization outcome, reported through the route. */
|
|
111
121
|
let presetState = { status: 'pending' }
|
|
@@ -299,6 +309,7 @@ async function materializePresets(ctx, distro, generation, previous) {
|
|
|
299
309
|
subprocessPath: SUBPROCESS_MODULE,
|
|
300
310
|
shellPath: SHELL_MODULE,
|
|
301
311
|
fsPath: FS_MODULE,
|
|
312
|
+
searchPath: SEARCH_MODULE,
|
|
302
313
|
...(distro !== undefined ? { distro } : {}),
|
|
303
314
|
...(sourceDir !== undefined ? { sourceDir } : {}),
|
|
304
315
|
})
|
|
@@ -634,6 +645,108 @@ const METHODS = {
|
|
|
634
645
|
} catch (error) {
|
|
635
646
|
record('fs.roundtrip', { error: error instanceof Error ? error.message : String(error) })
|
|
636
647
|
}
|
|
648
|
+
|
|
649
|
+
// Watch rows: the strategy the provider installs, driven the way a consumer
|
|
650
|
+
// drives it, and mutated from INSIDE the distribution — the case the rows are
|
|
651
|
+
// about. No model-facing tool reaches `fs.watch`, so the provider's own entry
|
|
652
|
+
// is the only place this can be observed.
|
|
653
|
+
if (fs !== undefined && shell !== undefined && typeof workspaceCwd === 'string' && workspaceCwd.startsWith('\\\\wsl.localhost\\')) {
|
|
654
|
+
/**
|
|
655
|
+
* The policy every mutation below carries. A request that omits `sandboxPolicy`
|
|
656
|
+
* makes `bash-sandbox`'s `resolve()` substitute the session-less default, and the
|
|
657
|
+
* confinement then binds a root that does NOT contain this scratch workspace —
|
|
658
|
+
* measured: every mutation failed EROFS ("Read-only file system"). The fallback is
|
|
659
|
+
* not a MISSING root: `lib/wsl/confinement.js` binds the command's cwd when a policy
|
|
660
|
+
* carries none. It is a root from the deployment's own world, which
|
|
661
|
+
* `lib/wsl/paths.js` maps to `/mnt/<drive>/…`. The tool layer never meets this
|
|
662
|
+
* because `tool-bash` stamps the resolved policy into its request; a direct
|
|
663
|
+
* provider call has to stamp it itself.
|
|
664
|
+
*/
|
|
665
|
+
const workspaceWritePolicy = { mode: 'workspace-write', workspaceRoot: workspaceCwd }
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Arm one watch, mutate the target from the distribution, report what arrived.
|
|
669
|
+
* @param {{ targetKey: string }} target - the resolved watch target.
|
|
670
|
+
* @param {string} command - the shell command that changes it.
|
|
671
|
+
* @returns {Promise<object>} the observation row.
|
|
672
|
+
*/
|
|
673
|
+
const observeWatch = async (target, command) => {
|
|
674
|
+
const controller = new AbortController()
|
|
675
|
+
const linuxPath = fs.processPath(target)
|
|
676
|
+
let notified = 0
|
|
677
|
+
let watchError = null
|
|
678
|
+
let unwatch
|
|
679
|
+
try {
|
|
680
|
+
unwatch = await fs.watch(target, (error) => {
|
|
681
|
+
if (error === undefined) notified += 1
|
|
682
|
+
else watchError = String(error?.message ?? error)
|
|
683
|
+
}, controller.signal)
|
|
684
|
+
const execution = await runShellSpec(shell, shell.resolve({ command, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
|
|
685
|
+
// The mutation's outcome is REPORTED, not assumed. A refused write otherwise
|
|
686
|
+
// leaves `notified: 0` with a null `watchError`, which reads exactly like a
|
|
687
|
+
// watcher that armed and observed nothing — two different facts this row must
|
|
688
|
+
// not conflate.
|
|
689
|
+
const result = await execution.result()
|
|
690
|
+
if (result.exitCode !== 0) {
|
|
691
|
+
const failure = `the mutation failed: exit ${result.exitCode ?? 'null'} ${(result.stderr?.text ?? '').slice(0, 200)}`.trim()
|
|
692
|
+
// Both facts survive: a watcher error and a failed mutation can coexist, and
|
|
693
|
+
// reporting only one of them drops the other from the row.
|
|
694
|
+
watchError = watchError === null ? failure : `${watchError}; ${failure}`
|
|
695
|
+
}
|
|
696
|
+
// The window is 5 polling periods: measured latency is 2-52ms, so 1500ms is
|
|
697
|
+
// ~29x the slowest measured case, and a poll that never runs still reddens.
|
|
698
|
+
await new Promise((resolve) => setTimeout(resolve, 5 * WATCH_INTERVAL_MS))
|
|
699
|
+
} catch (error) {
|
|
700
|
+
watchError = error instanceof Error ? error.message : String(error)
|
|
701
|
+
} finally {
|
|
702
|
+
controller.abort()
|
|
703
|
+
if (unwatch !== undefined) await unwatch().catch(() => {})
|
|
704
|
+
}
|
|
705
|
+
return { notified, watchError, linuxPath }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// The probe file EXISTS before the watch is armed — that is the point of the row.
|
|
709
|
+
// `fs.watchFile` on an ABSENT path notifies once immediately at arming (measured
|
|
710
|
+
// Node semantics, pinned offline in scripts/verify-watch.mjs, because an injected
|
|
711
|
+
// io can never fire by itself: only the default seam shows the baseline is real).
|
|
712
|
+
// With the file absent, `notified > 0` would be satisfied by that arming baseline
|
|
713
|
+
// alone — even if the in-distribution append below were never observed. An
|
|
714
|
+
// existing file is silent until it changes, so a notification here can only be
|
|
715
|
+
// the append.
|
|
716
|
+
const fileTarget = await fs.resolve('dsh-wsl-watch-probe.txt', { cwd: workspaceCwd })
|
|
717
|
+
// Created through the shell this block already runs its mutations with. A create
|
|
718
|
+
// that FAILED must not read as a false green, so its outcome is reported in the
|
|
719
|
+
// row: an absent path would notify from its arming baseline and pass on nothing.
|
|
720
|
+
const probeCreated = await runShellSpec(shell, shell.resolve({ command: `printf 'x\\n' > '${fs.processPath(fileTarget)}'`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
|
|
721
|
+
.then((execution) => execution.result())
|
|
722
|
+
.catch((error) => ({ exitCode: null, stderr: { text: String(error?.message ?? error) } }))
|
|
723
|
+
record('fs.watch.file', probeCreated.exitCode === 0
|
|
724
|
+
? await observeWatch(fileTarget, `printf 'x\\n' >> '${fs.processPath(fileTarget)}'`)
|
|
725
|
+
: {
|
|
726
|
+
notified: 0,
|
|
727
|
+
watchError: `the probe file could not be created: exit ${probeCreated.exitCode ?? 'null'} ${probeCreated.stderr?.text?.slice(0, 200) ?? ''}`.trim(),
|
|
728
|
+
linuxPath: fs.processPath(fileTarget),
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
const dirTarget = await fs.resolve('.', { cwd: workspaceCwd })
|
|
732
|
+
const dirCommand = `printf 'x\\n' > '${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt'`
|
|
733
|
+
record('fs.watch.dir', await observeWatch(dirTarget, dirCommand))
|
|
734
|
+
await runShellSpec(shell, shell.resolve({ command: `rm -f '${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt'`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
|
|
735
|
+
.then((execution) => execution.result()).catch(() => {})
|
|
736
|
+
|
|
737
|
+
// Why the override exists, recorded rather than assumed: if a future host or
|
|
738
|
+
// WSL release makes this work, this row says so and the override can go.
|
|
739
|
+
let nativeThrew = false
|
|
740
|
+
let nativeCode = null
|
|
741
|
+
try {
|
|
742
|
+
const probe = nativeFsWatch(fileTarget.targetKey, () => {})
|
|
743
|
+
probe.close()
|
|
744
|
+
} catch (error) {
|
|
745
|
+
nativeThrew = true
|
|
746
|
+
nativeCode = error?.code ?? null
|
|
747
|
+
}
|
|
748
|
+
record('fs.watch.native', { threw: nativeThrew, code: nativeCode, path: fileTarget.targetKey })
|
|
749
|
+
}
|
|
637
750
|
}
|
|
638
751
|
|
|
639
752
|
const subprocess = realmService('subprocess')
|
|
@@ -720,6 +833,88 @@ const METHODS = {
|
|
|
720
833
|
text: JSON.stringify(pwshTool.result ?? null).slice(0, 200),
|
|
721
834
|
})
|
|
722
835
|
|
|
836
|
+
// The realm's own `grep`/`glob`: the host removes both names from a WSL world
|
|
837
|
+
// (their argv[0] is a packaged Windows ripgrep) and this plugin mounts them back
|
|
838
|
+
// on the distribution's own grep/find. NO offline suite can drive them — the row
|
|
839
|
+
// module imports harness packages — so these two rows are the only observation of
|
|
840
|
+
// the chain end to end, and `scripts/verify-post-restart.mjs` reads them.
|
|
841
|
+
//
|
|
842
|
+
// The step names are `tool.search.*` (singular `tool`, as the plan spells them),
|
|
843
|
+
// NOT the `tools.*` the sibling rows use: the suite pairs with these exact strings,
|
|
844
|
+
// and both files are the only two places they appear.
|
|
845
|
+
//
|
|
846
|
+
// The fixture names, the token and the line number are spelled AGAIN in
|
|
847
|
+
// `scripts/verify-post-restart.mjs`, which asserts on their Linux paths — the same
|
|
848
|
+
// pairing `dsh-wsl-selftest.txt` already has between the two files. Three fixture
|
|
849
|
+
// facts, each one earning its place:
|
|
850
|
+
//
|
|
851
|
+
// - the probe carries the token on LINE 3, so a hit pins the line NUMBER rather
|
|
852
|
+
// than merely "some line of the right file";
|
|
853
|
+
// - `.git/x/hidden.ts` carries the same token and sits where the glob pattern
|
|
854
|
+
// reaches, so a search that stopped excluding the six VCS names prints an extra
|
|
855
|
+
// path (the shape Task 1's measurement removed) instead of passing quietly;
|
|
856
|
+
// - `src/a/b/deep.ts` is TWO directories below the pattern's own prefix, which is
|
|
857
|
+
// what pins "the pattern is anchored to the search root": an unanchored
|
|
858
|
+
// `src/*.ts` matches NOTHING (measured), so this file either appears or the
|
|
859
|
+
// anchoring broke.
|
|
860
|
+
//
|
|
861
|
+
// Written through the shell executor, not `fs`, and that is deliberate: the
|
|
862
|
+
// fixtures must exist in the world the search PROCESS runs in. A file written
|
|
863
|
+
// through the Windows-side share and a `grep` started through wsl.exe are only
|
|
864
|
+
// guaranteed to agree while the share serves the same running distribution —
|
|
865
|
+
// measured on this machine, after a host restart the share kept serving a stale
|
|
866
|
+
// view that the distribution could not see at all.
|
|
867
|
+
let searchFixtureError = null
|
|
868
|
+
const searchToken = 'dsh-wsl-search-needle'
|
|
869
|
+
if (shell === undefined || typeof workspaceCwd !== 'string' || !workspaceCwd.startsWith('\\\\wsl.localhost\\')) {
|
|
870
|
+
// A host (Windows) session has no distribution shell and no search tools; the
|
|
871
|
+
// rows still record, so the suite's WSL run can tell "no fixture" from "no row".
|
|
872
|
+
searchFixtureError = 'the WSL shell executor is unavailable, so the search fixtures were not created'
|
|
873
|
+
} else {
|
|
874
|
+
const searchFixtureCommand = [
|
|
875
|
+
'mkdir -p src/a/b src/.git/x',
|
|
876
|
+
`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`,
|
|
877
|
+
`printf '%s\\n' '${searchToken}' > src/.git/x/hidden.ts`,
|
|
878
|
+
`printf '%s\\n' 'the nested file a root-relative pattern must find' > src/a/b/deep.ts`,
|
|
879
|
+
].join(' && ')
|
|
880
|
+
// The policy is mandatory, not decorative: a `shell.resolve` with none gets the
|
|
881
|
+
// session-less default root, which does NOT contain this scratch workspace —
|
|
882
|
+
// measured, every write then failed EROFS. Same object the watch mutations and
|
|
883
|
+
// the fs roundtrip above stamp, for the same reason.
|
|
884
|
+
const searchFixturePolicy = { mode: 'workspace-write', workspaceRoot: workspaceCwd }
|
|
885
|
+
const searchFixture = await runShellSpec(shell, shell.resolve({
|
|
886
|
+
command: searchFixtureCommand,
|
|
887
|
+
workdir: workspaceCwd,
|
|
888
|
+
sandboxPolicy: searchFixturePolicy,
|
|
889
|
+
}))
|
|
890
|
+
.then((execution) => execution.result())
|
|
891
|
+
.catch((error) => ({ exitCode: null, stderr: { text: String(error?.message ?? error) } }))
|
|
892
|
+
if (searchFixture.exitCode !== 0) {
|
|
893
|
+
searchFixtureError = `the search fixtures could not be written: exit ${searchFixture.exitCode ?? 'null'} ${(searchFixture.stderr?.text ?? '').slice(0, 200)}`.trim()
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Both rows fold the fixture outcome into `ok`: a row that could not be given its
|
|
897
|
+
// fixture must not read as a passing observation of the tool.
|
|
898
|
+
//
|
|
899
|
+
// Both also record at 800 bytes, where their siblings use 400: this page carries
|
|
900
|
+
// the probe AND the nested path (each an absolute path under the scratch) plus the
|
|
901
|
+
// value those paths came from, and a cut that removed the `lineNumber` would red
|
|
902
|
+
// the assertion for a reason that is not the tool's. Measured on the real
|
|
903
|
+
// distribution output with a real scratch spelling: 309 (grep) and 356 (glob)
|
|
904
|
+
// bytes — 444 bytes of headroom, which is the point of the larger budget.
|
|
905
|
+
const grepSearch = await invoke('grep', { pattern: searchToken })
|
|
906
|
+
record('tool.search.grep', {
|
|
907
|
+
ok: grepSearch.ok && searchFixtureError === null,
|
|
908
|
+
error: grepSearch.error ?? searchFixtureError,
|
|
909
|
+
text: JSON.stringify(grepSearch.result ?? null).slice(0, 800),
|
|
910
|
+
})
|
|
911
|
+
const globSearch = await invoke('glob', { pattern: 'src/**/*.ts' })
|
|
912
|
+
record('tool.search.glob', {
|
|
913
|
+
ok: globSearch.ok && searchFixtureError === null,
|
|
914
|
+
error: globSearch.error ?? searchFixtureError,
|
|
915
|
+
text: JSON.stringify(globSearch.result ?? null).slice(0, 800),
|
|
916
|
+
})
|
|
917
|
+
|
|
723
918
|
// A tool resolves its policy from the session's logged permission state
|
|
724
919
|
// (`tool-bash/src/index.ts:199`), which a programmatically created
|
|
725
920
|
// session does not have. Recording one is what makes the tool layer's
|
package/lib/wsl/confinement.js
CHANGED
|
@@ -176,10 +176,11 @@ function helperGateScript(helperPath) {
|
|
|
176
176
|
'ok=yes',
|
|
177
177
|
// TYPE FIRST, and by an explicit test rather than by 'test -x': a root:root
|
|
178
178
|
// 0755 DIRECTORY satisfies -x, uid 0, no write bits and every ancestor test,
|
|
179
|
-
// so the ownership predicate answered yes for a directory — measured, as
|
|
180
|
-
//
|
|
181
|
-
// half refused it, and only because sudo cannot exec a
|
|
182
|
-
// claims to require an executable FILE; this is where it
|
|
179
|
+
// so the ownership predicate answered yes for a directory — measured, as an
|
|
180
|
+
// ordinary (non-root) user, with /opt/.../dir-helper (drwxr-xr-x root root).
|
|
181
|
+
// Only the version half refused it, and only because sudo cannot exec a
|
|
182
|
+
// directory. The gate claims to require an executable FILE; this is where it
|
|
183
|
+
// measures that.
|
|
183
184
|
'[ -f "$f" ] || ok=no',
|
|
184
185
|
// A symlink is REFUSED, not resolved. The grant names a PATH, so the link
|
|
185
186
|
// and its target are both part of the trust decision: resolving would have
|
package/lib/wsl/fs.js
CHANGED
|
@@ -26,6 +26,7 @@ import z from '@deepseek-ai/schemastery'
|
|
|
26
26
|
import { crossDistroUnc, isUnderHost, writableHostRootsFor } from './fence.js'
|
|
27
27
|
import { isWindowsPathShaped, joinWslUnc, mntToWindowsPath, parseWslUnc, windowsToMntPath } from './paths.js'
|
|
28
28
|
import { publishReplace } from './publish.js'
|
|
29
|
+
import { startWatch, watchStrategyFor } from './watch.js'
|
|
29
30
|
|
|
30
31
|
/** Share-level errors that mean "this primitive is unavailable", not "the operation failed". */
|
|
31
32
|
const PRIMITIVE_UNAVAILABLE = new Set(['ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'EINVAL'])
|
|
@@ -233,6 +234,33 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
233
234
|
throw new FsError(`cannot write "${target.displayPath}": file access denied under ${deniedUnder}`, 'FS_SANDBOX_DENIED')
|
|
234
235
|
}
|
|
235
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Observe one target through a strategy this share supports.
|
|
239
|
+
*
|
|
240
|
+
* Overridden because the inherited implementation cannot initialize here: it
|
|
241
|
+
* watches a DIRECTORY in both branches (the target, or a file target's `dirname`)
|
|
242
|
+
* and its primitive, `fs.watch`, throws `EISDIR` on `\\wsl.localhost\…` — for a
|
|
243
|
+
* file target as well as a directory one (measured; docs/WATCH.md). Unoverridden,
|
|
244
|
+
* `ctx.fs.watch` rejects for every WSL target and the workspace file tree keeps
|
|
245
|
+
* only manual refresh.
|
|
246
|
+
*
|
|
247
|
+
* The host path comes from the TARGET KEY, never from `processPath`: that returns
|
|
248
|
+
* the LINUX spelling for the model and for subprocesses, so resolving it on this
|
|
249
|
+
* side would put a watcher on `E:\home\…` for `/home/…` — observing something else
|
|
250
|
+
* entirely, and silently, since the wrong path usually does not exist.
|
|
251
|
+
* @param {{ targetKey: string }} target - the resolved target.
|
|
252
|
+
* @param {(error?: Error) => void} changed - invalidation callback.
|
|
253
|
+
* @param {AbortSignal} signal - cancels watcher initialization.
|
|
254
|
+
* @returns {Promise<() => Promise<void>>} the disposer once observation is active.
|
|
255
|
+
*/
|
|
256
|
+
async watch(target, changed, signal) {
|
|
257
|
+
signal.throwIfAborted()
|
|
258
|
+
const info = await this.stat(target, signal)
|
|
259
|
+
signal.throwIfAborted()
|
|
260
|
+
const kind = info?.type === 'directory' ? 'directory' : info === undefined ? 'absent' : 'file'
|
|
261
|
+
return startWatch({ hostPath: target.targetKey, strategy: watchStrategyFor(kind), changed, signal })
|
|
262
|
+
}
|
|
263
|
+
|
|
236
264
|
/**
|
|
237
265
|
* Fence the write by the per-call policy, then delegate to the inherited
|
|
238
266
|
* atomic write with the checked target.
|
package/lib/wsl/preset.js
CHANGED
|
@@ -202,12 +202,13 @@ export function hasClassifierVerdict(name) {
|
|
|
202
202
|
* @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
|
|
203
203
|
* @param {string} options.shellPath - module specifier of the WSL shell executor.
|
|
204
204
|
* @param {string} options.fsPath - module specifier of the WSL filesystem provider.
|
|
205
|
+
* @param {string} options.searchPath - module specifier of this plugin's WSL search tools.
|
|
205
206
|
* @param {string | undefined} options.distro - distribution for paths that do not name one.
|
|
206
207
|
* @param {boolean} options.includeEditor - whether the source preset mounted the string-replace editor.
|
|
207
208
|
* @param {Map<string, object>} [options.toolConfig] - config the pruned host-world rows carried, keyed by row id.
|
|
208
209
|
* @returns {object} the group entry.
|
|
209
210
|
*/
|
|
210
|
-
export function buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor, toolConfig }) {
|
|
211
|
+
export function buildWorldGroup({ subprocessPath, shellPath, fsPath, searchPath, distro, includeEditor, toolConfig }) {
|
|
211
212
|
// The pin belongs under `config`, NOT as a sibling row key. The loader hands a
|
|
212
213
|
// plugin ONLY `options.config` (`registry.plugin(plugin, this.options.config, …)`),
|
|
213
214
|
// and nothing relocates row keys into it — so as a bare `distro` key every
|
|
@@ -236,6 +237,19 @@ export function buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, inc
|
|
|
236
237
|
toolRow('tool-bash', '@deepseek-ai/dsh-tool-bash'),
|
|
237
238
|
toolRow('tool-fs', '@deepseek-ai/dsh-tool-fs'),
|
|
238
239
|
...(includeEditor ? [toolRow('str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor')] : []),
|
|
240
|
+
// Searching is re-mounted the same way bash and fs are: the two NAMES the model already
|
|
241
|
+
// knows, over a module of THIS plugin. It cannot be a carried host row — the host's
|
|
242
|
+
// search package is removed by WORLD_MODULE_NAMES, so naming it back would recreate the
|
|
243
|
+
// row the transform exists to drop (its argv[0] is a packaged Windows ripgrep binary).
|
|
244
|
+
//
|
|
245
|
+
// Both rows name ONE module, so the row's config is what tells that instance which tool
|
|
246
|
+
// to register: toolRow() is deliberately NOT used here — it would reapply the pruned
|
|
247
|
+
// host search row's config (a `sampleOverCapGlobResults`/caps shape this module does not
|
|
248
|
+
// read, and whose `tool` field would be absent), failing the row at mount time instead of
|
|
249
|
+
// mounting it. The tool name is also exactly `grep`/`glob`: callers invoke tools by name,
|
|
250
|
+
// and the row id is a separate thing that happens to spell the same.
|
|
251
|
+
{ id: 'grep', name: searchPath, config: { tool: 'grep' } },
|
|
252
|
+
{ id: 'glob', name: searchPath, config: { tool: 'glob' } },
|
|
239
253
|
]
|
|
240
254
|
return {
|
|
241
255
|
id: 'wsl-world',
|
|
@@ -306,11 +320,12 @@ function cloneRow(row) {
|
|
|
306
320
|
* @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
|
|
307
321
|
* @param {string} options.shellPath - module specifier of the WSL shell executor.
|
|
308
322
|
* @param {string} options.fsPath - module specifier of the WSL filesystem provider.
|
|
323
|
+
* @param {string} options.searchPath - module specifier of this plugin's WSL search tools.
|
|
309
324
|
* @param {string | undefined} options.distro - distribution for paths that do not name one.
|
|
310
325
|
* @param {string | undefined} options.sourceDir - directory the source preset lives in.
|
|
311
326
|
* @returns {{ plugins: object[], removed: string[], unclassified: { id: string | null, name: unknown }[] }} the variant rows, what was dropped, and the rows kept without a classifier verdict — an unreadable specifier, or a tool-family module that is neither removed nor verdicted-kept.
|
|
312
327
|
*/
|
|
313
|
-
export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fsPath, distro, sourceDir }) {
|
|
328
|
+
export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fsPath, searchPath, distro, sourceDir }) {
|
|
314
329
|
const kept = []
|
|
315
330
|
const removed = []
|
|
316
331
|
/** Rows kept without a classifier verdict — both shapes the prune() branches record. */
|
|
@@ -392,6 +407,6 @@ export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fs
|
|
|
392
407
|
const pruned = prune(row)
|
|
393
408
|
if (pruned !== null) kept.push(pruned)
|
|
394
409
|
}
|
|
395
|
-
kept.push(buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor, toolConfig }))
|
|
410
|
+
kept.push(buildWorldGroup({ subprocessPath, shellPath, fsPath, searchPath, distro, includeEditor, toolConfig }))
|
|
396
411
|
return { plugins: kept, removed, unclassified }
|
|
397
412
|
}
|