dsh-wsl-desktop 0.3.3 → 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 +78 -14
- package/lib/index.js +38 -26
- package/lib/wsl/confinement.js +16 -2
- package/lib/wsl/fence.js +26 -4
- package/lib/wsl/fs.js +12 -6
- package/lib/wsl/paths.js +10 -2
- package/lib/wsl/pty.js +1 -1
- package/lib/wsl/shell.js +4 -5
- package/lib/wsl/terminal-bridge.py +41 -4
- package/lib/wsl/world.js +96 -14
- package/package.json +1 -1
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' },
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
906
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
|
727
|
+
workspaceFlow: async ({ distro, linuxPath }) => {
|
|
726
728
|
const registry = hostCtx.get('workspaceRegistry')
|
|
727
729
|
if (registry === undefined) throw new Error('workspaceRegistry 服务不可用')
|
|
728
|
-
|
|
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 >=
|
|
1045
|
+
if (routeInFlight >= MAX_ROUTE_IN_FLIGHT) {
|
|
1034
1046
|
sendJson(res, 503, { ok: false, code: 'too-busy' })
|
|
1035
1047
|
return
|
|
1036
1048
|
}
|
package/lib/wsl/confinement.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,6 +36,31 @@ 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`.
|
|
@@ -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
|
-
|
|
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
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
if (
|
|
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
|
-
/**
|
|
16
|
-
|
|
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/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
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
420
|
-
os.unlink(fifo)
|
|
421
|
-
except OSError:
|
|
422
|
-
pass
|
|
459
|
+
bridge.remove_fifo()
|
|
423
460
|
return code
|
|
424
461
|
|
|
425
462
|
|
package/lib/wsl/world.js
CHANGED
|
@@ -62,8 +62,16 @@ const LXSS_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss'
|
|
|
62
62
|
*/
|
|
63
63
|
export const WSL_CHILD_ENV = { WSL_UTF8: '1' }
|
|
64
64
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Environment overrides that keep command output model-readable.
|
|
67
|
+
*
|
|
68
|
+
* ONE owner, shared with the shell executor (which spreads it into every
|
|
69
|
+
* command it runs) — an override added to one copy and not the other used to be
|
|
70
|
+
* a silent divergence two modules could not see. Frozen so a shared object stays
|
|
71
|
+
* shared on purpose: an accidental write now throws instead of leaking into the
|
|
72
|
+
* other consumer's environment.
|
|
73
|
+
*/
|
|
74
|
+
export const ENV_OVERRIDES = Object.freeze({ ...WSL_CHILD_ENV, NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' })
|
|
67
75
|
|
|
68
76
|
/**
|
|
69
77
|
* Decode `wsl.exe` output.
|
|
@@ -398,6 +406,52 @@ export function buildWslExecArgv(plan, argv, options = {}) {
|
|
|
398
406
|
]
|
|
399
407
|
}
|
|
400
408
|
|
|
409
|
+
/** The most wsl.exe processes this module runs at once.
|
|
410
|
+
*
|
|
411
|
+
* Every spawn here lands on ONE shared WSL2 VM, and this file's own notes record it
|
|
412
|
+
* stalling under load (a cold start clears a 30 s ceiling, and a busy sibling
|
|
413
|
+
* distribution stalled probes past theirs). The HTTP route bounds its own methods
|
|
414
|
+
* (lib/index.js `routeInFlight`), but one route call can fan out several wsl.exe
|
|
415
|
+
* children — a command plus its confinement probes, executable lookup and
|
|
416
|
+
* login-shell probe — so the route counter is not a spawn cap. This is.
|
|
417
|
+
*
|
|
418
|
+
* Deliberately a plain FIFO queue rather than a pool: no caller holds a slot while
|
|
419
|
+
* awaiting another wsl.exe call, so a hand-off can never deadlock, and FIFO order
|
|
420
|
+
* keeps an early caller from starving behind later ones.
|
|
421
|
+
*/
|
|
422
|
+
const MAX_CONCURRENT_SPAWNS = 8
|
|
423
|
+
|
|
424
|
+
/** Spawns currently in flight. */
|
|
425
|
+
let activeSpawns = 0
|
|
426
|
+
|
|
427
|
+
/** Callers waiting for a slot; each holds a resolve that hands the slot over. */
|
|
428
|
+
const spawnQueue = []
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Take one spawn slot, waiting for a release when the module is at its cap.
|
|
432
|
+
* @returns {Promise<void>} settled once the caller may spawn.
|
|
433
|
+
*/
|
|
434
|
+
function acquireSpawnSlot() {
|
|
435
|
+
if (activeSpawns < MAX_CONCURRENT_SPAWNS) {
|
|
436
|
+
activeSpawns += 1
|
|
437
|
+
return Promise.resolve()
|
|
438
|
+
}
|
|
439
|
+
return new Promise((resolve) => { spawnQueue.push(resolve) })
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Give a spawn slot back — to the head of the queue when one waits (the count
|
|
444
|
+
* stays at the cap), otherwise back to the pool.
|
|
445
|
+
*/
|
|
446
|
+
function releaseSpawnSlot() {
|
|
447
|
+
const next = spawnQueue.shift()
|
|
448
|
+
if (next !== undefined) {
|
|
449
|
+
next()
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
activeSpawns -= 1
|
|
453
|
+
}
|
|
454
|
+
|
|
401
455
|
/**
|
|
402
456
|
* Run one command inside a distribution.
|
|
403
457
|
*
|
|
@@ -436,7 +490,11 @@ export function runWslShell({ distro, linuxCwd, command, username, loginShell =
|
|
|
436
490
|
'--cd', linuxCwd,
|
|
437
491
|
'-e', 'bash', loginShell ? '-lc' : '-c', script,
|
|
438
492
|
]
|
|
439
|
-
|
|
493
|
+
// Validation stays SYNCHRONOUS: callers rely on a bad distro/username throwing
|
|
494
|
+
// before anything is awaited. Only the spawn itself waits for a slot, so the cap
|
|
495
|
+
// never turns a grammar rejection into a rejected promise.
|
|
496
|
+
return acquireSpawnSlot()
|
|
497
|
+
.then(() => new Promise((resolve, reject) => {
|
|
440
498
|
let child
|
|
441
499
|
try {
|
|
442
500
|
child = spawn(wslPath, argv, {
|
|
@@ -511,7 +569,8 @@ export function runWslShell({ distro, linuxCwd, command, username, loginShell =
|
|
|
511
569
|
child.on('close', (code) => {
|
|
512
570
|
settle(code, undefined)
|
|
513
571
|
})
|
|
514
|
-
|
|
572
|
+
}))
|
|
573
|
+
.finally(releaseSpawnSlot)
|
|
515
574
|
}
|
|
516
575
|
|
|
517
576
|
/** The documented probe ceiling: 60s, because a cold VM's first spawn can exceed 30s. */
|
|
@@ -569,7 +628,11 @@ export async function listLinuxDir(distro, linuxPath, { wslPath, run = runWslShe
|
|
|
569
628
|
// directory that is not empty.
|
|
570
629
|
const result = await probeWithRetry(run, { distro, linuxCwd: '/', command: script, loginShell: false, raw: true, wslPath })
|
|
571
630
|
if (result.exitCode !== 0) {
|
|
572
|
-
|
|
631
|
+
// The probe's own evidence goes into the message: a twice-retried cold-VM stall
|
|
632
|
+
// (exit code null) must read as a timeout, not as "退出码 null" — the transient
|
|
633
|
+
// failure a reader can act on by waking the distribution.
|
|
634
|
+
const stderr = result.stderr.toString('utf8').trim()
|
|
635
|
+
throw new Error(`无法列出 ${path}:${stderr || probeEvidence(result)}`)
|
|
573
636
|
}
|
|
574
637
|
const entries = result.stdout.toString('utf8')
|
|
575
638
|
.split('\0')
|
|
@@ -656,7 +719,10 @@ export async function resolveDistroHome(distro, username, { wslPath, run = runWs
|
|
|
656
719
|
const who = await probeWithRetry(run, { distro, linuxCwd: '/', command: 'id -un', loginShell: false, wslPath })
|
|
657
720
|
user = who.stdout.trim()
|
|
658
721
|
if (user === '' || user.includes('\n')) {
|
|
659
|
-
|
|
722
|
+
// probeEvidence, not a bare exit code: a twice-retried stall reports
|
|
723
|
+
// exitCode null, and "null" is not something a reader can act on. The
|
|
724
|
+
// sibling home probe below already does this for the same reason.
|
|
725
|
+
throw new Error(`无法确定发行版 ${distro} 的默认用户:${who.stderr.trim() || probeEvidence(who)}`)
|
|
660
726
|
}
|
|
661
727
|
}
|
|
662
728
|
// `getent` reads the distro's own NSS user database. The pipeline's exit
|
|
@@ -814,13 +880,29 @@ export async function resolveLoginShell(distro, username, { wslPath, run = runWs
|
|
|
814
880
|
const stderr = typeof shell.stderr === 'string' ? shell.stderr.trim() : ''
|
|
815
881
|
throw new LoginShellUnresolvedError(`无法确定发行版 ${distro} 的登录 shell:${stderr || probeEvidence(shell)}`)
|
|
816
882
|
}
|
|
817
|
-
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
883
|
+
// A MULTI-LINE answer is refused, not truncated to line 1: resolveDistroHome runs
|
|
884
|
+
// the same getent-passwd shape and refuses it because a name getent misparses can
|
|
885
|
+
// dump the whole database — taking line 1 would silently elect the FIRST entry's
|
|
886
|
+
// shell for whichever user the caller asked about. Exactly one line is the only
|
|
887
|
+
// unambiguous answer, and an unresolvable login shell is a SKIP for the terminal
|
|
888
|
+
// controller, while a wrong shell is a session that does not work. The refusal is
|
|
889
|
+
// on the LINE COUNT: a single line that trims to empty is NOT this case — that is
|
|
890
|
+
// the documented /bin/bash fallback below.
|
|
891
|
+
const lines = shell.stdout.trim().split('\n')
|
|
892
|
+
if (lines.length > 1) {
|
|
893
|
+
throw new LoginShellUnresolvedError(
|
|
894
|
+
`无法确定发行版 ${distro} 的登录 shell:getent 返回了 ${lines.length} 行,唯一一行才是无歧义答案`,
|
|
895
|
+
)
|
|
896
|
+
}
|
|
897
|
+
const resolved = lines[0].trim()
|
|
898
|
+
// EMPTY is the fallback (the only way `resolved` is empty past the refusal above
|
|
899
|
+
// is a single line that trims to nothing); an ANSWERED value is returned as
|
|
900
|
+
// measured, so the test is deliberately `=== ''` and not `startsWith('/')`. A
|
|
901
|
+
// non-path answer such as `nologin` is the distribution's own ruling that this
|
|
902
|
+
// account has no interactive shell, and both callers SPAWN the value
|
|
903
|
+
// (subprocess.js hands it back as the resolved executable, and uses it as
|
|
904
|
+
// spawnTerminal's whole argv), so swapping in an unmeasured /bin/bash would open
|
|
905
|
+
// exactly the shell the account's passwd entry refuses — a value the caller
|
|
906
|
+
// cannot tell from one the probe measured.
|
|
825
907
|
return resolved === '' ? '/bin/bash' : resolved
|
|
826
908
|
}
|