@vzn/vx 0.0.597 → 0.0.599

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.
@@ -65,13 +65,16 @@ export function closeSignalChannel(child: Child): void {
65
65
  let guardFd: number | null | undefined
66
66
 
67
67
  // Three lists: groups (`+`/`-`), single pids (`=`/`_`) and paths to
68
- // remove (`@`/`!`), killed and removed in that order at EOF.
68
+ // remove (`@`/`!`), killed and removed in that order at EOF. A
69
+ // group is listed once however often its line arrives: a `-` strikes
70
+ // one entry, and a shell that keeps a failed printf buffered (bash as
71
+ // macOS's sh, B-10) sends the line again with its retry.
69
72
  const GUARD_SCRIPT = [
70
73
  "g=' ' k=' ' f=' '",
71
74
  'while IFS= read -r l; do',
72
75
  ' p=${l#?}',
73
76
  ' case $l in',
74
- ' +*) g="$g$p " ;;',
77
+ ' +*) case $g in *" $p "*) ;; *) g="$g$p " ;; esac ;;',
75
78
  ' -*) case $g in *" $p "*) g="${g%% $p *} ${g#* $p }" ;; esac ;;',
76
79
  ' =*) k="$k$p " ;;',
77
80
  ' _*) case $k in *" $p "*) k="${k%% $p *} ${k#* $p }" ;; esac ;;',
@@ -168,18 +171,45 @@ function guardWrite(line: string): void {
168
171
  */
169
172
  export function spawnGuarded(spawn: (guard: number | undefined) => Child): Child {
170
173
  startGuard()
171
- return spawn(typeof guardFd === 'number' ? guardFd : undefined)
174
+ if (typeof guardFd !== 'number') return spawn(undefined)
175
+ handingTo = guardProc?.pid
176
+ try {
177
+ return spawn(guardFd)
178
+ } finally {
179
+ handingTo = undefined
180
+ }
172
181
  }
173
182
 
183
+ /**
184
+ * The guard whose pipe `spawnGuarded` is handing over right now. Only a
185
+ * line built for that hand-over retries: its pipe is broken only once
186
+ * the guard has exited, so `kill -0` ends the wait. A line for any other
187
+ * pipe would wait on a guard that has nothing to do with it.
188
+ */
189
+ let handingTo: number | undefined
190
+
174
191
  /**
175
192
  * The shell line that lists `$$`'s group on the guard's pipe at `fd` and
176
193
  * closes it, so the task never holds the guard open. `$$` is the spawned
177
194
  * shell, which leads its group (every guarded spawn is `detached`); a
178
195
  * program that is not a shell is `exec`'d after it. A write to a guard
179
196
  * that has died is ignored rather than a SIGPIPE that kills the task.
197
+ * The pipe is vx's own nonblocking one, so a full queue fails the write
198
+ * (EAGAIN) and the group went unlisted: the write is retried while the
199
+ * guard lives. The shell cannot tell EAGAIN from EPIPE, so the guard's
200
+ * pid ends the wait, and vx SIGKILLs a guard it gives up on.
180
201
  */
181
202
  export function guardLine(fd: number): string {
182
- return `trap '' PIPE; printf '+%s\\n' $$ >&${fd} 2>/dev/null; trap - PIPE; exec ${fd}>&-; `
203
+ const add = `printf '+%s\\n' $$ >&${fd} 2>/dev/null`
204
+ // `kill -0` counts a zombie, and a guard vx has not reaped (its event
205
+ // loop busy, or an init that never reaps an orphan after vx's kill -9)
206
+ // kept the shell spinning. Where /proc is ours, its state says dead;
207
+ // `##*) ` cuts at the LAST ')', as comm may hold one.
208
+ const alive = procfsIsOwn()
209
+ ? `{ read -r vx_g </proc/${handingTo}/stat; } 2>/dev/null && case "\${vx_g##*) }" in [ZX]*) false ;; esac`
210
+ : `kill -0 ${handingTo} 2>/dev/null`
211
+ const retry = handingTo === undefined ? '' : ` || until ${add}; do ${alive} || break; done`
212
+ return `trap '' PIPE; ${add}${retry}; trap - PIPE; exec ${fd}>&-; `
183
213
  }
184
214
 
185
215
  /**
@@ -4,7 +4,8 @@
4
4
  // process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
5
5
  // folded into the v11 `runs` table by the orchestrator.
6
6
 
7
- import { existsSync, readFileSync } from 'node:fs'
7
+ import type { Pointer } from 'bun:ffi'
8
+ import { closeSync, existsSync, readFileSync } from 'node:fs'
8
9
  import { constants as osConstants } from 'node:os'
9
10
  import {
10
11
  shellArgv,
@@ -98,11 +99,134 @@ export interface RunOptions {
98
99
  terminal?: boolean
99
100
  }
100
101
 
102
+ type Fd = 'inherit' | 'ignore' | 'pipe' | number
103
+
101
104
  /** A child's descriptors 0–2: vx's own for a task that holds the terminal. */
102
- function taskStdio(terminal: boolean | undefined, stdin: 'ignore' | 'pipe') {
103
- return terminal === true
104
- ? (['inherit', 'inherit', 'inherit'] as const)
105
- : ([stdin, 'pipe', 'pipe'] as const)
105
+ function taskStdio(
106
+ terminal: boolean | undefined,
107
+ stdin: 'ignore' | 'pipe',
108
+ pipes: TaskPipes,
109
+ ): [Fd, Fd, Fd] {
110
+ return terminal === true ? ['inherit', 'inherit', 'inherit'] : [stdin, ...pipes.stdio]
111
+ }
112
+
113
+ const O_NONBLOCK = 0o4000
114
+ const O_CLOEXEC = 0o2000000
115
+ const F_SETFL = 4
116
+ const F_SETPIPE_SZ = 1031
117
+ /**
118
+ * A pipe holds 64 KiB by default, and a 200 MB stdout drained 13% slower
119
+ * through one than through Bun's socketpair (min of 7: 293 vs 259 ms);
120
+ * at 256 KiB it drained in 230. Past the user's pipe quota the kernel
121
+ * refuses the resize and the pipe keeps its 64 KiB.
122
+ */
123
+ const PIPE_BYTES = 256 * 1024
124
+
125
+ type PipeCalls = {
126
+ pipe2: (fds: Pointer, flags: number) => number
127
+ fcntl: (fd: number, cmd: number, arg: number) => number
128
+ ptr: (view: Int32Array, byteOffset?: number) => Pointer
129
+ }
130
+ let pipeCalls: PipeCalls | null | undefined
131
+
132
+ /** libc's `pipe2` and `fcntl`, bound once; null off Linux or where libc will not load. */
133
+ function linuxPipeCalls(): PipeCalls | null {
134
+ if (pipeCalls !== undefined) return pipeCalls
135
+ pipeCalls = null
136
+ if (process.platform !== 'linux') return null
137
+ try {
138
+ // Loaded at the first spawn, not with the module: a static import cost
139
+ // every vx start 0.3 ms, a warm run that spawns nothing included.
140
+ const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
141
+ // glibc's soname; musl's loader answers any `libc.` name with itself
142
+ // (ldso/dynlink.c's reserved names).
143
+ const { symbols } = dlopen('libc.so.6', {
144
+ pipe2: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
145
+ fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
146
+ })
147
+ pipeCalls = { pipe2: symbols.pipe2, fcntl: symbols.fcntl, ptr }
148
+ } catch {
149
+ // Bun's socketpair, as before: only `/dev/stdout` by path fails on it.
150
+ }
151
+ return pipeCalls
152
+ }
153
+
154
+ /**
155
+ * Two pipes, `[out read, out write, err read, err write]`, or undefined
156
+ * when libc is not bound or a descriptor is not to be had (Bun's own
157
+ * socketpair then fails the spawn with the reason).
158
+ */
159
+ function realPipes(): Int32Array | undefined {
160
+ const calls = linuxPipeCalls()
161
+ if (calls === null) return undefined
162
+ const fds = new Int32Array(4)
163
+ // Bun's spawn closes every descriptor it is not handed (probed, Bun
164
+ // 1.4.2); O_CLOEXEC keeps any other exec from holding a write end open.
165
+ if (calls.pipe2(calls.ptr(fds), O_CLOEXEC) !== 0) return undefined
166
+ if (calls.pipe2(calls.ptr(fds, 8), O_CLOEXEC) !== 0) {
167
+ closeSync(fds[0]!)
168
+ closeSync(fds[1]!)
169
+ return undefined
170
+ }
171
+ // vx's ends only: a task whose stdout is non-blocking fails its writes with EAGAIN.
172
+ calls.fcntl(fds[0]!, F_SETFL, O_NONBLOCK)
173
+ calls.fcntl(fds[2]!, F_SETFL, O_NONBLOCK)
174
+ calls.fcntl(fds[0]!, F_SETPIPE_SZ, PIPE_BYTES)
175
+ calls.fcntl(fds[2]!, F_SETPIPE_SZ, PIPE_BYTES)
176
+ return fds
177
+ }
178
+
179
+ /**
180
+ * A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
181
+ * opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
182
+ * (ENXIO): `echo x > /dev/stdout` and `cmd | tee /dev/stderr` failed in
183
+ * every task, where a terminal or Turbo hands real pipes (X-113). So on
184
+ * Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
185
+ * the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
186
+ * which a socket allows, so Bun's pipe stays there.
187
+ */
188
+ export class TaskPipes {
189
+ /** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
190
+ readonly stdio: readonly [number | 'pipe', number | 'pipe']
191
+ readonly #read: readonly number[]
192
+
193
+ constructor(terminal?: boolean) {
194
+ const fds = terminal === true ? undefined : realPipes()
195
+ this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
196
+ this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
197
+ }
198
+
199
+ /**
200
+ * Close vx's copies of the write ends once the spawn has returned or
201
+ * thrown: the child holds its own, and one left here is a writer the
202
+ * reader's EOF would wait for.
203
+ */
204
+ spawned(): void {
205
+ for (const fd of this.stdio) if (typeof fd === 'number') closeSync(fd)
206
+ }
207
+
208
+ /** The child's stdout and stderr. */
209
+ streams(
210
+ child: ReturnType<typeof Bun.spawn>,
211
+ ): [
212
+ ReadableStream<Uint8Array> | number | undefined,
213
+ ReadableStream<Uint8Array> | number | undefined,
214
+ ] {
215
+ const [out, err] = this.#read
216
+ return out === undefined
217
+ ? [child.stdout, child.stderr]
218
+ : [Bun.file(out).stream(), Bun.file(err!).stream()]
219
+ }
220
+
221
+ /**
222
+ * Close the read ends, once both streams have ended or been cancelled.
223
+ * Bun reads a dup of each, but takes it on the stream's first pull: a
224
+ * number closed before then was reused by the next spawn's pipe, and
225
+ * the stream read THAT (probed, Bun 1.4.2).
226
+ */
227
+ close(): void {
228
+ for (const fd of this.#read) closeSync(fd)
229
+ }
106
230
  }
107
231
 
108
232
  /**
@@ -598,6 +722,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
598
722
  const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
599
723
 
600
724
  let child: ReturnType<typeof Bun.spawn>
725
+ const pipes = new TaskPipes(opts.terminal)
601
726
  const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
602
727
  try {
603
728
  const signalFd = opts.signalChannel === true
@@ -619,7 +744,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
619
744
  // The one-shot spawn below keeps 'ignore', so a task that reads
620
745
  // stdin can never hang CI.
621
746
  stdio: [
622
- ...taskStdio(opts.terminal, 'pipe'),
747
+ ...taskStdio(opts.terminal, 'pipe', pipes),
623
748
  ...(signalFd ? ['pipe' as const] : []),
624
749
  ...(guard === undefined ? [] : [guard]),
625
750
  ],
@@ -637,6 +762,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
637
762
  void spawned.exited.then(() => closeSignalChannel(spawned))
638
763
  }
639
764
  } catch (err) {
765
+ pipes.close()
640
766
  const message = err instanceof Error ? err.message : String(err)
641
767
  return {
642
768
  child: undefined as unknown as ReturnType<typeof Bun.spawn>,
@@ -645,6 +771,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
645
771
  ),
646
772
  readyMs: () => Date.now() - start,
647
773
  }
774
+ } finally {
775
+ pipes.spawned()
648
776
  }
649
777
 
650
778
  let resolveReady!: () => void
@@ -738,8 +866,10 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
738
866
 
739
867
  // Wire up readers. We deliberately don't await them — they run for
740
868
  // the child's lifetime. The `ready` promise resolves out-of-band.
741
- void consumeChunks(child.stdout, false)
742
- void consumeChunks(child.stderr, true)
869
+ const [stdout, stderr] = pipes.streams(child)
870
+ void Promise.all([consumeChunks(stdout, false), consumeChunks(stderr, true)]).finally(() =>
871
+ pipes.close(),
872
+ )
743
873
 
744
874
  opts.liveChildren?.add(child)
745
875
  opts.onSpawn?.(child.pid)
@@ -834,6 +964,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
834
964
  const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
835
965
 
836
966
  let proc: ReturnType<typeof Bun.spawn>
967
+ const pipes = new TaskPipes(opts.terminal)
837
968
  const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
838
969
  try {
839
970
  proc = spawnGuarded((guard) =>
@@ -841,7 +972,10 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
841
972
  ...SH_ARGV0,
842
973
  cwd: opts.cwd,
843
974
  env: opts.env as Record<string, string>,
844
- stdio: [...taskStdio(opts.terminal, 'ignore'), ...(guard === undefined ? [] : [guard])],
975
+ stdio: [
976
+ ...taskStdio(opts.terminal, 'ignore', pipes),
977
+ ...(guard === undefined ? [] : [guard]),
978
+ ],
845
979
  // Its own session and process group, so a kill reaches what it
846
980
  // forked (kill-tree.ts). A terminal handed over is not this
847
981
  // session's controlling one, so no read stops the group.
@@ -849,19 +983,23 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
849
983
  }),
850
984
  )
851
985
  } catch (err) {
986
+ pipes.close()
852
987
  const stderr = spawnFailureText(err, opts.cwd)
853
988
  opts.onStderr?.(stderr)
854
989
  return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr, spawnFailed: true }
990
+ } finally {
991
+ pipes.spawned()
855
992
  }
856
993
 
857
994
  opts.liveChildren?.add(proc)
858
995
  opts.onSpawn?.(proc.pid)
859
996
  const timeout = armTimeout(proc, opts.timeoutMs)
860
997
  const ac = new AbortController()
998
+ const [out, err] = pipes.streams(proc)
861
999
  const streams = Promise.all([
862
- streamToString(proc.stdout, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
863
- streamToString(proc.stderr, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
864
- ])
1000
+ streamToString(out, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
1001
+ streamToString(err, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
1002
+ ]).finally(() => pipes.close())
865
1003
  // Gate on the child's own exit, not on stream EOF: an orphaned grandchild
866
1004
  // can keep the pipe open past the child's exit (a timeout SIGTERM leaves the
867
1005
  // grandchild alive; a NORMAL exit of `server & echo up` does too), so EOF may
@@ -55,6 +55,7 @@ import {
55
55
  spawnFailureText,
56
56
  streamToString,
57
57
  resourceUsageToCpuRss,
58
+ TaskPipes,
58
59
  type CaptureConfig,
59
60
  type RunResult,
60
61
  } from './runner.js'
@@ -1281,6 +1282,11 @@ async function wrapIn(
1281
1282
  customConfig!.filesystem!.denyRead!.push(baselines.cwd)
1282
1283
  }
1283
1284
  customConfig!.filesystem!.allowWrite!.push(toRealPath(tmp))
1285
+ // Seatbelt judges `/dev/stdout` by the path the kernel resolves it to,
1286
+ // `/dev/fd/1`, which SRT's default `/dev/stdout` grant does not name, so
1287
+ // `echo x > /dev/stdout` failed EPERM (X-113). `/dev/fd/N` reopens only a
1288
+ // descriptor the task already holds.
1289
+ if (process.platform === 'darwin') customConfig!.filesystem!.allowWrite!.push('/dev/fd')
1284
1290
  if (scopedDenyScan) {
1285
1291
  customConfig!.filesystem!.denyWrite!.push(
1286
1292
  ...scopedMandatoryDenies(
@@ -1545,7 +1551,9 @@ function literalReadPaths(
1545
1551
  * baseline reads: a linked dependency under `packages/[legacy]/` was a
1546
1552
  * class too, and as an exact regex even `[[]` grants the directory's entry
1547
1553
  * and none of its files, so its subtree is granted beside it (a trailing
1548
- * `/**` is stripped before the compile).
1554
+ * `/**` is stripped before the compile). A task's read grant whose only
1555
+ * brackets are escaped names one path too, and Linux binds it whole, so
1556
+ * it gets the same subtree: `out/\[id\]` read `out/[id]` and none of it.
1549
1557
  */
1550
1558
  export function seatbeltBrackets(
1551
1559
  config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
@@ -1556,8 +1564,9 @@ export function seatbeltBrackets(
1556
1564
  const literal = (p: string): string => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']')
1557
1565
  const bracketed = new Set(names.filter((n) => /[[\]]/.test(n)))
1558
1566
  const read = (p: string): string[] => {
1559
- if (!bracketed.has(p)) return [literal(p)]
1560
- const at = p.replaceAll('[', '[[]')
1567
+ const name = bracketed.has(p) ? p : /\\[[\]]/.test(p) ? namedPath(p) : undefined
1568
+ if (name === undefined) return [literal(p)]
1569
+ const at = name.replaceAll('[', '[[]')
1561
1570
  return [at, `${at}/**/*`]
1562
1571
  }
1563
1572
  return {
@@ -1566,7 +1575,9 @@ export function seatbeltBrackets(
1566
1575
  ...fs,
1567
1576
  denyRead: fs.denyRead.map((p) => p.replaceAll('[', '[[]')),
1568
1577
  allowWrite: fs.allowWrite.map(literal),
1569
- ...(fs.allowRead !== undefined ? { allowRead: fs.allowRead.flatMap(read) } : {}),
1578
+ ...(fs.allowRead !== undefined
1579
+ ? { allowRead: [...new Set(fs.allowRead.flatMap(read))] }
1580
+ : {}),
1570
1581
  },
1571
1582
  }
1572
1583
  }
@@ -1977,6 +1988,7 @@ async function runSandboxedOnce(
1977
1988
  if (straceLog) unlinkOnExit(straceLog)
1978
1989
  let proc: ReturnType<typeof Bun.spawn>
1979
1990
  let traceFd: number | undefined
1991
+ const pipes = new TaskPipes()
1980
1992
  try {
1981
1993
  // Resolved on vx's own PATH (util/which.ts), not the task's, where a
1982
1994
  // project's node_modules/.bin comes first.
@@ -1991,8 +2003,7 @@ async function runSandboxedOnce(
1991
2003
  env: args.env as Record<string, string>,
1992
2004
  stdio: [
1993
2005
  'ignore',
1994
- 'pipe',
1995
- 'pipe',
2006
+ ...pipes.stdio,
1996
2007
  forwardsSignals ? 'pipe' : 'ignore',
1997
2008
  guard ?? 'ignore',
1998
2009
  ...(traceFd === undefined ? [] : [traceFd]),
@@ -2003,6 +2014,7 @@ async function runSandboxedOnce(
2003
2014
  )
2004
2015
  if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
2005
2016
  } catch (err) {
2017
+ pipes.close()
2006
2018
  if (straceLog) {
2007
2019
  rmSync(straceLog, { force: true })
2008
2020
  liveTempFiles.delete(straceLog)
@@ -2025,6 +2037,7 @@ async function runSandboxedOnce(
2025
2037
  } finally {
2026
2038
  // The child holds its own copy; ours would keep nothing but a descriptor.
2027
2039
  if (traceFd !== undefined) closeSync(traceFd)
2040
+ pipes.spawned()
2028
2041
  }
2029
2042
 
2030
2043
  args.liveChildren?.add(proc)
@@ -2035,10 +2048,11 @@ async function runSandboxedOnce(
2035
2048
  // The unfinished last line of stderr, and whether a line was strace's.
2036
2049
  let partial = ''
2037
2050
  let straceSpoke = false
2051
+ const [out, err] = pipes.streams(proc)
2038
2052
  const streams = Promise.all([
2039
- streamToString(proc.stdout, args.onStdout, ac.signal, args.capture?.stdout ?? true),
2053
+ streamToString(out, args.onStdout, ac.signal, args.capture?.stdout ?? true),
2040
2054
  streamToString(
2041
- proc.stderr,
2055
+ err,
2042
2056
  (chunk) => {
2043
2057
  // Read whatever the capture setting: a line of strace's own says
2044
2058
  // the trace stopped short.
@@ -2050,7 +2064,7 @@ async function runSandboxedOnce(
2050
2064
  ac.signal,
2051
2065
  args.capture?.stderr ?? true,
2052
2066
  ),
2053
- ])
2067
+ ]).finally(() => pipes.close())
2054
2068
  // See runCommand: gate on child exit; a lingering grandchild pipe (timeout
2055
2069
  // OR a clean exit that backgrounds a process) can't hang the run — timeout
2056
2070
  // aborts at once, otherwise drainOrAbort bounds the post-exit drain.
@@ -152,8 +152,7 @@ export function mergePriorities(
152
152
  // it sorts above the baseline for any node it covers, and add the
153
153
  // baseline as a tie-break for parity within the override set.
154
154
  const SCALE = 1 << 20
155
- const out = new Map<string, number>()
156
- for (const [id, w] of baseline) out.set(id, w)
155
+ const out = new Map(baseline)
157
156
  for (const [id, w] of overrides) {
158
157
  const b = baseline.get(id) ?? 0
159
158
  out.set(id, w * SCALE + b)
package/src/index.ts CHANGED
@@ -34,7 +34,9 @@ export { isLiteralPattern, normalizeGlob } from './util/index.js'
34
34
  // A task's command as vx prints it: values of its `exec.env.secret` names
35
35
  // masked. `vx show` and telemetry show this; a plugin that hands a command
36
36
  // to someone else (`@vzn/vx-mcp`'s `listTasks`, an AI agent) does too (L-26).
37
- export { maskedCommand } from './util/index.js'
37
+ // `maskedLine` is the same for a line no one task owns: the error `vx mcp`
38
+ // hands an agent (L-11).
39
+ export { maskedCommand, maskedLine } from './util/index.js'
38
40
 
39
41
  // Schema types and helpers (used by user vx.config files and presets).
40
42
  export type {
@@ -16,6 +16,7 @@ import {
16
16
  isUnsupportedBun,
17
17
  machineMemoryBytes,
18
18
  machineParallelism,
19
+ maskedLine,
19
20
  relPosix,
20
21
  } from '../util/index.js'
21
22
  import { VERSION } from '../version.js'
@@ -397,7 +398,8 @@ async function countLoadableTasks(
397
398
  : raw
398
399
  errors.push({
399
400
  path: relPosix(root, where),
400
- message,
401
+ // A config's own throw is its author's text, a secret too (L-11).
402
+ message: maskedLine(message),
401
403
  })
402
404
  }
403
405
  }),
@@ -462,7 +462,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
462
462
  const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
463
463
  const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
464
464
  log.status(
465
- `vx: ${node.id} not ready after ${after}: waiting for a line matching /${readyWhen}/ (readyWhen)${unbounded}`,
465
+ `vx: ${node.id} not ready after ${after}: waiting for a line matching /${serverSecrets?.mask(readyWhen) ?? readyWhen}/ (readyWhen)${unbounded}`,
466
466
  )
467
467
  }, noticeMs)
468
468
  if (notice !== undefined) {
@@ -531,7 +531,12 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
531
531
  return {
532
532
  node,
533
533
  status: 'failed',
534
- exitCode: ready?.reason === 'timeout' ? await spawn.child.exited : (ready?.exitCode ?? 1),
534
+ // A server that traps the TERM and exits 0 still timed out, as a
535
+ // one-shot task that does so reports 143 (X-116).
536
+ exitCode:
537
+ ready?.reason === 'timeout'
538
+ ? (await spawn.child.exited) || signalExitCode('SIGTERM')
539
+ : (ready?.exitCode ?? 1),
535
540
  durationMs: spawn.readyMs(),
536
541
  ...(ready !== undefined ? { notReady: ready.reason } : {}),
537
542
  wallclockStartNs,
@@ -867,6 +872,24 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
867
872
  for (const c of captured)
868
873
  if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
869
874
  c.hash = MASKED + c.hash
875
+ // A runtime probe's row is named by its command, which a TS config may
876
+ // build from `process.env`: the run history stored it and `vx why`
877
+ // printed it (L-11).
878
+ const nameSecrets =
879
+ captured.length > 0 ? secretMask([process.env, env, step.env?.define], step.env?.secret) : null
880
+ if (nameSecrets !== null) {
881
+ // Two probes that differ only by a secret mask to one name, and a row
882
+ // is stored once per name: numbered, the second's change still shows.
883
+ const seen = new Set(captured.map((c) => `${c.kind}\0${c.name}`))
884
+ for (const c of captured) {
885
+ const masked = nameSecrets.mask(c.name)
886
+ if (masked === c.name) continue
887
+ let name = masked
888
+ for (let n = 2; seen.has(`${c.kind}\0${name}`); n++) name = `${masked} (${n})`
889
+ seen.add(`${c.kind}\0${name}`)
890
+ c.name = name
891
+ }
892
+ }
870
893
  const inputs: TaskInputs | undefined = described?.inputs
871
894
  const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
872
895
  // A plugin may keep the request past the run (the cache closed): it gets
@@ -1493,7 +1516,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1493
1516
  ? {
1494
1517
  // vx's own notes ride with the lines and are no denial (B-20).
1495
1518
  sandboxViolations: finalViolations.filter((v) => v.hint !== true).length,
1496
- sandboxViolationLines: finalViolations.map((v) => v.line),
1519
+ // A denied path is the task's own spelling, a secret in it too.
1520
+ sandboxViolationLines: finalViolations.map((v) => secrets?.mask(v.line) ?? v.line),
1497
1521
  }
1498
1522
  : {}),
1499
1523
  }
@@ -130,7 +130,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
130
130
  // set that shrank would otherwise vouch for a tree it never saw,
131
131
  // and the failure it would let through is a skipped restore.
132
132
  const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
133
- setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
133
+ setKnown = covers && (await args.cache.outputDirsCurrent(node.projectDir, dirRows))
134
134
  endDirs()
135
135
  }
136
136
  let treeMatches: boolean
@@ -71,6 +71,7 @@ interface Digests {
71
71
 
72
72
  interface Memo {
73
73
  version: number
74
+ claimant: string
74
75
  lock: string
75
76
  /** `extraFiles` → content hash when the digests were computed. */
76
77
  extras?: Record<string, string>
@@ -120,6 +121,11 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
120
121
  const { file, digest, version } = options
121
122
  const scope = options.scope ?? 'project'
122
123
  const part = options.part ?? 'deps'
124
+ // `version` is one claimant's counter, and another claimant of the file
125
+ // at the same number read this one's memo: one that named no extra file
126
+ // left the other's patch edits out of its key (X-130). The functions'
127
+ // source stands for the claimant; a rebuild that changes it is a miss.
128
+ const claimant = xxh3hex([part, String(digest), String(options.extraFiles ?? '')].join('\0'))
123
129
  if (scope !== 'project' && scope !== 'workspace') {
124
130
  throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
125
131
  }
@@ -159,7 +165,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
159
165
  const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
160
166
  // The extra files the last digest read, and their hashes now: a warm run
161
167
  // re-hashes the files the memo names and never parses to learn them.
162
- const memo = await readMemo(memoFile, version, lockHash)
168
+ const memo = await readMemo(memoFile, version, claimant, lockHash)
163
169
  let extras: Record<string, string> = {}
164
170
  let extraStats: Record<string, string> = {}
165
171
  let importers: ReadonlyMap<string, string> | undefined
@@ -182,8 +188,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
182
188
  ? new Map<string, string>()
183
189
  : await computeAndMemo(
184
190
  memoFile,
185
- version,
186
- lockHash,
191
+ { version, claimant, lock: lockHash },
187
192
  extras,
188
193
  digest(text, new Map(Object.entries(extras))),
189
194
  )
@@ -273,11 +278,14 @@ function importerOf(workspaceRoot: string, projectDir: string): string {
273
278
  async function readMemo(
274
279
  memoFile: string,
275
280
  version: number,
281
+ claimant: string,
276
282
  lock: string,
277
283
  ): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
278
284
  try {
279
285
  const memo = (await Bun.file(memoFile).json()) as Memo
280
- if (memo.version !== version || memo.lock !== lock) return undefined
286
+ if (memo.version !== version || memo.claimant !== claimant || memo.lock !== lock) {
287
+ return undefined
288
+ }
281
289
  return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
282
290
  } catch {
283
291
  return undefined
@@ -286,12 +294,11 @@ async function readMemo(
286
294
 
287
295
  async function computeAndMemo(
288
296
  memoFile: string,
289
- version: number,
290
- lock: string,
297
+ identity: Pick<Memo, 'version' | 'claimant' | 'lock'>,
291
298
  extras: Record<string, string>,
292
299
  importers: ReadonlyMap<string, string>,
293
300
  ): Promise<ReadonlyMap<string, string>> {
294
- const memo: Memo = { version, lock, extras, importers: Object.fromEntries(importers) }
301
+ const memo: Memo = { ...identity, extras, importers: Object.fromEntries(importers) }
295
302
  // Write-then-rename: a reader never sees a half-written memo, and two
296
303
  // concurrent runs each land a whole one.
297
304
  const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
@@ -518,9 +518,11 @@ export function defaultLogger(
518
518
  killStatus()
519
519
  // Persistent tails first: they are context for whatever ran against
520
520
  // the server, so they read above the failures they explain. `none`
521
- // and `errors-only` state their contracts absolutely (no per-task
522
- // output / only failed tasks print), so they stay silent — the tail
523
- // is still captured and still bounded, just never printed. Guarded
521
+ // prints no per-task output and `errors-only` only failed tasks: a
522
+ // server that died on its own before the run stopped it is failed by
523
+ // now (run.ts sets its outcome in place), and under `errors-only` its
524
+ // tail was dropped with the healthy ones', so the crash read only as a
525
+ // count (WD-25). The rest is still captured and bounded. Guarded
524
526
  // like the failures below: run() calls runEnd twice on the success
525
527
  // path (once before the summary, once in its finally), and though the
526
528
  // bus delivers `run:end` once (`busLogger`), a renderer an embedder
@@ -528,12 +530,13 @@ export function defaultLogger(
528
530
  if (flushedPersistent) flushKept()
529
531
  if (!flushedPersistent) {
530
532
  flushedPersistent = true
531
- if (view.mode !== 'none' && view.mode !== 'errors-only') {
533
+ if (view.mode !== 'none') {
532
534
  for (const t of persistentTails.values()) {
533
535
  // No outcome means the task never completed — it is still
534
536
  // pre-ready, so there is no "since ready" window to render and
535
537
  // its buffered output belongs to the frame it never got.
536
538
  if (t.outcome === undefined) continue
539
+ if (view.mode === 'errors-only' && t.outcome.status !== 'failed') continue
537
540
  emitBlock(
538
541
  fenced(
539
542
  formatPersistentTailBlock(
@@ -10,7 +10,7 @@
10
10
  import path from 'node:path'
11
11
  import { ChainedCache, type CacheLayer } from '../cache/index.js'
12
12
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
13
- import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
13
+ import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
14
14
  import type { ProjectConfig, WorkspaceConfig, WorkspaceRules } from '../config.js'
15
15
  import { checkGraph, type TaskNode } from '../graph/index.js'
16
16
  import type {
@@ -86,8 +86,13 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
86
86
  try {
87
87
  return await fn()
88
88
  } catch (err) {
89
+ // Masked here, not by whoever prints it: an embedder of `run()` and
90
+ // `vx mcp` print it as they get it, and the throw quotes what the
91
+ // plugin was told (a remote's reply, a header) (L-11).
89
92
  throw new UserError(
90
- `plugin '${plugin.name}' failed in ${hook}: ${err instanceof Error ? err.message : String(err)}`,
93
+ maskedLine(
94
+ `plugin '${plugin.name}' failed in ${hook}: ${err instanceof Error ? err.message : String(err)}`,
95
+ ),
91
96
  )
92
97
  }
93
98
  }