@vzn/vx 0.0.21 → 0.0.23

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.
Files changed (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. 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 = new Cache(await cliCacheDir(root, parsed.cacheDir))
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
- `${t.cacheHit === true ? ' · cache hit' : t.cacheHit === false ? ' · executed' : ''} · key ${t.hash}`,
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 path.resolve(process.cwd(), override)
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 is always the task's own declarations: it may read its
161
- * resolved `cache.inputs.files`, write the prefixes of its
162
- * `cache.outputs.files`, and reach nothing else. Everything here is on top
163
- * of that.
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
- /** Readable path prefixes, beyond the resolved `cache.inputs.files`. */
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
- /** Writable path prefixes, beyond the `cache.outputs.files` prefixes. */
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` and
309
- * `resources` do — the environment the child gets, the CPU and memory it
310
- * gets, the filesystem and network it gets. A group task has no process,
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
@@ -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, or declares `exec.remote: false`. A `remote`
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
  },