@vzn/vx 0.0.18 → 0.0.21

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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -1,9 +1,9 @@
1
1
  // The per-task execution contract. `execute-task.ts` decides WHAT to run
2
2
  // (command, env, sandbox baselines, capture) and hands a fully-resolved
3
- // request here; an executor decides WHERE/HOW the process runs. Core ships
4
- // no executor of its own: `@vzn/vx/plugins/local-executor` is the
5
- // in-process spawn, declared in vx.workspace.ts like any other. Persistent
6
- // tasks (`exec.persistent`) never reach an executor: they are local by
3
+ // request here; an executor decides WHERE/HOW the process runs. Core's own
4
+ // `local-executor.ts` is the in-process spawn, and it sits at the TAIL of
5
+ // every executor list — what a plugin executor declines runs here.
6
+ // Persistent tasks (`exec.persistent`) never reach an executor: they are local by
7
7
  // construction (a worker cannot hand the submitter a listening port) and
8
8
  // stay on `runPersistent`.
9
9
  //
@@ -19,23 +19,20 @@ export interface ExecuteSandbox {
19
19
  readonly baseAllowRead: readonly string[]
20
20
  readonly baseAllowWrite: readonly string[]
21
21
  readonly baseDenyRead: readonly string[]
22
- readonly config: ResolvedSandboxConfig
23
22
  /**
24
- * True when an EMPTY violation store on a CLEAN exit will be read as
25
- * PROOF (`--verify=inputs` treats zero violations as `proven-complete`).
26
- * On macOS the store is fed asynchronously from the unified log, so the
27
- * runtime must pay the bounded settle window even for a 0-exit — a leaky
28
- * task that swallows its own read error exits 0, and without the wait a
29
- * merely-late violation record turns into a false pass of the verify.
23
+ * Only denials on a path under this directory are reported. A denial
24
+ * outside it is still ENFORCED — the task cannot leave its project — but
25
+ * that is the wall doing its job, not a finding to fail a run over.
30
26
  */
31
- readonly settleOnCleanExit: boolean
27
+ readonly reportWithin: string
28
+ readonly config: ResolvedSandboxConfig
32
29
  }
33
30
 
34
31
  /** One declared input file, as the cache key saw it. */
35
32
  export interface InputFile {
36
33
  /** Workspace-relative POSIX path. */
37
34
  readonly path: string
38
- /** Git blob OID of the WORKTREE bytes (the same digest the key folds). */
35
+ /** Git blob OID of the WORKTREE bytes — for a symlink, of its target string — the same digest the key folds. */
39
36
  readonly digest: string
40
37
  }
41
38
 
@@ -175,18 +172,6 @@ export interface TaskPlacement {
175
172
  readonly pinnedLocal: boolean
176
173
  /** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
177
174
  readonly cacheable: boolean
178
- /**
179
- * `exec.resources` VERBATIM — CPU cores, megabytes, and the image a
180
- * worker must be running for this task to be routed to it. All three are
181
- * requirements MATCHED against what an executor has, never instructions
182
- * to build a machine: a distributed executor's workers belong to whoever
183
- * runs the fleet.
184
- *
185
- * Placement is where this belongs: `exec.resources` is stripped from the
186
- * cache key precisely because it decides WHERE a task fits, never what
187
- * it produces.
188
- */
189
- readonly resources?: Readonly<{ cpus?: number; memory?: number; image?: string }>
190
175
  }
191
176
 
192
177
  export interface TaskExecutor {
@@ -234,6 +219,6 @@ export function selectExecutor(
234
219
  if (executor.accepts === undefined || executor.accepts(task)) return executor
235
220
  }
236
221
  throw new Error(
237
- `no executor accepted ${task.taskId} (declared: ${executors.map((e) => e.name).join(', ')}). Declare localExecutorPlugin() after the executor that declined to run such tasks locally.`,
222
+ `no executor accepted ${task.taskId} (declared: ${executors.map((e) => e.name).join(', ')}). Core's local executor accepts every task, so this means it is missing from the list.`,
238
223
  )
239
224
  }
package/src/exec/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Module contract. Cross-module imports must come through here; see
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
3
 
4
- export { buildIsolatedEnv } from './env.js'
4
+ export { buildIsolatedEnv, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from './env.js'
5
5
  export {
6
6
  runCommand,
7
7
  runPersistent,
@@ -12,13 +12,17 @@ export {
12
12
  export {
13
13
  initSandbox,
14
14
  probeSandbox,
15
+ unavailableReason,
15
16
  resetSandbox,
16
17
  resolveSandboxConfig,
17
18
  runSandboxed,
18
- type DeniedCall,
19
+ releaseBridges,
20
+ wrapSandboxedCommand,
19
21
  type ResolvedSandboxConfig,
20
22
  type SandboxViolation,
21
23
  } from './sandbox-runtime.js'
24
+ export { type DeniedCall } from './sandbox-violations.js'
25
+ export { localExecutor } from './local-executor.js'
22
26
  export {
23
27
  selectExecutor,
24
28
  type ExecuteRequest,
@@ -1,13 +1,14 @@
1
- // Core's executor as a plugin: spawn the task's command in-process, exactly
2
- // the `runCommand` / `runSandboxed` call the orchestrator used to make
3
- // directly. Imports core only through the public `@vzn/vx` surface so this
4
- // directory can become its own package unchanged.
1
+ // Core's own local executor: spawn the task's command here, in this
2
+ // process. Not a plugin — "run it on this machine" is not a capability
3
+ // someone else supplies differently, it is what running MEANS when no
4
+ // plugin claims the task, so it sits at the TAIL of every executor list
5
+ // (owner, 2026-09-05). An executor plugin places work ELSEWHERE; its
6
+ // absence is "here".
7
+ import { runCommand } from './runner.js'
8
+ import { runSandboxed } from './sandbox-runtime.js'
9
+ import type { TaskExecutor } from './executor.js'
5
10
 
6
- import { runCommand, runSandboxed, type TaskExecutor, type VxPlugin } from '@vzn/vx'
7
-
8
- export const LOCAL_EXECUTOR_PLUGIN = 'vx/local-executor'
9
-
10
- /** The executor itself — for tests and for plugins that wrap local execution. */
11
+ /** The local executor. Accepts every task. */
11
12
  export function localExecutor(): TaskExecutor {
12
13
  return {
13
14
  name: 'local',
@@ -27,19 +28,14 @@ export function localExecutor(): TaskExecutor {
27
28
  const res = await runCommand(common)
28
29
  return { ...res, violations: [] }
29
30
  }
30
- return await runSandboxed({
31
+ return runSandboxed({
31
32
  ...common,
32
33
  baseAllowRead: req.sandbox.baseAllowRead,
33
34
  baseAllowWrite: req.sandbox.baseAllowWrite,
34
35
  baseDenyRead: req.sandbox.baseDenyRead,
36
+ reportWithin: req.sandbox.reportWithin,
35
37
  config: req.sandbox.config,
36
- settleOnCleanExit: req.sandbox.settleOnCleanExit,
37
38
  })
38
39
  },
39
40
  }
40
41
  }
41
-
42
- /** Declare in vx.workspace.ts: `plugins: [localExecutorPlugin(), …]`. Accepts every task. */
43
- export function localExecutorPlugin(): VxPlugin {
44
- return { name: LOCAL_EXECUTOR_PLUGIN, executor: () => localExecutor() }
45
- }
@@ -4,7 +4,9 @@
4
4
  // process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
5
5
  // folded into the v11 `runs` table by the orchestrator.
6
6
 
7
+ import { readFileSync } from 'node:fs'
7
8
  import { constants as osConstants } from 'node:os'
9
+ import { killGraceMs } from '../util/index.js'
8
10
 
9
11
  export interface RunResult {
10
12
  exitCode: number
@@ -152,6 +154,12 @@ export function signalExitCode(signal: string): number {
152
154
  return num === undefined ? 130 : 128 + num
153
155
  }
154
156
 
157
+ /**
158
+ * Grace after a timeout SIGTERM before escalating to SIGKILL — a child that
159
+ * ignores SIGTERM must still be bounded. Matches the persistent-shutdown grace.
160
+ */
161
+ const TIMEOUT_SIGKILL_GRACE_MS = 2000
162
+
155
163
  /**
156
164
  * Arm a SIGTERM timeout on a spawned child. Returns a handle whose
157
165
  * `timedOut()` reports whether the timer fired — so the caller can
@@ -160,12 +168,6 @@ export function signalExitCode(signal: string): number {
160
168
  * on its own. A no-op (never fires, nothing to clear) when `timeoutMs`
161
169
  * is undefined.
162
170
  */
163
- /**
164
- * Grace after a timeout SIGTERM before escalating to SIGKILL — a child that
165
- * ignores SIGTERM must still be bounded. Matches the persistent-shutdown grace.
166
- */
167
- const TIMEOUT_SIGKILL_GRACE_MS = 2000
168
-
169
171
  export function armTimeout(
170
172
  proc: ReturnType<typeof Bun.spawn>,
171
173
  timeoutMs: number | undefined,
@@ -182,7 +184,7 @@ export function armTimeout(
182
184
  // timeout, so a wedged child hangs the whole run forever. Mirrors the
183
185
  // end-of-run persistent-shutdown escalation. Unref'd so it never keeps
184
186
  // the CLI alive.
185
- killTimer = setTimeout(() => proc.kill('SIGKILL'), TIMEOUT_SIGKILL_GRACE_MS)
187
+ killTimer = setTimeout(() => proc.kill('SIGKILL'), killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
186
188
  killTimer.unref?.()
187
189
  }, timeoutMs)
188
190
  return {
@@ -395,6 +397,14 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
395
397
  ),
396
398
  )
397
399
  child.kill('SIGTERM')
400
+ // Same escalation as `armTimeout`: a server that traps TERM and
401
+ // never became ready is not in the persistent registry, so nothing
402
+ // else would ever kill it — it outlived the run under init.
403
+ const killTimer = setTimeout(
404
+ () => child.kill('SIGKILL'),
405
+ killGraceMs(TIMEOUT_SIGKILL_GRACE_MS),
406
+ )
407
+ killTimer.unref?.()
398
408
  }
399
409
  }, opts.timeoutMs)
400
410
  }
@@ -478,8 +488,32 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
478
488
  stderr,
479
489
  ...(proc.signalCode ? { signal: proc.signalCode } : {}),
480
490
  ...(timeout.timedOut() ? { timedOut: true } : {}),
481
- ...resourceUsageToCpuRss(proc.resourceUsage()),
491
+ ...resourceUsageToCpuRss(proc.resourceUsage(), ownRssHighWater()),
492
+ }
493
+ }
494
+
495
+ /**
496
+ * This process's own RSS high-water mark, in bytes: the floor under which
497
+ * a child's `ru_maxrss` says nothing about the child. Linux folds the
498
+ * forking parent's high-water mark into the child's figure at exec (a
499
+ * forked child starts with its parent's pages; `exec_mmap` keeps the old
500
+ * mm's peak), so a task lighter than vx itself reads vx's footprint —
501
+ * `true` read 44 MB through vx while its shell's `VmHWM` was 1.9 MB, and
502
+ * 300 MB allocated in the parent made `true` read 328 MB (2026-09-12).
503
+ * Read after the child exits so it covers the whole task's span (the mark
504
+ * is monotonic). Linux reads `VmHWM`; elsewhere the current RSS is the
505
+ * bound in hand.
506
+ */
507
+ export function ownRssHighWater(): number {
508
+ if (process.platform === 'linux') {
509
+ try {
510
+ const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
511
+ if (m !== null) return Number(m[1]) * 1024
512
+ } catch {
513
+ // /proc unreadable: fall through to the current RSS.
514
+ }
482
515
  }
516
+ return process.memoryUsage.rss()
483
517
  }
484
518
 
485
519
  /**
@@ -540,18 +574,23 @@ export async function streamToString(
540
574
  */
541
575
  export function resourceUsageToCpuRss(
542
576
  usage: ReturnType<ReturnType<typeof Bun.spawn>['resourceUsage']>,
543
- platform: NodeJS.Platform = process.platform,
577
+ /** The parent's own high-water mark (`ownRssHighWater`); a peak at or under it is inherited, not the child's, and is not reported. */
578
+ floorBytes = 0,
544
579
  ): { cpuMs?: number; peakRssBytes?: number } {
545
580
  if (!usage) return {}
546
581
  // cpuTime.total is microseconds as a bigint → ms.
547
582
  const cpuMs = Number(usage.cpuTime.total) / 1000
548
- // maxRSS is the raw kernel `ru_maxrss`, whose UNIT IS PLATFORM-SPECIFIC and
549
- // Bun does NOT normalize it:
550
- // • Linux → kilobytes (multiply by 1024 for bytes)
551
- // • macOS / BSD → bytes (already bytes; do NOT multiply)
552
- // • Windows → bytes (PeakWorkingSetSize)
553
- // Treating macOS's byte value as KB inflates peak RSS by 1024× — e.g. a
554
- // real 460 MB showed up as 460 GB. Convert per platform.
555
- const peakRssBytes = platform === 'linux' ? usage.maxRSS * 1024 : usage.maxRSS
556
- return { cpuMs, peakRssBytes }
583
+ // `maxRSS` is BYTES on every platform: Bun normalizes the kernel's
584
+ // `ru_maxrss` (kilobytes on Linux, bytes on macOS) before handing it
585
+ // over, as its typing says. This used to multiply by 1024 on Linux on
586
+ // the belief that Bun passed the raw value through, and every Linux
587
+ // peak was 1024× too big — a 64 MB `bun test` recorded as 64 GB in
588
+ // telemetry, `--summarize` and the run history, and once reservations
589
+ // were learned from that history (2026-09-12) every task with a
590
+ // recorded peak was over any budget and ran alone. Measured, not
591
+ // assumed: `tests/runner.test.ts` allocates a known number of bytes
592
+ // and reads the peak back within a bounded factor of it.
593
+ // A reading at or under the parent's own mark is the parent's (see
594
+ // `ownRssHighWater`): the child's peak is unknown, bounded by it.
595
+ return usage.maxRSS > floorBytes ? { cpuMs, peakRssBytes: usage.maxRSS } : { cpuMs }
557
596
  }
@@ -0,0 +1,182 @@
1
+ // Write grants as bwrap can honour them, and the SRT custom config built
2
+ // from the resolved policy: a file-shaped grant widened to its directory,
3
+ // a read grant punched around the write grants inside it, the network
4
+ // lists SRT insists on receiving in full.
5
+
6
+ import { lstatSync, readdirSync, statSync } from 'node:fs'
7
+ import path from 'node:path'
8
+ import { localBindingOn, unique } from './sandbox-paths.js'
9
+ import type { SandboxedRunArgs } from './sandbox-runtime.js'
10
+
11
+ type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
12
+
13
+ /**
14
+ * Write grants as bwrap can actually honour them.
15
+ *
16
+ * A grant naming a FILE becomes a bwrap file bind, and you cannot rename
17
+ * onto an active mount point: any tool that writes its output by staging
18
+ * beside it and renaming — `bun build --compile`, most compilers, every
19
+ * atomic writer — dies with EBUSY. Minimal repro, 2026-09-05: under
20
+ * `bwrap --bind /w/dist/out.bin /w/dist/out.bin`, `mv /w/s /w/dist/out.bin`
21
+ * is "Device or resource busy"; binding `/w/dist` instead succeeds.
22
+ *
23
+ * So on Linux a file-shaped grant is widened to its directory. That IS a
24
+ * widening — the task may write its siblings — and it is the narrowest
25
+ * grant the mechanism can express: the alternative is a declared output
26
+ * the task cannot produce. macOS needs none of this (seatbelt matches
27
+ * paths, it does not mount), so the grant stays exact there.
28
+ */
29
+ function bindableWrites(paths: readonly string[]): string[] {
30
+ if (process.platform !== 'linux') return [...paths]
31
+ return unique(
32
+ paths.map((p) => {
33
+ if (/[*?[\]]/.test(p)) return p
34
+ try {
35
+ if (statSync(p).isDirectory()) return p
36
+ } catch {
37
+ // Does not exist yet: `prepareOutputsForBind` creates a file for a
38
+ // file-shaped grant, so treat it as one.
39
+ }
40
+ return path.dirname(p)
41
+ }),
42
+ )
43
+ }
44
+
45
+ /** Directories already warned about below — once per process, not per spawn. */
46
+ const warnedSymlinkPunch = new Set<string>()
47
+
48
+ /**
49
+ * Expand a read grant so it is never an ANCESTOR of a write grant.
50
+ *
51
+ * bwrap builds the sandbox out of mounts, and SRT emits them write-first:
52
+ * `--bind <out>` then `--ro-bind <readPath>`. When the read path is an
53
+ * ancestor of the write path the read-only mount lands ON TOP of the
54
+ * writable one and every write fails with `Read-only file system`
55
+ * (`pushReadDenyDirMounts`, SRT 0.0.75 — its skip only covers the reverse
56
+ * nesting). Verified in a Linux container 2026-09-05:
57
+ *
58
+ * read=[proj] write=[proj/dist] → mkdir: Read-only file system
59
+ * read=[proj/src] write=[proj/dist] → ok
60
+ * read=[proj] no writes → ok
61
+ *
62
+ * So punch the write paths out: grant the ancestor's children instead,
63
+ * recursing only along the branches that actually contain one. Same probe,
64
+ * same command: `read=[src, lib, package.json] write=[dist]` → ok. macOS
65
+ * never needed this (seatbelt is precedence-based, not mounts), and it is
66
+ * harmless there, so both platforms take the same path.
67
+ *
68
+ * A grant with no write path under it is returned untouched — the common
69
+ * case costs nothing, not even a readdir.
70
+ */
71
+ export function punchWritePaths(readPath: string, writePaths: readonly string[]): string[] {
72
+ // Linux only. The shadowing is a property of bwrap MOUNTS; macOS seatbelt
73
+ // evaluates rules by precedence, so a read grant on a directory and a
74
+ // write grant inside it coexist. Punching there costs the directory
75
+ // ENTRY: granting every child is not granting the dir, so a command that
76
+ // stats its own cwd — `bun build` — is denied it and dies with
77
+ // `error: An unknown error occurred (Unexpected)` (2026-09-05).
78
+ if (process.platform !== 'linux') return [readPath]
79
+ const under = writePaths.filter((w) => w !== readPath && w.startsWith(readPath + path.sep))
80
+ if (under.length === 0) return [readPath]
81
+ let entries: string[]
82
+ try {
83
+ entries = readdirSync(readPath)
84
+ } catch {
85
+ // Unreadable or not a directory: nothing to expand, hand it over as is.
86
+ return [readPath]
87
+ }
88
+ const out: string[] = []
89
+ const linked: string[] = []
90
+ for (const entry of entries) {
91
+ const child = path.join(readPath, entry)
92
+ // A write path is already bound read-write, which is readable.
93
+ if (under.includes(child)) continue
94
+ // bwrap resolves a bind SOURCE, so a symlinked child is mounted as the
95
+ // directory it points at: inside the sandbox the link is gone. For a
96
+ // package in Bun's isolated node_modules layout that severs it from
97
+ // the `.bun/` siblings its own dependencies resolve through — astro
98
+ // could not find `yargs-parser` for four days of red CI (2026-09-09).
99
+ // SRT's config carries no `--symlink`, so the only fix is the grant:
100
+ // say which one, loudly.
101
+ try {
102
+ if (lstatSync(child).isSymbolicLink()) linked.push(child)
103
+ } catch {
104
+ // vanished between readdir and lstat: nothing to bind either way
105
+ }
106
+ out.push(...punchWritePaths(child, under))
107
+ }
108
+ if (linked.length > 0 && !warnedSymlinkPunch.has(readPath)) {
109
+ warnedSymlinkPunch.add(readPath)
110
+ process.stderr.write(
111
+ `[vx] sandbox: a write grant under ${readPath} makes its ${linked.length} symlinked ` +
112
+ `entr${linked.length === 1 ? 'y' : 'ies'} (${linked
113
+ .slice(0, 3)
114
+ .map((l) => path.basename(l))
115
+ .join(', ')}${linked.length > 3 ? ', …' : ''}) plain directories inside the sandbox — ` +
116
+ `a package resolved through one loses its siblings. Move the write grant (${under
117
+ .map((w) => path.relative(readPath, w))
118
+ .join(', ')}) out of it.\n`,
119
+ )
120
+ }
121
+ return out
122
+ }
123
+
124
+ /**
125
+ * Merge the orchestrator-provided baseline (declared inputs / outputs /
126
+ * workspace-root anchor) with the user's resolved sandbox block to
127
+ * produce the SRT customConfig. Path arrays are unioned and deduped; every
128
+ * read grant is punched around the write grants (`punchWritePaths`).
129
+ *
130
+ * Network: `allow.network` missing → block all (allowedDomains: []);
131
+ * `true` → allow all (['*']); a domain list → exactly that list.
132
+ * `deny.network` is always passed as deniedDomains.
133
+ */
134
+ export function buildCustomConfig(
135
+ args: Pick<SandboxedRunArgs, 'config'>,
136
+ baselines: {
137
+ allowRead: readonly string[]
138
+ allowWrite: readonly string[]
139
+ denyRead: readonly string[]
140
+ },
141
+ ): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
142
+ const c = args.config
143
+ const denyRead = unique([...baselines.denyRead])
144
+ const allowWrite = bindableWrites(unique([...baselines.allowWrite, ...c.allowWrite]))
145
+ const allowRead = unique(
146
+ [...baselines.allowRead, ...c.allowRead].flatMap((r) => punchWritePaths(r, allowWrite)),
147
+ )
148
+
149
+ const custom: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] = {
150
+ filesystem: {
151
+ denyRead,
152
+ allowRead,
153
+ allowWrite,
154
+ denyWrite: [],
155
+ ...(c.gitConfig !== undefined ? { allowGitConfig: c.gitConfig } : {}),
156
+ },
157
+ }
158
+
159
+ // `allow.network` / `deny.network` become SRT's domain lists. SRT requires
160
+ // both to be present on any network config, so we always supply both;
161
+ // omitted means no network at all.
162
+ custom.network = {
163
+ allowedDomains: c.network === true ? ['*'] : [...(c.network ?? [])],
164
+ deniedDomains: [...(c.denyNetwork ?? [])],
165
+ ...(c.unixSockets === true
166
+ ? { allowAllUnixSockets: true }
167
+ : c.unixSockets !== undefined
168
+ ? { allowUnixSockets: [...c.unixSockets] }
169
+ : {}),
170
+ ...(c.localBinding !== undefined ? { allowLocalBinding: localBindingOn(c) } : {}),
171
+ ...(c.machLookup !== undefined ? { allowMachLookup: [...c.machLookup] } : {}),
172
+ }
173
+
174
+ if (c.pty !== undefined) custom.allowPty = c.pty
175
+ if (c.weakerWhenNested !== undefined) {
176
+ custom.enableWeakerNestedSandbox = c.weakerWhenNested
177
+ }
178
+ if (c.weakerNetworkIsolation !== undefined) {
179
+ custom.enableWeakerNetworkIsolation = c.weakerNetworkIsolation
180
+ }
181
+ return custom
182
+ }
@@ -0,0 +1,50 @@
1
+ // Path helpers the sandbox modules share: canonical forms (the policy
2
+ // matches real paths), the task-relative absolutizer the strace pass uses,
3
+ // and the prefix test.
4
+
5
+ import { realpathSync } from 'node:fs'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+
9
+ /**
10
+ * Canonicalize a path with realpath, tolerating paths that don't exist
11
+ * yet: resolve the longest existing ancestor and re-append the rest.
12
+ *
13
+ * Why: the sandbox policy matches on canonical paths (macOS seatbelt
14
+ * evaluates real vnode paths), and SRT's own normalization refuses to
15
+ * canonicalize bare symlinked roots like `/tmp` → `/private/tmp` (its
16
+ * boundary check only whitelists `/tmp/<child>` forms). Without this,
17
+ * `allowWrite: ['/tmp']` silently never matches on macOS.
18
+ */
19
+ export function toRealPath(p: string): string {
20
+ try {
21
+ return realpathSync(p)
22
+ } catch {
23
+ const parent = path.dirname(p)
24
+ if (parent === p) return p
25
+ return path.join(toRealPath(parent), path.basename(p))
26
+ }
27
+ }
28
+
29
+ export function absolutize(p: string, cwd?: string): string {
30
+ if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1))
31
+ if (path.isAbsolute(p)) return p
32
+ return path.resolve(cwd ?? process.cwd(), p)
33
+ }
34
+
35
+ export function isUnderAny(abs: string, allow: Set<string>): boolean {
36
+ if (allow.has(abs)) return true
37
+ for (const a of allow) {
38
+ if (abs === a || abs.startsWith(a + path.sep)) return true
39
+ }
40
+ return false
41
+ }
42
+
43
+ export function unique(arr: readonly string[]): string[] {
44
+ return [...new Set(arr)]
45
+ }
46
+
47
+ /** `allow.localBinding` grants loopback: `true`, or a non-empty port list. */
48
+ export function localBindingOn(c: { localBinding?: boolean | readonly number[] }): boolean {
49
+ return c.localBinding === true || (Array.isArray(c.localBinding) && c.localBinding.length > 0)
50
+ }