@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
@@ -0,0 +1,280 @@
1
+ // What a sandboxed task was denied, as a report: the Linux strace pass
2
+ // (paired by pid, because a forking task interleaves its lines), the
3
+ // seatbelt record description, and the filters that keep only what the
4
+ // task's owner can act on — inside the project, minus the loopback denial
5
+ // no grant can avoid, minus what the task chose to ignore.
6
+
7
+ import path from 'node:path'
8
+ import { absolutize, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
9
+ import type {
10
+ ResolvedSandboxConfig,
11
+ SandboxedRunArgs,
12
+ SandboxViolation,
13
+ } from './sandbox-runtime.js'
14
+
15
+ /**
16
+ * Parse a strace log for denied filesystem syscalls and convert each
17
+ * one inside the workspace deny anchor (and not in allowRead) into a
18
+ * SandboxViolation. Dedups by (syscall, abs-path) so a tool that
19
+ * statx's the same missing path 10 times in a row produces one line.
20
+ *
21
+ * strace line shape (with -f):
22
+ * <pid> openat(AT_FDCWD, "<path>", <flags>) = -1 ENOENT (...)
23
+ * <pid> access("<path>", <mode>) = -1 EACCES (...)
24
+ * <pid> statx(AT_FDCWD, "<path>", <flags>, <mask>, ...) = -1 ENOENT (...)
25
+ *
26
+ * …but ONLY when the syscall completes without another traced process
27
+ * interleaving. Under `-f` strace splits an interrupted call across two
28
+ * lines and the result never appears next to the path:
29
+ * <pid> openat(AT_FDCWD, "<path>", <flags> <unfinished ...>
30
+ * <pid> <... openat resumed>) = -1 ENOENT (...)
31
+ * A single-line regex silently drops every one of those, so a task that
32
+ * forks concurrent children reading undeclared files reported an
33
+ * INCOMPLETE violation list — a sandboxed task that tripped would look
34
+ * clean. We pair them by pid instead (a process has at most
35
+ * one syscall in flight, so the pid is a sufficient key).
36
+ *
37
+ * We capture the first quoted-string argument as the path. paths that
38
+ * are relative resolve against the task's cwd (set by Bun.spawn).
39
+ */
40
+ const SYSCALLS = 'openat|access|statx|newfstatat'
41
+ const STRACE_DONE_RE = new RegExp(
42
+ `^(\\d+)\\s+(${SYSCALLS})\\([^"]*"([^"]+)"[^)]*\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
43
+ )
44
+ const STRACE_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+(${SYSCALLS})\\([^"]*"([^"]+)"[^)]*<unfinished`)
45
+ const STRACE_RESUMED_RE = new RegExp(
46
+ `^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
47
+ )
48
+ /** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
49
+ const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
50
+
51
+ /** One denied syscall, however strace chose to lay it out. */
52
+ export interface DeniedCall {
53
+ syscall: string
54
+ rawPath: string
55
+ errno: string
56
+ }
57
+
58
+ /**
59
+ * Walk the trace, pairing `<unfinished ...>` with its `<... resumed>` line.
60
+ *
61
+ * Exported for testing: this is the security-relevant half of the Linux
62
+ * detector, and a synthetic trace pins the split-line shapes deterministically
63
+ * where an end-to-end run only produces them when strace happens to interleave.
64
+ */
65
+ export function deniedCalls(text: string): DeniedCall[] {
66
+ const pending = new Map<string, { syscall: string; rawPath: string }>()
67
+ const out: DeniedCall[] = []
68
+ for (const line of text.split('\n')) {
69
+ const done = STRACE_DONE_RE.exec(line)
70
+ if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
71
+ out.push({ syscall: done[2], rawPath: done[3], errno: done[4] })
72
+ continue
73
+ }
74
+ const unfinished = STRACE_UNFINISHED_RE.exec(line)
75
+ if (
76
+ unfinished?.[1] !== undefined &&
77
+ unfinished[2] !== undefined &&
78
+ unfinished[3] !== undefined
79
+ ) {
80
+ pending.set(unfinished[1], { syscall: unfinished[2], rawPath: unfinished[3] })
81
+ continue
82
+ }
83
+ const resumedOk = STRACE_RESUMED_OK_RE.exec(line)
84
+ if (resumedOk?.[1] === undefined) continue
85
+ const held = pending.get(resumedOk[1])
86
+ pending.delete(resumedOk[1])
87
+ const resumed = STRACE_RESUMED_RE.exec(line)
88
+ // Only a resume that carries a DENIAL is a violation; a successful
89
+ // resume just retires the pending entry.
90
+ if (held !== undefined && resumed?.[3] !== undefined) {
91
+ out.push({ syscall: held.syscall, rawPath: held.rawPath, errno: resumed[3] })
92
+ }
93
+ }
94
+ return out
95
+ }
96
+
97
+ export async function parseStraceViolations(
98
+ logPath: string,
99
+ args: SandboxedRunArgs,
100
+ baselines: { allowRead: readonly string[]; denyRead: readonly string[]; cwd: string },
101
+ ): Promise<SandboxViolation[]> {
102
+ const text = await Bun.file(logPath).text()
103
+ if (text.length === 0) return []
104
+
105
+ // Treat every baseAllow + sandbox.allowRead path as "this was
106
+ // explicitly permitted; any -ENOENT here is the user's own missing
107
+ // file, not a sandbox-induced denial". Same for absolute denyRead
108
+ // checks below. Canonical on BOTH sides — the policy is expressed in
109
+ // real paths (see `canonicalBaselines`), so comparing a link-path here
110
+ // would report an explicitly-allowed read as a violation.
111
+ const allowAbs = new Set<string>(
112
+ [...baselines.allowRead, ...args.config.allowRead].map((p) => toRealPath(absolutize(p))),
113
+ )
114
+ const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
115
+
116
+ const seen = new Set<string>()
117
+ const out: SandboxViolation[] = []
118
+ for (const { syscall, rawPath, errno } of deniedCalls(text)) {
119
+ const abs = toRealPath(absolutize(rawPath, baselines.cwd))
120
+ // Only report paths under the workspace-root deny anchor — system
121
+ // libs / /proc / /sys / etc. probes are not interesting violations.
122
+ if (!denyAnchors.some((root) => abs === root || abs.startsWith(root + path.sep))) continue
123
+ // Skip paths the user explicitly allowed (and their descendants).
124
+ if (isUnderAny(abs, allowAbs)) continue
125
+ const key = `${syscall}|${abs}`
126
+ if (seen.has(key)) continue
127
+ seen.add(key)
128
+ out.push({
129
+ line: `${syscall}(${rawPath}) = -1 ${errno} [${abs}]`,
130
+ timestamp: new Date(),
131
+ target: abs,
132
+ path: abs,
133
+ // The trace is `-e trace=openat`, and an openat is a read or a
134
+ // write depending on flags the trace does not carry — so either
135
+ // list can silence it.
136
+ ignorable: ['read', 'write'],
137
+ })
138
+ }
139
+ return out
140
+ }
141
+
142
+ /**
143
+ * Apply the task's user-provided `sandbox.ignore` grants on top
144
+ * of whatever the macOS log monitor + Linux strace pass produced.
145
+ * Mirrors SRT's own substring-match semantics:
146
+ * - `'*'` entries match every command
147
+ * - other keys match commands whose userCommand string CONTAINS the key
148
+ * - the array of strings under each key is substring-matched against
149
+ * the violation line
150
+ *
151
+ * The defaults installed in `initSandbox` already filter on the macOS
152
+ * side; this pass catches per-task additions + Linux strace results.
153
+ */
154
+ /**
155
+ * Does a violation line match something the task said to ignore?
156
+ *
157
+ * The line names an operation and a target — `deny(1) file-write-create
158
+ * /path/x`, `deny(1) system-info vfs.disk-space` — so the operation picks
159
+ * the list and the target is matched against its patterns. Anything
160
+ * unparseable is NOT ignored: a record we cannot classify is exactly the
161
+ * one worth seeing.
162
+ */
163
+ function matchesIgnore(
164
+ v: SandboxViolation,
165
+ ignore: NonNullable<ResolvedSandboxConfig['ignore']>,
166
+ ): boolean {
167
+ if (v.target === undefined || v.ignorable === undefined) return false
168
+ for (const which of v.ignorable) {
169
+ const patterns = ignore[which]
170
+ if (patterns === undefined) continue
171
+ if (patterns.some((pat) => pat === v.target || new Bun.Glob(pat).match(v.target!))) return true
172
+ }
173
+ return false
174
+ }
175
+
176
+ /**
177
+ * Split a seatbelt record into the pieces the filters need. A record with
178
+ * no path (a `system-info` probe) keeps its target — it is not a boundary
179
+ * crossing, and the task can grant it.
180
+ */
181
+ function describeMacViolation(line: string): Partial<SandboxViolation> {
182
+ const m = /deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/.exec(line)
183
+ if (m === null) return {}
184
+ const [op, target] = [m[1]!, m[2]!]
185
+ const which = op.startsWith('file-read')
186
+ ? 'read'
187
+ : op.startsWith('file-write')
188
+ ? 'write'
189
+ : op === 'system-info' || op === 'sysctl-read'
190
+ ? 'systemInfo'
191
+ : op.startsWith('network')
192
+ ? 'network'
193
+ : undefined
194
+ return {
195
+ target,
196
+ ...(target.startsWith('/') ? { path: toRealPath(target) } : {}),
197
+ ...(which !== undefined ? { ignorable: [which] } : {}),
198
+ }
199
+ }
200
+
201
+ /**
202
+ * The violations a task's report should carry: inside the project,
203
+ * minus the loopback denial no config can avoid, minus what the task
204
+ * chose to ignore. Exported so a test can drive it with either
205
+ * platform's line shape without needing that platform.
206
+ */
207
+ export function reportableViolations(
208
+ violations: readonly SandboxViolation[],
209
+ opts: { within: string; config: ResolvedSandboxConfig },
210
+ ): SandboxViolation[] {
211
+ // A record the producer did not describe is a seatbelt one, straight
212
+ // from SRT's store — parse it here so the filters below never see a
213
+ // platform's line format.
214
+ const described = violations.map((v) =>
215
+ v.target === undefined ? { ...v, ...describeMacViolation(v.line) } : v,
216
+ )
217
+ return filterIgnored(
218
+ loopbackNoise(withinReported(described, opts.within), opts.config),
219
+ opts.config.ignore,
220
+ )
221
+ }
222
+
223
+ /**
224
+ * Drop the loopback denial no grant can avoid.
225
+ *
226
+ * A runtime that opens a dual-stack socket reaches 127.0.0.1 as
227
+ * ::ffff:127.0.0.1, and seatbelt's only host tokens are `localhost` and
228
+ * `*` — no rule vx or SRT can write names that form. The first connect is
229
+ * denied, the runtime retries on AF_INET and succeeds (measured
230
+ * 2026-09-05: `fetch` to its own `Bun.serve` port returns 200 with one
231
+ * `deny(1) network-outbound` logged). It happens for a task's own server
232
+ * under `localBinding`, and again for SRT's filtering proxy whenever the
233
+ * task declared any network at all. The record has no address and no
234
+ * config can silence it, so under either grant it is noise.
235
+ *
236
+ * It is not a hole for the traffic that matters: a connection that tried
237
+ * to leave the machine goes through that proxy, which reports it WITH its
238
+ * host and port — a line this keeps.
239
+ */
240
+ function loopbackNoise(
241
+ violations: SandboxViolation[],
242
+ config: ResolvedSandboxConfig,
243
+ ): SandboxViolation[] {
244
+ const loopbackGranted = localBindingOn(config) || config.network !== undefined
245
+ if (!loopbackGranted) return violations
246
+ return violations.filter((v) => !/deny\(\d+\)\s+network-outbound\s*$/.test(v.line))
247
+ }
248
+
249
+ /**
250
+ * Keep only denials on a path inside `within`.
251
+ *
252
+ * A task may not leave its project — that is enforced by the deny anchor at
253
+ * the workspace root — but being STOPPED at the wall is the sandbox
254
+ * working, not a finding. Every process walks from `/` down to its own cwd
255
+ * (`bun build --compile` lists each directory on the way; traced
256
+ * 2026-09-05), and no configuration can declare that away.
257
+ *
258
+ * What is worth reporting is an undeclared touch of the project's OWN
259
+ * files: the cache key folds this project's inputs, so that is the read
260
+ * that makes a cached artifact wrong. A record with no path at all — a
261
+ * `system-info` probe — is kept, since it is not a boundary crossing and
262
+ * the task can grant it.
263
+ */
264
+ function withinReported(violations: SandboxViolation[], within: string): SandboxViolation[] {
265
+ const root = toRealPath(within)
266
+ // `root === '/'` would otherwise compare against `'//'` and drop
267
+ // everything — the one prefix that needs no separator appended.
268
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep
269
+ return violations.filter(
270
+ (v) => v.path === undefined || v.path === root || v.path.startsWith(prefix),
271
+ )
272
+ }
273
+
274
+ function filterIgnored(
275
+ violations: SandboxViolation[],
276
+ ignore: ResolvedSandboxConfig['ignore'],
277
+ ): SandboxViolation[] {
278
+ if (ignore === undefined) return violations
279
+ return violations.filter((v) => !matchesIgnore(v, ignore))
280
+ }
@@ -8,15 +8,7 @@ export {
8
8
  parseDependencySpec,
9
9
  type DependencySpec,
10
10
  } from './dependency-spec.js'
11
- export {
12
- type ContinueMode,
13
- type OutputFingerprint,
14
- type ResourceCost,
15
- runGraph,
16
- type TaskOutcome,
17
- type TaskStatus,
18
- type VerifyVerdict,
19
- } from './scheduler.js'
11
+ export { type ContinueMode, runGraph, type TaskOutcome, type TaskStatus } from './scheduler.js'
20
12
  export {
21
13
  buildTaskGraph,
22
14
  detectCycle,