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/README.en.md +56 -345
- package/README.md +54 -344
- package/lib/client.js +78 -14
- package/lib/index.js +38 -26
- package/lib/wsl/confinement.js +20 -6
- package/lib/wsl/fence.js +29 -7
- package/lib/wsl/fs.js +12 -6
- package/lib/wsl/paths.js +10 -2
- package/lib/wsl/preset.js +1 -1
- package/lib/wsl/pty.js +1 -1
- package/lib/wsl/shell.js +4 -5
- package/lib/wsl/subprocess.js +1 -1
- package/lib/wsl/terminal-bridge.py +41 -4
- package/lib/wsl/world.js +98 -16
- 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
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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,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 (
|
|
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 —
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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/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
|
-
//
|
|
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
|
|
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,
|
package/lib/wsl/subprocess.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
420
|
-
os.unlink(fifo)
|
|
421
|
-
except OSError:
|
|
422
|
-
pass
|
|
459
|
+
bridge.remove_fifo()
|
|
423
460
|
return code
|
|
424
461
|
|
|
425
462
|
|