@vzn/vx 0.0.652 → 0.0.653
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 +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { lstatSync } from 'node:fs'
|
|
7
7
|
import os from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import type
|
|
9
|
+
import { MOUNT_WILDCARDS, type SandboxViolation } from '../exec/index.js'
|
|
10
10
|
import type { TaskOutcome } from '../graph/index.js'
|
|
11
11
|
import { isLiteralPattern } from '../util/index.js'
|
|
12
12
|
import { credentialStorePaths } from './sandbox-request.js'
|
|
@@ -26,8 +26,8 @@ function isCredential(abs: string): boolean {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* A path as a grant
|
|
30
|
-
* under home, else absolute.
|
|
29
|
+
* A path as a grant names it, unescaped: project-relative inside the
|
|
30
|
+
* project, `~/…` under home, else absolute.
|
|
31
31
|
*/
|
|
32
32
|
function grantPath(abs: string, projectDir: string): string {
|
|
33
33
|
const rel = path.relative(projectDir, abs)
|
|
@@ -37,6 +37,26 @@ function grantPath(abs: string, projectDir: string): string {
|
|
|
37
37
|
return abs.startsWith(home + path.sep) ? `~/${abs.slice(home.length + 1)}` : abs
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The grant that allows `abs`, spelled as the sandbox reads it: a bracket
|
|
42
|
+
* escaped, since `pages/[id].tsx` is a class that grants `pages/i.tsx`,
|
|
43
|
+
* and applying the fix failed the run again on the same read. A path no
|
|
44
|
+
* grant can name alone (a `*`, `?` or backslash; on Linux a write with a
|
|
45
|
+
* bracket too: sandbox-binds.ts) is granted as the directory above it.
|
|
46
|
+
*/
|
|
47
|
+
function grantFor(abs: string, projectDir: string, kind: 'read' | 'write'): string {
|
|
48
|
+
const raw = grantPath(abs, projectDir)
|
|
49
|
+
const wild = raw.search(
|
|
50
|
+
kind === 'write' && process.platform === 'linux' ? MOUNT_WILDCARDS : /[*?]/,
|
|
51
|
+
)
|
|
52
|
+
const bad = Math.min(...[wild, raw.indexOf('\\')].filter((i) => i >= 0))
|
|
53
|
+
const at = bad === Infinity ? raw : raw.slice(0, raw.lastIndexOf('/', bad) + 1) || '.'
|
|
54
|
+
return at.replace(/[[\]]/g, '\\$&')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A project path as `inputs.files` names it: each task-glob wildcard escaped. */
|
|
58
|
+
const taskGlobOf = (rel: string): string => rel.replace(/[*?{}[\]\\]/g, '\\$&')
|
|
59
|
+
|
|
40
60
|
/**
|
|
41
61
|
* A write grant covers the top directory the write landed in: granting
|
|
42
62
|
* `dist/a.js` alone fails the run again on `dist/b.js`. Not when the task
|
|
@@ -45,7 +65,7 @@ function grantPath(abs: string, projectDir: string): string {
|
|
|
45
65
|
*/
|
|
46
66
|
function writeGrant(grant: string, isDir: boolean, inputs: readonly string[]): string {
|
|
47
67
|
const slash = grant.indexOf('/')
|
|
48
|
-
const exact = isDir ? `${grant}/` : grant
|
|
68
|
+
const exact = isDir && !grant.endsWith('/') ? `${grant}/` : grant
|
|
49
69
|
if (grant.startsWith('~/') || path.isAbsolute(grant) || slash === -1) return exact
|
|
50
70
|
const top = grant.slice(0, slash)
|
|
51
71
|
return inputs.some((g) => readsDir(g, top)) ? exact : `${top}/`
|
|
@@ -68,12 +88,18 @@ function keyedBy(globs: readonly string[]): (rel: string) => boolean {
|
|
|
68
88
|
return (rel) => take.some((g) => g.match(rel)) && !drop.some((g) => g.match(rel))
|
|
69
89
|
}
|
|
70
90
|
|
|
91
|
+
/** The task proxy's refusals an `allow.network` entry cannot lift (task-proxy.ts). */
|
|
92
|
+
const REFUSED_ANYWAY = /\((?:host is on the deny list|malformed host)\)$/
|
|
93
|
+
|
|
71
94
|
function kindOf(v: SandboxViolation): Kind | undefined {
|
|
72
95
|
const can = v.ignorable ?? []
|
|
73
96
|
if (can.length === 1) return can[0]
|
|
74
|
-
// Linux reports a refused `openat` as either:
|
|
75
|
-
//
|
|
76
|
-
//
|
|
97
|
+
// Linux reports a refused `openat` as either: its flags decide. Judged by
|
|
98
|
+
// the host alone, `cat optional.cfg` of a file it lacks was named a
|
|
99
|
+
// write, and the grant made vx create an empty one.
|
|
100
|
+
if (v.opened !== undefined) return v.opened
|
|
101
|
+
// A probe (`access`) names no flags: a file the host has was hidden from
|
|
102
|
+
// a read; one it lacks was being created.
|
|
77
103
|
if (can.includes('read') && can.includes('write') && v.path !== undefined)
|
|
78
104
|
return lstatSync(v.path, { throwIfNoEntry: false }) !== undefined ? 'read' : 'write'
|
|
79
105
|
return undefined
|
|
@@ -103,22 +129,24 @@ export function sandboxFix(
|
|
|
103
129
|
if (v.hint === true || v.target === undefined) continue
|
|
104
130
|
const kind = kindOf(v)
|
|
105
131
|
if (kind === undefined) continue
|
|
132
|
+
// A host the deny list holds, or a malformed one, no allow grant admits.
|
|
133
|
+
if (kind === 'network' && REFUSED_ANYWAY.test(v.line)) continue
|
|
106
134
|
if (kind === 'network') fix.network.add(v.target.replace(/:\d+$/, ''))
|
|
107
135
|
else if (kind === 'systemInfo') fix.systemInfo.add(v.target)
|
|
108
136
|
else if (v.path === undefined) continue
|
|
109
137
|
else if (isCredential(v.path)) fix.withheld.add(grantPath(v.path, projectDir))
|
|
110
138
|
else if (kind === 'read') {
|
|
111
|
-
const
|
|
112
|
-
fix.read.add(
|
|
139
|
+
const rel = grantPath(v.path, projectDir)
|
|
140
|
+
fix.read.add(grantFor(v.path, projectDir, 'read'))
|
|
113
141
|
// The link the task opened lies in a directory the sandbox lacks: the
|
|
114
142
|
// target alone stays unreachable, and the fix named it again forever.
|
|
115
|
-
if (v.via !== undefined) fix.read.add(
|
|
116
|
-
if (keyed !== undefined && inProject(
|
|
143
|
+
if (v.via !== undefined) fix.read.add(grantFor(v.via, projectDir, 'read'))
|
|
144
|
+
if (keyed !== undefined && inProject(rel) && !keyed(rel)) fix.inputs.add(taskGlobOf(rel))
|
|
117
145
|
} else {
|
|
118
146
|
const isDir =
|
|
119
147
|
v.line.startsWith('mkdir') ||
|
|
120
148
|
lstatSync(v.path, { throwIfNoEntry: false })?.isDirectory() === true
|
|
121
|
-
fix.write.add(writeGrant(
|
|
149
|
+
fix.write.add(writeGrant(grantFor(v.path, projectDir, 'write'), isDir, inputs ?? []))
|
|
122
150
|
}
|
|
123
151
|
}
|
|
124
152
|
const out: SandboxFix = {}
|
|
@@ -3,10 +3,11 @@
|
|
|
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 { existsSync } from 'node:fs'
|
|
6
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
7
7
|
import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import path from 'node:path'
|
|
10
|
+
import { repoFacts, repoRootOf } from '../cache/index.js'
|
|
10
11
|
import type { ExecConfig } from '../config.js'
|
|
11
12
|
import {
|
|
12
13
|
bindableWrites,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
type SandboxViolation,
|
|
25
26
|
} from '../exec/index.js'
|
|
26
27
|
import type { TaskNode } from '../graph/index.js'
|
|
27
|
-
import { grantPrefix, relPosix, UserError } from '../util/index.js'
|
|
28
|
+
import { grantPrefix, relPosix, restorableOutput, UserError } from '../util/index.js'
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Arm the sandbox runtime for a run, lazily: only when at least one task
|
|
@@ -186,7 +187,8 @@ export function prepareSandbox(nodes: Iterable<SandboxedNode>): SandboxArmer | n
|
|
|
186
187
|
*
|
|
187
188
|
* The other is the outputs of the tasks its key folds
|
|
188
189
|
* (`dependencyOutputs`, 2026-11-06): an edge the user drew, whose files
|
|
189
|
-
* this key already answers for, so the read can replay nothing stale
|
|
190
|
+
* this key already answers for, so the read can replay nothing stale —
|
|
191
|
+
* only the files the outputs take, never what lies beside them.
|
|
190
192
|
*/
|
|
191
193
|
export async function sandboxRequestFor(
|
|
192
194
|
node: Pick<TaskNode, 'projectDir'>,
|
|
@@ -203,6 +205,10 @@ export async function sandboxRequestFor(
|
|
|
203
205
|
cacheDir?: string,
|
|
204
206
|
/** The dependencies' outputs it may read (`dependencyReads`): absolute paths. */
|
|
205
207
|
dependencyOutputs: readonly string[] = [],
|
|
208
|
+
/** The heads walked for them (`dependencyReads`): a refused read under one is reported. */
|
|
209
|
+
dependencyReported: readonly string[] = [],
|
|
210
|
+
/** The projects nested in a keyed one, by its dir (keyed-projects.ts `nestedDirsOf`). */
|
|
211
|
+
nestedOf: (projectDir: string) => readonly string[] = () => [],
|
|
206
212
|
): Promise<SandboxRequest> {
|
|
207
213
|
// The runtime reads a path holding a bracket as a pattern: on Linux it
|
|
208
214
|
// mounts no such write path (B-60); seatbelt's rules compile it as a
|
|
@@ -233,18 +239,28 @@ export async function sandboxRequestFor(
|
|
|
233
239
|
`it mounts none of the project — rename the directory, or run the task without exec.sandbox`,
|
|
234
240
|
)
|
|
235
241
|
}
|
|
236
|
-
const depDirs = [
|
|
237
|
-
path.join(node.projectDir, 'node_modules'),
|
|
238
|
-
path.join(workspaceRoot, 'node_modules'),
|
|
239
|
-
]
|
|
240
242
|
// A workspace dependency is a SYMLINK in `node_modules` pointing at a
|
|
241
243
|
// sibling project, so granting `node_modules` grants a link whose
|
|
242
244
|
// target is outside it. That target is a dependency, not a reach-out:
|
|
243
245
|
// no project config should have to name a sibling to import what its
|
|
244
246
|
// own `package.json` depends on (2026-09-05) — when the key
|
|
245
247
|
// moves with it.
|
|
246
|
-
const links = await linkedDeps(
|
|
247
|
-
|
|
248
|
+
const links = await linkedDeps(node.projectDir, workspaceRoot, keyed)
|
|
249
|
+
// A granted package is bound whole, and its nested projects with it,
|
|
250
|
+
// whose files its key never folds (a glob stops at a nested project):
|
|
251
|
+
// an edit there was a stale hit. Walled unless the key answers for them
|
|
252
|
+
// too; reported, as a withheld package is.
|
|
253
|
+
const linkedWalls =
|
|
254
|
+
keyed === undefined
|
|
255
|
+
? []
|
|
256
|
+
: links.keyedGranted
|
|
257
|
+
.flatMap(nestedOf)
|
|
258
|
+
.filter((d) => !keyed.has(d))
|
|
259
|
+
.map(toRealPath)
|
|
260
|
+
const depDirs = [
|
|
261
|
+
...links.granted.flatMap((g) => punchWalls(g, linkedWalls)),
|
|
262
|
+
...dependencyOutputs,
|
|
263
|
+
]
|
|
248
264
|
// bwrap cannot --bind a path that does not exist: the bind silently
|
|
249
265
|
// becomes a no-op and writes to it appear to succeed but never land.
|
|
250
266
|
// Pre-create what the task said it will write — after the grants are
|
|
@@ -253,8 +269,10 @@ export async function sandboxRequestFor(
|
|
|
253
269
|
// The repository's and vx's own walls are sealed: no write grant may
|
|
254
270
|
// reach inside them either. A nested project is not: a grant into it is
|
|
255
271
|
// as explicit as one into a sibling.
|
|
272
|
+
const git = outsideGit(workspaceRoot)
|
|
256
273
|
const sealed = [
|
|
257
274
|
path.join(workspaceRoot, '.git'),
|
|
275
|
+
...git.dirs,
|
|
258
276
|
path.join(workspaceRoot, '.vx', 'cache'),
|
|
259
277
|
...(cacheDir !== undefined &&
|
|
260
278
|
toRealPath(cacheDir).startsWith(toRealPath(workspaceRoot) + path.sep)
|
|
@@ -293,7 +311,10 @@ export async function sandboxRequestFor(
|
|
|
293
311
|
// readable (tools need `~/.cache`), but a dependency the task runs
|
|
294
312
|
// could copy a key into an output the cache shares (L-41).
|
|
295
313
|
baseDenyRead: [
|
|
296
|
-
...(process.platform === 'darwin'
|
|
314
|
+
...(process.platform === 'darwin'
|
|
315
|
+
? [workspaceRoot, ...walls, ...linkedWalls]
|
|
316
|
+
: [workspaceRoot, ...git.dirs]),
|
|
317
|
+
...(await gitTrees(workspaceRoot, git.trees, config.allowWrite)),
|
|
297
318
|
...(await credentialStores(workspaceRoot, config.allowWrite)),
|
|
298
319
|
...(await vxStores(workspaceRoot, config.allowWrite, cacheDir)),
|
|
299
320
|
],
|
|
@@ -307,48 +328,148 @@ export async function sandboxRequestFor(
|
|
|
307
328
|
reportWithin: node.projectDir,
|
|
308
329
|
// …and of a dependency withheld above: the task reached for it
|
|
309
330
|
// through its own `node_modules`, and that read is the stale hit the
|
|
310
|
-
// withholding exists to stop, not the wall.
|
|
311
|
-
|
|
331
|
+
// withholding exists to stop, not the wall. So is one of a granted
|
|
332
|
+
// package's nested project walled above.
|
|
333
|
+
// So is a read beside a dependency's outputs, under the head its glob
|
|
334
|
+
// walked: a file no key answers for (`dependencyReads`).
|
|
335
|
+
reportLinked: [
|
|
336
|
+
...links.withheld.map((w) => w.dir),
|
|
337
|
+
...linkedWalls,
|
|
338
|
+
...dependencyReported.map(toRealPath),
|
|
339
|
+
],
|
|
312
340
|
config,
|
|
313
341
|
...(keyed === undefined ? {} : { keyed: [...keyed] }),
|
|
314
342
|
...(dependencyOutputs.length === 0 ? {} : { dependencyReads: [...dependencyOutputs] }),
|
|
343
|
+
...(dependencyReported.length === 0 ? {} : { dependencyReported: [...dependencyReported] }),
|
|
315
344
|
}
|
|
316
345
|
return { sandbox: request, placeholders, withheld: links.withheld }
|
|
317
346
|
}
|
|
318
347
|
|
|
319
348
|
/**
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
349
|
+
* What a sandboxed task may read of its dependencies' declared outputs
|
|
350
|
+
* (`readableDeps`): the files their globs take now (`restorableOutput`, so
|
|
351
|
+
* a `!` entry takes one back), a directory whole where every file under it
|
|
352
|
+
* is one. Binding each glob's head whole granted every file beside the
|
|
353
|
+
* outputs too (an untracked `gen/notes.txt` under `gen/**\/*.js`), which no
|
|
354
|
+
* key answers for: an edit to it replayed the old result as a hit. Asked
|
|
355
|
+
* when the task runs, after its dependencies ran or were restored.
|
|
356
|
+
*
|
|
357
|
+
* `reported` are the heads walked: a refused read under one is a file
|
|
358
|
+
* beside the outputs, reported as a violation. A glob with no head
|
|
359
|
+
* (`*.tsbuildinfo`) reports nothing, since its head is the dependency's
|
|
360
|
+
* whole project, whose sources its key folds.
|
|
326
361
|
*/
|
|
327
362
|
export async function dependencyReads(
|
|
328
363
|
deps: readonly TaskNode[],
|
|
329
364
|
workspaceRoot: string,
|
|
330
|
-
): Promise<
|
|
365
|
+
): Promise<DependencyReads> {
|
|
331
366
|
const reads = new Set<string>()
|
|
367
|
+
const reported = new Set<string>()
|
|
368
|
+
const bytesBy = new Map<string, number>()
|
|
369
|
+
let total = 0
|
|
332
370
|
for (const dep of deps) {
|
|
333
371
|
const outputs = dep.config.outputs
|
|
334
372
|
if (outputs === undefined) continue
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
373
|
+
const add = (p: string): void => {
|
|
374
|
+
if (reads.has(p)) return
|
|
375
|
+
reads.add(p)
|
|
376
|
+
total += bindBytes(p)
|
|
377
|
+
bytesBy.set(dep.id, (bytesBy.get(dep.id) ?? 0) + bindBytes(p))
|
|
378
|
+
}
|
|
379
|
+
const scopes = [
|
|
380
|
+
[dep.projectDir, outputs.files, 'project'],
|
|
381
|
+
[workspaceRoot, outputs.workspaceFiles ?? [], 'workspace'],
|
|
382
|
+
] as const
|
|
383
|
+
for (const [base, globs, scope] of scopes) {
|
|
384
|
+
const isOutput = restorableOutput(globs, scope)
|
|
385
|
+
for (const glob of globs) {
|
|
386
|
+
if (glob.startsWith('!')) continue
|
|
387
|
+
const head = grantPrefix(glob)
|
|
388
|
+
if (head === '' || head === '.') {
|
|
389
|
+
for await (const f of new Bun.Glob(glob).scan({ cwd: base, onlyFiles: true, dot: true }))
|
|
390
|
+
if (isOutput(f)) add(path.join(base, f))
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
343
393
|
const abs = path.join(base, head)
|
|
344
|
-
|
|
345
|
-
continue
|
|
394
|
+
const found = await outputBinds(abs, head, isOutput)
|
|
395
|
+
if (found === undefined) continue
|
|
396
|
+
found.binds.forEach(add)
|
|
397
|
+
if (found.dir) reported.add(abs)
|
|
346
398
|
}
|
|
347
|
-
for await (const f of new Bun.Glob(glob).scan({ cwd: base, onlyFiles: true, dot: true }))
|
|
348
|
-
reads.add(path.join(base, f))
|
|
349
399
|
}
|
|
350
400
|
}
|
|
351
|
-
|
|
401
|
+
if (total > DEPENDENCY_BIND_BYTES) {
|
|
402
|
+
const most = [...bytesBy].sort((a, b) => b[1] - a[1]).map(([id]) => id)
|
|
403
|
+
throw new UserError(
|
|
404
|
+
`exec.sandbox: the outputs of ${most.slice(0, 3).join(', ')} lie among other files, so ` +
|
|
405
|
+
`the sandbox would bind ${reads.size} of them one by one, more than its command line ` +
|
|
406
|
+
`holds — declare the files beside them outputs too (a whole \`dir/**\`), or move them ` +
|
|
407
|
+
`out of the output directory`,
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
return { reads: [...reads], reported: [...reported] }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* How much of the sandbox's command line the dependency binds may take.
|
|
415
|
+
* Each is `--ro-bind <path> <path>` on Linux, and the whole wrap is one
|
|
416
|
+
* argument to `sh -c`, which holds at most 128 KiB: 1,000 output files
|
|
417
|
+
* bound one by one under a 100-character path failed the spawn with E2BIG
|
|
418
|
+
* (206 KiB). What is left is the wrap's own and the command's.
|
|
419
|
+
*/
|
|
420
|
+
const DEPENDENCY_BIND_BYTES = 64 * 1024
|
|
421
|
+
|
|
422
|
+
/** The bytes one read bind adds to the wrap: its path twice, quoted, and the flag. */
|
|
423
|
+
function bindBytes(p: string): number {
|
|
424
|
+
return 2 * Buffer.byteLength(p) + 16
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** `dependencyReads`: the paths to bind, and the heads whose refused reads are reported. */
|
|
428
|
+
export interface DependencyReads {
|
|
429
|
+
readonly reads: readonly string[]
|
|
430
|
+
readonly reported: readonly string[]
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The binds that grant exactly the outputs at `abs` (`rel` from the
|
|
435
|
+
* dependency's base): itself when it is a file `isOutput` takes or a
|
|
436
|
+
* directory every file under it is, else its children's binds. `whole`
|
|
437
|
+
* says it was bound itself; `undefined` is a missing path. A link is a
|
|
438
|
+
* leaf: bound alone it would grant its target, which may be no output, so
|
|
439
|
+
* it is granted only inside a directory bound whole.
|
|
440
|
+
*/
|
|
441
|
+
async function outputBinds(
|
|
442
|
+
abs: string,
|
|
443
|
+
rel: string,
|
|
444
|
+
isOutput: (rel: string) => boolean,
|
|
445
|
+
): Promise<{ binds: string[]; whole: boolean; dir: boolean } | undefined> {
|
|
446
|
+
const st = await lstat(abs).catch(() => undefined)
|
|
447
|
+
if (st === undefined) return undefined
|
|
448
|
+
if (st.isDirectory()) return { ...outputDirBinds(abs, rel, isOutput), dir: true }
|
|
449
|
+
const whole = isOutput(rel)
|
|
450
|
+
return { binds: whole && !st.isSymbolicLink() ? [abs] : [], whole, dir: false }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** `outputBinds` for a directory. */
|
|
454
|
+
function outputDirBinds(
|
|
455
|
+
abs: string,
|
|
456
|
+
rel: string,
|
|
457
|
+
isOutput: (rel: string) => boolean,
|
|
458
|
+
): { binds: string[]; whole: boolean } {
|
|
459
|
+
const binds: string[] = []
|
|
460
|
+
let whole = true
|
|
461
|
+
for (const e of readdirSync(abs, { withFileTypes: true })) {
|
|
462
|
+
const childRel = `${rel}/${e.name}`
|
|
463
|
+
const childAbs = `${abs}/${e.name}`
|
|
464
|
+
if (e.isDirectory()) {
|
|
465
|
+
const child = outputDirBinds(childAbs, childRel, isOutput)
|
|
466
|
+
binds.push(...child.binds)
|
|
467
|
+
whole &&= child.whole
|
|
468
|
+
} else if (isOutput(childRel)) {
|
|
469
|
+
if (!e.isSymbolicLink()) binds.push(childAbs)
|
|
470
|
+
} else whole = false
|
|
471
|
+
}
|
|
472
|
+
return { binds: whole ? [abs] : binds, whole }
|
|
352
473
|
}
|
|
353
474
|
|
|
354
475
|
/**
|
|
@@ -383,8 +504,10 @@ export interface WithheldLink {
|
|
|
383
504
|
}
|
|
384
505
|
|
|
385
506
|
/**
|
|
386
|
-
*
|
|
387
|
-
*
|
|
507
|
+
* The project's and the workspace root's `node_modules`, and where the
|
|
508
|
+
* workspace links in them actually point, split into what the task is
|
|
509
|
+
* granted and what it is not. A `node_modules` that is itself a link is
|
|
510
|
+
* judged by its target, as a link inside one is.
|
|
388
511
|
*
|
|
389
512
|
* One level deep, plus one level inside a `@scope/` directory — the shape
|
|
390
513
|
* a package manager writes — and then the same in the `node_modules` of
|
|
@@ -405,16 +528,43 @@ export interface WithheldLink {
|
|
|
405
528
|
* outside the root lies outside the deny anchor, so granting it is a no-op
|
|
406
529
|
* either way. Every side of every comparison is canonical: a target always
|
|
407
530
|
* is, and a directory under a root reached through a link (macOS's `/var`)
|
|
408
|
-
* is not.
|
|
531
|
+
* is not. `keyedGranted` names each granted keyed project as `keyed` does.
|
|
409
532
|
*/
|
|
410
533
|
async function linkedDeps(
|
|
411
|
-
dirs: readonly string[],
|
|
412
534
|
projectDir: string,
|
|
413
535
|
workspaceRoot: string,
|
|
414
536
|
keyed: ReadonlySet<string> | undefined,
|
|
415
|
-
): Promise<{ granted: string[]; withheld: WithheldLink[] }> {
|
|
537
|
+
): Promise<{ granted: string[]; withheld: WithheldLink[]; keyedGranted: string[] }> {
|
|
416
538
|
const [self, root] = await Promise.all([realpath(projectDir), realpath(workspaceRoot)])
|
|
417
|
-
const
|
|
539
|
+
const git = path.join(root, '.git')
|
|
540
|
+
// A target holding the project hands it back whole; one holding or
|
|
541
|
+
// inside `.git` hands over the repository, which no key folds.
|
|
542
|
+
const walled = (target: string): boolean =>
|
|
543
|
+
atOrUnder(self, target) || atOrUnder(git, target) || atOrUnder(target, git)
|
|
544
|
+
// Canonical keyed dir -> the dir as vx holds it.
|
|
545
|
+
const keyedDirs =
|
|
546
|
+
keyed === undefined
|
|
547
|
+
? undefined
|
|
548
|
+
: new Map(await Promise.all([...keyed].map(async (d) => [await realpath(d), d] as const)))
|
|
549
|
+
const grants = (target: string): boolean =>
|
|
550
|
+
keyedDirs === undefined || !atOrUnder(target, root) || keyedDirs.has(target)
|
|
551
|
+
// The two implicit `node_modules`, each granted as itself only where it
|
|
552
|
+
// really is. One that is itself a link (`ln -s .. node_modules`) bound
|
|
553
|
+
// its target whole: the workspace root, a sibling or `.git`, and an edit
|
|
554
|
+
// there was a stale hit. Its target is judged as a workspace link's is.
|
|
555
|
+
const granted: string[] = []
|
|
556
|
+
const realDirs: string[] = []
|
|
557
|
+
const outward: Array<[target: string, link: string]> = []
|
|
558
|
+
for (const [dir, own] of [
|
|
559
|
+
[path.join(projectDir, 'node_modules'), path.join(self, 'node_modules')],
|
|
560
|
+
[path.join(workspaceRoot, 'node_modules'), path.join(root, 'node_modules')],
|
|
561
|
+
] as const) {
|
|
562
|
+
const target = await realpath(dir).catch(() => own)
|
|
563
|
+
if (target === own) {
|
|
564
|
+
granted.push(dir)
|
|
565
|
+
realDirs.push(own)
|
|
566
|
+
} else outward.push([target, dir])
|
|
567
|
+
}
|
|
418
568
|
const scan = async (dir: string, scope: string): Promise<Array<[string, string, string]>> => {
|
|
419
569
|
const found: Array<[target: string, link: string, name: string]> = []
|
|
420
570
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
@@ -425,7 +575,7 @@ async function linkedDeps(
|
|
|
425
575
|
if (
|
|
426
576
|
target !== undefined &&
|
|
427
577
|
!realDirs.some((d) => atOrUnder(target, d)) &&
|
|
428
|
-
!
|
|
578
|
+
!walled(target)
|
|
429
579
|
) {
|
|
430
580
|
found.push([target, full, scope + e.name])
|
|
431
581
|
}
|
|
@@ -435,20 +585,26 @@ async function linkedDeps(
|
|
|
435
585
|
}
|
|
436
586
|
return found
|
|
437
587
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
//
|
|
443
|
-
// when both have one. A granted package is scanned in turn: pnpm and
|
|
444
|
-
// Bun link a dependency's own workspace dependencies only under ITS
|
|
445
|
-
// `node_modules`, so `app -> ui -> core` left core hidden and the import
|
|
446
|
-
// of ui failed with ENOENT, unreported.
|
|
588
|
+
// Merged in order, the project's `node_modules` first, so the link a hint
|
|
589
|
+
// names is the project's own when both have one. A granted package is
|
|
590
|
+
// scanned in turn: pnpm and Bun link a dependency's own workspace
|
|
591
|
+
// dependencies only under ITS `node_modules`, so `app -> ui -> core` left
|
|
592
|
+
// core hidden and the import of ui failed with ENOENT, unreported.
|
|
447
593
|
const targets = new Map<string, { link: string; name: string }>()
|
|
448
|
-
let frontier: Array<[nodeModules: string, owner: string | undefined]> =
|
|
594
|
+
let frontier: Array<[nodeModules: string, owner: string | undefined]> = granted.map((d) => [
|
|
449
595
|
d,
|
|
450
596
|
undefined,
|
|
451
597
|
])
|
|
598
|
+
for (const [target, link] of outward) {
|
|
599
|
+
if (realDirs.some((d) => atOrUnder(target, d))) granted.push(link)
|
|
600
|
+
else if (!walled(target) && !targets.has(target)) {
|
|
601
|
+
targets.set(target, { link, name: relPosix(root, target) })
|
|
602
|
+
if (grants(target)) {
|
|
603
|
+
realDirs.push(target)
|
|
604
|
+
frontier.push([target, undefined])
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
452
608
|
while (frontier.length > 0) {
|
|
453
609
|
const next: typeof frontier = []
|
|
454
610
|
const scans = await Promise.all(frontier.map(([d]) => scan(d, '')))
|
|
@@ -464,11 +620,14 @@ async function linkedDeps(
|
|
|
464
620
|
}
|
|
465
621
|
frontier = next
|
|
466
622
|
}
|
|
467
|
-
const granted: string[] = []
|
|
468
623
|
const withheld: WithheldLink[] = []
|
|
624
|
+
const keyedGranted: string[] = []
|
|
469
625
|
for (const [target, { link, name }] of targets) {
|
|
470
|
-
if (grants(target))
|
|
471
|
-
|
|
626
|
+
if (grants(target)) {
|
|
627
|
+
granted.push(target)
|
|
628
|
+
const dir = keyedDirs?.get(target)
|
|
629
|
+
if (dir !== undefined) keyedGranted.push(dir)
|
|
630
|
+
} else
|
|
472
631
|
withheld.push({
|
|
473
632
|
dir: target,
|
|
474
633
|
name,
|
|
@@ -476,7 +635,7 @@ async function linkedDeps(
|
|
|
476
635
|
link: relPosix(workspaceRoot, link),
|
|
477
636
|
})
|
|
478
637
|
}
|
|
479
|
-
return { granted, withheld }
|
|
638
|
+
return { granted, withheld, keyedGranted }
|
|
480
639
|
}
|
|
481
640
|
|
|
482
641
|
/**
|
|
@@ -610,9 +769,11 @@ function wallOff(
|
|
|
610
769
|
const home = toRealPath(workspaceRoot)
|
|
611
770
|
for (const bind of bindableWrites(config.allowWrite)) {
|
|
612
771
|
// A bind outside the workspace is the user's own path (`/tmp/x`,
|
|
613
|
-
// `~/.cache/y`), spelled there on purpose; only one inside is judged
|
|
614
|
-
|
|
615
|
-
const wall =
|
|
772
|
+
// `~/.cache/y`), spelled there on purpose; only one inside is judged,
|
|
773
|
+
// and one reaching the repository's git dir outside it (`outsideGit`).
|
|
774
|
+
const wall = atOrUnder(bind, home)
|
|
775
|
+
? (walls.find((w) => atOrUnder(w, bind)) ?? sealed.find((w) => atOrUnder(bind, w)))
|
|
776
|
+
: sealed.find((w) => !atOrUnder(w, home) && (atOrUnder(w, bind) || atOrUnder(bind, w)))
|
|
616
777
|
if (wall === undefined) continue
|
|
617
778
|
throw new UserError(
|
|
618
779
|
`exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
|
|
@@ -695,10 +856,26 @@ export const CREDENTIAL_STORES = [
|
|
|
695
856
|
'.config/gh',
|
|
696
857
|
'.docker/config.json',
|
|
697
858
|
'.netrc',
|
|
859
|
+
// git's credential store, at its home and at its XDG default.
|
|
698
860
|
'.git-credentials',
|
|
861
|
+
'.config/git/credentials',
|
|
699
862
|
'.npmrc',
|
|
863
|
+
// Yarn's: Berry's, and classic's `_authToken`.
|
|
700
864
|
'.yarnrc.yml',
|
|
865
|
+
'.yarnrc',
|
|
866
|
+
// pnpm's global rc, which `pnpm config set` writes a token to.
|
|
867
|
+
'.config/pnpm/rc',
|
|
868
|
+
'Library/Preferences/pnpm/rc',
|
|
701
869
|
'.pypirc',
|
|
870
|
+
'.config/composer/auth.json',
|
|
871
|
+
'.composer/auth.json',
|
|
872
|
+
'.config/glab-cli',
|
|
873
|
+
'.config/github-copilot',
|
|
874
|
+
'.s3cfg',
|
|
875
|
+
'.oci',
|
|
876
|
+
'.cache/huggingface/token',
|
|
877
|
+
'.huggingface/token',
|
|
878
|
+
'.kaggle/kaggle.json',
|
|
702
879
|
// Registry and service tokens of the same kind: Bun's global bunfig
|
|
703
880
|
// (`[install.scopes]` tokens) at either of its homes, Cargo's, RubyGems'.
|
|
704
881
|
'.bunfig.toml',
|
|
@@ -734,14 +911,16 @@ export const CREDENTIAL_STORES = [
|
|
|
734
911
|
]
|
|
735
912
|
|
|
736
913
|
/**
|
|
737
|
-
* Each store's path: under `home`, and again where `$XDG_CONFIG_HOME
|
|
738
|
-
* `$XDG_DATA_HOME` moves a `.config
|
|
739
|
-
*
|
|
914
|
+
* Each store's path: under `home`, and again where `$XDG_CONFIG_HOME`,
|
|
915
|
+
* `$XDG_DATA_HOME` or `$XDG_CACHE_HOME` moves a `.config`, `.local/share`
|
|
916
|
+
* or `.cache` one. A task sees all three, so a CLI reads its login there.
|
|
917
|
+
* A relative value is no XDG path.
|
|
740
918
|
*/
|
|
741
919
|
export function credentialStorePaths(home: string): string[] {
|
|
742
920
|
const moved = [
|
|
743
921
|
['.config/', process.env['XDG_CONFIG_HOME']],
|
|
744
922
|
['.local/share/', process.env['XDG_DATA_HOME']],
|
|
923
|
+
['.cache/', process.env['XDG_CACHE_HOME']],
|
|
745
924
|
] as const
|
|
746
925
|
const out = new Set(CREDENTIAL_STORES.map((rel) => path.join(home, rel)))
|
|
747
926
|
for (const rel of CREDENTIAL_STORES)
|
|
@@ -795,6 +974,54 @@ async function vxStores(
|
|
|
795
974
|
return (await Promise.all(stores.map((p) => denyAround(p, writes)))).flat()
|
|
796
975
|
}
|
|
797
976
|
|
|
977
|
+
/** The repository's git dirs and other work trees outside the workspace (`outsideGit`). */
|
|
978
|
+
interface OutsideGit {
|
|
979
|
+
/** The git dir and the common dir: denied and sealed, as `<workspaceRoot>/.git` is. */
|
|
980
|
+
dirs: string[]
|
|
981
|
+
/** The top-level above the workspace, a linked worktree's main checkout: denied around it. */
|
|
982
|
+
trees: string[]
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* What of the repository lies outside the workspace. A workspace below
|
|
987
|
+
* the git top-level, or in a linked worktree (vx-dte's `local()` workers),
|
|
988
|
+
* keeps its git dirs there, and only `<workspaceRoot>/.git` was walled: a
|
|
989
|
+
* task read any project's committed bytes with `git --git-dir`, and a
|
|
990
|
+
* worktree's main checkout directly, into an output no key answered for.
|
|
991
|
+
* A tree holding home stays readable: a dotfiles repository at `~` would
|
|
992
|
+
* deny `~/.cache` with it, and its git dir alone is walled.
|
|
993
|
+
* Read from `repoFacts`, the memo the run's git enumeration filled.
|
|
994
|
+
*/
|
|
995
|
+
function outsideGit(workspaceRoot: string): OutsideGit {
|
|
996
|
+
const facts = repoFacts(workspaceRoot)
|
|
997
|
+
if (facts === null) return { dirs: [], trees: [] }
|
|
998
|
+
const root = toRealPath(workspaceRoot)
|
|
999
|
+
const common = toRealPath(path.resolve(root, facts.commonDir))
|
|
1000
|
+
const dirs = [...new Set([toRealPath(facts.gitDir), common])].filter(
|
|
1001
|
+
(d) => !atOrUnder(d, root) && !atOrUnder(root, d),
|
|
1002
|
+
)
|
|
1003
|
+
const home = toRealPath(process.env['HOME'] || homedir())
|
|
1004
|
+
// A common dir named `.git` sits in the main checkout; a bare one has none.
|
|
1005
|
+
const main = path.basename(common) === '.git' ? [path.dirname(common)] : []
|
|
1006
|
+
const trees = [...new Set([repoRootOf(root, facts.prefix), ...main])].filter(
|
|
1007
|
+
(t) => !atOrUnder(t, root) && !atOrUnder(home, t),
|
|
1008
|
+
)
|
|
1009
|
+
return { dirs, trees }
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Each tree denied around the workspace and the task's write grants: the
|
|
1014
|
+
* top-level's other contents are another workspace's, not this one's.
|
|
1015
|
+
*/
|
|
1016
|
+
async function gitTrees(
|
|
1017
|
+
workspaceRoot: string,
|
|
1018
|
+
trees: readonly string[],
|
|
1019
|
+
writes: readonly string[],
|
|
1020
|
+
): Promise<string[]> {
|
|
1021
|
+
const keep = [toRealPath(workspaceRoot), ...writes]
|
|
1022
|
+
return (await Promise.all(trees.map((t) => denyAround(t, keep)))).flat()
|
|
1023
|
+
}
|
|
1024
|
+
|
|
798
1025
|
/** `dir` denied, or its entries no write grant lies under or at when one is inside it. */
|
|
799
1026
|
async function denyAround(dir: string, writes: readonly string[]): Promise<string[]> {
|
|
800
1027
|
const inside = writes.filter((w) => atOrUnder(w, dir))
|