@vzn/vx 0.0.18 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/why.ts CHANGED
@@ -5,20 +5,24 @@
5
5
  // names the exact cache-key components that differ. Read-only over cache.db —
6
6
  // no config evaluation, no re-hash.
7
7
 
8
- import { Cache } from '../cache/index.js'
8
+ import { Cache, noteSchemaReset } from '../cache/index.js'
9
+ import { seeHelp } from './help.js'
9
10
  import { splitTaskId } from '../graph/index.js'
10
11
  import {
11
12
  cacheKeyDiff,
12
13
  explainCacheKeyQuery as explainCacheKey,
13
14
  whyDidThisRerunQuery as whyDidThisRerun,
14
15
  } from '../orchestrator/index.js'
15
- import { UserError } from '../util/index.js'
16
- import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
16
+ import { nearMatches, UserError } from '../util/index.js'
17
+ import { findWorkspaceRoot } from '../workspace/index.js'
18
+ import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
17
19
 
18
20
  interface WhyArgs {
19
21
  target?: string
20
22
  runId?: string
21
23
  format: 'pretty' | 'json'
24
+ /** `--cache-dir`: read the history a run with the same flag wrote. */
25
+ cacheDir?: string
22
26
  error?: string
23
27
  }
24
28
 
@@ -51,20 +55,37 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
51
55
  out.format = fv
52
56
  continue
53
57
  }
54
- if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
58
+ const cd = parseCacheDirFlag(args, i)
59
+ if (cd !== null) {
60
+ if ('error' in cd) return { ...out, error: cd.error }
61
+ out.cacheDir = cd.cacheDir
62
+ i = cd.next
63
+ continue
64
+ }
65
+ if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('why')}` }
55
66
  if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
56
67
  out.target = a
57
68
  }
58
69
  return out
59
70
  }
60
71
 
61
- /** Simple includes-match in both directions, case-insensitive. */
62
- function suggest(query: string, candidates: readonly string[]): string {
63
- const q = query.toLowerCase()
64
- const hits = candidates.filter((c) => {
65
- const n = c.toLowerCase()
66
- return n.includes(q) || q.includes(n)
67
- })
72
+ /**
73
+ * A bare query is a TASK name, so it is matched against the task half of
74
+ * every recorded id (the same rule `vx run` hints with) and the hint is
75
+ * the runnable `project#task`; an anchored query is matched whole.
76
+ */
77
+ function suggest(query: string, ids: readonly string[]): string {
78
+ let hits: string[]
79
+ if (query.includes('#')) {
80
+ hits = nearMatches(query, ids)
81
+ } else {
82
+ const byTask = new Map<string, string[]>()
83
+ for (const id of ids) {
84
+ const task = splitTaskId(id)[1]
85
+ byTask.set(task, [...(byTask.get(task) ?? []), id])
86
+ }
87
+ hits = nearMatches(query, byTask.keys()).flatMap((t) => byTask.get(t) ?? [])
88
+ }
68
89
  return hits.length > 0 ? ` — did you mean ${hits.slice(0, 3).join(', ')}?` : ''
69
90
  }
70
91
 
@@ -75,12 +96,21 @@ function suggest(query: string, candidates: readonly string[]): string {
75
96
  */
76
97
  function resolveTarget(cache: Cache, target: string): string {
77
98
  const db = cache.dbHandle()
99
+ if (target.includes('#')) {
100
+ // An exact id needs one row, and its newest is where a scan from the
101
+ // top of the table finds it first — the full DISTINCT walk below is for
102
+ // the error's suggestions and the bare-name form only.
103
+ const [project, task] = splitTaskId(target)
104
+ const hit = db
105
+ .query('SELECT 1 FROM runs WHERE project = ? AND task = ? ORDER BY id DESC LIMIT 1')
106
+ .get(project, task)
107
+ if (hit !== null) return target
108
+ }
78
109
  const pairs = db
79
110
  .query('SELECT DISTINCT project, task FROM runs ORDER BY project, task')
80
111
  .all() as Array<{ project: string; task: string }>
81
112
  const ids = pairs.map((p) => `${p.project}#${p.task}`)
82
113
  if (target.includes('#')) {
83
- if (ids.includes(target)) return target
84
114
  throw new UserError(`vx why: no recorded runs for "${target}"${suggest(target, ids)}`)
85
115
  }
86
116
  const matches = ids.filter((id) => id.endsWith(`#${target}`))
@@ -117,8 +147,8 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
117
147
  }
118
148
 
119
149
  const root = await findWorkspaceRoot(process.cwd())
120
- const cacheDir = resolveCacheDir(root, await loadWorkspaceConfig(root))
121
- const cache = new Cache(cacheDir)
150
+ const cache = new Cache(await cliCacheDir(root, parsed.cacheDir))
151
+ noteSchemaReset(cache, warnToStderr)
122
152
  try {
123
153
  const db = cache.dbHandle()
124
154
  const taskId = resolveTarget(cache, parsed.target)
@@ -0,0 +1,126 @@
1
+ // The workspace as every verb must see it: the config with the plugin
2
+ // `config` stage applied, and the cache dir derived from THAT. The stage
3
+ // is documented to shape `cacheDir`; a verb that read the file raw opened
4
+ // a directory the run never used — `vx last` found no runs, `vx cache
5
+ // prune` pruned nothing, `vx watch` ignored the wrong path.
6
+
7
+ import path from 'node:path'
8
+ import type { WorkspaceConfig } from '../config.js'
9
+ import { UserError } from '../util/index.js'
10
+ import { Cache, noteSchemaReset } from '../cache/index.js'
11
+ import { loadProjects, loadWorkspacePlugins } from '../orchestrator/index.js'
12
+ import type { VxPlugin } from '../orchestrator/index.js'
13
+ import {
14
+ buildPackageGraph,
15
+ computeWorkspaceFingerprint,
16
+ FROZEN_WITHOUT_LOCK,
17
+ type ProjectEntry,
18
+ type ProjectMeta,
19
+ readLockfile,
20
+ resolveCacheDir,
21
+ } from '../workspace/index.js'
22
+
23
+ export interface CliWorkspace {
24
+ workspaceConfig: WorkspaceConfig | null
25
+ plugins: readonly VxPlugin[]
26
+ cacheDir: string
27
+ }
28
+
29
+ export const warnToStderr = (message: string): void => {
30
+ process.stderr.write(`${message}\n`)
31
+ }
32
+
33
+ export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorkspace> {
34
+ const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnToStderr)
35
+ return { workspaceConfig, plugins, cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig) }
36
+ }
37
+
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
+ /**
46
+ * `--cache-dir <path>` / `--cache-dir=<path>` for a verb that reads the
47
+ * cache a run wrote — `why`, `last`, `info`, `cache prune`. Same rules as
48
+ * `vx run`'s: a value is required, and the space form refuses a value
49
+ * that looks like a flag (an unquoted empty shell variable would otherwise
50
+ * swallow the next flag as the path). Returns null when `args[i]` is not
51
+ * this flag; `next` is the index of the last argv slot consumed.
52
+ */
53
+ export function parseCacheDirFlag(
54
+ args: readonly string[],
55
+ i: number,
56
+ ): { cacheDir: string; next: number } | { error: string } | null {
57
+ const a = args[i]!
58
+ if (a !== '--cache-dir' && !a.startsWith('--cache-dir=')) return null
59
+ const v = a === '--cache-dir' ? args[i + 1] : a.slice('--cache-dir='.length)
60
+ if (v === undefined || v === '') return { error: '--cache-dir requires a path' }
61
+ if (a === '--cache-dir' && v.startsWith('-')) {
62
+ return { error: `--cache-dir requires a path, got flag: ${v}` }
63
+ }
64
+ return { cacheDir: v, next: a === '--cache-dir' ? i + 1 : i }
65
+ }
66
+
67
+ /**
68
+ * The cache directory a reading verb opens: `--cache-dir` resolved against
69
+ * cwd exactly as `vx run` resolves it (prepare.ts), else the workspace's —
70
+ * so `vx run --cache-dir X` and `vx last --cache-dir X` read one history.
71
+ */
72
+ export async function cliCacheDir(
73
+ workspaceRoot: string,
74
+ override: string | undefined,
75
+ ): Promise<string> {
76
+ if (override !== undefined) return path.resolve(process.cwd(), override)
77
+ return (await loadCliWorkspace(workspaceRoot)).cacheDir
78
+ }
79
+
80
+ /** The run flags a verb's staged load must honour to see what the run sees. */
81
+ export interface CliLoadOptions {
82
+ /** `--cache-dir`: where the cached evaluations live. */
83
+ cacheDir?: string
84
+ /** `--frozen`: configs come from `vx-lock.json`, not evaluation. */
85
+ frozen?: boolean
86
+ }
87
+
88
+ export async function loadCliProjects(
89
+ workspaceRoot: string,
90
+ metas: readonly ProjectMeta[],
91
+ scope: 'all' | readonly string[] = 'all',
92
+ opts: CliLoadOptions = {},
93
+ ): Promise<Map<string, ProjectEntry>> {
94
+ const ws = await loadCliWorkspace(workspaceRoot)
95
+ const { plugins } = ws
96
+ // The cached evaluations live where the run's do: a verb given
97
+ // `--cache-dir` must not open (and create) the workspace's default one.
98
+ const cacheDir = opts.cacheDir ?? ws.cacheDir
99
+ // A frozen run reads its configs from the lock; what it SELECTS must be
100
+ // decided from the same configs, or an env-dependent `workspaceFiles`
101
+ // glob could select live what the run then treats otherwise.
102
+ const lock = opts.frozen === true ? await readLockfile(workspaceRoot) : null
103
+ if (opts.frozen === true && lock === null) throw new UserError(FROZEN_WITHOUT_LOCK)
104
+ const cache = new Cache(cacheDir)
105
+ noteSchemaReset(cache, warnToStderr)
106
+ try {
107
+ const loaded = await loadProjects({
108
+ workspaceRoot,
109
+ cacheDir,
110
+ plugins,
111
+ projectMetas: metas,
112
+ packageGraph: buildPackageGraph([...metas]),
113
+ seeds: scope,
114
+ closure: false,
115
+ lock,
116
+ evalCache: {
117
+ store: cache,
118
+ workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot),
119
+ },
120
+ warn: warnToStderr,
121
+ })
122
+ return loaded.projects
123
+ } finally {
124
+ cache.close()
125
+ }
126
+ }
package/src/config.ts CHANGED
@@ -14,13 +14,25 @@ export interface WorkspaceConfig {
14
14
  */
15
15
  timeout?: number
16
16
  /**
17
- * Plugins registered for this workspace. Each plugin is an
18
- * in-process subscriber on the run event bus, installed once per
19
- * `vx run`. See `docs/design/extension-protocol-2026-06.md` §5.
17
+ * Plugins registered for this workspace, consulted in this order once
18
+ * per `vx run`: each fills any of the pipeline stages and capabilities
19
+ * below, and core's own executor and cache store sit at the tail of
20
+ * every list. See `docs/design/pipeline-2026-09.md`.
20
21
  */
21
22
  plugins?: readonly Plugin[]
22
23
  }
23
24
 
25
+ /**
26
+ * The runtime brand `definePlugin` stamps on a plugin: the name of the
27
+ * package it was defined in. The workspace loader accepts a plugin only
28
+ * when this is present and equals `name`, so a plugin's name is its
29
+ * package name and nothing else — no field to set, no override. A
30
+ * registry symbol on purpose: a plugin package imports its own copy of
31
+ * `@vzn/vx` beside a compiled binary's, and the two copies must stamp
32
+ * and check the same key.
33
+ */
34
+ export const PLUGIN_PACKAGE: unique symbol = Symbol.for('vx.plugin.package')
35
+
24
36
  /**
25
37
  * Structural plugin shape — any subset of the pipeline stages
26
38
  * (`config`/`project`/`graph`/`key`/`schedule`), run-level capabilities
@@ -32,20 +44,60 @@ export interface WorkspaceConfig {
32
44
  * before consulting capabilities.
33
45
  */
34
46
  export interface Plugin {
47
+ /** The package name — set by `definePlugin`, never by the plugin. */
35
48
  readonly name: string
36
49
  config?(workspace: unknown, ctx: unknown): unknown
37
50
  project?(config: unknown, ctx: unknown): unknown
38
51
  graph?(nodes: unknown, ctx: unknown): unknown
39
52
  key?(task: unknown, ctx: unknown): unknown
40
53
  schedule?(nodes: unknown, ctx: unknown): unknown
54
+ admit?(task: unknown, ctx: unknown): unknown
41
55
  readonly commands?: Readonly<Record<string, unknown>>
42
56
  cache?(ctx: unknown): unknown
43
57
  executor?(ctx: unknown): unknown
44
58
  telemetry?(ctx: unknown): unknown
45
59
  setup?(ctx: unknown): void | Promise<void>
46
60
  teardown?(): void | Promise<void>
61
+ readonly fingerprint?: unknown
47
62
  }
48
63
 
64
+ /**
65
+ * Every hook a plugin may fill, in pipeline order — THE list. The loader's
66
+ * "must be a function" and "at least one of" checks, the host's stage gate
67
+ * and `vx info`'s seam column all read it, so a stage added here is a stage
68
+ * everywhere: `admit` reached `vx info` a day late (2026-09-12) because
69
+ * that column kept its own copy, and the loader kept a third. The type
70
+ * pin below refuses a list that drifts from `Plugin`'s keys either way.
71
+ */
72
+ export const PLUGIN_HOOKS = [
73
+ 'config',
74
+ 'project',
75
+ 'graph',
76
+ 'key',
77
+ 'fingerprint',
78
+ 'schedule',
79
+ 'admit',
80
+ 'executor',
81
+ 'cache',
82
+ 'telemetry',
83
+ 'setup',
84
+ 'commands',
85
+ 'teardown',
86
+ ] as const
87
+ export type PluginHook = (typeof PLUGIN_HOOKS)[number]
88
+ /** The hooks that are functions; `commands` and `fingerprint` are objects. */
89
+ export const PLUGIN_FUNCTION_HOOKS = PLUGIN_HOOKS.filter(
90
+ (h): h is Exclude<PluginHook, 'commands' | 'fingerprint'> =>
91
+ h !== 'commands' && h !== 'fingerprint',
92
+ )
93
+ type PluginHookKeys = Exclude<keyof Plugin, 'name'>
94
+ type HooksNotListed = Exclude<PluginHookKeys, PluginHook>
95
+ type HooksNotOnPlugin = Exclude<PluginHook, PluginHookKeys>
96
+ // Both `never`, or this line does not compile: the list and the type agree.
97
+ const _pluginHooksMatch: [HooksNotListed, HooksNotOnPlugin] extends [never, never] ? true : never =
98
+ true
99
+ void _pluginHooksMatch
100
+
49
101
  export interface ProjectConfig {
50
102
  /** Tasks declared by this project, keyed by task name. */
51
103
  tasks?: Record<string, TaskConfig>
@@ -94,123 +146,101 @@ export interface TaskConfig {
94
146
  * runs.
95
147
  */
96
148
  cache?: CacheConfig
97
- /**
98
- * Sandbox configuration. **Sandbox is opt-in per task.**
99
- * - omitted entirely → task runs unsandboxed.
100
- * - `sandbox: {}` → opts in with the minimum baseline (only
101
- * resolved inputs readable, only static-prefix of outputs
102
- * writable, network blocked).
103
- * - `sandbox: { ... }` → opts in with the explicit options below.
104
- *
105
- * No inheritance, no workspace defaults, no built-in escapes. The
106
- * sandbox sees STRICTLY the union of resolved `cache.inputs.files`
107
- * + this block's `allowRead` (for reads) and the static prefix of
108
- * `cache.outputs.files` + this block's `allowWrite` (for writes).
109
- * If a task needs `node_modules` or `/tmp`, declare them here
110
- * explicitly.
111
- *
112
- * **Policy: fail on violation.** If the sandbox detects an
113
- * undeclared read/write (macOS log monitor) or the child returns
114
- * non-zero because it couldn't reach a path it expected (Linux
115
- * structural deny), the task fails. No cache is written for a
116
- * failed task.
117
- *
118
- * Activation requires `exec` (group tasks have no command to wrap)
119
- * and is silently skipped for persistent tasks (dev servers need
120
- * unrestricted network + an indefinite process).
121
- */
122
- sandbox?: SandboxConfig
123
149
  }
124
150
 
125
151
  /**
126
- * Per-task sandbox config. Mirrors the user-facing surface of
127
- * `@anthropic-ai/sandbox-runtime`'s `SandboxRuntimeConfig`, minus
128
- * deployment-only fields (binary paths, proxy ports, ripgrep config).
152
+ * What a sandboxed task may do, declared as CAPABILITIES.
153
+ *
154
+ * One shape for the whole policy. You say what the task is allowed to
155
+ * touch; vx decides how each capability is realised — some become sandbox
156
+ * runtime config, some become rules in the OS policy, and one the platform
157
+ * cannot express is an error rather than a silent no-op. None of that
158
+ * reaches the config.
129
159
  *
130
- * Path-list fields (`allowRead`, `allowWrite`, `network.allowUnixSockets`)
131
- * accept:
132
- * - relative paths → resolved against the project dir
133
- * - absolute paths → used as-is (`/etc/passwd`, `/tmp`)
134
- * - tilde paths → expanded against the user's home (`~/.npmrc`)
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.
135
164
  *
136
- * No globs in path lists — bwrap on Linux only accepts path prefixes.
165
+ * Paths are project-relative, absolute, or `~`-expanded — and are path
166
+ * PREFIXES, never globs (a glob cannot be enforced by a mount).
137
167
  */
138
168
  export interface SandboxConfig {
139
- // === Filesystem ===
140
- /**
141
- * Additional read-allowed paths beyond resolved `cache.inputs.files`.
142
- * These are unioned with the declared inputs to form the complete
143
- * allowRead set passed to the sandbox.
144
- */
145
- allowRead?: string[]
169
+ /** Capabilities granted on top of the task's own declarations. */
170
+ allow?: SandboxGrants
146
171
  /**
147
- * Additional write-allowed paths beyond the static prefix of
148
- * `cache.outputs.files`. Tasks with no declared outputs and no extra
149
- * `allowWrite` can write to nowhere — typical for `lint`/`typecheck`.
172
+ * Capabilities taken away. Evaluated before `allow`, so a domain in both
173
+ * is denied.
150
174
  */
151
- allowWrite?: string[]
175
+ deny?: SandboxDenials
152
176
  /**
153
- * Permit writes to `.git/config` files. Default `false` — most build
154
- * tools should not be reconfiguring git.
155
- */
156
- allowGitConfig?: boolean
157
-
158
- // === Network ===
159
- /**
160
- * Network policy.
161
- * - `false` (default) — block all outbound traffic.
162
- * - `true` — allow all outbound traffic.
163
- * - object — fine-grained control (domains / sockets / etc.).
164
- */
165
- network?: boolean | SandboxNetworkConfig
166
-
167
- // === Process behavior ===
168
- /**
169
- * Allow the task to acquire a pseudo-terminal. Default `false`.
170
- * Needed by tasks that interact with a TTY (rare in CI).
171
- */
172
- allowPty?: boolean
173
- /**
174
- * Linux only. Allow nested sandboxes (a sandboxed task spawning
175
- * another sandboxed task). Default `false`.
177
+ * Denials to leave out of the report, in the same shape as `allow`.
178
+ *
179
+ * This does NOT permit anything: the OS still denies the operation, and
180
+ * a command that cannot proceed without it still fails. It says "I know,
181
+ * and it does not affect this task's output" — for a denial you cannot
182
+ * grant away and do not want failing the run.
183
+ *
184
+ * Unlike `allow`, the values are GLOB PATTERNS, and they work the same
185
+ * on every platform: `allow` has to be expressible by the OS sandbox
186
+ * (bwrap mounts a path; it cannot mount a pattern), while `ignore` only
187
+ * has to match a line vx already holds. Relative patterns anchor at the
188
+ * project dir.
189
+ *
190
+ * ```ts
191
+ * ignore: { write: ['*.bun-build'] } // bun --compile stages a random
192
+ * // temp name in the cwd
193
+ * ```
176
194
  */
177
- enableWeakerNestedSandbox?: boolean
195
+ ignore?: SandboxGrants
178
196
  /**
179
- * macOS only. Skip the network namespace; routes traffic via the
180
- * host proxy instead of unsharing networking. Lower overhead but
181
- * weaker isolation. Default `false`.
197
+ * Accept a weaker sandbox when vx itself already runs inside one
198
+ * (a container, another sandbox). Without it, nesting fails fast rather
199
+ * than pretending to confine.
182
200
  */
183
- enableWeakerNetworkIsolation?: boolean
201
+ weakerWhenNested?: boolean
202
+ /** Accept weaker network isolation (host proxy) for lower overhead. */
203
+ weakerNetworkIsolation?: boolean
204
+ }
184
205
 
185
- // === Violation policy ===
186
- /**
187
- * Map of command-substring → list of paths to ignore violations on
188
- * for that command. Lets you silence known-noisy probes (e.g. a
189
- * compiler that statx's many candidate header paths). Per SRT's
190
- * own ignoreViolations field.
191
- */
192
- ignoreViolations?: Record<string, string[]>
206
+ export interface SandboxGrants {
207
+ /** Readable path prefixes, beyond the resolved `cache.inputs.files`. */
208
+ read?: string[]
209
+ /** Writable path prefixes, beyond the `cache.outputs.files` prefixes. */
210
+ write?: string[]
211
+ /**
212
+ * Reachable domains. `true` allows all, `['*.example.com']` a pattern.
213
+ * Omitted means no network at all.
214
+ */
215
+ network?: true | string[]
216
+ /**
217
+ * Kernel information the task may query, by info type — the name that
218
+ * appears in a denial, e.g. `vfs.disk-space` (every Bun and git process
219
+ * probes free space at startup).
220
+ */
221
+ systemInfo?: string[]
222
+ /** Unix socket paths the task may connect to. `true` allows any. */
223
+ unixSockets?: true | string[]
224
+ /**
225
+ * Bind and reach localhost ports — a test that boots a server for
226
+ * itself. A port LIST additionally makes those ports reachable from
227
+ * outside the sandbox (a dev server another task or the developer
228
+ * talks to): on Linux, where a sandboxed task lives in its own network
229
+ * namespace, each listed port is bridged out to the host's loopback;
230
+ * on macOS the host already sees them and the list means `true`.
231
+ */
232
+ localBinding?: boolean | number[]
233
+ /** macOS: Mach service names the task may look up. */
234
+ machLookup?: string[]
235
+ /** Acquire a TTY. Rare outside interactive tools. */
236
+ pty?: boolean
237
+ /** Write to `.git/config`. Most build tools should not. */
238
+ gitConfig?: boolean
193
239
  }
194
240
 
195
- export interface SandboxNetworkConfig {
196
- /**
197
- * Domain patterns the task may reach. Wildcards: `*.example.com`,
198
- * `*` (allow all).
199
- */
200
- allowedDomains?: string[]
201
- /** Domains explicitly blocked, evaluated before allowedDomains. */
202
- deniedDomains?: string[]
203
- /** Unix socket paths the task may connect to. */
204
- allowUnixSockets?: string[]
205
- /** Allow any Unix socket. Use with care. */
206
- allowAllUnixSockets?: boolean
207
- /**
208
- * Allow binding to local ports (e.g. a test that boots a server on
209
- * localhost for itself to query).
210
- */
211
- allowLocalBinding?: boolean
212
- /** macOS only. Mach service names the task may look up. */
213
- allowMachLookup?: string[]
241
+ export interface SandboxDenials {
242
+ /** Domains blocked even if `allow.network` would admit them. */
243
+ network?: string[]
214
244
  }
215
245
 
216
246
  export interface ExecConfig {
@@ -258,16 +288,6 @@ export interface ExecConfig {
258
288
  * default, including `retries: 0`.
259
289
  */
260
290
  retries?: number
261
- /**
262
- * Resource RESERVATIONS for admission control — NOT enforcement: vx
263
- * does not cgroup-limit, nice, or kill the task; it only decides what
264
- * to co-schedule so concurrent reservations never exceed the CPU /
265
- * memory budget. Each axis defaults to 0 = reserve nothing: the task
266
- * runs subject only to the concurrency-count limit. A pure scheduling
267
- * hint — the whole object is stripped from the cache key, so tuning a
268
- * reservation never invalidates a cached result.
269
- */
270
- resources?: ResourcesConfig
271
291
  /**
272
292
  * Long-running / continuous task (dev server, watcher, daemon).
273
293
  * When present, the task is spawned but the runner does NOT wait
@@ -277,30 +297,29 @@ export interface ExecConfig {
277
297
  * on ready, not on exit. The orchestrator SIGTERMs every persistent
278
298
  * subprocess once the rest of the graph has finished.
279
299
  *
280
- * `cache` is silently ignored for persistent tasks — there's no
281
- * exit code to cache and the work continues after vx's run notion
282
- * of "done".
300
+ * `cache` is rejected on a persistent task — there's no exit code
301
+ * to cache and the work continues after vx's run notion of "done".
283
302
  */
284
303
  persistent?: PersistentConfig
285
- }
286
-
287
- /**
288
- * Per-task resource reservation (see `ExecConfig.resources`). Grouped so
289
- * a future axis slots in without new top-level `exec` fields; the loader
290
- * rejects unknown keys.
291
- */
292
- export interface ResourcesConfig {
293
- /** CPU cores; fractional is allowed (`0.5`). */
294
- cpus?: number
295
- /** Megabytes. `memory: 4096` is four gigabytes. */
296
- memory?: number
297
304
  /**
298
- * The container image this task needs, MATCHED against what a worker
299
- * advertises — never a provisioning instruction, since a distributed
300
- * executor's workers belong to whoever runs the fleet. Ignored by
301
- * executors that do not run containers.
305
+ * Confine this task's process: what it may read, write and reach.
306
+ * Nothing is confined unless this is present.
307
+ *
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,
311
+ * so the field cannot be reached from one.
312
+ *
313
+ * **Policy: fail on violation.** An undeclared touch of the project's own
314
+ * files fails the task, and a failed task is never cached. Reaching
315
+ * OUTSIDE the project is denied but not reported — that is the wall
316
+ * doing its job, not a finding.
317
+ *
318
+ * A persistent task is confined the same way but never REPORTED on: the
319
+ * violation report reads the trace after the process exits, and a server
320
+ * exits at teardown. Enforced, not reported.
302
321
  */
303
- image?: string
322
+ sandbox?: SandboxConfig
304
323
  }
305
324
 
306
325
  export interface PersistentConfig {
@@ -354,8 +373,9 @@ export interface CacheOutputs {
354
373
  * files anywhere in the workspace, including inside other projects'
355
374
  * directories. Deliberate escape hatch — prefer project-relative
356
375
  * `files` whenever the task can write inside its own dir. Two tasks
357
- * declaring overlapping workspace outputs is user responsibility;
358
- * vx does not police it.
376
+ * whose workspace outputs provably overlap are refused at graph build,
377
+ * like overlapping `files` (vx cleans declared outputs before a run
378
+ * and before a restore, so the second would delete the first's).
359
379
  */
360
380
  workspaceFiles?: string[]
361
381
  }
package/src/exec/env.ts CHANGED
@@ -42,6 +42,17 @@ export const ESSENTIAL_ENV: readonly string[] = [
42
42
  'PATHEXT',
43
43
  ]
44
44
 
45
+ /**
46
+ * The two variables vx sets on every task it spawns, over the layers
47
+ * above: the workspace root the run is in and the `project#task` running.
48
+ * `run()` reads them back so a task that shells out to `vx run` in its OWN
49
+ * workspace is refused before it forks without bound (a different
50
+ * workspace — a fixture, a benchmark — is fine). Not in the allowlist:
51
+ * they come from this run, never from the parent environment.
52
+ */
53
+ export const VX_RUN_WORKSPACE_ENV = 'VX_RUN_WORKSPACE'
54
+ export const VX_RUN_TASK_ENV = 'VX_RUN_TASK'
55
+
45
56
  export interface BuildEnvOptions {
46
57
  passThrough: readonly string[]
47
58
  define: Readonly<Record<string, string>>