dsh-wsl-desktop 0.3.4 → 0.3.6
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/lib/client.js +6 -5
- package/lib/icon.svg +24 -0
- package/lib/index.js +58 -17
- package/lib/wsl/confinement.js +12 -11
- package/lib/wsl/fs.js +16 -6
- package/lib/wsl/preset.js +109 -14
- 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/world.js +73 -29
- package/package.json +2 -1
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/icon.svg
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="DSH WSL Desktop">
|
|
2
|
+
<!-- W monogram. The polyline's apex/valleys are chosen so the STROKED bounds
|
|
3
|
+
(8.5..55.5 x 13.5..50.5) center on (32,32): the mark is optically centered,
|
|
4
|
+
not merely its path data. Keep the 7px stroke when editing the path, and
|
|
5
|
+
re-center against those bounds rather than the raw vertices. -->
|
|
6
|
+
<defs>
|
|
7
|
+
<linearGradient id="wsl-base" x1="0" y1="0" x2="0" y2="1">
|
|
8
|
+
<stop offset="0" stop-color="#151D31"/>
|
|
9
|
+
<stop offset="1" stop-color="#0B111E"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<radialGradient id="wsl-glow" gradientUnits="userSpaceOnUse" cx="52" cy="4" r="44">
|
|
12
|
+
<stop offset="0" stop-color="#38BDF8" stop-opacity=".32"/>
|
|
13
|
+
<stop offset="1" stop-color="#38BDF8" stop-opacity="0"/>
|
|
14
|
+
</radialGradient>
|
|
15
|
+
<linearGradient id="wsl-mark" x1="12" y1="17" x2="52" y2="47" gradientUnits="userSpaceOnUse">
|
|
16
|
+
<stop offset="0" stop-color="#38BDF8"/>
|
|
17
|
+
<stop offset="1" stop-color="#4D6BFE"/>
|
|
18
|
+
</linearGradient>
|
|
19
|
+
</defs>
|
|
20
|
+
<rect width="64" height="64" rx="15" fill="url(#wsl-base)"/>
|
|
21
|
+
<rect width="64" height="64" rx="15" fill="url(#wsl-glow)"/>
|
|
22
|
+
<path d="M12 17 L22.5 47 L32 25 L41.5 47 L52 17" fill="none" stroke="url(#wsl-mark)" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
|
23
|
+
<rect x=".75" y=".75" width="62.5" height="62.5" rx="14.25" fill="none" stroke="#7DD3FC" stroke-opacity=".22" stroke-width="1.5"/>
|
|
24
|
+
</svg>
|
package/lib/index.js
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
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
|
|
|
@@ -44,6 +46,9 @@ export const Config = z.object({
|
|
|
44
46
|
* generation can be mounted beside a running one, which is how the host half
|
|
45
47
|
* is exercised without restarting the application.
|
|
46
48
|
*/
|
|
49
|
+
// MUST stay equal to the browser half's ENDPOINT (lib/client.js): nothing derives
|
|
50
|
+
// one from the other, and a deployment that mounts the route elsewhere loses the
|
|
51
|
+
// picker silently — the failure this default is the only record of.
|
|
47
52
|
routePath: z.string().default('/wsl-desktop/api'),
|
|
48
53
|
/**
|
|
49
54
|
* Path to `wsl.exe`. Every probe this half runs spawns it — discovery, directory
|
|
@@ -78,6 +83,20 @@ const SELF_MODULE = fileURLToPath(import.meta.url)
|
|
|
78
83
|
/** Prefix of every preset this plugin owns; its own outputs are never sources. */
|
|
79
84
|
const GENERATED_PREFIX = 'wsl-'
|
|
80
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The generated variant id for a resolved base preset.
|
|
88
|
+
*
|
|
89
|
+
* A preset that is already a variant keeps its id; anything else gets the prefix. The
|
|
90
|
+
* selftest and createWorkspace both derived it this way — the second site's own comment
|
|
91
|
+
* admitted it resolved exactly like the first — so a change to the prefix rule had two
|
|
92
|
+
* homes.
|
|
93
|
+
* @param {{ id: string }} preset - the resolved base preset.
|
|
94
|
+
* @returns {string} the variant id.
|
|
95
|
+
*/
|
|
96
|
+
function variantIdFor(preset) {
|
|
97
|
+
return preset.id.startsWith(GENERATED_PREFIX) ? preset.id : `${GENERATED_PREFIX}${preset.id}`
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
/**
|
|
82
101
|
* Module specifiers of the WSL providers, as the preset rows must name them.
|
|
83
102
|
* The 0.1.7 loader imports row names via `new URL(name, baseUrl)` or a bare
|
|
@@ -203,6 +222,24 @@ function basePluginsOf(presets, id) {
|
|
|
203
222
|
* refuses duplicates).
|
|
204
223
|
* @returns {Promise<object>} the registration outcome for the route.
|
|
205
224
|
*/
|
|
225
|
+
/**
|
|
226
|
+
* Run a shell spec through whichever seam the host exposes, and always hand back a
|
|
227
|
+
* handle whose `result()` is awaitable.
|
|
228
|
+
*
|
|
229
|
+
* 0.1.7 hosts expose `execute`, which resolves with a live handle; older ones have
|
|
230
|
+
* only `run`, whose settled result carries no `result()` projection. Both selftest
|
|
231
|
+
* steps used to spell that dual dispatch themselves, and the second one called
|
|
232
|
+
* `.result()` on `run`'s settled result — a TypeError on exactly the hosts that
|
|
233
|
+
* branch exists to support. One owner, so the two steps cannot drift again.
|
|
234
|
+
* @param {object} shell - the realm's shell service.
|
|
235
|
+
* @param {object} spec - the resolved spec to run.
|
|
236
|
+
* @returns {Promise<{ result: () => Promise<object> }>} a result()-able handle.
|
|
237
|
+
*/
|
|
238
|
+
function runShellSpec(shell, spec) {
|
|
239
|
+
if (typeof shell.execute === 'function') return shell.execute(spec)
|
|
240
|
+
return shell.run(spec).then((settled) => ({ result: async () => settled }))
|
|
241
|
+
}
|
|
242
|
+
|
|
206
243
|
async function materializePresets(ctx, distro, generation, previous) {
|
|
207
244
|
const presets = ctx.get('agentPresets')
|
|
208
245
|
if (presets === undefined) throw new Error('agentPresets 服务不可用')
|
|
@@ -213,9 +250,16 @@ async function materializePresets(ctx, distro, generation, previous) {
|
|
|
213
250
|
if (hostCompat.registry07 !== true) {
|
|
214
251
|
throw new Error('需要 DSH Desktop 0.1.7+:当前宿主的 agentPresets 缺少 readDocument(0.1.6 及以下不受支持)。请升级桌面,或使用 0.1.6 兼容的插件版本。')
|
|
215
252
|
}
|
|
216
|
-
|
|
253
|
+
/**
|
|
254
|
+
* The answer a round gives when its generation was torn down mid-flight: the preset
|
|
255
|
+
* surface stays READY (the previous generation is still registered), nothing is
|
|
256
|
+
* written, and the skip says why. Spelled once — it stood three times, so the
|
|
257
|
+
* wording and the shape each had three homes.
|
|
258
|
+
*/
|
|
259
|
+
const abandonedRound = () => ({ status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] })
|
|
260
|
+
if (generation?.disposed) return abandonedRound()
|
|
217
261
|
const roster = await presets.list()
|
|
218
|
-
if (generation?.disposed) return
|
|
262
|
+
if (generation?.disposed) return abandonedRound()
|
|
219
263
|
const written = []
|
|
220
264
|
const skipped = []
|
|
221
265
|
|
|
@@ -223,7 +267,7 @@ async function materializePresets(ctx, distro, generation, previous) {
|
|
|
223
267
|
for (const [id, dispose] of previous?.disposers ?? []) {
|
|
224
268
|
try { await dispose() } catch { /* already gone */ }
|
|
225
269
|
previous?.disposers.delete(id)
|
|
226
|
-
if (generation?.disposed) return
|
|
270
|
+
if (generation?.disposed) return abandonedRound()
|
|
227
271
|
}
|
|
228
272
|
|
|
229
273
|
for (const preset of roster) {
|
|
@@ -410,9 +454,7 @@ const METHODS = {
|
|
|
410
454
|
const presets = hostCtx?.get('agentPresets')
|
|
411
455
|
if (presets === undefined) throw new Error('agent presets 尚不可用')
|
|
412
456
|
const resolved = await presets.resolve(base)
|
|
413
|
-
const agentPreset = resolved
|
|
414
|
-
? resolved.id
|
|
415
|
-
: `${GENERATED_PREFIX}${resolved.id}`
|
|
457
|
+
const agentPreset = variantIdFor(resolved)
|
|
416
458
|
const roster = await presets.list()
|
|
417
459
|
const variant = roster.find((preset) => preset.id === agentPreset)
|
|
418
460
|
if (variant === undefined) throw new Error(`WSL 预设 ${agentPreset} 尚未生成`)
|
|
@@ -550,8 +592,9 @@ const METHODS = {
|
|
|
550
592
|
// failure would abort the whole selftest.
|
|
551
593
|
const spec = shell.resolve({ command: 'uname -s; pwd; id -un; echo "$WSL_DISTRO_NAME"', workdir: workspaceCwd })
|
|
552
594
|
// 0.1.7: execute resolves with a live handle; the settled result is
|
|
553
|
-
// its memoized result() projection. Dual-dispatch for older hosts
|
|
554
|
-
|
|
595
|
+
// its memoized result() projection. Dual-dispatch for older hosts — one
|
|
596
|
+
// owner, runShellSpec, shared with the confined step below.
|
|
597
|
+
const execution = await runShellSpec(shell, spec)
|
|
555
598
|
const result = await execution.result()
|
|
556
599
|
record('shell.run', {
|
|
557
600
|
exitCode: result?.exitCode,
|
|
@@ -609,8 +652,8 @@ const METHODS = {
|
|
|
609
652
|
}
|
|
610
653
|
|
|
611
654
|
if (shell !== undefined) {
|
|
612
|
-
const
|
|
613
|
-
|
|
655
|
+
const execution = await runShellSpec(
|
|
656
|
+
shell,
|
|
614
657
|
shell.resolve({ command: 'echo blocked > /dsh-wsl-forbidden.txt', workdir: workspaceCwd }),
|
|
615
658
|
).catch((error) => ({ result: async () => ({ exitCode: null, stderr: { text: String(error?.message ?? error) }, sandbox: null }) }))
|
|
616
659
|
const confined = await execution.result()
|
|
@@ -816,9 +859,7 @@ const METHODS = {
|
|
|
816
859
|
const presets = hostCtx.get('agentPresets')
|
|
817
860
|
if (presets !== undefined) {
|
|
818
861
|
const resolved = await presets.resolve()
|
|
819
|
-
const candidate = resolved
|
|
820
|
-
? resolved.id
|
|
821
|
-
: `${GENERATED_PREFIX}${resolved.id}`
|
|
862
|
+
const candidate = variantIdFor(resolved)
|
|
822
863
|
const roster = await presets.list()
|
|
823
864
|
if (roster.some((preset) => preset.id === candidate && preset.broken === undefined)) presetId = candidate
|
|
824
865
|
}
|
|
@@ -961,7 +1002,7 @@ async function isDeveloperCaller(req, config) {
|
|
|
961
1002
|
/**
|
|
962
1003
|
* Register the WSL route and generate the WSL presets.
|
|
963
1004
|
* @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
|
|
964
|
-
* @param {{ routePath: string, developerTools: boolean }} config - resolved plugin config.
|
|
1005
|
+
* @param {{ routePath: string, developerTools: boolean, wslPath?: string }} config - resolved plugin config.
|
|
965
1006
|
*/
|
|
966
1007
|
export function apply(ctx, config) {
|
|
967
1008
|
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'
|
|
@@ -207,9 +202,9 @@ function helperGateScript(helperPath) {
|
|
|
207
202
|
'u=$(stat -c %u "$f" 2>/dev/null) || u=',
|
|
208
203
|
'm=$(stat -c %a "$f" 2>/dev/null) || m=',
|
|
209
204
|
'[ "$u" = 0 ] || ok=no',
|
|
210
|
-
'[ -n "$m" ] || ok=no',
|
|
211
205
|
// A mode stat cannot report (or one that is not octal) must refuse, not
|
|
212
|
-
// error out silently: the arithmetic parse is guarded by
|
|
206
|
+
// error out silently: the arithmetic parse is guarded by its own non-empty
|
|
207
|
+
// test, which is why no separate [ -n "$m" ] refusal stands above it.
|
|
213
208
|
'[ -n "$m" ] && (( (8#$m & 022) == 0 )) || ok=no',
|
|
214
209
|
'[ -w "$f" ] && ok=no',
|
|
215
210
|
'd=$(dirname "$f")',
|
|
@@ -317,7 +312,9 @@ export async function detectRunner({ distro, username, run }) {
|
|
|
317
312
|
}
|
|
318
313
|
|
|
319
314
|
/**
|
|
320
|
-
* Forget cached probe results. Used by
|
|
315
|
+
* Forget cached probe results. Used by the verification suites — no settings change
|
|
316
|
+
* calls it (the doc claimed one did; the only caller in the tree is
|
|
317
|
+
* scripts/verify-confinement.mjs).
|
|
321
318
|
*/
|
|
322
319
|
export function resetConfinementCache() {
|
|
323
320
|
identityCache.clear()
|
|
@@ -421,7 +418,7 @@ export async function detectNoNewPrivs({ distro, username, run }) {
|
|
|
421
418
|
* unconditionally (dsh-wsl-confine.sh), so on a setpriv without the flag setpriv
|
|
422
419
|
* rejects the unknown option and exits 1 — the confined command simply does not
|
|
423
420
|
* run, and that is an ordinary COMMAND failure, not the setup-failure marker
|
|
424
|
-
* that reports an unavailable runner. docs/CONFINEMENT.md:
|
|
421
|
+
* that reports an unavailable runner. docs/CONFINEMENT.md:31 records the measured exit 1;
|
|
425
422
|
* this text previously contradicted it by recommending the helper as a remedy.
|
|
426
423
|
*
|
|
427
424
|
* The remedy names the PROBEABLE requirement, not a version: the util-linux release
|
|
@@ -668,7 +665,10 @@ function postConditions(keep) {
|
|
|
668
665
|
* @param {string} options.linuxCwd - absolute Linux working directory.
|
|
669
666
|
* @param {'read-only' | 'workspace-write'} options.mode - the confined mode.
|
|
670
667
|
* @param {string | undefined} options.workspaceLinuxRoot - writable root for `workspace-write`.
|
|
671
|
-
* @param {{ uid: string, gid: string }} options.identity -
|
|
668
|
+
* @param {{ uid: string, gid: string, home: string, name?: string }} options.identity -
|
|
669
|
+
* identity to drop back to; `home` and `name` are read for the environment.
|
|
670
|
+
* @param {boolean | undefined} options.noNewPrivs - whether the runner supports
|
|
671
|
+
* `setpriv --no-new-privs`; adds the flag to the drop-back when true.
|
|
672
672
|
* @returns {string} the script to run inside the namespace.
|
|
673
673
|
*/
|
|
674
674
|
export function buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs }) {
|
|
@@ -726,7 +726,8 @@ export function buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRo
|
|
|
726
726
|
* @param {string} options.linuxCwd - absolute Linux working directory.
|
|
727
727
|
* @param {'read-only' | 'workspace-write'} options.mode - the confined mode.
|
|
728
728
|
* @param {string | undefined} options.workspaceLinuxRoot - writable root for `workspace-write`.
|
|
729
|
-
* @param {{ uid: string, gid: string }} options.identity -
|
|
729
|
+
* @param {{ uid: string, gid: string, home: string, name?: string }} options.identity -
|
|
730
|
+
* identity to drop back to; `home` and `name` are read for the environment.
|
|
730
731
|
* @param {boolean} [options.noNewPrivs] - set NO_NEW_PRIVS on the drop when the
|
|
731
732
|
* distro's setpriv supports it (blocks setuid elevation inside the fence).
|
|
732
733
|
* @param {string} [options.runner] - the resolved runner: 'helper' routes the
|
package/lib/wsl/fs.js
CHANGED
|
@@ -83,11 +83,24 @@ export function toHostPath(value, cwd, fallbackDistro) {
|
|
|
83
83
|
* @returns {string} the Linux spelling when one exists, else the input.
|
|
84
84
|
*/
|
|
85
85
|
export function toLinuxPath(value) {
|
|
86
|
+
return toLinuxPathOrNull(value) ?? value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The Linux spelling of a host path, or undefined when one does not exist.
|
|
91
|
+
*
|
|
92
|
+
* The ONE place the UNC/drive translation is written: `toLinuxPath` and the fs
|
|
93
|
+
* backend's `processPathFromHostPath` differ only in the sentinel they answer for an
|
|
94
|
+
* unrepresentable path (the input vs undefined), and spelling the translation twice
|
|
95
|
+
* meant a fix could land in one copy only.
|
|
96
|
+
* @param {string} value - a host path on the share or a drive path.
|
|
97
|
+
* @returns {string | undefined} the normalized Linux path, or undefined.
|
|
98
|
+
*/
|
|
99
|
+
function toLinuxPathOrNull(value) {
|
|
86
100
|
const unc = parseWslUnc(value)
|
|
87
101
|
if (unc !== null) return posix.normalize(unc.linuxPath)
|
|
88
102
|
const drive = windowsToMntPath(value)
|
|
89
|
-
|
|
90
|
-
return value
|
|
103
|
+
return drive === null ? undefined : posix.normalize(drive)
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
/**
|
|
@@ -288,10 +301,7 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
288
301
|
*/
|
|
289
302
|
processPathFromHostPath(hostPath) {
|
|
290
303
|
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)
|
|
304
|
+
return toLinuxPathOrNull(hostPath)
|
|
295
305
|
}
|
|
296
306
|
|
|
297
307
|
/**
|
package/lib/wsl/preset.js
CHANGED
|
@@ -49,6 +49,48 @@ export const WORLD_MODULE_NAMES = new Set([
|
|
|
49
49
|
'@deepseek-ai/dsh-terminal',
|
|
50
50
|
])
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Tool-family modules whose survival in a WSL preset is a RECORDED VERDICT.
|
|
54
|
+
*
|
|
55
|
+
* The complement of WORLD_MODULE_NAMES inside the tool family, and the reason it
|
|
56
|
+
* exists: the removal rule is a closed list, so a tool-family row this plugin had
|
|
57
|
+
* never seen was neither removed NOR reported — it was copied into the WSL variant
|
|
58
|
+
* in silence, which is the leak shape this transform exists to prevent. A future
|
|
59
|
+
* DSH release that adds a host-execution tool row would have leaked exactly there,
|
|
60
|
+
* with every suite still green.
|
|
61
|
+
*
|
|
62
|
+
* Every name here was read off a shipped preset and judged world-agnostic: it
|
|
63
|
+
* neither runs host commands nor reaches host files on the model's behalf. A row
|
|
64
|
+
* outside BOTH sets is kept — the transform must not guess a row away from a preset
|
|
65
|
+
* — but reported through `unclassified`, so the next upstream tool row is a
|
|
66
|
+
* decision somebody makes rather than a leak nobody sees.
|
|
67
|
+
*/
|
|
68
|
+
export const VERDICTED_KEPT_MODULES = new Set([
|
|
69
|
+
'@deepseek-ai/dsh-tool-jobs',
|
|
70
|
+
'@deepseek-ai/dsh-tool-schedule',
|
|
71
|
+
'@deepseek-ai/dsh-tool-skill',
|
|
72
|
+
'@deepseek-ai/dsh-skill-filesystem',
|
|
73
|
+
'@deepseek-ai/dsh-command-goal',
|
|
74
|
+
'@deepseek-ai/dsh-tool-goal',
|
|
75
|
+
'@deepseek-ai/dsh-tool-subagent-control',
|
|
76
|
+
'@deepseek-ai/dsh-tool-subagent',
|
|
77
|
+
'@deepseek-ai/dsh-tool-workflow',
|
|
78
|
+
'@deepseek-ai/dsh-tool-ask-user',
|
|
79
|
+
'@deepseek-ai/dsh-tool-todo',
|
|
80
|
+
'@deepseek-ai/dsh-tool-web',
|
|
81
|
+
'@deepseek-ai/dsh-tool-cordis',
|
|
82
|
+
'@deepseek-ai/dsh-tool-present',
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The tool / execution family, by package name.
|
|
87
|
+
*
|
|
88
|
+
* Narrow on purpose: it decides which rows need a VERDICT, and a family wide enough
|
|
89
|
+
* to catch the persona, instruction, plan-mode or compaction rows would redden the
|
|
90
|
+
* acceptance gate over rows whose survival was never in question.
|
|
91
|
+
*/
|
|
92
|
+
const TOOL_FAMILY = /^@deepseek-ai\/dsh-(?:tool-[a-z0-9-]+|terminal[a-z0-9-]*|skill-filesystem)$/u
|
|
93
|
+
|
|
52
94
|
/** Module extensions a specifier may carry where it names the package itself. */
|
|
53
95
|
const MODULE_SUFFIX = /\.(?:[cm]?js)$/u
|
|
54
96
|
|
|
@@ -76,6 +118,26 @@ function specifierSegments(specifier) {
|
|
|
76
118
|
return readings.flatMap((reading) => reading.split(/[\\/]/u))
|
|
77
119
|
}
|
|
78
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Every `@scope/name` package reading a specifier carries.
|
|
123
|
+
*
|
|
124
|
+
* The one place the package identity is derived, shared by the removal predicate
|
|
125
|
+
* and the verdict predicate so the two cannot disagree about which package a
|
|
126
|
+
* spelling names — the drift that made the earlier basename match miss a subpath.
|
|
127
|
+
* @param {unknown} specifier - the row's module name.
|
|
128
|
+
* @returns {string[]} each scoped pair the specifier carries, in path order.
|
|
129
|
+
*/
|
|
130
|
+
function scopedPackages(specifier) {
|
|
131
|
+
if (typeof specifier !== 'string' || specifier.length === 0) return []
|
|
132
|
+
const segments = specifierSegments(specifier)
|
|
133
|
+
const out = []
|
|
134
|
+
for (let index = 0; index + 1 < segments.length; index += 1) {
|
|
135
|
+
if (!segments[index].startsWith('@')) continue
|
|
136
|
+
out.push(`${segments[index]}/${segments[index + 1].replace(MODULE_SUFFIX, '')}`)
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
79
141
|
/**
|
|
80
142
|
* Whether one row `name` loads a host-execution-world module.
|
|
81
143
|
*
|
|
@@ -100,13 +162,33 @@ function specifierSegments(specifier) {
|
|
|
100
162
|
export function isHostWorldModule(name) {
|
|
101
163
|
if (typeof name !== 'string' || name.length === 0) return false
|
|
102
164
|
if (WORLD_MODULE_NAMES.has(name)) return true
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
165
|
+
return scopedPackages(name).some((pkg) => WORLD_MODULE_NAMES.has(pkg))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Whether a row's module belongs to the family that needs a verdict.
|
|
170
|
+
* @param {unknown} name - the row's module name.
|
|
171
|
+
* @returns {boolean} true when the module is a tool-family package.
|
|
172
|
+
*/
|
|
173
|
+
export function isToolFamilyModule(name) {
|
|
174
|
+
if (typeof name !== 'string' || name.length === 0) return false
|
|
175
|
+
return [name, ...scopedPackages(name)].some((candidate) => TOOL_FAMILY.test(candidate))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Whether this plugin holds an explicit verdict for a row's module.
|
|
180
|
+
*
|
|
181
|
+
* True for both verdicts — removed (WORLD_MODULE_NAMES) and deliberately kept
|
|
182
|
+
* (VERDICTED_KEPT_MODULES) — because the question it answers is "has this row been
|
|
183
|
+
* decided?", not "is it dropped?".
|
|
184
|
+
* @param {unknown} name - the row's module name.
|
|
185
|
+
* @returns {boolean} true when the module carries a verdict.
|
|
186
|
+
*/
|
|
187
|
+
export function hasClassifierVerdict(name) {
|
|
188
|
+
if (typeof name !== 'string' || name.length === 0) return false
|
|
189
|
+
return [name, ...scopedPackages(name)].some((candidate) => (
|
|
190
|
+
WORLD_MODULE_NAMES.has(candidate) || VERDICTED_KEPT_MODULES.has(candidate)
|
|
191
|
+
))
|
|
110
192
|
}
|
|
111
193
|
|
|
112
194
|
/**
|
|
@@ -177,18 +259,20 @@ export const WSL_PERSONA_SENTENCE = 'Paths under \\\\wsl.localhost\\<distro> are
|
|
|
177
259
|
/**
|
|
178
260
|
* Append the WSL path-dialect sentence to a persona row OBJECT.
|
|
179
261
|
* @param {object} row - the persona entry.
|
|
180
|
-
*
|
|
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).
|
|
181
266
|
*/
|
|
182
267
|
function appendPersonaSuffixObject(row) {
|
|
183
268
|
const config = row.config ?? (row.config = {})
|
|
184
269
|
for (const field of ['suffix', 'text', 'prefix']) {
|
|
185
270
|
if (typeof config[field] === 'string' && config[field].length > 0) {
|
|
186
271
|
config[field] = `${config[field]} ${WSL_PERSONA_SENTENCE}`
|
|
187
|
-
return
|
|
272
|
+
return
|
|
188
273
|
}
|
|
189
274
|
}
|
|
190
275
|
config.suffix = WSL_PERSONA_SENTENCE
|
|
191
|
-
return true
|
|
192
276
|
}
|
|
193
277
|
|
|
194
278
|
/**
|
|
@@ -224,12 +308,12 @@ function cloneRow(row) {
|
|
|
224
308
|
* @param {string} options.fsPath - module specifier of the WSL filesystem provider.
|
|
225
309
|
* @param {string | undefined} options.distro - distribution for paths that do not name one.
|
|
226
310
|
* @param {string | undefined} options.sourceDir - directory the source preset lives in.
|
|
227
|
-
* @returns {{ plugins: object[], removed: string[], unclassified: { id: string | null, name: unknown }[] }} the variant rows, what was dropped, and the rows kept without a classifier verdict.
|
|
311
|
+
* @returns {{ plugins: object[], removed: string[], unclassified: { id: string | null, name: unknown }[] }} the variant rows, what was dropped, and the rows kept without a classifier verdict — an unreadable specifier, or a tool-family module that is neither removed nor verdicted-kept.
|
|
228
312
|
*/
|
|
229
313
|
export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fsPath, distro, sourceDir }) {
|
|
230
314
|
const kept = []
|
|
231
315
|
const removed = []
|
|
232
|
-
/** Rows kept without a classifier verdict —
|
|
316
|
+
/** Rows kept without a classifier verdict — both shapes the prune() branches record. */
|
|
233
317
|
const unclassified = []
|
|
234
318
|
let includeEditor = false
|
|
235
319
|
/** Config carried by the pruned host-world tool rows, keyed by row id. */
|
|
@@ -262,11 +346,22 @@ export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fs
|
|
|
262
346
|
unclassified.push({ id: row.id ?? null, name: row.name ?? null })
|
|
263
347
|
} else if (isHostWorldModule(row.name)) {
|
|
264
348
|
removed.push(row.id ?? row.name)
|
|
265
|
-
|
|
266
|
-
|
|
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) {
|
|
267
354
|
toolConfig.set('str-replace-editor', cloneRow({ config: row.config }).config)
|
|
268
355
|
}
|
|
269
356
|
return null
|
|
357
|
+
} else if (isToolFamilyModule(row.name) && !hasClassifierVerdict(row.name)) {
|
|
358
|
+
// Readable specifier, tool family, NO verdict: kept and reported. The removal
|
|
359
|
+
// rule is a closed list, so without this branch a tool row this plugin has
|
|
360
|
+
// never seen was copied into the WSL variant in silence — the leak shape this
|
|
361
|
+
// transform exists to prevent, and the one a new upstream tool row would have
|
|
362
|
+
// taken. Kept rather than removed for the same reason as the unreadable case
|
|
363
|
+
// above: the transform must not guess a row away from a preset.
|
|
364
|
+
unclassified.push({ id: row.id ?? null, name: row.name })
|
|
270
365
|
}
|
|
271
366
|
// The registry stores the caller's objects BY REFERENCE — mutating a row
|
|
272
367
|
// here would pollute the shared base definition. Clone per row;
|
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/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
|