@vzn/vx 0.0.591 → 0.0.595

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 (76) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +18 -7
  5. package/src/cache/cache.ts +137 -101
  6. package/src/cache/config-evals.ts +19 -4
  7. package/src/cache/git-inputs.ts +33 -17
  8. package/src/cache/inputs.ts +157 -54
  9. package/src/cache/layered-cache.ts +25 -2
  10. package/src/cache/tar-stream.ts +4 -2
  11. package/src/cli/cache.ts +4 -16
  12. package/src/cli/completions.ts +42 -6
  13. package/src/cli/foreign-flags.ts +56 -6
  14. package/src/cli/help.ts +27 -8
  15. package/src/cli/info.ts +4 -6
  16. package/src/cli/init.ts +4 -4
  17. package/src/cli/last.ts +49 -20
  18. package/src/cli/lock.ts +3 -3
  19. package/src/cli/plan-format.ts +9 -0
  20. package/src/cli/plugin-templates.ts +2 -2
  21. package/src/cli/run.ts +80 -50
  22. package/src/cli/select.ts +44 -5
  23. package/src/cli/show.ts +47 -9
  24. package/src/cli/upgrade.ts +2 -3
  25. package/src/cli/watch-filter.ts +21 -7
  26. package/src/cli/watch-fs.ts +32 -8
  27. package/src/cli/watch-judge.ts +1 -1
  28. package/src/cli/watch.ts +40 -15
  29. package/src/cli/why.ts +4 -13
  30. package/src/cli/workspace-config.ts +1 -1
  31. package/src/config.ts +5 -5
  32. package/src/exec/env.ts +8 -5
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +5 -1
  35. package/src/exec/runner.ts +126 -47
  36. package/src/exec/sandbox-deny-scan.ts +2 -2
  37. package/src/exec/sandbox-paths.ts +11 -5
  38. package/src/exec/sandbox-runtime.ts +89 -23
  39. package/src/exec/sandbox-violations.ts +23 -9
  40. package/src/graph/index.ts +1 -0
  41. package/src/graph/scheduler.ts +14 -4
  42. package/src/graph/task-graph.ts +3 -2
  43. package/src/index.ts +9 -0
  44. package/src/orchestrator/affected-tasks.ts +34 -10
  45. package/src/orchestrator/doctor.ts +2 -1
  46. package/src/orchestrator/execute-task.ts +43 -10
  47. package/src/orchestrator/hit-restore.ts +9 -5
  48. package/src/orchestrator/index.ts +1 -0
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +45 -49
  51. package/src/orchestrator/logger.ts +4 -1
  52. package/src/orchestrator/miss-save.ts +10 -3
  53. package/src/orchestrator/placement.ts +17 -3
  54. package/src/orchestrator/plan.ts +25 -1
  55. package/src/orchestrator/plugin-host.ts +41 -3
  56. package/src/orchestrator/plugin.ts +11 -14
  57. package/src/orchestrator/projects.ts +18 -9
  58. package/src/orchestrator/run-context.ts +2 -2
  59. package/src/orchestrator/run-lock.ts +14 -4
  60. package/src/orchestrator/run.ts +94 -16
  61. package/src/orchestrator/sandbox-request.ts +6 -10
  62. package/src/orchestrator/signals.ts +5 -2
  63. package/src/orchestrator/summary.ts +14 -1
  64. package/src/util/index.ts +7 -1
  65. package/src/util/secret-mask.ts +36 -0
  66. package/src/util/settle.ts +15 -0
  67. package/src/workspace/affected.ts +47 -19
  68. package/src/workspace/config-cache.ts +25 -4
  69. package/src/workspace/config-eval.ts +4 -0
  70. package/src/workspace/config-imports.ts +2 -2
  71. package/src/workspace/config-schema.ts +47 -10
  72. package/src/workspace/filter.ts +5 -4
  73. package/src/workspace/migrate-scripts.ts +23 -6
  74. package/src/workspace/package-graph.ts +15 -3
  75. package/src/workspace/project-loader.ts +89 -2
  76. package/src/workspace/workspace.ts +121 -14
@@ -12,6 +12,7 @@ import {
12
12
  isOutOfFds,
13
13
  killGraceMs,
14
14
  OUT_OF_FDS_HINT,
15
+ type SecretMask,
15
16
  } from '../util/index.js'
16
17
  import {
17
18
  closeSignalChannel,
@@ -112,30 +113,49 @@ export function shellQuote(arg: string): string {
112
113
  }
113
114
 
114
115
  /**
115
- * Where a shell comment still open at the end of `command` starts, or -1.
116
- * A `#` opens one only unquoted and at the start of a word (`a#b`, `$#` and
117
- * `${#x}` are words), and a newline closes it.
116
+ * One pass over `command` as sh reads it. `comment`: where a comment still
117
+ * open at the end starts, or -1 — a `#` opens one only unquoted and at the
118
+ * start of a word (`a#b`, `$#` and `${#x}` are words), and a newline closes
119
+ * it. `body`: per line, is it a heredoc's body or terminator (`<<X` /
120
+ * `<<-'X'` / `<<\X` … `X`). One scan, because each needs the other: a
121
+ * `<<X` in a comment or quotes opens no heredoc, and a quote in a body
122
+ * opens nothing (X-110).
118
123
  */
119
- function trailingCommentStart(command: string): number {
124
+ function shellShape(command: string): { comment: number; body: boolean[] } {
125
+ const lines = command.split('\n')
126
+ const body = lines.map(() => false)
127
+ const pending: Array<{ delim: string; tabs: boolean }> = []
120
128
  let quote = ''
121
129
  let comment = -1
122
130
  let atWordStart = true
131
+ let line = 0
123
132
  for (let i = 0; i < command.length; i++) {
124
133
  const c = command[i]!
125
- if (comment >= 0) {
126
- if (c === '\n') {
127
- comment = -1
128
- atWordStart = true
134
+ if (c === '\n') {
135
+ line++
136
+ comment = -1
137
+ if (quote !== '') continue
138
+ atWordStart = true
139
+ // A heredoc's body starts on the line after its `<<`.
140
+ while (pending.length > 0 && line < lines.length) {
141
+ const text = lines[line]!
142
+ body[line] = true
143
+ const open = pending[0]!
144
+ if ((open.tabs ? text.replace(/^\t+/, '') : text) === open.delim) pending.shift()
145
+ i += text.length + 1
146
+ line++
129
147
  }
130
148
  continue
131
149
  }
150
+ if (comment >= 0) continue
132
151
  if (quote === "'") {
133
152
  if (c === "'") quote = ''
134
153
  continue
135
154
  }
136
155
  if (c === '\\') {
156
+ if (command[i + 1] === '\n') line++
157
+ else atWordStart = false
137
158
  i++
138
- atWordStart = false
139
159
  continue
140
160
  }
141
161
  if (quote === '"') {
@@ -151,25 +171,18 @@ function trailingCommentStart(command: string): number {
151
171
  comment = i
152
172
  continue
153
173
  }
174
+ if (c === '<' && command[i - 1] !== '<') {
175
+ const m = /^<<(-?)[ \t]*\\?(['"]?)([A-Za-z_]\w*)\2/.exec(command.slice(i, i + 256))
176
+ if (m !== null) {
177
+ pending.push({ delim: m[3]!, tabs: m[1] === '-' })
178
+ i += m[0].length - 1
179
+ atWordStart = false
180
+ continue
181
+ }
182
+ }
154
183
  atWordStart = /[\s;&|()<>]/.test(c)
155
184
  }
156
- return comment
157
- }
158
-
159
- /** Per line: is it a heredoc's body or terminator (`<<X` / `<<-'X'` … `X`)? */
160
- function heredocLines(lines: readonly string[]): boolean[] {
161
- const pending: Array<{ delim: string; tabs: boolean }> = []
162
- return lines.map((line) => {
163
- const open = pending[0]
164
- if (open !== undefined) {
165
- if ((open.tabs ? line.replace(/^\t+/, '') : line) === open.delim) pending.shift()
166
- return true
167
- }
168
- for (const m of line.matchAll(/(?<!<)<<(?!<)(-?)\s*(['"]?)([A-Za-z_]\w*)\2/g)) {
169
- pending.push({ delim: m[3]!, tabs: m[1] === '-' })
170
- }
171
- return false
172
- })
185
+ return { comment, body }
173
186
  }
174
187
 
175
188
  /**
@@ -183,27 +196,34 @@ export function withForwardArgs(command: string, args: readonly string[] | undef
183
196
  // `X FWD` closed nothing and the args never reached the command (X-12).
184
197
  // They go on the last line that is a command, where `cat <<X FWD` is
185
198
  // still `cat FWD` reading the heredoc.
186
- const lines = command.trimEnd().split('\n')
187
- const body = heredocLines(lines)
188
- if (body[lines.length - 1] === true) {
189
- const last = body.lastIndexOf(false)
199
+ const trimmed = command.trimEnd()
200
+ const lines = trimmed.split('\n')
201
+ const shape = shellShape(trimmed)
202
+ if (shape.body[lines.length - 1] === true) {
203
+ const last = shape.body.lastIndexOf(false)
190
204
  const tail = lines.slice(last + 1).join('\n')
191
205
  return `${withForwardArgs(lines.slice(0, last + 1).join('\n'), args)}\n${tail}`
192
206
  }
193
207
  const quoted = args.map(shellQuote).join(' ')
194
- let comment = trailingCommentStart(command)
208
+ // Of the trimmed command: the newline closing a template literal's last
209
+ // line closed its comment too, and `echo args: # c\n` took the args into
210
+ // the comment (X-110).
211
+ let comment = shape.comment
195
212
  if (comment < 0) {
196
213
  // A command that ends in a newline (a template literal's closing line)
197
214
  // ran the args as a command of their own: `--watch: not found`. Kept
198
215
  // when an odd run of backslashes ends it: that escapes what follows.
199
- const body = command.trimEnd()
200
- const escaped = (/\\+$/.exec(body)?.[0].length ?? 0) % 2 === 1
201
- return `${escaped ? command : body} ${quoted}`
216
+ const escaped = (/\\+$/.exec(trimmed)?.[0].length ?? 0) % 2 === 1
217
+ return `${escaped ? command : trimmed} ${quoted}`
202
218
  }
203
219
  // A comment-only last line after a commented line: the args go before
204
220
  // the earliest comment, or they land inside it (`echo one # c\n# two`).
205
- for (let c = comment; c >= 0; c = trailingCommentStart(command.slice(0, c).trimEnd())) comment = c
206
- return `${command.slice(0, comment).trimEnd()} ${quoted} ${command.slice(comment)}`
221
+ for (let c = comment; c >= 0; c = shellShape(trimmed.slice(0, c).trimEnd()).comment) comment = c
222
+ // What precedes the comment may end in a heredoc: the args go where they
223
+ // would with no comment, never on its terminator.
224
+ const head = trimmed.slice(0, comment).trimEnd()
225
+ const gap = trimmed.slice(head.length, comment) || ' '
226
+ return `${withForwardArgs(head, args)}${gap}${trimmed.slice(comment)}`
207
227
  }
208
228
 
209
229
  // Any shell control/expansion character means the wrapping `sh` has real
@@ -348,6 +368,18 @@ const SIGNAL_ALIASES = new Set(['SIGIOT', 'SIGPOLL', 'SIGCLD'])
348
368
  */
349
369
  const TIMEOUT_SIGKILL_GRACE_MS = 2000
350
370
 
371
+ /**
372
+ * Run `fn` one loop turn later, once the child exits already pending are
373
+ * read. A deadline due in the same turn as a child's exit fires BEFORE Bun
374
+ * reaps it: a task that exited 0 inside its timeout, seen late by a busy vx,
375
+ * was SIGTERMed as a zombie and failed as timed out, and a server that died
376
+ * before ready read as a readiness timeout (2026-10-08). A turn later the
377
+ * exit has settled the caller, which clears the returned timer.
378
+ */
379
+ function afterPendingExits(fn: () => void): ReturnType<typeof setTimeout> {
380
+ return setTimeout(fn, 0)
381
+ }
382
+
351
383
  /**
352
384
  * Arm a SIGTERM timeout on a spawned child. Returns a handle whose
353
385
  * `timedOut()` reports whether the timer fired — so the caller can
@@ -368,7 +400,10 @@ export function armTimeout(
368
400
  let firedAt: number | undefined
369
401
  let killTimer: ReturnType<typeof setTimeout> | undefined
370
402
  const graceMs = killGraceMs(TIMEOUT_SIGKILL_GRACE_MS)
371
- const timer = setTimeout(() => {
403
+ let timer = setTimeout(() => {
404
+ timer = afterPendingExits(fire)
405
+ }, timeoutMs)
406
+ function fire(): void {
372
407
  firedAt = Date.now()
373
408
  killTree(proc, 'SIGTERM')
374
409
  // Escalate to SIGKILL after a grace: a child that TRAPS+IGNORES SIGTERM
@@ -379,7 +414,7 @@ export function armTimeout(
379
414
  // the CLI alive.
380
415
  killTimer = setTimeout(() => killTree(proc, 'SIGKILL'), graceMs)
381
416
  killTimer.unref?.()
382
- }, timeoutMs)
417
+ }
383
418
  return {
384
419
  timedOut: () => firedAt !== undefined,
385
420
  settle: async () => {
@@ -483,7 +518,7 @@ export interface PersistentSpawn {
483
518
  * Rejects with the spawn error if the child fails to start.
484
519
  */
485
520
  ready: Promise<void>
486
- /** ms elapsed from spawn to ready (or to current time if not yet ready). */
521
+ /** ms from spawn to ready, to the readiness timeout giving up, or to now. */
487
522
  readyMs: () => number
488
523
  }
489
524
 
@@ -522,6 +557,9 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'cap
522
557
  export function runPersistent(opts: PersistentOptions): PersistentSpawn {
523
558
  const start = Date.now()
524
559
  let readyAt: number | undefined
560
+ // When the readiness wait gave up: the wait's length, not the kill grace
561
+ // a TERM-trapping server runs out after it.
562
+ let gaveUpAt: number | undefined
525
563
 
526
564
  // Pattern compiled once; thrown errors surface synchronously so the
527
565
  // caller can wrap with a user-facing message.
@@ -677,8 +715,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
677
715
  // a healthy server is never killed by a stale timer.
678
716
  let readyTimer: ReturnType<typeof setTimeout> | undefined
679
717
  if (readyRe && opts.timeoutMs !== undefined) {
680
- readyTimer = setTimeout(() => {
718
+ const giveUp = (): void => {
681
719
  if (readyAt === undefined) {
720
+ gaveUpAt = Date.now()
682
721
  rejectReady(
683
722
  new PersistentReadyError(
684
723
  `persistent task not ready within ${opts.timeoutMs}ms — ` +
@@ -701,6 +740,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
701
740
  }, killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
702
741
  killTimer.unref?.()
703
742
  }
743
+ }
744
+ readyTimer = setTimeout(() => {
745
+ readyTimer = afterPendingExits(giveUp)
704
746
  }, opts.timeoutMs)
705
747
  }
706
748
 
@@ -730,7 +772,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
730
772
  return {
731
773
  child,
732
774
  ready,
733
- readyMs: () => (readyAt ?? Date.now()) - start,
775
+ readyMs: () => (readyAt ?? gaveUpAt ?? Date.now()) - start,
734
776
  }
735
777
  }
736
778
 
@@ -807,7 +849,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
807
849
  stderr,
808
850
  ...(proc.signalCode ? { signal: proc.signalCode } : {}),
809
851
  ...(timeout.timedOut() ? { timedOut: true } : {}),
810
- ...resourceUsageToCpuRss(proc.resourceUsage(), ownRssHighWater()),
852
+ ...resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor),
811
853
  }
812
854
  }
813
855
 
@@ -832,7 +874,7 @@ export function ownRssHighWater(): number {
832
874
  if (process.platform === 'linux') {
833
875
  try {
834
876
  const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
835
- if (m !== null) return Number(m[1]) * 1024
877
+ if (m !== null) return (highWaterSeen = Number(m[1]) * 1024)
836
878
  } catch {
837
879
  // /proc unreadable: fall through to the current RSS.
838
880
  }
@@ -840,6 +882,22 @@ export function ownRssHighWater(): number {
840
882
  return process.memoryUsage.rss()
841
883
  }
842
884
 
885
+ /** The last `VmHWM` read, in bytes; 0 until one is. */
886
+ let highWaterSeen = 0
887
+
888
+ /**
889
+ * The floor for a child's `peak`, read only when the last `VmHWM` cannot
890
+ * decide it: the mark never falls, so a peak within the slack of an older
891
+ * reading is within the slack of the current one, and goes unreported
892
+ * either way. A light task reads vx's own mark as its peak, so after the
893
+ * first such task the rest skip the read, which cost ~35 µs of the
894
+ * scheduler's thread per task (500 cold tasks, 2026-10-07). A current-RSS
895
+ * fallback is not monotonic and is never reused.
896
+ */
897
+ function rssFloorFor(peak: number): number {
898
+ return peak <= highWaterSeen + RSS_FLOOR_SLACK_BYTES ? highWaterSeen : ownRssHighWater()
899
+ }
900
+
843
901
  /**
844
902
  * What a task's captured output keeps: the first `CAPTURE_HEAD_CHARS` and
845
903
  * the last `CAPTURE_TAIL_CHARS` characters, the dropped middle counted and
@@ -863,6 +921,23 @@ export function droppedOutputLine(dropped: number): string {
863
921
  )
864
922
  }
865
923
 
924
+ /**
925
+ * A captured `text` with `secrets` masked. A value the dropped middle cut
926
+ * left a piece at the head's end and one at the tail's start, which the
927
+ * whole-value mask missed, and the cache entry stored and replayed both.
928
+ */
929
+ export function maskCaptured(text: string, secrets: SecretMask): string {
930
+ const at = text.indexOf('\n[vx] ', CAPTURE_HEAD_CHARS - 1)
931
+ const end = at === -1 ? -1 : text.indexOf('\n', at + 1) + 1
932
+ if (at === -1 || at > CAPTURE_HEAD_CHARS || !DROPPED_LINE.test(text.slice(at, end))) {
933
+ return secrets.mask(text)
934
+ }
935
+ const [head, tail] = secrets.maskCut(text.slice(0, at), text.slice(end))
936
+ return head + text.slice(at, end) + tail
937
+ }
938
+
939
+ const DROPPED_LINE = /^\n\[vx\] [\d.]+ MiB of output not kept — /
940
+
866
941
  const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
867
942
 
868
943
  /**
@@ -877,7 +952,10 @@ class BoundedCapture {
877
952
  private dropped = 0
878
953
 
879
954
  push(chunk: string): void {
880
- if (this.head.length < CAPTURE_HEAD_CHARS) {
955
+ // Once the tail has begun the head is closed, even a unit short of its
956
+ // bound (the surrogate case below): a later short chunk fit there and
957
+ // was retained ahead of the chunk before it.
958
+ if (this.tailLen === 0 && this.head.length < CAPTURE_HEAD_CHARS) {
881
959
  let room = CAPTURE_HEAD_CHARS - this.head.length
882
960
  if (chunk.length <= room) {
883
961
  this.head += chunk
@@ -1014,8 +1092,8 @@ export function peakRssBytes(maxRSS: number): number {
1014
1092
  */
1015
1093
  export function resourceUsageToCpuRss(
1016
1094
  usage: ReturnType<ReturnType<typeof Bun.spawn>['resourceUsage']>,
1017
- /** The parent's own high-water mark (`ownRssHighWater`); a peak at or under it is inherited, not the child's, and is not reported. */
1018
- floorBytes = 0,
1095
+ /** The parent's own high-water mark (`ownRssHighWater`), or a function of the peak that answers it; a peak at or under it is inherited, not the child's, and is not reported. */
1096
+ floorBytes: number | ((peak: number) => number) = 0,
1019
1097
  ): { cpuMs?: number; peakRssBytes?: number } {
1020
1098
  if (!usage) return {}
1021
1099
  // cpuTime.total is microseconds as a bigint → ms.
@@ -1028,5 +1106,6 @@ export function resourceUsageToCpuRss(
1028
1106
  // parent's (see `ownRssHighWater`, `RSS_FLOOR_SLACK_BYTES`): the child's
1029
1107
  // peak is unknown, bounded by it.
1030
1108
  const peak = peakRssBytes(usage.maxRSS)
1031
- return peak > floorBytes + RSS_FLOOR_SLACK_BYTES ? { cpuMs, peakRssBytes: peak } : { cpuMs }
1109
+ const floor = typeof floorBytes === 'number' ? floorBytes : floorBytes(peak)
1110
+ return peak > floor + RSS_FLOOR_SLACK_BYTES ? { cpuMs, peakRssBytes: peak } : { cpuMs }
1032
1111
  }
@@ -16,7 +16,7 @@
16
16
  import { lstatSync, readdirSync } from 'node:fs'
17
17
  import { homedir } from 'node:os'
18
18
  import path from 'node:path'
19
- import { UserError } from '../util/index.js'
19
+ import { relPosix, UserError } from '../util/index.js'
20
20
  import { atOrUnder, MOUNT_WILDCARDS } from './sandbox-paths.js'
21
21
 
22
22
  /** SRT's `rg --max-depth` for the scan (`DEFAULT_MANDATORY_DENY_SEARCH_DEPTH`). */
@@ -126,7 +126,7 @@ export function scopedMandatoryDenies(
126
126
  }
127
127
  }
128
128
  for (const root of roots) {
129
- const rel = path.relative(cwd, root).split(path.sep).join('/')
129
+ const rel = relPosix(cwd, root)
130
130
  const depth = rel === '' ? 0 : rel.split('/').length
131
131
  if (depth > SEARCH_DEPTH) continue
132
132
  let dir: boolean
@@ -78,14 +78,16 @@ export function localBindingOn(c: { localBinding?: boolean | readonly number[] }
78
78
  }
79
79
 
80
80
  /**
81
- * Whether a sandboxed task may read `file`: it lies under a read grant,
82
- * a write grant or a baseline read, by its canonical path. A path no
83
- * grant holds is not there inside the sandbox, which the shell reports
81
+ * Whether a sandboxed task may read `file`: it lies under no denial (the
82
+ * rest of the host is mounted read-only), or under a read grant, a write
83
+ * grant or a baseline read, by its canonical path. A path no grant holds
84
+ * under a denial is not there inside the sandbox, which the shell reports
84
85
  * as "not found" with no denial a trace can see (an `execve`).
85
86
  */
86
87
  export function sandboxReads(
87
88
  sandbox: {
88
89
  readonly baseAllowRead: readonly string[]
90
+ readonly baseDenyRead: readonly string[]
89
91
  readonly config: {
90
92
  readonly allowRead: readonly string[]
91
93
  readonly allowWrite: readonly string[]
@@ -94,7 +96,11 @@ export function sandboxReads(
94
96
  file: string,
95
97
  ): boolean {
96
98
  const real = toRealPath(file)
97
- return [...sandbox.baseAllowRead, ...sandbox.config.allowRead, ...sandbox.config.allowWrite].some(
98
- (g) => atOrUnder(real, toRealPath(g)),
99
+ const under = (g: string): boolean => atOrUnder(real, toRealPath(g))
100
+ return (
101
+ !sandbox.baseDenyRead.some(under) ||
102
+ [...sandbox.baseAllowRead, ...sandbox.config.allowRead, ...sandbox.config.allowWrite].some(
103
+ under,
104
+ )
99
105
  )
100
106
  }
@@ -108,9 +108,13 @@ import {
108
108
 
109
109
  type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
110
110
  let srtPromise: Promise<SrtModule> | undefined
111
+ /** Set once loaded, for `releaseBridges`, which a sync exit handler calls. */
112
+ let srtLoaded: SrtModule | undefined
111
113
 
112
114
  async function loadSrt(): Promise<SrtModule> {
113
- if (!srtPromise) srtPromise = import('@anthropic-ai/sandbox-runtime')
115
+ if (!srtPromise) {
116
+ srtPromise = import('@anthropic-ai/sandbox-runtime').then((m) => (srtLoaded = m))
117
+ }
114
118
  return srtPromise
115
119
  }
116
120
 
@@ -275,9 +279,16 @@ export function dependencyReason(errors: readonly string[]): string {
275
279
  * listen" on macOS and "Failed to create bridge sockets after 5 attempts"
276
280
  * on Linux (its retry loop swallows the code), neither naming the
277
281
  * directory (2026-09-16). Checked up front, with room for the sequence.
282
+ * On Linux the longer name is the network bridge's,
283
+ * `claude-http-<16 hex>.sock`, and it is the one that fails there.
278
284
  */
279
285
  export function socketPathRefusal(tmpdir = os.tmpdir()): string | undefined {
280
- const sample = path.join(tmpdir, `srt-mux-${process.pid}-zzz.sock`)
286
+ const sample = path.join(
287
+ tmpdir,
288
+ process.platform === 'linux'
289
+ ? `claude-http-${'0'.repeat(16)}.sock`
290
+ : `srt-mux-${process.pid}-zzz.sock`,
291
+ )
281
292
  const limit = process.platform === 'darwin' ? 103 : 107
282
293
  const length = Buffer.byteLength(sample)
283
294
  if (length <= limit) return undefined
@@ -1151,6 +1162,23 @@ export async function wrapSandboxedCommand(
1151
1162
  const tmp = taskTmpdir(tag)
1152
1163
  mkdirSync(tmp, { mode: 0o700 })
1153
1164
  trackTaskTmpdir(tmp)
1165
+ try {
1166
+ return await wrapIn(args, SandboxManager, userCommand, tag, tmp)
1167
+ } catch (err) {
1168
+ // Nothing spawned, so nothing releases it on exit: a `vx watch` kept
1169
+ // one task directory per refused wrap (a held port) until it quit.
1170
+ releaseBridges(tag)
1171
+ throw err
1172
+ }
1173
+ }
1174
+
1175
+ async function wrapIn(
1176
+ args: Parameters<typeof wrapSandboxedCommand>[0],
1177
+ SandboxManager: SrtModule['SandboxManager'],
1178
+ userCommand: string,
1179
+ tag: string,
1180
+ tmp: string,
1181
+ ): ReturnType<typeof wrapSandboxedCommand> {
1154
1182
  // After the tag: SRT keys violations by the command's first 100 chars.
1155
1183
  const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${javaToolOptionsFix(
1156
1184
  process.env['JAVA_TOOL_OPTIONS'],
@@ -1188,6 +1216,16 @@ export async function wrapSandboxedCommand(
1188
1216
  // user command, so it goes INTO the sandboxed command; the host side is
1189
1217
  // spawned here and released when the task's process ends.
1190
1218
  const ports = process.platform === 'linux' ? bridgedPorts(args.config) : []
1219
+ // Before the wrap, which holds the runtime's stub cleanup until `afterCommand`.
1220
+ const held = portsHeld(ports)
1221
+ if (held.length > 0) {
1222
+ throw new UserError(
1223
+ `sandbox: localBinding port${held.length === 1 ? '' : 's'} ${held.join(', ')} ` +
1224
+ `${held.length === 1 ? 'is' : 'are'} already in use on this machine, so the task's own ` +
1225
+ `cannot be exposed there and a client would reach the other listener; stop what ` +
1226
+ `holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
1227
+ )
1228
+ }
1191
1229
  const grouped =
1192
1230
  process.platform === 'linux'
1193
1231
  ? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
@@ -1215,7 +1253,13 @@ export async function wrapSandboxedCommand(
1215
1253
  ...macProfileRules(args.config),
1216
1254
  ...darwinWallRules(args.config, baselines.allowRead),
1217
1255
  ]
1218
- if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
1256
+ try {
1257
+ if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
1258
+ } catch (err) {
1259
+ releaseBridges(tag)
1260
+ afterCommand(SandboxManager)
1261
+ throw err
1262
+ }
1219
1263
  }
1220
1264
  // Linux: the shell execs bwrap, so bwrap is the spawn itself and its
1221
1265
  // `--die-with-parent` is keyed to vx. Behind a shell that waited on it,
@@ -1240,15 +1284,6 @@ export async function wrapSandboxedCommand(
1240
1284
  }
1241
1285
  if (process.platform === 'linux' && !hostHasIpv6())
1242
1286
  wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
1243
- const held = portsHeld(ports)
1244
- if (held.length > 0) {
1245
- throw new UserError(
1246
- `sandbox: localBinding port${held.length === 1 ? '' : 's'} ${held.join(', ')} ` +
1247
- `${held.length === 1 ? 'is' : 'are'} already in use on this machine, so the task's own ` +
1248
- `cannot be exposed there and a client would reach the other listener; stop what ` +
1249
- `holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
1250
- )
1251
- }
1252
1287
  if (args.server === true) liveServers.add(tag)
1253
1288
  if (ports.length > 0) {
1254
1289
  spawnHostBridges(ports, tag)
@@ -1361,11 +1396,14 @@ function ownGroupCommand(
1361
1396
  .join(' ')
1362
1397
  const body = shellQuote(`exec ${TRACE_FD}>&-; ${userCommand}`)
1363
1398
  const run = `{ trap - INT QUIT; exec ${setsid ?? ''}${tracer} ${shellQuote(sh)} -c ${body} 3<&-; } & c=$!;`
1399
+ // Bash reports a job a signal killed on its stderr, the task's: a task
1400
+ // whose shell died of SIGKILL printed this whole wrapper (X-111).
1401
+ const wait = `wait "$c" 2>/dev/null`
1364
1402
  if (setsid === undefined) {
1365
- return { command: `${tag0} ${run} wait "$c"`, forwards: false, traced: true }
1403
+ return { command: `${tag0} ${run} ${wait}`, forwards: false, traced: true }
1366
1404
  }
1367
1405
  const watch = `{ IFS= read -r s && kill -s "$s" -- "-$c"; } 2>/dev/null <&3 3<&- &`
1368
- return { command: `${tag0} ${run} ${watch} wait "$c"`, forwards: true, traced: true }
1406
+ return { command: `${tag0} ${run} ${watch} ${wait}`, forwards: true, traced: true }
1369
1407
  }
1370
1408
 
1371
1409
  /** What strace stops on: the reads, and what moves or makes a process's cwd. */
@@ -1409,9 +1447,11 @@ function literalReadPaths(
1409
1447
  * it the pattern, and SRT compiles any spelling holding `[` as a regex in
1410
1448
  * which a backslash is a literal one, so `pages/\[id\].tsx` matched no
1411
1449
  * file and the route could not be granted. `[[]` is a class of one `[`; a
1412
- * lone `]` is plain text (B-65).
1450
+ * lone `]` is plain text (B-65). A deny path is a real directory, never a
1451
+ * pattern: a nested project's wall under `[legacy]/` compiled as a class,
1452
+ * matched nothing, and the root task read it.
1413
1453
  */
1414
- function seatbeltBrackets(
1454
+ export function seatbeltBrackets(
1415
1455
  config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
1416
1456
  ): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
1417
1457
  const fs = config?.filesystem
@@ -1422,6 +1462,7 @@ function seatbeltBrackets(
1422
1462
  ...config,
1423
1463
  filesystem: {
1424
1464
  ...fs,
1465
+ denyRead: fs.denyRead.map((p) => p.replaceAll('[', '[[]')),
1425
1466
  allowWrite: literal(fs.allowWrite),
1426
1467
  ...(fs.allowRead !== undefined ? { allowRead: literal(fs.allowRead) } : {}),
1427
1468
  },
@@ -1620,8 +1661,17 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
1620
1661
  export function releaseBridges(tag: string): void {
1621
1662
  const tmp = taskTmpdir(tag)
1622
1663
  if (liveTaskTmpdirs.delete(tmp)) rmSync(tmp, { recursive: true, force: true })
1623
- if (liveServers.delete(tag) && liveServers.size === 0 && resetDeferred) {
1624
- void resetSandbox().catch(() => {})
1664
+ if (liveServers.delete(tag)) {
1665
+ // A server's wrap counts as a live sandbox in SRT until this, and SRT
1666
+ // removes bwrap's host stubs (`.bashrc`, `.vscode`, … under a write
1667
+ // grant) only at a count of 0: one stopped server kept every later
1668
+ // task's stubs in the workspace until the reset.
1669
+ try {
1670
+ srtLoaded!.SandboxManager.cleanupAfterCommand()
1671
+ } catch {
1672
+ // best-effort, as a one-shot task's
1673
+ }
1674
+ if (liveServers.size === 0 && resetDeferred) void resetSandbox().catch(() => {})
1625
1675
  }
1626
1676
  const bridges = hostBridges.get(tag)
1627
1677
  if (bridges === undefined) return
@@ -1775,6 +1825,7 @@ async function runSandboxedOnce(
1775
1825
  if (args.signal?.aborted === true) {
1776
1826
  releaseBridges(tag)
1777
1827
  takeRecords()
1828
+ afterCommand(SandboxManager)
1778
1829
  const signal = stopSignal(args.signal.reason)
1779
1830
  return {
1780
1831
  exitCode: signalExitCode(signal),
@@ -1819,10 +1870,15 @@ async function runSandboxedOnce(
1819
1870
  )
1820
1871
  if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
1821
1872
  } catch (err) {
1873
+ if (straceLog) {
1874
+ rmSync(straceLog, { force: true })
1875
+ liveTempFiles.delete(straceLog)
1876
+ }
1822
1877
  const stderr = spawnFailureText(err, args.cwd, 'sandboxed task')
1823
1878
  args.onStderr?.(stderr)
1824
1879
  releaseBridges(tag)
1825
1880
  takeRecords()
1881
+ afterCommand(SandboxManager)
1826
1882
  return {
1827
1883
  exitCode: 127,
1828
1884
  durationMs: Date.now() - start,
@@ -2012,11 +2068,7 @@ async function runSandboxedOnce(
2012
2068
  if (hidden.length > 0) violations.push(hiddenReadsHint(hidden, args.reportWithin))
2013
2069
  }
2014
2070
 
2015
- try {
2016
- SandboxManager.cleanupAfterCommand()
2017
- } catch {
2018
- // ignore; bwrap mount-point cleanup is best-effort
2019
- }
2071
+ afterCommand(SandboxManager)
2020
2072
 
2021
2073
  return {
2022
2074
  exitCode,
@@ -2042,6 +2094,20 @@ async function runSandboxedOnce(
2042
2094
  }
2043
2095
  }
2044
2096
 
2097
+ /**
2098
+ * The runtime counts every wrap as a live sandbox until this, and removes
2099
+ * the empty files bwrap made on the host as mount points (`.bashrc`,
2100
+ * `.vscode`, … under a write grant) only at a count of 0: a wrap with no
2101
+ * call here kept every later task's stubs on the host until the reset.
2102
+ */
2103
+ function afterCommand(SandboxManager: SrtModule['SandboxManager']): void {
2104
+ try {
2105
+ SandboxManager.cleanupAfterCommand()
2106
+ } catch {
2107
+ // ignore; bwrap mount-point cleanup is best-effort
2108
+ }
2109
+ }
2110
+
2045
2111
  /** The hint for writes refused outside the project, a few paths named. */
2046
2112
  function outsideWritesHint(
2047
2113
  paths: readonly string[],