@vzn/vx 0.0.596 → 0.0.598
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/bin.ts +9 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +60 -31
- 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/cache/layered-cache.ts +14 -9
- package/src/cache/output-index.ts +12 -7
- package/src/cache/run-history.ts +16 -8
- 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 +26 -20
- package/src/cli/run.ts +42 -15
- package/src/cli/select.ts +19 -5
- package/src/cli/show.ts +21 -27
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-cycle.ts +43 -0
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch.ts +50 -11
- 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 +121 -20
- package/src/exec/runner.ts +224 -48
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +187 -54
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/priorities.ts +1 -2
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- package/src/index.ts +3 -1
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +5 -3
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +70 -28
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/hit-restore.ts +1 -1
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/lockfile-claim.ts +14 -7
- package/src/orchestrator/logger.ts +17 -7
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/plugin-host.ts +7 -2
- package/src/orchestrator/plugin.ts +5 -2
- package/src/orchestrator/prepare.ts +4 -2
- package/src/orchestrator/projects.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-lock.ts +35 -25
- 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-cache.ts +15 -1
- package/src/workspace/config-eval.ts +47 -7
- 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 +33 -6
- package/src/workspace/workspace.ts +36 -14
|
@@ -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,
|
|
@@ -54,6 +55,7 @@ import {
|
|
|
54
55
|
spawnFailureText,
|
|
55
56
|
streamToString,
|
|
56
57
|
resourceUsageToCpuRss,
|
|
58
|
+
TaskPipes,
|
|
57
59
|
type CaptureConfig,
|
|
58
60
|
type RunResult,
|
|
59
61
|
} from './runner.js'
|
|
@@ -81,6 +83,7 @@ import {
|
|
|
81
83
|
isMountableLiteral,
|
|
82
84
|
localBindingOn,
|
|
83
85
|
MOUNT_WILDCARDS,
|
|
86
|
+
namedPath,
|
|
84
87
|
toRealPath,
|
|
85
88
|
unique,
|
|
86
89
|
} from './sandbox-paths.js'
|
|
@@ -103,6 +106,7 @@ import {
|
|
|
103
106
|
releaseGroup,
|
|
104
107
|
signalThrough,
|
|
105
108
|
guardLine,
|
|
109
|
+
guardSession,
|
|
106
110
|
spawnGuarded,
|
|
107
111
|
} from './kill-tree.js'
|
|
108
112
|
|
|
@@ -708,7 +712,8 @@ export async function initSandbox(opts?: {
|
|
|
708
712
|
...(scopedDenyScan ? scopedScanConfig() : {}),
|
|
709
713
|
...bundledJavaAgent(),
|
|
710
714
|
}
|
|
711
|
-
|
|
715
|
+
const wasUp = srtUp
|
|
716
|
+
if (!wasUp) await unlinkStaleMuxSockets()
|
|
712
717
|
const listening = srtCleanupAdopted ? undefined : cleanupListeners()
|
|
713
718
|
await SandboxManager.initialize(
|
|
714
719
|
config,
|
|
@@ -718,6 +723,10 @@ export async function initSandbox(opts?: {
|
|
|
718
723
|
)
|
|
719
724
|
if (listening !== undefined) adoptSrtCleanup(listening)
|
|
720
725
|
srtUp = true
|
|
726
|
+
if (!wasUp && process.platform === 'linux' && procfsIsOwn()) {
|
|
727
|
+
const { pids, paths } = sessionLeftovers(SandboxManager)
|
|
728
|
+
sessionRelease = guardSession(pids, paths)
|
|
729
|
+
}
|
|
721
730
|
perTaskRun = opts?.allowAllUnixSockets === true || opts?.gitConfig === true ? config : undefined
|
|
722
731
|
// `initialize()` returns early once SRT is up, and on Linux the
|
|
723
732
|
// availability probe brought it up with an EMPTY config before the run's
|
|
@@ -766,12 +775,81 @@ function srtDown(): void {
|
|
|
766
775
|
straceAvailableCache = undefined
|
|
767
776
|
}
|
|
768
777
|
|
|
778
|
+
/** Strikes the live session from the group guard's lists; set by `initSandbox`. */
|
|
779
|
+
let sessionRelease: (() => void) | undefined
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* The runtime's session as the group guard must take it if vx dies
|
|
783
|
+
* (kill-tree.md): the bridge socats, vx's children named by the socket
|
|
784
|
+
* they listen on, and what a `kill -9` leaves in the temp directory — the
|
|
785
|
+
* bridge sockets and the `srt-*` sockets vx itself holds (the violation
|
|
786
|
+
* observer's directory, the mux). The runtime exposes none of the pids and
|
|
787
|
+
* not the observer's path, so procfs answers: vx's children, and its own
|
|
788
|
+
* socket inodes against `/proc/net/unix`. ~3 ms, once per session.
|
|
789
|
+
*/
|
|
790
|
+
function sessionLeftovers(SandboxManager: SrtModule['SandboxManager']): {
|
|
791
|
+
pids: number[]
|
|
792
|
+
paths: string[]
|
|
793
|
+
} {
|
|
794
|
+
const sockets = new Set(
|
|
795
|
+
[SandboxManager.getLinuxHttpSocketPath(), SandboxManager.getLinuxSocksSocketPath()].filter(
|
|
796
|
+
(p): p is string => p !== undefined,
|
|
797
|
+
),
|
|
798
|
+
)
|
|
799
|
+
const pids: number[] = []
|
|
800
|
+
const paths = [...sockets]
|
|
801
|
+
try {
|
|
802
|
+
for (const entry of readdirSync('/proc')) {
|
|
803
|
+
const first = entry.charCodeAt(0)
|
|
804
|
+
if (first < 48 || first > 57) continue
|
|
805
|
+
try {
|
|
806
|
+
const stat = readFileSync(`/proc/${entry}/stat`, 'utf8')
|
|
807
|
+
if (Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ', 2)[1]) !== process.pid) continue
|
|
808
|
+
const argv = readFileSync(`/proc/${entry}/cmdline`, 'utf8').split('\0')
|
|
809
|
+
if (argv.some((a) => [...sockets].some((s) => a.startsWith(`UNIX-LISTEN:${s},`)))) {
|
|
810
|
+
pids.push(Number(entry))
|
|
811
|
+
}
|
|
812
|
+
} catch {
|
|
813
|
+
// exited
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const inodes = new Set<string>()
|
|
817
|
+
for (const fd of readdirSync('/proc/self/fd')) {
|
|
818
|
+
try {
|
|
819
|
+
const link = readlinkSync(`/proc/self/fd/${fd}`)
|
|
820
|
+
if (link.startsWith('socket:[')) inodes.add(link.slice(8, -1))
|
|
821
|
+
} catch {
|
|
822
|
+
// closed
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const tmp = os.tmpdir()
|
|
826
|
+
for (const line of readFileSync('/proc/net/unix', 'utf8').split('\n')) {
|
|
827
|
+
const f = line.trim().split(/\s+/)
|
|
828
|
+
if (f.length < 8 || !inodes.has(f[6]!)) continue
|
|
829
|
+
const p = f.slice(7).join(' ')
|
|
830
|
+
const dir = path.dirname(p)
|
|
831
|
+
if (dir === tmp && path.basename(p).startsWith('srt-mux-')) paths.push(p)
|
|
832
|
+
else if (path.dirname(dir) === tmp && path.basename(dir).startsWith('srt-obs-')) {
|
|
833
|
+
paths.push(dir)
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
} catch {
|
|
837
|
+
// Best-effort, as the guard is: what was found is listed.
|
|
838
|
+
}
|
|
839
|
+
return { pids, paths }
|
|
840
|
+
}
|
|
841
|
+
|
|
769
842
|
/** `reset` is the one `initSandbox` waits out before it starts SRT again. */
|
|
770
843
|
function trackReset(reset: Promise<unknown>): void {
|
|
771
844
|
const settled = reset.then(
|
|
772
845
|
() => {},
|
|
773
846
|
() => {},
|
|
774
847
|
)
|
|
848
|
+
// Struck once the runtime has killed its bridges and removed its files;
|
|
849
|
+
// a reset `exit` starts never settles, and the guard takes the rest.
|
|
850
|
+
const release = sessionRelease
|
|
851
|
+
sessionRelease = undefined
|
|
852
|
+
if (release !== undefined) void settled.then(release)
|
|
775
853
|
resetting = settled
|
|
776
854
|
void settled.then(() => {
|
|
777
855
|
if (resetting === settled) resetting = undefined
|
|
@@ -1180,7 +1258,12 @@ async function wrapIn(
|
|
|
1180
1258
|
tmp: string,
|
|
1181
1259
|
): ReturnType<typeof wrapSandboxedCommand> {
|
|
1182
1260
|
// After the tag: SRT keys violations by the command's first 100 chars.
|
|
1183
|
-
|
|
1261
|
+
// TMP and TEMP pass through from the host, whose temp directory the
|
|
1262
|
+
// sandbox mounts read-only: a tool reading them failed to write there.
|
|
1263
|
+
const alsoTmp = ['TMP', 'TEMP'].filter((name) => args.env[name] !== undefined)
|
|
1264
|
+
const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${alsoTmp
|
|
1265
|
+
.map((name) => `export ${name}="$TMPDIR"; `)
|
|
1266
|
+
.join('')}${javaToolOptionsFix(
|
|
1184
1267
|
process.env['JAVA_TOOL_OPTIONS'],
|
|
1185
1268
|
args.env['JAVA_TOOL_OPTIONS'],
|
|
1186
1269
|
)}${userCommand}`
|
|
@@ -1199,6 +1282,11 @@ async function wrapIn(
|
|
|
1199
1282
|
customConfig!.filesystem!.denyRead!.push(baselines.cwd)
|
|
1200
1283
|
}
|
|
1201
1284
|
customConfig!.filesystem!.allowWrite!.push(toRealPath(tmp))
|
|
1285
|
+
// Seatbelt judges `/dev/stdout` by the path the kernel resolves it to,
|
|
1286
|
+
// `/dev/fd/1`, which SRT's default `/dev/stdout` grant does not name, so
|
|
1287
|
+
// `echo x > /dev/stdout` failed EPERM (X-113). `/dev/fd/N` reopens only a
|
|
1288
|
+
// descriptor the task already holds.
|
|
1289
|
+
if (process.platform === 'darwin') customConfig!.filesystem!.allowWrite!.push('/dev/fd')
|
|
1202
1290
|
if (scopedDenyScan) {
|
|
1203
1291
|
customConfig!.filesystem!.denyWrite!.push(
|
|
1204
1292
|
...scopedMandatoryDenies(
|
|
@@ -1226,7 +1314,10 @@ async function wrapIn(
|
|
|
1226
1314
|
`holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
|
|
1227
1315
|
)
|
|
1228
1316
|
}
|
|
1229
|
-
if (ports.length > 0)
|
|
1317
|
+
if (ports.length > 0) {
|
|
1318
|
+
hostBridges.set(tag, { ports, procs: [] })
|
|
1319
|
+
writeFileSync(portDialScript(tag), PORT_DIAL_SCRIPT)
|
|
1320
|
+
}
|
|
1230
1321
|
const grouped =
|
|
1231
1322
|
process.platform === 'linux'
|
|
1232
1323
|
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
@@ -1244,7 +1335,7 @@ async function wrapIn(
|
|
|
1244
1335
|
process.platform === 'linux'
|
|
1245
1336
|
? literalReadPaths(customConfig)
|
|
1246
1337
|
: process.platform === 'darwin'
|
|
1247
|
-
? seatbeltBrackets(customConfig)
|
|
1338
|
+
? seatbeltBrackets(customConfig, baselines.allowRead)
|
|
1248
1339
|
: customConfig,
|
|
1249
1340
|
ports.length > 0 || asksUnixSockets(args.config),
|
|
1250
1341
|
args.config.gitConfig === true,
|
|
@@ -1456,22 +1547,37 @@ function literalReadPaths(
|
|
|
1456
1547
|
* file and the route could not be granted. `[[]` is a class of one `[`; a
|
|
1457
1548
|
* lone `]` is plain text (B-65). A deny path is a real directory, never a
|
|
1458
1549
|
* pattern: a nested project's wall under `[legacy]/` compiled as a class,
|
|
1459
|
-
* matched nothing, and the root task read it.
|
|
1550
|
+
* matched nothing, and the root task read it. So is each of `names`, the
|
|
1551
|
+
* baseline reads: a linked dependency under `packages/[legacy]/` was a
|
|
1552
|
+
* class too, and as an exact regex even `[[]` grants the directory's entry
|
|
1553
|
+
* and none of its files, so its subtree is granted beside it (a trailing
|
|
1554
|
+
* `/**` is stripped before the compile). A task's read grant whose only
|
|
1555
|
+
* brackets are escaped names one path too, and Linux binds it whole, so
|
|
1556
|
+
* it gets the same subtree: `out/\[id\]` read `out/[id]` and none of it.
|
|
1460
1557
|
*/
|
|
1461
1558
|
export function seatbeltBrackets(
|
|
1462
1559
|
config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
|
|
1560
|
+
names: readonly string[] = [],
|
|
1463
1561
|
): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
|
|
1464
1562
|
const fs = config?.filesystem
|
|
1465
1563
|
if (fs === undefined) return config
|
|
1466
|
-
const literal = (
|
|
1467
|
-
|
|
1564
|
+
const literal = (p: string): string => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']')
|
|
1565
|
+
const bracketed = new Set(names.filter((n) => /[[\]]/.test(n)))
|
|
1566
|
+
const read = (p: string): string[] => {
|
|
1567
|
+
const name = bracketed.has(p) ? p : /\\[[\]]/.test(p) ? namedPath(p) : undefined
|
|
1568
|
+
if (name === undefined) return [literal(p)]
|
|
1569
|
+
const at = name.replaceAll('[', '[[]')
|
|
1570
|
+
return [at, `${at}/**/*`]
|
|
1571
|
+
}
|
|
1468
1572
|
return {
|
|
1469
1573
|
...config,
|
|
1470
1574
|
filesystem: {
|
|
1471
1575
|
...fs,
|
|
1472
1576
|
denyRead: fs.denyRead.map((p) => p.replaceAll('[', '[[]')),
|
|
1473
|
-
allowWrite:
|
|
1474
|
-
...(fs.allowRead !== undefined
|
|
1577
|
+
allowWrite: fs.allowWrite.map(literal),
|
|
1578
|
+
...(fs.allowRead !== undefined
|
|
1579
|
+
? { allowRead: [...new Set(fs.allowRead.flatMap(read))] }
|
|
1580
|
+
: {}),
|
|
1475
1581
|
},
|
|
1476
1582
|
}
|
|
1477
1583
|
}
|
|
@@ -1588,7 +1694,14 @@ const hostBridges = new Map<
|
|
|
1588
1694
|
* (2026-10-03). Linux, own procfs only, as the wait.
|
|
1589
1695
|
*/
|
|
1590
1696
|
function portsHeld(ports: readonly number[]): number[] {
|
|
1591
|
-
if (ports.length === 0
|
|
1697
|
+
if (ports.length === 0) return []
|
|
1698
|
+
// This run's own bridges first: a wrap claims its ports before it awaits,
|
|
1699
|
+
// and two tasks granted one port both read the table before either
|
|
1700
|
+
// bridge bound, so the second's bind failed unseen and its clients
|
|
1701
|
+
// reached the first task.
|
|
1702
|
+
const claimed = new Set([...hostBridges.values()].flatMap((b) => b.ports))
|
|
1703
|
+
const ours = ports.filter((p) => claimed.has(p))
|
|
1704
|
+
if (ours.length > 0 || !procfsIsOwn()) return ours
|
|
1592
1705
|
const held = new Set<string>()
|
|
1593
1706
|
for (const [file, any, loop] of [
|
|
1594
1707
|
['/proc/net/tcp', '00000000', '0100007F'],
|
|
@@ -1644,7 +1757,7 @@ async function hostBridgesListen(ports: readonly number[], tag: string): Promise
|
|
|
1644
1757
|
}
|
|
1645
1758
|
|
|
1646
1759
|
function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
1647
|
-
const procs
|
|
1760
|
+
const procs = hostBridges.get(tag)!.procs
|
|
1648
1761
|
for (const p of ports) {
|
|
1649
1762
|
// A spawn failure (no socat on the host) is the task's to report:
|
|
1650
1763
|
// its own side dies the same way, in its frame.
|
|
@@ -1674,10 +1787,7 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
|
1674
1787
|
// see above
|
|
1675
1788
|
}
|
|
1676
1789
|
}
|
|
1677
|
-
if (procs.length > 0)
|
|
1678
|
-
hostBridges.set(tag, { ports, procs })
|
|
1679
|
-
for (const p of ports) unlinkOnExit(portBridgeSocket(tag, p))
|
|
1680
|
-
}
|
|
1790
|
+
if (procs.length > 0) for (const p of ports) unlinkOnExit(portBridgeSocket(tag, p))
|
|
1681
1791
|
}
|
|
1682
1792
|
|
|
1683
1793
|
/**
|
|
@@ -1736,12 +1846,22 @@ const TRACER_RETRY_LINE =
|
|
|
1736
1846
|
* declared, so a second run redoes, not doubles, it.
|
|
1737
1847
|
*/
|
|
1738
1848
|
export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
|
|
1739
|
-
const { tracerFailed, ...first } = await runSandboxedOnce(args)
|
|
1849
|
+
const { tracerFailed, ranMs, ...first } = await runSandboxedOnce(args)
|
|
1740
1850
|
// A stopping run has killed the children it holds; a retry would be one
|
|
1741
1851
|
// spawned after that kill.
|
|
1742
1852
|
if (!tracerFailed || args.signal?.aborted === true) return first
|
|
1743
1853
|
args.onStderr?.(TRACER_RETRY_LINE)
|
|
1744
|
-
|
|
1854
|
+
// `timeout` bounds the task's whole run (schema.md), so the retry gets
|
|
1855
|
+
// what the first attempt left of it, not a fresh window.
|
|
1856
|
+
const {
|
|
1857
|
+
tracerFailed: _again,
|
|
1858
|
+
ranMs: _ran,
|
|
1859
|
+
...second
|
|
1860
|
+
} = await runSandboxedOnce(
|
|
1861
|
+
args.timeoutMs === undefined
|
|
1862
|
+
? args
|
|
1863
|
+
: { ...args, timeoutMs: Math.max(0, args.timeoutMs - ranMs) },
|
|
1864
|
+
)
|
|
1745
1865
|
return {
|
|
1746
1866
|
...second,
|
|
1747
1867
|
durationMs: first.durationMs + second.durationMs,
|
|
@@ -1808,7 +1928,7 @@ const STRACE_OWN_ERROR = /^(?:[^\s:]*\/)?strace: /
|
|
|
1808
1928
|
*/
|
|
1809
1929
|
async function runSandboxedOnce(
|
|
1810
1930
|
args: SandboxedRunArgs,
|
|
1811
|
-
): Promise<SandboxedRunResult & { tracerFailed: boolean }> {
|
|
1931
|
+
): Promise<SandboxedRunResult & { tracerFailed: boolean; ranMs: number }> {
|
|
1812
1932
|
const start = Date.now()
|
|
1813
1933
|
const { SandboxManager } = await loadSrt()
|
|
1814
1934
|
// Linux: SRT's store sees only writes (below), so read denials need
|
|
@@ -1858,6 +1978,7 @@ async function runSandboxedOnce(
|
|
|
1858
1978
|
signal,
|
|
1859
1979
|
violations: [],
|
|
1860
1980
|
tracerFailed: false,
|
|
1981
|
+
ranMs: 0,
|
|
1861
1982
|
}
|
|
1862
1983
|
}
|
|
1863
1984
|
// Beside the task directories, which every sandbox replaces with its own:
|
|
@@ -1867,6 +1988,7 @@ async function runSandboxedOnce(
|
|
|
1867
1988
|
if (straceLog) unlinkOnExit(straceLog)
|
|
1868
1989
|
let proc: ReturnType<typeof Bun.spawn>
|
|
1869
1990
|
let traceFd: number | undefined
|
|
1991
|
+
const pipes = new TaskPipes()
|
|
1870
1992
|
try {
|
|
1871
1993
|
// Resolved on vx's own PATH (util/which.ts), not the task's, where a
|
|
1872
1994
|
// project's node_modules/.bin comes first.
|
|
@@ -1881,8 +2003,7 @@ async function runSandboxedOnce(
|
|
|
1881
2003
|
env: args.env as Record<string, string>,
|
|
1882
2004
|
stdio: [
|
|
1883
2005
|
'ignore',
|
|
1884
|
-
|
|
1885
|
-
'pipe',
|
|
2006
|
+
...pipes.stdio,
|
|
1886
2007
|
forwardsSignals ? 'pipe' : 'ignore',
|
|
1887
2008
|
guard ?? 'ignore',
|
|
1888
2009
|
...(traceFd === undefined ? [] : [traceFd]),
|
|
@@ -1893,6 +2014,7 @@ async function runSandboxedOnce(
|
|
|
1893
2014
|
)
|
|
1894
2015
|
if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
|
|
1895
2016
|
} catch (err) {
|
|
2017
|
+
pipes.close()
|
|
1896
2018
|
if (straceLog) {
|
|
1897
2019
|
rmSync(straceLog, { force: true })
|
|
1898
2020
|
liveTempFiles.delete(straceLog)
|
|
@@ -1910,23 +2032,27 @@ async function runSandboxedOnce(
|
|
|
1910
2032
|
spawnFailed: true,
|
|
1911
2033
|
violations: [],
|
|
1912
2034
|
tracerFailed: false,
|
|
2035
|
+
ranMs: 0,
|
|
1913
2036
|
}
|
|
1914
2037
|
} finally {
|
|
1915
2038
|
// The child holds its own copy; ours would keep nothing but a descriptor.
|
|
1916
2039
|
if (traceFd !== undefined) closeSync(traceFd)
|
|
2040
|
+
pipes.spawned()
|
|
1917
2041
|
}
|
|
1918
2042
|
|
|
1919
2043
|
args.liveChildren?.add(proc)
|
|
1920
2044
|
args.onSpawn?.(proc.pid)
|
|
1921
2045
|
const timeout = armTimeout(proc, args.timeoutMs)
|
|
2046
|
+
const armedAt = Date.now()
|
|
1922
2047
|
const ac = new AbortController()
|
|
1923
2048
|
// The unfinished last line of stderr, and whether a line was strace's.
|
|
1924
2049
|
let partial = ''
|
|
1925
2050
|
let straceSpoke = false
|
|
2051
|
+
const [out, err] = pipes.streams(proc)
|
|
1926
2052
|
const streams = Promise.all([
|
|
1927
|
-
streamToString(
|
|
2053
|
+
streamToString(out, args.onStdout, ac.signal, args.capture?.stdout ?? true),
|
|
1928
2054
|
streamToString(
|
|
1929
|
-
|
|
2055
|
+
err,
|
|
1930
2056
|
(chunk) => {
|
|
1931
2057
|
// Read whatever the capture setting: a line of strace's own says
|
|
1932
2058
|
// the trace stopped short.
|
|
@@ -1938,11 +2064,12 @@ async function runSandboxedOnce(
|
|
|
1938
2064
|
ac.signal,
|
|
1939
2065
|
args.capture?.stderr ?? true,
|
|
1940
2066
|
),
|
|
1941
|
-
])
|
|
2067
|
+
]).finally(() => pipes.close())
|
|
1942
2068
|
// See runCommand: gate on child exit; a lingering grandchild pipe (timeout
|
|
1943
2069
|
// OR a clean exit that backgrounds a process) can't hang the run — timeout
|
|
1944
2070
|
// aborts at once, otherwise drainOrAbort bounds the post-exit drain.
|
|
1945
2071
|
await proc.exited
|
|
2072
|
+
const ranMs = Date.now() - armedAt
|
|
1946
2073
|
await timeout.settle()
|
|
1947
2074
|
let cut = false
|
|
1948
2075
|
if (timeout.timedOut()) ac.abort()
|
|
@@ -2120,6 +2247,7 @@ async function runSandboxedOnce(
|
|
|
2120
2247
|
// that is someone else's. macOS's `sandbox-exec` execs the command, so
|
|
2121
2248
|
// its usage is the task's.
|
|
2122
2249
|
...(process.platform === 'linux' ? {} : resourceUsageToCpuRss(proc.resourceUsage())),
|
|
2250
|
+
ranMs,
|
|
2123
2251
|
tracerFailed:
|
|
2124
2252
|
straceLog !== undefined &&
|
|
2125
2253
|
!timeout.timedOut() &&
|
|
@@ -2405,7 +2533,7 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
|
|
|
2405
2533
|
*/
|
|
2406
2534
|
export function wallsGlobsReach(grants: readonly string[], walls: readonly string[]): string[] {
|
|
2407
2535
|
const heads = grants
|
|
2408
|
-
.filter((g) =>
|
|
2536
|
+
.filter((g) => namedPath(g) === undefined)
|
|
2409
2537
|
.map((g) => path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS))))
|
|
2410
2538
|
return walls.filter((w) => heads.some((h) => atOrUnder(w, h)))
|
|
2411
2539
|
}
|
|
@@ -2429,7 +2557,7 @@ export function darwinWallRules(
|
|
|
2429
2557
|
for (const w of walls) {
|
|
2430
2558
|
const at = `(subpath "${sbplResolvedPath(w, 'wall')}")`
|
|
2431
2559
|
const kept = literals
|
|
2432
|
-
.filter((l) =>
|
|
2560
|
+
.filter((l) => atOrUnder(l, w))
|
|
2433
2561
|
.map((l) => `(require-not (subpath "${sbplResolvedPath(l, 'grant')}"))`)
|
|
2434
2562
|
rules.push(
|
|
2435
2563
|
kept.length === 0
|
|
@@ -2438,8 +2566,11 @@ export function darwinWallRules(
|
|
|
2438
2566
|
)
|
|
2439
2567
|
}
|
|
2440
2568
|
}
|
|
2441
|
-
|
|
2442
|
-
|
|
2569
|
+
// A baseline is a name the filesystem handed back, never a pattern: a
|
|
2570
|
+
// bracket in it does not make it a glob.
|
|
2571
|
+
const named = (grants: readonly string[]): string[] => grants.flatMap((g) => namedPath(g) ?? [])
|
|
2572
|
+
deny('file-read-data', c.wallsReached?.read ?? [], [...named(c.allowRead), ...baseAllowRead])
|
|
2573
|
+
deny('file-write*', c.wallsReached?.write ?? [], named(c.allowWrite))
|
|
2443
2574
|
return rules
|
|
2444
2575
|
}
|
|
2445
2576
|
|
|
@@ -2527,8 +2658,9 @@ function expandGrants(
|
|
|
2527
2658
|
// A hit that IS a wall, or lies inside one, was matched, not named:
|
|
2528
2659
|
// `read: ['*']` in a root project bound `.git` and `.vx`, and
|
|
2529
2660
|
// `packages/*` a nested project its key excludes (B-1). A grant that
|
|
2530
|
-
// names a wall literally never reaches this loop and stays
|
|
2531
|
-
|
|
2661
|
+
// names a wall literally never reaches this loop and stays, and one
|
|
2662
|
+
// spelled with escaped brackets names it as surely.
|
|
2663
|
+
if (namedPath(p) === undefined && walls.some((w) => atOrUnder(real, w))) continue
|
|
2532
2664
|
out.push(abs)
|
|
2533
2665
|
hits++
|
|
2534
2666
|
}
|
|
@@ -2618,10 +2750,16 @@ function grantSpelled(p: string, within: string): string {
|
|
|
2618
2750
|
* names what is missing when there is no form, for the warning and for
|
|
2619
2751
|
* `vx info`.
|
|
2620
2752
|
*/
|
|
2621
|
-
let straceAvailableCache: { form: false | 'plain' | 'seccomp'; why: string } | undefined
|
|
2622
|
-
|
|
2623
|
-
if (process.platform !== 'linux') return { form: false, why: '' }
|
|
2624
|
-
|
|
2753
|
+
let straceAvailableCache: Promise<{ form: false | 'plain' | 'seccomp'; why: string }> | undefined
|
|
2754
|
+
function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
|
|
2755
|
+
if (process.platform !== 'linux') return Promise.resolve({ form: false, why: '' })
|
|
2756
|
+
// The promise is the memo, not its answer: a run starts a wave of tasks
|
|
2757
|
+
// at once, and each asked before the first answer landed (one probe per task).
|
|
2758
|
+
straceAvailableCache ??= probeStrace()
|
|
2759
|
+
return straceAvailableCache
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
async function probeStrace(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
|
|
2625
2763
|
try {
|
|
2626
2764
|
const p = Bun.spawn([executablePath('strace'), '--version'], {
|
|
2627
2765
|
stdout: 'pipe',
|
|
@@ -2629,33 +2767,28 @@ async function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why:
|
|
|
2629
2767
|
})
|
|
2630
2768
|
const out = await new Response(p.stdout).text()
|
|
2631
2769
|
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})` }
|
|
2770
|
+
if (p.exitCode !== 0) return { form: false, why: `strace --version exited ${p.exitCode}` }
|
|
2771
|
+
const m = /version (\d+)\.(\d+)/.exec(out)
|
|
2772
|
+
const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
|
|
2773
|
+
let form: 'plain' | 'seccomp' = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
|
|
2774
|
+
let refused = await traceRefusal(form)
|
|
2775
|
+
// A strace that cannot check the seccomp filter says so and traces on
|
|
2776
|
+
// without it, exit 0. Inside the sandbox that line read as the trace
|
|
2777
|
+
// cut short, and every sandboxed task ran twice: the plain form, if
|
|
2778
|
+
// it is quiet, is the one that works here.
|
|
2779
|
+
if (refused?.warned === true && form === 'seccomp') {
|
|
2780
|
+
refused = await traceRefusal('plain')
|
|
2781
|
+
if (refused === null) form = 'plain'
|
|
2651
2782
|
}
|
|
2783
|
+
return refused === null
|
|
2784
|
+
? { form, why: '' }
|
|
2785
|
+
: { form: false, why: `strace cannot trace here (${refused.line})` }
|
|
2652
2786
|
} catch {
|
|
2653
2787
|
// Not on PATH. Said as the refused attach is: without it an undeclared
|
|
2654
2788
|
// read is denied but never reported, so a task that tolerates the miss
|
|
2655
2789
|
// passes and caches with no word of it (J's lead).
|
|
2656
|
-
|
|
2790
|
+
return { form: false, why: 'strace is not on PATH' }
|
|
2657
2791
|
}
|
|
2658
|
-
return straceAvailableCache
|
|
2659
2792
|
}
|
|
2660
2793
|
|
|
2661
2794
|
async function wantsStraceDetection(): Promise<false | 'plain' | 'seccomp'> {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// task's owner can act on — inside the project, minus the loopback denial
|
|
5
5
|
// no grant can avoid, minus what the task chose to ignore.
|
|
6
6
|
|
|
7
|
-
import { lstatSync, statSync } from 'node:fs'
|
|
7
|
+
import { existsSync, lstatSync, statSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
|
|
10
10
|
import { bindableReads, bindableWrites } from './sandbox-binds.js'
|
|
@@ -364,6 +364,10 @@ export async function parseStraceViolations(
|
|
|
364
364
|
// workspace, never reported.
|
|
365
365
|
const pendingWrites = (args.config.pendingWrites ?? []).map((g) => new Bun.Glob(g))
|
|
366
366
|
const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
|
|
367
|
+
const mounted = new Set([
|
|
368
|
+
...allowAbs,
|
|
369
|
+
...bindableWrites(args.config.allowWrite).map((p) => toRealPath(absolutize(p))),
|
|
370
|
+
])
|
|
367
371
|
// A read under a widened write grant's directory is never refused, so it
|
|
368
372
|
// is reported when it succeeds: an entry that was there at the start and
|
|
369
373
|
// no grant covers is an input the key never saw, and so is the
|
|
@@ -388,12 +392,14 @@ export async function parseStraceViolations(
|
|
|
388
392
|
baselines.cwd,
|
|
389
393
|
widened.size > 0,
|
|
390
394
|
)) {
|
|
391
|
-
const
|
|
395
|
+
const lexical = absolutize(rawPath, dir ?? baselines.cwd)
|
|
396
|
+
const abs = toRealPath(lexical)
|
|
392
397
|
// Only report paths under the workspace-root deny anchor — system
|
|
393
398
|
// libs / /proc / /sys / etc. probes are not interesting violations.
|
|
394
399
|
if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
|
|
395
400
|
// Skip paths the user explicitly allowed (and their descendants).
|
|
396
|
-
if (isUnderAny(abs, allowAbs)
|
|
401
|
+
if (isUnderAny(abs, allowAbs) && !hiddenByLink(lexical, abs, mounted, denyAnchors)) continue
|
|
402
|
+
if (underGlob(abs, pendingWrites)) continue
|
|
397
403
|
if (read === true && !undeclared(abs)) continue
|
|
398
404
|
// A hidden path answers ENOENT, as a missing one does: a probe
|
|
399
405
|
// (`./gen.sh || …`, `test -x`, a PATH walk) is a refusal only where
|
|
@@ -427,6 +433,39 @@ export async function parseStraceViolations(
|
|
|
427
433
|
return out
|
|
428
434
|
}
|
|
429
435
|
|
|
436
|
+
/**
|
|
437
|
+
* A granted file the task could not open because a link on the path it
|
|
438
|
+
* opened is not in the sandbox. A grant is mounted at its real path and
|
|
439
|
+
* bwrap mounts no link, so `read: ['config.json']` over `config.json ->
|
|
440
|
+
* conf/real.json` bound the target and left the name the task opens out;
|
|
441
|
+
* judged by its real path, the ENOENT was granted and dropped, and a tool
|
|
442
|
+
* that fell back on the missing file was cached green. The link is there
|
|
443
|
+
* when the directory holding it is mounted; a target missing on the host
|
|
444
|
+
* is missing outside the sandbox too, and outside the deny anchor the
|
|
445
|
+
* host is mounted, links and all.
|
|
446
|
+
*/
|
|
447
|
+
function hiddenByLink(
|
|
448
|
+
lexical: string,
|
|
449
|
+
real: string,
|
|
450
|
+
mounted: Set<string>,
|
|
451
|
+
anchors: readonly string[],
|
|
452
|
+
): boolean {
|
|
453
|
+
if (lexical === real || !existsSync(real)) return false
|
|
454
|
+
let at = lexical
|
|
455
|
+
const links: string[] = []
|
|
456
|
+
for (let up = path.dirname(at); up !== at; at = up, up = path.dirname(at)) {
|
|
457
|
+
try {
|
|
458
|
+
if (lstatSync(at).isSymbolicLink()) links.push(at)
|
|
459
|
+
} catch {
|
|
460
|
+
// Not on the host by this spelling: no link to judge here.
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return links.some((l) => {
|
|
464
|
+
const holder = toRealPath(path.dirname(l))
|
|
465
|
+
return anchors.some((a) => atOrUnder(holder, a)) && !isUnderAny(holder, mounted)
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
|
|
430
469
|
/**
|
|
431
470
|
* Does a violation line match something the task said to ignore?
|
|
432
471
|
*
|
|
@@ -444,9 +483,11 @@ function matchesIgnore(
|
|
|
444
483
|
ignore: NonNullable<ResolvedSandboxConfig['ignore']>,
|
|
445
484
|
): boolean {
|
|
446
485
|
if (v.target === undefined || v.ignorable === undefined) return false
|
|
486
|
+
const proxy = PROXY_DENY_RE.test(v.line)
|
|
447
487
|
for (const which of v.ignorable) {
|
|
448
|
-
const
|
|
449
|
-
if (
|
|
488
|
+
const listed = ignore[which]
|
|
489
|
+
if (listed === undefined) continue
|
|
490
|
+
const patterns = proxy ? listed.map(hostKey) : listed
|
|
450
491
|
const subject = which === 'read' || which === 'write' ? (v.path ?? v.target) : v.target
|
|
451
492
|
if (patterns.some((pat) => pat === subject || new Bun.Glob(pat).match(subject))) return true
|
|
452
493
|
}
|
|
@@ -460,7 +501,7 @@ function matchesIgnore(
|
|
|
460
501
|
*/
|
|
461
502
|
function describeMacViolation(line: string): Partial<SandboxViolation> {
|
|
462
503
|
const proxy = PROXY_DENY_RE.exec(line)
|
|
463
|
-
if (proxy !== null) return { target: proxy[1]
|
|
504
|
+
if (proxy !== null) return { target: hostKey(proxy[1]!), ignorable: ['network'] }
|
|
464
505
|
const m = /deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/.exec(line)
|
|
465
506
|
if (m === null) return {}
|
|
466
507
|
const [op, target] = [m[1]!, m[2]!]
|
|
@@ -677,6 +718,18 @@ function filterIgnored(
|
|
|
677
718
|
*/
|
|
678
719
|
const PROXY_DENY_RE = /^deny network-outbound (\S+) \([^)]*\)$/
|
|
679
720
|
|
|
721
|
+
/**
|
|
722
|
+
* `<host>[:<port>]` as the proxy matches it: case-blind, no trailing dot.
|
|
723
|
+
* The record keeps the client's spelling (curl sends `EXAMPLE.Com`, curl
|
|
724
|
+
* and Bun keep `example.com.`), so an exact match against the ignore list
|
|
725
|
+
* left a refusal the task named in its report.
|
|
726
|
+
*/
|
|
727
|
+
function hostKey(target: string): string {
|
|
728
|
+
const colon = target.lastIndexOf(':')
|
|
729
|
+
const host = colon === -1 ? target : target.slice(0, colon)
|
|
730
|
+
return host.toLowerCase().replace(/\.$/, '') + (colon === -1 ? '' : target.slice(colon))
|
|
731
|
+
}
|
|
732
|
+
|
|
680
733
|
/**
|
|
681
734
|
* Linux: the connections the proxy refused, from the store records. Only
|
|
682
735
|
* the write observer's records were read there, so a task denied a host
|
package/src/graph/priorities.ts
CHANGED
|
@@ -152,8 +152,7 @@ export function mergePriorities(
|
|
|
152
152
|
// it sorts above the baseline for any node it covers, and add the
|
|
153
153
|
// baseline as a tie-break for parity within the override set.
|
|
154
154
|
const SCALE = 1 << 20
|
|
155
|
-
const out = new Map
|
|
156
|
-
for (const [id, w] of baseline) out.set(id, w)
|
|
155
|
+
const out = new Map(baseline)
|
|
157
156
|
for (const [id, w] of overrides) {
|
|
158
157
|
const b = baseline.get(id) ?? 0
|
|
159
158
|
out.set(id, w * SCALE + b)
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -179,8 +179,8 @@ export interface TaskOutcome {
|
|
|
179
179
|
/** The attempts that failed and were run again, in order: when each ended (epoch ms) and how. */
|
|
180
180
|
failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
|
|
181
181
|
/**
|
|
182
|
-
* Set when this run proved the task flaky: its key
|
|
183
|
-
*
|
|
182
|
+
* Set when this run proved the task flaky: its key failed after it had
|
|
183
|
+
* passed (this run's included) or it needed a retry. Its row carries it.
|
|
184
184
|
*/
|
|
185
185
|
flaky?: { passes: number; failures: number }
|
|
186
186
|
/**
|