@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.
- package/launcher.cjs +9 -2
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +18 -7
- package/src/cache/cache.ts +137 -101
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +33 -17
- package/src/cache/inputs.ts +157 -54
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +4 -2
- package/src/cli/cache.ts +4 -16
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +56 -6
- package/src/cli/help.ts +27 -8
- package/src/cli/info.ts +4 -6
- package/src/cli/init.ts +4 -4
- package/src/cli/last.ts +49 -20
- package/src/cli/lock.ts +3 -3
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +80 -50
- package/src/cli/select.ts +44 -5
- package/src/cli/show.ts +47 -9
- package/src/cli/upgrade.ts +2 -3
- package/src/cli/watch-filter.ts +21 -7
- package/src/cli/watch-fs.ts +32 -8
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +40 -15
- package/src/cli/why.ts +4 -13
- package/src/cli/workspace-config.ts +1 -1
- package/src/config.ts +5 -5
- package/src/exec/env.ts +8 -5
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +126 -47
- package/src/exec/sandbox-deny-scan.ts +2 -2
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +89 -23
- package/src/exec/sandbox-violations.ts +23 -9
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +14 -4
- package/src/graph/task-graph.ts +3 -2
- package/src/index.ts +9 -0
- package/src/orchestrator/affected-tasks.ts +34 -10
- package/src/orchestrator/doctor.ts +2 -1
- package/src/orchestrator/execute-task.ts +43 -10
- package/src/orchestrator/hit-restore.ts +9 -5
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +45 -49
- package/src/orchestrator/logger.ts +4 -1
- package/src/orchestrator/miss-save.ts +10 -3
- package/src/orchestrator/placement.ts +17 -3
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +41 -3
- package/src/orchestrator/plugin.ts +11 -14
- package/src/orchestrator/projects.ts +18 -9
- package/src/orchestrator/run-context.ts +2 -2
- package/src/orchestrator/run-lock.ts +14 -4
- package/src/orchestrator/run.ts +94 -16
- package/src/orchestrator/sandbox-request.ts +6 -10
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +14 -1
- package/src/util/index.ts +7 -1
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +47 -19
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-eval.ts +4 -0
- package/src/workspace/config-imports.ts +2 -2
- package/src/workspace/config-schema.ts +47 -10
- package/src/workspace/filter.ts +5 -4
- package/src/workspace/migrate-scripts.ts +23 -6
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +89 -2
- package/src/workspace/workspace.ts +121 -14
package/src/exec/runner.ts
CHANGED
|
@@ -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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* `${#x}` are words), and a newline closes
|
|
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
|
|
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 (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
200
|
-
|
|
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 =
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
82
|
-
*
|
|
83
|
-
* grant
|
|
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
|
-
|
|
98
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
1624
|
-
|
|
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
|
-
|
|
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[],
|