@vzn/vx 0.0.18 → 0.0.21
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/LICENSE +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/exec/executor.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// The per-task execution contract. `execute-task.ts` decides WHAT to run
|
|
2
2
|
// (command, env, sandbox baselines, capture) and hands a fully-resolved
|
|
3
|
-
// request here; an executor decides WHERE/HOW the process runs. Core
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// tasks (`exec.persistent`) never reach an executor: they are local by
|
|
3
|
+
// request here; an executor decides WHERE/HOW the process runs. Core's own
|
|
4
|
+
// `local-executor.ts` is the in-process spawn, and it sits at the TAIL of
|
|
5
|
+
// every executor list — what a plugin executor declines runs here.
|
|
6
|
+
// Persistent tasks (`exec.persistent`) never reach an executor: they are local by
|
|
7
7
|
// construction (a worker cannot hand the submitter a listening port) and
|
|
8
8
|
// stay on `runPersistent`.
|
|
9
9
|
//
|
|
@@ -19,23 +19,20 @@ export interface ExecuteSandbox {
|
|
|
19
19
|
readonly baseAllowRead: readonly string[]
|
|
20
20
|
readonly baseAllowWrite: readonly string[]
|
|
21
21
|
readonly baseDenyRead: readonly string[]
|
|
22
|
-
readonly config: ResolvedSandboxConfig
|
|
23
22
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* runtime must pay the bounded settle window even for a 0-exit — a leaky
|
|
28
|
-
* task that swallows its own read error exits 0, and without the wait a
|
|
29
|
-
* merely-late violation record turns into a false pass of the verify.
|
|
23
|
+
* Only denials on a path under this directory are reported. A denial
|
|
24
|
+
* outside it is still ENFORCED — the task cannot leave its project — but
|
|
25
|
+
* that is the wall doing its job, not a finding to fail a run over.
|
|
30
26
|
*/
|
|
31
|
-
readonly
|
|
27
|
+
readonly reportWithin: string
|
|
28
|
+
readonly config: ResolvedSandboxConfig
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
/** One declared input file, as the cache key saw it. */
|
|
35
32
|
export interface InputFile {
|
|
36
33
|
/** Workspace-relative POSIX path. */
|
|
37
34
|
readonly path: string
|
|
38
|
-
/** Git blob OID of the WORKTREE bytes
|
|
35
|
+
/** Git blob OID of the WORKTREE bytes — for a symlink, of its target string — the same digest the key folds. */
|
|
39
36
|
readonly digest: string
|
|
40
37
|
}
|
|
41
38
|
|
|
@@ -175,18 +172,6 @@ export interface TaskPlacement {
|
|
|
175
172
|
readonly pinnedLocal: boolean
|
|
176
173
|
/** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
|
|
177
174
|
readonly cacheable: boolean
|
|
178
|
-
/**
|
|
179
|
-
* `exec.resources` VERBATIM — CPU cores, megabytes, and the image a
|
|
180
|
-
* worker must be running for this task to be routed to it. All three are
|
|
181
|
-
* requirements MATCHED against what an executor has, never instructions
|
|
182
|
-
* to build a machine: a distributed executor's workers belong to whoever
|
|
183
|
-
* runs the fleet.
|
|
184
|
-
*
|
|
185
|
-
* Placement is where this belongs: `exec.resources` is stripped from the
|
|
186
|
-
* cache key precisely because it decides WHERE a task fits, never what
|
|
187
|
-
* it produces.
|
|
188
|
-
*/
|
|
189
|
-
readonly resources?: Readonly<{ cpus?: number; memory?: number; image?: string }>
|
|
190
175
|
}
|
|
191
176
|
|
|
192
177
|
export interface TaskExecutor {
|
|
@@ -234,6 +219,6 @@ export function selectExecutor(
|
|
|
234
219
|
if (executor.accepts === undefined || executor.accepts(task)) return executor
|
|
235
220
|
}
|
|
236
221
|
throw new Error(
|
|
237
|
-
`no executor accepted ${task.taskId} (declared: ${executors.map((e) => e.name).join(', ')}).
|
|
222
|
+
`no executor accepted ${task.taskId} (declared: ${executors.map((e) => e.name).join(', ')}). Core's local executor accepts every task, so this means it is missing from the list.`,
|
|
238
223
|
)
|
|
239
224
|
}
|
package/src/exec/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Module contract. Cross-module imports must come through here; see
|
|
2
2
|
// docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
|
|
3
3
|
|
|
4
|
-
export { buildIsolatedEnv } from './env.js'
|
|
4
|
+
export { buildIsolatedEnv, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from './env.js'
|
|
5
5
|
export {
|
|
6
6
|
runCommand,
|
|
7
7
|
runPersistent,
|
|
@@ -12,13 +12,17 @@ export {
|
|
|
12
12
|
export {
|
|
13
13
|
initSandbox,
|
|
14
14
|
probeSandbox,
|
|
15
|
+
unavailableReason,
|
|
15
16
|
resetSandbox,
|
|
16
17
|
resolveSandboxConfig,
|
|
17
18
|
runSandboxed,
|
|
18
|
-
|
|
19
|
+
releaseBridges,
|
|
20
|
+
wrapSandboxedCommand,
|
|
19
21
|
type ResolvedSandboxConfig,
|
|
20
22
|
type SandboxViolation,
|
|
21
23
|
} from './sandbox-runtime.js'
|
|
24
|
+
export { type DeniedCall } from './sandbox-violations.js'
|
|
25
|
+
export { localExecutor } from './local-executor.js'
|
|
22
26
|
export {
|
|
23
27
|
selectExecutor,
|
|
24
28
|
type ExecuteRequest,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// Core's
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Core's own local executor: spawn the task's command here, in this
|
|
2
|
+
// process. Not a plugin — "run it on this machine" is not a capability
|
|
3
|
+
// someone else supplies differently, it is what running MEANS when no
|
|
4
|
+
// plugin claims the task, so it sits at the TAIL of every executor list
|
|
5
|
+
// (owner, 2026-09-05). An executor plugin places work ELSEWHERE; its
|
|
6
|
+
// absence is "here".
|
|
7
|
+
import { runCommand } from './runner.js'
|
|
8
|
+
import { runSandboxed } from './sandbox-runtime.js'
|
|
9
|
+
import type { TaskExecutor } from './executor.js'
|
|
5
10
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export const LOCAL_EXECUTOR_PLUGIN = 'vx/local-executor'
|
|
9
|
-
|
|
10
|
-
/** The executor itself — for tests and for plugins that wrap local execution. */
|
|
11
|
+
/** The local executor. Accepts every task. */
|
|
11
12
|
export function localExecutor(): TaskExecutor {
|
|
12
13
|
return {
|
|
13
14
|
name: 'local',
|
|
@@ -27,19 +28,14 @@ export function localExecutor(): TaskExecutor {
|
|
|
27
28
|
const res = await runCommand(common)
|
|
28
29
|
return { ...res, violations: [] }
|
|
29
30
|
}
|
|
30
|
-
return
|
|
31
|
+
return runSandboxed({
|
|
31
32
|
...common,
|
|
32
33
|
baseAllowRead: req.sandbox.baseAllowRead,
|
|
33
34
|
baseAllowWrite: req.sandbox.baseAllowWrite,
|
|
34
35
|
baseDenyRead: req.sandbox.baseDenyRead,
|
|
36
|
+
reportWithin: req.sandbox.reportWithin,
|
|
35
37
|
config: req.sandbox.config,
|
|
36
|
-
settleOnCleanExit: req.sandbox.settleOnCleanExit,
|
|
37
38
|
})
|
|
38
39
|
},
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
-
/** Declare in vx.workspace.ts: `plugins: [localExecutorPlugin(), …]`. Accepts every task. */
|
|
43
|
-
export function localExecutorPlugin(): VxPlugin {
|
|
44
|
-
return { name: LOCAL_EXECUTOR_PLUGIN, executor: () => localExecutor() }
|
|
45
|
-
}
|
package/src/exec/runner.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
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 { readFileSync } from 'node:fs'
|
|
7
8
|
import { constants as osConstants } from 'node:os'
|
|
9
|
+
import { killGraceMs } from '../util/index.js'
|
|
8
10
|
|
|
9
11
|
export interface RunResult {
|
|
10
12
|
exitCode: number
|
|
@@ -152,6 +154,12 @@ export function signalExitCode(signal: string): number {
|
|
|
152
154
|
return num === undefined ? 130 : 128 + num
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Grace after a timeout SIGTERM before escalating to SIGKILL — a child that
|
|
159
|
+
* ignores SIGTERM must still be bounded. Matches the persistent-shutdown grace.
|
|
160
|
+
*/
|
|
161
|
+
const TIMEOUT_SIGKILL_GRACE_MS = 2000
|
|
162
|
+
|
|
155
163
|
/**
|
|
156
164
|
* Arm a SIGTERM timeout on a spawned child. Returns a handle whose
|
|
157
165
|
* `timedOut()` reports whether the timer fired — so the caller can
|
|
@@ -160,12 +168,6 @@ export function signalExitCode(signal: string): number {
|
|
|
160
168
|
* on its own. A no-op (never fires, nothing to clear) when `timeoutMs`
|
|
161
169
|
* is undefined.
|
|
162
170
|
*/
|
|
163
|
-
/**
|
|
164
|
-
* Grace after a timeout SIGTERM before escalating to SIGKILL — a child that
|
|
165
|
-
* ignores SIGTERM must still be bounded. Matches the persistent-shutdown grace.
|
|
166
|
-
*/
|
|
167
|
-
const TIMEOUT_SIGKILL_GRACE_MS = 2000
|
|
168
|
-
|
|
169
171
|
export function armTimeout(
|
|
170
172
|
proc: ReturnType<typeof Bun.spawn>,
|
|
171
173
|
timeoutMs: number | undefined,
|
|
@@ -182,7 +184,7 @@ export function armTimeout(
|
|
|
182
184
|
// timeout, so a wedged child hangs the whole run forever. Mirrors the
|
|
183
185
|
// end-of-run persistent-shutdown escalation. Unref'd so it never keeps
|
|
184
186
|
// the CLI alive.
|
|
185
|
-
killTimer = setTimeout(() => proc.kill('SIGKILL'), TIMEOUT_SIGKILL_GRACE_MS)
|
|
187
|
+
killTimer = setTimeout(() => proc.kill('SIGKILL'), killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
|
|
186
188
|
killTimer.unref?.()
|
|
187
189
|
}, timeoutMs)
|
|
188
190
|
return {
|
|
@@ -395,6 +397,14 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
395
397
|
),
|
|
396
398
|
)
|
|
397
399
|
child.kill('SIGTERM')
|
|
400
|
+
// Same escalation as `armTimeout`: a server that traps TERM and
|
|
401
|
+
// never became ready is not in the persistent registry, so nothing
|
|
402
|
+
// else would ever kill it — it outlived the run under init.
|
|
403
|
+
const killTimer = setTimeout(
|
|
404
|
+
() => child.kill('SIGKILL'),
|
|
405
|
+
killGraceMs(TIMEOUT_SIGKILL_GRACE_MS),
|
|
406
|
+
)
|
|
407
|
+
killTimer.unref?.()
|
|
398
408
|
}
|
|
399
409
|
}, opts.timeoutMs)
|
|
400
410
|
}
|
|
@@ -478,8 +488,32 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
478
488
|
stderr,
|
|
479
489
|
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
480
490
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
481
|
-
...resourceUsageToCpuRss(proc.resourceUsage()),
|
|
491
|
+
...resourceUsageToCpuRss(proc.resourceUsage(), ownRssHighWater()),
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* This process's own RSS high-water mark, in bytes: the floor under which
|
|
497
|
+
* a child's `ru_maxrss` says nothing about the child. Linux folds the
|
|
498
|
+
* forking parent's high-water mark into the child's figure at exec (a
|
|
499
|
+
* forked child starts with its parent's pages; `exec_mmap` keeps the old
|
|
500
|
+
* mm's peak), so a task lighter than vx itself reads vx's footprint —
|
|
501
|
+
* `true` read 44 MB through vx while its shell's `VmHWM` was 1.9 MB, and
|
|
502
|
+
* 300 MB allocated in the parent made `true` read 328 MB (2026-09-12).
|
|
503
|
+
* Read after the child exits so it covers the whole task's span (the mark
|
|
504
|
+
* is monotonic). Linux reads `VmHWM`; elsewhere the current RSS is the
|
|
505
|
+
* bound in hand.
|
|
506
|
+
*/
|
|
507
|
+
export function ownRssHighWater(): number {
|
|
508
|
+
if (process.platform === 'linux') {
|
|
509
|
+
try {
|
|
510
|
+
const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
|
|
511
|
+
if (m !== null) return Number(m[1]) * 1024
|
|
512
|
+
} catch {
|
|
513
|
+
// /proc unreadable: fall through to the current RSS.
|
|
514
|
+
}
|
|
482
515
|
}
|
|
516
|
+
return process.memoryUsage.rss()
|
|
483
517
|
}
|
|
484
518
|
|
|
485
519
|
/**
|
|
@@ -540,18 +574,23 @@ export async function streamToString(
|
|
|
540
574
|
*/
|
|
541
575
|
export function resourceUsageToCpuRss(
|
|
542
576
|
usage: ReturnType<ReturnType<typeof Bun.spawn>['resourceUsage']>,
|
|
543
|
-
|
|
577
|
+
/** The parent's own high-water mark (`ownRssHighWater`); a peak at or under it is inherited, not the child's, and is not reported. */
|
|
578
|
+
floorBytes = 0,
|
|
544
579
|
): { cpuMs?: number; peakRssBytes?: number } {
|
|
545
580
|
if (!usage) return {}
|
|
546
581
|
// cpuTime.total is microseconds as a bigint → ms.
|
|
547
582
|
const cpuMs = Number(usage.cpuTime.total) / 1000
|
|
548
|
-
// maxRSS is
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
583
|
+
// `maxRSS` is BYTES on every platform: Bun normalizes the kernel's
|
|
584
|
+
// `ru_maxrss` (kilobytes on Linux, bytes on macOS) before handing it
|
|
585
|
+
// over, as its typing says. This used to multiply by 1024 on Linux on
|
|
586
|
+
// the belief that Bun passed the raw value through, and every Linux
|
|
587
|
+
// peak was 1024× too big — a 64 MB `bun test` recorded as 64 GB in
|
|
588
|
+
// telemetry, `--summarize` and the run history, and once reservations
|
|
589
|
+
// were learned from that history (2026-09-12) every task with a
|
|
590
|
+
// recorded peak was over any budget and ran alone. Measured, not
|
|
591
|
+
// assumed: `tests/runner.test.ts` allocates a known number of bytes
|
|
592
|
+
// and reads the peak back within a bounded factor of it.
|
|
593
|
+
// A reading at or under the parent's own mark is the parent's (see
|
|
594
|
+
// `ownRssHighWater`): the child's peak is unknown, bounded by it.
|
|
595
|
+
return usage.maxRSS > floorBytes ? { cpuMs, peakRssBytes: usage.maxRSS } : { cpuMs }
|
|
557
596
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Write grants as bwrap can honour them, and the SRT custom config built
|
|
2
|
+
// from the resolved policy: a file-shaped grant widened to its directory,
|
|
3
|
+
// a read grant punched around the write grants inside it, the network
|
|
4
|
+
// lists SRT insists on receiving in full.
|
|
5
|
+
|
|
6
|
+
import { lstatSync, readdirSync, statSync } from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { localBindingOn, unique } from './sandbox-paths.js'
|
|
9
|
+
import type { SandboxedRunArgs } from './sandbox-runtime.js'
|
|
10
|
+
|
|
11
|
+
type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Write grants as bwrap can actually honour them.
|
|
15
|
+
*
|
|
16
|
+
* A grant naming a FILE becomes a bwrap file bind, and you cannot rename
|
|
17
|
+
* onto an active mount point: any tool that writes its output by staging
|
|
18
|
+
* beside it and renaming — `bun build --compile`, most compilers, every
|
|
19
|
+
* atomic writer — dies with EBUSY. Minimal repro, 2026-09-05: under
|
|
20
|
+
* `bwrap --bind /w/dist/out.bin /w/dist/out.bin`, `mv /w/s /w/dist/out.bin`
|
|
21
|
+
* is "Device or resource busy"; binding `/w/dist` instead succeeds.
|
|
22
|
+
*
|
|
23
|
+
* So on Linux a file-shaped grant is widened to its directory. That IS a
|
|
24
|
+
* widening — the task may write its siblings — and it is the narrowest
|
|
25
|
+
* grant the mechanism can express: the alternative is a declared output
|
|
26
|
+
* the task cannot produce. macOS needs none of this (seatbelt matches
|
|
27
|
+
* paths, it does not mount), so the grant stays exact there.
|
|
28
|
+
*/
|
|
29
|
+
function bindableWrites(paths: readonly string[]): string[] {
|
|
30
|
+
if (process.platform !== 'linux') return [...paths]
|
|
31
|
+
return unique(
|
|
32
|
+
paths.map((p) => {
|
|
33
|
+
if (/[*?[\]]/.test(p)) return p
|
|
34
|
+
try {
|
|
35
|
+
if (statSync(p).isDirectory()) return p
|
|
36
|
+
} catch {
|
|
37
|
+
// Does not exist yet: `prepareOutputsForBind` creates a file for a
|
|
38
|
+
// file-shaped grant, so treat it as one.
|
|
39
|
+
}
|
|
40
|
+
return path.dirname(p)
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Directories already warned about below — once per process, not per spawn. */
|
|
46
|
+
const warnedSymlinkPunch = new Set<string>()
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Expand a read grant so it is never an ANCESTOR of a write grant.
|
|
50
|
+
*
|
|
51
|
+
* bwrap builds the sandbox out of mounts, and SRT emits them write-first:
|
|
52
|
+
* `--bind <out>` then `--ro-bind <readPath>`. When the read path is an
|
|
53
|
+
* ancestor of the write path the read-only mount lands ON TOP of the
|
|
54
|
+
* writable one and every write fails with `Read-only file system`
|
|
55
|
+
* (`pushReadDenyDirMounts`, SRT 0.0.75 — its skip only covers the reverse
|
|
56
|
+
* nesting). Verified in a Linux container 2026-09-05:
|
|
57
|
+
*
|
|
58
|
+
* read=[proj] write=[proj/dist] → mkdir: Read-only file system
|
|
59
|
+
* read=[proj/src] write=[proj/dist] → ok
|
|
60
|
+
* read=[proj] no writes → ok
|
|
61
|
+
*
|
|
62
|
+
* So punch the write paths out: grant the ancestor's children instead,
|
|
63
|
+
* recursing only along the branches that actually contain one. Same probe,
|
|
64
|
+
* same command: `read=[src, lib, package.json] write=[dist]` → ok. macOS
|
|
65
|
+
* never needed this (seatbelt is precedence-based, not mounts), and it is
|
|
66
|
+
* harmless there, so both platforms take the same path.
|
|
67
|
+
*
|
|
68
|
+
* A grant with no write path under it is returned untouched — the common
|
|
69
|
+
* case costs nothing, not even a readdir.
|
|
70
|
+
*/
|
|
71
|
+
export function punchWritePaths(readPath: string, writePaths: readonly string[]): string[] {
|
|
72
|
+
// Linux only. The shadowing is a property of bwrap MOUNTS; macOS seatbelt
|
|
73
|
+
// evaluates rules by precedence, so a read grant on a directory and a
|
|
74
|
+
// write grant inside it coexist. Punching there costs the directory
|
|
75
|
+
// ENTRY: granting every child is not granting the dir, so a command that
|
|
76
|
+
// stats its own cwd — `bun build` — is denied it and dies with
|
|
77
|
+
// `error: An unknown error occurred (Unexpected)` (2026-09-05).
|
|
78
|
+
if (process.platform !== 'linux') return [readPath]
|
|
79
|
+
const under = writePaths.filter((w) => w !== readPath && w.startsWith(readPath + path.sep))
|
|
80
|
+
if (under.length === 0) return [readPath]
|
|
81
|
+
let entries: string[]
|
|
82
|
+
try {
|
|
83
|
+
entries = readdirSync(readPath)
|
|
84
|
+
} catch {
|
|
85
|
+
// Unreadable or not a directory: nothing to expand, hand it over as is.
|
|
86
|
+
return [readPath]
|
|
87
|
+
}
|
|
88
|
+
const out: string[] = []
|
|
89
|
+
const linked: string[] = []
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const child = path.join(readPath, entry)
|
|
92
|
+
// A write path is already bound read-write, which is readable.
|
|
93
|
+
if (under.includes(child)) continue
|
|
94
|
+
// bwrap resolves a bind SOURCE, so a symlinked child is mounted as the
|
|
95
|
+
// directory it points at: inside the sandbox the link is gone. For a
|
|
96
|
+
// package in Bun's isolated node_modules layout that severs it from
|
|
97
|
+
// the `.bun/` siblings its own dependencies resolve through — astro
|
|
98
|
+
// could not find `yargs-parser` for four days of red CI (2026-09-09).
|
|
99
|
+
// SRT's config carries no `--symlink`, so the only fix is the grant:
|
|
100
|
+
// say which one, loudly.
|
|
101
|
+
try {
|
|
102
|
+
if (lstatSync(child).isSymbolicLink()) linked.push(child)
|
|
103
|
+
} catch {
|
|
104
|
+
// vanished between readdir and lstat: nothing to bind either way
|
|
105
|
+
}
|
|
106
|
+
out.push(...punchWritePaths(child, under))
|
|
107
|
+
}
|
|
108
|
+
if (linked.length > 0 && !warnedSymlinkPunch.has(readPath)) {
|
|
109
|
+
warnedSymlinkPunch.add(readPath)
|
|
110
|
+
process.stderr.write(
|
|
111
|
+
`[vx] sandbox: a write grant under ${readPath} makes its ${linked.length} symlinked ` +
|
|
112
|
+
`entr${linked.length === 1 ? 'y' : 'ies'} (${linked
|
|
113
|
+
.slice(0, 3)
|
|
114
|
+
.map((l) => path.basename(l))
|
|
115
|
+
.join(', ')}${linked.length > 3 ? ', …' : ''}) plain directories inside the sandbox — ` +
|
|
116
|
+
`a package resolved through one loses its siblings. Move the write grant (${under
|
|
117
|
+
.map((w) => path.relative(readPath, w))
|
|
118
|
+
.join(', ')}) out of it.\n`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Merge the orchestrator-provided baseline (declared inputs / outputs /
|
|
126
|
+
* workspace-root anchor) with the user's resolved sandbox block to
|
|
127
|
+
* produce the SRT customConfig. Path arrays are unioned and deduped; every
|
|
128
|
+
* read grant is punched around the write grants (`punchWritePaths`).
|
|
129
|
+
*
|
|
130
|
+
* Network: `allow.network` missing → block all (allowedDomains: []);
|
|
131
|
+
* `true` → allow all (['*']); a domain list → exactly that list.
|
|
132
|
+
* `deny.network` is always passed as deniedDomains.
|
|
133
|
+
*/
|
|
134
|
+
export function buildCustomConfig(
|
|
135
|
+
args: Pick<SandboxedRunArgs, 'config'>,
|
|
136
|
+
baselines: {
|
|
137
|
+
allowRead: readonly string[]
|
|
138
|
+
allowWrite: readonly string[]
|
|
139
|
+
denyRead: readonly string[]
|
|
140
|
+
},
|
|
141
|
+
): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
|
|
142
|
+
const c = args.config
|
|
143
|
+
const denyRead = unique([...baselines.denyRead])
|
|
144
|
+
const allowWrite = bindableWrites(unique([...baselines.allowWrite, ...c.allowWrite]))
|
|
145
|
+
const allowRead = unique(
|
|
146
|
+
[...baselines.allowRead, ...c.allowRead].flatMap((r) => punchWritePaths(r, allowWrite)),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const custom: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] = {
|
|
150
|
+
filesystem: {
|
|
151
|
+
denyRead,
|
|
152
|
+
allowRead,
|
|
153
|
+
allowWrite,
|
|
154
|
+
denyWrite: [],
|
|
155
|
+
...(c.gitConfig !== undefined ? { allowGitConfig: c.gitConfig } : {}),
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// `allow.network` / `deny.network` become SRT's domain lists. SRT requires
|
|
160
|
+
// both to be present on any network config, so we always supply both;
|
|
161
|
+
// omitted means no network at all.
|
|
162
|
+
custom.network = {
|
|
163
|
+
allowedDomains: c.network === true ? ['*'] : [...(c.network ?? [])],
|
|
164
|
+
deniedDomains: [...(c.denyNetwork ?? [])],
|
|
165
|
+
...(c.unixSockets === true
|
|
166
|
+
? { allowAllUnixSockets: true }
|
|
167
|
+
: c.unixSockets !== undefined
|
|
168
|
+
? { allowUnixSockets: [...c.unixSockets] }
|
|
169
|
+
: {}),
|
|
170
|
+
...(c.localBinding !== undefined ? { allowLocalBinding: localBindingOn(c) } : {}),
|
|
171
|
+
...(c.machLookup !== undefined ? { allowMachLookup: [...c.machLookup] } : {}),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (c.pty !== undefined) custom.allowPty = c.pty
|
|
175
|
+
if (c.weakerWhenNested !== undefined) {
|
|
176
|
+
custom.enableWeakerNestedSandbox = c.weakerWhenNested
|
|
177
|
+
}
|
|
178
|
+
if (c.weakerNetworkIsolation !== undefined) {
|
|
179
|
+
custom.enableWeakerNetworkIsolation = c.weakerNetworkIsolation
|
|
180
|
+
}
|
|
181
|
+
return custom
|
|
182
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Path helpers the sandbox modules share: canonical forms (the policy
|
|
2
|
+
// matches real paths), the task-relative absolutizer the strace pass uses,
|
|
3
|
+
// and the prefix test.
|
|
4
|
+
|
|
5
|
+
import { realpathSync } from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Canonicalize a path with realpath, tolerating paths that don't exist
|
|
11
|
+
* yet: resolve the longest existing ancestor and re-append the rest.
|
|
12
|
+
*
|
|
13
|
+
* Why: the sandbox policy matches on canonical paths (macOS seatbelt
|
|
14
|
+
* evaluates real vnode paths), and SRT's own normalization refuses to
|
|
15
|
+
* canonicalize bare symlinked roots like `/tmp` → `/private/tmp` (its
|
|
16
|
+
* boundary check only whitelists `/tmp/<child>` forms). Without this,
|
|
17
|
+
* `allowWrite: ['/tmp']` silently never matches on macOS.
|
|
18
|
+
*/
|
|
19
|
+
export function toRealPath(p: string): string {
|
|
20
|
+
try {
|
|
21
|
+
return realpathSync(p)
|
|
22
|
+
} catch {
|
|
23
|
+
const parent = path.dirname(p)
|
|
24
|
+
if (parent === p) return p
|
|
25
|
+
return path.join(toRealPath(parent), path.basename(p))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function absolutize(p: string, cwd?: string): string {
|
|
30
|
+
if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1))
|
|
31
|
+
if (path.isAbsolute(p)) return p
|
|
32
|
+
return path.resolve(cwd ?? process.cwd(), p)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isUnderAny(abs: string, allow: Set<string>): boolean {
|
|
36
|
+
if (allow.has(abs)) return true
|
|
37
|
+
for (const a of allow) {
|
|
38
|
+
if (abs === a || abs.startsWith(a + path.sep)) return true
|
|
39
|
+
}
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function unique(arr: readonly string[]): string[] {
|
|
44
|
+
return [...new Set(arr)]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `allow.localBinding` grants loopback: `true`, or a non-empty port list. */
|
|
48
|
+
export function localBindingOn(c: { localBinding?: boolean | readonly number[] }): boolean {
|
|
49
|
+
return c.localBinding === true || (Array.isArray(c.localBinding) && c.localBinding.length > 0)
|
|
50
|
+
}
|