dsh-wsl-desktop 0.3.0 → 0.3.1
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/wsl/confinement.js +44 -4
- package/lib/wsl/pty.js +25 -7
- package/lib/wsl/shell.js +17 -5
- package/lib/wsl/world.js +45 -11
- package/package.json +1 -1
package/lib/wsl/confinement.js
CHANGED
|
@@ -135,16 +135,32 @@ export async function resolveIdentity({ distro, username, run }) {
|
|
|
135
135
|
* answered yes, i.e. it would have selected a file the model owns.
|
|
136
136
|
*
|
|
137
137
|
* The gate therefore requires, before the version is even asked for:
|
|
138
|
-
* -
|
|
139
|
-
*
|
|
140
|
-
*
|
|
138
|
+
* - a REGULAR FILE at the path ('[ -f ]'), because 'test -x' alone also accepts
|
|
139
|
+
* a directory — measured: a drwxr-xr-x root:root directory satisfied every
|
|
140
|
+
* other test and the ownership predicate answered yes for it. Only the version
|
|
141
|
+
* half refused it, and only because sudo cannot exec a directory;
|
|
142
|
+
* - NOT a SYMLINK ('[ -L ]' refuses; it is not resolved). Which tests resolve a
|
|
143
|
+
* link is not uniform, and that asymmetry is the reason for the explicit test:
|
|
144
|
+
* 'test -x' and '[ -w ]' follow the link, while 'stat -c %u/%a' do NOT follow
|
|
145
|
+
* it. A link is therefore refused today as collateral (stat reports the link's
|
|
146
|
+
* own fixed 0777 mode, which fails the write-bit test) with nothing in the
|
|
147
|
+
* script stating that this is intended. Resolving instead would demand
|
|
148
|
+
* checking BOTH chains — the link's own directory is decisive, since
|
|
149
|
+
* re-pointing it at any root-owned binary gives the caller that binary as
|
|
150
|
+
* root under an argument-wildcarded grant — so refusal stays the choice;
|
|
151
|
+
* - the INVOKING user's execute bit ('test -x'), so a 0700 root:root helper is
|
|
152
|
+
* not selected; that is fail-closed and deliberately unchanged here;
|
|
141
153
|
* - owner uid 0 ('stat -c %u'), so a session-user-owned file can never be the
|
|
142
154
|
* grant's target;
|
|
143
155
|
* - no group or other WRITE bit ('stat -c %a' & 022 == 0). The '-w' test alone
|
|
144
156
|
* would miss a mode a later group membership could widen; the mode bits alone
|
|
145
157
|
* would miss an ACL. Both are checked;
|
|
146
158
|
* - not writable by the invoking user ('[ -w ]', which also sees ACLs);
|
|
147
|
-
* - no writable ancestor up to '/'.
|
|
159
|
+
* - no writable ancestor up to '/'. Measured for the resolved case too: with the
|
|
160
|
+
* helper reached through a symlinked ANCESTOR (/opt/.../linkdir ->
|
|
161
|
+
* /home/<user>/...), '[ -w ]' follows that component and answers no, so the
|
|
162
|
+
* ancestor half has no lexical-vs-resolved gap — the walk tests each lexical
|
|
163
|
+
* component, and each test sees through it.
|
|
148
164
|
*
|
|
149
165
|
* Deliberately NOT a literal '0:755': a stricter mode must never be refused for
|
|
150
166
|
* being stricter, and what has to be pinned is replaceability. The '-w' test
|
|
@@ -163,6 +179,30 @@ function helperGateScript(helperPath) {
|
|
|
163
179
|
return [
|
|
164
180
|
'f=' + target,
|
|
165
181
|
'ok=yes',
|
|
182
|
+
// TYPE FIRST, and by an explicit test rather than by 'test -x': a root:root
|
|
183
|
+
// 0755 DIRECTORY satisfies -x, uid 0, no write bits and every ancestor test,
|
|
184
|
+
// so the ownership predicate answered yes for a directory — measured, as
|
|
185
|
+
// zcluo, with /opt/.../dir-helper (drwxr-xr-x root root). Only the version
|
|
186
|
+
// half refused it, and only because sudo cannot exec a directory. The gate
|
|
187
|
+
// claims to require an executable FILE; this is where it measures that.
|
|
188
|
+
'[ -f "$f" ] || ok=no',
|
|
189
|
+
// A symlink is REFUSED, not resolved. The grant names a PATH, so the link
|
|
190
|
+
// and its target are both part of the trust decision: resolving would have
|
|
191
|
+
// to check the link's own directory too (re-pointing it at any other
|
|
192
|
+
// root-owned binary such as /bin/bash is enough to run the caller's argv as
|
|
193
|
+
// root under an argument-wildcarded grant), while the ancestor walk below
|
|
194
|
+
// can only walk one of the two chains. Refusal is simpler, fail-closed, and
|
|
195
|
+
// costs only the hardened runner: the probe falls back to the direct
|
|
196
|
+
// sudo-unshare runner, and the README's install is a real file copy.
|
|
197
|
+
// Today such a path is refused already — but only as collateral, because
|
|
198
|
+
// 'stat' does NOT follow a link and reports the link's own fixed 0777 mode,
|
|
199
|
+
// which fails the write-bit test below. Nothing in the script says so, so
|
|
200
|
+
// any later edit that made the stat calls follow (-L) would silently turn
|
|
201
|
+
// that accident into an accepted, replaceable helper. This makes the
|
|
202
|
+
// refusal the measured intent instead of a side effect.
|
|
203
|
+
'[ -L "$f" ] && ok=no',
|
|
204
|
+
// The invoking user's execute bit (this follows the link, hence the tests
|
|
205
|
+
// above): a 0700 root:root helper is not selected — fail-closed, unchanged.
|
|
166
206
|
'test -x "$f" || ok=no',
|
|
167
207
|
'u=$(stat -c %u "$f" 2>/dev/null) || u=',
|
|
168
208
|
'm=$(stat -c %a "$f" 2>/dev/null) || m=',
|
package/lib/wsl/pty.js
CHANGED
|
@@ -179,7 +179,8 @@ async function requireBridgeRuntime(plan, options) {
|
|
|
179
179
|
* Hence a host-side rm -f, which is the only cleanup that survives a SIGKILL.
|
|
180
180
|
* Best effort and bounded (a distribution that is going down must not hang a
|
|
181
181
|
* teardown) and idempotent (safe on every path that ends a session). The cost is
|
|
182
|
-
* one
|
|
182
|
+
* one wsl.exe spawn per closed session, memoized in cleanupFifo() so the two end
|
|
183
|
+
* paths cannot each pay it; the alternative — inferring from
|
|
183
184
|
* the session outcome whether the bridge's finally ran — is not portable: on
|
|
184
185
|
* Windows a killed process reports no signal, and a non-zero exit code is also
|
|
185
186
|
* the ordinary shape of a session whose command failed.
|
|
@@ -241,6 +242,23 @@ export async function spawnWslTerminal({
|
|
|
241
242
|
let size = boundSize(cols, rows, { cols: DEFAULT_COLS, rows: DEFAULT_ROWS })
|
|
242
243
|
const fifo = `/tmp/dsh-pty-${randomUUID().slice(0, 12)}.fifo`
|
|
243
244
|
const select = { ...(wslPath !== undefined ? { wslPath } : {}), ...(username !== undefined ? { username } : {}) }
|
|
245
|
+
/**
|
|
246
|
+
* The session's ONE host-side FIFO removal, shared by every path that can end it.
|
|
247
|
+
*
|
|
248
|
+
* Both end paths reach here — the data-done handler (a bridge that exited or
|
|
249
|
+
* was killed) and terminate()/the failed-allocation catch — and each used to
|
|
250
|
+
* call removeFifo itself. Measured with a call counter across a full
|
|
251
|
+
* verify-pty-handle run: EVERY one of the five sessions it drives called
|
|
252
|
+
* removeFifo exactly TWICE, so "one extra wsl.exe spawn per closed session"
|
|
253
|
+
* was wrong by a factor of two, on the killed and failed paths as much as on
|
|
254
|
+
* terminate. Memoizing the promise makes the first caller the owner and every
|
|
255
|
+
* other caller await the same settlement: one rm per session, no end path left
|
|
256
|
+
* uncovered, and a path that throws before its own call still gets the
|
|
257
|
+
* handler's.
|
|
258
|
+
* @returns {Promise<void>} settlement of the single removal.
|
|
259
|
+
*/
|
|
260
|
+
let fifoCleanup
|
|
261
|
+
const cleanupFifo = () => (fifoCleanup ??= removeFifo(plan, fifo, select))
|
|
244
262
|
termLog(`allocate: distro=${plan.distro} linuxCwd=${JSON.stringify(plan.linuxCwd)} windowsCwd=${JSON.stringify(plan.windowsCwd)} argv0=${JSON.stringify(argv[0])} cols=${String(size.cols)} rows=${String(size.rows)} fifo=${fifo}`)
|
|
245
263
|
await requireBridgeRuntime(plan, select)
|
|
246
264
|
termLog('allocate: bridge runtime ok')
|
|
@@ -338,9 +356,9 @@ export async function spawnWslTerminal({
|
|
|
338
356
|
// distribution-side bash, holding the FIFO.
|
|
339
357
|
control?.terminate()
|
|
340
358
|
// The bridge is gone, so its own finally either ran or was preempted by a
|
|
341
|
-
// kill — and only this side can answer that by looking.
|
|
342
|
-
// session end
|
|
343
|
-
void
|
|
359
|
+
// kill — and only this side can answer that by looking. Reaches the shared
|
|
360
|
+
// removal on every session end, which is what covers the killed case.
|
|
361
|
+
void cleanupFifo()
|
|
344
362
|
},
|
|
345
363
|
(error) => {
|
|
346
364
|
bridgeDown = true
|
|
@@ -351,7 +369,7 @@ export async function spawnWslTerminal({
|
|
|
351
369
|
// Same host-side cleanup as the exited-bridge branch above: a FAILED data
|
|
352
370
|
// process is the case where the bridge's own finally is least likely to
|
|
353
371
|
// have run.
|
|
354
|
-
void
|
|
372
|
+
void cleanupFifo()
|
|
355
373
|
},
|
|
356
374
|
)
|
|
357
375
|
|
|
@@ -466,7 +484,7 @@ export async function spawnWslTerminal({
|
|
|
466
484
|
await Promise.allSettled([data.waitForExit?.() ?? waitForHandle(data), waitForHandle(control)])
|
|
467
485
|
// AFTER both processes have settled, from the host side: the bridge's
|
|
468
486
|
// own finally lost this race often enough to leave orphans behind.
|
|
469
|
-
await
|
|
487
|
+
await cleanupFifo()
|
|
470
488
|
},
|
|
471
489
|
}
|
|
472
490
|
} catch (error) {
|
|
@@ -491,7 +509,7 @@ export async function spawnWslTerminal({
|
|
|
491
509
|
// A session that never came up can still have created its FIFO (the bridge
|
|
492
510
|
// makes it before it announces), so the failed-allocation path cleans up too:
|
|
493
511
|
// rm -f is a no-op when it was never created.
|
|
494
|
-
await
|
|
512
|
+
await cleanupFifo()
|
|
495
513
|
throw error
|
|
496
514
|
}
|
|
497
515
|
}
|
package/lib/wsl/shell.js
CHANGED
|
@@ -235,6 +235,14 @@ export class WslShellExecutor extends ShellExecutor {
|
|
|
235
235
|
if (refusal !== null) throw new SandboxUnavailableError(mode, refusal)
|
|
236
236
|
noNewPrivs = true
|
|
237
237
|
}
|
|
238
|
+
// What the RESULT reports is the boundary the run actually gets, not the
|
|
239
|
+
// builder input. The helper's own drop sets NO_NEW_PRIVS unconditionally
|
|
240
|
+
// (`setpriv --no-new-privs`, dsh-wsl-confine.sh), so a helper run that
|
|
241
|
+
// reported `noNewPrivs: false` understated its own boundary; the direct
|
|
242
|
+
// runner reaches this line only with a MEASURED flag (the refusal above).
|
|
243
|
+
// Both runners therefore report true, and the field means one thing again:
|
|
244
|
+
// this run's drop carried NO_NEW_PRIVS.
|
|
245
|
+
const noNewPrivsApplied = runner === RUNNER_HELPER || noNewPrivs === true
|
|
238
246
|
const workspaceLinuxRoot = workspaceRootInLinux(spec.sandboxPolicy?.workspaceRoot, plan.linuxCwd, toLinux)
|
|
239
247
|
if (workspaceLinuxRoot === null) {
|
|
240
248
|
throw new SandboxUnavailableError(mode, 'wsl-sandbox: 工作区根目录无法映射到发行版内')
|
|
@@ -258,11 +266,15 @@ export class WslShellExecutor extends ShellExecutor {
|
|
|
258
266
|
// interop — a confined command may still ask `wsl.exe` to run a Windows
|
|
259
267
|
// program that writes files; and privilege re-escalation — the session
|
|
260
268
|
// user keeps the passwordless sudo grant the confinement runner itself
|
|
261
|
-
// requires.
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
269
|
+
// requires. That route is closed on BOTH runners, which is why no
|
|
270
|
+
// reachable path here runs a confined command without the flag: the direct
|
|
271
|
+
// runner REFUSES unless --no-new-privs was MEASURED present (so its drop
|
|
272
|
+
// always carries it and sudo inside the fence fails loudly), and the
|
|
273
|
+
// helper's own drop sets it unconditionally. A distribution whose setpriv
|
|
274
|
+
// lacks the flag therefore never runs a direct-runner command at all —
|
|
275
|
+
// the refusal above is what used to be described here as "the fence can be
|
|
276
|
+
// voided via retained sudo". Reporting `full` here was wrong.
|
|
277
|
+
sandbox: { mode, denied: false, enforcement: 'partial', noNewPrivs: noNewPrivsApplied },
|
|
266
278
|
}
|
|
267
279
|
}
|
|
268
280
|
|
package/lib/wsl/world.js
CHANGED
|
@@ -514,14 +514,49 @@ export function runWslShell({ distro, linuxCwd, command, username, loginShell =
|
|
|
514
514
|
})
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
/** The documented probe ceiling: 60s, because a cold VM's first spawn can exceed 30s. */
|
|
518
|
+
const PROBE_TIMEOUT_MS = 60_000
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Ceiling for one parsed-output probe, and the retry that goes with it.
|
|
522
|
+
*
|
|
523
|
+
* README.md states this policy for exactly these probes — 探针超时 60s + 超时后一次透明
|
|
524
|
+
* 重试:桌面重启后的首个 wsl.exe 冷启动可以超过短上限 — and it names `listLinuxDir`,
|
|
525
|
+
* `checkLinuxPath` and `resolveDistroHome` in the same sentence as the probes that
|
|
526
|
+
* already had it (`resolveIdentity`, `detectRunner`, the NO_NEW_PRIVS probe, the PTY's
|
|
527
|
+
* python3 probe). These three were the only parsed-output probes still on a 30s ceiling
|
|
528
|
+
* with no retry, so a single wsl.exe stall longer than 30s threw straight out of
|
|
529
|
+
* `checkLinuxPath` — and the stall is a property of the machine, not of the path: the
|
|
530
|
+
* distribution shares one VM with every other one, and measured while that VM ran at
|
|
531
|
+
* load ~11 with its swap 95% full, this probe answered in ~300ms and then timed out at
|
|
532
|
+
* 30.4s and 30.5s on two consecutive calls. The retry is the same ruling the identity
|
|
533
|
+
* probe follows: repeat the SAME probe once, immediately, and let a persistent stall
|
|
534
|
+
* still fail loudly with the second attempt's evidence.
|
|
535
|
+
*
|
|
536
|
+
* Retrying is safe here because all three probes are READS: the listing protocol frames
|
|
537
|
+
* its own output and the check prints one of three tokens, so a repeat cannot duplicate
|
|
538
|
+
* a side effect. The trigger is deliberately `timedOut` alone and not "any bad answer":
|
|
539
|
+
* a non-zero exit from the listing script is the ordinary answer for a directory that is
|
|
540
|
+
* not there, and doubling the latency of that case would slow the interactive picker for
|
|
541
|
+
* no measured benefit.
|
|
542
|
+
* @param {(request: object) => Promise<object>} run - the probe runner.
|
|
543
|
+
* @param {object} request - the probe, without its ceiling.
|
|
544
|
+
* @returns {Promise<object>} the first conclusive attempt, or the second after a timeout.
|
|
545
|
+
*/
|
|
546
|
+
async function probeWithRetry(run, request) {
|
|
547
|
+
const first = await run({ ...request, timeoutMs: PROBE_TIMEOUT_MS })
|
|
548
|
+
if (first.timedOut !== true) return first
|
|
549
|
+
return await run({ ...request, timeoutMs: PROBE_TIMEOUT_MS })
|
|
550
|
+
}
|
|
551
|
+
|
|
517
552
|
/**
|
|
518
553
|
* List one directory inside a distribution.
|
|
519
554
|
* @param {string} distro - distribution name.
|
|
520
555
|
* @param {string} linuxPath - absolute Linux directory.
|
|
521
|
-
* @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
|
|
556
|
+
* @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution — the same seam `resolveIdentity`/`detectRunner` expose).
|
|
522
557
|
* @returns {Promise<{ path: string, parent: string | null, entries: Array<{ name: string, kind: 'directory' | 'file' }> }>} the listing.
|
|
523
558
|
*/
|
|
524
|
-
export async function listLinuxDir(distro, linuxPath, { wslPath } = {}) {
|
|
559
|
+
export async function listLinuxDir(distro, linuxPath, { wslPath, run = runWslShell } = {}) {
|
|
525
560
|
const path = linuxPath.startsWith('/') ? linuxPath : `/${linuxPath}`
|
|
526
561
|
// Entries are NUL-terminated so a filename containing a newline survives the
|
|
527
562
|
// round trip; `..?*` covers names like `..keep` that `.[!.]*` misses.
|
|
@@ -532,7 +567,7 @@ export async function listLinuxDir(distro, linuxPath, { wslPath } = {}) {
|
|
|
532
567
|
// UTF-16LE — with one-character entry names every NUL lands on exactly the
|
|
533
568
|
// byte positions UTF-16LE uses — and guessing wrong returned [] for a
|
|
534
569
|
// directory that is not empty.
|
|
535
|
-
const result = await
|
|
570
|
+
const result = await probeWithRetry(run, { distro, linuxCwd: '/', command: script, loginShell: false, raw: true, wslPath })
|
|
536
571
|
if (result.exitCode !== 0) {
|
|
537
572
|
throw new Error(`无法列出 ${path}:${result.stderr.toString('utf8').trim() || `退出码 ${String(result.exitCode)}`}`)
|
|
538
573
|
}
|
|
@@ -553,13 +588,13 @@ export async function listLinuxDir(distro, linuxPath, { wslPath } = {}) {
|
|
|
553
588
|
* Check whether one Linux path exists.
|
|
554
589
|
* @param {string} distro - distribution name.
|
|
555
590
|
* @param {string} linuxPath - absolute Linux path.
|
|
556
|
-
* @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
|
|
591
|
+
* @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution).
|
|
557
592
|
* @returns {Promise<{ exists: boolean, isDirectory: boolean }>} existence facts.
|
|
558
593
|
*/
|
|
559
|
-
export async function checkLinuxPath(distro, linuxPath, { wslPath } = {}) {
|
|
594
|
+
export async function checkLinuxPath(distro, linuxPath, { wslPath, run = runWslShell } = {}) {
|
|
560
595
|
const script = `if [ -d ${shellQuote(linuxPath)} ]; then echo dir; `
|
|
561
596
|
+ `elif [ -e ${shellQuote(linuxPath)} ]; then echo other; else echo missing; fi`
|
|
562
|
-
const result = await
|
|
597
|
+
const result = await probeWithRetry(run, { distro, linuxCwd: '/', command: script, loginShell: false, wslPath })
|
|
563
598
|
// A probe that could not RUN is not "the path is missing". When the script runs
|
|
564
599
|
// at all it exits 0 and prints exactly one of its three answers, so a non-zero
|
|
565
600
|
// code, a timeout, or an unreadable answer means the DISTRIBUTION failed
|
|
@@ -609,16 +644,16 @@ export function probeEvidence(entry) {
|
|
|
609
644
|
* exactly one path.
|
|
610
645
|
* @param {string} distro - distribution name.
|
|
611
646
|
* @param {string} [username] - user to resolve; absent resolves the default user.
|
|
612
|
-
* @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
|
|
647
|
+
* @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution).
|
|
613
648
|
* @returns {Promise<{ user: string, home: string }>} the user and their home.
|
|
614
649
|
* @throws Error when the user or a usable home cannot be resolved.
|
|
615
650
|
*/
|
|
616
|
-
export async function resolveDistroHome(distro, username, { wslPath } = {}) {
|
|
651
|
+
export async function resolveDistroHome(distro, username, { wslPath, run = runWslShell } = {}) {
|
|
617
652
|
let user = typeof username === 'string' ? username.trim() : ''
|
|
618
653
|
if (user === '') {
|
|
619
654
|
// Non-login + trimmed on purpose: the parsed answer must be provably this
|
|
620
655
|
// probe's output (the login name), never profile scripts'.
|
|
621
|
-
const who = await
|
|
656
|
+
const who = await probeWithRetry(run, { distro, linuxCwd: '/', command: 'id -un', loginShell: false, wslPath })
|
|
622
657
|
user = who.stdout.trim()
|
|
623
658
|
if (user === '' || user.includes('\n')) {
|
|
624
659
|
throw new Error(`无法确定发行版 ${distro} 的默认用户:${who.stderr.trim() || `退出码 ${String(who.exitCode)}`}`)
|
|
@@ -629,12 +664,11 @@ export async function resolveDistroHome(distro, username, { wslPath } = {}) {
|
|
|
629
664
|
// output, not by the exit code. Exactly one passwd line is the only
|
|
630
665
|
// unambiguous answer: a name getent misparses can dump the whole database,
|
|
631
666
|
// and a multi-line "home" would only fail later and confusingly at listDir.
|
|
632
|
-
const entry = await
|
|
667
|
+
const entry = await probeWithRetry(run, {
|
|
633
668
|
distro,
|
|
634
669
|
linuxCwd: '/',
|
|
635
670
|
command: `getent passwd ${shellQuote(user)} | cut -d: -f6`,
|
|
636
671
|
loginShell: false,
|
|
637
|
-
timeoutMs: 30_000,
|
|
638
672
|
wslPath,
|
|
639
673
|
})
|
|
640
674
|
const lines = entry.stdout.trim().split('\n')
|