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