@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
package/src/cli/why.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { splitTaskId } from '../graph/index.js'
|
|
|
11
11
|
import {
|
|
12
12
|
cacheKeyDiff,
|
|
13
13
|
explainCacheKeyQuery as explainCacheKey,
|
|
14
|
+
latestRunId,
|
|
14
15
|
whyDidThisRerunQuery as whyDidThisRerun,
|
|
15
16
|
} from '../orchestrator/index.js'
|
|
16
17
|
import { nearMatches, UserError } from '../util/index.js'
|
|
@@ -124,19 +125,6 @@ function resolveTarget(cache: Cache, target: string): string {
|
|
|
124
125
|
throw new UserError(`vx why: no recorded runs for task "${target}"${suggest(target, ids)}`)
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
/** The latest recorded run of a task (run_id may be NULL on very old rows). */
|
|
128
|
-
function latestRunId(cache: Cache, taskId: string): string | null {
|
|
129
|
-
const [project, task] = splitTaskId(taskId)
|
|
130
|
-
const row = cache
|
|
131
|
-
.dbHandle()
|
|
132
|
-
.query(
|
|
133
|
-
`SELECT run_id AS runId FROM runs WHERE project = ? AND task = ?
|
|
134
|
-
ORDER BY started_at DESC LIMIT 1`,
|
|
135
|
-
)
|
|
136
|
-
.get(project, task) as { runId: string | null } | undefined
|
|
137
|
-
return row?.runId ?? null
|
|
138
|
-
}
|
|
139
|
-
|
|
140
128
|
const fmtWhen = (ms: number): string => new Date(ms).toISOString()
|
|
141
129
|
|
|
142
130
|
export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
@@ -147,12 +135,12 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
147
135
|
}
|
|
148
136
|
|
|
149
137
|
const root = await findWorkspaceRoot(process.cwd())
|
|
150
|
-
const cache =
|
|
138
|
+
const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
|
|
151
139
|
noteSchemaReset(cache, warnToStderr)
|
|
152
140
|
try {
|
|
153
141
|
const db = cache.dbHandle()
|
|
154
142
|
const taskId = resolveTarget(cache, parsed.target)
|
|
155
|
-
const runId = parsed.runId ?? latestRunId(cache, taskId)
|
|
143
|
+
const runId = parsed.runId ?? latestRunId(cache.dbHandle(), taskId)
|
|
156
144
|
|
|
157
145
|
if (runId === null) {
|
|
158
146
|
// Runs exist (resolveTarget passed) but predate run ids — fall back to
|
|
@@ -185,9 +173,13 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
185
173
|
const lines: string[] = []
|
|
186
174
|
const t = why.thisRun!
|
|
187
175
|
lines.push(`${taskId} — run ${runId}`)
|
|
176
|
+
// A hit's status (`cache-hit`, `cache-hit-remote`) already names it and
|
|
177
|
+
// its tier; only an executed run needs the word. A skipped task never
|
|
178
|
+
// ran and has no key: it read `skipped · executed · key ` (item 898).
|
|
179
|
+
const ran = t.cacheHit === false && t.status !== 'skipped'
|
|
188
180
|
lines.push(
|
|
189
181
|
` this run ${fmtWhen(t.startedAt)} · ${t.status}` +
|
|
190
|
-
`${
|
|
182
|
+
`${ran ? ' · executed' : ''}${t.hash === '' ? ' · no key' : ` · key ${t.hash}`}`,
|
|
191
183
|
)
|
|
192
184
|
if (why.previousRun == null) {
|
|
193
185
|
lines.push(' previous (none — first recorded run of this task)')
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// a directory the run never used — `vx last` found no runs, `vx cache
|
|
5
5
|
// prune` pruned nothing, `vx watch` ignored the wrong path.
|
|
6
6
|
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
7
8
|
import path from 'node:path'
|
|
8
9
|
import type { WorkspaceConfig } from '../config.js'
|
|
9
10
|
import { UserError } from '../util/index.js'
|
|
@@ -35,13 +36,6 @@ export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorksp
|
|
|
35
36
|
return { workspaceConfig, plugins, cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig) }
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/**
|
|
39
|
-
* The run path's project-config load (`loadProjects`) for a verb that only
|
|
40
|
-
* reads: the plugin `project` stage applies, and the local cache opens only
|
|
41
|
-
* to serve cached evaluations — a pure config costs a stat, not an
|
|
42
|
-
* evaluation. `scope` is every project or a list of names; no closure, no
|
|
43
|
-
* lock (a verb reads live, as a default run does).
|
|
44
|
-
*/
|
|
45
39
|
/**
|
|
46
40
|
* `--cache-dir <path>` / `--cache-dir=<path>` for a verb that reads the
|
|
47
41
|
* cache a run wrote — `why`, `last`, `info`, `cache prune`. Same rules as
|
|
@@ -64,6 +58,17 @@ export function parseCacheDirFlag(
|
|
|
64
58
|
return { cacheDir: v, next: a === '--cache-dir' ? i + 1 : i }
|
|
65
59
|
}
|
|
66
60
|
|
|
61
|
+
/**
|
|
62
|
+
* A reading verb's `--cache-dir`, resolved against the working directory.
|
|
63
|
+
* Named explicitly, a directory that is not there is a typo to report, not
|
|
64
|
+
* an empty cache to read (item 900).
|
|
65
|
+
*/
|
|
66
|
+
export function namedCacheDir(override: string): string {
|
|
67
|
+
const dir = path.resolve(process.cwd(), override)
|
|
68
|
+
if (!existsSync(dir)) throw new UserError(`--cache-dir ${override}: no such directory (${dir})`)
|
|
69
|
+
return dir
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
/**
|
|
68
73
|
* The cache directory a reading verb opens: `--cache-dir` resolved against
|
|
69
74
|
* cwd exactly as `vx run` resolves it (prepare.ts), else the workspace's —
|
|
@@ -73,7 +78,7 @@ export async function cliCacheDir(
|
|
|
73
78
|
workspaceRoot: string,
|
|
74
79
|
override: string | undefined,
|
|
75
80
|
): Promise<string> {
|
|
76
|
-
if (override !== undefined) return
|
|
81
|
+
if (override !== undefined) return namedCacheDir(override)
|
|
77
82
|
return (await loadCliWorkspace(workspaceRoot)).cacheDir
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -85,6 +90,13 @@ export interface CliLoadOptions {
|
|
|
85
90
|
frozen?: boolean
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The run path's project-config load (`loadProjects`) for a verb that only
|
|
95
|
+
* reads: the plugin `project` stage applies, and the local cache opens only
|
|
96
|
+
* to serve cached evaluations — a pure config costs a stat, not an
|
|
97
|
+
* evaluation. `scope` is every project or a list of names; no closure, no
|
|
98
|
+
* lock (a verb reads live, as a default run does).
|
|
99
|
+
*/
|
|
88
100
|
export async function loadCliProjects(
|
|
89
101
|
workspaceRoot: string,
|
|
90
102
|
metas: readonly ProjectMeta[],
|
|
@@ -101,7 +113,7 @@ export async function loadCliProjects(
|
|
|
101
113
|
// glob could select live what the run then treats otherwise.
|
|
102
114
|
const lock = opts.frozen === true ? await readLockfile(workspaceRoot) : null
|
|
103
115
|
if (opts.frozen === true && lock === null) throw new UserError(FROZEN_WITHOUT_LOCK)
|
|
104
|
-
const cache = new Cache(cacheDir)
|
|
116
|
+
const cache = new Cache(cacheDir, { read: true, write: true }, workspaceRoot)
|
|
105
117
|
noteSchemaReset(cache, warnToStderr)
|
|
106
118
|
try {
|
|
107
119
|
const loaded = await loadProjects({
|
package/src/config.ts
CHANGED
|
@@ -13,6 +13,15 @@ export interface WorkspaceConfig {
|
|
|
13
13
|
* key (a timed-out task fails and is never cached). Omitted → no default.
|
|
14
14
|
*/
|
|
15
15
|
timeout?: number
|
|
16
|
+
/**
|
|
17
|
+
* Evict from the local cache at the end of every run that writes to it,
|
|
18
|
+
* by the same policy `vx cache prune` takes as flags: entries not used
|
|
19
|
+
* for `olderThan` (`30d`, `12h`), then least-recently-used entries
|
|
20
|
+
* until the cache is under `maxSize` (`10G`, `500MB`). Either or both.
|
|
21
|
+
* Omitted → nothing is evicted unless you run `vx cache prune`. Never
|
|
22
|
+
* folded into a cache key.
|
|
23
|
+
*/
|
|
24
|
+
cacheRetention?: { olderThan?: string; maxSize?: string }
|
|
16
25
|
/**
|
|
17
26
|
* Plugins registered for this workspace, consulted in this order once
|
|
18
27
|
* per `vx run`: each fills any of the pipeline stages and capabilities
|
|
@@ -157,10 +166,29 @@ export interface TaskConfig {
|
|
|
157
166
|
* cannot express is an error rather than a silent no-op. None of that
|
|
158
167
|
* reaches the config.
|
|
159
168
|
*
|
|
160
|
-
* The baseline
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
169
|
+
* The baseline (`sandbox: {}`) grants almost nothing: the task reads
|
|
170
|
+
* nothing, writes nothing and reaches no network — NOT EVEN ITS OWN
|
|
171
|
+
* PROJECT DIRECTORY, which is why `allow: { read: ['.'] }` opens almost
|
|
172
|
+
* every real block. The one grant vx makes for you is dependencies:
|
|
173
|
+
* `node_modules`, and through it the real path of every workspace
|
|
174
|
+
* package linked there.
|
|
175
|
+
*
|
|
176
|
+
* `cache` grants no access in either direction: `cache.inputs` says what
|
|
177
|
+
* INVALIDATES a task, `allow` says what it may TOUCH, and deriving one
|
|
178
|
+
* from the other coupled them both ways — a declaration added for
|
|
179
|
+
* caching silently widened the sandbox, and a path the task needed had
|
|
180
|
+
* to be laundered through the cache key to get it (owner, 2026-09-05).
|
|
181
|
+
* A declared `cache.outputs` is NOT a write grant; the request
|
|
182
|
+
* `sandbox-request.ts` builds carries no write of its own and binds
|
|
183
|
+
* `allow.write` alone. This comment claimed the opposite — here and on
|
|
184
|
+
* both grant fields — while the file beside it and `schema.md` both said
|
|
185
|
+
* the truth, and no test read the bare baseline it described (item 443).
|
|
186
|
+
* That case has a test of its own now.
|
|
187
|
+
*
|
|
188
|
+
* Enforcement anchors at the WORKSPACE ROOT, so a task cannot leave its
|
|
189
|
+
* project whatever it declares here. The full account, including what a
|
|
190
|
+
* write grant costs on Linux, is in `docs/schema.md` under
|
|
191
|
+
* `exec.sandbox`.
|
|
164
192
|
*
|
|
165
193
|
* Paths are project-relative, absolute, or `~`-expanded — and are path
|
|
166
194
|
* PREFIXES, never globs (a glob cannot be enforced by a mount).
|
|
@@ -204,9 +232,18 @@ export interface SandboxConfig {
|
|
|
204
232
|
}
|
|
205
233
|
|
|
206
234
|
export interface SandboxGrants {
|
|
207
|
-
/**
|
|
235
|
+
/**
|
|
236
|
+
* Readable path prefixes, added to the baseline — which is
|
|
237
|
+
* `node_modules`, NOT `cache.inputs.files`. A task that reads its own
|
|
238
|
+
* source in the sandbox says so here.
|
|
239
|
+
*/
|
|
208
240
|
read?: string[]
|
|
209
|
-
/**
|
|
241
|
+
/**
|
|
242
|
+
* Writable path prefixes. The task can write NOTHING without these:
|
|
243
|
+
* declaring `cache.outputs.files` grants no write, and a task whose
|
|
244
|
+
* only declaration is its outputs writes into a tmpfs that evaporates
|
|
245
|
+
* (vx then warns that `cache.outputs` matched no files).
|
|
246
|
+
*/
|
|
210
247
|
write?: string[]
|
|
211
248
|
/**
|
|
212
249
|
* Reachable domains. `true` allows all, `['*.example.com']` a pattern.
|
|
@@ -305,9 +342,9 @@ export interface ExecConfig {
|
|
|
305
342
|
* Confine this task's process: what it may read, write and reach.
|
|
306
343
|
* Nothing is confined unless this is present.
|
|
307
344
|
*
|
|
308
|
-
* It sits in `exec` because it constrains the same spawn `env`
|
|
309
|
-
*
|
|
310
|
-
*
|
|
345
|
+
* It sits in `exec` because it constrains the same spawn `env` does —
|
|
346
|
+
* the environment the child gets, the filesystem and network it gets.
|
|
347
|
+
* A group task has no process,
|
|
311
348
|
* so the field cannot be reached from one.
|
|
312
349
|
*
|
|
313
350
|
* **Policy: fail on violation.** An undeclared touch of the project's own
|
package/src/exec/executor.ts
CHANGED
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
import type { CaptureConfig, RunResult } from './runner.js'
|
|
15
15
|
import type { ResolvedSandboxConfig, SandboxViolation } from './sandbox-runtime.js'
|
|
16
|
+
import { UserError } from '../util/index.js'
|
|
16
17
|
|
|
17
18
|
/** Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. */
|
|
18
19
|
export interface ExecuteSandbox {
|
|
19
20
|
readonly baseAllowRead: readonly string[]
|
|
20
|
-
readonly baseAllowWrite: readonly string[]
|
|
21
21
|
readonly baseDenyRead: readonly string[]
|
|
22
22
|
/**
|
|
23
23
|
* Only denials on a path under this directory are reported. A denial
|
|
@@ -25,6 +25,13 @@ export interface ExecuteSandbox {
|
|
|
25
25
|
* that is the wall doing its job, not a finding to fail a run over.
|
|
26
26
|
*/
|
|
27
27
|
readonly reportWithin: string
|
|
28
|
+
/**
|
|
29
|
+
* Canonical directories of the workspace packages core withheld from
|
|
30
|
+
* this task's `node_modules` grant (its key does not answer for them).
|
|
31
|
+
* A denial under one is reported too: the task reached for a dependency,
|
|
32
|
+
* which is the finding, not the wall.
|
|
33
|
+
*/
|
|
34
|
+
readonly reportLinked: readonly string[]
|
|
28
35
|
readonly config: ResolvedSandboxConfig
|
|
29
36
|
}
|
|
30
37
|
|
|
@@ -47,8 +54,8 @@ export interface InputFile {
|
|
|
47
54
|
export interface TaskInputs {
|
|
48
55
|
/** Declared `cache.inputs.files` + `workspaceFiles` (own outputs excluded), sorted by path. */
|
|
49
56
|
readonly files: readonly InputFile[]
|
|
50
|
-
/** Declared `cache.inputs.env` names with their resolved values. */
|
|
51
|
-
readonly env: ReadonlyArray<{ readonly name: string; readonly value: string }>
|
|
57
|
+
/** Declared `cache.inputs.env` names with their resolved values; `undefined` when unset. */
|
|
58
|
+
readonly env: ReadonlyArray<{ readonly name: string; readonly value: string | undefined }>
|
|
52
59
|
/** `cache.inputs.runtime` commands and the output that was folded — a toolchain expectation. */
|
|
53
60
|
readonly runtime: ReadonlyArray<{ readonly command: string; readonly output: string }>
|
|
54
61
|
/** `cache.inputs.workspaceRuntime`, same shape. */
|
|
@@ -166,8 +173,8 @@ export interface TaskPlacement {
|
|
|
166
173
|
readonly command: string
|
|
167
174
|
/**
|
|
168
175
|
* Must run on this machine: the task is persistent, depends (transitively)
|
|
169
|
-
* on a persistent task,
|
|
170
|
-
* executor is never offered such a task.
|
|
176
|
+
* on a persistent or sandboxed task, is sandboxed itself, or declares
|
|
177
|
+
* `exec.remote: false`. A `remote` executor is never offered such a task.
|
|
171
178
|
*/
|
|
172
179
|
readonly pinnedLocal: boolean
|
|
173
180
|
/** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
|
|
@@ -204,6 +211,45 @@ export interface TaskExecutor {
|
|
|
204
211
|
execute(req: ExecuteRequest): Promise<ExecuteResult>
|
|
205
212
|
}
|
|
206
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The result an executor resolves with is a plugin's, so its shape is a
|
|
216
|
+
* boundary: a plugin that resolved `{}` met `res.violations` in core and
|
|
217
|
+
* became "internal error in <task>: TypeError …" — vx's crash, the plugin's
|
|
218
|
+
* bug (2026-09-16). Name the executor and the field instead. The factory's
|
|
219
|
+
* output is checked the same way in `resolveExecutors`.
|
|
220
|
+
*/
|
|
221
|
+
export function assertExecuteResult(
|
|
222
|
+
executor: string,
|
|
223
|
+
taskId: string,
|
|
224
|
+
res: unknown,
|
|
225
|
+
): asserts res is ExecuteResult {
|
|
226
|
+
const fail = (what: string): never => {
|
|
227
|
+
throw new UserError(
|
|
228
|
+
`executor '${executor}' returned an invalid result for ${taskId}: ${what} — a plugin bug, not a task failure`,
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
if (typeof res !== 'object' || res === null)
|
|
232
|
+
fail(`${res === null ? 'null' : typeof res} (expected an object)`)
|
|
233
|
+
const r = res as Record<string, unknown>
|
|
234
|
+
for (const key of ['exitCode', 'durationMs']) {
|
|
235
|
+
if (typeof r[key] !== 'number') fail(`${key} is ${typeof r[key]} (expected a number)`)
|
|
236
|
+
}
|
|
237
|
+
for (const key of ['stdout', 'stderr']) {
|
|
238
|
+
if (typeof r[key] !== 'string') fail(`${key} is ${typeof r[key]} (expected a string)`)
|
|
239
|
+
}
|
|
240
|
+
if (!Array.isArray(r['violations']))
|
|
241
|
+
fail(`violations is ${typeof r['violations']} (expected an array)`)
|
|
242
|
+
const outputs = r['outputs'] as { kind?: unknown; materialize?: unknown } | undefined
|
|
243
|
+
if (outputs !== undefined) {
|
|
244
|
+
if (typeof outputs !== 'object' || outputs === null) fail('outputs is not an object')
|
|
245
|
+
else if (outputs.kind === 'deferred') {
|
|
246
|
+
if (typeof outputs.materialize !== 'function')
|
|
247
|
+
fail('outputs.kind is deferred without a materialize()')
|
|
248
|
+
} else if (outputs.kind !== 'disk')
|
|
249
|
+
fail(`outputs.kind is ${String(outputs.kind)} (expected disk or deferred)`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
207
253
|
/**
|
|
208
254
|
* Place one task: the first executor, in declaration order, that may take
|
|
209
255
|
* it — a `remote` executor is skipped for a `pinnedLocal` task, then
|
package/src/exec/index.ts
CHANGED
|
@@ -5,9 +5,12 @@ export { buildIsolatedEnv, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from './env.j
|
|
|
5
5
|
export {
|
|
6
6
|
runCommand,
|
|
7
7
|
runPersistent,
|
|
8
|
+
PersistentReadyError,
|
|
8
9
|
shellQuote,
|
|
9
10
|
signalExitCode,
|
|
11
|
+
exitSignal,
|
|
10
12
|
type CaptureConfig,
|
|
13
|
+
execWord,
|
|
11
14
|
} from './runner.js'
|
|
12
15
|
export {
|
|
13
16
|
initSandbox,
|
|
@@ -20,10 +23,17 @@ export {
|
|
|
20
23
|
wrapSandboxedCommand,
|
|
21
24
|
type ResolvedSandboxConfig,
|
|
22
25
|
type SandboxViolation,
|
|
26
|
+
dependencyReason,
|
|
27
|
+
socketPathRefusal,
|
|
28
|
+
thrownReason,
|
|
23
29
|
} from './sandbox-runtime.js'
|
|
24
30
|
export { type DeniedCall } from './sandbox-violations.js'
|
|
31
|
+
export { isMountableLiteral, MOUNT_WILDCARDS, toRealPath } from './sandbox-paths.js'
|
|
32
|
+
export { bindableWrites, punchWalls } from './sandbox-binds.js'
|
|
25
33
|
export { localExecutor } from './local-executor.js'
|
|
34
|
+
export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
|
|
26
35
|
export {
|
|
36
|
+
assertExecuteResult,
|
|
27
37
|
selectExecutor,
|
|
28
38
|
type ExecuteRequest,
|
|
29
39
|
type ExecuteResult,
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Signal a task and everything it forked. Every task child is spawned
|
|
2
|
+
// `detached` — its own session and process group — so the group IS the
|
|
3
|
+
// task's tree: the shell, what the shell backgrounded (`server &`), a
|
|
4
|
+
// test runner's workers. Signalling the child alone left all of that
|
|
5
|
+
// alive: a timed-out `sh -c "x & y"` killed the shell and orphaned `x`,
|
|
6
|
+
// and a Ctrl-C did the same for every compound command (2026-09-16). A
|
|
7
|
+
// group signal reaches a member whose parent already died, too — the
|
|
8
|
+
// group outlives its leader — so the order of death does not matter.
|
|
9
|
+
// What still escapes is a daemon that calls setsid itself (the residual
|
|
10
|
+
// every non-cgroup runner shares); under a sandbox the pid namespace
|
|
11
|
+
// takes even that.
|
|
12
|
+
|
|
13
|
+
import { closeSync, readdirSync, readFileSync, writeSync } from 'node:fs'
|
|
14
|
+
import { executablePath, procfsIsOwn } from '../util/index.js'
|
|
15
|
+
|
|
16
|
+
export type Child = ReturnType<typeof Bun.spawn>
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A child whose polite signals go down a pipe instead of to its group: a
|
|
20
|
+
* Linux sandboxed task. The group there is bwrap's, whose monitor dies of
|
|
21
|
+
* a SIGTERM and takes the namespace down with SIGKILL (`--die-with-parent`),
|
|
22
|
+
* so the command's own cleanup never ran; a shell inside reads the
|
|
23
|
+
* signal's name off the pipe and signals the command's group (item 752).
|
|
24
|
+
* SIGKILL still goes to the group: bwrap's death is the hard stop.
|
|
25
|
+
*/
|
|
26
|
+
const channels = new Map<Child, number>()
|
|
27
|
+
|
|
28
|
+
/** Route `child`'s SIGINT and SIGTERM through `fd`, an end vx owns. */
|
|
29
|
+
export function signalThrough(child: Child, fd: number): void {
|
|
30
|
+
channels.set(child, fd)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Close `child`'s channel once it has exited. The entry goes before the
|
|
35
|
+
* descriptor, so a later kill never writes to a number the process has
|
|
36
|
+
* since reused.
|
|
37
|
+
*/
|
|
38
|
+
export function closeSignalChannel(child: Child): void {
|
|
39
|
+
const fd = channels.get(child)
|
|
40
|
+
if (fd === undefined) return
|
|
41
|
+
channels.delete(child)
|
|
42
|
+
try {
|
|
43
|
+
closeSync(fd)
|
|
44
|
+
} catch {
|
|
45
|
+
// already closed
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The groups vx must take down if it dies without running a line: a
|
|
51
|
+
* `kill -9`, the OOM killer. Nothing in vx runs then, so the kill has to
|
|
52
|
+
* live outside it — one `sh` per vx process, its own group, reading a
|
|
53
|
+
* pipe only vx holds the write end of. `+<pgid>` and `-<pgid>` lines keep
|
|
54
|
+
* its list; when vx dies the kernel closes the pipe, the read hits EOF,
|
|
55
|
+
* and every group still listed is SIGKILLed — what bwrap's
|
|
56
|
+
* `--die-with-parent` does for a Linux sandboxed task, extended to the
|
|
57
|
+
* rest. A group signal reaches what the task forked, which
|
|
58
|
+
* `PR_SET_PDEATHSIG` never did (kill-tree.md). Started just before the
|
|
59
|
+
* first spawn, so a run that spawns nothing (a warm run) never starts
|
|
60
|
+
* it; a normal exit leaves the list empty, so the EOF then kills nothing.
|
|
61
|
+
* `undefined` until then, `null` once it could not start or write: the
|
|
62
|
+
* guard is best-effort, and its failure is the documented limit, never a
|
|
63
|
+
* failed task.
|
|
64
|
+
*/
|
|
65
|
+
let guardFd: number | null | undefined
|
|
66
|
+
|
|
67
|
+
const GUARD_SCRIPT = [
|
|
68
|
+
"g=' '",
|
|
69
|
+
'while IFS= read -r l; do',
|
|
70
|
+
' case $l in',
|
|
71
|
+
' +*) g="$g${l#+} " ;;',
|
|
72
|
+
' -*) p=${l#-}; case $g in *" $p "*) g="${g%% $p *} ${g#* $p }" ;; esac ;;',
|
|
73
|
+
' esac',
|
|
74
|
+
'done <&3',
|
|
75
|
+
'for p in $g; do kill -s KILL -- "-$p"; done 2>/dev/null',
|
|
76
|
+
].join('\n')
|
|
77
|
+
|
|
78
|
+
function startGuard(): void {
|
|
79
|
+
if (guardFd !== undefined) return
|
|
80
|
+
guardFd = null
|
|
81
|
+
try {
|
|
82
|
+
const guard = Bun.spawn([executablePath('sh'), '-c', GUARD_SCRIPT], {
|
|
83
|
+
argv0: 'vx-group-guard',
|
|
84
|
+
stdio: ['ignore', 'ignore', 'ignore', 'pipe'],
|
|
85
|
+
// Out of vx's group, so a Ctrl-C at the terminal leaves it to the
|
|
86
|
+
// pipe; vx's own teardown owns that path.
|
|
87
|
+
detached: true,
|
|
88
|
+
})
|
|
89
|
+
guard.unref()
|
|
90
|
+
guardFd = guard.stdio[3] as number
|
|
91
|
+
} catch {
|
|
92
|
+
// The limit as it was: nothing takes the groups down.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function guardWrite(line: string): void {
|
|
97
|
+
if (typeof guardFd !== 'number') return
|
|
98
|
+
try {
|
|
99
|
+
writeSync(guardFd, line)
|
|
100
|
+
} catch {
|
|
101
|
+
guardFd = null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Spawn a task child and list its group for the guard to kill if vx dies
|
|
107
|
+
* holding it. The guard starts BEFORE the spawn: started after it, the
|
|
108
|
+
* guard's own spawn was a window in which the task ran and a `kill -9`
|
|
109
|
+
* of vx found its group unlisted — under a traced sandbox, a third of
|
|
110
|
+
* the kills landed there. What is left is the step from the spawn's
|
|
111
|
+
* return to one pipe write.
|
|
112
|
+
*/
|
|
113
|
+
export function spawnGuarded(spawn: () => Child): Child {
|
|
114
|
+
startGuard()
|
|
115
|
+
const child = spawn()
|
|
116
|
+
if (child.pid > 0) guardWrite(`+${child.pid}\n`)
|
|
117
|
+
return child
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Strike `child`'s group from the guard's list: vx is done with it. What
|
|
122
|
+
* it left running is left as before, and a pid the kernel reuses is
|
|
123
|
+
* never killed on the old task's account. A group a teardown holds is
|
|
124
|
+
* struck when the teardown lets it go.
|
|
125
|
+
*/
|
|
126
|
+
export function releaseGroup(child: Child): void {
|
|
127
|
+
if (!(child.pid > 0)) return
|
|
128
|
+
const hold = holds.get(child.pid)
|
|
129
|
+
if (hold !== undefined) hold.released = true
|
|
130
|
+
else guardWrite(`-${child.pid}\n`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Groups a teardown is still taking down: how many hold each, and whether its runner let it go. */
|
|
134
|
+
const holds = new Map<number, { count: number; released: boolean }>()
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Keep `children`'s groups on the guard's list until the returned
|
|
138
|
+
* function runs. A teardown signals a group and waits out a grace for it,
|
|
139
|
+
* and the runner lets a group go when its LEADER exits: a shell that died
|
|
140
|
+
* on the signal while a child that ignores it ran on released the group
|
|
141
|
+
* mid-grace, and a `kill -9` of vx there left that child to nobody (item
|
|
142
|
+
* 865). The caller lets go once its SIGKILL sweep has settled.
|
|
143
|
+
*/
|
|
144
|
+
export function holdGroups(children: readonly Child[]): () => void {
|
|
145
|
+
const pids = children.map((c) => c.pid).filter((pid) => pid > 0)
|
|
146
|
+
for (const pid of pids) {
|
|
147
|
+
const hold = holds.get(pid)
|
|
148
|
+
if (hold === undefined) holds.set(pid, { count: 1, released: false })
|
|
149
|
+
else hold.count++
|
|
150
|
+
}
|
|
151
|
+
return () => {
|
|
152
|
+
for (const pid of pids) {
|
|
153
|
+
const hold = holds.get(pid)
|
|
154
|
+
if (hold === undefined) continue
|
|
155
|
+
if (--hold.count > 0) continue
|
|
156
|
+
holds.delete(pid)
|
|
157
|
+
if (hold.released) guardWrite(`-${pid}\n`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Children whose group was empty when their leader exited. Its number is
|
|
164
|
+
* free from then on, and the kernel hands it to the next process that
|
|
165
|
+
* needs it: a persistent server stays in the run's registry after it
|
|
166
|
+
* exits (keep-alive reports it), and a teardown that signalled `-pid`
|
|
167
|
+
* there could reach whatever group holds the number now (item 874). A
|
|
168
|
+
* group that still had a member keeps its number reserved, and is
|
|
169
|
+
* signalled as before.
|
|
170
|
+
*/
|
|
171
|
+
const goneGroups = new WeakSet<Child>()
|
|
172
|
+
|
|
173
|
+
/** Note, as `child`'s leader exits, whether its group went with it. */
|
|
174
|
+
export function markGroupIfGone(child: Child): void {
|
|
175
|
+
if (!groupAlive(child)) goneGroups.add(child)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function killTree(child: Child, signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL'): void {
|
|
179
|
+
// A pid of 0 would name OUR group (kill(0)): a child that never
|
|
180
|
+
// spawned has nothing to kill.
|
|
181
|
+
if (!(child.pid > 0) || goneGroups.has(child)) return
|
|
182
|
+
const fd = signal === 'SIGKILL' ? undefined : channels.get(child)
|
|
183
|
+
if (fd !== undefined) {
|
|
184
|
+
try {
|
|
185
|
+
writeSync(fd, `${signal.slice(3)}\n`)
|
|
186
|
+
return
|
|
187
|
+
} catch {
|
|
188
|
+
// The reader is gone (EPIPE): so is the command. The group signal
|
|
189
|
+
// below finds what is left, or nothing.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
process.kill(-child.pid, signal)
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// ESRCH: the whole group is gone. Anything else (a group that is not
|
|
196
|
+
// ours to signal): the child itself, as before.
|
|
197
|
+
if ((err as NodeJS.ErrnoException).code === 'ESRCH') return
|
|
198
|
+
child.kill(signal)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* How often a group whose leader has exited is asked again whether any
|
|
204
|
+
* member is left. Only a group that outlived its leader is polled.
|
|
205
|
+
*/
|
|
206
|
+
const GROUP_POLL_MS = 20
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Wait until every child's process group is gone, or `graceMs` passes.
|
|
210
|
+
* Resolves with the children whose group still has a live member: the
|
|
211
|
+
* ones to SIGKILL. The leader's exit is not the end of the tree — a
|
|
212
|
+
* `server & wait` shell dies on SIGTERM while a server that ignores it
|
|
213
|
+
* keeps the group, and the task's pipe, alive — so after the leaders the
|
|
214
|
+
* groups themselves are polled, and only while one is left.
|
|
215
|
+
*/
|
|
216
|
+
export async function untilGroupsGone(
|
|
217
|
+
children: readonly Child[],
|
|
218
|
+
graceMs: number,
|
|
219
|
+
): Promise<Child[]> {
|
|
220
|
+
const deadline = Date.now() + graceMs
|
|
221
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
222
|
+
// Not unref'd: it is what guarantees progress when every other handle
|
|
223
|
+
// has drained, and the grace is bounded either way.
|
|
224
|
+
await Promise.race([
|
|
225
|
+
Promise.allSettled(children.map((c) => c.exited)),
|
|
226
|
+
new Promise<void>((resolve) => {
|
|
227
|
+
timer = setTimeout(resolve, graceMs)
|
|
228
|
+
}),
|
|
229
|
+
])
|
|
230
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
231
|
+
let left = children.filter(groupAlive)
|
|
232
|
+
while (left.length > 0 && Date.now() < deadline) {
|
|
233
|
+
await Bun.sleep(Math.min(GROUP_POLL_MS, deadline - Date.now()))
|
|
234
|
+
left = left.filter(groupAlive)
|
|
235
|
+
}
|
|
236
|
+
return left
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Does any process of the child's group still run? `kill(-pgid, 0)`
|
|
241
|
+
* answers for the group but counts a zombie, and an orphaned member that
|
|
242
|
+
* died on the SIGTERM waits as one until init reaps it — 1 to 2 s under a
|
|
243
|
+
* container's init (measured 2026-09-24) — so on Linux a group the kernel
|
|
244
|
+
* still knows is read from /proc, where a zombie says so — when /proc is
|
|
245
|
+
* this namespace's. One mounted for another reads as no member at all,
|
|
246
|
+
* and a group that ignored the SIGTERM went un-killed; there the group
|
|
247
|
+
* signal is the answer and the grace ends the wait.
|
|
248
|
+
*/
|
|
249
|
+
function groupAlive(child: Child): boolean {
|
|
250
|
+
if (!(child.pid > 0) || goneGroups.has(child)) return false
|
|
251
|
+
try {
|
|
252
|
+
process.kill(-child.pid, 0)
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
255
|
+
}
|
|
256
|
+
return !procfsIsOwn() || groupHasLiveMember(child.pid)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function groupHasLiveMember(pgid: number): boolean {
|
|
260
|
+
let entries: string[]
|
|
261
|
+
try {
|
|
262
|
+
entries = readdirSync('/proc')
|
|
263
|
+
} catch {
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
const first = entry.charCodeAt(0)
|
|
268
|
+
if (first < 48 || first > 57) continue
|
|
269
|
+
let stat: string
|
|
270
|
+
try {
|
|
271
|
+
stat = readFileSync(`/proc/${entry}/stat`, 'utf8')
|
|
272
|
+
} catch {
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
// `<pid> (<comm>) <state> <ppid> <pgrp> …` — comm may hold spaces and
|
|
276
|
+
// parens, so the fields start after the LAST ')'.
|
|
277
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ', 3)
|
|
278
|
+
if (Number(fields[2]) === pgid && fields[0] !== 'Z' && fields[0] !== 'X') return true
|
|
279
|
+
}
|
|
280
|
+
return false
|
|
281
|
+
}
|
|
@@ -31,9 +31,9 @@ export function localExecutor(): TaskExecutor {
|
|
|
31
31
|
return runSandboxed({
|
|
32
32
|
...common,
|
|
33
33
|
baseAllowRead: req.sandbox.baseAllowRead,
|
|
34
|
-
baseAllowWrite: req.sandbox.baseAllowWrite,
|
|
35
34
|
baseDenyRead: req.sandbox.baseDenyRead,
|
|
36
35
|
reportWithin: req.sandbox.reportWithin,
|
|
36
|
+
reportLinked: req.sandbox.reportLinked,
|
|
37
37
|
config: req.sandbox.config,
|
|
38
38
|
})
|
|
39
39
|
},
|