dsh-wsl-desktop 0.7.2 → 0.7.3

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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Distribution discovery: which distributions exist, which one is the default, and
3
+ * which WSL version each one runs.
4
+ *
5
+ * Split out of `world.js` (C3). Everything here reaches `wsl.exe` or the Lxss
6
+ * registry and answers a question ABOUT a distribution; `world.js` keeps what
7
+ * happens INSIDE one. The two import the decoder and the child environment from
8
+ * `proc.js`, so neither imports the other.
9
+ *
10
+ * The re-exports at the bottom keep `world.js`'s public surface unchanged; that is
11
+ * a compatibility shim, not a second home for the code.
12
+ * @module dsh-wsl-desktop/wsl/distros
13
+ */
14
+
15
+ import { execFileSync } from 'node:child_process'
16
+ import { WSL_CHILD_ENV, decodeWslOutput, execFileAsync } from './proc.js'
17
+
18
+ /** Short ceiling for the registry and `wsl.exe` discovery calls. */
19
+ const DISCOVERY_TIMEOUT_MS = 10_000
20
+
21
+ /** Windows registry key holding the WSL distribution registrations. */
22
+ const LXSS_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss'
23
+
24
+ /**
25
+ * List the installed WSL distributions.
26
+ *
27
+ * The configured path is spawned here too: this call is a probe like any other,
28
+ * and a deployment that set the path because `wsl.exe` is not on PATH would get
29
+ * an empty discovery while its real spawns worked.
30
+ * @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
31
+ * @returns {Promise<string[]>} distribution names in `wsl.exe` order.
32
+ */
33
+ export async function listDistros({ wslPath = 'wsl.exe' } = {}) {
34
+ let stdout
35
+ try {
36
+ ({ stdout } = await execFileAsync(wslPath, ['-l', '-q'], {
37
+ encoding: 'buffer',
38
+ timeout: DISCOVERY_TIMEOUT_MS,
39
+ // The discovery call is a wsl.exe spawn like any other: without this its
40
+ // output is UTF-16LE and only the fallback probe reads it.
41
+ env: { ...process.env, ...WSL_CHILD_ENV },
42
+ }))
43
+ } catch (error) {
44
+ // The spawn code is the only thing that distinguishes "wsl.exe is not
45
+ // installed" (ENOENT) from a plugin fault; dropping it would turn the most
46
+ // common first-run state into `internal`. The message stays host-side.
47
+ const failure = new Error(`无法列出 WSL 发行版:${error instanceof Error ? error.message : String(error)}`, { cause: error })
48
+ if (typeof error?.code === 'string') failure.code = error.code
49
+ throw failure
50
+ }
51
+ return decodeWslOutput(stdout)
52
+ .split(/\r?\n/)
53
+ .map((line) => line.replace(/\0/g, '').trim())
54
+ .filter((line) => line.length > 0)
55
+ }
56
+
57
+ /**
58
+ * The WSL major version of one distribution, from `wsl.exe -l -v`.
59
+ *
60
+ * WSL1 is explicitly unsupported (its syscall translation layer has no mount
61
+ * namespace), but until now nothing in the product asked the host which version a
62
+ * distribution runs. The output is a table:
63
+ *
64
+ * ` NAME STATE VERSION`
65
+ * `* debian Running 2`
66
+ *
67
+ * The active marker is optional; the version is the last column. A table that
68
+ * cannot be parsed answers `null` — an unmeasured fact is not a failure.
69
+ * @param {string} distro - the distribution name.
70
+ * @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
71
+ * @returns {Promise<'1'|'2'|null>} the major version, or null when unreadable.
72
+ */
73
+ export async function wslMajorVersion(distro, { wslPath = 'wsl.exe' } = {}) {
74
+ let stdout
75
+ try {
76
+ ({ stdout } = await execFileAsync(wslPath, ['-l', '-v'], {
77
+ encoding: 'buffer',
78
+ timeout: DISCOVERY_TIMEOUT_MS,
79
+ env: { ...process.env, ...WSL_CHILD_ENV },
80
+ }))
81
+ } catch {
82
+ return null
83
+ }
84
+ return parseWslVersionTable(decodeWslOutput(stdout), distro)
85
+ }
86
+
87
+ /**
88
+ * Pick one distribution's major version out of a `wsl.exe -l -v` table.
89
+ *
90
+ * Kept pure and exported so the table's spacing variants are testable without a
91
+ * real WSL host. The active marker (`*`) is optional; the last column is VERSION.
92
+ * @param {unknown} text - the decoded table.
93
+ * @param {string} distro - the distribution name.
94
+ * @returns {'1'|'2'|null} the major version, or null when the row is absent.
95
+ */
96
+ export function parseWslVersionTable(text, distro) {
97
+ const lines = String(text ?? '').split(/\r?\n/)
98
+ for (const raw of lines) {
99
+ const line = raw.replace(/\0/g, '').trim()
100
+ if (line === '') continue
101
+ const cells = line.replace(/^\*\s*/, '').split(/\s{2,}|\t/).filter((cell) => cell !== '')
102
+ if (cells.length < 2) continue
103
+ if (cells[0] !== distro) continue
104
+ const version = cells[cells.length - 1]
105
+ return version === '1' || version === '2' ? version : null
106
+ }
107
+ return null
108
+ }
109
+
110
+ /**
111
+ * The Lxss registry values and the two REG_SZ lines the default-distribution read needs.
112
+ *
113
+ * The patterns live HERE, once, because the async and the sync reader each carried their
114
+ * own copy: a registry-format change had two homes, and only one of them was exercised by
115
+ * whichever caller the operator happened to hit.
116
+ */
117
+ const DEFAULT_DISTRIBUTION_VALUE = 'DefaultDistribution'
118
+ const DISTRIBUTION_NAME_VALUE = 'DistributionName'
119
+ const GUID_PATTERN = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/iu
120
+ const NAME_PATTERN = /DistributionName\s+REG_SZ\s+(.+)/iu
121
+
122
+ /**
123
+ * The distribution a `DistributionName` line names.
124
+ * @param {unknown} output - the registry query's stdout.
125
+ * @returns {string | undefined} the name, or undefined when the line names none.
126
+ */
127
+ function distroFromRegistryOutput(output) {
128
+ const distro = NAME_PATTERN.exec(String(output))?.[1]?.trim()
129
+ return distro === undefined || distro === '' ? undefined : distro
130
+ }
131
+
132
+ /**
133
+ * The Lxss subkey a `DefaultDistribution` line names.
134
+ * @param {unknown} output - the registry query's stdout.
135
+ * @returns {string | undefined} the GUID, or undefined when the line names none.
136
+ */
137
+ function guidFromRegistryOutput(output) {
138
+ return GUID_PATTERN.exec(String(output))?.[1]
139
+ }
140
+
141
+ /**
142
+ * Read the user's default distribution from the Lxss registry. Non-fatal:
143
+ * `undefined` when the value is absent or unreadable.
144
+ * @returns {Promise<string | undefined>} the default distribution name.
145
+ */
146
+ export async function defaultDistro() {
147
+ try {
148
+ const value = await execFileAsync('reg.exe', ['query', LXSS_KEY, '/v', DEFAULT_DISTRIBUTION_VALUE], {
149
+ timeout: DISCOVERY_TIMEOUT_MS,
150
+ })
151
+ const guid = guidFromRegistryOutput(value.stdout)
152
+ if (guid === undefined) return undefined
153
+ const name = await execFileAsync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', DISTRIBUTION_NAME_VALUE], {
154
+ timeout: DISCOVERY_TIMEOUT_MS,
155
+ })
156
+ return distroFromRegistryOutput(name.stdout)
157
+ } catch {
158
+ // A missing or unreadable registry value leaves the caller its own fallback.
159
+ return undefined
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Synchronous default-distribution read for plan steps that cannot await.
165
+ *
166
+ * Cached after the first read: a shell plan is built synchronously, and the
167
+ * registry does not change while the process runs.
168
+ * @returns {string | undefined} the default distribution name.
169
+ */
170
+ let syncDefaultResolved = false
171
+ let syncDefault
172
+ export function defaultDistroSync() {
173
+ if (syncDefaultResolved) return syncDefault
174
+ syncDefaultResolved = true
175
+ try {
176
+ const value = execFileSync('reg.exe', ['query', LXSS_KEY, '/v', DEFAULT_DISTRIBUTION_VALUE], {
177
+ timeout: DISCOVERY_TIMEOUT_MS,
178
+ })
179
+ const guid = guidFromRegistryOutput(value)
180
+ if (guid === undefined) return undefined
181
+ const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', DISTRIBUTION_NAME_VALUE], {
182
+ timeout: DISCOVERY_TIMEOUT_MS,
183
+ })
184
+ syncDefault = distroFromRegistryOutput(name)
185
+ } catch {
186
+ // An unreadable registry leaves the caller its own fail-loud path.
187
+ syncDefault = undefined
188
+ }
189
+ return syncDefault
190
+ }
package/lib/wsl/preset.js CHANGED
@@ -57,6 +57,13 @@ export const WORLD_MODULE_NAMES = new Set([
57
57
  '@deepseek-ai/dsh-tool-pwsh-persistent',
58
58
  '@deepseek-ai/dsh-terminal-bash',
59
59
  '@deepseek-ai/dsh-terminal',
60
+ // Added when harness 0.2.1-rc.3 shipped it (measured: a `wsl-ptc` preset row
61
+ // survived unclassified). It is a HOST-execution row: it injects `ptcRuntime` and
62
+ // runs programs through `runtime.run(...)`, and this plugin provides no WSL
63
+ // `ptcRuntime` implementation — the host's own (`dsh-ptc-runtime-node`) would run
64
+ // the program on WINDOWS inside a WSL session. That is the leak this set exists to
65
+ // close, so it is removed rather than kept.
66
+ '@deepseek-ai/dsh-tool-executor-ptc',
60
67
  ])
61
68
 
62
69
  /**
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Process-level helpers shared by the two halves of the WSL seam.
3
+ *
4
+ * `world.js` is split into "how a distribution is discovered" and "how a command
5
+ * runs in it", but both halves decode `wsl.exe` output and validate distro names,
6
+ * and the suites import the decoder from `world.js` directly. Putting those two
7
+ * in either half would make the halves import each other; they live here instead,
8
+ * and `world.js` re-exports the decoder so its public surface does not change.
9
+ *
10
+ * Extracted verbatim from `lib/wsl/world.js` (C3).
11
+ * @module dsh-wsl-desktop/wsl/proc
12
+ */
13
+
14
+ import { execFile } from 'node:child_process'
15
+ import { promisify } from 'node:util'
16
+ import { DISTRO_NAME } from './paths.js'
17
+
18
+ /** Promise-returning `execFile`, used by every discovery call. */
19
+ export const execFileAsync = promisify(execFile)
20
+
21
+ /**
22
+ * Refuse option values that cannot be safe single argv tokens for wsl.exe
23
+ * (-d/-u), independent of wsl.exe's external tokenization rules.
24
+ * @param {string} distro - the distribution name about to be spawned with.
25
+ */
26
+ export function assertDistroName(distro) {
27
+ if (typeof distro !== 'string' || !DISTRO_NAME.test(distro)) {
28
+ throw new Error(`wsl: 非法的发行版名 ${JSON.stringify(distro ?? null)}`)
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Decode `wsl.exe` output.
34
+ *
35
+ * Every `wsl.exe` this plugin spawns is asked for UTF-8 (`WSL_UTF8`, see
36
+ * {@link ENV_OVERRIDES}), so this is normally a plain UTF-8 decode — including the
37
+ * diagnostics wsl.exe prints for its OWN failures, which are UTF-16LE without that
38
+ * variable and were the whole reason this decoder used to guess between encodings.
39
+ *
40
+ * The fallback covers two things a WSL build that ignores the variable can send:
41
+ * an explicit UTF-16LE BOM, and UTF-16LE whose code units are below U+0100 (their
42
+ * high bytes are NUL — Latin-1 counts, not only ASCII).
43
+ *
44
+ * NUL-framed DATA is the awkward case, and a RATIO alone cannot settle it:
45
+ * measured, `find . -print0` scores 0.500, the real Chinese diagnostic 0.636, and
46
+ * `ls --zero` with single-character names reaches 1.000 — where the bytes ARE
47
+ * `Buffer.from('abc', 'utf16le')`, byte for byte. Hence the layering above: a
48
+ * partial interleave must also fail UTF-8 validity, which drops the framing
49
+ * payloads (valid UTF-8) and keeps the diagnostic (invalid); an all-odd
50
+ * interleave is honoured regardless, because that is the shape `listDistros`
51
+ * depends on when a build ignores the switch.
52
+ *
53
+ * The irreducible residue is exactly that byte-identical pair: no test can
54
+ * separate two readings of the same bytes. A consumer whose protocol frames with
55
+ * NUL must therefore opt out with `raw` — `listLinuxDir` does, and `execInWsl`,
56
+ * the only consumer of ARBITRARY command output, takes `raw` for the same reason.
57
+ *
58
+ * Three attempts at a smarter heuristic (interleave ratios, replacement-character
59
+ * densities, UTF-8 validity) each traded one failure class for another: every
60
+ * listing empty, then a >1 MiB CJK stream mojibake, then valid buffers flipped.
61
+ * Guessing between encodings was the wrong shape; asking for UTF-8 removed the
62
+ * need to guess, and this fallback covers the builds that predate the variable.
63
+ * @param {Buffer} buffer - raw captured stream.
64
+ * @returns {string} the decoded text.
65
+ */
66
+ export function decodeWslOutput(buffer) {
67
+ return looksUtf16le(buffer) ? buffer.toString('utf16le') : buffer.toString('utf8')
68
+ }
69
+
70
+ /**
71
+ * Whether a buffer is UTF-16LE whose code units are mostly below U+0100 — the
72
+ * fallback shape.
73
+ *
74
+ * The class counted is NOT "ASCII": the pick is `oddNuls * 2 >= pairs`, i.e. at
75
+ * least half the CODE UNITS must have a NUL high byte, and EVERY code unit below
76
+ * U+0100 encodes with one — Latin-1 included. Measured: a UTF-16LE buffer of
77
+ * U+00E9 and U+00FF (e-acute, y-diaeresis) is counted exactly like ASCII and
78
+ * decodes as UTF-16LE. For a pure-ASCII text the two readings coincide, which is
79
+ * why this used to be described as an "ASCII share"; for a Latin-1 text they do
80
+ * not, and the rate below is the binomial tail of the share of code units BELOW
81
+ * U+0100 — 100% for a text entirely below it, about 60% at half, about 8% at 35%
82
+ * (two independent generators measured 62.2%/52.8% and 8.0%/6.8% on those same
83
+ * anchors, which is the point: quote the mechanism, not a rate). The real wsl.exe
84
+ * diagnostics captured here happen to be majority-ASCII (0.636-0.662) and are
85
+ * caught; a message that is mostly CJK is not, and no threshold fixes that
86
+ * without also admitting NUL-framed data. That is why the switch is requested at
87
+ * the source instead.
88
+ *
89
+ * The test is the INTERLEAVED NUL pattern, not "contains a NUL": a Linux-side
90
+ * producer may emit NUL bytes as DATA (`printf 'a\\0b'`), and a bare NUL test
91
+ * decoded that as UTF-16LE and silently dropped the byte after it. Three code
92
+ * units minimum, or the pattern is a coincidence — `'a\\0b'` is one pair and
93
+ * satisfies any ratio.
94
+ *
95
+ * This deliberately does NOT try to recognise UTF-16LE whose code units are
96
+ * ABOVE U+00FF (there the high byte is not NUL, so nothing distinguishes it from
97
+ * text) — with ONE exception, the explicit BOM below, which is a declaration
98
+ * rather than a statistic. Everything else is handled at the SOURCE instead: every spawn asks
99
+ * wsl.exe for UTF-8 (WSL_UTF8), so a Chinese-locale diagnostic arrives as UTF-8
100
+ * rather than as UTF-16LE whose only distinguishing feature is a
101
+ * replacement-character count. Three heuristics were written for that case and
102
+ * each traded one failure class for another.
103
+ * @param {Buffer} buffer - raw captured stream.
104
+ * @returns {boolean} true when the buffer looks like ASCII-heavy UTF-16LE.
105
+ */
106
+ export function looksUtf16le(buffer) {
107
+ // An explicit BOM is a DECLARATION, not a guess: a file saved by a Windows
108
+ // editor starts FF FE, and decoding it as UTF-8 is mojibake for certain. It is
109
+ // checked BEFORE the parity guard on purpose, so a truncated BOM'd buffer
110
+ // (FF FE 41) is still read as UTF-16LE and loses its trailing byte, rather than
111
+ // being read as UTF-8 — the declaration wins over the shape.
112
+ if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) return true
113
+ // A UTF-16LE stream is always an EVEN number of bytes, so an odd-length buffer
114
+ // cannot be one — and `toString('utf16le')` would silently DROP the trailing byte
115
+ // (measured: `printf 'a\\0b\\0c\\0d'` is 7 bytes and decoded to "abc").
116
+ if (buffer.length % 2 !== 0) return false
117
+ const pairs = Math.floor(buffer.length / 2)
118
+ if (pairs < 3) return false
119
+ let oddNuls = 0
120
+ let evenNuls = 0
121
+ for (let index = 0; index < pairs * 2; index += 2) {
122
+ if (buffer[index] === 0) evenNuls += 1
123
+ if (buffer[index + 1] === 0) oddNuls += 1
124
+ }
125
+ // The predicate is EXACTLY `oddNuls > evenNuls && oddNuls * 2 >= pairs`. An
126
+ // earlier version of this comment called it "a majority of the odd positions and
127
+ // nothing else", which was wrong twice: the first conjunct is real — a buffer
128
+ // whose code units are mostly U+xx00 has as many even NULs as odd ones and is
129
+ // rejected, e.g. Buffer.from([0,0,0,0,0,1,0,2,0x61,0,0x62,0]) — and where
130
+ // evenNuls is 0 it fires at EXACTLY one half, not a majority.
131
+ //
132
+ // The populations OVERLAP, so this is a heuristic and not a proof — measured:
133
+ // real UTF-16LE diagnostics 0.636 - 0.662 -> fires, correctly
134
+ // real 'wsl.exe -l -q' UTF-16LE 1.000 -> fires (listDistros needs it)
135
+ // real 'find . -print0', long names 0.026 - 0.415 -> does not fire, correctly
136
+ // one-char entries './a\0./b\0./c\0' 0.500 EXACTLY -> FIRES, WRONGLY
137
+ // 'ls --zero', one-char names 1.000 -> fires; byte-identical
138
+ // to UTF-16LE, inherent
139
+ // (A real capture scored 0.529 on that one-char shape only because UTF-16LE text
140
+ // was mixed into the stream; the pure listing is 0.5000 and still fires.)
141
+ // That shape is the one that breaks: it puts a NUL on most odd byte positions, and
142
+ // no threshold separates it from a diagnostic at 0.636 without dropping the
143
+ // diagnostics that score below it. Three refinements
144
+ // were tried and ALL were falsified, so none is kept:
145
+ // - a 0.75 threshold excluded the real diagnostics themselves;
146
+ // - "a partial interleave must also fail UTF-8 validity" excluded UTF-16LE whose
147
+ // bytes ARE valid UTF-8 (Buffer.from('abc不','utf16le')) while still admitting
148
+ // NUL-framed bytes that are not (printf 'a\\0b\\0c\\xff');
149
+ // - a 0.6 threshold fits exactly the sample above — which is the sample it was
150
+ // measured on, while a diagnostic that is mostly non-ASCII scores lower.
151
+ // A ratio cannot prove an encoding. The OPT-OUTS can, and they are the answer:
152
+ // listLinuxDir and execInWsl(raw:true) never reach this test at all.
153
+ return oddNuls > evenNuls && oddNuls * 2 >= pairs
154
+ }
155
+
156
+ /**
157
+ * The environment every `wsl.exe` child must be started with, wherever it is
158
+ * spawned from.
159
+ *
160
+ * `WSL_UTF8` is load-bearing, not cosmetic: wsl.exe reads it from its OWN
161
+ * environment to decide the encoding of its diagnostics. Without it a missing
162
+ * distribution emits 258 bytes of UTF-16LE, which is what forced the decoder to
163
+ * guess between encodings — and every guess traded one failure class for another.
164
+ * With it the same message arrives as 223 bytes of UTF-8.
165
+ *
166
+ * It lives here, as one exported constant, because there are four spawn sites
167
+ * (the probe path, the shell executor, the subprocess provider's spawn and its
168
+ * terminal allocation) and a site that forgets it silently gets unreadable
169
+ * messages again. Linux-side output is unaffected; only wsl.exe's own text changes.
170
+ */
171
+ export const WSL_CHILD_ENV = { WSL_UTF8: '1' }
172
+
173
+ /**
174
+ * Environment overrides that keep command output model-readable.
175
+ *
176
+ * ONE owner, shared with the shell executor (which spreads it into every
177
+ * command it runs) — an override added to one copy and not the other used to be
178
+ * a silent divergence two modules could not see. Frozen so a shared object stays
179
+ * shared on purpose: an accidental write now throws instead of leaking into the
180
+ * other consumer's environment.
181
+ */
182
+ export const ENV_OVERRIDES = Object.freeze({ ...WSL_CHILD_ENV, NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' })
package/lib/wsl/shell.js CHANGED
@@ -41,7 +41,8 @@ import {
41
41
  workspaceRootInLinux,
42
42
  } from './confinement.js'
43
43
  import { parseWslUnc, shellQuote, windowsToMntPath } from './paths.js'
44
- import { ENV_OVERRIDES, planWsl, runWslShell, withWslEnvFlags } from './world.js'
44
+ import { planWsl, runWslShell, withWslEnvFlags } from './world.js'
45
+ import { ENV_OVERRIDES } from './proc.js'
45
46
 
46
47
  /** Default foreground timeout in milliseconds. */
47
48
  const DEFAULT_TIMEOUT_MS = 120_000
@@ -30,7 +30,8 @@ import z from '@deepseek-ai/schemastery'
30
30
  import { requireLocalSubprocess } from './host-refs.js'
31
31
  import { shellQuote } from './paths.js'
32
32
  import { describeArgv, spawnWslTerminal, termLog } from './pty.js'
33
- import { LoginShellUnresolvedError, WSL_CHILD_ENV, buildWslExecArgv, planWsl, resolveLoginShell, runWslShell, withWslEnvFlags } from './world.js'
33
+ import { LoginShellUnresolvedError, buildWslExecArgv, planWsl, resolveLoginShell, runWslShell, withWslEnvFlags } from './world.js'
34
+ import { WSL_CHILD_ENV } from './proc.js'
34
35
 
35
36
  /**
36
37
  * The executable lookup's ceiling, on the documented probe policy.