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.
@@ -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
- * - the path holds an executable file (the pre-existing 'test -x' — which also
139
- * requires the INVOKING user's execute bit, so a 0700 root:root helper is not
140
- * selected; that is fail-closed and deliberately unchanged here);
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 extra wsl.exe spawn per closed session; the alternative — inferring from
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. Runs on every
342
- // session end (idempotent), which is what covers the killed case.
343
- void removeFifo(plan, fifo, select)
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 removeFifo(plan, fifo, select)
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 removeFifo(plan, fifo, select)
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 removeFifo(plan, fifo, select)
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. When the distro's setpriv supports NO_NEW_PRIVS the drop
262
- // sets it and sudo inside the fence fails loudly; on distros without
263
- // that flag a deliberately non-compliant command can void the file
264
- // fence via retained sudo (see README). Reporting `full` here was wrong.
265
- sandbox: { mode, denied: false, enforcement: 'partial', noNewPrivs },
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 runWslShell({ distro, linuxCwd: '/', command: script, loginShell: false, timeoutMs: 30_000, raw: true, wslPath })
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 runWslShell({ distro, linuxCwd: '/', command: script, loginShell: false, timeoutMs: 30_000, wslPath })
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 runWslShell({ distro, linuxCwd: '/', command: 'id -un', loginShell: false, timeoutMs: 30_000, wslPath })
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 runWslShell({
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')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "exports": {