@vzn/vx 0.0.594 → 0.0.596
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.md +1 -1
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +235 -174
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +122 -49
- package/src/cache/layer.ts +7 -5
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/policy.ts +5 -0
- package/src/cache/tar-stream.ts +1 -1
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +11 -15
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +26 -5
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/last.ts +4 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +60 -42
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +77 -13
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +16 -9
- package/src/cli/watch.ts +22 -15
- package/src/config.ts +26 -26
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +6 -2
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +12 -6
- package/src/exec/sandbox-runtime.ts +136 -44
- package/src/exec/sandbox-violations.ts +58 -11
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +29 -13
- package/src/graph/task-graph.ts +134 -25
- package/src/index.ts +6 -0
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +65 -20
- package/src/orchestrator/failure-mode.ts +8 -2
- package/src/orchestrator/framed-output.ts +3 -3
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/logger.ts +11 -6
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +2 -2
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +28 -4
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +39 -17
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +2 -1
- package/src/orchestrator/run.ts +72 -12
- package/src/orchestrator/sandbox-request.ts +65 -9
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +16 -5
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/util/index.ts +7 -1
- package/src/util/paths.ts +23 -3
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +8 -6
- package/src/workspace/config-schema.ts +74 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +76 -2
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +98 -20
|
@@ -279,9 +279,16 @@ export function dependencyReason(errors: readonly string[]): string {
|
|
|
279
279
|
* listen" on macOS and "Failed to create bridge sockets after 5 attempts"
|
|
280
280
|
* on Linux (its retry loop swallows the code), neither naming the
|
|
281
281
|
* directory (2026-09-16). Checked up front, with room for the sequence.
|
|
282
|
+
* On Linux the longer name is the network bridge's,
|
|
283
|
+
* `claude-http-<16 hex>.sock`, and it is the one that fails there.
|
|
282
284
|
*/
|
|
283
285
|
export function socketPathRefusal(tmpdir = os.tmpdir()): string | undefined {
|
|
284
|
-
const sample = path.join(
|
|
286
|
+
const sample = path.join(
|
|
287
|
+
tmpdir,
|
|
288
|
+
process.platform === 'linux'
|
|
289
|
+
? `claude-http-${'0'.repeat(16)}.sock`
|
|
290
|
+
: `srt-mux-${process.pid}-zzz.sock`,
|
|
291
|
+
)
|
|
285
292
|
const limit = process.platform === 'darwin' ? 103 : 107
|
|
286
293
|
const length = Buffer.byteLength(sample)
|
|
287
294
|
if (length <= limit) return undefined
|
|
@@ -1219,6 +1226,7 @@ async function wrapIn(
|
|
|
1219
1226
|
`holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
|
|
1220
1227
|
)
|
|
1221
1228
|
}
|
|
1229
|
+
if (ports.length > 0) writeFileSync(portDialScript(tag), PORT_DIAL_SCRIPT)
|
|
1222
1230
|
const grouped =
|
|
1223
1231
|
process.platform === 'linux'
|
|
1224
1232
|
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
@@ -1246,7 +1254,13 @@ async function wrapIn(
|
|
|
1246
1254
|
...macProfileRules(args.config),
|
|
1247
1255
|
...darwinWallRules(args.config, baselines.allowRead),
|
|
1248
1256
|
]
|
|
1249
|
-
|
|
1257
|
+
try {
|
|
1258
|
+
if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
|
|
1259
|
+
} catch (err) {
|
|
1260
|
+
releaseBridges(tag)
|
|
1261
|
+
afterCommand(SandboxManager)
|
|
1262
|
+
throw err
|
|
1263
|
+
}
|
|
1250
1264
|
}
|
|
1251
1265
|
// Linux: the shell execs bwrap, so bwrap is the spawn itself and its
|
|
1252
1266
|
// `--die-with-parent` is keyed to vx. Behind a shell that waited on it,
|
|
@@ -1383,15 +1397,24 @@ function ownGroupCommand(
|
|
|
1383
1397
|
.join(' ')
|
|
1384
1398
|
const body = shellQuote(`exec ${TRACE_FD}>&-; ${userCommand}`)
|
|
1385
1399
|
const run = `{ trap - INT QUIT; exec ${setsid ?? ''}${tracer} ${shellQuote(sh)} -c ${body} 3<&-; } & c=$!;`
|
|
1400
|
+
// Bash reports a job a signal killed on its stderr, the task's: a task
|
|
1401
|
+
// whose shell died of SIGKILL printed this whole wrapper (X-111).
|
|
1402
|
+
const wait = `wait "$c" 2>/dev/null`
|
|
1386
1403
|
if (setsid === undefined) {
|
|
1387
|
-
return { command: `${tag0} ${run} wait
|
|
1404
|
+
return { command: `${tag0} ${run} ${wait}`, forwards: false, traced: true }
|
|
1388
1405
|
}
|
|
1389
1406
|
const watch = `{ IFS= read -r s && kill -s "$s" -- "-$c"; } 2>/dev/null <&3 3<&- &`
|
|
1390
|
-
return { command: `${tag0} ${run} ${watch} wait
|
|
1407
|
+
return { command: `${tag0} ${run} ${watch} ${wait}`, forwards: true, traced: true }
|
|
1391
1408
|
}
|
|
1392
1409
|
|
|
1393
|
-
/**
|
|
1394
|
-
|
|
1410
|
+
/**
|
|
1411
|
+
* What strace stops on: the reads, the execs and access probes (a refused
|
|
1412
|
+
* `./gen.sh || fallback` passed unreported), and what moves or makes a
|
|
1413
|
+
* process's cwd. Not the stat family: a stop per stat cost oxlint 19% and
|
|
1414
|
+
* `git status` 30%, where the execs and probes cost nothing measurable.
|
|
1415
|
+
*/
|
|
1416
|
+
const TRACED_CALLS =
|
|
1417
|
+
'trace=openat,execve,?access,faccessat,?faccessat2,chdir,fchdir,clone,?clone3,?fork,?vfork'
|
|
1395
1418
|
|
|
1396
1419
|
/** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
|
|
1397
1420
|
const TRACE_FD = 5
|
|
@@ -1501,18 +1524,40 @@ export function portBridgeSocket(tag: string, port: number): string {
|
|
|
1501
1524
|
return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
|
|
1502
1525
|
}
|
|
1503
1526
|
|
|
1527
|
+
/**
|
|
1528
|
+
* Which loopback a bridged connection dials, chosen per connection from
|
|
1529
|
+
* the namespace's listen tables (`$1` the port, `$2` the tables' directory):
|
|
1530
|
+
* `::1` when only it holds the port, else 127.0.0.1. A server bound to
|
|
1531
|
+
* `localhost` on a host that resolves `::1` first (Vite's default) listens
|
|
1532
|
+
* on `::1` alone, and a fixed 127.0.0.1 dial was refused. Read before any
|
|
1533
|
+
* byte moves, so a connection is never retried halfway through.
|
|
1534
|
+
*/
|
|
1535
|
+
export const PORT_DIAL_SCRIPT = `p=$1 n=\${2:-/proc/net}
|
|
1536
|
+
h=$(printf %04X "$p")
|
|
1537
|
+
if grep -qsE "^ *[0-9]+: 0{24}01000000:$h [0-9A-F]+:0{4} 0A" "$n/tcp6" &&
|
|
1538
|
+
! grep -qsE "^ *[0-9]+: (0100007F|0{8}):$h [0-9A-F]+:0{4} 0A" "$n/tcp"; then
|
|
1539
|
+
exec socat - "TCP6:[::1]:$p"
|
|
1540
|
+
fi
|
|
1541
|
+
exec socat - "TCP4:127.0.0.1:$p"
|
|
1542
|
+
`
|
|
1543
|
+
|
|
1544
|
+
/** Where the dial script lives: beside the bridge's sockets, written by `wrapSandboxedCommand`. */
|
|
1545
|
+
function portDialScript(tag: string): string {
|
|
1546
|
+
return path.join(taskTmpdir(tag), `vx-port-dial-${tag}.sh`)
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1504
1549
|
/**
|
|
1505
1550
|
* The task's side of the bridge, in front of the user command inside the
|
|
1506
1551
|
* sandbox: one socat per port, listening on the unix socket and relaying
|
|
1507
|
-
* into the namespace's loopback
|
|
1508
|
-
* exactly as SRT starts its own proxy bridges.
|
|
1509
|
-
* socket a killed task left behind; `>/dev/null`
|
|
1510
|
-
* of the task's frame.
|
|
1552
|
+
* into the namespace's loopback through `PORT_DIAL_SCRIPT`. Backgrounded
|
|
1553
|
+
* and reaped with the shell, exactly as SRT starts its own proxy bridges.
|
|
1554
|
+
* `unlink-early` clears a socket a killed task left behind; `>/dev/null`
|
|
1555
|
+
* keeps its chatter out of the task's frame.
|
|
1511
1556
|
*/
|
|
1512
1557
|
export function portBridgeInner(ports: readonly number[], tag: string): string {
|
|
1513
1558
|
const cmds = ports.map(
|
|
1514
1559
|
(p) =>
|
|
1515
|
-
`socat UNIX-LISTEN:${shellQuote(portBridgeSocket(tag, p))},fork,unlink-early
|
|
1560
|
+
`socat UNIX-LISTEN:${shellQuote(portBridgeSocket(tag, p))},fork,unlink-early ${shellQuote(`SYSTEM:sh ${portDialScript(tag)} ${p}`)} >/dev/null 2>&1 &`,
|
|
1516
1561
|
)
|
|
1517
1562
|
return `${cmds.join(' ')} trap 'kill $(jobs -p) 2>/dev/null' EXIT;`
|
|
1518
1563
|
}
|
|
@@ -1646,15 +1691,9 @@ export function releaseBridges(tag: string): void {
|
|
|
1646
1691
|
const tmp = taskTmpdir(tag)
|
|
1647
1692
|
if (liveTaskTmpdirs.delete(tmp)) rmSync(tmp, { recursive: true, force: true })
|
|
1648
1693
|
if (liveServers.delete(tag)) {
|
|
1649
|
-
// A server's wrap
|
|
1650
|
-
//
|
|
1651
|
-
|
|
1652
|
-
// task's stubs in the workspace until the reset.
|
|
1653
|
-
try {
|
|
1654
|
-
srtLoaded!.SandboxManager.cleanupAfterCommand()
|
|
1655
|
-
} catch {
|
|
1656
|
-
// best-effort, as a one-shot task's
|
|
1657
|
-
}
|
|
1694
|
+
// A server's wrap stays counted until it stops: one stopped server
|
|
1695
|
+
// kept every later task's stubs in the workspace until the reset.
|
|
1696
|
+
afterCommand(srtLoaded!.SandboxManager)
|
|
1658
1697
|
if (liveServers.size === 0 && resetDeferred) void resetSandbox().catch(() => {})
|
|
1659
1698
|
}
|
|
1660
1699
|
const bridges = hostBridges.get(tag)
|
|
@@ -1778,11 +1817,11 @@ async function runSandboxedOnce(
|
|
|
1778
1817
|
// share a stream. Skipped when strace isn't on PATH — bwrap still
|
|
1779
1818
|
// enforces structurally; we just lose the structured violation list.
|
|
1780
1819
|
//
|
|
1781
|
-
// We trace
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1820
|
+
// We trace `openat`, the read itself, plus `execve` and the access
|
|
1821
|
+
// probes (`TRACED_CALLS`). Not `statx` / `newfstatat`: a stop per stat
|
|
1822
|
+
// is a tax on every stat-heavy task. A probe's ENOENT counts only where
|
|
1823
|
+
// the host has the path (`parseStraceViolations`), so a PATH walk past
|
|
1824
|
+
// a missing `node_modules/.bin` entry reports nothing.
|
|
1786
1825
|
//
|
|
1787
1826
|
// `--seccomp-bpf` is what makes that filter cheap: without it strace
|
|
1788
1827
|
// ptrace-stops the tracee on EVERY syscall and discards the untraced
|
|
@@ -1792,7 +1831,7 @@ async function runSandboxedOnce(
|
|
|
1792
1831
|
// 24/24; under `strace -f -e trace=openat` the same four fail with
|
|
1793
1832
|
// medians 2.5–7× over budget; with `--seccomp-bpf` 24/24 again), and
|
|
1794
1833
|
// it taxed every other sandboxed task the same way. With the flag the
|
|
1795
|
-
// kernel filter stops only on
|
|
1834
|
+
// kernel filter stops only on the traced calls. strace ≥ 5.3 (2019); an older
|
|
1796
1835
|
// one gets the slow form rather than no detection.
|
|
1797
1836
|
const useStrace = await wantsStraceDetection()
|
|
1798
1837
|
// Before the spawn: what the task creates under a widened grant is its own.
|
|
@@ -1983,6 +2022,7 @@ async function runSandboxedOnce(
|
|
|
1983
2022
|
records.map((v) => v.line),
|
|
1984
2023
|
bindableWrites(args.config.allowWrite),
|
|
1985
2024
|
scratch,
|
|
2025
|
+
[...baselines.allowRead, ...bindableReads(args.config.allowRead)],
|
|
1986
2026
|
),
|
|
1987
2027
|
...refusedConnections(records.map((v) => v.line)),
|
|
1988
2028
|
]
|
|
@@ -2016,6 +2056,15 @@ async function runSandboxedOnce(
|
|
|
2016
2056
|
})
|
|
2017
2057
|
if (outside.length > 0)
|
|
2018
2058
|
violations.push(outsideWritesHint(outside, baselines.denyRead, args.reportWithin))
|
|
2059
|
+
if (process.platform === 'linux') {
|
|
2060
|
+
const removed = grantRemovalHint(
|
|
2061
|
+
`${stdout}\n${stderr}`,
|
|
2062
|
+
bindableWrites(args.config.allowWrite),
|
|
2063
|
+
args.cwd,
|
|
2064
|
+
args.reportWithin,
|
|
2065
|
+
)
|
|
2066
|
+
if (removed !== undefined) violations.push(removed)
|
|
2067
|
+
}
|
|
2019
2068
|
}
|
|
2020
2069
|
|
|
2021
2070
|
// The one denial macOS never logs. MEASURED 2026-09-05, same machine, two
|
|
@@ -2127,6 +2176,45 @@ function outsideWritesHint(
|
|
|
2127
2176
|
return { timestamp: new Date(), hint: true, line }
|
|
2128
2177
|
}
|
|
2129
2178
|
|
|
2179
|
+
/**
|
|
2180
|
+
* Linux: a directory write grant is a bind mount, and a mount point cannot
|
|
2181
|
+
* be removed or renamed, so `rm -rf dist` empties it and then fails with
|
|
2182
|
+
* "Read-only file system" (EBUSY under a writable parent). Binding the
|
|
2183
|
+
* parent instead would let the task write beside the grant, so the
|
|
2184
|
+
* removal stays refused; a failure whose output names a grant on such an
|
|
2185
|
+
* error line gets the spelling that works.
|
|
2186
|
+
*/
|
|
2187
|
+
function grantRemovalHint(
|
|
2188
|
+
output: string,
|
|
2189
|
+
roots: readonly string[],
|
|
2190
|
+
cwd: string,
|
|
2191
|
+
within: string,
|
|
2192
|
+
): SandboxViolation | undefined {
|
|
2193
|
+
const lines = output
|
|
2194
|
+
.split('\n')
|
|
2195
|
+
.filter((l) => /Read-only file system|EROFS|EBUSY|resource busy/i.test(l))
|
|
2196
|
+
if (lines.length === 0) return undefined
|
|
2197
|
+
const named = roots.filter((root) => {
|
|
2198
|
+
const rel = path.relative(cwd, root)
|
|
2199
|
+
const spellings = rel === '' ? [root] : [root, rel]
|
|
2200
|
+
return spellings.some((s) => {
|
|
2201
|
+
const re = new RegExp(`(^|[\\s'"\`(])(\\./)?${RegExp.escape(s)}/?($|[\\s'"\`:,)])`)
|
|
2202
|
+
return lines.some((l) => re.test(l))
|
|
2203
|
+
})
|
|
2204
|
+
})
|
|
2205
|
+
if (named.length === 0) return undefined
|
|
2206
|
+
const grants = named.map((r) => `'${path.relative(within, r) || '.'}/'`).join(', ')
|
|
2207
|
+
const contents = path.relative(cwd, named[0]!) || '.'
|
|
2208
|
+
return {
|
|
2209
|
+
timestamp: new Date(),
|
|
2210
|
+
hint: true,
|
|
2211
|
+
line:
|
|
2212
|
+
`vx: the write grant ${grants} is mounted in place on Linux: the task may empty it ` +
|
|
2213
|
+
`but not remove or rename it ("Read-only file system"). Remove its contents ` +
|
|
2214
|
+
`instead, e.g. \`rm -rf ${contents}/*\`.`,
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2130
2218
|
/** A path as a JS string literal a config can take: a quote in it is escaped. */
|
|
2131
2219
|
const jsString = (p: string): string => (p.includes("'") ? JSON.stringify(p) : `'${p}'`)
|
|
2132
2220
|
|
|
@@ -2192,8 +2280,12 @@ function sbplToken(value: string, field: string): string {
|
|
|
2192
2280
|
*/
|
|
2193
2281
|
export function macProfileRules(c: ResolvedSandboxConfig): string[] {
|
|
2194
2282
|
const rules: string[] = []
|
|
2283
|
+
// A name is an info type to one caller and a sysctl to another (Bun
|
|
2284
|
+
// reads `hw.optional.neon` with sysctl-read), so it grants both.
|
|
2195
2285
|
for (const t of c.systemInfo ?? []) {
|
|
2196
|
-
|
|
2286
|
+
const name = sbplToken(t, 'allow.systemInfo')
|
|
2287
|
+
rules.push(`(allow system-info (info-type "${name}"))`)
|
|
2288
|
+
rules.push(`(allow sysctl-read (sysctl-name "${name}"))`)
|
|
2197
2289
|
}
|
|
2198
2290
|
if (localBindingOn(c)) {
|
|
2199
2291
|
// `*:*`, not `localhost:*`: a dual-stack socket bound to 127.0.0.1 is
|
|
@@ -2307,22 +2399,6 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
|
|
|
2307
2399
|
}
|
|
2308
2400
|
|
|
2309
2401
|
/**
|
|
2310
|
-
* Grant paths, with globs handled per platform.
|
|
2311
|
-
*
|
|
2312
|
-
* macOS: SRT's own `pathFilter` turns a glob into `(regex …)` and a literal
|
|
2313
|
-
* into `(subpath …)`, so a pattern is passed through and seatbelt matches
|
|
2314
|
-
* it — including files created DURING the run.
|
|
2315
|
-
*
|
|
2316
|
-
* Linux: a grant is a bwrap bind mount, and you cannot mount a pattern.
|
|
2317
|
-
* The glob is expanded against the filesystem here, which means it covers
|
|
2318
|
-
* what exists when the task STARTS. A pattern matching a file the task
|
|
2319
|
-
* creates later grants nothing there — declare its directory instead.
|
|
2320
|
-
*
|
|
2321
|
-
* That last sentence is the whole contract, and until item 496 a task
|
|
2322
|
-
* that broke it learned so from its OWN tool. Measured, one task per
|
|
2323
|
-
* spelling, each writing files it declares:
|
|
2324
|
-
*
|
|
2325
|
-
* write: ['g/**
|
|
2326
2402
|
* The walls a glob grant can reach: each one under (or at) a glob's
|
|
2327
2403
|
* literal head. On Linux `expandGrants` drops such a hit before the bind
|
|
2328
2404
|
* (B-1); seatbelt matches a glob as a path regex, with no hit to drop.
|
|
@@ -2367,7 +2443,23 @@ export function darwinWallRules(
|
|
|
2367
2443
|
return rules
|
|
2368
2444
|
}
|
|
2369
2445
|
|
|
2370
|
-
/**
|
|
2446
|
+
/**
|
|
2447
|
+
* Grant paths, with globs handled per platform.
|
|
2448
|
+
*
|
|
2449
|
+
* macOS: SRT's own `pathFilter` turns a glob into `(regex …)` and a literal
|
|
2450
|
+
* into `(subpath …)`, so a pattern is passed through and seatbelt matches
|
|
2451
|
+
* it — including files created DURING the run.
|
|
2452
|
+
*
|
|
2453
|
+
* Linux: a grant is a bwrap bind mount, and you cannot mount a pattern.
|
|
2454
|
+
* The glob is expanded against the filesystem here, which means it covers
|
|
2455
|
+
* what exists when the task STARTS. A pattern matching a file the task
|
|
2456
|
+
* creates later grants nothing there — declare its directory instead.
|
|
2457
|
+
*
|
|
2458
|
+
* That last sentence is the whole contract, and until item 496 a task
|
|
2459
|
+
* that broke it learned so from its OWN tool. Measured, one task per
|
|
2460
|
+
* spelling, each writing files it declares:
|
|
2461
|
+
*
|
|
2462
|
+
* write: ['g/**'] ok — collapsed to the directory
|
|
2371
2463
|
* write: ['g/a.txt'] ok — a literal is widened to its directory
|
|
2372
2464
|
* write: ['g/*'] FAILED: `bash: g/a.txt: Read-only file system`
|
|
2373
2465
|
* write: ['g/*.txt'] FAILED, same
|
|
@@ -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 { statSync } from 'node:fs'
|
|
7
|
+
import { 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'
|
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
* <pid> openat(AT_FDCWD, "<path>", <flags>) = -1 ENOENT (...)
|
|
25
25
|
* <pid> access("<path>", <mode>) = -1 EACCES (...)
|
|
26
26
|
* <pid> statx(AT_FDCWD, "<path>", <flags>, <mask>, ...) = -1 ENOENT (...)
|
|
27
|
+
* <pid> execve("<path>", ["<argv0>", ...], <envp>) = -1 ENOENT (...)
|
|
27
28
|
*
|
|
28
29
|
* …but ONLY when the syscall completes without another traced process
|
|
29
30
|
* interleaving. Under `-f` strace splits an interrupted call across two
|
|
@@ -40,21 +41,26 @@ import type {
|
|
|
40
41
|
* (`cStringPath` decodes it). Paths that are relative resolve against the
|
|
41
42
|
* task's cwd (set by Bun.spawn).
|
|
42
43
|
*/
|
|
43
|
-
const SYSCALLS = 'openat|access|statx|newfstatat'
|
|
44
|
+
const SYSCALLS = 'openat|execve|faccessat2|faccessat|access|statx|newfstatat'
|
|
44
45
|
const QUOTED = '"((?:[^"\\\\]|\\\\.)+)"'
|
|
45
46
|
// The directory descriptor a call names, `-y`'s path included: taken up to
|
|
46
47
|
// the `, "` that opens the path argument, since a directory's name may hold
|
|
47
48
|
// a quote (`4</ws/q"d>`). Captured: a relative path is in that directory.
|
|
48
49
|
const DIRFD = '(?:AT_FDCWD|\\d+)(?:<(.*?)>)?, '
|
|
50
|
+
// The rest of the call: an `execve`'s argv strings may hold a `)`
|
|
51
|
+
// (`sh -c "f()"`), so it reads to the result at the line's end.
|
|
52
|
+
const ARGS = '.*'
|
|
49
53
|
const STRACE_DONE_RE = new RegExp(
|
|
50
|
-
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}
|
|
54
|
+
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
|
|
51
55
|
)
|
|
52
56
|
const STRACE_UNFINISHED_RE = new RegExp(
|
|
53
|
-
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}
|
|
57
|
+
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}<unfinished`,
|
|
54
58
|
)
|
|
55
59
|
const STRACE_RESUMED_RE = new RegExp(
|
|
56
60
|
`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
|
|
57
61
|
)
|
|
62
|
+
/** The calls that ask whether a path is there rather than read it. */
|
|
63
|
+
const PROBES = new Set(['execve', 'faccessat2', 'faccessat', 'access', 'statx', 'newfstatat'])
|
|
58
64
|
/** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
|
|
59
65
|
const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
|
|
60
66
|
/**
|
|
@@ -352,6 +358,11 @@ export async function parseStraceViolations(
|
|
|
352
358
|
...args.config.allowWrite.filter((w) => bindableWrites([w]).length > 0),
|
|
353
359
|
].map((p) => toRealPath(absolutize(p))),
|
|
354
360
|
)
|
|
361
|
+
// A write glob no mount holds is a write grant too (`refusedWrites`
|
|
362
|
+
// counts its scratch): cp's probe of its destination there failed a task
|
|
363
|
+
// on its own output. One a read bind holds is readable; one outside the
|
|
364
|
+
// workspace, never reported.
|
|
365
|
+
const pendingWrites = (args.config.pendingWrites ?? []).map((g) => new Bun.Glob(g))
|
|
355
366
|
const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
|
|
356
367
|
// A read under a widened write grant's directory is never refused, so it
|
|
357
368
|
// is reported when it succeeds: an entry that was there at the start and
|
|
@@ -382,8 +393,13 @@ export async function parseStraceViolations(
|
|
|
382
393
|
// libs / /proc / /sys / etc. probes are not interesting violations.
|
|
383
394
|
if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
|
|
384
395
|
// Skip paths the user explicitly allowed (and their descendants).
|
|
385
|
-
if (isUnderAny(abs, allowAbs)) continue
|
|
396
|
+
if (isUnderAny(abs, allowAbs) || underGlob(abs, pendingWrites)) continue
|
|
386
397
|
if (read === true && !undeclared(abs)) continue
|
|
398
|
+
// A hidden path answers ENOENT, as a missing one does: a probe
|
|
399
|
+
// (`./gen.sh || …`, `test -x`, a PATH walk) is a refusal only where
|
|
400
|
+
// the host has the file.
|
|
401
|
+
if (errno === 'ENOENT' && PROBES.has(syscall) && !lstatSync(abs, { throwIfNoEntry: false }))
|
|
402
|
+
continue
|
|
387
403
|
const key = `${syscall}|${abs}`
|
|
388
404
|
if (seen.has(key)) continue
|
|
389
405
|
seen.add(key)
|
|
@@ -401,9 +417,9 @@ export async function parseStraceViolations(
|
|
|
401
417
|
timestamp: new Date(),
|
|
402
418
|
target: abs,
|
|
403
419
|
path: abs,
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
420
|
+
// An openat is a read or a write depending on flags the parse
|
|
421
|
+
// does not keep, and `access` may ask either — so either list
|
|
422
|
+
// can silence it.
|
|
407
423
|
ignorable: ['read', 'write'],
|
|
408
424
|
},
|
|
409
425
|
)
|
|
@@ -418,7 +434,10 @@ export async function parseStraceViolations(
|
|
|
418
434
|
* /path/x`, `deny(1) system-info vfs.disk-space` — so the operation picks
|
|
419
435
|
* the list and the target is matched against its patterns. Anything
|
|
420
436
|
* unparseable is NOT ignored: a record we cannot classify is exactly the
|
|
421
|
-
* one worth seeing.
|
|
437
|
+
* one worth seeing. A file list matches the record's `path`, where it
|
|
438
|
+
* lands, as `read` and `write` patterns are anchored and as the report's
|
|
439
|
+
* own boundary judges it: a seatbelt record named through a link was
|
|
440
|
+
* reported and its pattern never matched.
|
|
422
441
|
*/
|
|
423
442
|
function matchesIgnore(
|
|
424
443
|
v: SandboxViolation,
|
|
@@ -428,7 +447,8 @@ function matchesIgnore(
|
|
|
428
447
|
for (const which of v.ignorable) {
|
|
429
448
|
const patterns = ignore[which]
|
|
430
449
|
if (patterns === undefined) continue
|
|
431
|
-
|
|
450
|
+
const subject = which === 'read' || which === 'write' ? (v.path ?? v.target) : v.target
|
|
451
|
+
if (patterns.some((pat) => pat === subject || new Bun.Glob(pat).match(subject))) return true
|
|
432
452
|
}
|
|
433
453
|
return false
|
|
434
454
|
}
|
|
@@ -685,6 +705,8 @@ export function refusedWrites(
|
|
|
685
705
|
writable: readonly string[],
|
|
686
706
|
/** Write globs whose writes land in the sandbox's scratch (`scratchWrites`): granted. */
|
|
687
707
|
scratch: readonly string[] = [],
|
|
708
|
+
/** What the sandbox mounts readable, canonical: a mkdir of what is there is no write. */
|
|
709
|
+
readable: readonly string[] = [],
|
|
688
710
|
): SandboxViolation[] {
|
|
689
711
|
const binds = new Set(writable.map((w) => toRealPath(absolutize(w))))
|
|
690
712
|
const globs = scratch.map((g) => new Bun.Glob(g))
|
|
@@ -705,7 +727,9 @@ export function refusedWrites(
|
|
|
705
727
|
// the bind: `mkdir -p node_modules/.cache/tool` under a grant of
|
|
706
728
|
// `node_modules/.cache/` met EEXIST on `node_modules`, wrote nothing,
|
|
707
729
|
// and the attempt failed a clean task (2026-10-03).
|
|
708
|
-
|
|
730
|
+
// So does one a read grant shows, or holds: `mkdir -p src` under
|
|
731
|
+
// `read: ['src']` failed a clean task the same way (X-82).
|
|
732
|
+
if (syscall.startsWith('mkdir') && inSandbox(abs, binds, readable)) continue
|
|
709
733
|
const key = `${syscall}|${abs}`
|
|
710
734
|
if (seen.has(key)) continue
|
|
711
735
|
seen.add(key)
|
|
@@ -720,6 +744,29 @@ export function refusedWrites(
|
|
|
720
744
|
return out
|
|
721
745
|
}
|
|
722
746
|
|
|
747
|
+
/**
|
|
748
|
+
* `abs` exists inside the sandbox: a write bind lies at or under it (vx
|
|
749
|
+
* made each before the spawn), a read grant that exists does, or a read
|
|
750
|
+
* grant holds it and it exists. A read grant naming nothing is not
|
|
751
|
+
* mounted, so neither it nor its ancestors are there.
|
|
752
|
+
*/
|
|
753
|
+
function inSandbox(abs: string, binds: Set<string>, readable: readonly string[]): boolean {
|
|
754
|
+
return (
|
|
755
|
+
[...binds].some((b) => atOrUnder(b, abs)) ||
|
|
756
|
+
readable.some((r) => atOrUnder(r, abs) && exists(r)) ||
|
|
757
|
+
(readable.some((r) => atOrUnder(abs, r)) && exists(abs))
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function exists(p: string): boolean {
|
|
762
|
+
try {
|
|
763
|
+
lstatSync(p)
|
|
764
|
+
return true
|
|
765
|
+
} catch {
|
|
766
|
+
return false
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
723
770
|
/** `p`, or a directory holding it, matches one of `globs`. */
|
|
724
771
|
function underGlob(p: string, globs: readonly InstanceType<typeof Bun.Glob>[]): boolean {
|
|
725
772
|
if (globs.length === 0) return false
|
package/src/graph/index.ts
CHANGED
package/src/graph/scheduler.ts
CHANGED
|
@@ -120,6 +120,12 @@ export interface TaskOutcome {
|
|
|
120
120
|
* so a dependant that folds `hash` saves nothing either (A-12).
|
|
121
121
|
*/
|
|
122
122
|
unkeyed?: true
|
|
123
|
+
/**
|
|
124
|
+
* The run's cache policy read and wrote nothing (`--no-cache`,
|
|
125
|
+
* `--cache=local:`): no cache answered for this task, so a run of it is
|
|
126
|
+
* `no-cache`, not a miss. Set by `run()`; `ranNoCache` reads it.
|
|
127
|
+
*/
|
|
128
|
+
cacheOff?: true
|
|
123
129
|
/**
|
|
124
130
|
* On a `skipped` outcome: the task at the ROOT of what blocked it — the
|
|
125
131
|
* failed (or aborted) upstream, followed through any chain of skips
|
|
@@ -292,9 +298,10 @@ export interface ScheduleOptions {
|
|
|
292
298
|
* Admission over the worker count: asked for every exec-tier task about
|
|
293
299
|
* to start on this machine, with the ids of the exec-tier tasks running
|
|
294
300
|
* here right now (added on dispatch, so two asks in one tick see each
|
|
295
|
-
* other
|
|
296
|
-
*
|
|
297
|
-
*
|
|
301
|
+
* other; removed when `execute` resolves, which for a persistent task is
|
|
302
|
+
* at ready). `false` parks the task until something finishes. Undefined →
|
|
303
|
+
* count-only, the legacy path byte for byte. Restore-tier hits, pooled
|
|
304
|
+
* tasks and groups hold no local resources: never asked, never listed.
|
|
298
305
|
*/
|
|
299
306
|
admit?: (id: string, running: ReadonlySet<string>) => boolean
|
|
300
307
|
/**
|
|
@@ -394,19 +401,26 @@ class ReadyHeap {
|
|
|
394
401
|
/**
|
|
395
402
|
* The dead server a dependency stands for: itself, or one a group reaches.
|
|
396
403
|
* A group is a name for its deps, and it finished the moment the server
|
|
397
|
-
* was ready, so a task behind it ran against the dead one.
|
|
404
|
+
* was ready, so a task behind it ran against the dead one. Walked with a
|
|
405
|
+
* stack and a seen set: recursion threw `RangeError` on a deep chain of
|
|
406
|
+
* groups, and re-walked a group each path reached it by.
|
|
398
407
|
*/
|
|
399
408
|
export function deadServerBehind(
|
|
400
409
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
401
410
|
serverDied: (id: string) => boolean,
|
|
402
411
|
id: string,
|
|
403
412
|
): string | undefined {
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
413
|
+
const stack = [id]
|
|
414
|
+
const seen = new Set<string>()
|
|
415
|
+
while (stack.length > 0) {
|
|
416
|
+
const at = stack.pop()!
|
|
417
|
+
if (seen.has(at)) continue
|
|
418
|
+
seen.add(at)
|
|
419
|
+
if (serverDied(at)) return at
|
|
420
|
+
const n = nodes.get(at)
|
|
421
|
+
if (n === undefined || !isGroupTask(n)) continue
|
|
422
|
+
// Reversed, so the first dep is walked first, as the order it names them.
|
|
423
|
+
for (let i = n.deps.length - 1; i >= 0; i--) stack.push(n.deps[i]!)
|
|
410
424
|
}
|
|
411
425
|
return undefined
|
|
412
426
|
}
|
|
@@ -582,8 +596,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
582
596
|
}
|
|
583
597
|
}
|
|
584
598
|
// The policy sees exec-tier local tasks only; a restore is a tar
|
|
585
|
-
// extract
|
|
586
|
-
|
|
599
|
+
// extract, a pooled task runs on someone else's capacity, and a group
|
|
600
|
+
// runs nothing, so a packing policy that counted one parked work for it.
|
|
601
|
+
const local = (id: string): boolean =>
|
|
602
|
+
!inRestoreTier(id) && poolOf?.(id) === undefined && !isGroupTask(nodes.get(id) as TaskNode)
|
|
587
603
|
const admits = (id: string): boolean => !local(id) || admitPolicy!(id, running)
|
|
588
604
|
// With no policy nothing reads `running`, so nothing is tracked: the
|
|
589
605
|
// count-only dispatch allocates no closure and touches no set per task.
|
|
@@ -969,7 +985,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
969
985
|
}
|
|
970
986
|
|
|
971
987
|
/** Persistent tasks nobody asked for whose every dependant is a restore-tier hit. */
|
|
972
|
-
function idleServers(
|
|
988
|
+
export function idleServers(
|
|
973
989
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
974
990
|
dependents: ReadonlyMap<string, string[]>,
|
|
975
991
|
restoreTier: ReadonlySet<string> | undefined,
|