@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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/cache/cache.ts +139 -84
  4. package/src/cache/chained-cache.ts +25 -7
  5. package/src/cache/index.ts +1 -0
  6. package/src/cache/inputs.ts +12 -12
  7. package/src/cache/layer.ts +7 -5
  8. package/src/cache/policy.ts +5 -0
  9. package/src/cache/zstd.ts +10 -4
  10. package/src/cli/cache.ts +10 -4
  11. package/src/cli/foreign-flags.ts +1 -1
  12. package/src/cli/last.ts +4 -1
  13. package/src/cli/run.ts +31 -15
  14. package/src/cli/watch-filter.ts +63 -12
  15. package/src/cli/watch-judge.ts +15 -8
  16. package/src/cli/watch.ts +4 -4
  17. package/src/config.ts +21 -21
  18. package/src/exec/proc-sample.ts +1 -1
  19. package/src/exec/sandbox-paths.ts +1 -1
  20. package/src/exec/sandbox-runtime.ts +116 -40
  21. package/src/exec/sandbox-violations.ts +58 -11
  22. package/src/graph/scheduler.ts +28 -12
  23. package/src/graph/task-graph.ts +134 -25
  24. package/src/orchestrator/admission.ts +12 -3
  25. package/src/orchestrator/events.ts +10 -1
  26. package/src/orchestrator/execute-task.ts +36 -13
  27. package/src/orchestrator/failure-mode.ts +8 -2
  28. package/src/orchestrator/framed-output.ts +3 -3
  29. package/src/orchestrator/logger.ts +11 -6
  30. package/src/orchestrator/metrics.ts +19 -8
  31. package/src/orchestrator/miss-save.ts +2 -2
  32. package/src/orchestrator/options.ts +7 -0
  33. package/src/orchestrator/plan.ts +3 -3
  34. package/src/orchestrator/plugin.ts +37 -16
  35. package/src/orchestrator/prepare.ts +13 -4
  36. package/src/orchestrator/run-artifacts.ts +2 -1
  37. package/src/orchestrator/run.ts +20 -4
  38. package/src/orchestrator/sandbox-request.ts +65 -9
  39. package/src/orchestrator/save-lane.ts +5 -5
  40. package/src/orchestrator/summary.ts +2 -4
  41. package/src/orchestrator/telemetry.ts +16 -7
  42. package/src/util/paths.ts +23 -3
  43. package/src/workspace/config-imports.ts +7 -5
  44. package/src/workspace/config-schema.ts +26 -3
  45. package/src/workspace/filter.ts +19 -0
  46. package/src/workspace/migrate-scripts.ts +4 -2
  47. package/src/workspace/migration.ts +1 -1
  48. package/src/workspace/project-loader.ts +4 -2
  49. package/src/workspace/repo-id.ts +42 -4
  50. 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
- /** What strace stops on: the reads, and what moves or makes a process's cwd. */
1410
- 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'
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. Backgrounded and reaped with the shell,
1524
- * exactly as SRT starts its own proxy bridges. `unlink-early` clears a
1525
- * socket a killed task left behind; `>/dev/null` keeps its chatter out
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 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 &`,
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 counts as a live sandbox in SRT until this, and SRT
1666
- // removes bwrap's host stubs (`.bashrc`, `.vscode`, … under a write
1667
- // grant) only at a count of 0: one stopped server kept every later
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 only `openat` — it's the actual file-read attempt, the
1798
- // signal the user cares about. `statx` / `newfstatat` / `access`
1799
- // are mostly shell PATH-walking and stat probes that aren't
1800
- // actionable (we'd report every node_modules/.bin entry the shell
1801
- // 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.
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 `openat`. strace ≥ 5.3 (2019); an older
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
- 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}"))`)
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
- /**'] 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
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}[^)]*\\)\\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
@@ -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.