dsh-wsl-desktop 0.3.5 → 0.3.7

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
@@ -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 six documents this restructure created have an English edition (linked above; the file names carry an `.en` marker) and the pair is enforced 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.
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` all green + 18 offline suites; each item below notes its evidence strength):
23
+ **Honest state after live acceptance** (`verify-post-restart.mjs` all green + 19 offline suites; each item below notes its evidence strength):
24
24
 
25
25
  | # | Capability | Status |
26
26
  |---|---|---|
@@ -75,10 +75,12 @@ After every DSH Desktop update, the plugin may break because of harness internal
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 (18)
83
+ node scripts/verify-all.mjs # all offline suites (19)
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` 全绿 + 离线 18 套件;下面每条都注明证据强度):
21
+ **活体验收通过后的诚实状态**(`verify-post-restart.mjs` 全绿 + 离线 19 套件;下面每条都注明证据强度):
22
22
 
23
23
  | # | 能力 | 状态 |
24
24
  |---|---|---|
@@ -73,10 +73,12 @@
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 # 全部离线套件(18 个)
81
+ node scripts/verify-all.mjs # 全部离线套件(19 个)
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/client.js CHANGED
@@ -20,6 +20,9 @@
20
20
  window.__ModuleLoader__.load({
21
21
  id: 'dsh-wsl-desktop',
22
22
  factory() {
23
+ // MUST stay equal to the host half's Config.routePath default (lib/index.js).
24
+ // Nothing derives one from the other: a deployment that mounts the route
25
+ // elsewhere loses the picker silently.
23
26
  const ENDPOINT = '/wsl-desktop/api'
24
27
 
25
28
  /**
@@ -268,12 +271,10 @@ window.__ModuleLoader__.load({
268
271
  * workspace be created and a session opened after the operator cancelled.
269
272
  */
270
273
  function close() {
274
+ // The guard is the ONLY difference from forceClose(); the teardown itself is
275
+ // that function's, so the two cannot drift apart.
271
276
  if (state.closed || state.working) return
272
- state.closed = true
273
- state.token += 1
274
- document.removeEventListener('keydown', onKey)
275
- overlay.remove()
276
- teardown = null
277
+ forceClose()
277
278
  }
278
279
 
279
280
  /** Close the picker regardless of an in-flight commit, once it settles. */
package/lib/index.js CHANGED
@@ -5,14 +5,16 @@
5
5
  *
6
6
  * 1. Answer the browser's WSL questions over the same-origin route — the
7
7
  * browser cannot run `wsl.exe`.
8
- * 2. Materialize the WSL agent presets into the user preset root. A session's
9
- * execution world is chosen by its preset, so this is what lets one Desktop
10
- * instance run Windows workspaces and WSL workspaces side by side.
8
+ * 2. Derive and register the WSL agent presets in the runtime preset registry. A
9
+ * session's execution world is chosen by its preset, so this is what lets one
10
+ * Desktop instance run Windows workspaces and WSL workspaces side by side.
11
+ * (Before 0.1.7 this half MATERIALIZED them into the user preset root; the
12
+ * registry owns them now, and nothing under $DSH_HOME is written.)
11
13
  * @module dsh-wsl-desktop
12
14
  */
13
15
 
14
16
  import { randomBytes, timingSafeEqual } from 'node:crypto'
15
- import { existsSync } from 'node:fs'
17
+ import { existsSync, watch as nativeFsWatch } from 'node:fs'
16
18
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
17
19
  import { homedir } from 'node:os'
18
20
  import { dirname, join } from 'node:path'
@@ -25,6 +27,7 @@ import { buildVariantPlugins } from './wsl/preset.js'
25
27
  import { setLocalSubprocess } from './wsl/host-refs.js'
26
28
  import { checkLinuxPath, defaultDistro, defaultWorkspaceUnc, listDistros, listLinuxDir, resolveDistroHome, runWslShell } from './wsl/world.js'
27
29
  import { joinWslUnc, parseWslUnc, windowsToMntPath } from './wsl/paths.js'
30
+ import { WATCH_INTERVAL_MS } from './wsl/watch.js'
28
31
 
29
32
  /** Loader row identity, also used in diagnostics. */
30
33
  export const name = 'wsl-desktop'
@@ -44,6 +47,9 @@ export const Config = z.object({
44
47
  * generation can be mounted beside a running one, which is how the host half
45
48
  * is exercised without restarting the application.
46
49
  */
50
+ // MUST stay equal to the browser half's ENDPOINT (lib/client.js): nothing derives
51
+ // one from the other, and a deployment that mounts the route elsewhere loses the
52
+ // picker silently — the failure this default is the only record of.
47
53
  routePath: z.string().default('/wsl-desktop/api'),
48
54
  /**
49
55
  * Path to `wsl.exe`. Every probe this half runs spawns it — discovery, directory
@@ -78,6 +84,20 @@ const SELF_MODULE = fileURLToPath(import.meta.url)
78
84
  /** Prefix of every preset this plugin owns; its own outputs are never sources. */
79
85
  const GENERATED_PREFIX = 'wsl-'
80
86
 
87
+ /**
88
+ * The generated variant id for a resolved base preset.
89
+ *
90
+ * A preset that is already a variant keeps its id; anything else gets the prefix. The
91
+ * selftest and createWorkspace both derived it this way — the second site's own comment
92
+ * admitted it resolved exactly like the first — so a change to the prefix rule had two
93
+ * homes.
94
+ * @param {{ id: string }} preset - the resolved base preset.
95
+ * @returns {string} the variant id.
96
+ */
97
+ function variantIdFor(preset) {
98
+ return preset.id.startsWith(GENERATED_PREFIX) ? preset.id : `${GENERATED_PREFIX}${preset.id}`
99
+ }
100
+
81
101
  /**
82
102
  * Module specifiers of the WSL providers, as the preset rows must name them.
83
103
  * The 0.1.7 loader imports row names via `new URL(name, baseUrl)` or a bare
@@ -203,6 +223,24 @@ function basePluginsOf(presets, id) {
203
223
  * refuses duplicates).
204
224
  * @returns {Promise<object>} the registration outcome for the route.
205
225
  */
226
+ /**
227
+ * Run a shell spec through whichever seam the host exposes, and always hand back a
228
+ * handle whose `result()` is awaitable.
229
+ *
230
+ * 0.1.7 hosts expose `execute`, which resolves with a live handle; older ones have
231
+ * only `run`, whose settled result carries no `result()` projection. Both selftest
232
+ * steps used to spell that dual dispatch themselves, and the second one called
233
+ * `.result()` on `run`'s settled result — a TypeError on exactly the hosts that
234
+ * branch exists to support. One owner, so the two steps cannot drift again.
235
+ * @param {object} shell - the realm's shell service.
236
+ * @param {object} spec - the resolved spec to run.
237
+ * @returns {Promise<{ result: () => Promise<object> }>} a result()-able handle.
238
+ */
239
+ function runShellSpec(shell, spec) {
240
+ if (typeof shell.execute === 'function') return shell.execute(spec)
241
+ return shell.run(spec).then((settled) => ({ result: async () => settled }))
242
+ }
243
+
206
244
  async function materializePresets(ctx, distro, generation, previous) {
207
245
  const presets = ctx.get('agentPresets')
208
246
  if (presets === undefined) throw new Error('agentPresets 服务不可用')
@@ -213,9 +251,16 @@ async function materializePresets(ctx, distro, generation, previous) {
213
251
  if (hostCompat.registry07 !== true) {
214
252
  throw new Error('需要 DSH Desktop 0.1.7+:当前宿主的 agentPresets 缺少 readDocument(0.1.6 及以下不受支持)。请升级桌面,或使用 0.1.6 兼容的插件版本。')
215
253
  }
216
- if (generation?.disposed) return { status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] }
254
+ /**
255
+ * The answer a round gives when its generation was torn down mid-flight: the preset
256
+ * surface stays READY (the previous generation is still registered), nothing is
257
+ * written, and the skip says why. Spelled once — it stood three times, so the
258
+ * wording and the shape each had three homes.
259
+ */
260
+ const abandonedRound = () => ({ status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] })
261
+ if (generation?.disposed) return abandonedRound()
217
262
  const roster = await presets.list()
218
- if (generation?.disposed) return { status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] }
263
+ if (generation?.disposed) return abandonedRound()
219
264
  const written = []
220
265
  const skipped = []
221
266
 
@@ -223,7 +268,7 @@ async function materializePresets(ctx, distro, generation, previous) {
223
268
  for (const [id, dispose] of previous?.disposers ?? []) {
224
269
  try { await dispose() } catch { /* already gone */ }
225
270
  previous?.disposers.delete(id)
226
- if (generation?.disposed) return { status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] }
271
+ if (generation?.disposed) return abandonedRound()
227
272
  }
228
273
 
229
274
  for (const preset of roster) {
@@ -410,9 +455,7 @@ const METHODS = {
410
455
  const presets = hostCtx?.get('agentPresets')
411
456
  if (presets === undefined) throw new Error('agent presets 尚不可用')
412
457
  const resolved = await presets.resolve(base)
413
- const agentPreset = resolved.id.startsWith(GENERATED_PREFIX)
414
- ? resolved.id
415
- : `${GENERATED_PREFIX}${resolved.id}`
458
+ const agentPreset = variantIdFor(resolved)
416
459
  const roster = await presets.list()
417
460
  const variant = roster.find((preset) => preset.id === agentPreset)
418
461
  if (variant === undefined) throw new Error(`WSL 预设 ${agentPreset} 尚未生成`)
@@ -550,8 +593,9 @@ const METHODS = {
550
593
  // failure would abort the whole selftest.
551
594
  const spec = shell.resolve({ command: 'uname -s; pwd; id -un; echo "$WSL_DISTRO_NAME"', workdir: workspaceCwd })
552
595
  // 0.1.7: execute resolves with a live handle; the settled result is
553
- // its memoized result() projection. Dual-dispatch for older hosts.
554
- const execution = await (typeof shell.execute === 'function' ? shell.execute(spec) : (async () => { const r = await shell.run(spec); return { result: async () => r } })())
596
+ // its memoized result() projection. Dual-dispatch for older hosts — one
597
+ // owner, runShellSpec, shared with the confined step below.
598
+ const execution = await runShellSpec(shell, spec)
555
599
  const result = await execution.result()
556
600
  record('shell.run', {
557
601
  exitCode: result?.exitCode,
@@ -591,6 +635,108 @@ const METHODS = {
591
635
  } catch (error) {
592
636
  record('fs.roundtrip', { error: error instanceof Error ? error.message : String(error) })
593
637
  }
638
+
639
+ // Watch rows: the strategy the provider installs, driven the way a consumer
640
+ // drives it, and mutated from INSIDE the distribution — the case the rows are
641
+ // about. No model-facing tool reaches `fs.watch`, so the provider's own entry
642
+ // is the only place this can be observed.
643
+ if (fs !== undefined && shell !== undefined && typeof workspaceCwd === 'string' && workspaceCwd.startsWith('\\\\wsl.localhost\\')) {
644
+ /**
645
+ * The policy every mutation below carries. A request that omits `sandboxPolicy`
646
+ * makes `bash-sandbox`'s `resolve()` substitute the session-less default, and the
647
+ * confinement then binds a root that does NOT contain this scratch workspace —
648
+ * measured: every mutation failed EROFS ("Read-only file system"). The fallback is
649
+ * not a MISSING root: `lib/wsl/confinement.js` binds the command's cwd when a policy
650
+ * carries none. It is a root from the deployment's own world, which
651
+ * `lib/wsl/paths.js` maps to `/mnt/<drive>/…`. The tool layer never meets this
652
+ * because `tool-bash` stamps the resolved policy into its request; a direct
653
+ * provider call has to stamp it itself.
654
+ */
655
+ const workspaceWritePolicy = { mode: 'workspace-write', workspaceRoot: workspaceCwd }
656
+
657
+ /**
658
+ * Arm one watch, mutate the target from the distribution, report what arrived.
659
+ * @param {{ targetKey: string }} target - the resolved watch target.
660
+ * @param {string} command - the shell command that changes it.
661
+ * @returns {Promise<object>} the observation row.
662
+ */
663
+ const observeWatch = async (target, command) => {
664
+ const controller = new AbortController()
665
+ const linuxPath = fs.processPath(target)
666
+ let notified = 0
667
+ let watchError = null
668
+ let unwatch
669
+ try {
670
+ unwatch = await fs.watch(target, (error) => {
671
+ if (error === undefined) notified += 1
672
+ else watchError = String(error?.message ?? error)
673
+ }, controller.signal)
674
+ const execution = await runShellSpec(shell, shell.resolve({ command, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
675
+ // The mutation's outcome is REPORTED, not assumed. A refused write otherwise
676
+ // leaves `notified: 0` with a null `watchError`, which reads exactly like a
677
+ // watcher that armed and observed nothing — two different facts this row must
678
+ // not conflate.
679
+ const result = await execution.result()
680
+ if (result.exitCode !== 0) {
681
+ const failure = `the mutation failed: exit ${result.exitCode ?? 'null'} ${(result.stderr?.text ?? '').slice(0, 200)}`.trim()
682
+ // Both facts survive: a watcher error and a failed mutation can coexist, and
683
+ // reporting only one of them drops the other from the row.
684
+ watchError = watchError === null ? failure : `${watchError}; ${failure}`
685
+ }
686
+ // The window is 5 polling periods: measured latency is 2-52ms, so 1500ms is
687
+ // ~29x the slowest measured case, and a poll that never runs still reddens.
688
+ await new Promise((resolve) => setTimeout(resolve, 5 * WATCH_INTERVAL_MS))
689
+ } catch (error) {
690
+ watchError = error instanceof Error ? error.message : String(error)
691
+ } finally {
692
+ controller.abort()
693
+ if (unwatch !== undefined) await unwatch().catch(() => {})
694
+ }
695
+ return { notified, watchError, linuxPath }
696
+ }
697
+
698
+ // The probe file EXISTS before the watch is armed — that is the point of the row.
699
+ // `fs.watchFile` on an ABSENT path notifies once immediately at arming (measured
700
+ // Node semantics, pinned offline in scripts/verify-watch.mjs, because an injected
701
+ // io can never fire by itself: only the default seam shows the baseline is real).
702
+ // With the file absent, `notified > 0` would be satisfied by that arming baseline
703
+ // alone — even if the in-distribution append below were never observed. An
704
+ // existing file is silent until it changes, so a notification here can only be
705
+ // the append.
706
+ const fileTarget = await fs.resolve('dsh-wsl-watch-probe.txt', { cwd: workspaceCwd })
707
+ // Created through the shell this block already runs its mutations with. A create
708
+ // that FAILED must not read as a false green, so its outcome is reported in the
709
+ // row: an absent path would notify from its arming baseline and pass on nothing.
710
+ const probeCreated = await runShellSpec(shell, shell.resolve({ command: `printf 'x\\n' > '${fs.processPath(fileTarget)}'`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
711
+ .then((execution) => execution.result())
712
+ .catch((error) => ({ exitCode: null, stderr: { text: String(error?.message ?? error) } }))
713
+ record('fs.watch.file', probeCreated.exitCode === 0
714
+ ? await observeWatch(fileTarget, `printf 'x\\n' >> '${fs.processPath(fileTarget)}'`)
715
+ : {
716
+ notified: 0,
717
+ watchError: `the probe file could not be created: exit ${probeCreated.exitCode ?? 'null'} ${probeCreated.stderr?.text?.slice(0, 200) ?? ''}`.trim(),
718
+ linuxPath: fs.processPath(fileTarget),
719
+ })
720
+
721
+ const dirTarget = await fs.resolve('.', { cwd: workspaceCwd })
722
+ const dirCommand = `printf 'x\\n' > '${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt'`
723
+ record('fs.watch.dir', await observeWatch(dirTarget, dirCommand))
724
+ await runShellSpec(shell, shell.resolve({ command: `rm -f '${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt'`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
725
+ .then((execution) => execution.result()).catch(() => {})
726
+
727
+ // Why the override exists, recorded rather than assumed: if a future host or
728
+ // WSL release makes this work, this row says so and the override can go.
729
+ let nativeThrew = false
730
+ let nativeCode = null
731
+ try {
732
+ const probe = nativeFsWatch(fileTarget.targetKey, () => {})
733
+ probe.close()
734
+ } catch (error) {
735
+ nativeThrew = true
736
+ nativeCode = error?.code ?? null
737
+ }
738
+ record('fs.watch.native', { threw: nativeThrew, code: nativeCode, path: fileTarget.targetKey })
739
+ }
594
740
  }
595
741
 
596
742
  const subprocess = realmService('subprocess')
@@ -609,8 +755,8 @@ const METHODS = {
609
755
  }
610
756
 
611
757
  if (shell !== undefined) {
612
- const exec = typeof shell.execute === 'function' ? shell.execute.bind(shell) : shell.run.bind(shell)
613
- const execution = await exec(
758
+ const execution = await runShellSpec(
759
+ shell,
614
760
  shell.resolve({ command: 'echo blocked > /dsh-wsl-forbidden.txt', workdir: workspaceCwd }),
615
761
  ).catch((error) => ({ result: async () => ({ exitCode: null, stderr: { text: String(error?.message ?? error) }, sandbox: null }) }))
616
762
  const confined = await execution.result()
@@ -816,9 +962,7 @@ const METHODS = {
816
962
  const presets = hostCtx.get('agentPresets')
817
963
  if (presets !== undefined) {
818
964
  const resolved = await presets.resolve()
819
- const candidate = resolved.id.startsWith(GENERATED_PREFIX)
820
- ? resolved.id
821
- : `${GENERATED_PREFIX}${resolved.id}`
965
+ const candidate = variantIdFor(resolved)
822
966
  const roster = await presets.list()
823
967
  if (roster.some((preset) => preset.id === candidate && preset.broken === undefined)) presetId = candidate
824
968
  }
@@ -961,7 +1105,7 @@ async function isDeveloperCaller(req, config) {
961
1105
  /**
962
1106
  * Register the WSL route and generate the WSL presets.
963
1107
  * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
964
- * @param {{ routePath: string, developerTools: boolean }} config - resolved plugin config.
1108
+ * @param {{ routePath: string, developerTools: boolean, wslPath?: string }} config - resolved plugin config.
965
1109
  */
966
1110
  export function apply(ctx, config) {
967
1111
  hostCtx = ctx
@@ -20,11 +20,6 @@
20
20
  */
21
21
 
22
22
  import { shellQuote } from './paths.js'
23
- import { fileURLToPath } from 'node:url'
24
- import { dirname, join } from 'node:path'
25
-
26
- /** Shipped helper script, installed by the operator (see docs/CONFINEMENT.md). */
27
- export const HELPER_SOURCE = join(dirname(fileURLToPath(import.meta.url)), 'dsh-wsl-confine.sh')
28
23
 
29
24
  /** The direct runner: sudo-wrapped unshare with the fence built in-process. */
30
25
  export const RUNNER_SUDO_UNSHARE = 'sudo-unshare'
@@ -181,10 +176,11 @@ function helperGateScript(helperPath) {
181
176
  'ok=yes',
182
177
  // TYPE FIRST, and by an explicit test rather than by 'test -x': a root:root
183
178
  // 0755 DIRECTORY satisfies -x, uid 0, no write bits and every ancestor test,
184
- // so the ownership predicate answered yes for a directory — measured, as
185
- // zcluo, with /opt/.../dir-helper (drwxr-xr-x root root). Only the version
186
- // half refused it, and only because sudo cannot exec a directory. The gate
187
- // claims to require an executable FILE; this is where it measures that.
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.
188
184
  '[ -f "$f" ] || ok=no',
189
185
  // A symlink is REFUSED, not resolved. The grant names a PATH, so the link
190
186
  // and its target are both part of the trust decision: resolving would have
@@ -207,9 +203,9 @@ function helperGateScript(helperPath) {
207
203
  'u=$(stat -c %u "$f" 2>/dev/null) || u=',
208
204
  'm=$(stat -c %a "$f" 2>/dev/null) || m=',
209
205
  '[ "$u" = 0 ] || ok=no',
210
- '[ -n "$m" ] || ok=no',
211
206
  // A mode stat cannot report (or one that is not octal) must refuse, not
212
- // error out silently: the arithmetic parse is guarded by the non-empty test.
207
+ // error out silently: the arithmetic parse is guarded by its own non-empty
208
+ // test, which is why no separate [ -n "$m" ] refusal stands above it.
213
209
  '[ -n "$m" ] && (( (8#$m & 022) == 0 )) || ok=no',
214
210
  '[ -w "$f" ] && ok=no',
215
211
  'd=$(dirname "$f")',
@@ -317,7 +313,9 @@ export async function detectRunner({ distro, username, run }) {
317
313
  }
318
314
 
319
315
  /**
320
- * Forget cached probe results. Used by tests and by a settings change.
316
+ * Forget cached probe results. Used by the verification suites — no settings change
317
+ * calls it (the doc claimed one did; the only caller in the tree is
318
+ * scripts/verify-confinement.mjs).
321
319
  */
322
320
  export function resetConfinementCache() {
323
321
  identityCache.clear()
@@ -421,7 +419,7 @@ export async function detectNoNewPrivs({ distro, username, run }) {
421
419
  * unconditionally (dsh-wsl-confine.sh), so on a setpriv without the flag setpriv
422
420
  * rejects the unknown option and exits 1 — the confined command simply does not
423
421
  * run, and that is an ordinary COMMAND failure, not the setup-failure marker
424
- * that reports an unavailable runner. docs/CONFINEMENT.md:33 records the measured exit 1;
422
+ * that reports an unavailable runner. docs/CONFINEMENT.md:31 records the measured exit 1;
425
423
  * this text previously contradicted it by recommending the helper as a remedy.
426
424
  *
427
425
  * The remedy names the PROBEABLE requirement, not a version: the util-linux release
@@ -668,7 +666,10 @@ function postConditions(keep) {
668
666
  * @param {string} options.linuxCwd - absolute Linux working directory.
669
667
  * @param {'read-only' | 'workspace-write'} options.mode - the confined mode.
670
668
  * @param {string | undefined} options.workspaceLinuxRoot - writable root for `workspace-write`.
671
- * @param {{ uid: string, gid: string }} options.identity - identity to drop back to.
669
+ * @param {{ uid: string, gid: string, home: string, name?: string }} options.identity -
670
+ * identity to drop back to; `home` and `name` are read for the environment.
671
+ * @param {boolean | undefined} options.noNewPrivs - whether the runner supports
672
+ * `setpriv --no-new-privs`; adds the flag to the drop-back when true.
672
673
  * @returns {string} the script to run inside the namespace.
673
674
  */
674
675
  export function buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs }) {
@@ -726,7 +727,8 @@ export function buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRo
726
727
  * @param {string} options.linuxCwd - absolute Linux working directory.
727
728
  * @param {'read-only' | 'workspace-write'} options.mode - the confined mode.
728
729
  * @param {string | undefined} options.workspaceLinuxRoot - writable root for `workspace-write`.
729
- * @param {{ uid: string, gid: string }} options.identity - identity to drop back to.
730
+ * @param {{ uid: string, gid: string, home: string, name?: string }} options.identity -
731
+ * identity to drop back to; `home` and `name` are read for the environment.
730
732
  * @param {boolean} [options.noNewPrivs] - set NO_NEW_PRIVS on the drop when the
731
733
  * distro's setpriv supports it (blocks setuid elevation inside the fence).
732
734
  * @param {string} [options.runner] - the resolved runner: 'helper' routes the
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'])
@@ -83,11 +84,24 @@ export function toHostPath(value, cwd, fallbackDistro) {
83
84
  * @returns {string} the Linux spelling when one exists, else the input.
84
85
  */
85
86
  export function toLinuxPath(value) {
87
+ return toLinuxPathOrNull(value) ?? value
88
+ }
89
+
90
+ /**
91
+ * The Linux spelling of a host path, or undefined when one does not exist.
92
+ *
93
+ * The ONE place the UNC/drive translation is written: `toLinuxPath` and the fs
94
+ * backend's `processPathFromHostPath` differ only in the sentinel they answer for an
95
+ * unrepresentable path (the input vs undefined), and spelling the translation twice
96
+ * meant a fix could land in one copy only.
97
+ * @param {string} value - a host path on the share or a drive path.
98
+ * @returns {string | undefined} the normalized Linux path, or undefined.
99
+ */
100
+ function toLinuxPathOrNull(value) {
86
101
  const unc = parseWslUnc(value)
87
102
  if (unc !== null) return posix.normalize(unc.linuxPath)
88
103
  const drive = windowsToMntPath(value)
89
- if (drive !== null) return posix.normalize(drive)
90
- return value
104
+ return drive === null ? undefined : posix.normalize(drive)
91
105
  }
92
106
 
93
107
  /**
@@ -220,6 +234,33 @@ export class WslFileSystem extends LocalFileSystem {
220
234
  throw new FsError(`cannot write "${target.displayPath}": file access denied under ${deniedUnder}`, 'FS_SANDBOX_DENIED')
221
235
  }
222
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
+
223
264
  /**
224
265
  * Fence the write by the per-call policy, then delegate to the inherited
225
266
  * atomic write with the checked target.
@@ -288,10 +329,7 @@ export class WslFileSystem extends LocalFileSystem {
288
329
  */
289
330
  processPathFromHostPath(hostPath) {
290
331
  if (typeof hostPath !== 'string' || hostPath.length === 0) return undefined
291
- const unc = parseWslUnc(hostPath)
292
- if (unc !== null) return posix.normalize(unc.linuxPath)
293
- const drive = windowsToMntPath(hostPath)
294
- return drive === null ? undefined : posix.normalize(drive)
332
+ return toLinuxPathOrNull(hostPath)
295
333
  }
296
334
 
297
335
  /**
package/lib/wsl/preset.js CHANGED
@@ -259,18 +259,20 @@ export const WSL_PERSONA_SENTENCE = 'Paths under \\\\wsl.localhost\\<distro> are
259
259
  /**
260
260
  * Append the WSL path-dialect sentence to a persona row OBJECT.
261
261
  * @param {object} row - the persona entry.
262
- * @returns {boolean} whether the row was amended.
262
+ *
263
+ * The row is ALWAYS amended — a string field is extended when one exists and `suffix`
264
+ * is created when none does — so this returns nothing. It used to document and return
265
+ * "whether the row was amended" (always true, and its only caller discards it).
263
266
  */
264
267
  function appendPersonaSuffixObject(row) {
265
268
  const config = row.config ?? (row.config = {})
266
269
  for (const field of ['suffix', 'text', 'prefix']) {
267
270
  if (typeof config[field] === 'string' && config[field].length > 0) {
268
271
  config[field] = `${config[field]} ${WSL_PERSONA_SENTENCE}`
269
- return true
272
+ return
270
273
  }
271
274
  }
272
275
  config.suffix = WSL_PERSONA_SENTENCE
273
- return true
274
276
  }
275
277
 
276
278
  /**
@@ -344,8 +346,11 @@ export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fs
344
346
  unclassified.push({ id: row.id ?? null, name: row.name ?? null })
345
347
  } else if (isHostWorldModule(row.name)) {
346
348
  removed.push(row.id ?? row.name)
347
- if (row.name.includes('str-replace-editor')) includeEditor = true
348
- if (row.name.includes('str-replace-editor') && row.config !== undefined) {
349
+ // One evaluation, two uses: the predicate stood on adjacent lines, so a future
350
+ // edit to one of them would silently leave the other behind.
351
+ const isEditor = row.name.includes('str-replace-editor')
352
+ if (isEditor) includeEditor = true
353
+ if (isEditor && row.config !== undefined) {
349
354
  toolConfig.set('str-replace-editor', cloneRow({ config: row.config }).config)
350
355
  }
351
356
  return null
package/lib/wsl/pty.js CHANGED
@@ -318,7 +318,22 @@ export async function spawnWslTerminal({
318
318
  }
319
319
 
320
320
  const stderr = data.stderr
321
- if (stderr === undefined) throw new Error('wsl-pty: 数据进程没有 stderr,无法承载控制应答')
321
+ if (stderr === undefined) {
322
+ // This guard throws BEFORE the allocation try below, so it used to escape with
323
+ // the data process still running — the very leak that try's catch exists to
324
+ // prevent. Same cleanup, minus `control`: it is not spawned yet at this point,
325
+ // and `control` is not even in scope here.
326
+ try {
327
+ data.terminate()
328
+ } catch {
329
+ // Already gone.
330
+ }
331
+ await Promise.allSettled([waitForHandle(data)])
332
+ // The bridge may already have created its FIFO before this check; rm -f is a
333
+ // no-op when it did not.
334
+ await cleanupFifo()
335
+ throw new Error('wsl-pty: 数据进程没有 stderr,无法承载控制应答')
336
+ }
322
337
  stderr.setEncoding('utf8')
323
338
  stderr.on('data', (chunk) => {
324
339
  replyBuffer += chunk
@@ -481,7 +496,7 @@ export async function spawnWslTerminal({
481
496
  control.terminate()
482
497
  data.terminate()
483
498
  failPending('wsl-pty: 会话已终止')
484
- await Promise.allSettled([data.waitForExit?.() ?? waitForHandle(data), waitForHandle(control)])
499
+ await Promise.allSettled([waitForHandle(data), waitForHandle(control)])
485
500
  // AFTER both processes have settled, from the host side: the bridge's
486
501
  // own finally lost this race often enough to leave orphans behind.
487
502
  await cleanupFifo()
package/lib/wsl/shell.js CHANGED
@@ -428,10 +428,13 @@ export class WslShellExecutor extends ShellExecutor {
428
428
  }
429
429
  }
430
430
  const emptyReader = { readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }) }
431
- /** The failure note the READ PATH must serve; filled in when a spawn fails. */
432
- const spawnFailure = {
433
- note: spawnError === undefined ? undefined : `subprocess failed before reporting an outcome: ${String(spawnError)}`,
434
- }
431
+ /**
432
+ * The failure note the READ PATH must serve; assigned by settle() when a spawn
433
+ * fails. The holder starts bare on purpose: servesNote stays false until settle()
434
+ * has written note, so an initializer here could never reach a reader — the
435
+ * ternary that stood here built a string no branch could observe.
436
+ */
437
+ const spawnFailure = { note: undefined }
435
438
  const providerReaders = handle === undefined ? undefined : WslShellExecutor.readers(handle)
436
439
  const stdout = providerReaders?.stdout ?? emptyReader
437
440
  const baseStderr = providerReaders?.stderr ?? emptyReader
@@ -34,7 +34,7 @@ import { LoginShellUnresolvedError, WSL_CHILD_ENV, buildWslExecArgv, planWsl, re
34
34
  /**
35
35
  * The executable lookup's ceiling, on the documented probe policy.
36
36
  *
37
- * Was 15s and the shortest ceiling in the plugin. docs/CONFINEMENT.md:36 names
37
+ * Was 15s and the shortest ceiling in the plugin. docs/CONFINEMENT.md:34 names
38
38
  * `resolveExecutable` in the same sentence as 探针超时 60s + 超时后一次透明重试, and it is an
39
39
  * output-parsed probe like the siblings that sentence lists (the comment on the probe below
40
40
  * says so), so it gets the same 60s as `listLinuxDir`/`checkLinuxPath`/`resolveDistroHome`
@@ -46,6 +46,20 @@ const LOOKUP_TIMEOUT_MS = 60_000
46
46
  /** Windows shells whose names the terminal controller may probe inside the distribution. */
47
47
  const WINDOWS_SHELLS = new Set(['powershell', 'pwsh', 'cmd'])
48
48
 
49
+ /**
50
+ * The basename a Windows-shell test compares: directories stripped, lowercased, a
51
+ * trailing `.exe` removed.
52
+ *
53
+ * ONE owner: the candidate lookup and the terminal-argv translation each spelled this
54
+ * pipeline, so a fix to either (a Windows path separator, a new extension) could land in
55
+ * one copy only. `value ?? ''` because one caller passes an optional argv element.
56
+ * @param {unknown} value - a program spelling.
57
+ * @returns {string} the comparable basename, or '' when there is nothing to compare.
58
+ */
59
+ function shellBaseName(value) {
60
+ return String(value ?? '').split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? ''
61
+ }
62
+
49
63
  /** The PTY bridge shipped beside this module. */
50
64
  const BRIDGE_PATH = join(dirname(fileURLToPath(import.meta.url)), 'terminal-bridge.py')
51
65
 
@@ -167,7 +181,7 @@ export class WslSubprocessRuntime extends SubprocessRuntime {
167
181
  // shell (PowerShell on a Windows deployment) through THIS provider; in
168
182
  // the distribution that dialect-translates to the session user's login
169
183
  // shell. Absent any Windows shell, the login shell is the honest answer.
170
- const base = String(command).split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? ''
184
+ const base = shellBaseName(command)
171
185
  if (WINDOWS_SHELLS.has(base)) {
172
186
  try {
173
187
  return await resolveLoginShell(plan.distro, this.config.username, { wslPath: this.config.wslPath })
@@ -261,7 +275,7 @@ export class WslSubprocessRuntime extends SubprocessRuntime {
261
275
  // (PowerShell on Windows) through THIS provider; in the distribution
262
276
  // that dialect-translates to the session user's login shell — a lone
263
277
  // '-NoLogo' arg would kill bash, so the whole argv is translated.
264
- const base0 = String(spec.argv?.[0] ?? '').split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? ''
278
+ const base0 = shellBaseName(spec.argv?.[0])
265
279
  // Deliberately NOT translated into SubprocessExecutableNotFoundError the
266
280
  // way the candidate lookup above is. There is no candidate list here to
267
281
  // skip down — this spec named exactly ONE program — so the translation
@@ -200,7 +200,12 @@ class Bridge:
200
200
  def activity(self):
201
201
  """Report busy when the foreground group is not the shell itself."""
202
202
  pgrp = self.foreground_pgrp()
203
- state = "unknown" if pgrp is None else ("idle" if pgrp == self.shell_pgrp else "busy")
203
+ if pgrp is None:
204
+ state = "unknown"
205
+ elif pgrp == self.shell_pgrp:
206
+ state = "idle"
207
+ else:
208
+ state = "busy"
204
209
  if state != self.last_state:
205
210
  self.last_state = state
206
211
  self.revision += 1
@@ -0,0 +1,212 @@
1
+ /**
2
+ * File watching for the distribution's 9P share.
3
+ *
4
+ * The share does not support the primitive the host backend is built on: `fs.watch`
5
+ * throws `EISDIR` on `\\wsl.localhost\…` for a DIRECTORY target and for a FILE target
6
+ * alike (measured; docs/WATCH.md). `LocalFileSystem.watch` watches a directory in
7
+ * both branches — the target, or a file target's `dirname` — so it cannot initialize
8
+ * here at all, and `ctx.fs.watch` rejects for every WSL target: the workspace's file
9
+ * tree loses its live refresh and keeps manual refresh only.
10
+ *
11
+ * This module is the strategy that does work over the share: user-space polling on
12
+ * `node:fs` alone. (chokidar is not importable: it is a transitive dependency of the
13
+ * host's fs-local, and this plugin declares no dependencies.)
14
+ *
15
+ * - `dir-entries` — one readdir per tick, comparing the entry-name set. Measured ~0ms
16
+ * CPU per 3s and 2-3ms latency at 200 entries. Blind to a child's CONTENT change,
17
+ * which is not what a consumer of a directory target asks for.
18
+ * - `path-stat` — `fs.watchFile` on one path. Measured ~0ms CPU per 3s and 52ms. Also
19
+ * covers a file target and an absent path observed for creation.
20
+ * @module dsh-wsl-desktop/wsl/watch
21
+ */
22
+
23
+ // `readdir` comes from the PROMISE surface, because the io seam calls it promise-style
24
+ // (`await io.readdir(path)`) and the injected fake is async: the callback-style
25
+ // `fs.readdir` throws ERR_INVALID_ARG_TYPE the moment it is handed no callback
26
+ // (measured), so the same call that passes the suite would refuse every WSL target.
27
+ // The stat poller's two calls are the callback surface's own and stay on `node:fs`.
28
+ import { unwatchFile as nodeUnwatchFile, watchFile as nodeWatchFile } from 'node:fs'
29
+ import { readdir as nodeReaddir } from 'node:fs/promises'
30
+
31
+ /**
32
+ * The polling period of both strategies.
33
+ *
34
+ * A constant, not a config field: both strategies measured ~0ms CPU per 3s at 200
35
+ * entries, so there is no lever to hand an operator. If a deployment ever measures
36
+ * cost, this is the single place it would be turned.
37
+ */
38
+ export const WATCH_INTERVAL_MS = 300
39
+
40
+ /**
41
+ * The strategy for a target.
42
+ * @param {'directory' | 'file' | 'absent'} kind - what the target is right now.
43
+ * @returns {'dir-entries' | 'path-stat'} the strategy name.
44
+ */
45
+ export function watchStrategyFor(kind) {
46
+ return kind === 'directory' ? 'dir-entries' : 'path-stat'
47
+ }
48
+
49
+ /**
50
+ * The comparison key of one directory reading.
51
+ * @param {readonly string[]} names - entry names, in any order.
52
+ * @returns {string} the key: sorted and NUL-joined.
53
+ */
54
+ export function entrySetKey(names) {
55
+ return [...names].sort().join('\0')
56
+ }
57
+
58
+ /**
59
+ * Whether a directory reading differs from the previous one.
60
+ * @param {string | undefined} previousKey - the previous key; undefined before the first reading.
61
+ * @param {string} nextKey - this reading's key.
62
+ * @returns {boolean} true only when a baseline exists AND differs — the first reading
63
+ * establishes the baseline silently, mirroring fs-local's `ignoreInitial: true`.
64
+ * Without it every watch would report a change the moment it opens.
65
+ */
66
+ export function entrySetChanged(previousKey, nextKey) {
67
+ return previousKey !== undefined && previousKey !== nextKey
68
+ }
69
+
70
+ /**
71
+ * The io and timer surface this module uses.
72
+ *
73
+ * NOT all of it is `node:fs`: `readdir` is the `node:fs/promises` surface, for the reason
74
+ * the import note above records, so "the default is node:fs" would name the wrong module
75
+ * for one of the three calls.
76
+ *
77
+ * Injectable so the suite drives ticks and IO by hand: with the real timers a check
78
+ * would have to sleep and race the poll it is measuring.
79
+ */
80
+ const NODE_IO = {
81
+ readdir: nodeReaddir,
82
+ watchFile: nodeWatchFile,
83
+ unwatchFile: nodeUnwatchFile,
84
+ setInterval: globalThis.setInterval,
85
+ clearInterval: globalThis.clearInterval,
86
+ }
87
+
88
+ /**
89
+ * Read one directory reading as a key, answering an absent directory with the empty set.
90
+ *
91
+ * The contract observes a path for CREATION as well as for change, so a directory that
92
+ * is not there is an empty entry set rather than a failure — its deletion and its
93
+ * reappearance are both changes. One qualification: an EMPTY directory produces this same
94
+ * key, so an empty directory being created or removed notifies nobody (the limitation
95
+ * `docs/WATCH.md` states). Every other error belongs to the caller.
96
+ * @param {string} hostPath - the target's host path.
97
+ * @param {object} io - the io seam.
98
+ * @returns {Promise<string>} the entry-set key.
99
+ */
100
+ async function readEntryKey(hostPath, io) {
101
+ try {
102
+ return entrySetKey(await io.readdir(hostPath))
103
+ } catch (error) {
104
+ if (error?.code === 'ENOENT') return ''
105
+ throw error
106
+ }
107
+ }
108
+
109
+ /**
110
+ * One close path for both strategies: idempotent, stops the strategy, detaches the
111
+ * abort listener it added.
112
+ *
113
+ * The base contract uses `signal` to cancel INITIALIZATION only — the caller closes an
114
+ * initialized watcher. Stopping an active poll on a later abort as well is a deliberate
115
+ * superset: a leaked poller costs a syscall every interval forever, and the disposer is
116
+ * idempotent, so honouring a late abort cannot double-close.
117
+ * @param {() => void} stop - the strategy's own teardown.
118
+ * @param {AbortSignal} signal - the signal the caller passed.
119
+ * @returns {() => Promise<void>} the disposer.
120
+ */
121
+ function makeDisposer(stop, signal) {
122
+ let stopped = false
123
+ const onAbort = () => { void dispose() }
124
+ /**
125
+ * Stop observing.
126
+ * @returns {Promise<void>} once stopped.
127
+ */
128
+ function dispose() {
129
+ if (stopped) return Promise.resolve()
130
+ stopped = true
131
+ stop()
132
+ signal.removeEventListener('abort', onAbort)
133
+ return Promise.resolve()
134
+ }
135
+ signal.addEventListener('abort', onAbort, { once: true })
136
+ return dispose
137
+ }
138
+
139
+ /**
140
+ * Poll one directory's direct entries.
141
+ * @param {object} options - as {@link startWatch}.
142
+ * @returns {Promise<() => Promise<void>>} the disposer once the baseline is read.
143
+ */
144
+ async function startDirEntries({ hostPath, changed, signal, intervalMs, io }) {
145
+ let previousKey
146
+ let ticking = false
147
+ let stopped = false
148
+
149
+ /**
150
+ * One poll: read the entries, compare, notify at most once.
151
+ * @returns {Promise<void>} once this tick has settled.
152
+ */
153
+ const tick = async () => {
154
+ if (ticking || stopped) return
155
+ ticking = true
156
+ try {
157
+ const key = await readEntryKey(hostPath, io)
158
+ const previous = previousKey
159
+ previousKey = key
160
+ if (entrySetChanged(previous, key)) changed()
161
+ } catch (error) {
162
+ changed(error instanceof Error ? error : new Error(String(error)))
163
+ } finally {
164
+ ticking = false
165
+ }
166
+ }
167
+
168
+ // The baseline is read BEFORE the watch reports itself active: a change landing
169
+ // between the call and the first tick must be a change, not the baseline. It also
170
+ // makes initialization fail loudly for a path that can never be read, which is what
171
+ // the base contract's `@throws cannot initialize` asks for.
172
+ previousKey = await readEntryKey(hostPath, io)
173
+ signal.throwIfAborted()
174
+ // The callback RETURNS the tick's promise rather than discarding it (`void tick()`):
175
+ // the real `setInterval` ignores a callback's return value, while the injected seam
176
+ // hands it back, and only a poll the seam hands back can be awaited. `tick` reports
177
+ // every failure through `changed`, so nothing is left unhandled either way.
178
+ const timer = io.setInterval(() => tick(), intervalMs)
179
+ return makeDisposer(() => { stopped = true; io.clearInterval(timer) }, signal)
180
+ }
181
+
182
+ /**
183
+ * Watch one path's stat.
184
+ * @param {object} options - as {@link startWatch}.
185
+ * @returns {() => Promise<void>} the disposer.
186
+ */
187
+ function startPathStat({ hostPath, changed, signal, intervalMs, io }) {
188
+ const listener = () => { changed() }
189
+ io.watchFile(hostPath, { interval: intervalMs, persistent: true }, listener)
190
+ // The listener goes to `unwatchFile` so only THIS registration is removed: Node
191
+ // refcounts one StatWatcher per path, and `unwatchFile(path)` without a listener
192
+ // would drop every consumer watching that same path.
193
+ return makeDisposer(() => io.unwatchFile(hostPath, listener), signal)
194
+ }
195
+
196
+ /**
197
+ * Observe one target through a strategy the share supports.
198
+ * @param {object} options - the watch request.
199
+ * @param {string} options.hostPath - the target's HOST path (a UNC spelling for WSL).
200
+ * @param {'dir-entries' | 'path-stat'} options.strategy - from {@link watchStrategyFor}.
201
+ * @param {(error?: Error) => void} options.changed - the invalidation callback.
202
+ * @param {AbortSignal} options.signal - cancels initialization.
203
+ * @param {number} [options.intervalMs] - the polling period.
204
+ * @param {object} [options.io] - the io seam; defaults to `NODE_IO` — `readdir` from
205
+ * `node:fs/promises`, the stat poller from `node:fs`, and the real timers.
206
+ * @returns {Promise<() => Promise<void>>} the disposer once observation is active.
207
+ */
208
+ export async function startWatch({ hostPath, strategy, changed, signal, intervalMs = WATCH_INTERVAL_MS, io = NODE_IO }) {
209
+ signal.throwIfAborted()
210
+ if (strategy === 'path-stat') return startPathStat({ hostPath, changed, signal, intervalMs, io })
211
+ return startDirEntries({ hostPath, changed, signal, intervalMs, io })
212
+ }
package/lib/wsl/world.js CHANGED
@@ -225,6 +225,37 @@ export async function listDistros({ wslPath = 'wsl.exe' } = {}) {
225
225
  .filter((line) => line.length > 0)
226
226
  }
227
227
 
228
+ /**
229
+ * The Lxss registry values and the two REG_SZ lines the default-distribution read needs.
230
+ *
231
+ * The patterns live HERE, once, because the async and the sync reader each carried their
232
+ * own copy: a registry-format change had two homes, and only one of them was exercised by
233
+ * whichever caller the operator happened to hit.
234
+ */
235
+ const DEFAULT_DISTRIBUTION_VALUE = 'DefaultDistribution'
236
+ const DISTRIBUTION_NAME_VALUE = 'DistributionName'
237
+ const GUID_PATTERN = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/iu
238
+ const NAME_PATTERN = /DistributionName\s+REG_SZ\s+(.+)/iu
239
+
240
+ /**
241
+ * The distribution a `DistributionName` line names.
242
+ * @param {unknown} output - the registry query's stdout.
243
+ * @returns {string | undefined} the name, or undefined when the line names none.
244
+ */
245
+ function distroFromRegistryOutput(output) {
246
+ const distro = NAME_PATTERN.exec(String(output))?.[1]?.trim()
247
+ return distro === undefined || distro === '' ? undefined : distro
248
+ }
249
+
250
+ /**
251
+ * The Lxss subkey a `DefaultDistribution` line names.
252
+ * @param {unknown} output - the registry query's stdout.
253
+ * @returns {string | undefined} the GUID, or undefined when the line names none.
254
+ */
255
+ function guidFromRegistryOutput(output) {
256
+ return GUID_PATTERN.exec(String(output))?.[1]
257
+ }
258
+
228
259
  /**
229
260
  * Read the user's default distribution from the Lxss registry. Non-fatal:
230
261
  * `undefined` when the value is absent or unreadable.
@@ -232,16 +263,15 @@ export async function listDistros({ wslPath = 'wsl.exe' } = {}) {
232
263
  */
233
264
  export async function defaultDistro() {
234
265
  try {
235
- const value = await execFileAsync('reg.exe', ['query', LXSS_KEY, '/v', 'DefaultDistribution'], {
266
+ const value = await execFileAsync('reg.exe', ['query', LXSS_KEY, '/v', DEFAULT_DISTRIBUTION_VALUE], {
236
267
  timeout: DISCOVERY_TIMEOUT_MS,
237
268
  })
238
- const guid = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/i.exec(String(value.stdout))?.[1]
269
+ const guid = guidFromRegistryOutput(value.stdout)
239
270
  if (guid === undefined) return undefined
240
- const name = await execFileAsync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', 'DistributionName'], {
271
+ const name = await execFileAsync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', DISTRIBUTION_NAME_VALUE], {
241
272
  timeout: DISCOVERY_TIMEOUT_MS,
242
273
  })
243
- const distro = /DistributionName\s+REG_SZ\s+(.+)/i.exec(String(name.stdout))?.[1]?.trim()
244
- return distro === undefined || distro === '' ? undefined : distro
274
+ return distroFromRegistryOutput(name.stdout)
245
275
  } catch {
246
276
  // A missing or unreadable registry value leaves the caller its own fallback.
247
277
  return undefined
@@ -261,16 +291,15 @@ export function defaultDistroSync() {
261
291
  if (syncDefaultResolved) return syncDefault
262
292
  syncDefaultResolved = true
263
293
  try {
264
- const value = execFileSync('reg.exe', ['query', LXSS_KEY, '/v', 'DefaultDistribution'], {
294
+ const value = execFileSync('reg.exe', ['query', LXSS_KEY, '/v', DEFAULT_DISTRIBUTION_VALUE], {
265
295
  timeout: DISCOVERY_TIMEOUT_MS,
266
296
  })
267
- const guid = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/i.exec(String(value))?.[1]
297
+ const guid = guidFromRegistryOutput(value)
268
298
  if (guid === undefined) return undefined
269
- const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', 'DistributionName'], {
299
+ const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', DISTRIBUTION_NAME_VALUE], {
270
300
  timeout: DISCOVERY_TIMEOUT_MS,
271
301
  })
272
- const distro = /DistributionName\s+REG_SZ\s+(.+)/i.exec(String(name))?.[1]?.trim()
273
- syncDefault = distro === undefined || distro === '' ? undefined : distro
302
+ syncDefault = distroFromRegistryOutput(name)
274
303
  } catch {
275
304
  // An unreadable registry leaves the caller its own fail-loud path.
276
305
  syncDefault = undefined
@@ -353,15 +382,31 @@ function bridgeEnv(extra) {
353
382
  * @returns {{ distro: string, linuxCwd: string, windowsCwd: string }} the execution plan.
354
383
  * @throws Error when the directory belongs to neither world.
355
384
  */
385
+ /**
386
+ * The distribution a plan runs in: the caller's fallback, else the machine default.
387
+ * @param {string} workdir - the directory being planned, named in the failure.
388
+ * @param {string|undefined} fallbackDistro - the configured distro, when there is one.
389
+ * @returns {string} the distribution name.
390
+ * @throws {Error} when neither source names one.
391
+ */
392
+ function wslUserOption(username) {
393
+ return username !== undefined && username !== '' ? ['-u', username] : []
394
+ }
395
+
396
+ function planDistro(workdir, fallbackDistro) {
397
+ const distro = fallbackDistro ?? defaultDistroSync()
398
+ if (distro === undefined) {
399
+ throw new Error(`wsl: 无法确定 "${workdir}" 所属的发行版,请配置 distro`)
400
+ }
401
+ return distro
402
+ }
403
+
356
404
  export function planWsl(workdir, fallbackDistro) {
357
405
  const windowsCwd = process.env.SystemRoot ?? process.cwd()
358
406
  const unc = parseWslUnc(workdir)
359
407
  if (unc !== null) return { distro: unc.distro, linuxCwd: unc.linuxPath, windowsCwd }
360
408
  if (workdir.startsWith('/')) {
361
- const distro = fallbackDistro ?? defaultDistroSync()
362
- if (distro === undefined) {
363
- throw new Error(`wsl: 无法确定 "${workdir}" 所属的发行版,请配置 distro`)
364
- }
409
+ const distro = planDistro(workdir, fallbackDistro)
365
410
  // A drive mount is also addressable from the Windows side; keep that as the
366
411
  // spawn directory so a failure still names a real path.
367
412
  return { distro, linuxCwd: workdir, windowsCwd: mntToWindowsPath(workdir) ?? windowsCwd }
@@ -370,10 +415,7 @@ export function planWsl(workdir, fallbackDistro) {
370
415
  if (mnt === null) {
371
416
  throw new Error(`wsl: 工作目录 "${workdir}" 不在 WSL 执行世界里`)
372
417
  }
373
- const distro = fallbackDistro ?? defaultDistroSync()
374
- if (distro === undefined) {
375
- throw new Error(`wsl: 无法确定 "${workdir}" 所属的发行版,请配置 distro`)
376
- }
418
+ const distro = planDistro(workdir, fallbackDistro)
377
419
  return { distro, linuxCwd: mnt, windowsCwd }
378
420
  }
379
421
 
@@ -399,7 +441,7 @@ export function buildWslExecArgv(plan, argv, options = {}) {
399
441
  return [
400
442
  options.wslPath ?? 'wsl.exe',
401
443
  '-d', plan.distro,
402
- ...(options.username !== undefined && options.username !== '' ? ['-u', options.username] : []),
444
+ ...wslUserOption(options.username),
403
445
  '--cd', plan.linuxCwd,
404
446
  '-e',
405
447
  ...argv,
@@ -486,7 +528,7 @@ export function runWslShell({ distro, linuxCwd, command, username, loginShell =
486
528
  const script = loginShell ? `cd ${shellQuote(linuxCwd)} && ${command}` : command
487
529
  const argv = [
488
530
  '-d', distro,
489
- ...(username !== undefined && username !== '' ? ['-u', username] : []),
531
+ ...wslUserOption(username),
490
532
  '--cd', linuxCwd,
491
533
  '-e', 'bash', loginShell ? '-lc' : '-c', script,
492
534
  ]
@@ -592,12 +634,14 @@ const PROBE_TIMEOUT_MS = 60_000
592
634
  * probe follows: repeat the SAME probe once, immediately, and let a persistent stall
593
635
  * still fail loudly with the second attempt's evidence.
594
636
  *
595
- * Retrying is safe here because all three probes are READS: the listing protocol frames
637
+ * Retrying is safe here because all FOUR probes are READS: the listing protocol frames
596
638
  * its own output and the check prints one of three tokens, so a repeat cannot duplicate
597
- * a side effect. The trigger is deliberately `timedOut` alone and not "any bad answer":
598
- * a non-zero exit from the listing script is the ordinary answer for a directory that is
599
- * not there, and doubling the latency of that case would slow the interactive picker for
600
- * no measured benefit.
639
+ * a side effect; `resolveLoginShell` runs a read-only `getent` and answers the same way.
640
+ * (This comment said "three" while four probes already consumed the runner — scripts/
641
+ * wsl-probe.mjs counts the same four.) The trigger is deliberately `timedOut` alone and
642
+ * not "any bad answer": a non-zero exit from the listing script is the ordinary answer
643
+ * for a directory that is not there, and doubling the latency of that case would slow
644
+ * the interactive picker for no measured benefit.
601
645
  * @param {(request: object) => Promise<object>} run - the probe runner.
602
646
  * @param {object} request - the probe, without its ceiling.
603
647
  * @returns {Promise<object>} the first conclusive attempt, or the second after a timeout.
@@ -612,7 +656,7 @@ async function probeWithRetry(run, request) {
612
656
  * List one directory inside a distribution.
613
657
  * @param {string} distro - distribution name.
614
658
  * @param {string} linuxPath - absolute Linux directory.
615
- * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution — the same seam `resolveIdentity`/`detectRunner` expose).
659
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy above can be driven without a distribution — the same seam `resolveIdentity`/`detectRunner` expose).
616
660
  * @returns {Promise<{ path: string, parent: string | null, entries: Array<{ name: string, kind: 'directory' | 'file' }> }>} the listing.
617
661
  */
618
662
  export async function listLinuxDir(distro, linuxPath, { wslPath, run = runWslShell } = {}) {
@@ -651,7 +695,7 @@ export async function listLinuxDir(distro, linuxPath, { wslPath, run = runWslShe
651
695
  * Check whether one Linux path exists.
652
696
  * @param {string} distro - distribution name.
653
697
  * @param {string} linuxPath - absolute Linux path.
654
- * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution).
698
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy above can be driven without a distribution).
655
699
  * @returns {Promise<{ exists: boolean, isDirectory: boolean }>} existence facts.
656
700
  */
657
701
  export async function checkLinuxPath(distro, linuxPath, { wslPath, run = runWslShell } = {}) {
@@ -707,7 +751,7 @@ export function probeEvidence(entry) {
707
751
  * exactly one path.
708
752
  * @param {string} distro - distribution name.
709
753
  * @param {string} [username] - user to resolve; absent resolves the default user.
710
- * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution).
754
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy above can be driven without a distribution).
711
755
  * @returns {Promise<{ user: string, home: string }>} the user and their home.
712
756
  * @throws Error when the user or a usable home cannot be resolved.
713
757
  */
@@ -842,7 +886,7 @@ export class LoginShellUnresolvedError extends Error {
842
886
  * own evidence instead of answering.
843
887
  * @param {string} distro - distribution name.
844
888
  * @param {string} [username] - Linux user; omitted uses the distribution default.
845
- * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution — the same seam the sibling probes expose).
889
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy above can be driven without a distribution — the same seam the sibling probes expose).
846
890
  * @returns {Promise<string>} the login shell path.
847
891
  * @throws {LoginShellUnresolvedError} when the probe timed out twice, could not
848
892
  * run, or `getent` did not answer. The one value returned without the probe (an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",