@vzn/vx 0.0.596 → 0.0.597

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 (64) hide show
  1. package/launcher.cjs +7 -3
  2. package/package.json +5 -5
  3. package/src/cache/archive.ts +35 -6
  4. package/src/cache/cache.ts +31 -15
  5. package/src/cache/git-inputs.ts +23 -0
  6. package/src/cache/index.ts +2 -0
  7. package/src/cache/inputs.ts +87 -32
  8. package/src/cache/key-fold.ts +1 -1
  9. package/src/cache/layer.ts +2 -1
  10. package/src/cli/cache.ts +2 -2
  11. package/src/cli/foreign-flags.ts +56 -2
  12. package/src/cli/info.ts +1 -1
  13. package/src/cli/init.ts +2 -2
  14. package/src/cli/last.ts +6 -3
  15. package/src/cli/lock.ts +5 -3
  16. package/src/cli/plan-format.ts +4 -4
  17. package/src/cli/run.ts +42 -15
  18. package/src/cli/select.ts +6 -3
  19. package/src/cli/show.ts +8 -2
  20. package/src/cli/upgrade.ts +1 -1
  21. package/src/cli/watch-fs.ts +11 -3
  22. package/src/cli/watch.ts +14 -7
  23. package/src/cli/workspace-config.ts +3 -1
  24. package/src/config.ts +21 -0
  25. package/src/exec/executor.ts +1 -1
  26. package/src/exec/kill-tree.ts +89 -18
  27. package/src/exec/runner.ts +74 -36
  28. package/src/exec/sandbox-paths.ts +11 -0
  29. package/src/exec/sandbox-runtime.ts +168 -49
  30. package/src/exec/sandbox-violations.ts +59 -6
  31. package/src/graph/scheduler.ts +2 -2
  32. package/src/graph/task-graph.ts +145 -33
  33. package/src/orchestrator/affected-tasks.ts +6 -2
  34. package/src/orchestrator/deferred-outputs.ts +3 -2
  35. package/src/orchestrator/doctor.ts +2 -2
  36. package/src/orchestrator/excluded-keys.ts +2 -13
  37. package/src/orchestrator/execute-task.ts +49 -26
  38. package/src/orchestrator/failure-mode.ts +38 -29
  39. package/src/orchestrator/framed-output.ts +5 -5
  40. package/src/orchestrator/index.ts +0 -1
  41. package/src/orchestrator/keyed-projects.ts +6 -3
  42. package/src/orchestrator/logger.ts +10 -3
  43. package/src/orchestrator/miss-save.ts +7 -11
  44. package/src/orchestrator/plan.ts +4 -20
  45. package/src/orchestrator/run-artifacts.ts +2 -2
  46. package/src/orchestrator/run-lock.ts +25 -24
  47. package/src/orchestrator/run.ts +2 -1
  48. package/src/orchestrator/sandbox-request.ts +31 -9
  49. package/src/orchestrator/signals.ts +22 -4
  50. package/src/orchestrator/stable-keys.ts +3 -11
  51. package/src/orchestrator/summary.ts +5 -9
  52. package/src/orchestrator/task-hash.ts +13 -3
  53. package/src/orchestrator/upstream.ts +11 -0
  54. package/src/util/hangup.ts +42 -0
  55. package/src/util/index.ts +2 -0
  56. package/src/util/paths.ts +1 -1
  57. package/src/workspace/affected.ts +79 -42
  58. package/src/workspace/config-eval.ts +27 -6
  59. package/src/workspace/config-imports.ts +8 -55
  60. package/src/workspace/config-schema.ts +30 -6
  61. package/src/workspace/lockfile.ts +2 -1
  62. package/src/workspace/migrate-scripts.ts +1 -1
  63. package/src/workspace/project-loader.ts +15 -2
  64. package/src/workspace/workspace.ts +36 -14
@@ -18,8 +18,8 @@ import {
18
18
  closeSignalChannel,
19
19
  holdGroups,
20
20
  killTree,
21
- markGroupIfGone,
22
21
  releaseGroup,
22
+ releaseServerGroup,
23
23
  signalThrough,
24
24
  guardLine,
25
25
  spawnGuarded,
@@ -105,6 +105,36 @@ function taskStdio(terminal: boolean | undefined, stdin: 'ignore' | 'pipe') {
105
105
  : ([stdin, 'pipe', 'pipe'] as const)
106
106
  }
107
107
 
108
+ /**
109
+ * Read the terminal's modes before a task that holds it runs, and return
110
+ * what puts them back once it exits: a task killed in raw mode (or one
111
+ * that never undid its `stty raw -echo`) left the user's terminal with no
112
+ * echo and no Ctrl-C. Bun restores them at exit only when vx's stdout is
113
+ * the terminal, so `vx run … > log` kept them, and the rest of a run
114
+ * printed in them. `detached`, as the task is: a vx in a background job
115
+ * would be stopped by SIGTTOU on the set.
116
+ */
117
+ function keepTerminalModes(): (() => void) | undefined {
118
+ const saved = stty('-g')
119
+ if (saved === undefined) return undefined
120
+ return () => {
121
+ if (stty('-g') !== saved) stty(saved)
122
+ }
123
+ }
124
+
125
+ function stty(arg: string): string | undefined {
126
+ try {
127
+ const out = Bun.spawnSync(['stty', arg], {
128
+ stdio: ['inherit', 'pipe', 'ignore'],
129
+ detached: true,
130
+ })
131
+ return out.success ? out.stdout.toString().trim() : undefined
132
+ } catch {
133
+ // No stty: the modes stay the task's, as before.
134
+ return undefined
135
+ }
136
+ }
137
+
108
138
  export function shellQuote(arg: string): string {
109
139
  if (arg === '') return `''`
110
140
  // A `#` opens a comment only at a word's start, so `app#build` stays bare.
@@ -485,12 +515,14 @@ const READY_MATCH_WINDOW_CHARS = 64 * 1024
485
515
 
486
516
  /**
487
517
  * Terminal escapes a `readyWhen` pattern is matched without: CSI (colour,
488
- * cursor), OSC (titles, links) and the two-byte forms. Vite under
518
+ * cursor), OSC (titles, links), charset picks (`tput sgr0` writes
519
+ * `\x1b(B\x1b[m`) and the two-byte forms. Vite under
489
520
  * `FORCE_COLOR` prints `\x1b[1mLocal\x1b[22m:`, which `Local:` never matched
490
521
  * (item 1059). The streamed bytes keep them; only the tested text drops them.
491
522
  */
492
- // eslint-disable-next-line no-control-regex -- ESC is the point
493
- const TERMINAL_ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g
523
+ const TERMINAL_ESCAPE_RE =
524
+ // eslint-disable-next-line no-control-regex -- ESC is the point
525
+ /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[ -/]+[0-~]|[@-Z\\-_])/g
494
526
 
495
527
  /**
496
528
  * Why a persistent task never became ready — the reason every label and
@@ -566,6 +598,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
566
598
  const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
567
599
 
568
600
  let child: ReturnType<typeof Bun.spawn>
601
+ const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
569
602
  try {
570
603
  const signalFd = opts.signalChannel === true
571
604
  child = spawnGuarded((guard) =>
@@ -597,6 +630,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
597
630
  },
598
631
  ),
599
632
  )
633
+ if (restoreModes !== undefined) void child.exited.then(restoreModes)
600
634
  if (signalFd) {
601
635
  signalThrough(child, child.stdio[3] as number)
602
636
  const spawned = child
@@ -621,7 +655,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
621
655
  })
622
656
 
623
657
  const markReady = (): void => {
624
- if (readyAt === undefined) {
658
+ // A marker printed on the way down, once the wait gave up, is not ready.
659
+ if (readyAt === undefined && gaveUpAt === undefined) {
625
660
  readyAt = Date.now()
626
661
  if (readyTimer !== undefined) clearTimeout(readyTimer)
627
662
  resolveReady()
@@ -709,40 +744,42 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
709
744
  opts.liveChildren?.add(child)
710
745
  opts.onSpawn?.(child.pid)
711
746
 
712
- // Readiness deadline. Reject FIRST so the failure reads as a
713
- // timeout, then SIGTERM — the exit handler's later reject is a
714
- // no-op on the settled promise. Cleared the moment ready fires so
715
- // a healthy server is never killed by a stale timer.
747
+ // Readiness deadline: SIGTERM, wait the grace for the GROUP, SIGKILL
748
+ // what is left, and only then reject. The caller's teardown used to
749
+ // signal the group again a turn after this SIGTERM, and a server whose
750
+ // handler is a one-shot (`process.once('SIGTERM')`, a trap that resets
751
+ // itself) died on the second mid-cleanup. Cleared the moment ready
752
+ // fires so a healthy server is never killed by a stale timer.
716
753
  let readyTimer: ReturnType<typeof setTimeout> | undefined
717
754
  if (readyRe && opts.timeoutMs !== undefined) {
718
- const giveUp = (): void => {
719
- if (readyAt === undefined) {
720
- gaveUpAt = Date.now()
721
- rejectReady(
722
- new PersistentReadyError(
723
- `persistent task not ready within ${opts.timeoutMs}ms — ` +
724
- `readyWhen pattern never matched; child killed`,
725
- 'timeout',
726
- ),
727
- )
728
- // Listed on the group guard until the SIGKILL: the shell may die
729
- // on the SIGTERM and let the group go while the server runs out
730
- // the grace, and a vx that exits inside it (the timer is unref'd)
731
- // leaves the server to the guard (kill-tree.ts, item 865).
732
- const letGo = holdGroups([child])
755
+ const giveUp = async (): Promise<void> => {
756
+ if (readyAt !== undefined) return
757
+ gaveUpAt = Date.now()
758
+ // Listed on the group guard until the SIGKILL: the shell may die on
759
+ // the SIGTERM and let the group go while the server runs out the
760
+ // grace, and a vx killed inside it leaves the server to the guard
761
+ // (kill-tree.ts, item 865).
762
+ const letGo = holdGroups([child])
763
+ try {
733
764
  killTree(child, 'SIGTERM')
734
- // Same escalation as `armTimeout`: a server that traps TERM and
735
- // never became ready is not in the persistent registry, so nothing
736
- // else would ever kill it — it outlived the run under init.
737
- const killTimer = setTimeout(() => {
738
- killTree(child, 'SIGKILL')
739
- letGo()
740
- }, killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
741
- killTimer.unref?.()
765
+ // A server that traps TERM and never became ready is not in the
766
+ // persistent registry, so nothing else would ever kill it.
767
+ const left = await untilGroupsGone([child], killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
768
+ for (const c of left) killTree(c, 'SIGKILL')
769
+ await child.exited
770
+ } finally {
771
+ letGo()
742
772
  }
773
+ rejectReady(
774
+ new PersistentReadyError(
775
+ `persistent task not ready within ${opts.timeoutMs}ms — ` +
776
+ `readyWhen pattern never matched; child killed`,
777
+ 'timeout',
778
+ ),
779
+ )
743
780
  }
744
781
  readyTimer = setTimeout(() => {
745
- readyTimer = afterPendingExits(giveUp)
782
+ readyTimer = afterPendingExits(() => void giveUp())
746
783
  }, opts.timeoutMs)
747
784
  }
748
785
 
@@ -750,10 +787,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
750
787
  // — reject the ready promise so the caller can surface it.
751
788
  void child.exited.then((code) => {
752
789
  opts.liveChildren?.delete(child)
753
- markGroupIfGone(child)
754
- releaseGroup(child)
790
+ releaseServerGroup(child)
755
791
  if (readyTimer !== undefined) clearTimeout(readyTimer)
756
- if (readyAt === undefined) {
792
+ if (readyAt === undefined && gaveUpAt === undefined) {
757
793
  rejectReady(
758
794
  new PersistentReadyError(
759
795
  `persistent task exited before becoming ready (exit ${code ?? '?'})` +
@@ -798,6 +834,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
798
834
  const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
799
835
 
800
836
  let proc: ReturnType<typeof Bun.spawn>
837
+ const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
801
838
  try {
802
839
  proc = spawnGuarded((guard) =>
803
840
  Bun.spawn(shellArgv((guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand)), {
@@ -832,6 +869,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
832
869
  // lets a clean exit EOF immediately and only cuts off a stuck reader after a
833
870
  // brief grace — without this the run hangs forever.
834
871
  await proc.exited
872
+ restoreModes?.()
835
873
  await timeout.settle()
836
874
  let cut = false
837
875
  if (timeout.timedOut()) ac.abort()
@@ -27,6 +27,17 @@ export function isMountableLiteral(grant: string): boolean {
27
27
  return !MOUNT_WILDCARDS.test(grant)
28
28
  }
29
29
 
30
+ /**
31
+ * The one path a grant names, or undefined for a pattern. A grant whose
32
+ * only brackets are escaped (`out/\[id\]`) names `out/[id]` as surely as a
33
+ * literal does, though it is still scanned and matched as a glob.
34
+ */
35
+ export function namedPath(grant: string): string | undefined {
36
+ if (isMountableLiteral(grant)) return grant
37
+ const escaped = /\\([[\]])/g
38
+ return isMountableLiteral(grant.replace(escaped, '')) ? grant.replace(escaped, '$1') : undefined
39
+ }
40
+
30
41
  /**
31
42
  * Canonicalize a path with realpath, tolerating paths that don't exist
32
43
  * yet: resolve the longest existing ancestor and re-append the rest.
@@ -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,
@@ -81,6 +82,7 @@ import {
81
82
  isMountableLiteral,
82
83
  localBindingOn,
83
84
  MOUNT_WILDCARDS,
85
+ namedPath,
84
86
  toRealPath,
85
87
  unique,
86
88
  } from './sandbox-paths.js'
@@ -103,6 +105,7 @@ import {
103
105
  releaseGroup,
104
106
  signalThrough,
105
107
  guardLine,
108
+ guardSession,
106
109
  spawnGuarded,
107
110
  } from './kill-tree.js'
108
111
 
@@ -708,7 +711,8 @@ export async function initSandbox(opts?: {
708
711
  ...(scopedDenyScan ? scopedScanConfig() : {}),
709
712
  ...bundledJavaAgent(),
710
713
  }
711
- if (!srtUp) await unlinkStaleMuxSockets()
714
+ const wasUp = srtUp
715
+ if (!wasUp) await unlinkStaleMuxSockets()
712
716
  const listening = srtCleanupAdopted ? undefined : cleanupListeners()
713
717
  await SandboxManager.initialize(
714
718
  config,
@@ -718,6 +722,10 @@ export async function initSandbox(opts?: {
718
722
  )
719
723
  if (listening !== undefined) adoptSrtCleanup(listening)
720
724
  srtUp = true
725
+ if (!wasUp && process.platform === 'linux' && procfsIsOwn()) {
726
+ const { pids, paths } = sessionLeftovers(SandboxManager)
727
+ sessionRelease = guardSession(pids, paths)
728
+ }
721
729
  perTaskRun = opts?.allowAllUnixSockets === true || opts?.gitConfig === true ? config : undefined
722
730
  // `initialize()` returns early once SRT is up, and on Linux the
723
731
  // availability probe brought it up with an EMPTY config before the run's
@@ -766,12 +774,81 @@ function srtDown(): void {
766
774
  straceAvailableCache = undefined
767
775
  }
768
776
 
777
+ /** Strikes the live session from the group guard's lists; set by `initSandbox`. */
778
+ let sessionRelease: (() => void) | undefined
779
+
780
+ /**
781
+ * The runtime's session as the group guard must take it if vx dies
782
+ * (kill-tree.md): the bridge socats, vx's children named by the socket
783
+ * they listen on, and what a `kill -9` leaves in the temp directory — the
784
+ * bridge sockets and the `srt-*` sockets vx itself holds (the violation
785
+ * observer's directory, the mux). The runtime exposes none of the pids and
786
+ * not the observer's path, so procfs answers: vx's children, and its own
787
+ * socket inodes against `/proc/net/unix`. ~3 ms, once per session.
788
+ */
789
+ function sessionLeftovers(SandboxManager: SrtModule['SandboxManager']): {
790
+ pids: number[]
791
+ paths: string[]
792
+ } {
793
+ const sockets = new Set(
794
+ [SandboxManager.getLinuxHttpSocketPath(), SandboxManager.getLinuxSocksSocketPath()].filter(
795
+ (p): p is string => p !== undefined,
796
+ ),
797
+ )
798
+ const pids: number[] = []
799
+ const paths = [...sockets]
800
+ try {
801
+ for (const entry of readdirSync('/proc')) {
802
+ const first = entry.charCodeAt(0)
803
+ if (first < 48 || first > 57) continue
804
+ try {
805
+ const stat = readFileSync(`/proc/${entry}/stat`, 'utf8')
806
+ if (Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ', 2)[1]) !== process.pid) continue
807
+ const argv = readFileSync(`/proc/${entry}/cmdline`, 'utf8').split('\0')
808
+ if (argv.some((a) => [...sockets].some((s) => a.startsWith(`UNIX-LISTEN:${s},`)))) {
809
+ pids.push(Number(entry))
810
+ }
811
+ } catch {
812
+ // exited
813
+ }
814
+ }
815
+ const inodes = new Set<string>()
816
+ for (const fd of readdirSync('/proc/self/fd')) {
817
+ try {
818
+ const link = readlinkSync(`/proc/self/fd/${fd}`)
819
+ if (link.startsWith('socket:[')) inodes.add(link.slice(8, -1))
820
+ } catch {
821
+ // closed
822
+ }
823
+ }
824
+ const tmp = os.tmpdir()
825
+ for (const line of readFileSync('/proc/net/unix', 'utf8').split('\n')) {
826
+ const f = line.trim().split(/\s+/)
827
+ if (f.length < 8 || !inodes.has(f[6]!)) continue
828
+ const p = f.slice(7).join(' ')
829
+ const dir = path.dirname(p)
830
+ if (dir === tmp && path.basename(p).startsWith('srt-mux-')) paths.push(p)
831
+ else if (path.dirname(dir) === tmp && path.basename(dir).startsWith('srt-obs-')) {
832
+ paths.push(dir)
833
+ }
834
+ }
835
+ } catch {
836
+ // Best-effort, as the guard is: what was found is listed.
837
+ }
838
+ return { pids, paths }
839
+ }
840
+
769
841
  /** `reset` is the one `initSandbox` waits out before it starts SRT again. */
770
842
  function trackReset(reset: Promise<unknown>): void {
771
843
  const settled = reset.then(
772
844
  () => {},
773
845
  () => {},
774
846
  )
847
+ // Struck once the runtime has killed its bridges and removed its files;
848
+ // a reset `exit` starts never settles, and the guard takes the rest.
849
+ const release = sessionRelease
850
+ sessionRelease = undefined
851
+ if (release !== undefined) void settled.then(release)
775
852
  resetting = settled
776
853
  void settled.then(() => {
777
854
  if (resetting === settled) resetting = undefined
@@ -1180,7 +1257,12 @@ async function wrapIn(
1180
1257
  tmp: string,
1181
1258
  ): ReturnType<typeof wrapSandboxedCommand> {
1182
1259
  // After the tag: SRT keys violations by the command's first 100 chars.
1183
- const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${javaToolOptionsFix(
1260
+ // TMP and TEMP pass through from the host, whose temp directory the
1261
+ // sandbox mounts read-only: a tool reading them failed to write there.
1262
+ const alsoTmp = ['TMP', 'TEMP'].filter((name) => args.env[name] !== undefined)
1263
+ const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${alsoTmp
1264
+ .map((name) => `export ${name}="$TMPDIR"; `)
1265
+ .join('')}${javaToolOptionsFix(
1184
1266
  process.env['JAVA_TOOL_OPTIONS'],
1185
1267
  args.env['JAVA_TOOL_OPTIONS'],
1186
1268
  )}${userCommand}`
@@ -1226,7 +1308,10 @@ async function wrapIn(
1226
1308
  `holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
1227
1309
  )
1228
1310
  }
1229
- if (ports.length > 0) writeFileSync(portDialScript(tag), PORT_DIAL_SCRIPT)
1311
+ if (ports.length > 0) {
1312
+ hostBridges.set(tag, { ports, procs: [] })
1313
+ writeFileSync(portDialScript(tag), PORT_DIAL_SCRIPT)
1314
+ }
1230
1315
  const grouped =
1231
1316
  process.platform === 'linux'
1232
1317
  ? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
@@ -1244,7 +1329,7 @@ async function wrapIn(
1244
1329
  process.platform === 'linux'
1245
1330
  ? literalReadPaths(customConfig)
1246
1331
  : process.platform === 'darwin'
1247
- ? seatbeltBrackets(customConfig)
1332
+ ? seatbeltBrackets(customConfig, baselines.allowRead)
1248
1333
  : customConfig,
1249
1334
  ports.length > 0 || asksUnixSockets(args.config),
1250
1335
  args.config.gitConfig === true,
@@ -1456,22 +1541,32 @@ function literalReadPaths(
1456
1541
  * file and the route could not be granted. `[[]` is a class of one `[`; a
1457
1542
  * lone `]` is plain text (B-65). A deny path is a real directory, never a
1458
1543
  * pattern: a nested project's wall under `[legacy]/` compiled as a class,
1459
- * matched nothing, and the root task read it.
1544
+ * matched nothing, and the root task read it. So is each of `names`, the
1545
+ * baseline reads: a linked dependency under `packages/[legacy]/` was a
1546
+ * class too, and as an exact regex even `[[]` grants the directory's entry
1547
+ * and none of its files, so its subtree is granted beside it (a trailing
1548
+ * `/**` is stripped before the compile).
1460
1549
  */
1461
1550
  export function seatbeltBrackets(
1462
1551
  config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
1552
+ names: readonly string[] = [],
1463
1553
  ): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
1464
1554
  const fs = config?.filesystem
1465
1555
  if (fs === undefined) return config
1466
- const literal = (paths: readonly string[]): string[] =>
1467
- paths.map((p) => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']'))
1556
+ const literal = (p: string): string => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']')
1557
+ const bracketed = new Set(names.filter((n) => /[[\]]/.test(n)))
1558
+ const read = (p: string): string[] => {
1559
+ if (!bracketed.has(p)) return [literal(p)]
1560
+ const at = p.replaceAll('[', '[[]')
1561
+ return [at, `${at}/**/*`]
1562
+ }
1468
1563
  return {
1469
1564
  ...config,
1470
1565
  filesystem: {
1471
1566
  ...fs,
1472
1567
  denyRead: fs.denyRead.map((p) => p.replaceAll('[', '[[]')),
1473
- allowWrite: literal(fs.allowWrite),
1474
- ...(fs.allowRead !== undefined ? { allowRead: literal(fs.allowRead) } : {}),
1568
+ allowWrite: fs.allowWrite.map(literal),
1569
+ ...(fs.allowRead !== undefined ? { allowRead: fs.allowRead.flatMap(read) } : {}),
1475
1570
  },
1476
1571
  }
1477
1572
  }
@@ -1588,7 +1683,14 @@ const hostBridges = new Map<
1588
1683
  * (2026-10-03). Linux, own procfs only, as the wait.
1589
1684
  */
1590
1685
  function portsHeld(ports: readonly number[]): number[] {
1591
- if (ports.length === 0 || !procfsIsOwn()) return []
1686
+ if (ports.length === 0) return []
1687
+ // This run's own bridges first: a wrap claims its ports before it awaits,
1688
+ // and two tasks granted one port both read the table before either
1689
+ // bridge bound, so the second's bind failed unseen and its clients
1690
+ // reached the first task.
1691
+ const claimed = new Set([...hostBridges.values()].flatMap((b) => b.ports))
1692
+ const ours = ports.filter((p) => claimed.has(p))
1693
+ if (ours.length > 0 || !procfsIsOwn()) return ours
1592
1694
  const held = new Set<string>()
1593
1695
  for (const [file, any, loop] of [
1594
1696
  ['/proc/net/tcp', '00000000', '0100007F'],
@@ -1644,7 +1746,7 @@ async function hostBridgesListen(ports: readonly number[], tag: string): Promise
1644
1746
  }
1645
1747
 
1646
1748
  function spawnHostBridges(ports: readonly number[], tag: string): void {
1647
- const procs: Array<ReturnType<typeof Bun.spawn>> = []
1749
+ const procs = hostBridges.get(tag)!.procs
1648
1750
  for (const p of ports) {
1649
1751
  // A spawn failure (no socat on the host) is the task's to report:
1650
1752
  // its own side dies the same way, in its frame.
@@ -1674,10 +1776,7 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
1674
1776
  // see above
1675
1777
  }
1676
1778
  }
1677
- if (procs.length > 0) {
1678
- hostBridges.set(tag, { ports, procs })
1679
- for (const p of ports) unlinkOnExit(portBridgeSocket(tag, p))
1680
- }
1779
+ if (procs.length > 0) for (const p of ports) unlinkOnExit(portBridgeSocket(tag, p))
1681
1780
  }
1682
1781
 
1683
1782
  /**
@@ -1736,12 +1835,22 @@ const TRACER_RETRY_LINE =
1736
1835
  * declared, so a second run redoes, not doubles, it.
1737
1836
  */
1738
1837
  export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
1739
- const { tracerFailed, ...first } = await runSandboxedOnce(args)
1838
+ const { tracerFailed, ranMs, ...first } = await runSandboxedOnce(args)
1740
1839
  // A stopping run has killed the children it holds; a retry would be one
1741
1840
  // spawned after that kill.
1742
1841
  if (!tracerFailed || args.signal?.aborted === true) return first
1743
1842
  args.onStderr?.(TRACER_RETRY_LINE)
1744
- const { tracerFailed: _again, ...second } = await runSandboxedOnce(args)
1843
+ // `timeout` bounds the task's whole run (schema.md), so the retry gets
1844
+ // what the first attempt left of it, not a fresh window.
1845
+ const {
1846
+ tracerFailed: _again,
1847
+ ranMs: _ran,
1848
+ ...second
1849
+ } = await runSandboxedOnce(
1850
+ args.timeoutMs === undefined
1851
+ ? args
1852
+ : { ...args, timeoutMs: Math.max(0, args.timeoutMs - ranMs) },
1853
+ )
1745
1854
  return {
1746
1855
  ...second,
1747
1856
  durationMs: first.durationMs + second.durationMs,
@@ -1808,7 +1917,7 @@ const STRACE_OWN_ERROR = /^(?:[^\s:]*\/)?strace: /
1808
1917
  */
1809
1918
  async function runSandboxedOnce(
1810
1919
  args: SandboxedRunArgs,
1811
- ): Promise<SandboxedRunResult & { tracerFailed: boolean }> {
1920
+ ): Promise<SandboxedRunResult & { tracerFailed: boolean; ranMs: number }> {
1812
1921
  const start = Date.now()
1813
1922
  const { SandboxManager } = await loadSrt()
1814
1923
  // Linux: SRT's store sees only writes (below), so read denials need
@@ -1858,6 +1967,7 @@ async function runSandboxedOnce(
1858
1967
  signal,
1859
1968
  violations: [],
1860
1969
  tracerFailed: false,
1970
+ ranMs: 0,
1861
1971
  }
1862
1972
  }
1863
1973
  // Beside the task directories, which every sandbox replaces with its own:
@@ -1910,6 +2020,7 @@ async function runSandboxedOnce(
1910
2020
  spawnFailed: true,
1911
2021
  violations: [],
1912
2022
  tracerFailed: false,
2023
+ ranMs: 0,
1913
2024
  }
1914
2025
  } finally {
1915
2026
  // The child holds its own copy; ours would keep nothing but a descriptor.
@@ -1919,6 +2030,7 @@ async function runSandboxedOnce(
1919
2030
  args.liveChildren?.add(proc)
1920
2031
  args.onSpawn?.(proc.pid)
1921
2032
  const timeout = armTimeout(proc, args.timeoutMs)
2033
+ const armedAt = Date.now()
1922
2034
  const ac = new AbortController()
1923
2035
  // The unfinished last line of stderr, and whether a line was strace's.
1924
2036
  let partial = ''
@@ -1943,6 +2055,7 @@ async function runSandboxedOnce(
1943
2055
  // OR a clean exit that backgrounds a process) can't hang the run — timeout
1944
2056
  // aborts at once, otherwise drainOrAbort bounds the post-exit drain.
1945
2057
  await proc.exited
2058
+ const ranMs = Date.now() - armedAt
1946
2059
  await timeout.settle()
1947
2060
  let cut = false
1948
2061
  if (timeout.timedOut()) ac.abort()
@@ -2120,6 +2233,7 @@ async function runSandboxedOnce(
2120
2233
  // that is someone else's. macOS's `sandbox-exec` execs the command, so
2121
2234
  // its usage is the task's.
2122
2235
  ...(process.platform === 'linux' ? {} : resourceUsageToCpuRss(proc.resourceUsage())),
2236
+ ranMs,
2123
2237
  tracerFailed:
2124
2238
  straceLog !== undefined &&
2125
2239
  !timeout.timedOut() &&
@@ -2405,7 +2519,7 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
2405
2519
  */
2406
2520
  export function wallsGlobsReach(grants: readonly string[], walls: readonly string[]): string[] {
2407
2521
  const heads = grants
2408
- .filter((g) => !isMountableLiteral(g))
2522
+ .filter((g) => namedPath(g) === undefined)
2409
2523
  .map((g) => path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS))))
2410
2524
  return walls.filter((w) => heads.some((h) => atOrUnder(w, h)))
2411
2525
  }
@@ -2429,7 +2543,7 @@ export function darwinWallRules(
2429
2543
  for (const w of walls) {
2430
2544
  const at = `(subpath "${sbplResolvedPath(w, 'wall')}")`
2431
2545
  const kept = literals
2432
- .filter((l) => isMountableLiteral(l) && atOrUnder(l, w))
2546
+ .filter((l) => atOrUnder(l, w))
2433
2547
  .map((l) => `(require-not (subpath "${sbplResolvedPath(l, 'grant')}"))`)
2434
2548
  rules.push(
2435
2549
  kept.length === 0
@@ -2438,8 +2552,11 @@ export function darwinWallRules(
2438
2552
  )
2439
2553
  }
2440
2554
  }
2441
- deny('file-read-data', c.wallsReached?.read ?? [], [...c.allowRead, ...baseAllowRead])
2442
- deny('file-write*', c.wallsReached?.write ?? [], c.allowWrite)
2555
+ // A baseline is a name the filesystem handed back, never a pattern: a
2556
+ // bracket in it does not make it a glob.
2557
+ const named = (grants: readonly string[]): string[] => grants.flatMap((g) => namedPath(g) ?? [])
2558
+ deny('file-read-data', c.wallsReached?.read ?? [], [...named(c.allowRead), ...baseAllowRead])
2559
+ deny('file-write*', c.wallsReached?.write ?? [], named(c.allowWrite))
2443
2560
  return rules
2444
2561
  }
2445
2562
 
@@ -2527,8 +2644,9 @@ function expandGrants(
2527
2644
  // A hit that IS a wall, or lies inside one, was matched, not named:
2528
2645
  // `read: ['*']` in a root project bound `.git` and `.vx`, and
2529
2646
  // `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
2647
+ // names a wall literally never reaches this loop and stays, and one
2648
+ // spelled with escaped brackets names it as surely.
2649
+ if (namedPath(p) === undefined && walls.some((w) => atOrUnder(real, w))) continue
2532
2650
  out.push(abs)
2533
2651
  hits++
2534
2652
  }
@@ -2618,10 +2736,16 @@ function grantSpelled(p: string, within: string): string {
2618
2736
  * names what is missing when there is no form, for the warning and for
2619
2737
  * `vx info`.
2620
2738
  */
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
2739
+ let straceAvailableCache: Promise<{ form: false | 'plain' | 'seccomp'; why: string }> | undefined
2740
+ function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
2741
+ if (process.platform !== 'linux') return Promise.resolve({ form: false, why: '' })
2742
+ // The promise is the memo, not its answer: a run starts a wave of tasks
2743
+ // at once, and each asked before the first answer landed (one probe per task).
2744
+ straceAvailableCache ??= probeStrace()
2745
+ return straceAvailableCache
2746
+ }
2747
+
2748
+ async function probeStrace(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
2625
2749
  try {
2626
2750
  const p = Bun.spawn([executablePath('strace'), '--version'], {
2627
2751
  stdout: 'pipe',
@@ -2629,33 +2753,28 @@ async function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why:
2629
2753
  })
2630
2754
  const out = await new Response(p.stdout).text()
2631
2755
  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})` }
2756
+ if (p.exitCode !== 0) return { form: false, why: `strace --version exited ${p.exitCode}` }
2757
+ const m = /version (\d+)\.(\d+)/.exec(out)
2758
+ const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
2759
+ let form: 'plain' | 'seccomp' = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
2760
+ let refused = await traceRefusal(form)
2761
+ // A strace that cannot check the seccomp filter says so and traces on
2762
+ // without it, exit 0. Inside the sandbox that line read as the trace
2763
+ // cut short, and every sandboxed task ran twice: the plain form, if
2764
+ // it is quiet, is the one that works here.
2765
+ if (refused?.warned === true && form === 'seccomp') {
2766
+ refused = await traceRefusal('plain')
2767
+ if (refused === null) form = 'plain'
2651
2768
  }
2769
+ return refused === null
2770
+ ? { form, why: '' }
2771
+ : { form: false, why: `strace cannot trace here (${refused.line})` }
2652
2772
  } catch {
2653
2773
  // Not on PATH. Said as the refused attach is: without it an undeclared
2654
2774
  // read is denied but never reported, so a task that tolerates the miss
2655
2775
  // passes and caches with no word of it (J's lead).
2656
- straceAvailableCache = { form: false, why: 'strace is not on PATH' }
2776
+ return { form: false, why: 'strace is not on PATH' }
2657
2777
  }
2658
- return straceAvailableCache
2659
2778
  }
2660
2779
 
2661
2780
  async function wantsStraceDetection(): Promise<false | 'plain' | 'seccomp'> {