@vzn/vx 0.0.646 → 0.0.648

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -37,6 +37,7 @@ import {
37
37
  readFileSync,
38
38
  readlinkSync,
39
39
  realpathSync,
40
+ renameSync,
40
41
  rmSync,
41
42
  statSync,
42
43
  unlinkSync,
@@ -47,11 +48,13 @@ import type { SandboxConfig } from '../config.js'
47
48
  import {
48
49
  armTimeout,
49
50
  drainOrAbort,
51
+ lineEnds,
50
52
  POST_EXIT_CUT_LINE,
51
53
  shellQuote,
52
54
  withForwardArgs,
53
55
  signalExitCode,
54
56
  stopSignal,
57
+ SANDBOXED_TASK,
55
58
  spawnFailureText,
56
59
  streamToString,
57
60
  resourceUsageToCpuRss,
@@ -62,6 +65,7 @@ import {
62
65
  import { watchTree } from './proc-sample.js'
63
66
  import {
64
67
  BUN_GLOB_WILDCARDS,
68
+ ESSENTIAL_ENV,
65
69
  executablePath,
66
70
  helperEnv,
67
71
  taskShell,
@@ -69,6 +73,7 @@ import {
69
73
  isTmpdirRefusal,
70
74
  procfsIsOwn,
71
75
  TMPDIR_HINT,
76
+ ui,
72
77
  UserError,
73
78
  xxh3hex,
74
79
  } from '../util/index.js'
@@ -206,6 +211,7 @@ async function probeUncached(weakerNested: boolean): Promise<SandboxAvailability
206
211
  function applyPolicyHere(): SandboxAvailability {
207
212
  const proc = Bun.spawnSync({
208
213
  cmd: ['sandbox-exec', '-p', '(version 1)(allow default)', '/usr/bin/true'],
214
+ env: helperEnv(),
209
215
  stdout: 'ignore',
210
216
  stderr: 'pipe',
211
217
  })
@@ -231,12 +237,25 @@ function applyPolicyHere(): SandboxAvailability {
231
237
  * the loader).
232
238
  */
233
239
  export function unavailableReason(exitCode: number | null, stderr: string): string {
234
- const hint = stderr.includes('uid_map')
235
- ? " — the runtime's seccomp helper cannot create its nested user namespace here (root inside a container, or a kernel that forbids nested user namespaces): run as a non-root user, or set `sandbox.weakerWhenNested: true` on every sandboxed task"
236
- : ''
240
+ const hint = stderr.includes('capability-restricted')
241
+ ? // Ubuntu 25.04+ confines bwrap with this AppArmor profile (2026-10-23).
242
+ " — AppArmor's bwrap-userns-restrict profile strips the capabilities of the seccomp helper's nested user namespace: replace it with a profile for /usr/bin/bwrap that grants `userns,` (https://discourse.ubuntu.com/t/58007)"
243
+ : stderr.includes('uid_map')
244
+ ? " — the runtime's seccomp helper cannot create its nested user namespace here (root inside a container, or a kernel that forbids nested user namespaces): run as a non-root user, or set `sandbox.weakerWhenNested: true` on every sandboxed task"
245
+ : ''
237
246
  return `a sandboxed \`true\` failed (exit ${exitCode}): ${stderr.slice(0, 200)}${hint}`
238
247
  }
239
248
 
249
+ /** vx's values of the variables every task gets (`ESSENTIAL_ENV`). */
250
+ function essentialEnv(): Record<string, string> {
251
+ const env: Record<string, string> = {}
252
+ for (const name of ESSENTIAL_ENV) {
253
+ const value = process.env[name]
254
+ if (value !== undefined) env[name] = value
255
+ }
256
+ return env
257
+ }
258
+
240
259
  async function trySandboxedTrue(
241
260
  SandboxManager: SrtModule['SandboxManager'],
242
261
  weakerNested: boolean,
@@ -249,6 +268,8 @@ async function trySandboxedTrue(
249
268
  )
250
269
  const proc = Bun.spawn([taskShell(), '-c', wrapped], {
251
270
  argv0: 'sh',
271
+ // What a task gets of vx's env: the probe is a sandboxed `true`.
272
+ env: essentialEnv(),
252
273
  stdout: 'ignore',
253
274
  stderr: 'pipe',
254
275
  stdin: 'ignore',
@@ -446,10 +467,41 @@ function trackTaskTmpdir(dir: string): void {
446
467
  if (tmpdirExitHooked) return
447
468
  tmpdirExitHooked = true
448
469
  process.on('exit', () => {
449
- for (const d of liveTaskTmpdirs) rmSync(d, { recursive: true, force: true })
470
+ for (const d of liveTaskTmpdirs) removeTaskTmpdir(d)
450
471
  })
451
472
  }
452
473
 
474
+ /**
475
+ * Remove a task's temp directory; never throws. The task may leave it
476
+ * unremovable: a read-only directory in it (EACCES), or a straggler still
477
+ * writing (ENOTEMPTY). Thrown, either failed a task that had passed, as a
478
+ * worker fault under dte. A read-only one is made writable and the remove
479
+ * retried; what still stands is left to the exit hook's last try.
480
+ */
481
+ function removeTaskTmpdir(dir: string): boolean {
482
+ for (let attempt = 0; ; attempt++) {
483
+ try {
484
+ rmSync(dir, { recursive: true, force: true })
485
+ return true
486
+ } catch {
487
+ if (attempt === 2) return false
488
+ ownerWritable(dir)
489
+ }
490
+ }
491
+ }
492
+
493
+ /** Give the owner write and search on every directory under `dir`, which a recursive remove needs. */
494
+ function ownerWritable(dir: string): void {
495
+ try {
496
+ const st = lstatSync(dir)
497
+ if (!st.isDirectory()) return
498
+ if ((st.mode & 0o700) !== 0o700) chmodSync(dir, (st.mode & 0o7777) | 0o700)
499
+ for (const name of readdirSync(dir)) ownerWritable(path.join(dir, name))
500
+ } catch {
501
+ // gone, or not this user's: the retry says
502
+ }
503
+ }
504
+
453
505
  /** Whether SRT is up in this process — set by `initSandbox`, cleared by `resetSandbox`. */
454
506
  let srtUp = false
455
507
  /**
@@ -596,6 +648,22 @@ function noScanCommand(): string {
596
648
  return file
597
649
  }
598
650
 
651
+ /**
652
+ * vx's executables the runtime spawns OUTSIDE the sandbox (the no-op scan,
653
+ * the socat wrapper), as directories every task is walled from, and the
654
+ * files a task still runs (the wrapper is its side of the proxy bridge
655
+ * too), granted back read-only. They live in the OS temp dir, and where
656
+ * that is one every task may write (SRT binds `/tmp/claude` writable into
657
+ * each), a task rewrote the no-op and the next wrap ran it unsandboxed.
658
+ */
659
+ function hostHelpers(): { dirs: string[]; files: string[] } {
660
+ const files = [...socatWrappers].filter(([bare, w]) => w !== bare).map(([, w]) => w)
661
+ const dirs = [...(noScan === undefined ? [] : [noScan]), ...files].map((f) =>
662
+ toRealPath(path.dirname(f)),
663
+ )
664
+ return { dirs: unique(dirs), files: files.map(toRealPath) }
665
+ }
666
+
599
667
  function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
600
668
  if (process.platform !== 'linux') return {}
601
669
  const paths: { bwrapPath?: string; socatPath?: string } = {}
@@ -605,13 +673,93 @@ function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
605
673
  // SRT's dependency check says so
606
674
  }
607
675
  try {
608
- paths.socatPath = executablePath('socat')
676
+ paths.socatPath = envCutSocat(executablePath('socat'))
609
677
  } catch {
610
678
  // SRT's dependency check says so
611
679
  }
612
680
  return paths
613
681
  }
614
682
 
683
+ /**
684
+ * socat behind a wrapper that `exec`s it with the essential env only.
685
+ * SRT spawns its host bridge (`socat UNIX-LISTEN:… TCP:localhost:…`) with
686
+ * no `env`, so it held vx's whole environment for the run, readable from
687
+ * its `/proc/<pid>/environ` by any unsandboxed task beside it, the hole
688
+ * `hideEnvFromTasks` closes for vx itself. SRT takes no env for it, and
689
+ * narrowing `process.env` around `initialize` would hand a task spawned
690
+ * meanwhile the narrowed one. The same path runs socat inside the
691
+ * sandbox, so the wrapper keeps `SOCAT_DEFAULT_LISTEN_IP` (`hostHasIpv6`).
692
+ * It lives in this user's own 0700 directory, named by its content, and
693
+ * no exit hook removes it: SRT's dependency check reads the path its live
694
+ * config holds, and a per-process one an emitted `exit` reaped left the
695
+ * next probe "socat not executable" while the process went on (B-93's
696
+ * shape). A host where it cannot run (`/tmp` mounted noexec) keeps the
697
+ * bare socat.
698
+ */
699
+ const socatWrappers = new Map<string, string>()
700
+ function envCutSocat(socat: string): string {
701
+ const known = socatWrappers.get(socat)
702
+ // One stat per init: a wrapper someone removed is made again.
703
+ if (known !== undefined && (known === socat || existsSync(known))) return known
704
+ let wrapper = socat
705
+ try {
706
+ const keep = [...ESSENTIAL_ENV, 'SOCAT_DEFAULT_LISTEN_IP']
707
+ .map((n) => `\${${n}+"${n}=$${n}"}`)
708
+ .join(' ')
709
+ const body = `#!/bin/sh\nexec ${shellQuote(executablePath('env'))} -i ${keep} ${shellQuote(socat)} "$@"\n`
710
+ const file = ownWrapper(body)
711
+ if (
712
+ Bun.spawnSync([file, '-V'], { env: helperEnv(), stdout: 'ignore', stderr: 'ignore' })
713
+ .exitCode === 0
714
+ ) {
715
+ wrapper = file
716
+ }
717
+ } catch {
718
+ // no private directory or no `env`: the bare socat, as before
719
+ }
720
+ socatWrappers.set(socat, wrapper)
721
+ return wrapper
722
+ }
723
+
724
+ /**
725
+ * `body` as an executable in `vx-socat-<uid>/` under the OS temp dir,
726
+ * written once (to a temp, then renamed, so a process beside this one
727
+ * never runs half of it) and checked byte for byte before it is used.
728
+ * Anything else at the names (another owner, group or other bits, a
729
+ * link, other bytes) is refused.
730
+ */
731
+ function ownWrapper(body: string): string {
732
+ const uid = process.getuid!()
733
+ const dir = path.join(os.tmpdir(), `vx-socat-${uid}`)
734
+ try {
735
+ mkdirSync(dir, { mode: 0o700 })
736
+ } catch (err) {
737
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
738
+ }
739
+ const d = lstatSync(dir)
740
+ if (!d.isDirectory() || d.uid !== uid || (d.mode & 0o077) !== 0) {
741
+ throw new Error(`${dir} is not this user's own directory`)
742
+ }
743
+ const file = path.join(dir, `socat-${xxh3hex(body).slice(0, 16)}`)
744
+ const ok = (): boolean => {
745
+ const f = lstatSync(file, { throwIfNoEntry: false })
746
+ return (
747
+ f !== undefined &&
748
+ f.isFile() &&
749
+ f.uid === uid &&
750
+ (f.mode & 0o777) === 0o500 &&
751
+ readFileSync(file, 'utf8') === body
752
+ )
753
+ }
754
+ if (ok()) return file
755
+ const tmp = `${file}.${process.pid}`
756
+ rmSync(tmp, { force: true })
757
+ writeFileSync(tmp, body, { mode: 0o500, flag: 'wx' })
758
+ renameSync(tmp, file)
759
+ if (!ok()) throw new Error(`${file} is not this user's wrapper`)
760
+ return file
761
+ }
762
+
615
763
  /**
616
764
  * Whether this Linux host speaks IPv6. SRT's in-sandbox network bridge is
617
765
  * `socat TCP-LISTEN:3128`, and socat 1.8 opens that as an IPv6 socket: on
@@ -1166,6 +1314,12 @@ export interface SandboxViolation {
1166
1314
  hint?: true
1167
1315
  /** Absolute, when `target` is a path at all. */
1168
1316
  path?: string
1317
+ /**
1318
+ * Linux: the directory holding a link on the path the task opened, not
1319
+ * in the sandbox, so the link was not there: a grant of `path` alone
1320
+ * leaves it hidden.
1321
+ */
1322
+ via?: string
1169
1323
  ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
1170
1324
  }
1171
1325
 
@@ -1280,6 +1434,9 @@ async function wrapIn(
1280
1434
  baselines.cwd,
1281
1435
  )
1282
1436
  customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
1437
+ const helpers = hostHelpers()
1438
+ customConfig!.filesystem!.denyRead!.push(...helpers.dirs)
1439
+ customConfig!.filesystem!.allowRead!.push(...helpers.files)
1283
1440
  if (process.platform === 'linux' && !cwdMounted(baselines.cwd, customConfig!.filesystem!)) {
1284
1441
  customConfig!.filesystem!.denyRead!.push(baselines.cwd)
1285
1442
  }
@@ -1324,7 +1481,10 @@ async function wrapIn(
1324
1481
  process.platform === 'linux'
1325
1482
  ? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
1326
1483
  : { command: taggedCommand, forwards: false, traced: false }
1484
+ // The tag leads: SRT keys a record by the wrap's first 100 characters,
1485
+ // and the proxy wait below is the same 190 for every networked task.
1327
1486
  const inner = [
1487
+ process.platform === 'linux' ? `: 'vx-${tag}';` : '',
1328
1488
  ports.length > 0 ? portBridgeInner(ports, tag) : '',
1329
1489
  process.platform === 'linux' && args.config.network !== undefined ? PROXY_BRIDGE_WAIT : '',
1330
1490
  grouped.command,
@@ -1504,7 +1664,10 @@ function ownGroupCommand(
1504
1664
  * What strace stops on: the reads, the execs and access probes (a refused
1505
1665
  * `./gen.sh || fallback` passed unreported), and what moves or makes a
1506
1666
  * process's cwd. Not the stat family: a stop per stat cost oxlint 19% and
1507
- * `git status` 30%, where the execs and probes cost nothing measurable.
1667
+ * `git status` 30%, where the execs and probes cost nothing measurable, and
1668
+ * vx's own no-op run 69% (pr-bench, 495 → 835 ms, 2026-10-23). So a tool
1669
+ * that stats before it opens, as Ubuntu 26's cat (uutils) does, refused
1670
+ * nothing the trace sees.
1508
1671
  */
1509
1672
  const TRACED_CALLS =
1510
1673
  'trace=openat,execve,?access,faccessat,?faccessat2,chdir,fchdir,clone,?clone3,?fork,?vfork'
@@ -1604,7 +1767,8 @@ function seatbeltSpells(grant: string, kind: 'read' | 'write', name: boolean): b
1604
1767
  const at = name ? m.index : m.index + m[0].length - 2
1605
1768
  if (!warnedUnspellable.has(`${kind} ${grant}`)) {
1606
1769
  warnedUnspellable.add(`${kind} ${grant}`)
1607
- process.stderr.write(
1770
+ ui.say(
1771
+ 'warn',
1608
1772
  `[vx] sandbox: the ${kind} grant ${grant} names a literal ${grant[at + (name ? 0 : 1)]}, ` +
1609
1773
  `which the macOS sandbox reads as a pattern and cannot spell as a name, so it is not ` +
1610
1774
  `granted. Rename it, or grant the directory above it: ` +
@@ -1832,7 +1996,8 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
1832
1996
  */
1833
1997
  export function releaseBridges(tag: string): void {
1834
1998
  const tmp = taskTmpdir(tag)
1835
- if (liveTaskTmpdirs.delete(tmp)) rmSync(tmp, { recursive: true, force: true })
1999
+ // One that will not go stays tracked, for the exit hook.
2000
+ if (liveTaskTmpdirs.has(tmp) && removeTaskTmpdir(tmp)) liveTaskTmpdirs.delete(tmp)
1836
2001
  if (liveServers.delete(tag)) {
1837
2002
  // A server's wrap stays counted until it stops: one stopped server
1838
2003
  // kept every later task's stubs in the workspace until the reset.
@@ -1879,11 +2044,25 @@ const TRACER_RETRY_LINE =
1879
2044
  * declared, so a second run redoes, not doubles, it.
1880
2045
  */
1881
2046
  export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
1882
- const { tracerFailed, ranMs, ...first } = await runSandboxedOnce(args)
2047
+ // Whether the first attempt's output, both streams, ended mid-line: the
2048
+ // notice starts its own line (it read `…/strace: bare[vx] the sandbox's…`).
2049
+ let midLine = false
2050
+ const seen =
2051
+ (on: ((chunk: string) => void) | undefined) =>
2052
+ (chunk: string): void => {
2053
+ if (chunk.length > 0) midLine = !chunk.endsWith('\n')
2054
+ on?.(chunk)
2055
+ }
2056
+ const { tracerFailed, ranMs, ...first } = await runSandboxedOnce({
2057
+ ...args,
2058
+ onStdout: seen(args.onStdout),
2059
+ onStderr: seen(args.onStderr),
2060
+ })
1883
2061
  // A stopping run has killed the children it holds; a retry would be one
1884
2062
  // spawned after that kill.
1885
2063
  if (!tracerFailed || args.signal?.aborted === true) return first
1886
- args.onStderr?.(TRACER_RETRY_LINE)
2064
+ const notice = (midLine ? '\n' : '') + TRACER_RETRY_LINE
2065
+ args.onStderr?.(notice)
1887
2066
  // `timeout` bounds the task's whole run (schema.md), so the retry gets
1888
2067
  // what the first attempt left of it, not a fresh window.
1889
2068
  const {
@@ -1899,7 +2078,7 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
1899
2078
  ...second,
1900
2079
  durationMs: first.durationMs + second.durationMs,
1901
2080
  stdout: first.stdout + second.stdout,
1902
- stderr: first.stderr + TRACER_RETRY_LINE + second.stderr,
2081
+ stderr: first.stderr + notice + second.stderr,
1903
2082
  }
1904
2083
  }
1905
2084
 
@@ -2052,7 +2231,7 @@ async function runSandboxedOnce(
2052
2231
  rmSync(straceLog, { force: true })
2053
2232
  liveTempFiles.delete(straceLog)
2054
2233
  }
2055
- const stderr = spawnFailureText(err, args.cwd, 'sandboxed task')
2234
+ const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped)
2056
2235
  args.onStderr?.(stderr)
2057
2236
  releaseBridges(tag)
2058
2237
  takeRecords()
@@ -2083,8 +2262,9 @@ async function runSandboxedOnce(
2083
2262
  let partial = ''
2084
2263
  let straceSpoke = false
2085
2264
  const [out, err] = pipes.streams(proc)
2265
+ const ends = lineEnds(args.onStdout, args.onStderr)
2086
2266
  const streams = Promise.all([
2087
- streamToString(out, args.onStdout, ac.signal, args.capture?.stdout ?? true),
2267
+ streamToString(out, ends.onStdout, ac.signal, args.capture?.stdout ?? true),
2088
2268
  streamToString(
2089
2269
  err,
2090
2270
  (chunk) => {
@@ -2093,7 +2273,7 @@ async function runSandboxedOnce(
2093
2273
  const lines = (partial + chunk).split('\n')
2094
2274
  partial = (lines.pop() ?? '').slice(0, 512)
2095
2275
  if (lines.some((l) => STRACE_OWN_ERROR.test(l))) straceSpoke = true
2096
- args.onStderr?.(chunk)
2276
+ ends.onStderr?.(chunk)
2097
2277
  },
2098
2278
  ac.signal,
2099
2279
  args.capture?.stderr ?? true,
@@ -2110,13 +2290,17 @@ async function runSandboxedOnce(
2110
2290
  if (timeout.timedOut()) ac.abort()
2111
2291
  else cut = await drainOrAbort(streams, ac)
2112
2292
  const [stdout, streamed] = await streams
2113
- if (cut) args.onStderr?.(POST_EXIT_CUT_LINE)
2114
- const stderr = cut ? streamed + POST_EXIT_CUT_LINE : streamed
2293
+ const cutLine = ends.own(POST_EXIT_CUT_LINE)
2294
+ if (cut) args.onStderr?.(cutLine)
2295
+ const stderr = cut ? streamed + cutLine : streamed
2115
2296
  args.liveChildren?.delete(proc)
2116
2297
  releaseGroup(proc)
2117
2298
  closeSignalChannel(proc)
2118
2299
  releaseBridges(tag)
2119
- const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
2300
+ const exited = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
2301
+ // A timeout is a failure, whatever the child said: one that traps the
2302
+ // TERM and exits 0 reported success to every executor built on this.
2303
+ const exitCode = exited === 0 && timeout.timedOut() ? signalExitCode('SIGTERM') : exited
2120
2304
 
2121
2305
  // macOS: read the violation store keyed by our tagged command.
2122
2306
  //
@@ -2135,8 +2319,8 @@ async function runSandboxedOnce(
2135
2319
  // DROPPED by the unified log under pressure, not delayed. Reporting on
2136
2320
  // macOS is therefore lossy-by-OS under load; ENFORCEMENT is unaffected
2137
2321
  // (every observed loss still denied the read and failed the child).
2138
- // EVERY record SRT's store saw for this command, unfiltered (owner,
2139
- // 2026-09-05). A sandboxed task that fails must say what it was denied;
2322
+ // EVERY record SRT's store saw for this command, unfiltered
2323
+ // (2026-09-05). A sandboxed task that fails must say what it was denied;
2140
2324
  // deciding on the user's behalf that a record was "just traversal" is how
2141
2325
  // a failure ends up with an empty violations section and no explanation.
2142
2326
  const records = takeRecords()
@@ -2151,7 +2335,7 @@ async function runSandboxedOnce(
2151
2335
  // below (`refusedWrites`).
2152
2336
  const macViolations = process.platform === 'darwin' ? records : []
2153
2337
  // No settle window: the store is read once, right after the child exits
2154
- // (owner, 2026-09-05). It cost 300ms on EVERY clean sandboxed task — the
2338
+ // (2026-09-05). It cost 300ms on EVERY clean sandboxed task — the
2155
2339
  // full budget, since a task with nothing to report can only prove that by
2156
2340
  // waiting — against 26ms for one that reports something and breaks out
2157
2341
  // early. Measured: clean `echo` 331ms sandboxed vs 16ms plain.
@@ -2271,7 +2455,7 @@ async function runSandboxedOnce(
2271
2455
  stdout,
2272
2456
  stderr,
2273
2457
  violations,
2274
- ...(proc.signalCode ? { signal: proc.signalCode } : {}),
2458
+ ...(typeof proc.signalCode === 'string' ? { signal: proc.signalCode } : {}),
2275
2459
  ...(timeout.timedOut() ? { timedOut: true } : {}),
2276
2460
  ...(cut ? { leftRunning: true } : {}),
2277
2461
  // Linux: the task's CPU and peak never reach this wait. bwrap runs it
@@ -2854,7 +3038,8 @@ function writeGrantMatchedNothing(grant: string, within: string): void {
2854
3038
  // As a committed config spells them (B-97): the absolute path held only
2855
3039
  // on the machine that printed it, as in `outsideWritesHint`.
2856
3040
  const dir = grantSpelled(grantPrefix(grant), within)
2857
- process.stderr.write(
3041
+ ui.say(
3042
+ 'warn',
2858
3043
  `[vx] sandbox: the write grant ${jsString(grantSpelled(grant, within))} matches nothing ` +
2859
3044
  `yet, and a read grant mounts its directory read-only, so a file the task creates under ` +
2860
3045
  `it will fail with "Read-only file system". A bind mount covers what exists when the ` +
@@ -2890,6 +3075,7 @@ function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why: string
2890
3075
  async function probeStrace(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
2891
3076
  try {
2892
3077
  const p = Bun.spawn([executablePath('strace'), '--version'], {
3078
+ env: helperEnv(),
2893
3079
  stdout: 'pipe',
2894
3080
  stderr: 'ignore',
2895
3081
  })
@@ -2963,7 +3149,7 @@ async function traceRefusal(
2963
3149
  '--',
2964
3150
  executablePath('true'),
2965
3151
  ],
2966
- { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
3152
+ { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe', env: helperEnv() },
2967
3153
  )
2968
3154
  const err = await new Response(p.stderr).text()
2969
3155
  const line = err.split('\n').find((l) => STRACE_OWN_ERROR.test(l))
@@ -2976,7 +3162,8 @@ let warnedNoTrace = false
2976
3162
  function warnUntraced(why: string): void {
2977
3163
  if (warnedNoTrace) return
2978
3164
  warnedNoTrace = true
2979
- process.stderr.write(
3165
+ ui.say(
3166
+ 'warn',
2980
3167
  `[vx] sandbox: ${why}, so sandboxed tasks run without the report of the reads the sandbox denied; ` +
2981
3168
  `the sandbox still enforces\n`,
2982
3169
  )
@@ -8,6 +8,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'
11
+ import { printable } from '../util/index.js'
11
12
  import type {
12
13
  ResolvedSandboxConfig,
13
14
  SandboxedRunArgs,
@@ -50,17 +51,30 @@ const DIRFD = '(?:AT_FDCWD|\\d+)(?:<(.*?)>)?, '
50
51
  // The rest of the call: an `execve`'s argv strings may hold a `)`
51
52
  // (`sh -c "f()"`), so it reads to the result at the line's end.
52
53
  const ARGS = '.*'
54
+ // The result, at the line's end: an argv string may hold `) = -1 ENOENT`
55
+ // itself, and a call that succeeded read as denied.
56
+ const FAILED = '\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)(?: \\([^)]*\\))?\\s*$'
53
57
  const STRACE_DONE_RE = new RegExp(
54
- `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
58
+ `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}\\)${FAILED}`,
55
59
  )
56
60
  const STRACE_UNFINISHED_RE = new RegExp(
57
61
  `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}<unfinished`,
58
62
  )
59
- const STRACE_RESUMED_RE = new RegExp(
60
- `^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
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'])
63
+ const STRACE_RESUMED_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?${FAILED}`)
64
+ /**
65
+ * The calls that ask whether a path is there rather than read it. A
66
+ * `chdir` creates nothing: one into a directory the host lacks (`mkdir -p`
67
+ * after its refused mkdir) was judged a write, and the fix named a file.
68
+ */
69
+ const PROBES = new Set([
70
+ 'execve',
71
+ 'faccessat2',
72
+ 'faccessat',
73
+ 'access',
74
+ 'statx',
75
+ 'newfstatat',
76
+ 'chdir',
77
+ ])
64
78
  /** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
65
79
  const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
66
80
  /**
@@ -331,6 +345,9 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
331
345
  return out
332
346
  }
333
347
 
348
+ /** Distinct calls `parseStraceViolations` judges between yields to the event loop. */
349
+ const JUDGED_PER_TURN = 500
350
+
334
351
  export async function parseStraceViolations(
335
352
  logPath: string,
336
353
  args: SandboxedRunArgs,
@@ -386,19 +403,29 @@ export async function parseStraceViolations(
386
403
  }
387
404
 
388
405
  const seen = new Set<string>()
406
+ const judged = new Set<string>()
389
407
  const out: SandboxViolation[] = []
390
408
  for (const { syscall, rawPath, errno, dir, read } of deniedCalls(
391
409
  text,
392
410
  baselines.cwd,
393
411
  widened.size > 0,
394
412
  )) {
413
+ // A suite's log repeats a call by the thousand, each judged by sync
414
+ // stats: a test shard's 44,788 calls held the event loop 2.9 s, and a
415
+ // DTE worker that quiet for two heartbeats was lost (main, 2026-10-25).
416
+ // Each call is judged once, and the loop yields between batches.
417
+ const call = `${syscall}\0${rawPath}\0${dir}\0${errno}\0${read}`
418
+ if (judged.has(call)) continue
419
+ judged.add(call)
420
+ if (judged.size % JUDGED_PER_TURN === 0) await new Promise((r) => setImmediate(r))
395
421
  const lexical = absolutize(rawPath, dir ?? baselines.cwd)
396
422
  const abs = toRealPath(lexical)
397
423
  // Only report paths under the workspace-root deny anchor — system
398
424
  // libs / /proc / /sys / etc. probes are not interesting violations.
399
425
  if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
400
426
  // Skip paths the user explicitly allowed (and their descendants).
401
- if (isUnderAny(abs, allowAbs) && !hiddenByLink(lexical, abs, mounted, denyAnchors)) continue
427
+ const via = lexical === abs ? undefined : hiddenLink(lexical, abs, mounted, denyAnchors)
428
+ if (isUnderAny(abs, allowAbs) && via === undefined) continue
402
429
  if (underGlob(abs, pendingWrites)) continue
403
430
  if (read === true && !undeclared(abs)) continue
404
431
  // A hidden path answers ENOENT, as a missing one does: a probe
@@ -409,17 +436,20 @@ export async function parseStraceViolations(
409
436
  const key = `${syscall}|${abs}`
410
437
  if (seen.has(key)) continue
411
438
  seen.add(key)
439
+ // The line is printed verbatim, and the trace's `\033` is decoded: a
440
+ // denied file named `ESC ]0;…` retitled the terminal.
441
+ const [said, real] = [printable(rawPath), printable(abs)]
412
442
  out.push(
413
443
  read === true
414
444
  ? {
415
- line: `${syscall}(${rawPath}) read under a write grant's directory, granted no read [${abs}]`,
445
+ line: `${syscall}(${said}) read under a write grant's directory, granted no read [${real}]`,
416
446
  timestamp: new Date(),
417
447
  target: abs,
418
448
  path: abs,
419
449
  ignorable: ['read'],
420
450
  }
421
451
  : {
422
- line: `${syscall}(${rawPath}) = -1 ${errno} [${abs}]`,
452
+ line: `${syscall}(${said}) = -1 ${errno} [${real}]`,
423
453
  timestamp: new Date(),
424
454
  target: abs,
425
455
  path: abs,
@@ -427,6 +457,7 @@ export async function parseStraceViolations(
427
457
  // does not keep, and `access` may ask either — so either list
428
458
  // can silence it.
429
459
  ignorable: ['read', 'write'],
460
+ ...(via === undefined ? {} : { via }),
430
461
  },
431
462
  )
432
463
  }
@@ -434,8 +465,10 @@ export async function parseStraceViolations(
434
465
  }
435
466
 
436
467
  /**
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
468
+ * The directory holding a link on the path the task opened, when that
469
+ * directory is not in the sandbox, so the link is not there either: what
470
+ * a fix must grant beside the target. A granted file the task could not
471
+ * open this way was dropped as granted. A grant is mounted at its real path and
439
472
  * bwrap mounts no link, so `read: ['config.json']` over `config.json ->
440
473
  * conf/real.json` bound the target and left the name the task opens out;
441
474
  * judged by its real path, the ENOENT was granted and dropped, and a tool
@@ -444,26 +477,25 @@ export async function parseStraceViolations(
444
477
  * is missing outside the sandbox too, and outside the deny anchor the
445
478
  * host is mounted, links and all.
446
479
  */
447
- function hiddenByLink(
480
+ function hiddenLink(
448
481
  lexical: string,
449
482
  real: string,
450
483
  mounted: Set<string>,
451
484
  anchors: readonly string[],
452
- ): boolean {
453
- if (lexical === real || !existsSync(real)) return false
485
+ ): string | undefined {
486
+ if (!existsSync(real)) return undefined
454
487
  let at = lexical
455
- const links: string[] = []
456
488
  for (let up = path.dirname(at); up !== at; at = up, up = path.dirname(at)) {
457
489
  try {
458
- if (lstatSync(at).isSymbolicLink()) links.push(at)
490
+ if (!lstatSync(at).isSymbolicLink()) continue
459
491
  } catch {
460
492
  // Not on the host by this spelling: no link to judge here.
493
+ continue
461
494
  }
495
+ const holder = toRealPath(up)
496
+ if (anchors.some((a) => atOrUnder(holder, a)) && !isUnderAny(holder, mounted)) return holder
462
497
  }
463
- return links.some((l) => {
464
- const holder = toRealPath(path.dirname(l))
465
- return anchors.some((a) => atOrUnder(holder, a)) && !isUnderAny(holder, mounted)
466
- })
498
+ return undefined
467
499
  }
468
500
 
469
501
  /**