@vzn/vx 0.0.596 → 0.0.597
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/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +31 -15
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +87 -32
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +2 -1
- package/src/cli/cache.ts +2 -2
- package/src/cli/foreign-flags.ts +56 -2
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +6 -3
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/run.ts +42 -15
- package/src/cli/select.ts +6 -3
- package/src/cli/show.ts +8 -2
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch.ts +14 -7
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +21 -0
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +89 -18
- package/src/exec/runner.ts +74 -36
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +168 -49
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +49 -26
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/logger.ts +10 -3
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-lock.ts +25 -24
- package/src/orchestrator/run.ts +2 -1
- package/src/orchestrator/sandbox-request.ts +31 -9
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +5 -9
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +1 -1
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-eval.ts +27 -6
- package/src/workspace/config-imports.ts +8 -55
- package/src/workspace/config-schema.ts +30 -6
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/project-loader.ts +15 -2
- package/src/workspace/workspace.ts +36 -14
package/src/exec/runner.ts
CHANGED
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
closeSignalChannel,
|
|
19
19
|
holdGroups,
|
|
20
20
|
killTree,
|
|
21
|
-
markGroupIfGone,
|
|
22
21
|
releaseGroup,
|
|
22
|
+
releaseServerGroup,
|
|
23
23
|
signalThrough,
|
|
24
24
|
guardLine,
|
|
25
25
|
spawnGuarded,
|
|
@@ -105,6 +105,36 @@ function taskStdio(terminal: boolean | undefined, stdin: 'ignore' | 'pipe') {
|
|
|
105
105
|
: ([stdin, 'pipe', 'pipe'] as const)
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Read the terminal's modes before a task that holds it runs, and return
|
|
110
|
+
* what puts them back once it exits: a task killed in raw mode (or one
|
|
111
|
+
* that never undid its `stty raw -echo`) left the user's terminal with no
|
|
112
|
+
* echo and no Ctrl-C. Bun restores them at exit only when vx's stdout is
|
|
113
|
+
* the terminal, so `vx run … > log` kept them, and the rest of a run
|
|
114
|
+
* printed in them. `detached`, as the task is: a vx in a background job
|
|
115
|
+
* would be stopped by SIGTTOU on the set.
|
|
116
|
+
*/
|
|
117
|
+
function keepTerminalModes(): (() => void) | undefined {
|
|
118
|
+
const saved = stty('-g')
|
|
119
|
+
if (saved === undefined) return undefined
|
|
120
|
+
return () => {
|
|
121
|
+
if (stty('-g') !== saved) stty(saved)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stty(arg: string): string | undefined {
|
|
126
|
+
try {
|
|
127
|
+
const out = Bun.spawnSync(['stty', arg], {
|
|
128
|
+
stdio: ['inherit', 'pipe', 'ignore'],
|
|
129
|
+
detached: true,
|
|
130
|
+
})
|
|
131
|
+
return out.success ? out.stdout.toString().trim() : undefined
|
|
132
|
+
} catch {
|
|
133
|
+
// No stty: the modes stay the task's, as before.
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
108
138
|
export function shellQuote(arg: string): string {
|
|
109
139
|
if (arg === '') return `''`
|
|
110
140
|
// A `#` opens a comment only at a word's start, so `app#build` stays bare.
|
|
@@ -485,12 +515,14 @@ const READY_MATCH_WINDOW_CHARS = 64 * 1024
|
|
|
485
515
|
|
|
486
516
|
/**
|
|
487
517
|
* Terminal escapes a `readyWhen` pattern is matched without: CSI (colour,
|
|
488
|
-
* cursor), OSC (titles, links)
|
|
518
|
+
* cursor), OSC (titles, links), charset picks (`tput sgr0` writes
|
|
519
|
+
* `\x1b(B\x1b[m`) and the two-byte forms. Vite under
|
|
489
520
|
* `FORCE_COLOR` prints `\x1b[1mLocal\x1b[22m:`, which `Local:` never matched
|
|
490
521
|
* (item 1059). The streamed bytes keep them; only the tested text drops them.
|
|
491
522
|
*/
|
|
492
|
-
|
|
493
|
-
|
|
523
|
+
const TERMINAL_ESCAPE_RE =
|
|
524
|
+
// eslint-disable-next-line no-control-regex -- ESC is the point
|
|
525
|
+
/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[ -/]+[0-~]|[@-Z\\-_])/g
|
|
494
526
|
|
|
495
527
|
/**
|
|
496
528
|
* Why a persistent task never became ready — the reason every label and
|
|
@@ -566,6 +598,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
566
598
|
const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
|
|
567
599
|
|
|
568
600
|
let child: ReturnType<typeof Bun.spawn>
|
|
601
|
+
const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
|
|
569
602
|
try {
|
|
570
603
|
const signalFd = opts.signalChannel === true
|
|
571
604
|
child = spawnGuarded((guard) =>
|
|
@@ -597,6 +630,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
597
630
|
},
|
|
598
631
|
),
|
|
599
632
|
)
|
|
633
|
+
if (restoreModes !== undefined) void child.exited.then(restoreModes)
|
|
600
634
|
if (signalFd) {
|
|
601
635
|
signalThrough(child, child.stdio[3] as number)
|
|
602
636
|
const spawned = child
|
|
@@ -621,7 +655,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
621
655
|
})
|
|
622
656
|
|
|
623
657
|
const markReady = (): void => {
|
|
624
|
-
|
|
658
|
+
// A marker printed on the way down, once the wait gave up, is not ready.
|
|
659
|
+
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
625
660
|
readyAt = Date.now()
|
|
626
661
|
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
627
662
|
resolveReady()
|
|
@@ -709,40 +744,42 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
709
744
|
opts.liveChildren?.add(child)
|
|
710
745
|
opts.onSpawn?.(child.pid)
|
|
711
746
|
|
|
712
|
-
// Readiness deadline
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
747
|
+
// Readiness deadline: SIGTERM, wait the grace for the GROUP, SIGKILL
|
|
748
|
+
// what is left, and only then reject. The caller's teardown used to
|
|
749
|
+
// signal the group again a turn after this SIGTERM, and a server whose
|
|
750
|
+
// handler is a one-shot (`process.once('SIGTERM')`, a trap that resets
|
|
751
|
+
// itself) died on the second mid-cleanup. Cleared the moment ready
|
|
752
|
+
// fires so a healthy server is never killed by a stale timer.
|
|
716
753
|
let readyTimer: ReturnType<typeof setTimeout> | undefined
|
|
717
754
|
if (readyRe && opts.timeoutMs !== undefined) {
|
|
718
|
-
const giveUp = (): void => {
|
|
719
|
-
if (readyAt
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
)
|
|
728
|
-
// Listed on the group guard until the SIGKILL: the shell may die
|
|
729
|
-
// on the SIGTERM and let the group go while the server runs out
|
|
730
|
-
// the grace, and a vx that exits inside it (the timer is unref'd)
|
|
731
|
-
// leaves the server to the guard (kill-tree.ts, item 865).
|
|
732
|
-
const letGo = holdGroups([child])
|
|
755
|
+
const giveUp = async (): Promise<void> => {
|
|
756
|
+
if (readyAt !== undefined) return
|
|
757
|
+
gaveUpAt = Date.now()
|
|
758
|
+
// Listed on the group guard until the SIGKILL: the shell may die on
|
|
759
|
+
// the SIGTERM and let the group go while the server runs out the
|
|
760
|
+
// grace, and a vx killed inside it leaves the server to the guard
|
|
761
|
+
// (kill-tree.ts, item 865).
|
|
762
|
+
const letGo = holdGroups([child])
|
|
763
|
+
try {
|
|
733
764
|
killTree(child, 'SIGTERM')
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
killTimer.unref?.()
|
|
765
|
+
// A server that traps TERM and never became ready is not in the
|
|
766
|
+
// persistent registry, so nothing else would ever kill it.
|
|
767
|
+
const left = await untilGroupsGone([child], killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
|
|
768
|
+
for (const c of left) killTree(c, 'SIGKILL')
|
|
769
|
+
await child.exited
|
|
770
|
+
} finally {
|
|
771
|
+
letGo()
|
|
742
772
|
}
|
|
773
|
+
rejectReady(
|
|
774
|
+
new PersistentReadyError(
|
|
775
|
+
`persistent task not ready within ${opts.timeoutMs}ms — ` +
|
|
776
|
+
`readyWhen pattern never matched; child killed`,
|
|
777
|
+
'timeout',
|
|
778
|
+
),
|
|
779
|
+
)
|
|
743
780
|
}
|
|
744
781
|
readyTimer = setTimeout(() => {
|
|
745
|
-
readyTimer = afterPendingExits(giveUp)
|
|
782
|
+
readyTimer = afterPendingExits(() => void giveUp())
|
|
746
783
|
}, opts.timeoutMs)
|
|
747
784
|
}
|
|
748
785
|
|
|
@@ -750,10 +787,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
750
787
|
// — reject the ready promise so the caller can surface it.
|
|
751
788
|
void child.exited.then((code) => {
|
|
752
789
|
opts.liveChildren?.delete(child)
|
|
753
|
-
|
|
754
|
-
releaseGroup(child)
|
|
790
|
+
releaseServerGroup(child)
|
|
755
791
|
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
756
|
-
if (readyAt === undefined) {
|
|
792
|
+
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
757
793
|
rejectReady(
|
|
758
794
|
new PersistentReadyError(
|
|
759
795
|
`persistent task exited before becoming ready (exit ${code ?? '?'})` +
|
|
@@ -798,6 +834,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
798
834
|
const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
|
|
799
835
|
|
|
800
836
|
let proc: ReturnType<typeof Bun.spawn>
|
|
837
|
+
const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
|
|
801
838
|
try {
|
|
802
839
|
proc = spawnGuarded((guard) =>
|
|
803
840
|
Bun.spawn(shellArgv((guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand)), {
|
|
@@ -832,6 +869,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
832
869
|
// lets a clean exit EOF immediately and only cuts off a stuck reader after a
|
|
833
870
|
// brief grace — without this the run hangs forever.
|
|
834
871
|
await proc.exited
|
|
872
|
+
restoreModes?.()
|
|
835
873
|
await timeout.settle()
|
|
836
874
|
let cut = false
|
|
837
875
|
if (timeout.timedOut()) ac.abort()
|
|
@@ -27,6 +27,17 @@ export function isMountableLiteral(grant: string): boolean {
|
|
|
27
27
|
return !MOUNT_WILDCARDS.test(grant)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The one path a grant names, or undefined for a pattern. A grant whose
|
|
32
|
+
* only brackets are escaped (`out/\[id\]`) names `out/[id]` as surely as a
|
|
33
|
+
* literal does, though it is still scanned and matched as a glob.
|
|
34
|
+
*/
|
|
35
|
+
export function namedPath(grant: string): string | undefined {
|
|
36
|
+
if (isMountableLiteral(grant)) return grant
|
|
37
|
+
const escaped = /\\([[\]])/g
|
|
38
|
+
return isMountableLiteral(grant.replace(escaped, '')) ? grant.replace(escaped, '$1') : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
/**
|
|
31
42
|
* Canonicalize a path with realpath, tolerating paths that don't exist
|
|
32
43
|
* yet: resolve the longest existing ancestor and re-append the rest.
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
existsSync,
|
|
34
34
|
mkdirSync,
|
|
35
35
|
openSync,
|
|
36
|
+
readdirSync,
|
|
36
37
|
readFileSync,
|
|
37
38
|
readlinkSync,
|
|
38
39
|
realpathSync,
|
|
@@ -81,6 +82,7 @@ import {
|
|
|
81
82
|
isMountableLiteral,
|
|
82
83
|
localBindingOn,
|
|
83
84
|
MOUNT_WILDCARDS,
|
|
85
|
+
namedPath,
|
|
84
86
|
toRealPath,
|
|
85
87
|
unique,
|
|
86
88
|
} from './sandbox-paths.js'
|
|
@@ -103,6 +105,7 @@ import {
|
|
|
103
105
|
releaseGroup,
|
|
104
106
|
signalThrough,
|
|
105
107
|
guardLine,
|
|
108
|
+
guardSession,
|
|
106
109
|
spawnGuarded,
|
|
107
110
|
} from './kill-tree.js'
|
|
108
111
|
|
|
@@ -708,7 +711,8 @@ export async function initSandbox(opts?: {
|
|
|
708
711
|
...(scopedDenyScan ? scopedScanConfig() : {}),
|
|
709
712
|
...bundledJavaAgent(),
|
|
710
713
|
}
|
|
711
|
-
|
|
714
|
+
const wasUp = srtUp
|
|
715
|
+
if (!wasUp) await unlinkStaleMuxSockets()
|
|
712
716
|
const listening = srtCleanupAdopted ? undefined : cleanupListeners()
|
|
713
717
|
await SandboxManager.initialize(
|
|
714
718
|
config,
|
|
@@ -718,6 +722,10 @@ export async function initSandbox(opts?: {
|
|
|
718
722
|
)
|
|
719
723
|
if (listening !== undefined) adoptSrtCleanup(listening)
|
|
720
724
|
srtUp = true
|
|
725
|
+
if (!wasUp && process.platform === 'linux' && procfsIsOwn()) {
|
|
726
|
+
const { pids, paths } = sessionLeftovers(SandboxManager)
|
|
727
|
+
sessionRelease = guardSession(pids, paths)
|
|
728
|
+
}
|
|
721
729
|
perTaskRun = opts?.allowAllUnixSockets === true || opts?.gitConfig === true ? config : undefined
|
|
722
730
|
// `initialize()` returns early once SRT is up, and on Linux the
|
|
723
731
|
// availability probe brought it up with an EMPTY config before the run's
|
|
@@ -766,12 +774,81 @@ function srtDown(): void {
|
|
|
766
774
|
straceAvailableCache = undefined
|
|
767
775
|
}
|
|
768
776
|
|
|
777
|
+
/** Strikes the live session from the group guard's lists; set by `initSandbox`. */
|
|
778
|
+
let sessionRelease: (() => void) | undefined
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* The runtime's session as the group guard must take it if vx dies
|
|
782
|
+
* (kill-tree.md): the bridge socats, vx's children named by the socket
|
|
783
|
+
* they listen on, and what a `kill -9` leaves in the temp directory — the
|
|
784
|
+
* bridge sockets and the `srt-*` sockets vx itself holds (the violation
|
|
785
|
+
* observer's directory, the mux). The runtime exposes none of the pids and
|
|
786
|
+
* not the observer's path, so procfs answers: vx's children, and its own
|
|
787
|
+
* socket inodes against `/proc/net/unix`. ~3 ms, once per session.
|
|
788
|
+
*/
|
|
789
|
+
function sessionLeftovers(SandboxManager: SrtModule['SandboxManager']): {
|
|
790
|
+
pids: number[]
|
|
791
|
+
paths: string[]
|
|
792
|
+
} {
|
|
793
|
+
const sockets = new Set(
|
|
794
|
+
[SandboxManager.getLinuxHttpSocketPath(), SandboxManager.getLinuxSocksSocketPath()].filter(
|
|
795
|
+
(p): p is string => p !== undefined,
|
|
796
|
+
),
|
|
797
|
+
)
|
|
798
|
+
const pids: number[] = []
|
|
799
|
+
const paths = [...sockets]
|
|
800
|
+
try {
|
|
801
|
+
for (const entry of readdirSync('/proc')) {
|
|
802
|
+
const first = entry.charCodeAt(0)
|
|
803
|
+
if (first < 48 || first > 57) continue
|
|
804
|
+
try {
|
|
805
|
+
const stat = readFileSync(`/proc/${entry}/stat`, 'utf8')
|
|
806
|
+
if (Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ', 2)[1]) !== process.pid) continue
|
|
807
|
+
const argv = readFileSync(`/proc/${entry}/cmdline`, 'utf8').split('\0')
|
|
808
|
+
if (argv.some((a) => [...sockets].some((s) => a.startsWith(`UNIX-LISTEN:${s},`)))) {
|
|
809
|
+
pids.push(Number(entry))
|
|
810
|
+
}
|
|
811
|
+
} catch {
|
|
812
|
+
// exited
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const inodes = new Set<string>()
|
|
816
|
+
for (const fd of readdirSync('/proc/self/fd')) {
|
|
817
|
+
try {
|
|
818
|
+
const link = readlinkSync(`/proc/self/fd/${fd}`)
|
|
819
|
+
if (link.startsWith('socket:[')) inodes.add(link.slice(8, -1))
|
|
820
|
+
} catch {
|
|
821
|
+
// closed
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const tmp = os.tmpdir()
|
|
825
|
+
for (const line of readFileSync('/proc/net/unix', 'utf8').split('\n')) {
|
|
826
|
+
const f = line.trim().split(/\s+/)
|
|
827
|
+
if (f.length < 8 || !inodes.has(f[6]!)) continue
|
|
828
|
+
const p = f.slice(7).join(' ')
|
|
829
|
+
const dir = path.dirname(p)
|
|
830
|
+
if (dir === tmp && path.basename(p).startsWith('srt-mux-')) paths.push(p)
|
|
831
|
+
else if (path.dirname(dir) === tmp && path.basename(dir).startsWith('srt-obs-')) {
|
|
832
|
+
paths.push(dir)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
} catch {
|
|
836
|
+
// Best-effort, as the guard is: what was found is listed.
|
|
837
|
+
}
|
|
838
|
+
return { pids, paths }
|
|
839
|
+
}
|
|
840
|
+
|
|
769
841
|
/** `reset` is the one `initSandbox` waits out before it starts SRT again. */
|
|
770
842
|
function trackReset(reset: Promise<unknown>): void {
|
|
771
843
|
const settled = reset.then(
|
|
772
844
|
() => {},
|
|
773
845
|
() => {},
|
|
774
846
|
)
|
|
847
|
+
// Struck once the runtime has killed its bridges and removed its files;
|
|
848
|
+
// a reset `exit` starts never settles, and the guard takes the rest.
|
|
849
|
+
const release = sessionRelease
|
|
850
|
+
sessionRelease = undefined
|
|
851
|
+
if (release !== undefined) void settled.then(release)
|
|
775
852
|
resetting = settled
|
|
776
853
|
void settled.then(() => {
|
|
777
854
|
if (resetting === settled) resetting = undefined
|
|
@@ -1180,7 +1257,12 @@ async function wrapIn(
|
|
|
1180
1257
|
tmp: string,
|
|
1181
1258
|
): ReturnType<typeof wrapSandboxedCommand> {
|
|
1182
1259
|
// After the tag: SRT keys violations by the command's first 100 chars.
|
|
1183
|
-
|
|
1260
|
+
// TMP and TEMP pass through from the host, whose temp directory the
|
|
1261
|
+
// sandbox mounts read-only: a tool reading them failed to write there.
|
|
1262
|
+
const alsoTmp = ['TMP', 'TEMP'].filter((name) => args.env[name] !== undefined)
|
|
1263
|
+
const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${alsoTmp
|
|
1264
|
+
.map((name) => `export ${name}="$TMPDIR"; `)
|
|
1265
|
+
.join('')}${javaToolOptionsFix(
|
|
1184
1266
|
process.env['JAVA_TOOL_OPTIONS'],
|
|
1185
1267
|
args.env['JAVA_TOOL_OPTIONS'],
|
|
1186
1268
|
)}${userCommand}`
|
|
@@ -1226,7 +1308,10 @@ async function wrapIn(
|
|
|
1226
1308
|
`holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
|
|
1227
1309
|
)
|
|
1228
1310
|
}
|
|
1229
|
-
if (ports.length > 0)
|
|
1311
|
+
if (ports.length > 0) {
|
|
1312
|
+
hostBridges.set(tag, { ports, procs: [] })
|
|
1313
|
+
writeFileSync(portDialScript(tag), PORT_DIAL_SCRIPT)
|
|
1314
|
+
}
|
|
1230
1315
|
const grouped =
|
|
1231
1316
|
process.platform === 'linux'
|
|
1232
1317
|
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
@@ -1244,7 +1329,7 @@ async function wrapIn(
|
|
|
1244
1329
|
process.platform === 'linux'
|
|
1245
1330
|
? literalReadPaths(customConfig)
|
|
1246
1331
|
: process.platform === 'darwin'
|
|
1247
|
-
? seatbeltBrackets(customConfig)
|
|
1332
|
+
? seatbeltBrackets(customConfig, baselines.allowRead)
|
|
1248
1333
|
: customConfig,
|
|
1249
1334
|
ports.length > 0 || asksUnixSockets(args.config),
|
|
1250
1335
|
args.config.gitConfig === true,
|
|
@@ -1456,22 +1541,32 @@ function literalReadPaths(
|
|
|
1456
1541
|
* file and the route could not be granted. `[[]` is a class of one `[`; a
|
|
1457
1542
|
* lone `]` is plain text (B-65). A deny path is a real directory, never a
|
|
1458
1543
|
* pattern: a nested project's wall under `[legacy]/` compiled as a class,
|
|
1459
|
-
* matched nothing, and the root task read it.
|
|
1544
|
+
* matched nothing, and the root task read it. So is each of `names`, the
|
|
1545
|
+
* baseline reads: a linked dependency under `packages/[legacy]/` was a
|
|
1546
|
+
* class too, and as an exact regex even `[[]` grants the directory's entry
|
|
1547
|
+
* and none of its files, so its subtree is granted beside it (a trailing
|
|
1548
|
+
* `/**` is stripped before the compile).
|
|
1460
1549
|
*/
|
|
1461
1550
|
export function seatbeltBrackets(
|
|
1462
1551
|
config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
|
|
1552
|
+
names: readonly string[] = [],
|
|
1463
1553
|
): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
|
|
1464
1554
|
const fs = config?.filesystem
|
|
1465
1555
|
if (fs === undefined) return config
|
|
1466
|
-
const literal = (
|
|
1467
|
-
|
|
1556
|
+
const literal = (p: string): string => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']')
|
|
1557
|
+
const bracketed = new Set(names.filter((n) => /[[\]]/.test(n)))
|
|
1558
|
+
const read = (p: string): string[] => {
|
|
1559
|
+
if (!bracketed.has(p)) return [literal(p)]
|
|
1560
|
+
const at = p.replaceAll('[', '[[]')
|
|
1561
|
+
return [at, `${at}/**/*`]
|
|
1562
|
+
}
|
|
1468
1563
|
return {
|
|
1469
1564
|
...config,
|
|
1470
1565
|
filesystem: {
|
|
1471
1566
|
...fs,
|
|
1472
1567
|
denyRead: fs.denyRead.map((p) => p.replaceAll('[', '[[]')),
|
|
1473
|
-
allowWrite:
|
|
1474
|
-
...(fs.allowRead !== undefined ? { allowRead:
|
|
1568
|
+
allowWrite: fs.allowWrite.map(literal),
|
|
1569
|
+
...(fs.allowRead !== undefined ? { allowRead: fs.allowRead.flatMap(read) } : {}),
|
|
1475
1570
|
},
|
|
1476
1571
|
}
|
|
1477
1572
|
}
|
|
@@ -1588,7 +1683,14 @@ const hostBridges = new Map<
|
|
|
1588
1683
|
* (2026-10-03). Linux, own procfs only, as the wait.
|
|
1589
1684
|
*/
|
|
1590
1685
|
function portsHeld(ports: readonly number[]): number[] {
|
|
1591
|
-
if (ports.length === 0
|
|
1686
|
+
if (ports.length === 0) return []
|
|
1687
|
+
// This run's own bridges first: a wrap claims its ports before it awaits,
|
|
1688
|
+
// and two tasks granted one port both read the table before either
|
|
1689
|
+
// bridge bound, so the second's bind failed unseen and its clients
|
|
1690
|
+
// reached the first task.
|
|
1691
|
+
const claimed = new Set([...hostBridges.values()].flatMap((b) => b.ports))
|
|
1692
|
+
const ours = ports.filter((p) => claimed.has(p))
|
|
1693
|
+
if (ours.length > 0 || !procfsIsOwn()) return ours
|
|
1592
1694
|
const held = new Set<string>()
|
|
1593
1695
|
for (const [file, any, loop] of [
|
|
1594
1696
|
['/proc/net/tcp', '00000000', '0100007F'],
|
|
@@ -1644,7 +1746,7 @@ async function hostBridgesListen(ports: readonly number[], tag: string): Promise
|
|
|
1644
1746
|
}
|
|
1645
1747
|
|
|
1646
1748
|
function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
1647
|
-
const procs
|
|
1749
|
+
const procs = hostBridges.get(tag)!.procs
|
|
1648
1750
|
for (const p of ports) {
|
|
1649
1751
|
// A spawn failure (no socat on the host) is the task's to report:
|
|
1650
1752
|
// its own side dies the same way, in its frame.
|
|
@@ -1674,10 +1776,7 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
|
1674
1776
|
// see above
|
|
1675
1777
|
}
|
|
1676
1778
|
}
|
|
1677
|
-
if (procs.length > 0)
|
|
1678
|
-
hostBridges.set(tag, { ports, procs })
|
|
1679
|
-
for (const p of ports) unlinkOnExit(portBridgeSocket(tag, p))
|
|
1680
|
-
}
|
|
1779
|
+
if (procs.length > 0) for (const p of ports) unlinkOnExit(portBridgeSocket(tag, p))
|
|
1681
1780
|
}
|
|
1682
1781
|
|
|
1683
1782
|
/**
|
|
@@ -1736,12 +1835,22 @@ const TRACER_RETRY_LINE =
|
|
|
1736
1835
|
* declared, so a second run redoes, not doubles, it.
|
|
1737
1836
|
*/
|
|
1738
1837
|
export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
|
|
1739
|
-
const { tracerFailed, ...first } = await runSandboxedOnce(args)
|
|
1838
|
+
const { tracerFailed, ranMs, ...first } = await runSandboxedOnce(args)
|
|
1740
1839
|
// A stopping run has killed the children it holds; a retry would be one
|
|
1741
1840
|
// spawned after that kill.
|
|
1742
1841
|
if (!tracerFailed || args.signal?.aborted === true) return first
|
|
1743
1842
|
args.onStderr?.(TRACER_RETRY_LINE)
|
|
1744
|
-
|
|
1843
|
+
// `timeout` bounds the task's whole run (schema.md), so the retry gets
|
|
1844
|
+
// what the first attempt left of it, not a fresh window.
|
|
1845
|
+
const {
|
|
1846
|
+
tracerFailed: _again,
|
|
1847
|
+
ranMs: _ran,
|
|
1848
|
+
...second
|
|
1849
|
+
} = await runSandboxedOnce(
|
|
1850
|
+
args.timeoutMs === undefined
|
|
1851
|
+
? args
|
|
1852
|
+
: { ...args, timeoutMs: Math.max(0, args.timeoutMs - ranMs) },
|
|
1853
|
+
)
|
|
1745
1854
|
return {
|
|
1746
1855
|
...second,
|
|
1747
1856
|
durationMs: first.durationMs + second.durationMs,
|
|
@@ -1808,7 +1917,7 @@ const STRACE_OWN_ERROR = /^(?:[^\s:]*\/)?strace: /
|
|
|
1808
1917
|
*/
|
|
1809
1918
|
async function runSandboxedOnce(
|
|
1810
1919
|
args: SandboxedRunArgs,
|
|
1811
|
-
): Promise<SandboxedRunResult & { tracerFailed: boolean }> {
|
|
1920
|
+
): Promise<SandboxedRunResult & { tracerFailed: boolean; ranMs: number }> {
|
|
1812
1921
|
const start = Date.now()
|
|
1813
1922
|
const { SandboxManager } = await loadSrt()
|
|
1814
1923
|
// Linux: SRT's store sees only writes (below), so read denials need
|
|
@@ -1858,6 +1967,7 @@ async function runSandboxedOnce(
|
|
|
1858
1967
|
signal,
|
|
1859
1968
|
violations: [],
|
|
1860
1969
|
tracerFailed: false,
|
|
1970
|
+
ranMs: 0,
|
|
1861
1971
|
}
|
|
1862
1972
|
}
|
|
1863
1973
|
// Beside the task directories, which every sandbox replaces with its own:
|
|
@@ -1910,6 +2020,7 @@ async function runSandboxedOnce(
|
|
|
1910
2020
|
spawnFailed: true,
|
|
1911
2021
|
violations: [],
|
|
1912
2022
|
tracerFailed: false,
|
|
2023
|
+
ranMs: 0,
|
|
1913
2024
|
}
|
|
1914
2025
|
} finally {
|
|
1915
2026
|
// The child holds its own copy; ours would keep nothing but a descriptor.
|
|
@@ -1919,6 +2030,7 @@ async function runSandboxedOnce(
|
|
|
1919
2030
|
args.liveChildren?.add(proc)
|
|
1920
2031
|
args.onSpawn?.(proc.pid)
|
|
1921
2032
|
const timeout = armTimeout(proc, args.timeoutMs)
|
|
2033
|
+
const armedAt = Date.now()
|
|
1922
2034
|
const ac = new AbortController()
|
|
1923
2035
|
// The unfinished last line of stderr, and whether a line was strace's.
|
|
1924
2036
|
let partial = ''
|
|
@@ -1943,6 +2055,7 @@ async function runSandboxedOnce(
|
|
|
1943
2055
|
// OR a clean exit that backgrounds a process) can't hang the run — timeout
|
|
1944
2056
|
// aborts at once, otherwise drainOrAbort bounds the post-exit drain.
|
|
1945
2057
|
await proc.exited
|
|
2058
|
+
const ranMs = Date.now() - armedAt
|
|
1946
2059
|
await timeout.settle()
|
|
1947
2060
|
let cut = false
|
|
1948
2061
|
if (timeout.timedOut()) ac.abort()
|
|
@@ -2120,6 +2233,7 @@ async function runSandboxedOnce(
|
|
|
2120
2233
|
// that is someone else's. macOS's `sandbox-exec` execs the command, so
|
|
2121
2234
|
// its usage is the task's.
|
|
2122
2235
|
...(process.platform === 'linux' ? {} : resourceUsageToCpuRss(proc.resourceUsage())),
|
|
2236
|
+
ranMs,
|
|
2123
2237
|
tracerFailed:
|
|
2124
2238
|
straceLog !== undefined &&
|
|
2125
2239
|
!timeout.timedOut() &&
|
|
@@ -2405,7 +2519,7 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
|
|
|
2405
2519
|
*/
|
|
2406
2520
|
export function wallsGlobsReach(grants: readonly string[], walls: readonly string[]): string[] {
|
|
2407
2521
|
const heads = grants
|
|
2408
|
-
.filter((g) =>
|
|
2522
|
+
.filter((g) => namedPath(g) === undefined)
|
|
2409
2523
|
.map((g) => path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS))))
|
|
2410
2524
|
return walls.filter((w) => heads.some((h) => atOrUnder(w, h)))
|
|
2411
2525
|
}
|
|
@@ -2429,7 +2543,7 @@ export function darwinWallRules(
|
|
|
2429
2543
|
for (const w of walls) {
|
|
2430
2544
|
const at = `(subpath "${sbplResolvedPath(w, 'wall')}")`
|
|
2431
2545
|
const kept = literals
|
|
2432
|
-
.filter((l) =>
|
|
2546
|
+
.filter((l) => atOrUnder(l, w))
|
|
2433
2547
|
.map((l) => `(require-not (subpath "${sbplResolvedPath(l, 'grant')}"))`)
|
|
2434
2548
|
rules.push(
|
|
2435
2549
|
kept.length === 0
|
|
@@ -2438,8 +2552,11 @@ export function darwinWallRules(
|
|
|
2438
2552
|
)
|
|
2439
2553
|
}
|
|
2440
2554
|
}
|
|
2441
|
-
|
|
2442
|
-
|
|
2555
|
+
// A baseline is a name the filesystem handed back, never a pattern: a
|
|
2556
|
+
// bracket in it does not make it a glob.
|
|
2557
|
+
const named = (grants: readonly string[]): string[] => grants.flatMap((g) => namedPath(g) ?? [])
|
|
2558
|
+
deny('file-read-data', c.wallsReached?.read ?? [], [...named(c.allowRead), ...baseAllowRead])
|
|
2559
|
+
deny('file-write*', c.wallsReached?.write ?? [], named(c.allowWrite))
|
|
2443
2560
|
return rules
|
|
2444
2561
|
}
|
|
2445
2562
|
|
|
@@ -2527,8 +2644,9 @@ function expandGrants(
|
|
|
2527
2644
|
// A hit that IS a wall, or lies inside one, was matched, not named:
|
|
2528
2645
|
// `read: ['*']` in a root project bound `.git` and `.vx`, and
|
|
2529
2646
|
// `packages/*` a nested project its key excludes (B-1). A grant that
|
|
2530
|
-
// names a wall literally never reaches this loop and stays
|
|
2531
|
-
|
|
2647
|
+
// names a wall literally never reaches this loop and stays, and one
|
|
2648
|
+
// spelled with escaped brackets names it as surely.
|
|
2649
|
+
if (namedPath(p) === undefined && walls.some((w) => atOrUnder(real, w))) continue
|
|
2532
2650
|
out.push(abs)
|
|
2533
2651
|
hits++
|
|
2534
2652
|
}
|
|
@@ -2618,10 +2736,16 @@ function grantSpelled(p: string, within: string): string {
|
|
|
2618
2736
|
* names what is missing when there is no form, for the warning and for
|
|
2619
2737
|
* `vx info`.
|
|
2620
2738
|
*/
|
|
2621
|
-
let straceAvailableCache: { form: false | 'plain' | 'seccomp'; why: string } | undefined
|
|
2622
|
-
|
|
2623
|
-
if (process.platform !== 'linux') return { form: false, why: '' }
|
|
2624
|
-
|
|
2739
|
+
let straceAvailableCache: Promise<{ form: false | 'plain' | 'seccomp'; why: string }> | undefined
|
|
2740
|
+
function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
|
|
2741
|
+
if (process.platform !== 'linux') return Promise.resolve({ form: false, why: '' })
|
|
2742
|
+
// The promise is the memo, not its answer: a run starts a wave of tasks
|
|
2743
|
+
// at once, and each asked before the first answer landed (one probe per task).
|
|
2744
|
+
straceAvailableCache ??= probeStrace()
|
|
2745
|
+
return straceAvailableCache
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
async function probeStrace(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
|
|
2625
2749
|
try {
|
|
2626
2750
|
const p = Bun.spawn([executablePath('strace'), '--version'], {
|
|
2627
2751
|
stdout: 'pipe',
|
|
@@ -2629,33 +2753,28 @@ async function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why:
|
|
|
2629
2753
|
})
|
|
2630
2754
|
const out = await new Response(p.stdout).text()
|
|
2631
2755
|
await p.exited
|
|
2632
|
-
if (p.exitCode !== 0) {
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
if (refused
|
|
2644
|
-
refused = await traceRefusal('plain')
|
|
2645
|
-
if (refused === null) form = 'plain'
|
|
2646
|
-
}
|
|
2647
|
-
straceAvailableCache =
|
|
2648
|
-
refused === null
|
|
2649
|
-
? { form, why: '' }
|
|
2650
|
-
: { form: false, why: `strace cannot trace here (${refused.line})` }
|
|
2756
|
+
if (p.exitCode !== 0) return { form: false, why: `strace --version exited ${p.exitCode}` }
|
|
2757
|
+
const m = /version (\d+)\.(\d+)/.exec(out)
|
|
2758
|
+
const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
|
|
2759
|
+
let form: 'plain' | 'seccomp' = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
|
|
2760
|
+
let refused = await traceRefusal(form)
|
|
2761
|
+
// A strace that cannot check the seccomp filter says so and traces on
|
|
2762
|
+
// without it, exit 0. Inside the sandbox that line read as the trace
|
|
2763
|
+
// cut short, and every sandboxed task ran twice: the plain form, if
|
|
2764
|
+
// it is quiet, is the one that works here.
|
|
2765
|
+
if (refused?.warned === true && form === 'seccomp') {
|
|
2766
|
+
refused = await traceRefusal('plain')
|
|
2767
|
+
if (refused === null) form = 'plain'
|
|
2651
2768
|
}
|
|
2769
|
+
return refused === null
|
|
2770
|
+
? { form, why: '' }
|
|
2771
|
+
: { form: false, why: `strace cannot trace here (${refused.line})` }
|
|
2652
2772
|
} catch {
|
|
2653
2773
|
// Not on PATH. Said as the refused attach is: without it an undeclared
|
|
2654
2774
|
// read is denied but never reported, so a task that tolerates the miss
|
|
2655
2775
|
// passes and caches with no word of it (J's lead).
|
|
2656
|
-
|
|
2776
|
+
return { form: false, why: 'strace is not on PATH' }
|
|
2657
2777
|
}
|
|
2658
|
-
return straceAvailableCache
|
|
2659
2778
|
}
|
|
2660
2779
|
|
|
2661
2780
|
async function wantsStraceDetection(): Promise<false | 'plain' | 'seccomp'> {
|