@vzn/vx 0.0.596 → 0.0.598

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