@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.
Files changed (80) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +2 -2
  5. package/src/cache/cache.ts +235 -174
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/config-evals.ts +19 -4
  8. package/src/cache/git-inputs.ts +19 -8
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/inputs.ts +122 -49
  11. package/src/cache/layer.ts +7 -5
  12. package/src/cache/layered-cache.ts +25 -2
  13. package/src/cache/policy.ts +5 -0
  14. package/src/cache/tar-stream.ts +1 -1
  15. package/src/cache/zstd.ts +10 -4
  16. package/src/cli/cache.ts +11 -15
  17. package/src/cli/completions.ts +42 -6
  18. package/src/cli/foreign-flags.ts +26 -5
  19. package/src/cli/help.ts +5 -3
  20. package/src/cli/init.ts +1 -1
  21. package/src/cli/last.ts +4 -1
  22. package/src/cli/plan-format.ts +9 -0
  23. package/src/cli/plugin-templates.ts +2 -2
  24. package/src/cli/run.ts +60 -42
  25. package/src/cli/select.ts +30 -3
  26. package/src/cli/show.ts +42 -3
  27. package/src/cli/watch-filter.ts +77 -13
  28. package/src/cli/watch-fs.ts +15 -7
  29. package/src/cli/watch-judge.ts +16 -9
  30. package/src/cli/watch.ts +22 -15
  31. package/src/config.ts +26 -26
  32. package/src/exec/env.ts +5 -0
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +6 -2
  35. package/src/exec/runner.ts +98 -40
  36. package/src/exec/sandbox-paths.ts +12 -6
  37. package/src/exec/sandbox-runtime.ts +136 -44
  38. package/src/exec/sandbox-violations.ts +58 -11
  39. package/src/graph/index.ts +1 -0
  40. package/src/graph/scheduler.ts +29 -13
  41. package/src/graph/task-graph.ts +134 -25
  42. package/src/index.ts +6 -0
  43. package/src/orchestrator/admission.ts +12 -3
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/execute-task.ts +65 -20
  46. package/src/orchestrator/failure-mode.ts +8 -2
  47. package/src/orchestrator/framed-output.ts +3 -3
  48. package/src/orchestrator/hit-restore.ts +6 -3
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +43 -46
  51. package/src/orchestrator/logger.ts +11 -6
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +2 -2
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +28 -4
  56. package/src/orchestrator/plugin-host.ts +25 -3
  57. package/src/orchestrator/plugin.ts +39 -17
  58. package/src/orchestrator/prepare.ts +13 -4
  59. package/src/orchestrator/run-artifacts.ts +2 -1
  60. package/src/orchestrator/run.ts +72 -12
  61. package/src/orchestrator/sandbox-request.ts +65 -9
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +5 -2
  64. package/src/orchestrator/summary.ts +16 -5
  65. package/src/orchestrator/telemetry.ts +16 -7
  66. package/src/util/index.ts +7 -1
  67. package/src/util/paths.ts +23 -3
  68. package/src/util/secret-mask.ts +36 -0
  69. package/src/util/settle.ts +15 -0
  70. package/src/workspace/affected.ts +30 -8
  71. package/src/workspace/config-cache.ts +25 -4
  72. package/src/workspace/config-imports.ts +8 -6
  73. package/src/workspace/config-schema.ts +74 -9
  74. package/src/workspace/filter.ts +19 -0
  75. package/src/workspace/migrate-scripts.ts +5 -3
  76. package/src/workspace/migration.ts +1 -1
  77. package/src/workspace/package-graph.ts +15 -3
  78. package/src/workspace/project-loader.ts +76 -2
  79. package/src/workspace/repo-id.ts +42 -4
  80. 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(tmpdir, `srt-mux-${process.pid}-zzz.sock`)
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
- if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
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 "$c"`, forwards: false, traced: true }
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 "$c"`, forwards: true, traced: true }
1407
+ return { command: `${tag0} ${run} ${watch} ${wait}`, forwards: true, traced: true }
1391
1408
  }
1392
1409
 
1393
- /** What strace stops on: the reads, and what moves or makes a process's cwd. */
1394
- const TRACED_CALLS = 'trace=openat,chdir,fchdir,clone,?clone3,?fork,?vfork'
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. Backgrounded and reaped with the shell,
1508
- * exactly as SRT starts its own proxy bridges. `unlink-early` clears a
1509
- * socket a killed task left behind; `>/dev/null` keeps its chatter out
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 TCP:127.0.0.1:${p} >/dev/null 2>&1 &`,
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 counts as a live sandbox in SRT until this, and SRT
1650
- // removes bwrap's host stubs (`.bashrc`, `.vscode`, … under a write
1651
- // grant) only at a count of 0: one stopped server kept every later
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 only `openat` — it's the actual file-read attempt, the
1782
- // signal the user cares about. `statx` / `newfstatat` / `access`
1783
- // are mostly shell PATH-walking and stat probes that aren't
1784
- // actionable (we'd report every node_modules/.bin entry the shell
1785
- // checks before resolving a command).
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 `openat`. strace ≥ 5.3 (2019); an older
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
- rules.push(`(allow system-info (info-type "${sbplToken(t, 'allow.systemInfo')}"))`)
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
- /**'] ok — collapsed to the directory
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}[^)]*\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
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}[^)]*<unfinished`,
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
- // The trace is `-e trace=openat`, and an openat is a read or a
405
- // write depending on flags the trace does not carry — so either
406
- // list can silence it.
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
- if (patterns.some((pat) => pat === v.target || new Bun.Glob(pat).match(v.target!))) return true
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
- if (syscall.startsWith('mkdir') && [...binds].some((b) => atOrUnder(b, abs))) continue
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
@@ -11,6 +11,7 @@ export {
11
11
  export {
12
12
  type ContinueMode,
13
13
  deadServerBehind,
14
+ idleServers,
14
15
  type InputChanges,
15
16
  RestoreDemoted,
16
17
  runGraph,
@@ -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). `false` parks the task until something finishes. Undefined →
296
- * count-only, the legacy path byte for byte. Restore-tier hits and
297
- * pooled tasks hold no local resources and are never asked.
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
- if (serverDied(id)) return id
405
- const n = nodes.get(id)
406
- if (n === undefined || !isGroupTask(n)) return undefined
407
- for (const d of n.deps) {
408
- const dead = deadServerBehind(nodes, serverDied, d)
409
- if (dead !== undefined) return dead
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 and a pooled task runs on someone else's capacity.
586
- const local = (id: string): boolean => !inRestoreTier(id) && poolOf?.(id) === undefined
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,