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 +7 -4
- package/README.md +6 -3
- package/lib/client.js +6 -5
- package/lib/index.js +162 -18
- package/lib/wsl/confinement.js +17 -15
- package/lib/wsl/fs.js +44 -6
- package/lib/wsl/preset.js +10 -5
- package/lib/wsl/pty.js +17 -2
- package/lib/wsl/shell.js +7 -4
- package/lib/wsl/subprocess.js +17 -3
- package/lib/wsl/terminal-bridge.py +6 -1
- package/lib/wsl/watch.js +212 -0
- package/lib/wsl/world.js +73 -29
- 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` all green +
|
|
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 (
|
|
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` 全绿 + 离线
|
|
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 # 全部离线套件(
|
|
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
|
-
|
|
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.
|
|
9
|
-
* execution world is chosen by its preset, so this is what lets one
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
613
|
-
|
|
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
|
|
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
|
package/lib/wsl/confinement.js
CHANGED
|
@@ -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
|
-
//
|
|
186
|
-
// half refused it, and only because sudo cannot exec a
|
|
187
|
-
// 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.
|
|
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
|
|
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
|
|
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:
|
|
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 -
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
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)
|
|
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([
|
|
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
|
-
/**
|
|
432
|
-
|
|
433
|
-
|
|
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
|
package/lib/wsl/subprocess.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
package/lib/wsl/watch.js
ADDED
|
@@ -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',
|
|
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 =
|
|
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',
|
|
271
|
+
const name = await execFileAsync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', DISTRIBUTION_NAME_VALUE], {
|
|
241
272
|
timeout: DISCOVERY_TIMEOUT_MS,
|
|
242
273
|
})
|
|
243
|
-
|
|
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',
|
|
294
|
+
const value = execFileSync('reg.exe', ['query', LXSS_KEY, '/v', DEFAULT_DISTRIBUTION_VALUE], {
|
|
265
295
|
timeout: DISCOVERY_TIMEOUT_MS,
|
|
266
296
|
})
|
|
267
|
-
const guid =
|
|
297
|
+
const guid = guidFromRegistryOutput(value)
|
|
268
298
|
if (guid === undefined) return undefined
|
|
269
|
-
const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v',
|
|
299
|
+
const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', DISTRIBUTION_NAME_VALUE], {
|
|
270
300
|
timeout: DISCOVERY_TIMEOUT_MS,
|
|
271
301
|
})
|
|
272
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|