dsh-wsl-desktop 0.7.2 → 0.7.4

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.
@@ -776,6 +776,22 @@ export function buildConfinedCommand({ command, linuxCwd, mode, workspaceLinuxRo
776
776
  }
777
777
  const script = buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs })
778
778
  const flags = ['--mount', '--propagation', 'private']
779
- if (isolateProcesses) flags.push('--pid', '--fork')
780
- return `sudo -n unshare ${flags.join(' ')} bash -c ${shellQuote(script)}`
779
+ // A PID namespace re-parents the tree but the inherited procfs still lists the PARENT's
780
+ // pids, so /proc/<outer-pid>/root names the outer mount namespace — where nothing was
781
+ // remounted read-only. `--mount-proc` is what closes that, and it is one control with
782
+ // the pid namespace, not an option beside it.
783
+ if (isolateProcesses) flags.push('--pid', '--fork', '--mount-proc')
784
+ // The helper pins PATH and runs its fence under `bash -p` (see its own comments for the
785
+ // measurements). This runner emits the same fence body, so it must carry the same
786
+ // invariants: without the pin, a caller able to pass a sudo command-line assignment
787
+ // shadows mount/findmnt/mountpoint and the postconditions pass while the mounts stay
788
+ // writable — a fence that lies. `-p` skips BASH_ENV and the function import for the
789
+ // fence body, and the three `-u` removals close the interop door (a Windows program
790
+ // launched from here can start an unfenced `wsl.exe ... bash`, where NO_NEW_PRIVS does
791
+ // not apply and the retained sudo grant re-escalates normally).
792
+ const hardened = [
793
+ 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH;',
794
+ script,
795
+ ].join('\n')
796
+ return `sudo -n unshare ${flags.join(' ')} env -u BASH_ENV -u WSL_INTEROP -u WSLENV -u WSL_DISTRO_NAME bash -p -c ${shellQuote(hardened)}`
781
797
  }
@@ -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
+ }
@@ -194,8 +194,24 @@ command -v findmnt >/dev/null 2>&1 || { echo 'dsh-wsl-sandbox: setup failed: fin
194
194
  # form is not portable and the refusal above is what the fence relies on.)
195
195
  KEEP_ENTRIES=(/tmp /dev /dev/pts /dev/mqueue /proc /sys)
196
196
  [[ -z "$workspace" ]] || KEEP_ENTRIES+=("$workspace")
197
+
198
+ # Kernel virtual filesystems are exempt as SUBTREES, not as single mount points. A fresh
199
+ # `--mount-proc` brings /proc's own nested mounts with it (measured: /proc/sys/fs/binfmt_misc
200
+ # is a separate mount record), and a nested kernel mount cannot be made read-only — it is
201
+ # not backing storage. Exact-entry matching left those children unexempt, so the
202
+ # postcondition found the first one writable and the fence refused every command.
203
+ #
204
+ # The subtree list is deliberately NARROWER than KEEP_ENTRIES. /dev and /tmp stay exact so
205
+ # their writable children keep being swept: /dev/shm is shared memory that must end up
206
+ # READONLY (the post-condition asserts it), and a workspace under /tmp is already refused
207
+ # outright. The subtree rule applies only to the two pseudo-filesystems whose children are
208
+ # kernel state by construction, and the workspace is never one of them.
209
+ KERNEL_SUBTREE=(/proc /sys)
197
210
  EXEMPT_PATTERN=$(printf '%s\n' "${KEEP_ENTRIES[@]}" | sed 's/[][\\^$.*+?(){}|]/\\&/g' | tr '\n' '|')
198
211
  EXEMPT_PATTERN="${EXEMPT_PATTERN%|}"
212
+ for kernel in "${KERNEL_SUBTREE[@]}"; do
213
+ EXEMPT_PATTERN="${EXEMPT_PATTERN}|$(printf '%s' "$kernel" | sed 's/[][\\^$.*+?(){}|]/\\&/g')/.*"
214
+ done
199
215
 
200
216
  # The fence script is built HERE from the validated parameters — the caller
201
217
  # never supplies script text. It is identical in effect to the in-process
@@ -228,12 +244,28 @@ exec setpriv --no-new-privs --reuid="$DROP_UID" --regid="$DROP_GID" --init-group
228
244
  PID_FLAG=''
229
245
  [[ "$pidns" = 1 ]] && PID_FLAG='--pid --fork'
230
246
 
247
+ # A PID namespace alone does NOT hide the outer processes: it re-parents this tree, but
248
+ # the procfs it inherits still lists the PARENT's pids, so /proc/<outer-pid>/root keeps
249
+ # naming the outer mount namespace — where / and /mnt/c were never remounted read-only,
250
+ # because that remount is private to this fence. `--mount-proc` mounts a FRESH procfs over
251
+ # /proc, so a magic link can only ever name a process inside this namespace. The two
252
+ # halves are one control, which is why the flag is tied to the pid namespace and not
253
+ # offered separately: a fresh procfs without a pid namespace would still list outer pids.
254
+ PROC_FLAG=''
255
+ [[ "$pidns" = 1 ]] && PROC_FLAG='--mount-proc'
256
+
257
+ # WSL interop is a door OUT of the fence, and NO_NEW_PRIVS does not survive it: a confined
258
+ # command can launch a WINDOWS program, and that program can start a fresh
259
+ # `wsl.exe -d <distro> -e bash` — a new Linux process in the INITIAL mount namespace
260
+ # carrying no NNP, free to use the passwordless sudo grant this runner itself requires.
261
+ # The door is opened by environment variables, so they are removed on the one invocation
262
+ # every root-phase descendant hangs off. /mnt/c stays mounted read-only under the fence
263
+ # and interop was the way around that write fence, not a way through it.
264
+ #
231
265
  # -p stops OUR bash from processing BASH_ENV, but it does NOT remove the variable from
232
266
  # the environment, and every descendant inherits it. The drop-side `bash -lc
233
267
  # "$DROP_COMMAND"` is NOT privileged and does process it (measured: it sourced the
234
- # caller's file as the session user, inside the fence). Drop it HERE, on the one
235
- # invocation every root-phase descendant hangs off, so no descendant can be made to
236
- # source a caller's file.
268
+ # caller's file as the session user, inside the fence). Drop it HERE.
237
269
  #
238
270
  # -p on THIS file's bash also stops the import of functions from the environment
239
271
  # into THIS shell only. The fence body is a separate `bash -c`, so without -p there a
@@ -243,7 +275,8 @@ PID_FLAG=''
243
275
  # therefore launched with -p as well: the same privileged-mode rule that skips
244
276
  # BASH_ENV skips the function import, so a coordinated override becomes a no-op
245
277
  # instead of a fence that lies.
246
- exec unshare --mount --propagation private $PID_FLAG env -u BASH_ENV \
278
+ exec unshare --mount --propagation private $PID_FLAG $PROC_FLAG \
279
+ env -u BASH_ENV -u WSL_INTEROP -u WSLENV -u WSL_DISTRO_NAME \
247
280
  DROP_UID="$uid" DROP_GID="$gid" DROP_HOME="$home" DROP_USER="${SUDO_USER:-root}" \
248
281
  DROP_CWD="$cwd" DROP_WORKSPACE="$workspace" DROP_COMMAND="${ARGS[*]}" \
249
282
  DROP_EXEMPT="$EXEMPT_PATTERN" \
package/lib/wsl/fs.js CHANGED
@@ -162,7 +162,39 @@ export class WslFileSystem extends LocalFileSystem {
162
162
  // the share refuses that one step transiently (see ./publish.js for the
163
163
  // measurement). The publication is waited out rather than reported as a
164
164
  // failure the caller cannot tell apart from a real one.
165
+ //
166
+ // The containment re-test lives HERE, next to the syscall, because that is the only
167
+ // place it can see a component swapped after `checkedTarget`: the fence's own
168
+ // documentation records that a rename through such a component lands the file AT THE
169
+ // LINK'S TARGET, so the window is a content escape, not just an out-of-bounds
170
+ // directory. Refusing here reports the same structured denial the entry check uses.
165
171
  replaceFile: async (replaced, replacement) => {
172
+ // Resolve the policy HERE rather than carrying a snapshot. The primitive's signature
173
+ // is `(replaced: string, replacement: string)`, so no per-call context can reach it,
174
+ // and an instance field would be shared by every concurrent write — task B's roots
175
+ // would judge task A's publication. Resolving at the syscall is also the sounder
176
+ // semantic: the re-test answers "is this path inside a writable root RIGHT NOW",
177
+ // and a policy that changed since the entry check should narrow the write, not be
178
+ // ignored in favour of a stale grant.
179
+ const policy = this.ctx.sandboxPolicy.resolve()
180
+ if (policy.mode !== 'danger-full-access') {
181
+ // `replaced` is a HOST spelling (a UNC or a drive path), so its parent is a win32
182
+ // concern, not a POSIX one.
183
+ const parent = await this.resolve(win32.dirname(replaced))
184
+ let contained = false
185
+ for (const root of this.writableHostRoots(policy)) {
186
+ if (await isUnderHost(parent.targetKey, root)) {
187
+ contained = true
188
+ break
189
+ }
190
+ }
191
+ if (!contained) {
192
+ throw new FsError(
193
+ `cannot write "${replaced}": the destination left the writable roots between the check and the publication`,
194
+ 'FS_SANDBOX_DENIED',
195
+ )
196
+ }
197
+ }
166
198
  await publishReplace(replaced, replacement)
167
199
  },
168
200
  // The share exposes no settable DACL, and staging is already private.
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' })
@@ -51,10 +51,17 @@ export const Config = z.object({
51
51
  /**
52
52
  * Add a PID namespace to the fence. OFF for this provider's consumers on purpose: the
53
53
  * argv it wraps is an INTERACTIVE shell on a PTY, and a PID namespace re-parents that
54
- * shell to PID 1 with a process group the PTY's own foreground queries
55
- * (`terminal-bridge.py`'s `tcgetpgrp`) can no longer address — the reason the shipped
56
- * helper carries `--no-pidns` at all. The shell executor's one-shot commands keep the
57
- * namespace (its own default); this is the terminal path only.
54
+ * shell to PID 1, which the PTY path cannot live with. Re-measured while closing the
55
+ * /proc escape: `tcgetpgrp` alone DOES still resolve under `--pid --fork --mount-proc`
56
+ * (TPGID names the shell's own pid), but driving the real `terminal-bridge.py` through
57
+ * the same namespace HANGS — it never completes its start, so the terminal is dead.
58
+ * The one-shot shell executor keeps the namespace (its own default); this is the
59
+ * terminal path only.
60
+ *
61
+ * The /proc consequence is accepted HERE and closed on the one-shot path: with the
62
+ * namespace off, a fresh procfs would still list the outer pids, so this arm carries no
63
+ * `--mount-proc` either — mounting one would buy nothing. What the terminal path cannot
64
+ * have, `shell.js` and the direct runner do have.
58
65
  */
59
66
  isolateProcesses: z.boolean().default(false),
60
67
  })
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.