@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -2,12 +2,12 @@
2
2
  // per-task filesystem + network isolation.
3
3
  //
4
4
  // Design contract:
5
- // The caller (executeCachedTask) computes the exact allowRead /
6
- // allowWrite paths from the task's declared inputs + outputs + sandbox
7
- // block. This module adds nothing implicit — no /tmp, no node_modules,
8
- // no project dir. If a task needs them, the user declares them in
9
- // their sandbox config. That gives users a complete view of what each
10
- // task can touch from a single vx.config.ts file.
5
+ // The caller (sandbox-request.ts) hands over the task's own
6
+ // `sandbox.allow` block plus two baselines: the `node_modules` reads a
7
+ // task's dependencies need, and the workspace-root deny anchor. Writes
8
+ // are the task's `allow.write` alone — nothing is derived from `cache`.
9
+ // This module adds nothing implicit on top — no /tmp, no project dir —
10
+ // so what a task may touch is its vx.config.ts plus those baselines.
11
11
  //
12
12
  // Network is opt-in per task. By default the sandbox blocks all outbound
13
13
  // traffic; tasks that need it set `sandbox.network: true`.
@@ -25,22 +25,45 @@
25
25
 
26
26
  import path from 'node:path'
27
27
  import os from 'node:os'
28
+ import { mkdirSync, readlinkSync, realpathSync, rmSync, unlinkSync } from 'node:fs'
28
29
  import { mkdir, unlink } from 'node:fs/promises'
29
30
  import type { SandboxConfig } from '../config.js'
30
31
  import {
31
32
  armTimeout,
32
33
  drainOrAbort,
34
+ POST_EXIT_CUT_LINE,
33
35
  shellQuote,
34
36
  signalExitCode,
37
+ spawnFailureText,
35
38
  streamToString,
36
39
  resourceUsageToCpuRss,
37
40
  type CaptureConfig,
38
41
  type RunResult,
39
42
  } from './runner.js'
40
- import { UserError, xxh3hex } from '../util/index.js'
43
+ import {
44
+ executablePath,
45
+ grantPrefix,
46
+ isTmpdirRefusal,
47
+ TMPDIR_HINT,
48
+ UserError,
49
+ xxh3hex,
50
+ } from '../util/index.js'
41
51
  import { buildCustomConfig } from './sandbox-binds.js'
42
- import { localBindingOn, toRealPath, unique } from './sandbox-paths.js'
52
+ import {
53
+ isMountableLiteral,
54
+ localBindingOn,
55
+ MOUNT_WILDCARDS,
56
+ toRealPath,
57
+ unique,
58
+ } from './sandbox-paths.js'
43
59
  import { parseStraceViolations, reportableViolations } from './sandbox-violations.js'
60
+ import {
61
+ closeSignalChannel,
62
+ killTree,
63
+ releaseGroup,
64
+ signalThrough,
65
+ spawnGuarded,
66
+ } from './kill-tree.js'
44
67
 
45
68
  type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
46
69
  let srtPromise: Promise<SrtModule> | undefined
@@ -101,7 +124,9 @@ async function probeUncached(weakerNested: boolean): Promise<SandboxAvailability
101
124
  return { available: false, reason: `platform ${process.platform} not supported` }
102
125
  }
103
126
  const deps = SandboxManager.checkDependencies()
104
- if (deps.errors.length > 0) return { available: false, reason: deps.errors.join('; ') }
127
+ if (deps.errors.length > 0) return { available: false, reason: dependencyReason(deps.errors) }
128
+ const long = socketPathRefusal()
129
+ if (long !== undefined) return { available: false, reason: long }
105
130
  if (process.platform === 'linux') {
106
131
  await initSandbox()
107
132
  return trySandboxedTrue(SandboxManager, weakerNested)
@@ -166,7 +191,8 @@ async function trySandboxedTrue(
166
191
  undefined,
167
192
  weakerNested ? { enableWeakerNestedSandbox: true } : undefined,
168
193
  )
169
- const proc = Bun.spawn(['sh', '-c', wrapped], {
194
+ const proc = Bun.spawn([executablePath('sh'), '-c', wrapped], {
195
+ argv0: 'sh',
170
196
  stdout: 'ignore',
171
197
  stderr: 'pipe',
172
198
  stdin: 'ignore',
@@ -176,8 +202,55 @@ async function trySandboxedTrue(
176
202
  if (proc.exitCode === 0) return { available: true, reason: '' }
177
203
  return { available: false, reason: unavailableReason(proc.exitCode, stderr) }
178
204
  } catch (err) {
179
- return { available: false, reason: `sandbox probe threw: ${(err as Error).message}` }
205
+ return { available: false, reason: thrownReason(err, 'sandbox probe threw') }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * The runtime's own dependency check names what is missing ("ripgrep (rg)
211
+ * not found") and nothing else. On Linux it needs three binaries — bwrap
212
+ * for the namespaces, socat for the network bridge, ripgrep to expand its
213
+ * mandatory deny globs — and the docs named two of them until a minimal
214
+ * image with the two failed on the third (2026-09-16). Name the set and
215
+ * the install.
216
+ */
217
+ export function dependencyReason(errors: readonly string[]): string {
218
+ const need =
219
+ process.platform === 'linux'
220
+ ? 'the sandbox runtime needs bubblewrap (bwrap), socat and ripgrep (rg) on PATH'
221
+ : 'the sandbox runtime is missing a dependency'
222
+ return `${need}: ${errors.join('; ')} — install it (Linux: apt install bubblewrap socat ripgrep, or the same names in your package manager) and re-run`
223
+ }
224
+
225
+ /**
226
+ * The runtime listens on `<tmpdir>/srt-mux-<pid>-<seq>.sock`, and a unix
227
+ * socket path has a hard length (`sun_path`: 108 bytes on Linux, 104 on
228
+ * macOS, one of them the NUL). Past it the runtime says "ENAMETOOLONG …
229
+ * listen" on macOS and "Failed to create bridge sockets after 5 attempts"
230
+ * on Linux (its retry loop swallows the code), neither naming the
231
+ * directory (2026-09-16). Checked up front, with room for the sequence.
232
+ */
233
+ export function socketPathRefusal(tmpdir = os.tmpdir()): string | undefined {
234
+ const sample = path.join(tmpdir, `srt-mux-${process.pid}-zzz.sock`)
235
+ const limit = process.platform === 'darwin' ? 103 : 107
236
+ const length = Buffer.byteLength(sample)
237
+ if (length <= limit) return undefined
238
+ return `the sandbox runtime listens on a unix socket under the temp directory, and ${sample} is ${length} bytes where the OS allows ${limit} — point TMPDIR at a shorter path`
239
+ }
240
+
241
+ /**
242
+ * A throw from the runtime itself, in the user's terms. Its own temp files
243
+ * (the observer directory, the bridge sockets, the strace log) live under
244
+ * `os.tmpdir()`, so a temp directory that is missing or not writable
245
+ * failed a sandboxed task with a path and no knob ("EACCES … mkdtemp
246
+ * '/tmp/probe-ro/srt-obs-…'", a minimal image, 2026-09-16).
247
+ */
248
+ export function thrownReason(err: unknown, what = ''): string {
249
+ const message = err instanceof Error ? err.message : String(err)
250
+ if (isTmpdirRefusal(err)) {
251
+ return `the sandbox runtime needs a writable temp directory and ${os.tmpdir()} is not one (${message}) — ${TMPDIR_HINT}`
180
252
  }
253
+ return what === '' ? message : `${what}: ${message}`
181
254
  }
182
255
 
183
256
  /**
@@ -200,15 +273,6 @@ const DEFAULT_IGNORE_VIOLATIONS: Record<string, string[]> = {
200
273
  ],
201
274
  }
202
275
 
203
- /**
204
- * One-time SRT initialization per orchestrator run. Starts the proxy
205
- * servers + (on macOS) the violation log monitor. Safe to call repeatedly
206
- * — SRT itself returns early on the second call.
207
- *
208
- * The base config sets network to "block everything" (empty allowedDomains).
209
- * Per-task wrapping passes a customConfig that re-enables network for
210
- * tasks with `sandbox.network: true`.
211
- */
212
276
  /**
213
277
  * The temp directory SRT hands every sandboxed task. It overrides `TMPDIR`
214
278
  * so temp-file writers land somewhere its filesystem policy already allows,
@@ -228,6 +292,90 @@ function sandboxTmpdir(): string {
228
292
  }
229
293
 
230
294
  /**
295
+ * The temp files of tasks still running: strace logs and port-bridge
296
+ * sockets. A signal exit is `process.exit` (orchestrator/signals.ts),
297
+ * which never reaches a task's own unlink, so a Ctrl-C left one log per
298
+ * sandboxed task in the temp dir (item 848); the process's `exit` event
299
+ * removes what is still listed.
300
+ */
301
+ const liveTempFiles = new Set<string>()
302
+ let tempExitHooked = false
303
+ function unlinkOnExit(file: string): void {
304
+ liveTempFiles.add(file)
305
+ if (tempExitHooked) return
306
+ tempExitHooked = true
307
+ process.on('exit', () => {
308
+ for (const f of liveTempFiles) {
309
+ try {
310
+ unlinkSync(f)
311
+ } catch {
312
+ // never written, or gone
313
+ }
314
+ }
315
+ })
316
+ }
317
+
318
+ /**
319
+ * A task's own temp directory, `TMPDIR` inside its sandbox. SRT points
320
+ * every sandboxed task at ONE host directory (`sandboxTmpdir`), bound
321
+ * read-write and kept across runs, so a file one task wrote there was
322
+ * another's undeclared input: a cached reader replayed the first value it
323
+ * saw after the writer changed it (item 965). Created for the task, removed
324
+ * with its bridges at its end (and at exit, as the logs are); a `kill -9`
325
+ * leaves it. No sweep by the owner's pid: a nested vx sees another pid
326
+ * namespace, where the outer vx's pid reads as dead, and a sweep there
327
+ * removed the outer task's own TMPDIR mid-run (the gate, item 965). The shared
328
+ * directory itself stays writable, since SRT's policy grants it: a command
329
+ * that names it outright still reaches it.
330
+ */
331
+ function taskTmpdir(tag: string): string {
332
+ return path.join(sandboxTmpdir(), `vx-task-${process.pid}-${tag}`)
333
+ }
334
+
335
+ const liveTaskTmpdirs = new Set<string>()
336
+ let tmpdirExitHooked = false
337
+ function trackTaskTmpdir(dir: string): void {
338
+ liveTaskTmpdirs.add(dir)
339
+ if (tmpdirExitHooked) return
340
+ tmpdirExitHooked = true
341
+ process.on('exit', () => {
342
+ for (const d of liveTaskTmpdirs) rmSync(d, { recursive: true, force: true })
343
+ })
344
+ }
345
+
346
+ /** Whether SRT is up in this process — set by `initSandbox`, cleared by `resetSandbox`. */
347
+ let srtUp = false
348
+
349
+ /**
350
+ * SRT listens on `<tmpdir>/srt-mux-<pid>-<seq>.sock`, seq from 0, and a
351
+ * killed run leaves those files behind; when the kernel hands the pid to a
352
+ * later vx, its first listen meets EADDRINUSE and every sandboxed task
353
+ * fails (255 of them after a stopped gate, 2026-09-16). A file carrying
354
+ * OUR pid before SRT is up can only be a dead process's, so unlink the
355
+ * contiguous run from seq 0 — a stat per file, no directory scan (a 7,000
356
+ * entry /tmp reads in 9 ms; this is microseconds).
357
+ */
358
+ async function unlinkStaleMuxSockets(): Promise<void> {
359
+ for (let seq = 0; seq < 64; seq++) {
360
+ const stale = path.join(os.tmpdir(), `srt-mux-${process.pid}-${seq.toString(36)}.sock`)
361
+ try {
362
+ await unlink(stale)
363
+ } catch (err) {
364
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return
365
+ throw err
366
+ }
367
+ }
368
+ }
369
+
370
+ /**
371
+ * One-time SRT initialization per orchestrator run. Starts the proxy
372
+ * servers + (on macOS) the violation log monitor. Safe to call repeatedly
373
+ * — SRT itself returns early on the second call.
374
+ *
375
+ * The base config sets network to "block everything" (empty allowedDomains).
376
+ * Per-task wrapping passes a customConfig that re-enables network for
377
+ * tasks with `sandbox.network: true`.
378
+ *
231
379
  * @param opts.allowedDomains every domain any sandboxed task in this run
232
380
  * declared. SRT's filtering proxy is per-RUN and reads its allowlist from
233
381
  * this call, never from the per-task config, so the union is the only
@@ -245,6 +393,10 @@ export async function initSandbox(opts?: {
245
393
  */
246
394
  allowAllUnixSockets?: boolean
247
395
  }): Promise<void> {
396
+ // A reset a server's exit started unawaited: a watch cycle stops its
397
+ // server and starts its run at once, and an init under that reset
398
+ // found SRT up, hot-reloaded it, and had it torn down after (item 884).
399
+ await resetting
248
400
  // Before SRT starts, so the very first task already has one.
249
401
  await mkdir(sandboxTmpdir(), { recursive: true })
250
402
  const { SandboxManager } = await loadSrt()
@@ -257,12 +409,14 @@ export async function initSandbox(opts?: {
257
409
  filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
258
410
  ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
259
411
  }
412
+ if (!srtUp) await unlinkStaleMuxSockets()
260
413
  await SandboxManager.initialize(
261
414
  config,
262
415
  undefined,
263
416
  // enableLogMonitor — macOS-only; populates the SandboxViolationStore.
264
417
  true,
265
418
  )
419
+ srtUp = true
266
420
  // `initialize()` returns early once SRT is up, and on Linux the
267
421
  // availability probe brought it up with an EMPTY config before the run's
268
422
  // own call — so the run's allowlist and unix-socket allowance never
@@ -273,12 +427,43 @@ export async function initSandbox(opts?: {
273
427
  SandboxManager.updateConfig(config)
274
428
  }
275
429
 
430
+ /**
431
+ * The tags of sandboxed servers still running (`wrapSandboxedCommand`'s
432
+ * `server`). A run resets the sandbox at its end, and a foreground
433
+ * `vx run dev` or a `vx watch` holds its servers past that: the reset
434
+ * released their host bridges and SRT's proxies, and a server's port went
435
+ * dark ~40 ms after the summary (item 882). While one runs, the reset
436
+ * releases only the bridges no server owns and waits for the last
437
+ * server's `releaseBridges`.
438
+ */
439
+ const liveServers = new Set<string>()
440
+ let resetDeferred = false
441
+ /** The reset in flight, settled either way; `initSandbox` waits for it. */
442
+ let resetting: Promise<void> | undefined
443
+
276
444
  export async function resetSandbox(): Promise<void> {
277
- for (const tag of [...hostBridges.keys()]) releaseBridges(tag)
278
- const { SandboxManager } = await loadSrt()
279
- await SandboxManager.reset()
280
- availabilityCache.clear()
281
- straceAvailableCache = undefined
445
+ for (const tag of [...hostBridges.keys()]) if (!liveServers.has(tag)) releaseBridges(tag)
446
+ if (liveServers.size > 0) {
447
+ resetDeferred = true
448
+ return
449
+ }
450
+ resetDeferred = false
451
+ const reset = (async () => {
452
+ const { SandboxManager } = await loadSrt()
453
+ await SandboxManager.reset()
454
+ srtUp = false
455
+ availabilityCache.clear()
456
+ straceAvailableCache = undefined
457
+ })()
458
+ const settled = reset.then(
459
+ () => {},
460
+ () => {},
461
+ )
462
+ resetting = settled
463
+ void settled.then(() => {
464
+ if (resetting === settled) resetting = undefined
465
+ })
466
+ await reset
282
467
  }
283
468
 
284
469
  export interface SandboxedRunArgs {
@@ -295,15 +480,12 @@ export interface SandboxedRunArgs {
295
480
  /** See `CaptureConfig` — which streams are retained on the result. */
296
481
  capture?: CaptureConfig
297
482
  /**
298
- * Baseline reads — paths the sandbox unconditionally allows. The
299
- * caller builds this from resolved `cache.inputs.files`.
483
+ * Baseline reads — paths the sandbox allows beside the task's own
484
+ * `allow.read`. Core passes the project's and the root's
485
+ * `node_modules` and the workspace dependencies linked from them
486
+ * (sandbox-request.ts); nothing is derived from `cache`.
300
487
  */
301
488
  baseAllowRead: readonly string[]
302
- /**
303
- * Baseline writes — paths the sandbox unconditionally allows for
304
- * writes. Built from the static prefix of `cache.outputs.files`.
305
- */
306
- baseAllowWrite: readonly string[]
307
489
  /**
308
490
  * Read-deny anchor. Combined with allowRead it produces the effective
309
491
  * deny set: anything under one of these paths that isn't in allowRead
@@ -316,6 +498,8 @@ export interface SandboxedRunArgs {
316
498
  * that is the wall doing its job, not a finding to fail a run over.
317
499
  */
318
500
  readonly reportWithin: string
501
+ /** Canonical directories whose denials are reported too (`ExecuteSandbox.reportLinked`). */
502
+ readonly reportLinked: readonly string[]
319
503
  /**
320
504
  * User-declared sandbox block (after path-resolution). Path lists are
321
505
  * unioned with the baselines; bool/object fields fall through to SRT.
@@ -355,7 +539,7 @@ export interface ResolvedSandboxConfig {
355
539
  * Canonical form of every path the sandbox policy is expressed in.
356
540
  *
357
541
  * `resolveSandboxConfig` already canonicalizes the USER's paths; the
358
- * orchestrator-supplied baselines (resolved inputs, output prefixes, the
542
+ * orchestrator-supplied baselines (the `node_modules` reads and the
359
543
  * workspace-root deny anchor) arrived raw, so a workspace reached through a
360
544
  * symlink expressed HALF its policy in real paths and half in link paths.
361
545
  * bwrap then died mounting the link path inside its new root
@@ -365,22 +549,61 @@ export interface ResolvedSandboxConfig {
365
549
  */
366
550
  interface CanonicalBaselines {
367
551
  allowRead: string[]
368
- allowWrite: string[]
369
552
  denyRead: string[]
370
553
  cwd: string
371
554
  }
372
555
 
373
556
  function canonicalBaselines(
374
- args: Pick<SandboxedRunArgs, 'baseAllowRead' | 'baseAllowWrite' | 'baseDenyRead' | 'cwd'>,
557
+ args: Pick<SandboxedRunArgs, 'baseAllowRead' | 'baseDenyRead' | 'cwd'>,
375
558
  ): CanonicalBaselines {
376
559
  return {
377
560
  allowRead: args.baseAllowRead.map(toRealPath),
378
- allowWrite: args.baseAllowWrite.map(toRealPath),
379
561
  denyRead: args.baseDenyRead.map(toRealPath),
380
562
  cwd: toRealPath(args.cwd),
381
563
  }
382
564
  }
383
565
 
566
+ /**
567
+ * A write grant that names a path in the project must BIND one there. The
568
+ * grant was realpath'd, so `out.txt -> ../b/src/x` (committed, or planted
569
+ * by the task's own previous run) bound project b's directory writable,
570
+ * and the task wrote into a sibling with exit 0 (item 1003). A read grant
571
+ * may resolve out through a link (`node_modules` into the store) and is
572
+ * not judged here; a grant spelled outside the project is the user's own.
573
+ */
574
+ function assertWriteStaysHome(grant: string, real: string, projectDir: string): void {
575
+ if (grant.startsWith('~') || path.isAbsolute(grant)) return
576
+ const lexical = path.resolve(projectDir, grant)
577
+ const inside = (p: string, dir: string): boolean =>
578
+ p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
579
+ if (!inside(lexical, projectDir)) return
580
+ const home = toRealPath(projectDir)
581
+ // `toRealPath` stops at a DANGLING link and keeps its own path, which is
582
+ // inside; the link's target is where a write through it would land.
583
+ const target = throughLinks(real)
584
+ if (inside(real, home) && inside(target, home)) return
585
+ throw new UserError(
586
+ `exec.sandbox.allow.write: "${grant}" resolves through a symlink to ${inside(real, home) ? target : real}, outside the ` +
587
+ `project — a write grant binds the path it names in the project, and vx does not follow a ` +
588
+ `link out of it. Remove the link, or grant the target by its own path.`,
589
+ )
590
+ }
591
+
592
+ /** Where `p` lands through every link on it, a dangling last one included. */
593
+ function throughLinks(p: string, hops = 0): string {
594
+ if (hops > 40) return p
595
+ try {
596
+ return realpathSync(p)
597
+ } catch {
598
+ try {
599
+ return throughLinks(path.resolve(path.dirname(p), readlinkSync(p)), hops + 1)
600
+ } catch {
601
+ const parent = path.dirname(p)
602
+ return parent === p ? p : path.join(throughLinks(parent, hops + 1), path.basename(p))
603
+ }
604
+ }
605
+ }
606
+
384
607
  /**
385
608
  * Convert a user-facing `SandboxConfig` (paths may be relative / tilde)
386
609
  * into a `ResolvedSandboxConfig` (all paths absolute + canonical) for a
@@ -398,8 +621,15 @@ export function resolveSandboxConfig(
398
621
  }
399
622
  const a = cfg.allow ?? {}
400
623
  const r: ResolvedSandboxConfig = {
401
- allowRead: expandGrants((a.read ?? []).map(resolve)),
402
- allowWrite: expandGrants((a.write ?? []).map(resolve)),
624
+ allowRead: expandGrants((a.read ?? []).map(resolve), 'read'),
625
+ allowWrite: expandGrants(
626
+ (a.write ?? []).map((p) => {
627
+ const real = resolve(p)
628
+ assertWriteStaysHome(p, real, projectDir)
629
+ return real
630
+ }),
631
+ 'write',
632
+ ),
403
633
  }
404
634
  if (a.network !== undefined) r.network = a.network
405
635
  if (cfg.deny?.network !== undefined) r.denyNetwork = cfg.deny.network
@@ -451,15 +681,33 @@ export interface SandboxedRunResult extends RunResult {
451
681
  }
452
682
 
453
683
  /**
454
- * Run a single task wrapped in the sandbox. Caller must have called
455
- * `initSandbox()` first.
456
- *
457
- * Violations are matched by a unique per-task command prefix — SRT's
458
- * `getViolationsForCommand` keys by base64 of the first 100 chars, so
459
- * two tasks running the same underlying command (e.g. parallel `tsc`
460
- * across packages) would otherwise collide. We prepend `: '<tag>';`
461
- * (shell no-op) to make every command's first 100 chars unique.
684
+ * The shell that gives a sandboxed task its OWN `JAVA_TOOL_OPTIONS`. When
685
+ * SRT restricts the network it sets the variable to its proxy agent's flag
686
+ * composed with VX'S value (`process.env`, read inside the wrap), over the
687
+ * one vx gave the task: a host value no layer passes reached the task, out
688
+ * of its key, and a changed host value replayed the old output; a task's
689
+ * own `define` never arrived (item 995). Where SRT left the variable alone
690
+ * it already holds the task's value and this is a no-op; elsewhere the
691
+ * host's value is cut out and the task's appended to what SRT added.
462
692
  */
693
+ function javaToolOptionsFix(host: string | undefined, task: string | undefined): string {
694
+ // The same value both sides (a task that passes the host's through, or
695
+ // neither has one): SRT's composition is already the task's own, and a
696
+ // prefix quoting the value twice would only lengthen every command
697
+ // (item 1007).
698
+ if ((host ?? '') === (task ?? '')) return ''
699
+ const cut = host
700
+ ? `__vx_h=${shellQuote(host)}; case "$__vx_j" in *"$__vx_h"*) __vx_j="\${__vx_j%%"$__vx_h"*}\${__vx_j#*"$__vx_h"}";; esac; `
701
+ : ''
702
+ const add = task ? `__vx_j="$__vx_j $__vx_t"; ` : ''
703
+ return (
704
+ `__vx_t=${shellQuote(task ?? '')}; __vx_j=\${JAVA_TOOL_OPTIONS-}; ` +
705
+ `if [ "$__vx_j" != "$__vx_t" ]; then ${cut}${add}` +
706
+ `case "$__vx_j" in *[![:space:]]*) export JAVA_TOOL_OPTIONS="$__vx_j";; *) unset JAVA_TOOL_OPTIONS;; esac; fi; ` +
707
+ `unset __vx_j __vx_h __vx_t; `
708
+ )
709
+ }
710
+
463
711
  /**
464
712
  * The sandboxed form of a command: SRT's wrapper over the tagged command,
465
713
  * with vx's own seatbelt rules appended on macOS. This is the ENFORCEMENT
@@ -469,13 +717,18 @@ export interface SandboxedRunResult extends RunResult {
469
717
  * exit). Returns the wrapped command and the tag the store keys by.
470
718
  */
471
719
  export async function wrapSandboxedCommand(
472
- args: Pick<SandboxedRunArgs, 'command' | 'cwd' | 'forwardArgs' | 'config'> &
473
- Pick<SandboxedRunArgs, 'baseAllowRead' | 'baseAllowWrite' | 'baseDenyRead'>,
720
+ args: Pick<SandboxedRunArgs, 'command' | 'cwd' | 'forwardArgs' | 'config' | 'env'> &
721
+ Pick<SandboxedRunArgs, 'baseAllowRead' | 'baseDenyRead'> & {
722
+ /** A persistent server: the sandbox outlives a run's reset until `releaseBridges(tag)`. */
723
+ server?: boolean
724
+ },
474
725
  ): Promise<{
475
726
  wrapped: string
476
727
  tag: string
477
728
  taggedCommand: string
478
729
  baselines: CanonicalBaselines
730
+ /** The command reads its polite signals off fd 3: spawn it with one and `signalThrough` it. */
731
+ forwardsSignals: boolean
479
732
  }> {
480
733
  const { SandboxManager } = await loadSrt()
481
734
  const userCommand =
@@ -484,7 +737,15 @@ export async function wrapSandboxedCommand(
484
737
  : args.command
485
738
 
486
739
  const tag = xxh3hex(`${args.cwd}|${userCommand}|${process.hrtime.bigint()}`).slice(0, 16)
487
- const taggedCommand = `: 'vx-${tag}'; ${userCommand}`
740
+ const tmp = taskTmpdir(tag)
741
+ mkdirSync(tmp, { recursive: true })
742
+ trackTaskTmpdir(tmp)
743
+ // After the tag: SRT keys violations by the command's first 100 chars.
744
+ const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${javaToolOptionsFix(
745
+ process.env['JAVA_TOOL_OPTIONS'],
746
+ args.env['JAVA_TOOL_OPTIONS'],
747
+ )}${userCommand}`
748
+ const taggedCommand = `: 'vx-${tag}'; ${inTmp}`
488
749
 
489
750
  const baselines = canonicalBaselines(args)
490
751
  const customConfig = buildCustomConfig(args, baselines)
@@ -493,14 +754,77 @@ export async function wrapSandboxedCommand(
493
754
  // user command, so it goes INTO the sandboxed command; the host side is
494
755
  // spawned here and released when the task's process ends.
495
756
  const ports = process.platform === 'linux' ? bridgedPorts(args.config) : []
496
- const inner = ports.length > 0 ? `${portBridgeInner(ports, tag)} ${taggedCommand}` : taggedCommand
757
+ const grouped =
758
+ process.platform === 'linux'
759
+ ? ownGroupCommand(tag, inTmp)
760
+ : { command: taggedCommand, forwards: false }
761
+ const inner =
762
+ ports.length > 0 ? `${portBridgeInner(ports, tag)} ${grouped.command}` : grouped.command
497
763
  let wrapped = await SandboxManager.wrapWithSandbox(inner, undefined, customConfig)
498
764
  if (process.platform === 'darwin') {
499
765
  const rules = macProfileRules(args.config)
500
766
  if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
501
767
  }
768
+ // Linux: the shell execs bwrap, so bwrap is the spawn itself and its
769
+ // `--die-with-parent` is keyed to vx. Behind a shell that waited on it,
770
+ // a `kill -9` of vx left the shell alive, bwrap never heard, and a
771
+ // sandboxed server and all it forked outlived vx (turborepo#9666). Now
772
+ // the namespace goes with vx, a `setsid` daemon inside included. A
773
+ // strace-traced spawn keeps strace as bwrap's parent: that residual is
774
+ // a one-shot task's, and a persistent one is never traced.
775
+ if (process.platform === 'linux' && wrapped.startsWith('bwrap ')) wrapped = `exec ${wrapped}`
776
+ if (args.server === true) liveServers.add(tag)
502
777
  if (ports.length > 0) spawnHostBridges(ports, tag)
503
- return { wrapped, tag, taggedCommand, baselines }
778
+ return { wrapped, tag, taggedCommand, baselines, forwardsSignals: grouped.forwards }
779
+ }
780
+
781
+ /**
782
+ * Linux: the user command in a session, and so a process group, of its
783
+ * own. bwrap's `--new-session` puts the runtime's shells (the proxy
784
+ * bridges' script, the seccomp step's) in ONE group with the command, and
785
+ * `kill 0` reaches a group's members across the nested pid namespace: a
786
+ * command that signalled its own group ended the runtime's shell, bwrap
787
+ * exited 143 and the namespace's teardown SIGKILLed the rest mid-trap
788
+ * (item 751). The shell `exec`s `setsid`, which is no group leader here,
789
+ * so it calls setsid() and execs without a fork: the command keeps the
790
+ * shell's pid, and its status, a signal death included, is the one the
791
+ * runtime waits on.
792
+ *
793
+ * The same group is where a cancellation lands. vx's group signal reaches
794
+ * bwrap's monitor, which dies of it, and the namespace goes with SIGKILL,
795
+ * so vx sends a polite signal's name down fd 3 instead (`signalThrough`)
796
+ * and a watcher forked before the `exec` signals the group, `$$`, with it
797
+ * (item 752). The command runs in the foreground because an `&` command
798
+ * starts with SIGINT ignored, and a shell cannot trap what it inherited
799
+ * ignored. The command does not get fd 3; a caller that passed none
800
+ * forwards nothing, silently.
801
+ *
802
+ * Tools resolve on vx's own PATH; without `setsid` the command keeps the
803
+ * shared group, as before, and `forwards` says the channel is not there.
804
+ */
805
+ function ownGroupCommand(tag: string, userCommand: string): { command: string; forwards: boolean } {
806
+ // `sh`, as an unsandboxed task runs (`runner.ts`): the command ran under
807
+ // bash here, so `[[ … ]]`, brace expansion and `echo 'a\tb'` read one
808
+ // way sandboxed and another unsandboxed or on a remote executor, where
809
+ // `/bin/sh` is dash (item 964).
810
+ let sh: string
811
+ try {
812
+ sh = executablePath('sh')
813
+ } catch {
814
+ return { command: `: 'vx-${tag}'; ${userCommand}`, forwards: false }
815
+ }
816
+ let setsid: string
817
+ try {
818
+ setsid = executablePath('setsid')
819
+ } catch {
820
+ return {
821
+ command: `: 'vx-${tag}'; exec ${shellQuote(sh)} -c ${shellQuote(userCommand)}`,
822
+ forwards: false,
823
+ }
824
+ }
825
+ const watch = `{ IFS= read -r s && kill -s "$s" -- "-$$"; } 2>/dev/null <&3 3<&- &`
826
+ const run = `exec ${shellQuote(setsid)} ${shellQuote(sh)} -c ${shellQuote(userCommand)} 3<&-`
827
+ return { command: `: 'vx-${tag}'; ${watch} ${run}`, forwards: true }
504
828
  }
505
829
 
506
830
  /** The ports a list grants, deduped; `true` bridges nothing (the host sees no port on Linux). */
@@ -542,7 +866,10 @@ export function portBridgeHostArgv(tag: string, port: number): string[] {
542
866
  ]
543
867
  }
544
868
 
545
- const hostBridges = new Map<string, Array<ReturnType<typeof Bun.spawn>>>()
869
+ const hostBridges = new Map<
870
+ string,
871
+ { ports: readonly number[]; procs: Array<ReturnType<typeof Bun.spawn>> }
872
+ >()
546
873
 
547
874
  function spawnHostBridges(ports: readonly number[], tag: string): void {
548
875
  const procs: Array<ReturnType<typeof Bun.spawn>> = []
@@ -550,38 +877,110 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
550
877
  // A spawn failure (no socat on the host) is the task's to report:
551
878
  // its own side dies the same way, in its frame.
552
879
  try {
880
+ // Guarded, in a group of its own (kill-tree.ts): a plain child of vx
881
+ // was in no group the guard lists, and a `kill -9` of vx left it
882
+ // listening on the port under init, where the next run's bridge
883
+ // could not bind it (item 873).
553
884
  procs.push(
554
- Bun.spawn(portBridgeHostArgv(tag, p), {
555
- stdin: 'ignore',
556
- stdout: 'ignore',
557
- stderr: 'ignore',
558
- }),
885
+ spawnGuarded(() =>
886
+ Bun.spawn(portBridgeHostArgv(tag, p), {
887
+ stdin: 'ignore',
888
+ stdout: 'ignore',
889
+ stderr: 'ignore',
890
+ detached: true,
891
+ }),
892
+ ),
559
893
  )
560
894
  } catch {
561
895
  // see above
562
896
  }
563
897
  }
564
- if (procs.length > 0) hostBridges.set(tag, procs)
898
+ if (procs.length > 0) {
899
+ hostBridges.set(tag, { ports, procs })
900
+ for (const p of ports) unlinkOnExit(portBridgeSocket(tag, p))
901
+ }
565
902
  }
566
903
 
567
- /** Stop the host side of a task's port bridges; idempotent. */
904
+ /**
905
+ * Stop the host side of a task's port bridges and remove their sockets;
906
+ * idempotent. Called once the task's process has exited, so the socat
907
+ * that listened on each socket is gone with the namespace, and never
908
+ * unlinked it: one socket per bridged run stayed in the temp dir (item
909
+ * 877).
910
+ */
568
911
  export function releaseBridges(tag: string): void {
569
- const procs = hostBridges.get(tag)
570
- if (procs === undefined) return
912
+ const tmp = taskTmpdir(tag)
913
+ if (liveTaskTmpdirs.delete(tmp)) rmSync(tmp, { recursive: true, force: true })
914
+ if (liveServers.delete(tag) && liveServers.size === 0 && resetDeferred) {
915
+ void resetSandbox().catch(() => {})
916
+ }
917
+ const bridges = hostBridges.get(tag)
918
+ if (bridges === undefined) return
571
919
  hostBridges.delete(tag)
572
- for (const p of procs) {
920
+ for (const port of bridges.ports) {
921
+ const sock = portBridgeSocket(tag, port)
573
922
  try {
574
- p.kill('SIGTERM')
923
+ unlinkSync(sock)
575
924
  } catch {
576
- // already gone
925
+ // never bound, or gone
577
926
  }
927
+ liveTempFiles.delete(sock)
928
+ }
929
+ for (const p of bridges.procs) {
930
+ // The group: a socat forks one child per connection. Listed on the
931
+ // guard until it has gone (item 867's order).
932
+ killTree(p, 'SIGTERM')
933
+ void p.exited.then(() => releaseGroup(p))
578
934
  }
579
935
  }
580
936
 
937
+ /** What a task's output says when strace, not the task, ended its first attempt. */
938
+ const TRACER_RETRY_LINE =
939
+ "[vx] the sandbox's tracer (strace) failed on its own; running the task again\n"
940
+
941
+ /**
942
+ * Run a single task wrapped in the sandbox. Caller must have called
943
+ * `initSandbox()` first.
944
+ *
945
+ * On Linux the task runs under strace, which only REPORTS what the sandbox
946
+ * denied, and a traced task's exit is strace's. strace failing on its own
947
+ * (`ptrace(PTRACE_LISTEN,…): Input/output error`, after a build that had
948
+ * finished) turned green work red on CI five times (STATUS Next 24). Such an
949
+ * attempt, its last word strace's own, is run once more: the sandbox kept
950
+ * its writes to what it declared, so a second run redoes, not doubles, it.
951
+ */
581
952
  export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
953
+ const { tracerFailed, ...first } = await runSandboxedOnce(args)
954
+ if (!tracerFailed) return first
955
+ args.onStderr?.(TRACER_RETRY_LINE)
956
+ const { tracerFailed: _again, ...second } = await runSandboxedOnce(args)
957
+ return {
958
+ ...second,
959
+ durationMs: first.durationMs + second.durationMs,
960
+ stdout: first.stdout + second.stdout,
961
+ stderr: first.stderr + TRACER_RETRY_LINE + second.stderr,
962
+ }
963
+ }
964
+
965
+ /** strace's own message, as the last line a failed traced task printed. */
966
+ const STRACE_OWN_ERROR = /^strace: /
967
+
968
+ /**
969
+ * One attempt of `runSandboxed`.
970
+ *
971
+ * Violations are matched by a unique per-task command prefix — SRT's
972
+ * `getViolationsForCommand` keys by base64 of the first 100 chars, so
973
+ * two tasks running the same underlying command (e.g. parallel `tsc`
974
+ * across packages) would otherwise collide. We prepend `: '<tag>';`
975
+ * (shell no-op) to make every command's first 100 chars unique.
976
+ */
977
+ async function runSandboxedOnce(
978
+ args: SandboxedRunArgs,
979
+ ): Promise<SandboxedRunResult & { tracerFailed: boolean }> {
582
980
  const start = Date.now()
583
981
  const { SandboxManager } = await loadSrt()
584
- const { wrapped, tag, taggedCommand, baselines } = await wrapSandboxedCommand(args)
982
+ const { wrapped, tag, taggedCommand, baselines, forwardsSignals } =
983
+ await wrapSandboxedCommand(args)
585
984
 
586
985
  // Linux: SRT's SandboxViolationStore is macOS-only, so structured
587
986
  // detection on Linux requires us to wrap the spawn with strace and
@@ -591,6 +990,7 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
591
990
  // structurally; we just lose the structured violation list.
592
991
  const useStrace = await wantsStraceDetection()
593
992
  const straceLog = useStrace ? path.join(os.tmpdir(), `vx-strace-${tag}.log`) : undefined
993
+ if (straceLog) unlinkOnExit(straceLog)
594
994
  // We trace only `openat` — it's the actual file-read attempt, the
595
995
  // signal the user cares about. `statx` / `newfstatat` / `access`
596
996
  // are mostly shell PATH-walking and stat probes that aren't
@@ -607,58 +1007,85 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
607
1007
  // it taxed every other sandboxed task the same way. With the flag the
608
1008
  // kernel filter stops only on `openat`. strace ≥ 5.3 (2019); an older
609
1009
  // one gets the slow form rather than no detection.
610
- const spawnArgv = straceLog
611
- ? [
612
- 'strace',
613
- '-f',
614
- ...(useStrace === 'seccomp' ? ['--seccomp-bpf'] : []),
615
- '-e',
616
- 'trace=openat',
617
- '-o',
618
- straceLog,
619
- '--',
620
- 'sh',
621
- '-c',
622
- wrapped,
623
- ]
624
- : ['sh', '-c', wrapped]
625
-
626
1010
  let proc: ReturnType<typeof Bun.spawn>
627
1011
  try {
628
- proc = Bun.spawn(spawnArgv, {
629
- cwd: args.cwd,
630
- env: args.env as Record<string, string>,
631
- stdin: 'ignore',
632
- stdout: 'pipe',
633
- stderr: 'pipe',
634
- })
1012
+ // Both tools resolved on vx's own PATH (util/which.ts): strace would
1013
+ // otherwise walk the task's PATH for `sh`, where a project's
1014
+ // node_modules/.bin comes first.
1015
+ const sh = executablePath('sh')
1016
+ const spawnArgv = straceLog
1017
+ ? [
1018
+ executablePath('strace'),
1019
+ '-f',
1020
+ ...(useStrace === 'seccomp' ? ['--seccomp-bpf'] : []),
1021
+ '-e',
1022
+ 'trace=openat',
1023
+ '-o',
1024
+ straceLog,
1025
+ '--',
1026
+ sh,
1027
+ '-c',
1028
+ wrapped,
1029
+ ]
1030
+ : [sh, '-c', wrapped]
1031
+ proc = spawnGuarded(() =>
1032
+ Bun.spawn(spawnArgv, {
1033
+ argv0: straceLog ? 'strace' : 'sh',
1034
+ cwd: args.cwd,
1035
+ env: args.env as Record<string, string>,
1036
+ // fd 3 is the signal channel the in-sandbox watcher reads.
1037
+ stdio: forwardsSignals ? ['ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
1038
+ // As the unsandboxed spawn: its own process group (kill-tree.ts).
1039
+ detached: true,
1040
+ }),
1041
+ )
1042
+ if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
635
1043
  } catch (err) {
636
- const message = err instanceof Error ? err.message : String(err)
1044
+ const stderr = spawnFailureText(err, args.cwd, 'sandboxed task')
1045
+ args.onStderr?.(stderr)
1046
+ releaseBridges(tag)
637
1047
  return {
638
1048
  exitCode: 127,
639
1049
  durationMs: Date.now() - start,
640
1050
  stdout: '',
641
- stderr: `\n[vx] failed to spawn sandboxed task: ${message}\n`,
1051
+ stderr,
642
1052
  violations: [],
1053
+ tracerFailed: false,
643
1054
  }
644
1055
  }
645
1056
 
646
1057
  args.liveChildren?.add(proc)
647
1058
  const timeout = armTimeout(proc, args.timeoutMs)
648
1059
  const ac = new AbortController()
1060
+ let stderrTail = ''
649
1061
  const streams = Promise.all([
650
1062
  streamToString(proc.stdout, args.onStdout, ac.signal, args.capture?.stdout ?? true),
651
- streamToString(proc.stderr, args.onStderr, ac.signal, args.capture?.stderr ?? true),
1063
+ streamToString(
1064
+ proc.stderr,
1065
+ (chunk) => {
1066
+ // Kept whatever the capture setting: the last line says whether
1067
+ // strace, not the task, ended the attempt.
1068
+ stderrTail = (stderrTail + chunk).slice(-1024)
1069
+ args.onStderr?.(chunk)
1070
+ },
1071
+ ac.signal,
1072
+ args.capture?.stderr ?? true,
1073
+ ),
652
1074
  ])
653
1075
  // See runCommand: gate on child exit; a lingering grandchild pipe (timeout
654
1076
  // OR a clean exit that backgrounds a process) can't hang the run — timeout
655
1077
  // aborts at once, otherwise drainOrAbort bounds the post-exit drain.
656
1078
  await proc.exited
657
- timeout.clear()
1079
+ await timeout.settle()
1080
+ let cut = false
658
1081
  if (timeout.timedOut()) ac.abort()
659
- else await drainOrAbort(streams, ac)
660
- const [stdout, stderr] = await streams
1082
+ else cut = await drainOrAbort(streams, ac)
1083
+ const [stdout, streamed] = await streams
1084
+ if (cut) args.onStderr?.(POST_EXIT_CUT_LINE)
1085
+ const stderr = cut ? streamed + POST_EXIT_CUT_LINE : streamed
661
1086
  args.liveChildren?.delete(proc)
1087
+ releaseGroup(proc)
1088
+ closeSignalChannel(proc)
662
1089
  releaseBridges(tag)
663
1090
  const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
664
1091
 
@@ -715,7 +1142,11 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
715
1142
  const linuxViolations: SandboxViolation[] = straceLog
716
1143
  ? await parseStraceViolations(straceLog, args, baselines).catch(() => [])
717
1144
  : []
718
- if (straceLog) await unlink(straceLog).catch(() => undefined)
1145
+ if (straceLog) {
1146
+ // Listed until the unlink lands, as the run lock's taking (item 868).
1147
+ await unlink(straceLog).catch(() => undefined)
1148
+ liveTempFiles.delete(straceLog)
1149
+ }
719
1150
 
720
1151
  // Apply the task's own ignoreViolations on top of the global defaults.
721
1152
  // SRT's wrapCommandWithSandboxMacOS doesn't actually thread customConfig.
@@ -724,6 +1155,7 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
724
1155
  // to be applied here, after read-back.
725
1156
  const violations = reportableViolations([...macViolations, ...linuxViolations], {
726
1157
  within: args.reportWithin,
1158
+ linked: args.reportLinked,
727
1159
  config: args.config,
728
1160
  })
729
1161
 
@@ -756,6 +1188,7 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
756
1188
  // ignore; bwrap mount-point cleanup is best-effort
757
1189
  }
758
1190
 
1191
+ const lastLine = stderrTail.trimEnd().split('\n').pop() ?? ''
759
1192
  return {
760
1193
  exitCode,
761
1194
  durationMs: Date.now() - start,
@@ -765,6 +1198,11 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
765
1198
  ...(proc.signalCode ? { signal: proc.signalCode } : {}),
766
1199
  ...(timeout.timedOut() ? { timedOut: true } : {}),
767
1200
  ...resourceUsageToCpuRss(proc.resourceUsage()),
1201
+ tracerFailed:
1202
+ straceLog !== undefined &&
1203
+ exitCode !== 0 &&
1204
+ !timeout.timedOut() &&
1205
+ STRACE_OWN_ERROR.test(lastLine),
768
1206
  }
769
1207
  }
770
1208
 
@@ -805,7 +1243,7 @@ function sbplToken(value: string, field: string): string {
805
1243
  * grant passed there is silently dropped. vx is per-task by definition,
806
1244
  * so it emits them itself. The rule text mirrors SRT's own.
807
1245
  */
808
- function macProfileRules(c: ResolvedSandboxConfig): string[] {
1246
+ export function macProfileRules(c: ResolvedSandboxConfig): string[] {
809
1247
  const rules: string[] = []
810
1248
  for (const t of c.systemInfo ?? []) {
811
1249
  rules.push(`(allow system-info (info-type "${sbplToken(t, 'allow.systemInfo')}"))`)
@@ -829,7 +1267,10 @@ function macProfileRules(c: ResolvedSandboxConfig): string[] {
829
1267
  // Both the declared path and what it resolves to: seatbelt matches the
830
1268
  // path the kernel sees, and on macOS `/tmp` is a symlink to
831
1269
  // `/private/tmp` — a grant on the former alone never matches.
832
- for (const p of unique([sbplPath(sock, 'allow.unixSockets'), toRealPath(sock)])) {
1270
+ for (const p of unique([
1271
+ sbplPath(sock, 'allow.unixSockets'),
1272
+ sbplResolvedPath(toRealPath(sock), 'allow.unixSockets'),
1273
+ ])) {
833
1274
  rules.push(`(allow network-bind (local unix-socket (subpath "${p}")))`)
834
1275
  rules.push(`(allow network-outbound (remote unix-socket (subpath "${p}")))`)
835
1276
  }
@@ -852,6 +1293,30 @@ function sbplPath(value: string, field: string): string {
852
1293
  return value
853
1294
  }
854
1295
 
1296
+ /**
1297
+ * The check for a path the FILESYSTEM handed back (`toRealPath` of a
1298
+ * declared socket), which the declared-value allowlist above is wrong for:
1299
+ * a real macOS home is `/Users/Jane Smith`, and refusing the space would
1300
+ * regress every such layout to close a hole. What can leave the quoted
1301
+ * SBPL string, or the single-quoted `sandbox-exec -p '…'` argument it
1302
+ * travels in, is exactly a double quote, a backslash, a single quote, or a
1303
+ * control character — so only those are refused. Item 478 recorded the
1304
+ * hole (a symlink whose TARGET carries a quote went into the profile
1305
+ * unchecked, because the check was on the string the user wrote and the
1306
+ * interpolation was of the string the kernel resolves); item 582 closed
1307
+ * it. Refuse, never escape, as the sibling checkers do.
1308
+ */
1309
+ export function sbplResolvedPath(value: string, field: string): string {
1310
+ // eslint-disable-next-line no-control-regex -- the control range is the point
1311
+ if (/["'\\\x00-\x1f\x7f]/.test(value)) {
1312
+ throw new UserError(
1313
+ `${field}: '${value}' (resolved from a symlink) carries a quote, a backslash or a control ` +
1314
+ `character, which cannot go into a seatbelt profile; point the link at a plain path`,
1315
+ )
1316
+ }
1317
+ return value
1318
+ }
1319
+
855
1320
  /**
856
1321
  * Add rules to the END of the seatbelt profile SRT generated.
857
1322
  *
@@ -905,8 +1370,28 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
905
1370
  * The glob is expanded against the filesystem here, which means it covers
906
1371
  * what exists when the task STARTS. A pattern matching a file the task
907
1372
  * creates later grants nothing there — declare its directory instead.
1373
+ *
1374
+ * That last sentence is the whole contract, and until item 496 a task
1375
+ * that broke it learned so from its OWN tool. Measured, one task per
1376
+ * spelling, each writing files it declares:
1377
+ *
1378
+ * write: ['g/**'] ok — collapsed to the directory
1379
+ * write: ['g/a.txt'] ok — a literal is widened to its directory
1380
+ * write: ['g/*'] FAILED: `bash: g/a.txt: Read-only file system`
1381
+ * write: ['g/*.txt'] FAILED, same
1382
+ * write: ['g/?.txt'] FAILED, same
1383
+ * write: ['g/[ab].txt'] FAILED, same
1384
+ *
1385
+ * The failure names neither vx nor the grant, so `writeGrantMatchedNothing`
1386
+ * below says what happened and what to write instead. A READ grant that
1387
+ * matches nothing is ordinary (an optional file, a cache not yet
1388
+ * populated), so only writes are reported.
1389
+ *
1390
+ * The classifier is `isMountableLiteral` (sandbox-paths.ts), deliberately
1391
+ * NOT `isLiteralPattern`: its docblock says why the brace is not a wildcard
1392
+ * to a grant.
908
1393
  */
909
- function expandGrants(paths: readonly string[]): string[] {
1394
+ function expandGrants(paths: readonly string[], kind: 'read' | 'write'): string[] {
910
1395
  // A pattern covering a directory WHOLE is that directory. `<d>/**/*` and
911
1396
  // `<d>/**` match everything UNDER `<d>` and never `<d>` itself, so a task
912
1397
  // granted `read: ['**/*']` still could not list its own cwd — the exact
@@ -919,21 +1404,58 @@ function expandGrants(paths: readonly string[]): string[] {
919
1404
  if (process.platform !== 'linux') return collapsed
920
1405
  const out: string[] = []
921
1406
  for (const p of collapsed) {
922
- if (!/[*?[\]]/.test(p)) {
1407
+ if (isMountableLiteral(p)) {
923
1408
  out.push(p)
924
1409
  continue
925
1410
  }
926
1411
  // Anchor the scan at the longest literal prefix so a pattern does not
927
1412
  // walk the whole filesystem to find its matches.
928
- const base = path.dirname(p.slice(0, p.search(/[*?[\]]/)))
1413
+ const base = path.dirname(p.slice(0, p.search(MOUNT_WILDCARDS)))
929
1414
  const pattern = path.relative(base, p)
1415
+ // A hit that leaves the scan's base through a link is not what the
1416
+ // pattern named: bwrap binds by the link's target, so `read: ['*']`
1417
+ // over `shared -> ../b/src` bound project b readable, a read `'.'`
1418
+ // never gives, and a cached task replayed b's old bytes (item 1006).
1419
+ // The boundary is the directory holding the first wildcard component,
1420
+ // not the scan's anchor: that sits one component higher.
1421
+ const head = p.slice(0, p.search(MOUNT_WILDCARDS))
1422
+ const home = toRealPath(head.endsWith('/') ? head.slice(0, -1) : path.dirname(head))
1423
+ let hits = 0
930
1424
  for (const hit of new Bun.Glob(pattern).scanSync({ cwd: base, onlyFiles: false, dot: true })) {
931
- out.push(path.join(base, hit))
1425
+ const abs = path.join(base, hit)
1426
+ const real = toRealPath(abs)
1427
+ if (real !== home && !real.startsWith(home + path.sep)) continue
1428
+ out.push(abs)
1429
+ hits++
932
1430
  }
1431
+ if (hits === 0 && kind === 'write') writeGrantMatchedNothing(p)
933
1432
  }
934
1433
  return out
935
1434
  }
936
1435
 
1436
+ /** Grants already reported — once per process, not per spawn. */
1437
+ const warnedEmptyWriteGrant = new Set<string>()
1438
+
1439
+ /**
1440
+ * A write grant that mounted nothing, said once, before the task dies on
1441
+ * it. The remedy is the directory, which is what the grant would have been
1442
+ * widened to anyway had it named a file — and it is named with
1443
+ * `grantPrefix`, the shared wildcard-free head, NOT the scan's anchor
1444
+ * above: that anchor is one component higher (`dirname` of the head, so
1445
+ * the relative pattern keeps its wildcard component), and printing it
1446
+ * would tell the user to grant the PARENT of the directory they meant.
1447
+ */
1448
+ function writeGrantMatchedNothing(grant: string): void {
1449
+ if (warnedEmptyWriteGrant.has(grant)) return
1450
+ warnedEmptyWriteGrant.add(grant)
1451
+ process.stderr.write(
1452
+ `[vx] sandbox: the write grant ${grant} matches nothing yet, so it mounts nothing and ` +
1453
+ `a file the task creates under it will fail with "Read-only file system". A bind mount ` +
1454
+ `covers what exists when the task starts — grant the directory instead: ` +
1455
+ `${grantPrefix(grant)}/**\n`,
1456
+ )
1457
+ }
1458
+
937
1459
  /**
938
1460
  * Memoized check: is `strace` on PATH on a Linux host, and does it know
939
1461
  * `--seccomp-bpf` (5.3+)? `'seccomp'` is the fast form; `'plain'` traces
@@ -944,7 +1466,10 @@ async function wantsStraceDetection(): Promise<false | 'plain' | 'seccomp'> {
944
1466
  if (process.platform !== 'linux') return false
945
1467
  if (straceAvailableCache !== undefined) return straceAvailableCache
946
1468
  try {
947
- const p = Bun.spawn(['strace', '--version'], { stdout: 'pipe', stderr: 'ignore' })
1469
+ const p = Bun.spawn([executablePath('strace'), '--version'], {
1470
+ stdout: 'pipe',
1471
+ stderr: 'ignore',
1472
+ })
948
1473
  const out = await new Response(p.stdout).text()
949
1474
  await p.exited
950
1475
  if (p.exitCode !== 0) straceAvailableCache = false