@vzn/vx 0.0.595 → 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/cache/cache.ts +139 -84
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +12 -12
- package/src/cache/layer.ts +7 -5
- package/src/cache/policy.ts +5 -0
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +10 -4
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/last.ts +4 -1
- package/src/cli/run.ts +31 -15
- package/src/cli/watch-filter.ts +63 -12
- package/src/cli/watch-judge.ts +15 -8
- package/src/cli/watch.ts +4 -4
- package/src/config.ts +21 -21
- package/src/exec/proc-sample.ts +1 -1
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +116 -40
- package/src/exec/sandbox-violations.ts +58 -11
- package/src/graph/scheduler.ts +28 -12
- package/src/graph/task-graph.ts +134 -25
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +36 -13
- package/src/orchestrator/failure-mode.ts +8 -2
- package/src/orchestrator/framed-output.ts +3 -3
- 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 +3 -3
- package/src/orchestrator/plugin.ts +37 -16
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +2 -1
- package/src/orchestrator/run.ts +20 -4
- package/src/orchestrator/sandbox-request.ts +65 -9
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/summary.ts +2 -4
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/util/paths.ts +23 -3
- package/src/workspace/config-imports.ts +7 -5
- package/src/workspace/config-schema.ts +26 -3
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/migrate-scripts.ts +4 -2
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +4 -2
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +17 -10
|
@@ -1226,6 +1226,7 @@ async function wrapIn(
|
|
|
1226
1226
|
`holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
|
|
1227
1227
|
)
|
|
1228
1228
|
}
|
|
1229
|
+
if (ports.length > 0) writeFileSync(portDialScript(tag), PORT_DIAL_SCRIPT)
|
|
1229
1230
|
const grouped =
|
|
1230
1231
|
process.platform === 'linux'
|
|
1231
1232
|
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
@@ -1406,8 +1407,14 @@ function ownGroupCommand(
|
|
|
1406
1407
|
return { command: `${tag0} ${run} ${watch} ${wait}`, forwards: true, traced: true }
|
|
1407
1408
|
}
|
|
1408
1409
|
|
|
1409
|
-
/**
|
|
1410
|
-
|
|
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'
|
|
1411
1418
|
|
|
1412
1419
|
/** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
|
|
1413
1420
|
const TRACE_FD = 5
|
|
@@ -1517,18 +1524,40 @@ export function portBridgeSocket(tag: string, port: number): string {
|
|
|
1517
1524
|
return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
|
|
1518
1525
|
}
|
|
1519
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
|
+
|
|
1520
1549
|
/**
|
|
1521
1550
|
* The task's side of the bridge, in front of the user command inside the
|
|
1522
1551
|
* sandbox: one socat per port, listening on the unix socket and relaying
|
|
1523
|
-
* into the namespace's loopback
|
|
1524
|
-
* exactly as SRT starts its own proxy bridges.
|
|
1525
|
-
* socket a killed task left behind; `>/dev/null`
|
|
1526
|
-
* 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.
|
|
1527
1556
|
*/
|
|
1528
1557
|
export function portBridgeInner(ports: readonly number[], tag: string): string {
|
|
1529
1558
|
const cmds = ports.map(
|
|
1530
1559
|
(p) =>
|
|
1531
|
-
`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 &`,
|
|
1532
1561
|
)
|
|
1533
1562
|
return `${cmds.join(' ')} trap 'kill $(jobs -p) 2>/dev/null' EXIT;`
|
|
1534
1563
|
}
|
|
@@ -1662,15 +1691,9 @@ export function releaseBridges(tag: string): void {
|
|
|
1662
1691
|
const tmp = taskTmpdir(tag)
|
|
1663
1692
|
if (liveTaskTmpdirs.delete(tmp)) rmSync(tmp, { recursive: true, force: true })
|
|
1664
1693
|
if (liveServers.delete(tag)) {
|
|
1665
|
-
// A server's wrap
|
|
1666
|
-
//
|
|
1667
|
-
|
|
1668
|
-
// task's stubs in the workspace until the reset.
|
|
1669
|
-
try {
|
|
1670
|
-
srtLoaded!.SandboxManager.cleanupAfterCommand()
|
|
1671
|
-
} catch {
|
|
1672
|
-
// best-effort, as a one-shot task's
|
|
1673
|
-
}
|
|
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)
|
|
1674
1697
|
if (liveServers.size === 0 && resetDeferred) void resetSandbox().catch(() => {})
|
|
1675
1698
|
}
|
|
1676
1699
|
const bridges = hostBridges.get(tag)
|
|
@@ -1794,11 +1817,11 @@ async function runSandboxedOnce(
|
|
|
1794
1817
|
// share a stream. Skipped when strace isn't on PATH — bwrap still
|
|
1795
1818
|
// enforces structurally; we just lose the structured violation list.
|
|
1796
1819
|
//
|
|
1797
|
-
// We trace
|
|
1798
|
-
//
|
|
1799
|
-
//
|
|
1800
|
-
//
|
|
1801
|
-
//
|
|
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.
|
|
1802
1825
|
//
|
|
1803
1826
|
// `--seccomp-bpf` is what makes that filter cheap: without it strace
|
|
1804
1827
|
// ptrace-stops the tracee on EVERY syscall and discards the untraced
|
|
@@ -1808,7 +1831,7 @@ async function runSandboxedOnce(
|
|
|
1808
1831
|
// 24/24; under `strace -f -e trace=openat` the same four fail with
|
|
1809
1832
|
// medians 2.5–7× over budget; with `--seccomp-bpf` 24/24 again), and
|
|
1810
1833
|
// it taxed every other sandboxed task the same way. With the flag the
|
|
1811
|
-
// kernel filter stops only on
|
|
1834
|
+
// kernel filter stops only on the traced calls. strace ≥ 5.3 (2019); an older
|
|
1812
1835
|
// one gets the slow form rather than no detection.
|
|
1813
1836
|
const useStrace = await wantsStraceDetection()
|
|
1814
1837
|
// Before the spawn: what the task creates under a widened grant is its own.
|
|
@@ -1999,6 +2022,7 @@ async function runSandboxedOnce(
|
|
|
1999
2022
|
records.map((v) => v.line),
|
|
2000
2023
|
bindableWrites(args.config.allowWrite),
|
|
2001
2024
|
scratch,
|
|
2025
|
+
[...baselines.allowRead, ...bindableReads(args.config.allowRead)],
|
|
2002
2026
|
),
|
|
2003
2027
|
...refusedConnections(records.map((v) => v.line)),
|
|
2004
2028
|
]
|
|
@@ -2032,6 +2056,15 @@ async function runSandboxedOnce(
|
|
|
2032
2056
|
})
|
|
2033
2057
|
if (outside.length > 0)
|
|
2034
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
|
+
}
|
|
2035
2068
|
}
|
|
2036
2069
|
|
|
2037
2070
|
// The one denial macOS never logs. MEASURED 2026-09-05, same machine, two
|
|
@@ -2143,6 +2176,45 @@ function outsideWritesHint(
|
|
|
2143
2176
|
return { timestamp: new Date(), hint: true, line }
|
|
2144
2177
|
}
|
|
2145
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
|
+
|
|
2146
2218
|
/** A path as a JS string literal a config can take: a quote in it is escaped. */
|
|
2147
2219
|
const jsString = (p: string): string => (p.includes("'") ? JSON.stringify(p) : `'${p}'`)
|
|
2148
2220
|
|
|
@@ -2208,8 +2280,12 @@ function sbplToken(value: string, field: string): string {
|
|
|
2208
2280
|
*/
|
|
2209
2281
|
export function macProfileRules(c: ResolvedSandboxConfig): string[] {
|
|
2210
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.
|
|
2211
2285
|
for (const t of c.systemInfo ?? []) {
|
|
2212
|
-
|
|
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}"))`)
|
|
2213
2289
|
}
|
|
2214
2290
|
if (localBindingOn(c)) {
|
|
2215
2291
|
// `*:*`, not `localhost:*`: a dual-stack socket bound to 127.0.0.1 is
|
|
@@ -2323,22 +2399,6 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
|
|
|
2323
2399
|
}
|
|
2324
2400
|
|
|
2325
2401
|
/**
|
|
2326
|
-
* Grant paths, with globs handled per platform.
|
|
2327
|
-
*
|
|
2328
|
-
* macOS: SRT's own `pathFilter` turns a glob into `(regex …)` and a literal
|
|
2329
|
-
* into `(subpath …)`, so a pattern is passed through and seatbelt matches
|
|
2330
|
-
* it — including files created DURING the run.
|
|
2331
|
-
*
|
|
2332
|
-
* Linux: a grant is a bwrap bind mount, and you cannot mount a pattern.
|
|
2333
|
-
* The glob is expanded against the filesystem here, which means it covers
|
|
2334
|
-
* what exists when the task STARTS. A pattern matching a file the task
|
|
2335
|
-
* creates later grants nothing there — declare its directory instead.
|
|
2336
|
-
*
|
|
2337
|
-
* That last sentence is the whole contract, and until item 496 a task
|
|
2338
|
-
* that broke it learned so from its OWN tool. Measured, one task per
|
|
2339
|
-
* spelling, each writing files it declares:
|
|
2340
|
-
*
|
|
2341
|
-
* write: ['g/**
|
|
2342
2402
|
* The walls a glob grant can reach: each one under (or at) a glob's
|
|
2343
2403
|
* literal head. On Linux `expandGrants` drops such a hit before the bind
|
|
2344
2404
|
* (B-1); seatbelt matches a glob as a path regex, with no hit to drop.
|
|
@@ -2383,7 +2443,23 @@ export function darwinWallRules(
|
|
|
2383
2443
|
return rules
|
|
2384
2444
|
}
|
|
2385
2445
|
|
|
2386
|
-
/**
|
|
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
|
|
2387
2463
|
* write: ['g/a.txt'] ok — a literal is widened to its directory
|
|
2388
2464
|
* write: ['g/*'] FAILED: `bash: g/a.txt: Read-only file system`
|
|
2389
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/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.
|