@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -44,6 +44,7 @@ import {
44
44
  writeFileSync,
45
45
  } from 'node:fs'
46
46
  import { mkdir, unlink } from 'node:fs/promises'
47
+ import { createConnection } from 'node:net'
47
48
  import type { SandboxConfig } from '../config.js'
48
49
  import {
49
50
  armTimeout,
@@ -58,10 +59,11 @@ import {
58
59
  spawnFailureText,
59
60
  streamToString,
60
61
  resourceUsageToCpuRss,
61
- TaskPipes,
62
62
  type CaptureConfig,
63
63
  type RunResult,
64
64
  } from './runner.js'
65
+ import { TaskPipes } from './pipes.js'
66
+ import { startTaskProxy, type TaskProxy } from './task-proxy.js'
65
67
  import { watchTree } from './proc-sample.js'
66
68
  import {
67
69
  BUN_GLOB_WILDCARDS,
@@ -73,10 +75,10 @@ import {
73
75
  isTmpdirRefusal,
74
76
  procfsIsOwn,
75
77
  TMPDIR_HINT,
76
- ui,
77
78
  UserError,
78
79
  xxh3hex,
79
80
  } from '../util/index.js'
81
+ import * as ui from '../ui/index.js'
80
82
  import {
81
83
  bindableReads,
82
84
  bindableWrites,
@@ -664,6 +666,124 @@ function hostHelpers(): { dirs: string[]; files: string[] } {
664
666
  return { dirs: unique(dirs), files: files.map(toRealPath) }
665
667
  }
666
668
 
669
+ /**
670
+ * What a hermetic task reads outside the workspace without naming it: the
671
+ * programs vx and the runtime run around its command (the runtime's bash,
672
+ * the task shell, setsid, strace, env, socat, the seccomp helper, and the
673
+ * grep and sleep its network wait runs) and what the
674
+ * loader maps for them. Anything else outside the workspace is the task's
675
+ * to grant. Learned once per PATH: the files a host's shell links are
676
+ * fixed for the process.
677
+ */
678
+ interface HermeticReads {
679
+ /** What is denied: `/`, spelled as the runtime can mount it. */
680
+ deny: string[]
681
+ allow: string[]
682
+ }
683
+ let hermeticMemo: { path: string; reads: Promise<HermeticReads> } | undefined
684
+ function hermeticReads(): Promise<HermeticReads> {
685
+ const PATH = process.env['PATH'] ?? ''
686
+ if (hermeticMemo?.path !== PATH) hermeticMemo = { path: PATH, reads: learnHermeticReads() }
687
+ return hermeticMemo.reads
688
+ }
689
+
690
+ async function learnHermeticReads(): Promise<HermeticReads> {
691
+ return { deny: rootDenies(), allow: await hermeticAllows() }
692
+ }
693
+
694
+ /**
695
+ * `/` as the runtime can deny it. Given `/`, it lays a tmpfs over every
696
+ * entry of the root, and bwrap cannot mount one on a link: a merged-/usr
697
+ * host's `/sbin -> usr/sbin` failed every hermetic task. A link at the
698
+ * root leads into a directory denied here, so it is left as it is. `/proc`,
699
+ * `/dev` and `/sys` are the runtime's own mounts; macOS denies by path, so
700
+ * `/` stands.
701
+ */
702
+ function rootDenies(): string[] {
703
+ if (process.platform !== 'linux') return ['/']
704
+ const skip = new Set(['proc', 'dev', 'sys'])
705
+ return readdirSync('/', { withFileTypes: true })
706
+ .filter((e) => !skip.has(e.name) && !e.isSymbolicLink())
707
+ .map((e) => `/${e.name}`)
708
+ }
709
+
710
+ async function hermeticAllows(): Promise<string[]> {
711
+ const programs: string[] = []
712
+ for (const name of ['bash', 'setsid', 'strace', 'env', 'socat', 'grep', 'sleep']) {
713
+ try {
714
+ programs.push(executablePath(name))
715
+ } catch {
716
+ // The runtime's dependency check names a missing one.
717
+ }
718
+ }
719
+ programs.push(taskShell())
720
+ if (process.platform === 'darwin') {
721
+ // dyld and the shared cache every macOS binary maps.
722
+ return unique([...programs.flatMap(bothSpellings), '/usr/lib', '/System'])
723
+ }
724
+ const { getApplySeccompBinaryPath } =
725
+ await import('@anthropic-ai/sandbox-runtime/dist/sandbox/generate-seccomp-filter.js')
726
+ const seccomp = getApplySeccompBinaryPath(undefined)
727
+ if (seccomp) programs.push(seccomp)
728
+ const files = [...programs]
729
+ // The loader and the libraries each program links, by `ldd`'s answer.
730
+ let ldd: string
731
+ try {
732
+ ldd = executablePath('ldd')
733
+ } catch {
734
+ throw new UserError(
735
+ 'exec.sandbox.hermetic: no `ldd` on PATH, so vx cannot tell which libraries the ' +
736
+ "task's shell needs — install it (glibc's libc-bin), or drop hermetic",
737
+ )
738
+ }
739
+ const r = Bun.spawnSync({ cmd: [ldd, ...programs], stdout: 'pipe', stderr: 'ignore' })
740
+ for (const m of r.stdout.toString().matchAll(/(\/\S+)\s+\(0x/g)) files.push(m[1]!)
741
+ return unique(files.flatMap(bothSpellings))
742
+ }
743
+
744
+ /**
745
+ * Every spelling the loader walks from `p` to its file: a link (the
746
+ * loader's `/lib64/ld-linux-x86-64.so.2` is one, then `/usr/lib64`'s is
747
+ * another) is bound as the file it reaches, so each hop needs its own
748
+ * grant, named under its directory's real path, where the mount lands.
749
+ */
750
+ function bothSpellings(p: string): string[] {
751
+ const hops: string[] = []
752
+ for (let at = p; hops.length < 40;) {
753
+ const here = path.join(toRealPath(path.dirname(at)), path.basename(at))
754
+ hops.push(here)
755
+ let next: string
756
+ try {
757
+ if (!lstatSync(here).isSymbolicLink()) break
758
+ next = path.resolve(path.dirname(here), readlinkSync(here))
759
+ } catch {
760
+ break
761
+ }
762
+ at = next
763
+ }
764
+ return hops
765
+ }
766
+
767
+ /**
768
+ * The runtime's own sockets a hermetic task's command reaches: the write
769
+ * observer's and the network proxy's, under the host's temp directory.
770
+ * The runtime binds each before the read denies, so the deny's tmpfs over
771
+ * that directory hid them (`connect …/srt-obs-…: No such file`), and no
772
+ * call names the observer's. They are read off the wrap, once per run
773
+ * each: the observer listens after the runtime starts, so a later wrap may
774
+ * name one the first did not.
775
+ */
776
+ const runtimeOwn = new Set<string>()
777
+ function runtimeBinds(wrapped: string): string[] {
778
+ const tmp = toRealPath(os.tmpdir()) + path.sep
779
+ const out: string[] = []
780
+ for (const m of wrapped.matchAll(/--bind (?:'([^']*)'|(\S+)) (?:'([^']*)'|(\S+))/g)) {
781
+ const from = m[1] ?? m[2]!
782
+ if (from === (m[3] ?? m[4]) && from.startsWith(tmp)) out.push(from)
783
+ }
784
+ return out
785
+ }
786
+
667
787
  function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
668
788
  if (process.platform !== 'linux') return {}
669
789
  const paths: { bwrapPath?: string; socatPath?: string } = {}
@@ -815,16 +935,14 @@ function assertDomains(
815
935
  * @param opts.allowedDomains every domain any sandboxed task in this run
816
936
  * declared. SRT's filtering proxy is per-RUN and reads its allowlist from
817
937
  * this call, never from the per-task config, so the union is the only
818
- * place a domain list can take effect. Per-task precision survives where
819
- * it matters: a task that declared none is never handed the proxy port.
938
+ * place a domain list can take effect; each task's own front
939
+ * (`ownProxy`) narrows it to the task's list.
820
940
  */
821
941
  export async function initSandbox(opts?: {
822
942
  allowedDomains?: readonly string[]
823
943
  /**
824
- * Every domain any sandboxed task of the run denies. The proxy is the
825
- * run's, so a deny refuses it for every task, as the allowlist is the
826
- * union's; before B-21 the list was always empty and a `deny.network`
827
- * refused nothing.
944
+ * Every domain any sandboxed task of the run denies, checked here and
945
+ * never handed to the run's proxy: each task's front refuses its own.
828
946
  */
829
947
  deniedDomains?: readonly string[]
830
948
  /**
@@ -853,7 +971,9 @@ export async function initSandbox(opts?: {
853
971
  const config: Parameters<typeof SandboxManager.initialize>[0] = {
854
972
  network: {
855
973
  allowedDomains: [...(opts?.allowedDomains ?? [])],
856
- deniedDomains: [...(opts?.deniedDomains ?? [])],
974
+ // Each task's own front refuses its own denies; the run's proxy
975
+ // refusing the union denied one task's host to every task.
976
+ deniedDomains: [],
857
977
  ...(opts?.allowAllUnixSockets === true ? { allowAllUnixSockets: true } : {}),
858
978
  },
859
979
  filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
@@ -862,6 +982,8 @@ export async function initSandbox(opts?: {
862
982
  ...(scopedDenyScan ? scopedScanConfig() : {}),
863
983
  ...bundledJavaAgent(),
864
984
  }
985
+ runAllowed = config.network.allowedDomains
986
+ runAllowsHosts = runAllowed.length > 0
865
987
  const wasUp = srtUp
866
988
  if (!wasUp) await unlinkStaleMuxSockets()
867
989
  const listening = srtCleanupAdopted ? undefined : cleanupListeners()
@@ -920,6 +1042,7 @@ export async function resetSandbox(): Promise<void> {
920
1042
 
921
1043
  function srtDown(): void {
922
1044
  srtUp = false
1045
+ runtimeOwn.clear()
923
1046
  perTaskRun = undefined
924
1047
  availabilityCache.clear()
925
1048
  straceAvailableCache = undefined
@@ -1108,6 +1231,8 @@ export interface ResolvedSandboxConfig {
1108
1231
  gitConfig?: boolean
1109
1232
  weakerWhenNested?: boolean
1110
1233
  weakerNetworkIsolation?: boolean
1234
+ /** Reads outside the workspace are refused too (`hermeticReads`). */
1235
+ hermetic?: boolean
1111
1236
  /** macOS: the walls a glob grant reaches, by kind (`darwinWallRules`). */
1112
1237
  wallsReached?: { read: readonly string[]; write: readonly string[] }
1113
1238
  ignore?: {
@@ -1266,6 +1391,7 @@ export function resolveSandboxConfig(
1266
1391
  if (cfg.weakerNetworkIsolation !== undefined) {
1267
1392
  r.weakerNetworkIsolation = cfg.weakerNetworkIsolation
1268
1393
  }
1394
+ if (cfg.hermetic === true) r.hermetic = true
1269
1395
  if (cfg.ignore !== undefined) {
1270
1396
  // Relative patterns anchor at the project dir, `~` ones at the home
1271
1397
  // directory, as a grant's are (a `~` pattern kept as written matched no
@@ -1434,6 +1560,11 @@ async function wrapIn(
1434
1560
  baselines.cwd,
1435
1561
  )
1436
1562
  customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
1563
+ if (args.config.hermetic === true) {
1564
+ const reads = await hermeticReads()
1565
+ customConfig!.filesystem!.denyRead!.push(...reads.deny)
1566
+ customConfig!.filesystem!.allowRead!.push(...reads.allow, ...runtimeOwn)
1567
+ }
1437
1568
  const helpers = hostHelpers()
1438
1569
  customConfig!.filesystem!.denyRead!.push(...helpers.dirs)
1439
1570
  customConfig!.filesystem!.allowRead!.push(...helpers.files)
@@ -1502,6 +1633,29 @@ async function wrapIn(
1502
1633
  ports.length > 0 || asksUnixSockets(args.config),
1503
1634
  args.config.gitConfig === true,
1504
1635
  )
1636
+ if (process.platform === 'linux' && args.config.hermetic === true) {
1637
+ const own = runtimeBinds(wrapped).filter((p) => !runtimeOwn.has(p))
1638
+ if (own.length > 0) {
1639
+ for (const p of own) runtimeOwn.add(p)
1640
+ customConfig!.filesystem!.allowRead!.push(...own)
1641
+ wrapped = await wrapForTask(
1642
+ SandboxManager,
1643
+ inner,
1644
+ literalReadPaths(customConfig),
1645
+ ports.length > 0 || asksUnixSockets(args.config),
1646
+ args.config.gitConfig === true,
1647
+ )
1648
+ }
1649
+ }
1650
+ if (runAllowsHosts) {
1651
+ try {
1652
+ wrapped = await ownProxy(SandboxManager, wrapped, inner, tag, args.config)
1653
+ } catch (err) {
1654
+ releaseBridges(tag)
1655
+ afterCommand(SandboxManager)
1656
+ throw err
1657
+ }
1658
+ }
1505
1659
  if (process.platform === 'darwin') {
1506
1660
  const rules = [
1507
1661
  ...macProfileRules(args.config),
@@ -1821,6 +1975,77 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
1821
1975
  const PROXY_BRIDGE_WAIT =
1822
1976
  "( i=0; until grep -qsE ':0C38 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6 && grep -qsE ':0438 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6; do [ $i -ge 500 ] && break; i=$((i+1)); sleep 0.01; done );"
1823
1977
 
1978
+ /** Each networked task's own proxy front, by tag, closed when its bridges go. */
1979
+ const taskProxies = new Map<string, TaskProxy>()
1980
+ /** Whether the run's proxy allows any host: one that allows none refuses every task alike, front or not. */
1981
+ let runAllowsHosts = false
1982
+ /** The run's union: what a `network: true` task's front allows. */
1983
+ let runAllowed: readonly string[] = []
1984
+
1985
+ /**
1986
+ * A networked task's own proxy front (task-proxy.ts). The runtime's proxy
1987
+ * is the run's and filters by the union of every task's lists, so a task
1988
+ * reached a host only another task of the run declared. The front judges
1989
+ * by this task's own lists and passes what it allows on to the run's
1990
+ * proxy. Linux: it listens on a socket in the task's tmpdir, bound over
1991
+ * the runtime's bridge socket, so the task's side dials the same path.
1992
+ * macOS: it listens on a loopback port of its own, and the task's proxy
1993
+ * variables and profile name that port in place of the run's, so the
1994
+ * profile refuses the run's. A task that lists no network gets a front
1995
+ * that refuses every host; `network: true` gets one that allows the
1996
+ * run's union. Each front refuses only its own task's denies. Fails closed: a task the front cannot take does not run.
1997
+ */
1998
+ async function ownProxy(
1999
+ SandboxManager: SrtModule['SandboxManager'],
2000
+ wrapped: string,
2001
+ inner: string,
2002
+ tag: string,
2003
+ config: ResolvedSandboxConfig,
2004
+ ): Promise<string> {
2005
+ const port = SandboxManager.getProxyPort()
2006
+ const token = SandboxManager.getProxyAuthToken()
2007
+ const linux = process.platform === 'linux'
2008
+ const bridge = linux ? SandboxManager.getLinuxHttpSocketPath() : undefined
2009
+ const bind = bridge === undefined ? undefined : `--bind ${bridge} ${bridge}`
2010
+ const runPort = new RegExp(`localhost:${port}(?!\\d)`, 'g')
2011
+ const handed =
2012
+ port !== undefined &&
2013
+ token !== undefined &&
2014
+ (linux ? bind !== undefined && wrapped.includes(bind) : runPort.test(wrapped))
2015
+ // A task that lists no network is handed the run's proxy too when another
2016
+ // task of the run lists some; it gets a front that refuses every host.
2017
+ if (!handed) {
2018
+ if (config.network === undefined) return wrapped
2019
+ throw new Error('sandbox: the runtime gave this networked task no proxy to replace')
2020
+ }
2021
+ const socket = path.join(taskTmpdir(tag), 'vx-net.sock')
2022
+ const key = Buffer.from(inner.slice(0, 100)).toString('base64')
2023
+ const front = await startTaskProxy({
2024
+ endpoint: linux ? socket : 0,
2025
+ allowed:
2026
+ config.network === true
2027
+ ? runAllowed
2028
+ : config.network === undefined
2029
+ ? []
2030
+ : (config.network as string[]),
2031
+ denied: config.denyNetwork ?? [],
2032
+ runProxyPort: port!,
2033
+ token: token!,
2034
+ encodedCommand: key,
2035
+ refused: (line) => collecting.get(key)?.push({ line, timestamp: new Date() }),
2036
+ })
2037
+ taskProxies.set(tag, front)
2038
+ if (!linux) return wrapped.replace(runPort, `localhost:${front.port}`)
2039
+ // After the root's read-only bind, which would mask a bind made before it
2040
+ // with the host's own file: the runtime's socket, reachable as itself.
2041
+ const own = `--bind ${socket} ${bridge}`
2042
+ const root = ' --ro-bind / / '
2043
+ const at = wrapped.indexOf(root)
2044
+ return at === -1
2045
+ ? wrapped.replace(bind!, own)
2046
+ : `${wrapped.slice(0, at + root.length)}${own} ${wrapped.slice(at + root.length)}`
2047
+ }
2048
+
1824
2049
  /** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
1825
2050
  export function portBridgeSocket(tag: string, port: number): string {
1826
2051
  return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
@@ -2004,6 +2229,12 @@ export function releaseBridges(tag: string): void {
2004
2229
  afterCommand(srtLoaded!.SandboxManager)
2005
2230
  if (liveServers.size === 0 && resetDeferred) void resetSandbox().catch(() => {})
2006
2231
  }
2232
+ const front = taskProxies.get(tag)
2233
+ if (front !== undefined) {
2234
+ taskProxies.delete(tag)
2235
+ // quiet: a front that will not close holds only its own socket, gone with the tmpdir
2236
+ void front.close().catch(() => undefined)
2237
+ }
2007
2238
  const bridges = hostBridges.get(tag)
2008
2239
  if (bridges === undefined) return
2009
2240
  hostBridges.delete(tag)
@@ -2028,6 +2259,18 @@ export function releaseBridges(tag: string): void {
2028
2259
  const TRACER_RETRY_LINE =
2029
2260
  "[vx] the sandbox's tracer (strace) failed on its own; running the task again\n"
2030
2261
 
2262
+ /** What a task's output says when a mount went away as bwrap set the sandbox up. */
2263
+ const MOUNT_RACE_RETRY_LINE =
2264
+ '[vx] a mount went away as the sandbox was set up; running the task again\n'
2265
+
2266
+ /**
2267
+ * bwrap's own refusal of a mount gone between its read of the mount table
2268
+ * and its remount: a container runtime's transient mount under /tmp
2269
+ * (`/newroot/tmp/containerd-mount…`, a `docker build` beside the task on
2270
+ * CI). The command never started.
2271
+ */
2272
+ const BWRAP_MOUNT_GONE = /^bwrap: Can't .*: No such file or directory$/
2273
+
2031
2274
  /**
2032
2275
  * Run a single task wrapped in the sandbox. Caller must have called
2033
2276
  * `initSandbox()` first.
@@ -2053,20 +2296,21 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
2053
2296
  if (chunk.length > 0) midLine = !chunk.endsWith('\n')
2054
2297
  on?.(chunk)
2055
2298
  }
2056
- const { tracerFailed, ranMs, ...first } = await runSandboxedOnce({
2299
+ const { tracerFailed, mountGone, ranMs, ...first } = await runSandboxedOnce({
2057
2300
  ...args,
2058
2301
  onStdout: seen(args.onStdout),
2059
2302
  onStderr: seen(args.onStderr),
2060
2303
  })
2061
2304
  // A stopping run has killed the children it holds; a retry would be one
2062
2305
  // spawned after that kill.
2063
- if (!tracerFailed || args.signal?.aborted === true) return first
2064
- const notice = (midLine ? '\n' : '') + TRACER_RETRY_LINE
2306
+ if (!(tracerFailed || mountGone) || args.signal?.aborted === true) return first
2307
+ const notice = (midLine ? '\n' : '') + (mountGone ? MOUNT_RACE_RETRY_LINE : TRACER_RETRY_LINE)
2065
2308
  args.onStderr?.(notice)
2066
2309
  // `timeout` bounds the task's whole run (schema.md), so the retry gets
2067
2310
  // what the first attempt left of it, not a fresh window.
2068
2311
  const {
2069
2312
  tracerFailed: _again,
2313
+ mountGone: _gone,
2070
2314
  ranMs: _ran,
2071
2315
  ...second
2072
2316
  } = await runSandboxedOnce(
@@ -2088,18 +2332,21 @@ let collectorOn = false
2088
2332
 
2089
2333
  /**
2090
2334
  * Start collecting SRT's store records for `command`; the returned function
2091
- * stops and hands them over. The store is a 100-record ring the whole run
2092
- * shares, and on Linux its write observer reports every write any task
2335
+ * stops and hands them over, with `settle` once every record the task's
2336
+ * Linux observer (`observer`, its socket) sent is read. The store is a
2337
+ * 100-record ring the whole run shares, and on Linux its write observer
2338
+ * reports every write any task
2093
2339
  * makes, so a refused write followed by 150 declared ones was gone before
2094
2340
  * the task's exit read it (B-7). One subscription takes each record as it
2095
2341
  * arrives and keeps it only for a command still running. SRT keys a record
2096
2342
  * by the base64 of the command's first 100 characters
2097
2343
  * (`encodeSandboxedCommand`, not exported).
2098
2344
  */
2099
- function collectRecords(
2345
+ export function collectRecords(
2100
2346
  store: ReturnType<SrtModule['SandboxManager']['getSandboxViolationStore']>,
2101
2347
  command: string,
2102
- ): () => SandboxViolation[] {
2348
+ observer: string | undefined,
2349
+ ): (settle: boolean) => Promise<SandboxViolation[]> {
2103
2350
  if (!collectorOn) {
2104
2351
  collectorOn = true
2105
2352
  let seen = store.getTotalCount()
@@ -2115,12 +2362,44 @@ function collectRecords(
2115
2362
  const key = Buffer.from(command.slice(0, 100)).toString('base64')
2116
2363
  const list: SandboxViolation[] = []
2117
2364
  collecting.set(key, list)
2118
- return () => {
2365
+ return async (settle) => {
2366
+ if (settle && observer !== undefined) await observerCaughtUp(observer)
2119
2367
  collecting.delete(key)
2120
2368
  return list
2121
2369
  }
2122
2370
  }
2123
2371
 
2372
+ /**
2373
+ * The Unix socket SRT's Linux write observer listens on, from the command
2374
+ * it wrapped (`--setenv SRT_OBSERVE_SOCK <path>`); none when the listener
2375
+ * failed or on macOS.
2376
+ */
2377
+ function observerSocket(wrapped: string): string | undefined {
2378
+ return /--setenv SRT_OBSERVE_SOCK (?:'([^']*)'|(\S+))/.exec(wrapped)?.slice(1).find(Boolean)
2379
+ }
2380
+
2381
+ /**
2382
+ * Resolves once SRT has read every record sent to `socket` before the call.
2383
+ * A task's observer sends its records before the task exits, but they are
2384
+ * read when this process gets to that connection: a fixed count of event
2385
+ * loop turns after the exit (2) lost 4 of 24 refusals on a loaded CI
2386
+ * worker. So a connection of ours goes in after them: the kernel queues
2387
+ * connections in order, so SRT accepts it no earlier than the task's, and
2388
+ * the task's bytes were already there when it did. SRT's server is not
2389
+ * half-open, so it ends ours once it reads our end, and that end coming
2390
+ * back means the task's bytes were read first. A socket that is gone
2391
+ * answers at once with an error.
2392
+ */
2393
+ function observerCaughtUp(socket: string): Promise<void> {
2394
+ return new Promise((resolve) => {
2395
+ const conn = createConnection(socket)
2396
+ conn.on('connect', () => conn.end())
2397
+ conn.on('error', () => resolve())
2398
+ conn.on('close', () => resolve())
2399
+ conn.resume()
2400
+ })
2401
+ }
2402
+
2124
2403
  /**
2125
2404
  * strace's own message, a line of a traced task's stderr. strace names
2126
2405
  * itself by its argv[0], the absolute path vx runs it by
@@ -2140,7 +2419,7 @@ const STRACE_OWN_ERROR = /^(?:[^\s:]*\/)?strace: /
2140
2419
  */
2141
2420
  async function runSandboxedOnce(
2142
2421
  args: SandboxedRunArgs,
2143
- ): Promise<SandboxedRunResult & { tracerFailed: boolean; ranMs: number }> {
2422
+ ): Promise<SandboxedRunResult & { tracerFailed: boolean; mountGone: boolean; ranMs: number }> {
2144
2423
  const start = Date.now()
2145
2424
  const { SandboxManager } = await loadSrt()
2146
2425
  // Linux: SRT's store sees only writes (below), so read denials need
@@ -2174,12 +2453,16 @@ async function runSandboxedOnce(
2174
2453
  ...(useStrace ? { trace: useStrace } : {}),
2175
2454
  ...(widened !== undefined && widened.size > 0 ? { tracePaths: true } : {}),
2176
2455
  })
2177
- const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
2456
+ const takeRecords = collectRecords(
2457
+ SandboxManager.getSandboxViolationStore(),
2458
+ srtCommand,
2459
+ observerSocket(wrapped),
2460
+ )
2178
2461
  // A stop that landed during the awaits above leaves nothing to kill yet:
2179
2462
  // spawned now, the task ran after the teardown swept the run's children.
2180
2463
  if (args.signal?.aborted === true) {
2181
2464
  releaseBridges(tag)
2182
- takeRecords()
2465
+ void takeRecords(false)
2183
2466
  afterCommand(SandboxManager)
2184
2467
  const signal = stopSignal(args.signal.reason)
2185
2468
  return {
@@ -2190,6 +2473,7 @@ async function runSandboxedOnce(
2190
2473
  signal,
2191
2474
  violations: [],
2192
2475
  tracerFailed: false,
2476
+ mountGone: false,
2193
2477
  ranMs: 0,
2194
2478
  }
2195
2479
  }
@@ -2234,7 +2518,7 @@ async function runSandboxedOnce(
2234
2518
  const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped)
2235
2519
  args.onStderr?.(stderr)
2236
2520
  releaseBridges(tag)
2237
- takeRecords()
2521
+ void takeRecords(false)
2238
2522
  afterCommand(SandboxManager)
2239
2523
  return {
2240
2524
  exitCode: 127,
@@ -2244,6 +2528,7 @@ async function runSandboxedOnce(
2244
2528
  spawnFailed: true,
2245
2529
  violations: [],
2246
2530
  tracerFailed: false,
2531
+ mountGone: false,
2247
2532
  ranMs: 0,
2248
2533
  }
2249
2534
  } finally {
@@ -2261,6 +2546,7 @@ async function runSandboxedOnce(
2261
2546
  // The unfinished last line of stderr, and whether a line was strace's.
2262
2547
  let partial = ''
2263
2548
  let straceSpoke = false
2549
+ let mountGone = false
2264
2550
  const [out, err] = pipes.streams(proc)
2265
2551
  const ends = lineEnds(args.onStdout, args.onStderr)
2266
2552
  const streams = Promise.all([
@@ -2273,6 +2559,7 @@ async function runSandboxedOnce(
2273
2559
  const lines = (partial + chunk).split('\n')
2274
2560
  partial = (lines.pop() ?? '').slice(0, 512)
2275
2561
  if (lines.some((l) => STRACE_OWN_ERROR.test(l))) straceSpoke = true
2562
+ if (lines.some((l) => BWRAP_MOUNT_GONE.test(l))) mountGone = true
2276
2563
  ends.onStderr?.(chunk)
2277
2564
  },
2278
2565
  ac.signal,
@@ -2290,6 +2577,7 @@ async function runSandboxedOnce(
2290
2577
  if (timeout.timedOut()) ac.abort()
2291
2578
  else cut = await drainOrAbort(streams, ac)
2292
2579
  const [stdout, streamed] = await streams
2580
+ ends.end()
2293
2581
  const cutLine = ends.own(POST_EXIT_CUT_LINE)
2294
2582
  if (cut) args.onStderr?.(cutLine)
2295
2583
  const stderr = cut ? streamed + cutLine : streamed
@@ -2323,7 +2611,9 @@ async function runSandboxedOnce(
2323
2611
  // (2026-09-05). A sandboxed task that fails must say what it was denied;
2324
2612
  // deciding on the user's behalf that a record was "just traversal" is how
2325
2613
  // a failure ends up with an empty violations section and no explanation.
2326
- const records = takeRecords()
2614
+ // Linux: the write observer's records arrive over a socket, not with the
2615
+ // exit; `takeRecords(true)` waits until all it sent are read.
2616
+ const records = await takeRecords(true)
2327
2617
  // Read as is on darwin only. Since SRT 0.0.75 the store is fed on Linux
2328
2618
  // too, by the seccomp helper's write observer — but SRT judges those
2329
2619
  // reports against the GLOBAL `filesystem.allowWrite` from `initialize`
@@ -2472,6 +2762,7 @@ async function runSandboxedOnce(
2472
2762
  ? {}
2473
2763
  : { cpuMs: looked.cpuMs, peakRssBytes: looked.rssBytes }),
2474
2764
  ranMs,
2765
+ mountGone: exitCode !== 0 && (mountGone || BWRAP_MOUNT_GONE.test(partial)),
2475
2766
  tracerFailed:
2476
2767
  straceLog !== undefined &&
2477
2768
  !timeout.timedOut() &&
@@ -2625,7 +2916,7 @@ function sbplToken(value: string, field: string): string {
2625
2916
  *
2626
2917
  * `systemInfo` has no SRT field at any level. The rest DO have fields,
2627
2918
  * but SRT reads `network.allowLocalBinding` / `allowUnixSockets` /
2628
- * `allowMachLookup` off the config given to `initialize()` and never off
2919
+ * `allowMachLookup` / `enableWeakerNetworkIsolation` off the config given to `initialize()` and never off
2629
2920
  * the per-call one (`sandbox-manager.js` getters, 0.0.75 and 0.0.76) — so a per-task
2630
2921
  * grant passed there is silently dropped. vx is per-task by definition,
2631
2922
  * so it emits them itself. The rule text mirrors SRT's own.
@@ -2648,6 +2939,9 @@ export function macProfileRules(c: ResolvedSandboxConfig): string[] {
2648
2939
  rules.push('(allow network-inbound (local ip "*:*"))')
2649
2940
  rules.push('(allow network-outbound (remote ip "localhost:*"))')
2650
2941
  }
2942
+ if (c.weakerNetworkIsolation === true) {
2943
+ rules.push('(allow mach-lookup (global-name "com.apple.trustd.agent"))')
2944
+ }
2651
2945
  if (c.unixSockets === true) {
2652
2946
  rules.push('(allow system-socket (socket-domain AF_UNIX))')
2653
2947
  rules.push('(allow network-bind (local unix-socket (path-regex #"^/")))')