@vzn/vx 0.0.349 → 0.0.352

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.
@@ -742,20 +742,19 @@ function canonicalBaselines(
742
742
  * only if it exists in the new root, and otherwise `$HOME`, with no word:
743
743
  * a project granted no read ran in the home directory, where `cat x.txt`
744
744
  * read `~/x.txt`. A grant at or above the cwd holds it, and so does an
745
- * existing one below (bwrap builds the path to a bind), and so does a
746
- * deny that IS the cwd (a single-package workspace's anchor). When none does,
745
+ * existing one below (bwrap builds the path to a bind). When none does,
747
746
  * the cwd is denied instead: an empty directory the task enters, whose
748
- * reads are refused and reported as the anchor's are.
747
+ * reads are refused and reported as the anchor's are. A single-package
748
+ * workspace's cwd is its anchor, denied already; the runtime takes the
749
+ * second entry as the same mount.
749
750
  */
750
751
  function cwdMounted(
751
752
  cwd: string,
752
753
  fs: {
753
754
  allowRead?: readonly string[] | undefined
754
755
  allowWrite?: readonly string[] | undefined
755
- denyRead?: readonly string[] | undefined
756
756
  },
757
757
  ): boolean {
758
- if (fs.denyRead?.includes(cwd) === true) return true
759
758
  return [...(fs.allowRead ?? []), ...(fs.allowWrite ?? [])].some(
760
759
  (p) => atOrUnder(cwd, p) || (atOrUnder(p, cwd) && existsSync(p)),
761
760
  )
@@ -1007,12 +1006,21 @@ export async function wrapSandboxedCommand(
1007
1006
  process.platform === 'linux'
1008
1007
  ? ownGroupCommand(tag, inTmp, args.trace)
1009
1008
  : { command: taggedCommand, forwards: false, traced: false }
1010
- const inner =
1011
- ports.length > 0 ? `${portBridgeInner(ports, tag)} ${grouped.command}` : grouped.command
1009
+ const inner = [
1010
+ ports.length > 0 ? portBridgeInner(ports, tag) : '',
1011
+ process.platform === 'linux' && args.config.network !== undefined ? PROXY_BRIDGE_WAIT : '',
1012
+ grouped.command,
1013
+ ]
1014
+ .filter((part) => part !== '')
1015
+ .join(' ')
1012
1016
  let wrapped = await wrapForTask(
1013
1017
  SandboxManager,
1014
1018
  inner,
1015
- process.platform === 'linux' ? literalReadPaths(customConfig) : customConfig,
1019
+ process.platform === 'linux'
1020
+ ? literalReadPaths(customConfig)
1021
+ : process.platform === 'darwin'
1022
+ ? seatbeltBrackets(customConfig)
1023
+ : customConfig,
1016
1024
  ports.length > 0 || asksUnixSockets(args.config),
1017
1025
  args.config.gitConfig === true,
1018
1026
  )
@@ -1177,6 +1185,30 @@ function literalReadPaths(
1177
1185
  }
1178
1186
  }
1179
1187
 
1188
+ /**
1189
+ * macOS: a grant's escaped bracket as seatbelt's SRT can read it. vx hands
1190
+ * it the pattern, and SRT compiles any spelling holding `[` as a regex in
1191
+ * which a backslash is a literal one, so `pages/\[id\].tsx` matched no
1192
+ * file and the route could not be granted. `[[]` is a class of one `[`; a
1193
+ * lone `]` is plain text (B-65).
1194
+ */
1195
+ function seatbeltBrackets(
1196
+ config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
1197
+ ): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
1198
+ const fs = config?.filesystem
1199
+ if (fs === undefined) return config
1200
+ const literal = (paths: readonly string[]): string[] =>
1201
+ paths.map((p) => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']'))
1202
+ return {
1203
+ ...config,
1204
+ filesystem: {
1205
+ ...fs,
1206
+ allowWrite: literal(fs.allowWrite),
1207
+ ...(fs.allowRead !== undefined ? { allowRead: literal(fs.allowRead) } : {}),
1208
+ },
1209
+ }
1210
+ }
1211
+
1180
1212
  /** SRT's wrap, with the socket lift and the git-config grant this task asked for, or none (L-6, B-41). */
1181
1213
  function wrapForTask(
1182
1214
  SandboxManager: SrtModule['SandboxManager'],
@@ -1204,6 +1236,18 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
1204
1236
  return Array.isArray(c.localBinding) ? [...new Set(c.localBinding)] : []
1205
1237
  }
1206
1238
 
1239
+ /**
1240
+ * Linux: SRT starts its in-sandbox proxy bridges (`socat TCP-LISTEN:3128`
1241
+ * and `:1080`) in the background and runs the command at once, so a
1242
+ * networked task that dialled the proxy first met "connection refused"
1243
+ * (curl's `000`) on a loaded box (M-20). This waits, in front of the
1244
+ * command, until both listen in the task's network namespace, read off
1245
+ * /proc/net (IPv4 or IPv6, state 0A). Bounded at ~5 s: a bridge that never
1246
+ * listens leaves the command to meet the refusal it met before.
1247
+ */
1248
+ const PROXY_BRIDGE_WAIT =
1249
+ "( i=0; until grep -qsE ':0C38 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6 && grep -qsE ':0438 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6; do [ $i -ge 500 ] && break; i=$((i+1)); sleep 0.01; done );"
1250
+
1207
1251
  /** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
1208
1252
  export function portBridgeSocket(tag: string, port: number): string {
1209
1253
  return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
@@ -2076,12 +2120,20 @@ async function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why:
2076
2120
  } else {
2077
2121
  const m = /version (\d+)\.(\d+)/.exec(out)
2078
2122
  const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
2079
- const form = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
2080
- const refused = await traceRefusal(form)
2123
+ let form: 'plain' | 'seccomp' = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
2124
+ let refused = await traceRefusal(form)
2125
+ // A strace that cannot check the seccomp filter says so and traces on
2126
+ // without it, exit 0. Inside the sandbox that line read as the trace
2127
+ // cut short, and every sandboxed task ran twice: the plain form, if
2128
+ // it is quiet, is the one that works here.
2129
+ if (refused?.warned === true && form === 'seccomp') {
2130
+ refused = await traceRefusal('plain')
2131
+ if (refused === null) form = 'plain'
2132
+ }
2081
2133
  straceAvailableCache =
2082
2134
  refused === null
2083
2135
  ? { form, why: '' }
2084
- : { form: false, why: `strace cannot trace here (${refused})` }
2136
+ : { form: false, why: `strace cannot trace here (${refused.line})` }
2085
2137
  }
2086
2138
  } catch {
2087
2139
  // Not on PATH. Said as the refused attach is: without it an undeclared
@@ -2111,14 +2163,17 @@ export async function untracedReason(): Promise<string | null> {
2111
2163
 
2112
2164
  /**
2113
2165
  * Why strace may not attach here (null when it may), asked once with the flags a task's trace
2114
- * uses (`ownGroupCommand`). `--version` answers on a host that refuses
2166
+ * uses (`ownGroupCommand`). A strace that exits 0 having said something of
2167
+ * its own is refused too (`warned`): in a task, its line is the retry key. `--version` answers on a host that refuses
2115
2168
  * ptrace (Yama's `ptrace_scope` 2 or 3, a container's seccomp profile),
2116
2169
  * and there every sandboxed task failed twice, the retry included, on
2117
2170
  * `attach: ptrace(PTRACE_SEIZE…): Operation not permitted`. Refused, the
2118
2171
  * run goes untraced, as without strace: bwrap still enforces, and only the
2119
2172
  * read-violation report is lost, which is said once.
2120
2173
  */
2121
- async function traceRefusal(form: 'plain' | 'seccomp'): Promise<string | null> {
2174
+ async function traceRefusal(
2175
+ form: 'plain' | 'seccomp',
2176
+ ): Promise<{ line: string; warned: boolean } | null> {
2122
2177
  const p = Bun.spawn(
2123
2178
  [
2124
2179
  executablePath('strace'),
@@ -2136,8 +2191,9 @@ async function traceRefusal(form: 'plain' | 'seccomp'): Promise<string | null> {
2136
2191
  { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
2137
2192
  )
2138
2193
  const err = await new Response(p.stderr).text()
2139
- if ((await p.exited) === 0) return null
2140
- return err.trim().split('\n')[0] || `exit ${p.exitCode}`
2194
+ const line = err.split('\n').find((l) => STRACE_OWN_ERROR.test(l))
2195
+ if ((await p.exited) === 0) return line === undefined ? null : { line, warned: true }
2196
+ return { line: err.trim().split('\n')[0] || `exit ${p.exitCode}`, warned: false }
2141
2197
  }
2142
2198
  let warnedNoTrace = false
2143
2199
 
@@ -100,7 +100,14 @@ export interface DeniedCall {
100
100
  // a child starts in its parent's cwd as it was at the fork.
101
101
  const CHDIR_DONE_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED}\\)\\s*=\\s*0`)
102
102
  const CHDIR_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED} <unfinished`)
103
- const CHDIR_RESUMED_RE = /^(\d+)\s+<\.\.\. chdir resumed>.*?=\s*(-?\d+)/
103
+ const CHDIR_RESUMED_RE =
104
+ /^(\d+)\s+<\.\.\. chdir resumed>.*?=\s*(-?\d+)(?:\s+(ENOENT|EACCES|EPERM))?/
105
+ // A denied `chdir` is a denied read of the directory: `cd src` into a
106
+ // directory no grant holds failed with no line of the trace's own, and
107
+ // `cd src || …` passed and cached.
108
+ const CHDIR_DENIED_RE = new RegExp(
109
+ `^(\\d+)\\s+chdir\\(${QUOTED}\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
110
+ )
104
111
  const FCHDIR_RE = /^(\d+)\s+(?:fchdir\(\d+\)|<\.\.\. fchdir resumed>.*?)\s*=\s*0/
105
112
  const FORK = '(?:clone3?|v?fork)'
106
113
  const FORK_DONE_RE = new RegExp(`^(\\d+)\\s+${FORK}\\(.*\\)\\s*=\\s*(\\d+)$`)
@@ -150,6 +157,17 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
150
157
  out.push(call)
151
158
  }
152
159
  for (const line of text.split('\n')) {
160
+ // Most lines are opens that succeeded: two substring tests skip them.
161
+ const opener =
162
+ line.includes('= -1') || line.includes('<unfinished') || line.includes('resumed>')
163
+ if (!opener) {
164
+ if (
165
+ cwd !== undefined &&
166
+ (line.includes('chdir(') || line.includes('fork(') || line.includes('clone'))
167
+ )
168
+ follow(line)
169
+ continue
170
+ }
153
171
  const done = STRACE_DONE_RE.exec(line)
154
172
  if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
155
173
  denied(done[1]!, { syscall: done[2], rawPath: cStringPath(done[3]), errno: done[4] })
@@ -176,15 +194,32 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
176
194
  }
177
195
  continue
178
196
  }
179
- if (cwd === undefined) continue
197
+ // A refused or split `chdir` arrives here: its denial counts with or
198
+ // without the cwd tracking.
199
+ follow(line)
200
+ }
201
+ function follow(line: string): void {
180
202
  let m: RegExpExecArray | null
181
- if ((m = CHDIR_DONE_RE.exec(line)) !== null) opsOf(m[1]!).push({ chdir: cStringPath(m[2]!) })
182
- else if ((m = CHDIR_UNFINISHED_RE.exec(line)) !== null) chdirring.set(m[1]!, cStringPath(m[2]!))
183
- else if ((m = CHDIR_RESUMED_RE.exec(line)) !== null) {
203
+ if ((m = CHDIR_DENIED_RE.exec(line)) !== null) {
204
+ denied(m[1]!, { syscall: 'chdir', rawPath: cStringPath(m[2]!), errno: m[3]! })
205
+ return
206
+ }
207
+ if ((m = CHDIR_UNFINISHED_RE.exec(line)) !== null) {
208
+ chdirring.set(m[1]!, cStringPath(m[2]!))
209
+ return
210
+ }
211
+ if ((m = CHDIR_RESUMED_RE.exec(line)) !== null) {
184
212
  const to = chdirring.get(m[1]!)
185
213
  chdirring.delete(m[1]!)
186
214
  if (to !== undefined && m[2] === '0') opsOf(m[1]!).push({ chdir: to })
187
- } else if ((m = FCHDIR_RE.exec(line)) !== null) opsOf(m[1]!).push({ lost: true })
215
+ else if (to !== undefined && m[3] !== undefined) {
216
+ denied(m[1]!, { syscall: 'chdir', rawPath: to, errno: m[3] })
217
+ }
218
+ return
219
+ }
220
+ if (cwd === undefined) return
221
+ if ((m = CHDIR_DONE_RE.exec(line)) !== null) opsOf(m[1]!).push({ chdir: cStringPath(m[2]!) })
222
+ else if ((m = FCHDIR_RE.exec(line)) !== null) opsOf(m[1]!).push({ lost: true })
188
223
  else if ((m = FORK_DONE_RE.exec(line)) !== null) {
189
224
  forked(m[1]!, m[2]!, opsOf(m[1]!).length, line.includes('CLONE_FS'))
190
225
  } else if ((m = FORK_UNFINISHED_RE.exec(line)) !== null) {
@@ -726,14 +726,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
726
726
  return
727
727
  }
728
728
  const message = err instanceof Error ? err.message : String(err)
729
+ // After the run's stop a rejection is the stop's doing (a probe
730
+ // it killed, C-65): aborted, as any task it kills, and unsaid.
731
+ const stopped = aborted()
729
732
  const outcome: TaskOutcome = withHold({
730
733
  node,
731
- status: 'failed',
734
+ status: stopped ? 'aborted' : 'failed',
732
735
  exitCode: 1,
733
736
  durationMs: 0,
734
737
  })
735
738
  leave()
736
739
  untrack()
740
+ if (stopped) {
741
+ finishOne(id, outcome)
742
+ tick()
743
+ return
744
+ }
737
745
  // A UserError is a config/input failure (e.g. a failed
738
746
  // `cache.inputs.runtime` command), not a vx bug — report it
739
747
  // plainly, never as an "internal error".
@@ -33,6 +33,7 @@ import {
33
33
  type TaskExecutor,
34
34
  type TaskInputs,
35
35
  PersistentReadyError,
36
+ sandboxReads,
36
37
  } from '../exec/index.js'
37
38
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
38
39
  import { killGraceMs, maskedEmitter, printable, relPosix, secretMask, span } from '../util/index.js'
@@ -875,6 +876,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
875
876
  cwd: node.projectDir,
876
877
  bins: taskBinDirs(node, args.workspaceRoot),
877
878
  signal: res.signal,
879
+ hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
878
880
  })
879
881
  if (verdict !== undefined) log.taskStderr(node, `\n${verdict}\n`)
880
882
  // A committed file another task's clean removed, still gone: vx
@@ -468,6 +468,19 @@ export function formatPersistentTailBlock(
468
468
  return lines.join('\n') + '\n'
469
469
  }
470
470
 
471
+ /**
472
+ * What a kept server writes after the summary, one line at a time under its
473
+ * id (`app#dev │ Local: http://localhost:5173`), so several servers'
474
+ * logs read apart. `text` holds whole lines, its last newline cut off.
475
+ */
476
+ export function formatKeptLines(node: TaskNode, text: string, colors: ColorSupport): string {
477
+ const prefix = `${paintTaskId(node, colors)} ${paint('', '│', colors, { dim: true })} `
478
+ return text
479
+ .split('\n')
480
+ .map((line) => `${prefix}${line}\n`)
481
+ .join('')
482
+ }
483
+
471
484
  /**
472
485
  * Between the requested task's frame and the summary footer, list the
473
486
  * persistent tasks (dev servers / watchers) the run is keeping alive in
@@ -4,6 +4,7 @@ import {
4
4
  formatFailureRecap,
5
5
  formatFrameClose,
6
6
  formatFrameOpen,
7
+ formatKeptLines,
7
8
  formatPersistentTailBlock,
8
9
  formatTaskBlock,
9
10
  formatTaskExecutedLine,
@@ -318,6 +319,39 @@ export function defaultLogger(
318
319
  { node: TaskNode; outcome?: TaskOutcome; out: Tail; err: Tail }
319
320
  >()
320
321
  let flushedPersistent = false
322
+ // After that flush nothing prints a tail again, yet a kept server writes
323
+ // for as long as vx holds it: `vx run dev --all` showed none of its
324
+ // servers' logs while it ran (C-56). So a ready server's output streams
325
+ // from then on, a line at a time under its id; the partial line each
326
+ // holds waits for its newline, a second runEnd, or `settle`.
327
+ const KEPT_LINE_CAP = 64 * 1024
328
+ const keptLines = new Map<string, { node: TaskNode; rest: string }>()
329
+ const streamKept = (node: TaskNode, chunk: string): void => {
330
+ const held = keptLines.get(node.id) ?? { node, rest: '' }
331
+ keptLines.set(node.id, held)
332
+ const text = held.rest + chunk
333
+ const cut = text.lastIndexOf('\n')
334
+ if (cut < 0) {
335
+ // A line that never ends (a `\r` progress bar) is printed at the cap
336
+ // rather than held without bound.
337
+ if (text.length < KEPT_LINE_CAP) held.rest = text
338
+ else {
339
+ held.rest = ''
340
+ writer.write(fenced(formatKeptLines(node, text, colors)))
341
+ }
342
+ return
343
+ }
344
+ held.rest = text.slice(cut + 1)
345
+ writer.write(fenced(formatKeptLines(node, text.slice(0, cut), colors)))
346
+ }
347
+ const flushKept = (): void => {
348
+ for (const held of keptLines.values()) {
349
+ if (held.rest.length > 0) writer.write(fenced(formatKeptLines(held.node, held.rest, colors)))
350
+ held.rest = ''
351
+ }
352
+ }
353
+ const printsKept = (tail: { outcome?: TaskOutcome }): boolean =>
354
+ flushedPersistent && tail.outcome !== undefined && view.mode !== 'errors-only'
321
355
  // The recap's material, decided at each failure so nothing is held for a
322
356
  // task that passed. A buffered task's tail is read off the buffers the
323
357
  // frame drains; a live-streamed one keeps no buffer, so a bounded ring
@@ -411,6 +445,8 @@ export function defaultLogger(
411
445
 
412
446
  return {
413
447
  settle() {
448
+ // The bus delivers one `run:end`, so the CLI's last word reaches here.
449
+ flushKept()
414
450
  writer.settle()
415
451
  },
416
452
  failureRecap() {
@@ -483,6 +519,7 @@ export function defaultLogger(
483
519
  // like the failures below: run() calls runEnd twice on the success
484
520
  // path (once before the summary, once in its finally), and a
485
521
  // kept-alive child keeps writing between the two.
522
+ if (flushedPersistent) flushKept()
486
523
  if (!flushedPersistent) {
487
524
  flushedPersistent = true
488
525
  if (view.mode !== 'none' && view.mode !== 'errors-only') {
@@ -533,7 +570,8 @@ export function defaultLogger(
533
570
  }
534
571
  const tail = persistentTails.get(node.id)
535
572
  if (tail !== undefined) {
536
- appendTail(tail.out, chunk)
573
+ if (printsKept(tail)) streamKept(node, chunk)
574
+ else appendTail(tail.out, chunk)
537
575
  return
538
576
  }
539
577
  pushChunk(stdoutBuffers, node.id, chunk)
@@ -550,7 +588,8 @@ export function defaultLogger(
550
588
  }
551
589
  const tail = persistentTails.get(node.id)
552
590
  if (tail !== undefined) {
553
- appendTail(tail.err, chunk)
591
+ if (printsKept(tail)) streamKept(node, chunk)
592
+ else appendTail(tail.err, chunk)
554
593
  return
555
594
  }
556
595
  pushChunk(stderrBuffers, node.id, chunk)
@@ -180,9 +180,10 @@ export interface RunOptions {
180
180
  */
181
181
  signal?: AbortSignal
182
182
  /**
183
- * Hand the requested persistent tasks still running, and the persistent
184
- * tasks they depend on (C-46), back on `RunSummary.persistent` instead
185
- * of stopping them when the graph ends.
183
+ * Hand the requested persistent tasks still running, those a requested
184
+ * group stands for (C-52), and the persistent tasks they depend on
185
+ * (C-46), back on `RunSummary.persistent` instead of stopping them when
186
+ * the graph ends.
186
187
  * The caller owns them from then on and stops them with its `stop()`.
187
188
  * The watch loop sets this: a dev server stays up while watch idles and
188
189
  * is replaced only when the next cycle starts.
@@ -229,7 +230,8 @@ export interface RunOptions {
229
230
  * The raw command string for the `invocations` row (e.g.
230
231
  * `'vx run build test --all'`). When absent, run() falls back to
231
232
  * `process.argv.slice(1).join(' ')` so programmatic callers still
232
- * record something useful.
233
+ * record something useful. Telemetry sinks get it with what follows
234
+ * `--` counted, not quoted, as they get the argv (C-67).
233
235
  */
234
236
  command?: string
235
237
  /**
@@ -386,12 +386,13 @@ export function buildAdmission(
386
386
  }
387
387
 
388
388
  /**
389
- * Collect every plugin's `cache` layer in declaration order. One layer is
390
- * used as is; two or more are chained (lookup walks them, save reaches all;
391
- * see ChainedCache). A bare local layer that another declared layer already
392
- * wraps (`layer.local === ctx.localCache`) is dropped, so a remote plugin
393
- * that layers over the local handle does not also write the local store
394
- * directly. A plugin declaring nothing leaves that store, unwrapped.
389
+ * Collect every plugin's `cache` layer in declaration order, with the local
390
+ * store at the tail, and chain them (lookup walks them, save reaches all;
391
+ * see ChainedCache). The local store is dropped when a declared layer wraps
392
+ * it (`layer.local === ctx.localCache`), so a remote plugin that layers over
393
+ * the local handle does not also write it directly. Only a single layer
394
+ * left is used as is: the local store when no plugin declares one, or the
395
+ * one layer that wraps it.
395
396
  */
396
397
  export async function resolveCache(
397
398
  plugins: readonly VxPlugin[],
@@ -20,6 +20,7 @@ import {
20
20
  LayeredCache,
21
21
  applyGitEnumeration,
22
22
  gitPathspecs,
23
+ MAX_SCOPED_PATHSPECS,
23
24
  startGitEnumeration,
24
25
  lazyGitEnumeration,
25
26
  } from '../cache/index.js'
@@ -31,6 +32,7 @@ import {
31
32
  type LoadReads,
32
33
  loadWorkspace,
33
34
  FROZEN_WITHOUT_LOCK,
35
+ type Lockfile,
34
36
  readLockfile,
35
37
  resolveCacheDir,
36
38
  type ProjectEntry,
@@ -181,9 +183,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
181
183
  // config, discovery, the cache open and the config evaluation. Its
182
184
  // ~60 ms is the warm run's wall floor on a 1000-project tree; it used to
183
185
  // start after discovery and the cache open, ~25 ms later. A scoped run
184
- // waits: its pathspecs depend on which projects the configs pull in.
185
- const unscoped =
186
- options.projects === undefined && options.tasks.some((spec) => spec.indexOf('#') <= 0)
186
+ // waits: its pathspecs depend on which projects the configs pull in —
187
+ // unless it already names more projects than pathspecs scope, where the
188
+ // walk is the whole tree either way (an `--affected` run on 1,000
189
+ // projects waited ~60 ms for it after the configs loaded).
190
+ const wholeTree =
191
+ (options.projects === undefined || options.projects.length > MAX_SCOPED_PATHSPECS) &&
192
+ options.tasks.some((spec) => spec.indexOf('#') <= 0)
187
193
  // A `discover` hook that reads the worktree (nx()'s graph key) starts the
188
194
  // whole-tree enumeration a scoped run would otherwise scope later, here or
189
195
  // in the CLI's selection pass; the run reuses it instead of walking the
@@ -192,7 +198,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
192
198
  const git =
193
199
  (reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
194
200
  lazyGitEnumeration(workspaceRoot)
195
- if (unscoped) void git.start()
201
+ if (wholeTree) void git.start()
196
202
  const workspace = await loadWorkspace(workspaceRoot, reads)
197
203
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
198
204
  log.status(m),
@@ -277,10 +283,24 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
277
283
  // so consuming the lock by default would silently serve stale
278
284
  // freezes. `vx lock --check` is the full re-evaluation audit.
279
285
  // See docs/design/config-lock-2026-06.md.
280
- const lock = options.frozen === true ? await readLockfile(workspaceRoot) : null
281
- if (options.frozen === true && lock === null) {
282
- throw new UserError(FROZEN_WITHOUT_LOCK)
283
- }
286
+ // Read once, and only when a config is to be read from it: the CLI's
287
+ // selection pass may have staged every config (`options.staged`), and
288
+ // its load refused a frozen run without a lock. A 1,000-project lock is
289
+ // 1.1 MB of JSON (I-27).
290
+ let lockRead: Promise<Lockfile> | undefined
291
+ const readLock = (): Promise<Lockfile> =>
292
+ (lockRead ??= readLockfile(workspaceRoot).then((read) => {
293
+ if (read === null) throw new UserError(FROZEN_WITHOUT_LOCK)
294
+ return read
295
+ }))
296
+ const staged = options.staged
297
+ const allStaged =
298
+ staged !== undefined &&
299
+ projectMetas.every(
300
+ (m) => typeof m.configPath !== 'string' || m.configPath === '' || staged.has(m.name),
301
+ )
302
+ if (options.frozen === true && !allStaged) await readLock()
303
+ const lock = options.frozen === true ? readLock : null
284
304
 
285
305
  const loadArgs = {
286
306
  workspaceRoot,
@@ -436,7 +456,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
436
456
  workspaceRoot,
437
457
  gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
438
458
  ))
439
- applyGitEnumeration(enumeration, workspaceRoot, projectDirs, gitFilesCache, usesWorkspaceInputs)
459
+ await applyGitEnumeration(
460
+ enumeration,
461
+ workspaceRoot,
462
+ projectDirs,
463
+ gitFilesCache,
464
+ usesWorkspaceInputs,
465
+ localCache,
466
+ )
440
467
  mark('git enumeration')
441
468
  const hashCache = createHashCache()
442
469
  const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
@@ -116,8 +116,12 @@ interface LoadProjectsBase {
116
116
  * way, since the package graph cannot see the cross form.
117
117
  */
118
118
  seeds: 'all' | Iterable<string>
119
- /** Read configs from the lock instead of evaluating them (`--frozen`). */
120
- lock: Lockfile | null
119
+ /**
120
+ * Read configs from the lock instead of evaluating them (`--frozen`),
121
+ * asked only when a config is to be read: a run whose every config the
122
+ * CLI's selection pass staged never parses it again.
123
+ */
124
+ lock: (() => Promise<Lockfile>) | null
121
125
  evalCache: LoadProjectConfigOptions['evalCache']
122
126
  warn: (message: string) => void
123
127
  /**
@@ -223,7 +227,9 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
223
227
  typeof m.configPath === 'string' && args.staged?.get(m.name) === undefined,
224
228
  )
225
229
  const loaded = lock
226
- ? await frozenProjectConfigs(lock, withFile, workspaceRoot, args.evalCache?.store)
230
+ ? withFile.length === 0
231
+ ? []
232
+ : await frozenProjectConfigs(await lock(), withFile, workspaceRoot, args.evalCache?.store)
227
233
  : await loadProjectConfigs(
228
234
  withFile.map((m) => m.configPath),
229
235
  args.evalCache !== undefined ? { evalCache: args.evalCache } : {},
@@ -5,7 +5,12 @@
5
5
  import type { ProjectEntry } from '../workspace/index.js'
6
6
  import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
7
7
  import path from 'node:path'
8
- import { type CacheLayer, type CachePolicy, FULL_CACHE_POLICY } from '../cache/index.js'
8
+ import {
9
+ type CacheLayer,
10
+ type CachePolicy,
11
+ FULL_CACHE_POLICY,
12
+ stopRuntimeProbes,
13
+ } from '../cache/index.js'
9
14
  import { VERSION } from '../version.js'
10
15
  import {
11
16
  resetSandbox,
@@ -533,6 +538,7 @@ async function runOnBus(
533
538
  // ends after it is the stop's own kill, and was named a crash (item 1061).
534
539
  let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
535
540
  const onAbort = (): void => {
541
+ stopRuntimeProbes()
536
542
  endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
537
543
  aborting = terminateChildren(
538
544
  () => [...liveChildren, ...persistentRegistry.values()],
@@ -634,7 +640,9 @@ async function runOnBus(
634
640
  runContextRecord = {
635
641
  runId,
636
642
  vxVersion: VERSION,
637
- command: options.command ?? invocationCommand(process.argv.slice(1)),
643
+ // An embedder's `command` is redacted as the argv is: it passed a
644
+ // token after `--` to every sink verbatim (C-67).
645
+ command: invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
638
646
  requestedTasks: [...options.tasks],
639
647
  cachePolicy: compactCachePolicy(policy),
640
648
  concurrency,
@@ -187,13 +187,14 @@ export async function sandboxRequestFor(
187
187
  */
188
188
  cacheDir?: string,
189
189
  ): Promise<SandboxRequest> {
190
- // SRT reads a Linux path holding a bracket as a glob and drops it from
191
- // the writes, so no grant here could be mounted (B-60).
190
+ // The runtime reads a path holding a bracket as a pattern: on Linux it
191
+ // mounts no such write path (B-60); seatbelt's rules compile it as a
192
+ // character class, so vx's own workspace wall matched nothing (B-65).
192
193
  const home = toRealPath(node.projectDir)
193
- if (process.platform === 'linux' && /[[\]]/.test(home)) {
194
+ if (process.platform !== 'win32' && /[[\]]/.test(home)) {
194
195
  throw new UserError(
195
- `exec.sandbox: ${home} holds a bracket ([ or ]), and the Linux sandbox mounts no path ` +
196
- `that does — rename the directory, or run the task without exec.sandbox`,
196
+ `exec.sandbox: ${home} holds a bracket ([ or ]), which the sandbox runtime reads as a ` +
197
+ `pattern, not a name — rename the directory, or run the task without exec.sandbox`,
197
198
  )
198
199
  }
199
200
  const depDirs = [
@@ -23,13 +23,22 @@ export function shellVerdict(args: {
23
23
  bins: string[]
24
24
  /** The signal that killed the child when the runner saw one (`RunResult.signal`). */
25
25
  signal?: string | undefined
26
+ /** A sandboxed task's view: whether a file is hidden from it (no grant reads it). */
27
+ hidden?: ((file: string) => boolean) | undefined
26
28
  }): string | undefined {
27
29
  if (args.code !== 127 && args.code !== 126) return signalVerdict(args.code, args.signal)
28
30
  const word = execWord(args.command)
29
31
  const what = word ?? 'a command in this task'
30
32
  if (word !== undefined && word.includes('/')) {
31
33
  const file = path.resolve(args.cwd, word)
32
- return `[vx] exit ${args.code} is the shell's "${args.code === 127 ? 'not found' : 'cannot execute'}": ${word} ${fileVerdict(file)}`
34
+ // The file the host has may be one the sandbox does not: then the
35
+ // shell's "not found" is the sandbox's, and the file's own state (its
36
+ // `#!` line) is not the reason.
37
+ const verdict =
38
+ existsSync(file) && args.hidden?.(file) === true
39
+ ? `exists, but no sandbox grant reads it, so inside the sandbox it is not there — add it (or its directory) to exec.sandbox.allow.read`
40
+ : fileVerdict(file)
41
+ return `[vx] exit ${args.code} is the shell's "${args.code === 127 ? 'not found' : 'cannot execute'}": ${word} ${verdict}`
33
42
  }
34
43
  if (args.code === 126) {
35
44
  return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`