@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
|
@@ -3,18 +3,26 @@
|
|
|
3
3
|
// (bwrap cannot bind a path that does not exist). Shared by the cached path
|
|
4
4
|
// (through the executor) and the persistent path (spawned in execute-task).
|
|
5
5
|
|
|
6
|
-
import { mkdir, readdir, realpath,
|
|
6
|
+
import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
7
7
|
import { homedir } from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import type { ExecConfig } from '../config.js'
|
|
10
10
|
import {
|
|
11
|
+
bindableWrites,
|
|
11
12
|
initSandbox,
|
|
12
13
|
probeSandbox,
|
|
14
|
+
punchWalls,
|
|
13
15
|
resolveSandboxConfig,
|
|
16
|
+
type ResolvedSandboxConfig,
|
|
17
|
+
thrownReason,
|
|
18
|
+
toRealPath,
|
|
14
19
|
type ExecuteRequest,
|
|
20
|
+
isMountableLiteral,
|
|
21
|
+
type SandboxViolation,
|
|
15
22
|
} from '../exec/index.js'
|
|
16
23
|
import type { TaskNode } from '../graph/index.js'
|
|
17
|
-
import {
|
|
24
|
+
import { grantPrefix, UserError } from '../util/index.js'
|
|
25
|
+
import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
28
|
* Arm the sandbox runtime for a run, lazily: only when at least one task
|
|
@@ -35,6 +43,46 @@ export interface SandboxArmer {
|
|
|
35
43
|
readonly armed: boolean
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* What SRT's run-wide `initialize()` is armed with, folded from every
|
|
48
|
+
* sandboxed task in the graph — or `null` when the run has none.
|
|
49
|
+
*
|
|
50
|
+
* Split out of `prepareSandbox` (pure motion) because it is the only
|
|
51
|
+
* part of the arming that can be READ without starting a sandbox: the
|
|
52
|
+
* call itself is observable solely through a live runtime, so the values
|
|
53
|
+
* it carries had no witness of any kind and three separate widenings of
|
|
54
|
+
* them survived a whole-suite mutation sweep (item 537).
|
|
55
|
+
*/
|
|
56
|
+
export interface SandboxRunUnion {
|
|
57
|
+
/** Domains the run's filtering proxy will accept, in declaration order. */
|
|
58
|
+
domains: string[]
|
|
59
|
+
/** Whether SRT's all-or-nothing `socket(AF_UNIX)` filter is lifted for the run. */
|
|
60
|
+
unixSockets: boolean
|
|
61
|
+
/** Whether EVERY sandboxed task accepts the weaker nested profile. */
|
|
62
|
+
weakerNested: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | null {
|
|
66
|
+
const sandboxed = [...nodes].filter((n) => n.config.exec?.sandbox !== undefined)
|
|
67
|
+
if (sandboxed.length === 0) return null
|
|
68
|
+
const weakerNested = sandboxed.every((n) => n.config.exec?.sandbox?.weakerWhenNested === true)
|
|
69
|
+
const domains = new Set<string>()
|
|
70
|
+
let unixSockets = false
|
|
71
|
+
for (const n of sandboxed) {
|
|
72
|
+
const allow = n.config.exec?.sandbox?.allow
|
|
73
|
+
const net = allow?.network
|
|
74
|
+
// `network: true` is deliberately absent from the union: it SKIPS the
|
|
75
|
+
// proxy rather than going through it, so folding it in as `*` would
|
|
76
|
+
// widen the allowlist every OTHER task in the run is filtered against.
|
|
77
|
+
if (Array.isArray(net)) for (const d of net) domains.add(d)
|
|
78
|
+
const sockets = allow?.unixSockets
|
|
79
|
+
if (sockets === true || (Array.isArray(sockets) && sockets.length > 0)) unixSockets = true
|
|
80
|
+
const lb = allow?.localBinding
|
|
81
|
+
if (Array.isArray(lb) && lb.length > 0 && process.platform === 'linux') unixSockets = true
|
|
82
|
+
}
|
|
83
|
+
return { domains: [...domains], unixSockets, weakerNested }
|
|
84
|
+
}
|
|
85
|
+
|
|
38
86
|
/**
|
|
39
87
|
* Prepare the sandbox for a run WITHOUT starting it. Starting is
|
|
40
88
|
* `arm()`, and it happens on the first task that actually executes inside
|
|
@@ -57,20 +105,9 @@ export interface SandboxArmer {
|
|
|
57
105
|
* it for the run. macOS keeps per-task precision through vx's own rules.
|
|
58
106
|
*/
|
|
59
107
|
export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
const
|
|
63
|
-
const domains = new Set<string>()
|
|
64
|
-
let unixSockets = false
|
|
65
|
-
for (const n of sandboxed) {
|
|
66
|
-
const allow = n.config.exec?.sandbox?.allow
|
|
67
|
-
const net = allow?.network
|
|
68
|
-
if (Array.isArray(net)) for (const d of net) domains.add(d)
|
|
69
|
-
const sockets = allow?.unixSockets
|
|
70
|
-
if (sockets === true || (Array.isArray(sockets) && sockets.length > 0)) unixSockets = true
|
|
71
|
-
const lb = allow?.localBinding
|
|
72
|
-
if (Array.isArray(lb) && lb.length > 0 && process.platform === 'linux') unixSockets = true
|
|
73
|
-
}
|
|
108
|
+
const union = sandboxRunUnion(nodes)
|
|
109
|
+
if (union === null) return null
|
|
110
|
+
const { domains, unixSockets, weakerNested } = union
|
|
74
111
|
let pending: Promise<void> | undefined
|
|
75
112
|
let armed = false
|
|
76
113
|
return {
|
|
@@ -83,7 +120,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
|
|
|
83
120
|
const avail = await probeSandbox({ weakerNested })
|
|
84
121
|
if (!avail.available) throw new UserError(`sandbox not available: ${avail.reason}`)
|
|
85
122
|
await initSandbox({
|
|
86
|
-
allowedDomains:
|
|
123
|
+
allowedDomains: domains,
|
|
87
124
|
...(unixSockets ? { allowAllUnixSockets: true } : {}),
|
|
88
125
|
})
|
|
89
126
|
} catch (err) {
|
|
@@ -91,9 +128,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
|
|
|
91
128
|
// the dependency check does not cover) gets the same one-line
|
|
92
129
|
// verdict as a refused probe, not an internal error with a stack.
|
|
93
130
|
if (err instanceof UserError) throw err
|
|
94
|
-
throw new UserError(
|
|
95
|
-
`sandbox not available: ${err instanceof Error ? err.message : String(err)}`,
|
|
96
|
-
)
|
|
131
|
+
throw new UserError(`sandbox not available: ${thrownReason(err)}`)
|
|
97
132
|
}
|
|
98
133
|
armed = true
|
|
99
134
|
})()
|
|
@@ -106,25 +141,31 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
|
|
|
106
141
|
* The sandbox half of an `ExecuteRequest` for one task, shared by the
|
|
107
142
|
* cached path (through the executor) and the persistent path (spawned here).
|
|
108
143
|
*
|
|
109
|
-
* The
|
|
110
|
-
* two different questions: `cache.inputs` says what INVALIDATES the
|
|
111
|
-
* `sandbox.allow` says what it may TOUCH. Deriving one from the other
|
|
144
|
+
* The user's grants derive NOTHING from `cache` (owner, 2026-09-05). Those
|
|
145
|
+
* are two different questions: `cache.inputs` says what INVALIDATES the
|
|
146
|
+
* task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
|
|
112
147
|
* coupled them in both directions — a declaration added for caching
|
|
113
148
|
* silently widened the sandbox, and a path the task needed had to be
|
|
114
149
|
* laundered through the cache key to get it. A sandboxed task declares
|
|
115
150
|
* its own reads and writes.
|
|
116
151
|
*
|
|
117
|
-
* `node_modules` is the one grant core still makes
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
152
|
+
* `node_modules` is the one grant core still makes: it is where the task's
|
|
153
|
+
* own PATH gets its `.bin` entries, and denying it made the sandbox
|
|
154
|
+
* unusable for anything that imports a dependency (`bun build --compile`
|
|
155
|
+
* died with only `error: An unknown error occurred (Unexpected)`; owner
|
|
156
|
+
* call 2026-09-04). Declaring `cache` may NARROW that grant, never widen
|
|
157
|
+
* anyone's (2026-09-24): `keyed` is the set of project directories the
|
|
158
|
+
* task's key answers for (keyed-projects.ts), and `undefined` for a task
|
|
159
|
+
* with no `cache`, which has no key to be stale.
|
|
122
160
|
*/
|
|
123
161
|
export async function sandboxRequestFor(
|
|
124
162
|
node: TaskNode,
|
|
125
163
|
sandbox: NonNullable<ExecConfig['sandbox']>,
|
|
126
164
|
workspaceRoot: string,
|
|
127
|
-
|
|
165
|
+
keyed: ReadonlySet<string> | undefined,
|
|
166
|
+
/** The projects nested in this one (their dirs): a wall its grants do not reach. */
|
|
167
|
+
nested: readonly string[] = [],
|
|
168
|
+
): Promise<SandboxRequest> {
|
|
128
169
|
const depDirs = [
|
|
129
170
|
path.join(node.projectDir, 'node_modules'),
|
|
130
171
|
path.join(workspaceRoot, 'node_modules'),
|
|
@@ -133,18 +174,26 @@ export async function sandboxRequestFor(
|
|
|
133
174
|
// sibling project, so granting `node_modules` grants a link whose
|
|
134
175
|
// target is outside it. That target is a dependency, not a reach-out:
|
|
135
176
|
// no project config should have to name a sibling to import what its
|
|
136
|
-
// own `package.json` depends on (owner, 2026-09-05)
|
|
137
|
-
|
|
177
|
+
// own `package.json` depends on (owner, 2026-09-05) — when the key
|
|
178
|
+
// moves with it.
|
|
179
|
+
const links = await linkedDeps(depDirs, node.projectDir, workspaceRoot, keyed)
|
|
180
|
+
depDirs.push(...links.granted)
|
|
138
181
|
// bwrap cannot --bind a path that does not exist: the bind silently
|
|
139
182
|
// becomes a no-op and writes to it appear to succeed but never land.
|
|
140
|
-
// Pre-create what the task said it will write
|
|
141
|
-
|
|
142
|
-
|
|
183
|
+
// Pre-create what the task said it will write — after the grants are
|
|
184
|
+
// resolved, which refuses one that leaves the project through a link
|
|
185
|
+
// before anything is created along it (item 1003).
|
|
186
|
+
const config = wallOff(resolveSandboxConfig(sandbox, node.projectDir), workspaceRoot, [
|
|
187
|
+
...nested,
|
|
188
|
+
path.join(workspaceRoot, '.git'),
|
|
189
|
+
path.join(workspaceRoot, '.vx'),
|
|
190
|
+
])
|
|
191
|
+
const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
|
|
192
|
+
const request: NonNullable<ExecuteRequest['sandbox']> = {
|
|
143
193
|
// Only what the task declared, plus node_modules. Write paths are
|
|
144
194
|
// readable too: a task that writes `dist/x` expects to read it back
|
|
145
195
|
// (`tsc --incremental` re-reads .tsbuildinfo).
|
|
146
196
|
baseAllowRead: depDirs,
|
|
147
|
-
baseAllowWrite: [],
|
|
148
197
|
// Enforcement anchors at the WORKSPACE ROOT: a task may not leave
|
|
149
198
|
// its project, so every sibling and every root file is denied.
|
|
150
199
|
// Reporting is a different question — see `reportWithin` below.
|
|
@@ -157,46 +206,171 @@ export async function sandboxRequestFor(
|
|
|
157
206
|
// project's own files — that is the one that breaks the cache key,
|
|
158
207
|
// because the key folds this project's inputs.
|
|
159
208
|
reportWithin: node.projectDir,
|
|
160
|
-
|
|
209
|
+
// …and of a dependency withheld above: the task reached for it
|
|
210
|
+
// through its own `node_modules`, and that read is the stale hit the
|
|
211
|
+
// withholding exists to stop, not the wall.
|
|
212
|
+
reportLinked: links.withheld.map((w) => w.dir),
|
|
213
|
+
config,
|
|
161
214
|
}
|
|
215
|
+
return { sandbox: request, placeholders, withheld: links.withheld }
|
|
162
216
|
}
|
|
163
217
|
|
|
164
218
|
/**
|
|
165
|
-
*
|
|
219
|
+
* The sandbox half of the request, plus the empty files vx created so a
|
|
220
|
+
* literal write grant had something to bind (`placeholders`). They are
|
|
221
|
+
* vx's, not the task's, until the task writes them: `sweepPlaceholders`
|
|
222
|
+
* takes back the ones it never touched. `withheld` names the linked
|
|
223
|
+
* dependencies a cached task was denied, for the hint a denial under one
|
|
224
|
+
* earns (`withheldLinkLine`).
|
|
225
|
+
*/
|
|
226
|
+
export interface SandboxRequest {
|
|
227
|
+
sandbox: NonNullable<ExecuteRequest['sandbox']>
|
|
228
|
+
placeholders: Placeholder[]
|
|
229
|
+
withheld: WithheldLink[]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** An empty file vx created for a bind, and its mtime at creation. */
|
|
233
|
+
export interface Placeholder {
|
|
234
|
+
path: string
|
|
235
|
+
mtimeMs: number
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A workspace link whose target the task's key does not answer for. */
|
|
239
|
+
export interface WithheldLink {
|
|
240
|
+
/** The canonical target, inside the workspace root. */
|
|
241
|
+
dir: string
|
|
242
|
+
/** The name it is installed under (`@x/ui`). */
|
|
243
|
+
name: string
|
|
244
|
+
/** The target and the link, workspace-relative POSIX, for the hint. */
|
|
245
|
+
target: string
|
|
246
|
+
link: string
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Where the workspace links in `dirs` actually point, split into what the
|
|
251
|
+
* task is granted and what it is not.
|
|
166
252
|
*
|
|
167
253
|
* One level deep, plus one level inside a `@scope/` directory — the shape
|
|
168
254
|
* a package manager writes. Anything already inside a granted directory
|
|
169
255
|
* is dropped; what is left is a sibling project's real path.
|
|
256
|
+
*
|
|
257
|
+
* A link to the task's OWN project, or to a directory holding it, is
|
|
258
|
+
* dropped too: npm and Yarn classic link every workspace package at the
|
|
259
|
+
* root, the task's own included, and granting that target handed the
|
|
260
|
+
* project back whole whatever its `allow.read` said — an undeclared read
|
|
261
|
+
* of its own file ran unreported, and an edit to it was a stale hit.
|
|
262
|
+
*
|
|
263
|
+
* With `keyed` given (a cached task), a target inside the workspace root
|
|
264
|
+
* is granted only when it IS a keyed project's directory; every other one
|
|
265
|
+
* is withheld, since an edit there would not move the key. A target
|
|
266
|
+
* outside the root lies outside the deny anchor, so granting it is a no-op
|
|
267
|
+
* either way. Every side of every comparison is canonical: a target always
|
|
268
|
+
* is, and a directory under a root reached through a link (macOS's `/var`)
|
|
269
|
+
* is not.
|
|
170
270
|
*/
|
|
171
|
-
async function linkedDeps(
|
|
172
|
-
|
|
173
|
-
|
|
271
|
+
async function linkedDeps(
|
|
272
|
+
dirs: readonly string[],
|
|
273
|
+
projectDir: string,
|
|
274
|
+
workspaceRoot: string,
|
|
275
|
+
keyed: ReadonlySet<string> | undefined,
|
|
276
|
+
): Promise<{ granted: string[]; withheld: WithheldLink[] }> {
|
|
277
|
+
const [self, root] = await Promise.all([realpath(projectDir), realpath(workspaceRoot)])
|
|
278
|
+
const scan = async (dir: string, scope: string): Promise<Array<[string, string, string]>> => {
|
|
279
|
+
const found: Array<[target: string, link: string, name: string]> = []
|
|
174
280
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
175
281
|
for (const e of entries) {
|
|
176
282
|
const full = path.join(dir, e.name)
|
|
177
283
|
if (e.isSymbolicLink()) {
|
|
178
284
|
const target = await realpath(full).catch(() => undefined)
|
|
179
|
-
if (
|
|
180
|
-
target
|
|
181
|
-
!dirs.some((d) => target === d || target.startsWith(d + path.sep))
|
|
182
|
-
) {
|
|
183
|
-
out.add(target)
|
|
285
|
+
if (target !== undefined && !dirs.some((d) => within(target, d)) && !within(self, target)) {
|
|
286
|
+
found.push([target, full, scope + e.name])
|
|
184
287
|
}
|
|
185
|
-
} else if (
|
|
186
|
-
await scan(full,
|
|
288
|
+
} else if (scope === '' && e.isDirectory() && e.name.startsWith('@')) {
|
|
289
|
+
found.push(...(await scan(full, `${e.name}/`)))
|
|
187
290
|
}
|
|
188
291
|
}
|
|
292
|
+
return found
|
|
293
|
+
}
|
|
294
|
+
// Merged in `dirs` order, so the link a hint names is the project's own
|
|
295
|
+
// when both have one.
|
|
296
|
+
const targets = new Map<string, { link: string; name: string }>()
|
|
297
|
+
for (const found of await Promise.all(dirs.map((d) => scan(d, '')))) {
|
|
298
|
+
for (const [target, link, name] of found) {
|
|
299
|
+
if (!targets.has(target)) targets.set(target, { link, name })
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (keyed === undefined) return { granted: [...targets.keys()], withheld: [] }
|
|
303
|
+
const keyedDirs = new Set(await Promise.all([...keyed].map((d) => realpath(d))))
|
|
304
|
+
const granted: string[] = []
|
|
305
|
+
const withheld: WithheldLink[] = []
|
|
306
|
+
for (const [target, { link, name }] of targets) {
|
|
307
|
+
if (!within(target, root) || keyedDirs.has(target)) granted.push(target)
|
|
308
|
+
else
|
|
309
|
+
withheld.push({
|
|
310
|
+
dir: target,
|
|
311
|
+
name,
|
|
312
|
+
target: posixRel(root, target),
|
|
313
|
+
link: posixRel(workspaceRoot, link),
|
|
314
|
+
})
|
|
189
315
|
}
|
|
190
|
-
|
|
191
|
-
return [...out]
|
|
316
|
+
return { granted, withheld }
|
|
192
317
|
}
|
|
193
318
|
|
|
194
319
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
|
|
320
|
+
* The line a task denied a withheld dependency gets beside the denial:
|
|
321
|
+
* what it reached, through which link, and the two ways to make the key
|
|
322
|
+
* answer for it. Added only when a violation already sits under the
|
|
323
|
+
* target, so it never reddens a pass.
|
|
324
|
+
*/
|
|
325
|
+
export function withheldLinkLine(taskId: string, w: WithheldLink): string {
|
|
326
|
+
return (
|
|
327
|
+
`vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key folds no task of ` +
|
|
328
|
+
`${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
|
|
329
|
+
`reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
|
|
330
|
+
`\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
|
|
331
|
+
`\`cache.inputs.workspaceFiles\`).`
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The withheld dependencies a reported denial lies under: each earns its hint. */
|
|
336
|
+
export function reachedWithheld(
|
|
337
|
+
withheld: readonly WithheldLink[],
|
|
338
|
+
violations: readonly SandboxViolation[],
|
|
339
|
+
): WithheldLink[] {
|
|
340
|
+
return withheld.filter((w) =>
|
|
341
|
+
violations.some((v) => v.path !== undefined && within(v.path, w.dir)),
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** `p` relative to `from`, with `/` separators. */
|
|
346
|
+
function posixRel(from: string, p: string): string {
|
|
347
|
+
return path.relative(from, p).split(path.sep).join('/')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* `p` is `dir` or below it — by path, so a sibling sharing `dir`'s name
|
|
352
|
+
* prefix is not. `/` already ends in the separator, and a link to it
|
|
353
|
+
* holds every project.
|
|
354
|
+
*/
|
|
355
|
+
function within(p: string, dir: string): boolean {
|
|
356
|
+
return p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Ensure each declared write path exists on the host as either an empty
|
|
361
|
+
* file (a literal path) or a directory (a glob's static prefix, or a
|
|
362
|
+
* literal ending in `/`) so bwrap's --bind can find a real fs entry to
|
|
363
|
+
* mount. Without this, writes inside the sandbox to a non-existent
|
|
364
|
+
* allowWrite path silently disappear (bwrap creates a tmpfs that
|
|
365
|
+
* evaporates on exit).
|
|
366
|
+
*
|
|
367
|
+
* A literal that names nothing yet is a FILE — `dist/vx` for `bun build
|
|
368
|
+
* --outfile dist/vx` — and the task that meant a directory (`write:
|
|
369
|
+
* ['dist']`, then `mkdir -p dist`) met "File exists" from its own tool,
|
|
370
|
+
* with the empty file left behind for every later run to meet again
|
|
371
|
+
* (its `dist/**` clean matches nothing under a file; 2026-09-16). So a
|
|
372
|
+
* directory is spelled `dist/`, and the files created here are returned
|
|
373
|
+
* so the caller can take back the ones the task never wrote.
|
|
200
374
|
*
|
|
201
375
|
* `cleanOutputs` ran just before this in the cache-enabled path, so
|
|
202
376
|
* we know any stale content was wiped; what's left is to materialize
|
|
@@ -205,9 +379,10 @@ async function linkedDeps(dirs: readonly string[]): Promise<string[]> {
|
|
|
205
379
|
async function prepareOutputsForBind(
|
|
206
380
|
projectDir: string,
|
|
207
381
|
outputs: readonly string[],
|
|
208
|
-
): Promise<
|
|
382
|
+
): Promise<Placeholder[]> {
|
|
383
|
+
const placeholders: Placeholder[] = []
|
|
209
384
|
for (const g of outputs) {
|
|
210
|
-
const hasWildcard =
|
|
385
|
+
const hasWildcard = !isMountableLiteral(g)
|
|
211
386
|
// A grant OUTSIDE the project is the user's own path — never joined
|
|
212
387
|
// onto the project dir, which would create a literal `~` there. Its
|
|
213
388
|
// directory is still created when the grant is a glob, because then
|
|
@@ -218,26 +393,177 @@ async function prepareOutputsForBind(
|
|
|
218
393
|
// reported a network error for an unwritable cache; 2026-09-05).
|
|
219
394
|
if (path.isAbsolute(g) || g.startsWith('~')) {
|
|
220
395
|
if (!hasWildcard) continue
|
|
221
|
-
const abs = expandHome(
|
|
396
|
+
const abs = expandHome(grantPrefix(g))
|
|
222
397
|
await mkdir(abs, { recursive: true }).catch(() => undefined)
|
|
223
398
|
continue
|
|
224
399
|
}
|
|
225
|
-
if (hasWildcard) {
|
|
226
|
-
const abs = path.join(projectDir,
|
|
400
|
+
if (hasWildcard || g.endsWith('/')) {
|
|
401
|
+
const abs = path.join(projectDir, hasWildcard ? grantPrefix(g) : g)
|
|
227
402
|
await mkdir(abs, { recursive: true })
|
|
228
403
|
} else {
|
|
229
404
|
const abs = path.join(projectDir, g)
|
|
230
405
|
// Whatever is already there is what the task meant — a grant on the
|
|
231
406
|
// project dir itself is a directory, and touching it as a file is
|
|
232
|
-
// an EISDIR, not a missing bind.
|
|
233
|
-
|
|
407
|
+
// an EISDIR, not a missing bind. `lstat`, and an exclusive create: a
|
|
408
|
+
// dangling link at the grant made vx, unsandboxed, create the empty
|
|
409
|
+
// file at the link's target in another project (item 1003).
|
|
410
|
+
if (await lstat(abs).catch(() => undefined)) continue
|
|
234
411
|
await mkdir(path.dirname(abs), { recursive: true })
|
|
235
|
-
await
|
|
412
|
+
await writeFile(abs, '', { flag: 'wx' })
|
|
413
|
+
placeholders.push({ path: abs, mtimeMs: (await lstat(abs)).mtimeMs })
|
|
236
414
|
}
|
|
237
415
|
}
|
|
416
|
+
return placeholders
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The walls a project's grants stop at: its nested projects, the
|
|
421
|
+
* repository and vx's directory (item 1010). A read grant around one is
|
|
422
|
+
* punched (`punchWalls`); a write grant whose bind would hold one — a file
|
|
423
|
+
* grant binds its directory on Linux, so `write: ['out.txt']` in a root
|
|
424
|
+
* project bound the whole workspace writable, `.git` included — is
|
|
425
|
+
* refused, as there is no writable bind that leaves a wall out.
|
|
426
|
+
*/
|
|
427
|
+
function wallOff(
|
|
428
|
+
config: ResolvedSandboxConfig,
|
|
429
|
+
workspaceRoot: string,
|
|
430
|
+
raw: readonly string[],
|
|
431
|
+
): ResolvedSandboxConfig {
|
|
432
|
+
const walls = raw.map(toRealPath)
|
|
433
|
+
const home = toRealPath(workspaceRoot)
|
|
434
|
+
for (const bind of bindableWrites(config.allowWrite)) {
|
|
435
|
+
// A bind outside the workspace is the user's own path (`/tmp/x`,
|
|
436
|
+
// `~/.cache/y`), spelled there on purpose; only one inside is judged.
|
|
437
|
+
if (bind !== home && !bind.startsWith(home + path.sep)) continue
|
|
438
|
+
const wall = walls.find((w) => w === bind || w.startsWith(bind + path.sep))
|
|
439
|
+
if (wall === undefined) continue
|
|
440
|
+
throw new UserError(
|
|
441
|
+
`exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
|
|
442
|
+
`project's directory, the repository or vx's own. A file grant binds its directory; ` +
|
|
443
|
+
`grant a directory of the task's own instead, such as "dist/".`,
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
return { ...config, allowRead: config.allowRead.flatMap((r) => punchWalls(r, walls)) }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Remove the placeholder files the task never wrote — still empty, mtime
|
|
451
|
+
* untouched — and return their paths. What the task wrote is its output
|
|
452
|
+
* and stays; what it did not is vx's own litter, and litter under a
|
|
453
|
+
* declared output would be archived as the task's (an empty `dist/vx`
|
|
454
|
+
* saved as a build) or, as a file where the task wanted a directory,
|
|
455
|
+
* would fail every later run the same way.
|
|
456
|
+
*/
|
|
457
|
+
export async function sweepPlaceholders(placeholders: readonly Placeholder[]): Promise<string[]> {
|
|
458
|
+
const untouched: string[] = []
|
|
459
|
+
for (const p of placeholders) {
|
|
460
|
+
// `lstat`: a link the task put in the placeholder's place is its
|
|
461
|
+
// output, never vx's litter to remove (item 1003).
|
|
462
|
+
const st = await lstat(p.path).catch(() => undefined)
|
|
463
|
+
if (st === undefined || !st.isFile() || st.size !== 0 || st.mtimeMs !== p.mtimeMs) continue
|
|
464
|
+
await rm(p.path, { force: true })
|
|
465
|
+
untouched.push(p.path)
|
|
466
|
+
}
|
|
467
|
+
return untouched
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* One sweep, however many askers, each getting the SAME list.
|
|
472
|
+
*
|
|
473
|
+
* A failed persistent task has two askers — the child's exit handler and
|
|
474
|
+
* the readiness failure that reports the hint — and a second
|
|
475
|
+
* `sweepPlaceholders` cannot serve the later one: the first one's `rm`
|
|
476
|
+
* already happened, so it finds nothing and names nothing. Collecting
|
|
477
|
+
* both lists and unioning them does not close it either, because a sweep
|
|
478
|
+
* still between its `rm` and its return has published nothing yet, and
|
|
479
|
+
* the union is then empty on both sides. Sharing the one promise is what
|
|
480
|
+
* makes the answer independent of who asked first.
|
|
481
|
+
*/
|
|
482
|
+
export function placeholderSweeper(placeholders: readonly Placeholder[]): () => Promise<string[]> {
|
|
483
|
+
let once: Promise<string[]> | undefined
|
|
484
|
+
return () => (once ??= sweepPlaceholders(placeholders))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The line a failed task gets for a placeholder it never wrote. Not a
|
|
489
|
+
* diagnosis — the task may have died before its first write — but the
|
|
490
|
+
* one clue to the trap: a grant that meant a directory is bound as a
|
|
491
|
+
* file, and the task's own `mkdir` says only "File exists". Added when
|
|
492
|
+
* the task already failed and the sandbox reported nothing else, so it
|
|
493
|
+
* never reddens a pass and never buries a real denial.
|
|
494
|
+
*/
|
|
495
|
+
export function untouchedPlaceholderLine(projectDir: string, placeholder: string): string {
|
|
496
|
+
const rel = path.relative(projectDir, placeholder).split(path.sep).join('/')
|
|
497
|
+
return (
|
|
498
|
+
`vx: the sandbox write grant \`${rel}\` named nothing on disk, so vx bound it as an empty ` +
|
|
499
|
+
`file, which the task never wrote (removed again). If the task creates a directory there ` +
|
|
500
|
+
`("File exists" from its own mkdir), spell the grant \`${rel}/\` — a literal without the ` +
|
|
501
|
+
`slash is a file.`
|
|
502
|
+
)
|
|
238
503
|
}
|
|
239
504
|
|
|
240
505
|
/** `~/x` against the user's home; anything else unchanged. */
|
|
241
506
|
function expandHome(p: string): string {
|
|
242
507
|
return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p
|
|
243
508
|
}
|
|
509
|
+
|
|
510
|
+
/** Where a command may have written: nothing a key reads, its own project, or any project. */
|
|
511
|
+
export type WriteReach = 'none' | 'project' | 'workspace'
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Where a task that ran a command may have written files no declaration
|
|
515
|
+
* names — the files whose run-start facts (the git snapshot, its index
|
|
516
|
+
* OIDs, the `package.json` digest) a later key must not reuse (item 743).
|
|
517
|
+
*
|
|
518
|
+
* A cached task declares its outputs and is held to them. A task with no
|
|
519
|
+
* `cache` block can declare none, so it may have written anywhere
|
|
520
|
+
* `commandWriteReach` says. An unsandboxed write into another project is
|
|
521
|
+
* out of contract, as it is for a cached task.
|
|
522
|
+
*/
|
|
523
|
+
export function undeclaredWriteReach(node: TaskNode, workspaceRoot: string): WriteReach {
|
|
524
|
+
if (node.config.cache !== undefined) return 'none'
|
|
525
|
+
return commandWriteReach(node, workspaceRoot)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Where a task's command may write at all: anywhere in its own project
|
|
530
|
+
* (`'project'`), unless a sandbox bounds it — no write grant writes nothing
|
|
531
|
+
* a key reads (`'none'`), and a grant that leaves the project for elsewhere
|
|
532
|
+
* in the workspace reaches every project (`'workspace'`). A grant outside
|
|
533
|
+
* the workspace reaches no input. A cached task's reach is where it may
|
|
534
|
+
* rewrite its own inputs in place (a formatter), which the stability gate
|
|
535
|
+
* reads (stable-keys.ts).
|
|
536
|
+
*/
|
|
537
|
+
export function commandWriteReach(node: TaskNode, workspaceRoot: string): WriteReach {
|
|
538
|
+
const exec = node.config.exec
|
|
539
|
+
if (exec === undefined) return 'none'
|
|
540
|
+
if (exec.sandbox === undefined) return 'project'
|
|
541
|
+
let reach: 'none' | 'project' = 'none'
|
|
542
|
+
for (const grant of exec.sandbox.allow?.write ?? []) {
|
|
543
|
+
const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
|
|
544
|
+
if (within(abs, node.projectDir)) reach = 'project'
|
|
545
|
+
else if (within(abs, workspaceRoot) || within(workspaceRoot, abs)) return 'workspace'
|
|
546
|
+
}
|
|
547
|
+
return reach
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* May this task's command rewrite a file the workspace fingerprint folds
|
|
552
|
+
* (a lockfile, `pnpm-workspace.yaml`)? Those sit at the workspace root, so
|
|
553
|
+
* an unsandboxed task may only when the root is its own project (`pnpm
|
|
554
|
+
* install` without `--frozen-lockfile` in a root task); a sandboxed one
|
|
555
|
+
* when a write grant covers one. Anyone else writing there crosses a
|
|
556
|
+
* project boundary, out of contract.
|
|
557
|
+
*/
|
|
558
|
+
export function mayWriteFingerprint(node: TaskNode, workspaceRoot: string): boolean {
|
|
559
|
+
const exec = node.config.exec
|
|
560
|
+
if (exec === undefined) return false
|
|
561
|
+
if (exec.sandbox === undefined) return node.projectDir === workspaceRoot
|
|
562
|
+
for (const grant of exec.sandbox.allow?.write ?? []) {
|
|
563
|
+
const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
|
|
564
|
+
for (const f of WORKSPACE_FINGERPRINT_FILES) {
|
|
565
|
+
if (within(path.join(workspaceRoot, f), abs)) return true
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return false
|
|
569
|
+
}
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// artifact against ~5 ms of execution on the 1,000-project bench
|
|
5
5
|
// (2026-09-10) — so holding the slot for it serialized I/O behind CPU.
|
|
6
6
|
// A bounded number run at once (memory: each pack holds an artifact's
|
|
7
|
-
// bytes)
|
|
8
|
-
//
|
|
7
|
+
// bytes); the scheduler finishes a task only once its save has settled
|
|
8
|
+
// (`settledOf`), so a run ends with every save in the cache; and a save
|
|
9
|
+
// that fails degrades to a miss next time, said once — the task's work
|
|
10
|
+
// ran.
|
|
9
11
|
|
|
10
12
|
export interface SaveLane {
|
|
11
13
|
/**
|
|
@@ -15,8 +17,6 @@ export interface SaveLane {
|
|
|
15
17
|
* same task in another run (admission's in-flight join).
|
|
16
18
|
*/
|
|
17
19
|
defer(save: () => Promise<void>): Promise<void>
|
|
18
|
-
/** Settle every deferred save, started or queued. */
|
|
19
|
-
drain(): Promise<void>
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
|
|
@@ -40,8 +40,5 @@ export function createSaveLane(cap: number, onError: (err: unknown) => void): Sa
|
|
|
40
40
|
else queue.push({ save, settle })
|
|
41
41
|
})
|
|
42
42
|
},
|
|
43
|
-
async drain() {
|
|
44
|
-
while (running.size > 0) await Promise.all([...running])
|
|
45
|
-
},
|
|
46
43
|
}
|
|
47
44
|
}
|