@vzn/vx 0.0.645 → 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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -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,
@@ -59,8 +62,10 @@ import {
59
62
  type CaptureConfig,
60
63
  type RunResult,
61
64
  } from './runner.js'
65
+ import { watchTree } from './proc-sample.js'
62
66
  import {
63
67
  BUN_GLOB_WILDCARDS,
68
+ ESSENTIAL_ENV,
64
69
  executablePath,
65
70
  helperEnv,
66
71
  taskShell,
@@ -68,6 +73,7 @@ import {
68
73
  isTmpdirRefusal,
69
74
  procfsIsOwn,
70
75
  TMPDIR_HINT,
76
+ ui,
71
77
  UserError,
72
78
  xxh3hex,
73
79
  } from '../util/index.js'
@@ -205,6 +211,7 @@ async function probeUncached(weakerNested: boolean): Promise<SandboxAvailability
205
211
  function applyPolicyHere(): SandboxAvailability {
206
212
  const proc = Bun.spawnSync({
207
213
  cmd: ['sandbox-exec', '-p', '(version 1)(allow default)', '/usr/bin/true'],
214
+ env: helperEnv(),
208
215
  stdout: 'ignore',
209
216
  stderr: 'pipe',
210
217
  })
@@ -230,12 +237,25 @@ function applyPolicyHere(): SandboxAvailability {
230
237
  * the loader).
231
238
  */
232
239
  export function unavailableReason(exitCode: number | null, stderr: string): string {
233
- const hint = stderr.includes('uid_map')
234
- ? " — 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"
235
- : ''
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
+ : ''
236
246
  return `a sandboxed \`true\` failed (exit ${exitCode}): ${stderr.slice(0, 200)}${hint}`
237
247
  }
238
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
+
239
259
  async function trySandboxedTrue(
240
260
  SandboxManager: SrtModule['SandboxManager'],
241
261
  weakerNested: boolean,
@@ -248,6 +268,8 @@ async function trySandboxedTrue(
248
268
  )
249
269
  const proc = Bun.spawn([taskShell(), '-c', wrapped], {
250
270
  argv0: 'sh',
271
+ // What a task gets of vx's env: the probe is a sandboxed `true`.
272
+ env: essentialEnv(),
251
273
  stdout: 'ignore',
252
274
  stderr: 'pipe',
253
275
  stdin: 'ignore',
@@ -445,10 +467,41 @@ function trackTaskTmpdir(dir: string): void {
445
467
  if (tmpdirExitHooked) return
446
468
  tmpdirExitHooked = true
447
469
  process.on('exit', () => {
448
- for (const d of liveTaskTmpdirs) rmSync(d, { recursive: true, force: true })
470
+ for (const d of liveTaskTmpdirs) removeTaskTmpdir(d)
449
471
  })
450
472
  }
451
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
+
452
505
  /** Whether SRT is up in this process — set by `initSandbox`, cleared by `resetSandbox`. */
453
506
  let srtUp = false
454
507
  /**
@@ -595,6 +648,22 @@ function noScanCommand(): string {
595
648
  return file
596
649
  }
597
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
+
598
667
  function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
599
668
  if (process.platform !== 'linux') return {}
600
669
  const paths: { bwrapPath?: string; socatPath?: string } = {}
@@ -604,13 +673,93 @@ function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
604
673
  // SRT's dependency check says so
605
674
  }
606
675
  try {
607
- paths.socatPath = executablePath('socat')
676
+ paths.socatPath = envCutSocat(executablePath('socat'))
608
677
  } catch {
609
678
  // SRT's dependency check says so
610
679
  }
611
680
  return paths
612
681
  }
613
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
+
614
763
  /**
615
764
  * Whether this Linux host speaks IPv6. SRT's in-sandbox network bridge is
616
765
  * `socat TCP-LISTEN:3128`, and socat 1.8 opens that as an IPv6 socket: on
@@ -1165,6 +1314,12 @@ export interface SandboxViolation {
1165
1314
  hint?: true
1166
1315
  /** Absolute, when `target` is a path at all. */
1167
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
1168
1323
  ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
1169
1324
  }
1170
1325
 
@@ -1279,6 +1434,9 @@ async function wrapIn(
1279
1434
  baselines.cwd,
1280
1435
  )
1281
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)
1282
1440
  if (process.platform === 'linux' && !cwdMounted(baselines.cwd, customConfig!.filesystem!)) {
1283
1441
  customConfig!.filesystem!.denyRead!.push(baselines.cwd)
1284
1442
  }
@@ -1323,7 +1481,10 @@ async function wrapIn(
1323
1481
  process.platform === 'linux'
1324
1482
  ? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
1325
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.
1326
1486
  const inner = [
1487
+ process.platform === 'linux' ? `: 'vx-${tag}';` : '',
1327
1488
  ports.length > 0 ? portBridgeInner(ports, tag) : '',
1328
1489
  process.platform === 'linux' && args.config.network !== undefined ? PROXY_BRIDGE_WAIT : '',
1329
1490
  grouped.command,
@@ -1503,7 +1664,10 @@ function ownGroupCommand(
1503
1664
  * What strace stops on: the reads, the execs and access probes (a refused
1504
1665
  * `./gen.sh || fallback` passed unreported), and what moves or makes a
1505
1666
  * process's cwd. Not the stat family: a stop per stat cost oxlint 19% and
1506
- * `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.
1507
1671
  */
1508
1672
  const TRACED_CALLS =
1509
1673
  'trace=openat,execve,?access,faccessat,?faccessat2,chdir,fchdir,clone,?clone3,?fork,?vfork'
@@ -1603,7 +1767,8 @@ function seatbeltSpells(grant: string, kind: 'read' | 'write', name: boolean): b
1603
1767
  const at = name ? m.index : m.index + m[0].length - 2
1604
1768
  if (!warnedUnspellable.has(`${kind} ${grant}`)) {
1605
1769
  warnedUnspellable.add(`${kind} ${grant}`)
1606
- process.stderr.write(
1770
+ ui.say(
1771
+ 'warn',
1607
1772
  `[vx] sandbox: the ${kind} grant ${grant} names a literal ${grant[at + (name ? 0 : 1)]}, ` +
1608
1773
  `which the macOS sandbox reads as a pattern and cannot spell as a name, so it is not ` +
1609
1774
  `granted. Rename it, or grant the directory above it: ` +
@@ -1831,7 +1996,8 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
1831
1996
  */
1832
1997
  export function releaseBridges(tag: string): void {
1833
1998
  const tmp = taskTmpdir(tag)
1834
- 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)
1835
2001
  if (liveServers.delete(tag)) {
1836
2002
  // A server's wrap stays counted until it stops: one stopped server
1837
2003
  // kept every later task's stubs in the workspace until the reset.
@@ -1878,11 +2044,25 @@ const TRACER_RETRY_LINE =
1878
2044
  * declared, so a second run redoes, not doubles, it.
1879
2045
  */
1880
2046
  export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
1881
- 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
+ })
1882
2061
  // A stopping run has killed the children it holds; a retry would be one
1883
2062
  // spawned after that kill.
1884
2063
  if (!tracerFailed || args.signal?.aborted === true) return first
1885
- args.onStderr?.(TRACER_RETRY_LINE)
2064
+ const notice = (midLine ? '\n' : '') + TRACER_RETRY_LINE
2065
+ args.onStderr?.(notice)
1886
2066
  // `timeout` bounds the task's whole run (schema.md), so the retry gets
1887
2067
  // what the first attempt left of it, not a fresh window.
1888
2068
  const {
@@ -1898,7 +2078,7 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
1898
2078
  ...second,
1899
2079
  durationMs: first.durationMs + second.durationMs,
1900
2080
  stdout: first.stdout + second.stdout,
1901
- stderr: first.stderr + TRACER_RETRY_LINE + second.stderr,
2081
+ stderr: first.stderr + notice + second.stderr,
1902
2082
  }
1903
2083
  }
1904
2084
 
@@ -2051,7 +2231,7 @@ async function runSandboxedOnce(
2051
2231
  rmSync(straceLog, { force: true })
2052
2232
  liveTempFiles.delete(straceLog)
2053
2233
  }
2054
- const stderr = spawnFailureText(err, args.cwd, 'sandboxed task')
2234
+ const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped)
2055
2235
  args.onStderr?.(stderr)
2056
2236
  releaseBridges(tag)
2057
2237
  takeRecords()
@@ -2074,6 +2254,7 @@ async function runSandboxedOnce(
2074
2254
 
2075
2255
  args.liveChildren?.add(proc)
2076
2256
  args.onSpawn?.(proc.pid)
2257
+ const watch = process.platform === 'linux' ? watchTree(proc.pid) : undefined
2077
2258
  const timeout = armTimeout(proc, args.timeoutMs)
2078
2259
  const armedAt = Date.now()
2079
2260
  const ac = new AbortController()
@@ -2081,8 +2262,9 @@ async function runSandboxedOnce(
2081
2262
  let partial = ''
2082
2263
  let straceSpoke = false
2083
2264
  const [out, err] = pipes.streams(proc)
2265
+ const ends = lineEnds(args.onStdout, args.onStderr)
2084
2266
  const streams = Promise.all([
2085
- streamToString(out, args.onStdout, ac.signal, args.capture?.stdout ?? true),
2267
+ streamToString(out, ends.onStdout, ac.signal, args.capture?.stdout ?? true),
2086
2268
  streamToString(
2087
2269
  err,
2088
2270
  (chunk) => {
@@ -2091,7 +2273,7 @@ async function runSandboxedOnce(
2091
2273
  const lines = (partial + chunk).split('\n')
2092
2274
  partial = (lines.pop() ?? '').slice(0, 512)
2093
2275
  if (lines.some((l) => STRACE_OWN_ERROR.test(l))) straceSpoke = true
2094
- args.onStderr?.(chunk)
2276
+ ends.onStderr?.(chunk)
2095
2277
  },
2096
2278
  ac.signal,
2097
2279
  args.capture?.stderr ?? true,
@@ -2102,18 +2284,23 @@ async function runSandboxedOnce(
2102
2284
  // aborts at once, otherwise drainOrAbort bounds the post-exit drain.
2103
2285
  await proc.exited
2104
2286
  const ranMs = Date.now() - armedAt
2287
+ const looked = watch?.stop()
2105
2288
  await timeout.settle()
2106
2289
  let cut = false
2107
2290
  if (timeout.timedOut()) ac.abort()
2108
2291
  else cut = await drainOrAbort(streams, ac)
2109
2292
  const [stdout, streamed] = await streams
2110
- if (cut) args.onStderr?.(POST_EXIT_CUT_LINE)
2111
- 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
2112
2296
  args.liveChildren?.delete(proc)
2113
2297
  releaseGroup(proc)
2114
2298
  closeSignalChannel(proc)
2115
2299
  releaseBridges(tag)
2116
- 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
2117
2304
 
2118
2305
  // macOS: read the violation store keyed by our tagged command.
2119
2306
  //
@@ -2132,8 +2319,8 @@ async function runSandboxedOnce(
2132
2319
  // DROPPED by the unified log under pressure, not delayed. Reporting on
2133
2320
  // macOS is therefore lossy-by-OS under load; ENFORCEMENT is unaffected
2134
2321
  // (every observed loss still denied the read and failed the child).
2135
- // EVERY record SRT's store saw for this command, unfiltered (owner,
2136
- // 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;
2137
2324
  // deciding on the user's behalf that a record was "just traversal" is how
2138
2325
  // a failure ends up with an empty violations section and no explanation.
2139
2326
  const records = takeRecords()
@@ -2148,7 +2335,7 @@ async function runSandboxedOnce(
2148
2335
  // below (`refusedWrites`).
2149
2336
  const macViolations = process.platform === 'darwin' ? records : []
2150
2337
  // No settle window: the store is read once, right after the child exits
2151
- // (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
2152
2339
  // full budget, since a task with nothing to report can only prove that by
2153
2340
  // waiting — against 26ms for one that reports something and breaks out
2154
2341
  // early. Measured: clean `echo` 331ms sandboxed vs 16ms plain.
@@ -2268,7 +2455,7 @@ async function runSandboxedOnce(
2268
2455
  stdout,
2269
2456
  stderr,
2270
2457
  violations,
2271
- ...(proc.signalCode ? { signal: proc.signalCode } : {}),
2458
+ ...(typeof proc.signalCode === 'string' ? { signal: proc.signalCode } : {}),
2272
2459
  ...(timeout.timedOut() ? { timedOut: true } : {}),
2273
2460
  ...(cut ? { leftRunning: true } : {}),
2274
2461
  // Linux: the task's CPU and peak never reach this wait. bwrap runs it
@@ -2276,10 +2463,14 @@ async function runSandboxedOnce(
2276
2463
  // processes used is not folded into bwrap's usage: a 500 ms busy loop
2277
2464
  // read 2 ms under it and 510 without the flag, and the peak read was
2278
2465
  // vx's own high-water mark, inherited at exec (a sandboxed `true` from a
2279
- // 300 MB vx: 353 MB; B-3). Nothing is reported rather than a number
2280
- // that is someone else's. macOS's `sandbox-exec` execs the command, so
2281
- // its usage is the task's.
2282
- ...(process.platform === 'linux' ? {} : resourceUsageToCpuRss(proc.resourceUsage())),
2466
+ // 300 MB vx: 353 MB; B-3). So the tree is looked at while it runs
2467
+ // (`watchTree`); a task over before the first look reports nothing.
2468
+ // macOS's `sandbox-exec` execs the command, so its usage is the task's.
2469
+ ...(process.platform !== 'linux'
2470
+ ? resourceUsageToCpuRss(proc.resourceUsage())
2471
+ : looked === undefined
2472
+ ? {}
2473
+ : { cpuMs: looked.cpuMs, peakRssBytes: looked.rssBytes }),
2283
2474
  ranMs,
2284
2475
  tracerFailed:
2285
2476
  straceLog !== undefined &&
@@ -2847,7 +3038,8 @@ function writeGrantMatchedNothing(grant: string, within: string): void {
2847
3038
  // As a committed config spells them (B-97): the absolute path held only
2848
3039
  // on the machine that printed it, as in `outsideWritesHint`.
2849
3040
  const dir = grantSpelled(grantPrefix(grant), within)
2850
- process.stderr.write(
3041
+ ui.say(
3042
+ 'warn',
2851
3043
  `[vx] sandbox: the write grant ${jsString(grantSpelled(grant, within))} matches nothing ` +
2852
3044
  `yet, and a read grant mounts its directory read-only, so a file the task creates under ` +
2853
3045
  `it will fail with "Read-only file system". A bind mount covers what exists when the ` +
@@ -2883,6 +3075,7 @@ function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why: string
2883
3075
  async function probeStrace(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
2884
3076
  try {
2885
3077
  const p = Bun.spawn([executablePath('strace'), '--version'], {
3078
+ env: helperEnv(),
2886
3079
  stdout: 'pipe',
2887
3080
  stderr: 'ignore',
2888
3081
  })
@@ -2956,7 +3149,7 @@ async function traceRefusal(
2956
3149
  '--',
2957
3150
  executablePath('true'),
2958
3151
  ],
2959
- { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
3152
+ { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe', env: helperEnv() },
2960
3153
  )
2961
3154
  const err = await new Response(p.stderr).text()
2962
3155
  const line = err.split('\n').find((l) => STRACE_OWN_ERROR.test(l))
@@ -2969,7 +3162,8 @@ let warnedNoTrace = false
2969
3162
  function warnUntraced(why: string): void {
2970
3163
  if (warnedNoTrace) return
2971
3164
  warnedNoTrace = true
2972
- process.stderr.write(
3165
+ ui.say(
3166
+ 'warn',
2973
3167
  `[vx] sandbox: ${why}, so sandboxed tasks run without the report of the reads the sandbox denied; ` +
2974
3168
  `the sandbox still enforces\n`,
2975
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
  /**