@vzn/vx 0.0.0 → 0.0.14
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 +21 -0
- package/README.md +349 -1
- package/launcher.mjs +92 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +28 -0
- package/src/cache/inputs.ts +839 -0
- package/src/cache/layered-cache.ts +355 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +96 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +129 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +80 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +683 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +460 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +85 -0
- package/src/graph/index.ts +27 -0
- package/src/graph/scheduler.ts +677 -0
- package/src/graph/task-graph.ts +377 -0
- package/src/index.ts +222 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +939 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +174 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1927 -0
- package/src/orchestrator/options.ts +197 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +185 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +306 -0
- package/src/orchestrator/protocol.ts +156 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +887 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +315 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +253 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +358 -0
- package/src/orchestrator/upstream.ts +121 -0
- package/src/orchestrator/verify.ts +164 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +8 -0
- package/src/util/paths.ts +16 -0
- package/src/util/size.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +512 -0
- package/src/workspace/workspace.ts +176 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// Thin wrapper around `@anthropic-ai/sandbox-runtime` (SRT) for enforcing
|
|
2
|
+
// per-task filesystem + network isolation.
|
|
3
|
+
//
|
|
4
|
+
// Design contract:
|
|
5
|
+
// The caller (executeCachedTask) computes the exact allowRead /
|
|
6
|
+
// allowWrite paths from the task's declared inputs + outputs + sandbox
|
|
7
|
+
// block. This module adds nothing implicit — no /tmp, no node_modules,
|
|
8
|
+
// no project dir. If a task needs them, the user declares them in
|
|
9
|
+
// their sandbox config. That gives users a complete view of what each
|
|
10
|
+
// task can touch from a single vx.config.ts file.
|
|
11
|
+
//
|
|
12
|
+
// Network is opt-in per task. By default the sandbox blocks all outbound
|
|
13
|
+
// traffic; tasks that need it set `sandbox.network: true`.
|
|
14
|
+
//
|
|
15
|
+
// Platform reality:
|
|
16
|
+
// macOS — `SandboxViolationStore` is populated from the system log
|
|
17
|
+
// monitor in real time; violations carry the offending command +
|
|
18
|
+
// syscall line, so we get structured detection.
|
|
19
|
+
// Linux — bwrap denies the read at the kernel boundary; the child
|
|
20
|
+
// sees ENOENT (or EPERM for some operations). SRT doesn't surface
|
|
21
|
+
// structured events for Linux, so detection is enforcement-only.
|
|
22
|
+
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import os from 'node:os'
|
|
25
|
+
import { realpathSync } from 'node:fs'
|
|
26
|
+
import { unlink } from 'node:fs/promises'
|
|
27
|
+
import type { SandboxConfig, SandboxNetworkConfig } from '../config.js'
|
|
28
|
+
import {
|
|
29
|
+
armTimeout,
|
|
30
|
+
shellQuote,
|
|
31
|
+
signalExitCode,
|
|
32
|
+
streamToString,
|
|
33
|
+
resourceUsageToCpuRss,
|
|
34
|
+
type RunResult,
|
|
35
|
+
} from './runner.js'
|
|
36
|
+
import { xxh3hex } from '../util/index.js'
|
|
37
|
+
|
|
38
|
+
type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
39
|
+
let srtPromise: Promise<SrtModule> | undefined
|
|
40
|
+
|
|
41
|
+
async function loadSrt(): Promise<SrtModule> {
|
|
42
|
+
if (!srtPromise) srtPromise = import('@anthropic-ai/sandbox-runtime')
|
|
43
|
+
return srtPromise
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SandboxAvailability {
|
|
47
|
+
available: boolean
|
|
48
|
+
/** Reason it isn't available (platform / missing deps). Empty when available. */
|
|
49
|
+
reason: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let availabilityCache: SandboxAvailability | undefined
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Probe whether SRT can sandbox on this host. Memoized — the binary
|
|
56
|
+
* presence + platform check doesn't change within a process.
|
|
57
|
+
*
|
|
58
|
+
* In addition to SRT's own `checkDependencies` (which only verifies
|
|
59
|
+
* binary presence on PATH), this runs a minimal bwrap invocation on
|
|
60
|
+
* Linux to catch the "bwrap installed but unprivileged user namespaces
|
|
61
|
+
* blocked by AppArmor / sysctl" case that's the default on stock
|
|
62
|
+
* Ubuntu 24.04. Without this real-execution probe, the unavailability
|
|
63
|
+
* surfaces only at the first task spawn, deep inside the orchestrator.
|
|
64
|
+
*/
|
|
65
|
+
export async function probeSandbox(): Promise<SandboxAvailability> {
|
|
66
|
+
if (availabilityCache) return availabilityCache
|
|
67
|
+
const { SandboxManager } = await loadSrt()
|
|
68
|
+
if (!SandboxManager.isSupportedPlatform()) {
|
|
69
|
+
availabilityCache = { available: false, reason: `platform ${process.platform} not supported` }
|
|
70
|
+
return availabilityCache
|
|
71
|
+
}
|
|
72
|
+
const deps = SandboxManager.checkDependencies()
|
|
73
|
+
if (deps.errors.length > 0) {
|
|
74
|
+
availabilityCache = { available: false, reason: deps.errors.join('; ') }
|
|
75
|
+
return availabilityCache
|
|
76
|
+
}
|
|
77
|
+
// Linux only: real-execution probe. Run `bwrap` with the minimal
|
|
78
|
+
// user-namespace invocation it'd attempt for a sandboxed task; if
|
|
79
|
+
// the kernel rejects (AppArmor / sysctl), surface that here.
|
|
80
|
+
if (process.platform === 'linux') {
|
|
81
|
+
const ok = await tryBwrapOnce()
|
|
82
|
+
if (!ok.available) {
|
|
83
|
+
availabilityCache = ok
|
|
84
|
+
return availabilityCache
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
availabilityCache = { available: true, reason: '' }
|
|
88
|
+
return availabilityCache
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function tryBwrapOnce(): Promise<SandboxAvailability> {
|
|
92
|
+
try {
|
|
93
|
+
const proc = Bun.spawn(
|
|
94
|
+
['bwrap', '--ro-bind', '/', '/', '--proc', '/proc', '--dev', '/dev', '/bin/true'],
|
|
95
|
+
{ stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' },
|
|
96
|
+
)
|
|
97
|
+
const stderr = await new Response(proc.stderr).text()
|
|
98
|
+
await proc.exited
|
|
99
|
+
if (proc.exitCode !== 0) {
|
|
100
|
+
return {
|
|
101
|
+
available: false,
|
|
102
|
+
reason: `bwrap probe failed (exit ${proc.exitCode}): ${stderr.trim().slice(0, 200)}`,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { available: true, reason: '' }
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return { available: false, reason: `bwrap probe threw: ${(err as Error).message}` }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Default `ignoreViolations` we install in the SRT global config.
|
|
113
|
+
* These are well-known macOS shell-startup probes that SRT's own
|
|
114
|
+
* sysctl allowlist doesn't cover — every binary launched by sh
|
|
115
|
+
* (bash, sleep, mkdir, touch, etc.) sysctl-reads them at init, so
|
|
116
|
+
* without this filter every task floods with noise that isn't
|
|
117
|
+
* actionable security signal.
|
|
118
|
+
*
|
|
119
|
+
* Format mirrors SRT's: `'*'` is a wildcard pattern; entries in the
|
|
120
|
+
* array are substring-matched against the violation details line.
|
|
121
|
+
* Users can ADD to this via per-task `sandbox.ignoreViolations`;
|
|
122
|
+
* those are applied at violation read-back time, on top of the
|
|
123
|
+
* defaults installed here.
|
|
124
|
+
*/
|
|
125
|
+
const DEFAULT_IGNORE_VIOLATIONS: Record<string, string[]> = {
|
|
126
|
+
'*': [
|
|
127
|
+
'kern.iossupportversion', // newer macOS sysctl SRT's allowlist misses
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One-time SRT initialization per orchestrator run. Starts the proxy
|
|
133
|
+
* servers + (on macOS) the violation log monitor. Safe to call repeatedly
|
|
134
|
+
* — SRT itself returns early on the second call.
|
|
135
|
+
*
|
|
136
|
+
* The base config sets network to "block everything" (empty allowedDomains).
|
|
137
|
+
* Per-task wrapping passes a customConfig that re-enables network for
|
|
138
|
+
* tasks with `sandbox.network: true`.
|
|
139
|
+
*/
|
|
140
|
+
export async function initSandbox(): Promise<void> {
|
|
141
|
+
const { SandboxManager } = await loadSrt()
|
|
142
|
+
await SandboxManager.initialize(
|
|
143
|
+
{
|
|
144
|
+
network: { allowedDomains: [], deniedDomains: [] },
|
|
145
|
+
filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
|
|
146
|
+
ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
|
|
147
|
+
},
|
|
148
|
+
undefined,
|
|
149
|
+
// enableLogMonitor — macOS-only; populates the SandboxViolationStore.
|
|
150
|
+
true,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function resetSandbox(): Promise<void> {
|
|
155
|
+
const { SandboxManager } = await loadSrt()
|
|
156
|
+
await SandboxManager.reset()
|
|
157
|
+
availabilityCache = undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface SandboxedRunArgs {
|
|
161
|
+
command: string
|
|
162
|
+
cwd: string
|
|
163
|
+
env: NodeJS.ProcessEnv
|
|
164
|
+
forwardArgs?: readonly string[] | undefined
|
|
165
|
+
onStdout?: (chunk: string) => void
|
|
166
|
+
onStderr?: (chunk: string) => void
|
|
167
|
+
/** See `RunOptions.liveChildren` — same contract for sandboxed spawns. */
|
|
168
|
+
liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
169
|
+
/** See `RunOptions.timeoutMs` — SIGTERM the child after this many ms. */
|
|
170
|
+
timeoutMs?: number
|
|
171
|
+
/**
|
|
172
|
+
* Baseline reads — paths the sandbox unconditionally allows. The
|
|
173
|
+
* caller builds this from resolved `cache.inputs.files`.
|
|
174
|
+
*/
|
|
175
|
+
baseAllowRead: readonly string[]
|
|
176
|
+
/**
|
|
177
|
+
* Baseline writes — paths the sandbox unconditionally allows for
|
|
178
|
+
* writes. Built from the static prefix of `cache.outputs.files`.
|
|
179
|
+
*/
|
|
180
|
+
baseAllowWrite: readonly string[]
|
|
181
|
+
/**
|
|
182
|
+
* Read-deny anchor. Combined with allowRead it produces the effective
|
|
183
|
+
* deny set: anything under one of these paths that isn't in allowRead
|
|
184
|
+
* is forbidden. Pass `[workspaceRoot]` to enforce project boundaries.
|
|
185
|
+
*/
|
|
186
|
+
baseDenyRead: readonly string[]
|
|
187
|
+
/**
|
|
188
|
+
* User-declared sandbox block (after path-resolution). Path lists are
|
|
189
|
+
* unioned with the baselines; bool/object fields fall through to SRT.
|
|
190
|
+
*/
|
|
191
|
+
config: ResolvedSandboxConfig
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Sandbox config with all path fields resolved to absolute paths.
|
|
196
|
+
* Produced by `resolveSandboxConfig`. The shape mirrors `SandboxConfig`
|
|
197
|
+
* but every string in a path list is guaranteed absolute.
|
|
198
|
+
*/
|
|
199
|
+
export interface ResolvedSandboxConfig {
|
|
200
|
+
allowRead: readonly string[]
|
|
201
|
+
allowWrite: readonly string[]
|
|
202
|
+
allowGitConfig?: boolean
|
|
203
|
+
network?: boolean | SandboxNetworkConfig
|
|
204
|
+
allowPty?: boolean
|
|
205
|
+
enableWeakerNestedSandbox?: boolean
|
|
206
|
+
enableWeakerNetworkIsolation?: boolean
|
|
207
|
+
ignoreViolations?: Record<string, string[]>
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Canonicalize a path with realpath, tolerating paths that don't exist
|
|
212
|
+
* yet: resolve the longest existing ancestor and re-append the rest.
|
|
213
|
+
*
|
|
214
|
+
* Why: the sandbox policy matches on canonical paths (macOS seatbelt
|
|
215
|
+
* evaluates real vnode paths), and SRT's own normalization refuses to
|
|
216
|
+
* canonicalize bare symlinked roots like `/tmp` → `/private/tmp` (its
|
|
217
|
+
* boundary check only whitelists `/tmp/<child>` forms). Without this,
|
|
218
|
+
* `allowWrite: ['/tmp']` silently never matches on macOS.
|
|
219
|
+
*/
|
|
220
|
+
function toRealPath(p: string): string {
|
|
221
|
+
try {
|
|
222
|
+
return realpathSync(p)
|
|
223
|
+
} catch {
|
|
224
|
+
const parent = path.dirname(p)
|
|
225
|
+
if (parent === p) return p
|
|
226
|
+
return path.join(toRealPath(parent), path.basename(p))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Convert a user-facing `SandboxConfig` (paths may be relative / tilde)
|
|
232
|
+
* into a `ResolvedSandboxConfig` (all paths absolute + canonical) for a
|
|
233
|
+
* given project. Relative paths resolve against `projectDir`; tilde
|
|
234
|
+
* paths expand against the user's home; symlinks resolve to real paths.
|
|
235
|
+
*/
|
|
236
|
+
export function resolveSandboxConfig(
|
|
237
|
+
cfg: SandboxConfig,
|
|
238
|
+
projectDir: string,
|
|
239
|
+
): ResolvedSandboxConfig {
|
|
240
|
+
const resolve = (p: string): string => {
|
|
241
|
+
if (p.startsWith('~')) return toRealPath(path.join(os.homedir(), p.slice(1)))
|
|
242
|
+
if (path.isAbsolute(p)) return toRealPath(p)
|
|
243
|
+
return toRealPath(path.resolve(projectDir, p))
|
|
244
|
+
}
|
|
245
|
+
const r: ResolvedSandboxConfig = {
|
|
246
|
+
allowRead: (cfg.allowRead ?? []).map(resolve),
|
|
247
|
+
allowWrite: (cfg.allowWrite ?? []).map(resolve),
|
|
248
|
+
}
|
|
249
|
+
if (cfg.allowGitConfig !== undefined) r.allowGitConfig = cfg.allowGitConfig
|
|
250
|
+
if (cfg.network !== undefined) r.network = cfg.network
|
|
251
|
+
if (cfg.allowPty !== undefined) r.allowPty = cfg.allowPty
|
|
252
|
+
if (cfg.enableWeakerNestedSandbox !== undefined) {
|
|
253
|
+
r.enableWeakerNestedSandbox = cfg.enableWeakerNestedSandbox
|
|
254
|
+
}
|
|
255
|
+
if (cfg.enableWeakerNetworkIsolation !== undefined) {
|
|
256
|
+
r.enableWeakerNetworkIsolation = cfg.enableWeakerNetworkIsolation
|
|
257
|
+
}
|
|
258
|
+
if (cfg.ignoreViolations !== undefined) r.ignoreViolations = cfg.ignoreViolations
|
|
259
|
+
return r
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface SandboxViolation {
|
|
263
|
+
/** Raw log line from SRT. Format differs between macOS / Linux. */
|
|
264
|
+
line: string
|
|
265
|
+
timestamp: Date
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface SandboxedRunResult extends RunResult {
|
|
269
|
+
/** Violations captured during this task. Empty when nothing tripped. */
|
|
270
|
+
violations: SandboxViolation[]
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Run a single task wrapped in the sandbox. Caller must have called
|
|
275
|
+
* `initSandbox()` first.
|
|
276
|
+
*
|
|
277
|
+
* Violations are matched by a unique per-task command prefix — SRT's
|
|
278
|
+
* `getViolationsForCommand` keys by base64 of the first 100 chars, so
|
|
279
|
+
* two tasks running the same underlying command (e.g. parallel `tsc`
|
|
280
|
+
* across packages) would otherwise collide. We prepend `: '<tag>';`
|
|
281
|
+
* (shell no-op) to make every command's first 100 chars unique.
|
|
282
|
+
*/
|
|
283
|
+
export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
|
|
284
|
+
const { SandboxManager } = await loadSrt()
|
|
285
|
+
const start = Date.now()
|
|
286
|
+
const userCommand =
|
|
287
|
+
args.forwardArgs && args.forwardArgs.length > 0
|
|
288
|
+
? args.command + ' ' + args.forwardArgs.map(shellQuote).join(' ')
|
|
289
|
+
: args.command
|
|
290
|
+
|
|
291
|
+
const tag = xxh3hex(`${args.cwd}|${userCommand}|${process.hrtime.bigint()}`).slice(0, 16)
|
|
292
|
+
const taggedCommand = `: 'vx-${tag}'; ${userCommand}`
|
|
293
|
+
|
|
294
|
+
const customConfig = buildCustomConfig(args)
|
|
295
|
+
const wrapped = await SandboxManager.wrapWithSandbox(taggedCommand, undefined, customConfig)
|
|
296
|
+
|
|
297
|
+
// Linux: SRT's SandboxViolationStore is macOS-only, so structured
|
|
298
|
+
// detection on Linux requires us to wrap the spawn with strace and
|
|
299
|
+
// parse the trace for denied syscalls. The trace is per-task (unique
|
|
300
|
+
// log path keyed by the command tag) so parallel tasks don't share
|
|
301
|
+
// a stream. Skipped when strace isn't on PATH — bwrap still enforces
|
|
302
|
+
// structurally; we just lose the structured violation list.
|
|
303
|
+
const useStrace = await wantsStraceDetection()
|
|
304
|
+
const straceLog = useStrace ? path.join(os.tmpdir(), `vx-strace-${tag}.log`) : undefined
|
|
305
|
+
// We trace only `openat` — it's the actual file-read attempt, the
|
|
306
|
+
// signal the user cares about. `statx` / `newfstatat` / `access`
|
|
307
|
+
// are mostly shell PATH-walking and stat probes that aren't
|
|
308
|
+
// actionable (we'd report every node_modules/.bin entry the shell
|
|
309
|
+
// checks before resolving a command).
|
|
310
|
+
const spawnArgv = straceLog
|
|
311
|
+
? ['strace', '-f', '-e', 'trace=openat', '-o', straceLog, '--', 'sh', '-c', wrapped]
|
|
312
|
+
: ['sh', '-c', wrapped]
|
|
313
|
+
|
|
314
|
+
let proc: ReturnType<typeof Bun.spawn>
|
|
315
|
+
try {
|
|
316
|
+
proc = Bun.spawn(spawnArgv, {
|
|
317
|
+
cwd: args.cwd,
|
|
318
|
+
env: args.env as Record<string, string>,
|
|
319
|
+
stdin: 'ignore',
|
|
320
|
+
stdout: 'pipe',
|
|
321
|
+
stderr: 'pipe',
|
|
322
|
+
})
|
|
323
|
+
} catch (err) {
|
|
324
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
325
|
+
return {
|
|
326
|
+
exitCode: 127,
|
|
327
|
+
durationMs: Date.now() - start,
|
|
328
|
+
stdout: '',
|
|
329
|
+
stderr: `\n[vx] failed to spawn sandboxed task: ${message}\n`,
|
|
330
|
+
violations: [],
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
args.liveChildren?.add(proc)
|
|
335
|
+
const timeout = armTimeout(proc, args.timeoutMs)
|
|
336
|
+
const ac = new AbortController()
|
|
337
|
+
const streams = Promise.all([
|
|
338
|
+
streamToString(proc.stdout, args.onStdout, ac.signal),
|
|
339
|
+
streamToString(proc.stderr, args.onStderr, ac.signal),
|
|
340
|
+
])
|
|
341
|
+
// See runCommand: gate on child exit, abort the readers on timeout so
|
|
342
|
+
// a lingering grandchild pipe can't hang the run.
|
|
343
|
+
await proc.exited
|
|
344
|
+
timeout.clear()
|
|
345
|
+
if (timeout.timedOut()) ac.abort()
|
|
346
|
+
const [stdout, stderr] = await streams
|
|
347
|
+
args.liveChildren?.delete(proc)
|
|
348
|
+
const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
349
|
+
|
|
350
|
+
// macOS: read the violation store keyed by our tagged command.
|
|
351
|
+
const store = SandboxManager.getSandboxViolationStore()
|
|
352
|
+
const macViolations = store.getViolationsForCommand(taggedCommand).map((v) => ({
|
|
353
|
+
line: v.line,
|
|
354
|
+
timestamp: v.timestamp,
|
|
355
|
+
}))
|
|
356
|
+
|
|
357
|
+
// Linux: parse the strace log and emit one violation per denied
|
|
358
|
+
// syscall on a path inside denyRead that wasn't unconditionally
|
|
359
|
+
// allowed. Best-effort — if parsing fails we surface no Linux
|
|
360
|
+
// violations rather than fail the whole task.
|
|
361
|
+
const linuxViolations: SandboxViolation[] = straceLog
|
|
362
|
+
? await parseStraceViolations(straceLog, args).catch(() => [])
|
|
363
|
+
: []
|
|
364
|
+
if (straceLog) await unlink(straceLog).catch(() => undefined)
|
|
365
|
+
|
|
366
|
+
// Apply the task's own ignoreViolations on top of the global defaults.
|
|
367
|
+
// SRT's wrapCommandWithSandboxMacOS doesn't actually thread customConfig.
|
|
368
|
+
// ignoreViolations through to the log monitor — that filter is set
|
|
369
|
+
// once globally at initSandbox time. So per-task user overrides have
|
|
370
|
+
// to be applied here, after read-back.
|
|
371
|
+
const userIgnore = args.config.ignoreViolations
|
|
372
|
+
const violations: SandboxViolation[] = filterIgnored(
|
|
373
|
+
[...macViolations, ...linuxViolations],
|
|
374
|
+
userIgnore,
|
|
375
|
+
userCommand,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
SandboxManager.cleanupAfterCommand()
|
|
380
|
+
} catch {
|
|
381
|
+
// ignore; bwrap mount-point cleanup is best-effort
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
exitCode,
|
|
386
|
+
durationMs: Date.now() - start,
|
|
387
|
+
stdout,
|
|
388
|
+
stderr,
|
|
389
|
+
violations,
|
|
390
|
+
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
391
|
+
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
392
|
+
...resourceUsageToCpuRss(proc.resourceUsage()),
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Memoized check: is `strace` on PATH on a Linux host? */
|
|
397
|
+
let straceAvailableCache: boolean | undefined
|
|
398
|
+
async function wantsStraceDetection(): Promise<boolean> {
|
|
399
|
+
if (process.platform !== 'linux') return false
|
|
400
|
+
if (straceAvailableCache !== undefined) return straceAvailableCache
|
|
401
|
+
try {
|
|
402
|
+
const p = Bun.spawn(['strace', '--version'], { stdout: 'ignore', stderr: 'ignore' })
|
|
403
|
+
await p.exited
|
|
404
|
+
straceAvailableCache = p.exitCode === 0
|
|
405
|
+
} catch {
|
|
406
|
+
straceAvailableCache = false
|
|
407
|
+
}
|
|
408
|
+
return straceAvailableCache
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Parse a strace log for denied filesystem syscalls and convert each
|
|
413
|
+
* one inside the workspace deny anchor (and not in allowRead) into a
|
|
414
|
+
* SandboxViolation. Dedups by (syscall, abs-path) so a tool that
|
|
415
|
+
* statx's the same missing path 10 times in a row produces one line.
|
|
416
|
+
*
|
|
417
|
+
* strace line shape (with -f):
|
|
418
|
+
* <pid> openat(AT_FDCWD, "<path>", <flags>) = -1 ENOENT (...)
|
|
419
|
+
* <pid> access("<path>", <mode>) = -1 EACCES (...)
|
|
420
|
+
* <pid> statx(AT_FDCWD, "<path>", <flags>, <mask>, ...) = -1 ENOENT (...)
|
|
421
|
+
*
|
|
422
|
+
* We capture the first quoted-string argument as the path. paths that
|
|
423
|
+
* are relative resolve against the task's cwd (set by Bun.spawn).
|
|
424
|
+
*/
|
|
425
|
+
const STRACE_RE =
|
|
426
|
+
/^\d+\s+(openat|access|statx|newfstatat)\([^"]*"([^"]+)"[^)]*\)\s*=\s*-1\s+(ENOENT|EACCES|EPERM)/gm
|
|
427
|
+
|
|
428
|
+
async function parseStraceViolations(
|
|
429
|
+
logPath: string,
|
|
430
|
+
args: SandboxedRunArgs,
|
|
431
|
+
): Promise<SandboxViolation[]> {
|
|
432
|
+
const text = await Bun.file(logPath).text()
|
|
433
|
+
if (text.length === 0) return []
|
|
434
|
+
|
|
435
|
+
// Treat every baseAllow + sandbox.allowRead path as "this was
|
|
436
|
+
// explicitly permitted; any -ENOENT here is the user's own missing
|
|
437
|
+
// file, not a sandbox-induced denial". Same for absolute denyRead
|
|
438
|
+
// checks below.
|
|
439
|
+
const allowAbs = new Set<string>(
|
|
440
|
+
[...args.baseAllowRead, ...args.config.allowRead].map((p) => absolutize(p)),
|
|
441
|
+
)
|
|
442
|
+
const denyAnchors = args.baseDenyRead.map((p) => absolutize(p))
|
|
443
|
+
|
|
444
|
+
const seen = new Set<string>()
|
|
445
|
+
const out: SandboxViolation[] = []
|
|
446
|
+
for (const m of text.matchAll(STRACE_RE)) {
|
|
447
|
+
const [, syscall, rawPath, errno] = m
|
|
448
|
+
if (!rawPath) continue
|
|
449
|
+
const abs = absolutize(rawPath, args.cwd)
|
|
450
|
+
// Only report paths under the workspace-root deny anchor — system
|
|
451
|
+
// libs / /proc / /sys / etc. probes are not interesting violations.
|
|
452
|
+
if (!denyAnchors.some((root) => abs === root || abs.startsWith(root + path.sep))) continue
|
|
453
|
+
// Skip paths the user explicitly allowed (and their descendants).
|
|
454
|
+
if (isUnderAny(abs, allowAbs)) continue
|
|
455
|
+
const key = `${syscall}|${abs}`
|
|
456
|
+
if (seen.has(key)) continue
|
|
457
|
+
seen.add(key)
|
|
458
|
+
out.push({
|
|
459
|
+
line: `${syscall}(${rawPath}) = -1 ${errno} [${abs}]`,
|
|
460
|
+
timestamp: new Date(),
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
return out
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function absolutize(p: string, cwd?: string): string {
|
|
467
|
+
if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1))
|
|
468
|
+
if (path.isAbsolute(p)) return p
|
|
469
|
+
return path.resolve(cwd ?? process.cwd(), p)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function isUnderAny(abs: string, allow: Set<string>): boolean {
|
|
473
|
+
if (allow.has(abs)) return true
|
|
474
|
+
for (const a of allow) {
|
|
475
|
+
if (abs === a || abs.startsWith(a + path.sep)) return true
|
|
476
|
+
}
|
|
477
|
+
return false
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Apply the task's user-provided `sandbox.ignoreViolations` map on top
|
|
482
|
+
* of whatever the macOS log monitor + Linux strace pass produced.
|
|
483
|
+
* Mirrors SRT's own substring-match semantics:
|
|
484
|
+
* - `'*'` entries match every command
|
|
485
|
+
* - other keys match commands whose userCommand string CONTAINS the key
|
|
486
|
+
* - the array of strings under each key is substring-matched against
|
|
487
|
+
* the violation line
|
|
488
|
+
*
|
|
489
|
+
* The defaults installed in `initSandbox` already filter on the macOS
|
|
490
|
+
* side; this pass catches per-task additions + Linux strace results.
|
|
491
|
+
*/
|
|
492
|
+
function filterIgnored(
|
|
493
|
+
violations: SandboxViolation[],
|
|
494
|
+
ignore: Record<string, string[]> | undefined,
|
|
495
|
+
userCommand: string,
|
|
496
|
+
): SandboxViolation[] {
|
|
497
|
+
if (!ignore) return violations
|
|
498
|
+
const wildcard = ignore['*'] ?? []
|
|
499
|
+
const cmdEntries = Object.entries(ignore).filter(([k]) => k !== '*')
|
|
500
|
+
return violations.filter((v) => {
|
|
501
|
+
if (wildcard.some((s) => v.line.includes(s))) return false
|
|
502
|
+
for (const [pattern, needles] of cmdEntries) {
|
|
503
|
+
if (userCommand.includes(pattern) && needles.some((s) => v.line.includes(s))) {
|
|
504
|
+
return false
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return true
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Merge the orchestrator-provided baseline (declared inputs / outputs /
|
|
513
|
+
* workspace-root anchor) with the user's resolved sandbox block to
|
|
514
|
+
* produce the SRT customConfig. Path arrays are unioned and deduped.
|
|
515
|
+
*
|
|
516
|
+
* Network coercion:
|
|
517
|
+
* - missing / undefined / false → block all (allowedDomains: [])
|
|
518
|
+
* - true → allow all (allowedDomains: ['*'])
|
|
519
|
+
* - object → use as-is (no merge with shortcuts)
|
|
520
|
+
*/
|
|
521
|
+
function buildCustomConfig(
|
|
522
|
+
args: SandboxedRunArgs,
|
|
523
|
+
): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
|
|
524
|
+
const c = args.config
|
|
525
|
+
const allowRead = unique([...args.baseAllowRead, ...c.allowRead])
|
|
526
|
+
const denyRead = unique([...args.baseDenyRead])
|
|
527
|
+
const allowWrite = unique([...args.baseAllowWrite, ...c.allowWrite])
|
|
528
|
+
|
|
529
|
+
const custom: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] = {
|
|
530
|
+
filesystem: {
|
|
531
|
+
denyRead,
|
|
532
|
+
allowRead,
|
|
533
|
+
allowWrite,
|
|
534
|
+
denyWrite: [],
|
|
535
|
+
...(c.allowGitConfig !== undefined ? { allowGitConfig: c.allowGitConfig } : {}),
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Network coercion. SRT requires allowedDomains + deniedDomains to be
|
|
540
|
+
// present on any network config; we always supply both.
|
|
541
|
+
if (c.network === true) {
|
|
542
|
+
custom.network = { allowedDomains: ['*'], deniedDomains: [] }
|
|
543
|
+
} else if (c.network && typeof c.network === 'object') {
|
|
544
|
+
custom.network = {
|
|
545
|
+
allowedDomains: c.network.allowedDomains ?? [],
|
|
546
|
+
deniedDomains: c.network.deniedDomains ?? [],
|
|
547
|
+
...(c.network.allowUnixSockets !== undefined
|
|
548
|
+
? { allowUnixSockets: c.network.allowUnixSockets }
|
|
549
|
+
: {}),
|
|
550
|
+
...(c.network.allowAllUnixSockets !== undefined
|
|
551
|
+
? { allowAllUnixSockets: c.network.allowAllUnixSockets }
|
|
552
|
+
: {}),
|
|
553
|
+
...(c.network.allowLocalBinding !== undefined
|
|
554
|
+
? { allowLocalBinding: c.network.allowLocalBinding }
|
|
555
|
+
: {}),
|
|
556
|
+
...(c.network.allowMachLookup !== undefined
|
|
557
|
+
? { allowMachLookup: c.network.allowMachLookup }
|
|
558
|
+
: {}),
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
// false / undefined → block all
|
|
562
|
+
custom.network = { allowedDomains: [], deniedDomains: [] }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (c.allowPty !== undefined) custom.allowPty = c.allowPty
|
|
566
|
+
if (c.enableWeakerNestedSandbox !== undefined) {
|
|
567
|
+
custom.enableWeakerNestedSandbox = c.enableWeakerNestedSandbox
|
|
568
|
+
}
|
|
569
|
+
if (c.enableWeakerNetworkIsolation !== undefined) {
|
|
570
|
+
custom.enableWeakerNetworkIsolation = c.enableWeakerNetworkIsolation
|
|
571
|
+
}
|
|
572
|
+
if (c.ignoreViolations !== undefined) custom.ignoreViolations = c.ignoreViolations
|
|
573
|
+
return custom
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function unique(arr: readonly string[]): string[] {
|
|
577
|
+
return [...new Set(arr)]
|
|
578
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Parser for the Turbo/Nx-style task-edge micro-syntax used in
|
|
2
|
+
// `dependsOn` and `cache.inputs.tasks`. Returns a small discriminated
|
|
3
|
+
// union the graph builder + cache filter both consume.
|
|
4
|
+
|
|
5
|
+
export type DependencySpec =
|
|
6
|
+
| { kind: 'self'; task: string; negated: boolean }
|
|
7
|
+
| { kind: 'deps'; task: string; negated: boolean }
|
|
8
|
+
| { kind: 'cross'; project: string; task: string; negated: boolean }
|
|
9
|
+
| { kind: 'wildcardSelf'; negated: boolean }
|
|
10
|
+
| { kind: 'wildcardDeps'; negated: boolean }
|
|
11
|
+
|
|
12
|
+
export class DependencySpecError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
public readonly raw: string,
|
|
15
|
+
message: string,
|
|
16
|
+
) {
|
|
17
|
+
super(`Invalid dependency spec "${raw}": ${message}`)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse one `dependsOn` / `cache.inputs.tasks` entry.
|
|
23
|
+
*
|
|
24
|
+
* `name` → self.name
|
|
25
|
+
* `^name` → every dep workspace's `name` task
|
|
26
|
+
* `pkg#name` → specific package's `name` task (cross-edge)
|
|
27
|
+
* `*` → all same-project upstream (filter-only)
|
|
28
|
+
* `^*` → all dep upstream (filter-only)
|
|
29
|
+
* `!<form>` → negation of any of the above (filter-only)
|
|
30
|
+
*
|
|
31
|
+
* Wildcards and negation are accepted everywhere — callers (the graph
|
|
32
|
+
* builder vs the filter) decide which ones make semantic sense and
|
|
33
|
+
* surface their own validation errors. This keeps the parser pure.
|
|
34
|
+
*/
|
|
35
|
+
/** True when a task form is a `*` pattern (`build.*`) rather than an exact name. */
|
|
36
|
+
export function isTaskPattern(task: string): boolean {
|
|
37
|
+
return task.includes('*')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compile a `*`-only glob over task names: `build.*` → /^build\..*$/.
|
|
42
|
+
* `*` is the sole metacharacter — everything else matches literally, so the
|
|
43
|
+
* dotted-namespace convention (`build.bun.linux-x64`) needs no escaping by
|
|
44
|
+
* the user.
|
|
45
|
+
*/
|
|
46
|
+
export function compileTaskPattern(pattern: string): RegExp {
|
|
47
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|
|
48
|
+
return new RegExp(`^${escaped}$`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseDependencySpec(raw: string): DependencySpec {
|
|
52
|
+
if (raw.length === 0) throw new DependencySpecError(raw, 'empty spec')
|
|
53
|
+
|
|
54
|
+
let negated = false
|
|
55
|
+
let s = raw
|
|
56
|
+
if (s.startsWith('!')) {
|
|
57
|
+
negated = true
|
|
58
|
+
s = s.slice(1)
|
|
59
|
+
if (s.length === 0) throw new DependencySpecError(raw, 'negation with no body')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (s.startsWith('^')) {
|
|
63
|
+
const rest = s.slice(1)
|
|
64
|
+
if (rest.length === 0) throw new DependencySpecError(raw, '"^" with no task name')
|
|
65
|
+
if (rest === '*') return { kind: 'wildcardDeps', negated }
|
|
66
|
+
if (rest.includes('#')) {
|
|
67
|
+
throw new DependencySpecError(raw, '"^" cannot combine with "pkg#task" — pick one')
|
|
68
|
+
}
|
|
69
|
+
return { kind: 'deps', task: rest, negated }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (s === '*') return { kind: 'wildcardSelf', negated }
|
|
73
|
+
|
|
74
|
+
const hashIdx = s.indexOf('#')
|
|
75
|
+
if (hashIdx >= 0) {
|
|
76
|
+
const project = s.slice(0, hashIdx)
|
|
77
|
+
const task = s.slice(hashIdx + 1)
|
|
78
|
+
if (!project || !task) {
|
|
79
|
+
throw new DependencySpecError(raw, 'pkg#task requires a non-empty project AND task')
|
|
80
|
+
}
|
|
81
|
+
return { kind: 'cross', project, task, negated }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { kind: 'self', task: s, negated }
|
|
85
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Module contract. Cross-module imports must come through here; see
|
|
2
|
+
// docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
DependencySpecError,
|
|
6
|
+
compileTaskPattern,
|
|
7
|
+
isTaskPattern,
|
|
8
|
+
parseDependencySpec,
|
|
9
|
+
type DependencySpec,
|
|
10
|
+
} from './dependency-spec.js'
|
|
11
|
+
export {
|
|
12
|
+
type ContinueMode,
|
|
13
|
+
type OutputFingerprint,
|
|
14
|
+
type ResourceCost,
|
|
15
|
+
runGraph,
|
|
16
|
+
type TaskOutcome,
|
|
17
|
+
type TaskStatus,
|
|
18
|
+
type VerifyVerdict,
|
|
19
|
+
ZERO_COST,
|
|
20
|
+
} from './scheduler.js'
|
|
21
|
+
export {
|
|
22
|
+
buildTaskGraph,
|
|
23
|
+
expandRequested,
|
|
24
|
+
isGroupTask,
|
|
25
|
+
markSurfacedDeps,
|
|
26
|
+
type TaskNode,
|
|
27
|
+
} from './task-graph.js'
|