dsh-wsl-desktop 0.3.2 → 0.3.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.
package/lib/client.js CHANGED
@@ -446,7 +446,7 @@ window.__ModuleLoader__.load({
446
446
  function render() {
447
447
  const body = []
448
448
 
449
- body.push(el('div', { fontSize: '15px', fontWeight: '600' }, '添加 WSL 工作区'))
449
+ body.push(el('div', { fontSize: '15px', fontWeight: '600' }, BUTTON_LABEL))
450
450
  body.push(el('div', { fontSize: '12px', color: T.dim },
451
451
  '新会话的 bash 与文件工具都会在该发行版里工作;Windows 文件可在 /mnt/<盘符> 下访问。'))
452
452
 
@@ -610,17 +610,28 @@ window.__ModuleLoader__.load({
610
610
  hostElement().append(overlay)
611
611
  render()
612
612
 
613
+ // The rejection handler only ever sees a refusal of listDistros itself; a
614
+ // throw INSIDE the continuation (a body the route did not expect,
615
+ // chooseDistro failing) would surface as an unhandled rejection —
616
+ // state.distros assigned without a render, the dialog stuck on its reading
617
+ // line, and nothing anywhere to read. One handler owns the whole chain.
613
618
  call('listDistros').then(async (names) => {
614
- if (state.closed) return
615
- state.distros = names
616
- const fallback = await call('defaultDistro').catch(() => ({ distro: null }))
617
- if (state.closed) return
618
- const selected = names.includes(fallback.distro) ? fallback.distro : names[0] ?? null
619
- if (selected === null) {
619
+ try {
620
+ if (state.closed) return
621
+ state.distros = names
622
+ const fallback = await call('defaultDistro').catch(() => ({ distro: null }))
623
+ if (state.closed) return
624
+ const selected = names.includes(fallback.distro) ? fallback.distro : names[0] ?? null
625
+ if (selected === null) {
626
+ render()
627
+ return
628
+ }
629
+ chooseDistro(selected)
630
+ } catch (failure) {
631
+ if (state.closed) return
632
+ state.error = message(failure)
620
633
  render()
621
- return
622
634
  }
623
- chooseDistro(selected)
624
635
  }, (failure) => {
625
636
  if (state.closed) return
626
637
  state.error = message(failure)
@@ -806,6 +817,30 @@ window.__ModuleLoader__.load({
806
817
  }
807
818
  }
808
819
 
820
+ /**
821
+ * Whether one observed mutation could have changed a workspace row's markup.
822
+ *
823
+ * Chat streaming mutates the transcript many times a second, and the icon scan
824
+ * is a whole-document `querySelectorAll` — running it at that rate is exactly
825
+ * the per-frame scan the trigger cache exists to avoid, and the comment on
826
+ * `sync` claimed it did not happen when it did. A row only changes when React
827
+ * writes inside one (add, remove, relabel, expand) or replaces its container,
828
+ * so a record whose target and nodes all sit outside every workspace row
829
+ * cannot have moved what the scan reads.
830
+ * @param {MutationRecord} record - one observed mutation.
831
+ * @returns {boolean} true when the icon scan has to re-run.
832
+ */
833
+ function touchesWorkspaceRow(record) {
834
+ const target = record.target
835
+ const anchor = target instanceof Element ? target : target.parentElement
836
+ if (anchor === null) return false
837
+ // Inside a row, IS a row, or IS an ancestor of the rows (the list container
838
+ // React re-renders rows into). The transcript pane satisfies none of the
839
+ // three, which is the whole point.
840
+ if (anchor.closest('[data-row-key^="workspace:"]') !== null) return true
841
+ return anchor.querySelector('[data-row-key^="workspace:"]') !== null
842
+ }
843
+
809
844
  /**
810
845
  * Re-attach the companion button when React replaced or reordered its anchor.
811
846
  *
@@ -814,8 +849,12 @@ window.__ModuleLoader__.load({
814
849
  * of feeding the observer a fresh mutation; hiding while connected is
815
850
  * expressed as `display`, which is not a childList mutation at all.
816
851
  * @param {object} ctx - the client plugin context.
852
+ * @param {boolean} [iconsDirty] - false skips the whole-document workspace-row
853
+ * scan: the observer only asks for it when a mutation actually touched a
854
+ * workspace row, so transcript churn (chat streaming) costs the cheap
855
+ * placement checks below and nothing more.
817
856
  */
818
- function sync(ctx) {
857
+ function sync(ctx, iconsDirty = true) {
819
858
  companion.scheduled = false
820
859
  // A frame queued before disposal must not run: it would re-mark the rows
821
860
  // the disposer just restored, with no observer left to converge it (see
@@ -823,7 +862,7 @@ window.__ModuleLoader__.load({
823
862
  if (companion.disposed === true) return
824
863
  // Runs before the companion's own early returns: marking workspace rows
825
864
  // is independent of whether the add-workspace trigger was found.
826
- syncWorkspaceIcons(ctx)
865
+ if (iconsDirty) syncWorkspaceIcons(ctx)
827
866
  const { button } = companion
828
867
  if (button === null) return
829
868
  // Steady-state fast path: the cached trigger stays valid while it
@@ -902,12 +941,36 @@ window.__ModuleLoader__.load({
902
941
  // Cleared on every install: the flag is set by the disposer and this
903
942
  // companion object outlives a disable/enable cycle.
904
943
  companion.disposed = false
905
- const observer = new MutationObserver(() => {
906
- if (companion.scheduled) return
944
+ // Two independent things make the icon scan necessary, so they OR into one
945
+ // coalesced frame: the DOM actually changed inside the workspace rows, or the
946
+ // workspace LIST changed under them (a re-point edits the store, and the row
947
+ // markup may not move at all). Either trigger upgrades a scheduled
948
+ // transcript-only sync to a full pass; neither adds a timer.
949
+ let pendingIcons = false
950
+ const schedule = (iconsDirty) => {
951
+ if (companion.scheduled) {
952
+ if (iconsDirty) pendingIcons = true
953
+ return
954
+ }
907
955
  companion.scheduled = true
908
- requestAnimationFrame(() => sync(ctx))
956
+ pendingIcons = iconsDirty
957
+ requestAnimationFrame(() => {
958
+ const dirty = pendingIcons
959
+ pendingIcons = false
960
+ sync(ctx, dirty)
961
+ })
962
+ }
963
+ const observer = new MutationObserver((mutations) => {
964
+ // The record list is the cheap discriminator: a transcript-only burst
965
+ // schedules a sync that skips the icon scan, while anything that touched a
966
+ // workspace row — or the container React renders rows into — schedules the
967
+ // full pass. Either way the placement checks below run as before.
968
+ schedule(mutations.some((record) => touchesWorkspaceRow(record)))
909
969
  })
910
970
  companion.observer = observer
971
+ const workspaceSource = ctx.workspaces?.list
972
+ const unsubscribe =
973
+ typeof workspaceSource?.subscribe === 'function' ? workspaceSource.subscribe(() => schedule(true)) : null
911
974
  sync(ctx)
912
975
  observer.observe(document.documentElement, { childList: true, subtree: true })
913
976
  const warning = setTimeout(() => {
@@ -920,6 +983,7 @@ window.__ModuleLoader__.load({
920
983
  return () => {
921
984
  clearTimeout(warning)
922
985
  observer.disconnect()
986
+ unsubscribe?.()
923
987
  // disconnect() does not cancel a callback already handed to
924
988
  // requestAnimationFrame, and that queued sync would re-inject the cloud
925
989
  // icons (and re-hide the shipped folder icons) after the loop below put
package/lib/index.js CHANGED
@@ -513,30 +513,32 @@ const METHODS = {
513
513
  }
514
514
  }
515
515
 
516
- // 0.1.7 moved serviceFor off the registry (now a standalone
517
- // serviceForAgent in the package). The harness's own consumers use
518
- // `agent.ctx.get(name)` — which resolves through the agent's scope chain
519
- // into the realm — so that's the compatible form on both generations.
520
- // 0.1.7 mounts preset services under the standing mount's own fiber —
521
- // `agent.ctx.get` resolves the HOST services (the mount is not in the
522
- // agent's scope chain). The harness's accessor is the exported
523
- // serviceForAgent(ctx, agent, name); resolve it dynamically (the
524
- // registry package lives in the host graph, not in this plugin's
525
- // node_modules) and fall back to ctx.get on pre-0.1.7 hosts.
526
- let realmService = null
527
- try {
528
- const registryModule = await import('@deepseek-ai/dsh-agent-preset-registry')
529
- if (typeof registryModule.serviceForAgent === 'function') {
530
- realmService = (name) => registryModule.serviceForAgent(hostCtx, agent, name)
531
- }
532
- } catch {
533
- // Registry package not resolvable from this plugin — pre-0.1.7 host.
534
- }
535
- if (realmService === null) realmService = (name) => agent.ctx.get(name)
516
+ // A realm hides its services from the outside by design: the preset mount
517
+ // owns its own fiber, so `agent.ctx.get(name)` resolves the HOST plane.
518
+ // The registry is the only thing that can look inside, and it exposes
519
+ // that as the SERVICE METHOD `agentPresets.serviceFor(agent, name)`.
520
+ //
521
+ // This used to resolve the package-level `serviceForAgent(ctx, agent,
522
+ // name)` export dynamically. DSH 0.2.1-alpha.1 REMOVED that export
523
+ // (commit 2e3ab61940, "fix(presets): own composition mounts in registry
524
+ // service"; no upgrade-guide entry covers it), and the removal was
525
+ // silent here: the typeof probe simply missed, the ctx.get fallback then
526
+ // measured the HOST shell, and every step still reported success.
527
+ // `serviceFor` performs the same mount-fiber store scan the removed
528
+ // export did, reached through the service instead of the module.
529
+ //
530
+ // The ctx.get fallback stays for genuinely pre-0.1.7 hosts, but the
531
+ // accessor that answered travels WITH each observation, so a fallback
532
+ // can never again be read as a realm measurement.
533
+ const realmAccessor = typeof presets.serviceFor === 'function' ? 'agentPresets.serviceFor' : 'agent.ctx.get'
534
+ const realmService = realmAccessor === 'agentPresets.serviceFor'
535
+ ? (name) => presets.serviceFor(agent, name)
536
+ : (name) => agent.ctx.get(name)
536
537
 
537
538
  const shell = realmService('shell')
538
539
  record('shell.service', {
539
540
  found: shell !== undefined,
541
+ accessor: realmAccessor,
540
542
  sandboxMode: shell?.sandboxMode ?? null,
541
543
  proto: Object.getOwnPropertyNames(Object.getPrototypeOf(shell ?? {})).join(','),
542
544
  executeType: typeof shell?.execute,
@@ -561,7 +563,7 @@ const METHODS = {
561
563
  }
562
564
 
563
565
  const fs = realmService('fs')
564
- record('fs.service', { found: fs !== undefined })
566
+ record('fs.service', { found: fs !== undefined, accessor: realmAccessor })
565
567
  if (fs !== undefined) {
566
568
  // A mutation is called the way the tool layer calls it: `tool-fs`
567
569
  // resolves a per-call policy and stamps the calling session's cwd as the
@@ -592,7 +594,7 @@ const METHODS = {
592
594
  }
593
595
 
594
596
  const subprocess = realmService('subprocess')
595
- record('subprocess.service', { found: subprocess !== undefined })
597
+ record('subprocess.service', { found: subprocess !== undefined, accessor: realmAccessor })
596
598
  if (subprocess !== undefined) {
597
599
  try {
598
600
  const environment = await subprocess.terminalEnvironment()
@@ -722,10 +724,16 @@ const METHODS = {
722
724
  * @param {{ distro?: string, linuxPath?: string }} params - target directory.
723
725
  * @returns {Promise<object>} what each step observed.
724
726
  */
725
- workspaceFlow: async ({ distro, linuxPath = '/tmp' }) => {
727
+ workspaceFlow: async ({ distro, linuxPath }) => {
726
728
  const registry = hostCtx.get('workspaceRegistry')
727
729
  if (registry === undefined) throw new Error('workspaceRegistry 服务不可用')
728
- const target = await resolveLocation(linuxPath, distro)
730
+ // Default the same way the selftest does — the distribution home, never /tmp:
731
+ // the fence refuses a workspace root under /tmp (confinement mounts a private
732
+ // tmpfs over it after binding the workspace), so a workspace registered there
733
+ // could never be used as a writable root. This method's old default handed the
734
+ // registry exactly such a path.
735
+ const location = linuxPath ?? (await defaultWorkspaceUnc({ wslPath: routeWslPath })).uncPath
736
+ const target = await resolveLocation(location, distro)
729
737
  const listing = await listLinuxDir(target.distro, target.linuxPath, { wslPath: routeWslPath })
730
738
  const facts = await checkLinuxPath(target.distro, target.linuxPath, { wslPath: routeWslPath })
731
739
  const existing = await registry.resolveByPath(target.uncPath)
@@ -959,7 +967,11 @@ export function apply(ctx, config) {
959
967
  hostCtx = ctx
960
968
  routeWslPath = config.wslPath
961
969
  // Bound concurrent route methods: each discovery call spawns wsl.exe, and a
962
- // misbehaving page could fan out without limit.
970
+ // misbehaving page could fan out without limit. Named because a bare "8" here
971
+ // was once mis-read as a wsl.exe SPAWN cap — it is not: one route method may
972
+ // fan out several children (a command plus its probes). The spawn cap is
973
+ // MAX_CONCURRENT_SPAWNS in lib/wsl/world.js.
974
+ const MAX_ROUTE_IN_FLIGHT = 8
963
975
  let routeInFlight = 0
964
976
  // The WSL providers start `wsl.exe`, an ordinary Windows process, through the
965
977
  // host's own subprocess provider. A realm that isolates `subprocess` shadows
@@ -1030,7 +1042,7 @@ export function apply(ctx, config) {
1030
1042
  sendRejection(res, admitted)
1031
1043
  return
1032
1044
  }
1033
- if (routeInFlight >= 8) {
1045
+ if (routeInFlight >= MAX_ROUTE_IN_FLIGHT) {
1034
1046
  sendJson(res, 503, { ok: false, code: 'too-busy' })
1035
1047
  return
1036
1048
  }
@@ -23,7 +23,7 @@ import { shellQuote } from './paths.js'
23
23
  import { fileURLToPath } from 'node:url'
24
24
  import { dirname, join } from 'node:path'
25
25
 
26
- /** Shipped helper script, installed by the operator (see README). */
26
+ /** Shipped helper script, installed by the operator (see docs/CONFINEMENT.md). */
27
27
  export const HELPER_SOURCE = join(dirname(fileURLToPath(import.meta.url)), 'dsh-wsl-confine.sh')
28
28
 
29
29
  /** The direct runner: sudo-wrapped unshare with the fence built in-process. */
@@ -32,7 +32,7 @@ export const RUNNER_SUDO_UNSHARE = 'sudo-unshare'
32
32
  /** The hardened runner: a root-owned helper that always fences before exec. */
33
33
  export const RUNNER_HELPER = 'helper'
34
34
 
35
- /** Where the operator installs the confinement helper (see README). */
35
+ /** Where the operator installs the confinement helper (see docs/CONFINEMENT.md). */
36
36
  export const HELPER_PATH = '/usr/local/sbin/dsh-wsl-confine'
37
37
 
38
38
  /**
@@ -193,7 +193,7 @@ function helperGateScript(helperPath) {
193
193
  // root under an argument-wildcarded grant), while the ancestor walk below
194
194
  // can only walk one of the two chains. Refusal is simpler, fail-closed, and
195
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.
196
+ // sudo-unshare runner, and docs/CONFINEMENT.md's install is a real file copy.
197
197
  // Today such a path is refused already — but only as collateral, because
198
198
  // 'stat' does NOT follow a link and reports the link's own fixed 0777 mode,
199
199
  // which fails the write-bit test below. Nothing in the script says so, so
@@ -421,7 +421,7 @@ export async function detectNoNewPrivs({ distro, username, run }) {
421
421
  * unconditionally (dsh-wsl-confine.sh), so on a setpriv without the flag setpriv
422
422
  * rejects the unknown option and exits 1 — the confined command simply does not
423
423
  * run, and that is an ordinary COMMAND failure, not the setup-failure marker
424
- * that reports an unavailable runner. README.md:129 records the measured exit 1;
424
+ * that reports an unavailable runner. docs/CONFINEMENT.md:33 records the measured exit 1;
425
425
  * this text previously contradicted it by recommending the helper as a remedy.
426
426
  *
427
427
  * The remedy names the PROBEABLE requirement, not a version: the util-linux release
@@ -576,6 +576,20 @@ function exemptPattern(keep) {
576
576
  return `^(${[...new Set([...keep, ...KERNEL_SURFACES])].map(escapeEre).join('|')})$`
577
577
  }
578
578
 
579
+ /**
580
+ * The shared head of the read-only sweep and the post-condition: list the
581
+ * namespace's mount targets, decode findmnt's hex escapes, drop the exempt ones
582
+ * (a failing grep is a setup failure, not a command failure), and hand the rest
583
+ * to a per-target loop. The two callers differ only in the loop BODY, so the head
584
+ * lives in one place — a decode or quoting fix cannot land in one and be missed
585
+ * in the other, which is the drift the helper exists to make impossible.
586
+ * @param {string} pattern - the exemption ERE as DATA (the callers pass
587
+ * exemptPattern(keep)'s output unquoted); this helper applies shellQuote.
588
+ * @returns {string} the pipeline up to the per-target loop.
589
+ */
590
+ function sweepHead(pattern) {
591
+ return `findmnt -rno TARGET | while IFS= read -r raw; do printf '%b\\n' "$raw"; done | { grep -Ev ${shellQuote(pattern)} || fail ${shellQuote('exemption grep failed')}; } | while IFS= read -r target; do`
592
+ }
579
593
  /**
580
594
  * The shell fragment that makes every remaining writable mount read-only.
581
595
  *
@@ -601,7 +615,7 @@ function readOnlySweep(keep) {
601
615
  // stay WRITABLE while the postcondition tested the bogus name. Decode
602
616
  // before matching: findmnt only ever emits \xNN for literal characters
603
617
  // (literal backslash arrives as \x5c), so bash's %b is lossless here.
604
- `findmnt -rno TARGET | while IFS= read -r raw; do printf '%b\\n' "$raw"; done | { grep -Ev ${shellQuote(pattern)} || fail ${shellQuote('exemption grep failed')}; } | while IFS= read -r target; do`,
618
+ sweepHead(pattern),
605
619
  ' mount -o remount,ro,bind "$target" >/dev/null 2>&1 || true;',
606
620
  'done',
607
621
  ].join('\n')
@@ -631,7 +645,7 @@ function postConditions(keep) {
631
645
  // `fail` still exits 97 from inside the loop, which pipefail turns into
632
646
  // the script's own setup failure — the marker and exit code still
633
647
  // surface.
634
- `findmnt -rno TARGET | while IFS= read -r raw; do printf '%b\\n' "$raw"; done | { grep -Ev ${shellQuote(pattern)} || fail ${shellQuote('exemption grep failed')}; } | while IFS= read -r target; do`,
648
+ sweepHead(pattern),
635
649
  ' [ -w "$target" ] && fail "$target is still writable";',
636
650
  ' true;',
637
651
  'done',
package/lib/wsl/fence.js CHANGED
@@ -36,13 +36,38 @@ export function canonicalHostPath(path) {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * Whether two spellings name DIFFERENT distributions, both of them WSL UNCs.
41
+ *
42
+ * The rule behind the boolean: the Linux portion of a WSL UNC is ambiguous across
43
+ * distributions — 9P shares are not per-filesystem, so same-spelled paths on two
44
+ * distributions can carry the same content — and the distribution segment is a
45
+ * WINDOWS spelling, which folds case, so the comparison is case-insensitive while
46
+ * the Linux portion above it stays case-sensitive. One-sided answers (a drive
47
+ * path against a UNC root, either side not a WSL UNC at all) are NOT cross-distro:
48
+ * those compare by their own namespace rules.
49
+ *
50
+ * ONE owner, because this exact test stood in `isUnderHost` and in `contains()`
51
+ * (the fence's two containment consumers) and had to be kept in sync by hand.
52
+ * @param {string} a - one spelling.
53
+ * @param {string} b - the other spelling.
54
+ * @returns {boolean} true when both parse as WSL UNCs and the distribution
55
+ * segments differ.
56
+ */
57
+ export function crossDistroUnc(a, b) {
58
+ const aUnc = parseWslUnc(a)
59
+ const bUnc = parseWslUnc(b)
60
+ return aUnc !== null && bUnc !== null
61
+ && aUnc.distro.toLowerCase() !== bUnc.distro.toLowerCase()
62
+ }
63
+
39
64
  /**
40
65
  * Lexical containment with a separator boundary. The boundary is what stops
41
66
  * `/proj` matching `/proj-secret`.
42
67
  *
43
68
  * Only the WINDOWS spellings fold. The UNC host and the distribution segment are
44
69
  * Windows spellings and compare case-insensitively; the Linux portion does not,
45
- * because the share resolves it with Linux semantics (README, "Measured fence
70
+ * because the share resolves it with Linux semantics (docs/FS-FENCE.md, "Measured fence
46
71
  * facts": `/TMP` is ENOENT where `/tmp` exists, so `PROJ` and `proj` are
47
72
  * different directories). Folding it judged a target under a case-variant
48
73
  * sibling of the writable root to be contained — and that escape needed no
@@ -203,7 +228,7 @@ export async function isUnderHost(targetKey, root) {
203
228
  // THROUGH THE TARGET'S OWN SHARE — so a foreign target's ancestors are compared
204
229
  // against a LOCAL root's identity. Inode numbers are per-filesystem, but the 9P
205
230
  // shares are not: this machine's `debian` and `debian-dev` report the SAME
206
- // (dev,ino) for /, /tmp and /home (dev 0; ino 2, 1, 16386 — README, "Measured fence
231
+ // (dev,ino) for /, /tmp and /home (dev 0; ino 2, 1, 16386 — docs/FS-FENCE.md, "Measured fence
207
232
  // facts"), so an identity collision across two distributions read as containment, and
208
233
  // a workspace-write session could be authorized to write into another distribution's
209
234
  // /tmp — which `writableHostRootsFor` ALWAYS grants. `contains()` (./fs.js) already
@@ -222,10 +247,7 @@ export async function isUnderHost(targetKey, root) {
222
247
  // `wsl.exe -d DEBIAN` names the same distribution — so it is compared
223
248
  // case-insensitively, the way `isLexicallyUnderHost` folds the prefix and
224
249
  // `contains()` compares.
225
- const targetUnc = parseWslUnc(targetKey)
226
- const rootUnc = parseWslUnc(root)
227
- if (targetUnc !== null && rootUnc !== null
228
- && targetUnc.distro.toLowerCase() !== rootUnc.distro.toLowerCase()) return false
250
+ if (crossDistroUnc(targetKey, root)) return false
229
251
  const rootInfo = await statIfPresent(root)
230
252
  if (rootInfo === undefined) return false
231
253
  let ancestor = targetKey
@@ -274,7 +296,7 @@ export function writableHostRootsFor(policy, distro) {
274
296
  // coerces to the STRING "undefined", which matches the grammar — so an absent
275
297
  // distro used to produce a root spelled \\wsl.localhost\undefined\tmp. That
276
298
  // root can never match, silently dropping the distribution's /tmp from the
277
- // allow-list (a write the README grants was refused), and it disguised the
299
+ // allow-list (a write docs/FS-FENCE.md grants was refused), and it disguised the
278
300
  // provider-side config bug that made the distro undefined in the first place.
279
301
  const distroTmp = typeof distro === 'string' && DISTRO_NAME.test(distro)
280
302
  ? joinWslUnc(distro, '/tmp')
package/lib/wsl/fs.js CHANGED
@@ -23,7 +23,7 @@ import { pathToFileURL } from 'node:url'
23
23
  import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
24
24
  import { FsError } from '@deepseek-ai/dsh-fs'
25
25
  import z from '@deepseek-ai/schemastery'
26
- import { isUnderHost, writableHostRootsFor } from './fence.js'
26
+ import { crossDistroUnc, isUnderHost, writableHostRootsFor } from './fence.js'
27
27
  import { isWindowsPathShaped, joinWslUnc, mntToWindowsPath, parseWslUnc, windowsToMntPath } from './paths.js'
28
28
  import { publishReplace } from './publish.js'
29
29
 
@@ -210,7 +210,14 @@ export class WslFileSystem extends LocalFileSystem {
210
210
  for (const root of this.writableHostRoots(policy)) {
211
211
  if (await isUnderHost(fresh.targetKey, root)) return fresh
212
212
  }
213
- throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
213
+ // Any mode that is neither granted nor refused above still fails closed —
214
+ // writableHostRootsFor grants nothing it does not recognise — but the denial
215
+ // must name the mode it actually saw: a mode arriving here that this backend
216
+ // does not implement would otherwise be triaged as a workspace-write
217
+ // containment question, with the real question (who passed this mode in)
218
+ // left unasked.
219
+ const deniedUnder = mode === 'workspace-write' ? 'workspace-write mode' : `unsupported mode '${String(mode)}'`
220
+ throw new FsError(`cannot write "${target.displayPath}": file access denied under ${deniedUnder}`, 'FS_SANDBOX_DENIED')
214
221
  }
215
222
 
216
223
  /**
@@ -316,10 +323,9 @@ export class WslFileSystem extends LocalFileSystem {
316
323
  * @returns {boolean} true when child is parent or below it.
317
324
  */
318
325
  contains(parent, child) {
319
- const parentDistro = parseWslUnc(parent.targetKey)?.distro
320
- const childDistro = parseWslUnc(child.targetKey)?.distro
321
- if (parentDistro !== undefined && childDistro !== undefined
322
- && parentDistro.toLowerCase() !== childDistro.toLowerCase()) return false
326
+ // The SAME cross-distribution rule fence.js's identity walk applies, now one
327
+ // shared helper — the two fail-closed distro checks cannot drift apart.
328
+ if (crossDistroUnc(parent.targetKey, child.targetKey)) return false
323
329
  const relative = posix.relative(this.processPath(parent), this.processPath(child))
324
330
  return relative === '' || (relative !== '..' && !relative.startsWith('../'))
325
331
  }
package/lib/wsl/paths.js CHANGED
@@ -12,8 +12,16 @@
12
12
  /** UNC hosts Windows uses for the WSL 9P share. */
13
13
  const UNC_HOSTS = new Set(['wsl.localhost', 'wsl$'])
14
14
 
15
- /** A distribution name that is safe to concatenate into a UNC path. */
16
- export const DISTRO_NAME = /^[A-Za-z0-9._-]+$/
15
+ /**
16
+ * A distribution name that is safe to concatenate into a UNC path.
17
+ *
18
+ * A name made ENTIRELY of dots is refused even though every one of its characters
19
+ * is in the class: as a path segment `.` and `..` are the current and the parent
20
+ * directory, so `\\wsl.localhost\..\<path>` would hand the share a traversal
21
+ * spelling. No real distribution name is all dots, and `wsl.exe -d ..` cannot be
22
+ * a legal invocation, so the lookahead costs nothing legitimate.
23
+ */
24
+ export const DISTRO_NAME = /^(?!\.+$)[A-Za-z0-9._-]+$/
17
25
 
18
26
  /** A Linux user name that is safe to pass as a single wsl.exe -u option value. */
19
27
  export const LINUX_USER = /^[A-Za-z0-9._][A-Za-z0-9._-]*\$?$/
package/lib/wsl/preset.js CHANGED
@@ -133,7 +133,7 @@ export function buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, inc
133
133
  // a Linux-only workdir fell back to the machine's DEFAULT distribution, and the
134
134
  // fs fence derived its distro `/tmp` writable root from the literal string
135
135
  // "undefined" (joinWslUnc(undefined, '/tmp') does not throw), so a write the
136
- // README grants was refused.
136
+ // docs/FS-FENCE.md grants was refused.
137
137
  const distroConfig = distro === undefined ? {} : { config: { distro } }
138
138
  /** Re-mount one host-world tool row, keeping the operator's own config for it. */
139
139
  const toolRow = (id, name) => {
package/lib/wsl/pty.js CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { appendFileSync } from 'node:fs'
19
19
  import { randomUUID } from 'node:crypto'
20
- import { homedir, tmpdir } from 'node:os'
20
+ import { homedir } from 'node:os'
21
21
  import { join } from 'node:path'
22
22
  import { shellQuote } from './paths.js'
23
23
  import { buildWslExecArgv, runWslShell } from './world.js'
package/lib/wsl/shell.js CHANGED
@@ -41,10 +41,7 @@ import {
41
41
  workspaceRootInLinux,
42
42
  } from './confinement.js'
43
43
  import { parseWslUnc, shellQuote, windowsToMntPath } from './paths.js'
44
- import { WSL_CHILD_ENV, planWsl, runWslShell, withWslEnvFlags } from './world.js'
45
-
46
- /** Model-readable environment overrides applied to every command. */
47
- const ENV_OVERRIDES = { ...WSL_CHILD_ENV, NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' }
44
+ import { ENV_OVERRIDES, planWsl, runWslShell, withWslEnvFlags } from './world.js'
48
45
 
49
46
  /** Default foreground timeout in milliseconds. */
50
47
  const DEFAULT_TIMEOUT_MS = 120_000
@@ -253,7 +250,9 @@ export class WslShellExecutor extends ShellExecutor {
253
250
  linuxCwd: plan.linuxCwd,
254
251
  mode,
255
252
  runner,
256
- workspaceLinuxRoot: workspaceLinuxRoot ?? undefined,
253
+ // Not `?? undefined`: the null case already threw above, so the fallback
254
+ // could never fire and the expression was a no-op read of the same value.
255
+ workspaceLinuxRoot,
257
256
  identity,
258
257
  noNewPrivs,
259
258
  isolateProcesses: this.config.isolateProcesses,
@@ -34,7 +34,7 @@ import { LoginShellUnresolvedError, WSL_CHILD_ENV, buildWslExecArgv, planWsl, re
34
34
  /**
35
35
  * The executable lookup's ceiling, on the documented probe policy.
36
36
  *
37
- * Was 15s and the shortest ceiling in the plugin. README.md:132 / README.en.md:132 name
37
+ * Was 15s and the shortest ceiling in the plugin. docs/CONFINEMENT.md:36 names
38
38
  * `resolveExecutable` in the same sentence as 探针超时 60s + 超时后一次透明重试, and it is an
39
39
  * output-parsed probe like the siblings that sentence lists (the comment on the probe below
40
40
  * says so), so it gets the same 60s as `listLinuxDir`/`checkLinuxPath`/`resolveDistroHome`
@@ -21,6 +21,7 @@ Exits with the child's own exit status (128 + signal when it was killed).
21
21
  """
22
22
 
23
23
  import errno
24
+ import atexit
24
25
  import fcntl
25
26
  import json
26
27
  import os
@@ -128,10 +129,22 @@ class Bridge:
128
129
  self.control_open = True
129
130
  self.revision = 0
130
131
  self.last_state = None
132
+ self.fifo = fifo
131
133
  self.control_fd = self._open_control(fifo)
132
134
  self._spawn()
133
135
  self.resize(cols, rows)
134
136
 
137
+ def remove_fifo(self):
138
+ """Unlink this bridge's control FIFO. Idempotent, and safe at any stage:
139
+ before the FIFO exists it raises FileNotFoundError, which is swallowed, and
140
+ after removal a second call does the same. The host runs a compensating
141
+ rm -f on every session-ending path, but that is a backstop for the paths
142
+ this process cannot catch (SIGKILL) — it is not the owner."""
143
+ try:
144
+ os.unlink(self.fifo)
145
+ except OSError:
146
+ pass
147
+
135
148
  def _open_control(self, fifo):
136
149
  """Create the control FIFO and hold its read end open."""
137
150
  try:
@@ -413,13 +426,37 @@ def main():
413
426
  cols, rows = sys.argv[2], sys.argv[3]
414
427
  argv = sys.argv[4:]
415
428
  bridge = Bridge(fifo, cols, rows, argv)
429
+
430
+ # The finally below covers every orderly path, but it does not run when the
431
+ # BRIDGE ITSELF is signalled — and the FIFO is the one thing this process owns
432
+ # exclusively, so the host's compensating rm -f must not be the only remover.
433
+ # SIGTERM and SIGINT therefore remove it first, then restore the default
434
+ # disposition and re-raise: a signalled bridge still reports 128+n, exactly the
435
+ # status it already had with no handler installed (an uncaught SIGTERM kills by
436
+ # default), so this changes what gets cleaned up and nothing else. SIGKILL is
437
+ # uncatchable by construction and stays the host's backstop.
438
+ atexit.register(bridge.remove_fifo)
439
+ for name in ("SIGTERM", "SIGINT"):
440
+ number = getattr(signal, name, None)
441
+ if number is None:
442
+ continue
443
+
444
+ def handler(signum, _frame, _default=number):
445
+ bridge.remove_fifo()
446
+ signal.signal(signum, signal.SIG_DFL)
447
+ os.kill(os.getpid(), signum)
448
+
449
+ try:
450
+ signal.signal(number, handler)
451
+ except (ValueError, OSError):
452
+ # Registered off the main thread, or the platform refuses it: the
453
+ # finally below still covers every orderly path.
454
+ pass
455
+
416
456
  try:
417
457
  code = bridge.run()
418
458
  finally:
419
- try:
420
- os.unlink(fifo)
421
- except OSError:
422
- pass
459
+ bridge.remove_fifo()
423
460
  return code
424
461
 
425
462