@vzn/vx 0.0.651 → 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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- 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 +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- 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 +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- 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 +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- 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 +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- 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 +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- 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-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- 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 +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- 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 +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- 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 +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -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/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- 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 +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- 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 +142 -6
- 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 +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- 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 +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- 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 +58 -31
- 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
|
|
@@ -183,6 +184,11 @@ export function prepareSandbox(nodes: Iterable<SandboxedNode>): SandboxArmer | n
|
|
|
183
184
|
* anyone's (2026-09-24): `keyed` is the set of project directories the
|
|
184
185
|
* task's key answers for (keyed-projects.ts), and `undefined` for a task
|
|
185
186
|
* with no `cache`, which has no key to be stale.
|
|
187
|
+
*
|
|
188
|
+
* The other is the outputs of the tasks its key folds
|
|
189
|
+
* (`dependencyOutputs`, 2026-11-06): an edge the user drew, whose files
|
|
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.
|
|
186
192
|
*/
|
|
187
193
|
export async function sandboxRequestFor(
|
|
188
194
|
node: Pick<TaskNode, 'projectDir'>,
|
|
@@ -197,6 +203,12 @@ export async function sandboxRequestFor(
|
|
|
197
203
|
* grant covered it could plant artifacts and index rows (L-24).
|
|
198
204
|
*/
|
|
199
205
|
cacheDir?: string,
|
|
206
|
+
/** The dependencies' outputs it may read (`dependencyReads`): absolute paths. */
|
|
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[] = () => [],
|
|
200
212
|
): Promise<SandboxRequest> {
|
|
201
213
|
// The runtime reads a path holding a bracket as a pattern: on Linux it
|
|
202
214
|
// mounts no such write path (B-60); seatbelt's rules compile it as a
|
|
@@ -227,18 +239,28 @@ export async function sandboxRequestFor(
|
|
|
227
239
|
`it mounts none of the project — rename the directory, or run the task without exec.sandbox`,
|
|
228
240
|
)
|
|
229
241
|
}
|
|
230
|
-
const depDirs = [
|
|
231
|
-
path.join(node.projectDir, 'node_modules'),
|
|
232
|
-
path.join(workspaceRoot, 'node_modules'),
|
|
233
|
-
]
|
|
234
242
|
// A workspace dependency is a SYMLINK in `node_modules` pointing at a
|
|
235
243
|
// sibling project, so granting `node_modules` grants a link whose
|
|
236
244
|
// target is outside it. That target is a dependency, not a reach-out:
|
|
237
245
|
// no project config should have to name a sibling to import what its
|
|
238
246
|
// own `package.json` depends on (2026-09-05) — when the key
|
|
239
247
|
// moves with it.
|
|
240
|
-
const links = await linkedDeps(
|
|
241
|
-
|
|
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
|
+
]
|
|
242
264
|
// bwrap cannot --bind a path that does not exist: the bind silently
|
|
243
265
|
// becomes a no-op and writes to it appear to succeed but never land.
|
|
244
266
|
// Pre-create what the task said it will write — after the grants are
|
|
@@ -247,8 +269,10 @@ export async function sandboxRequestFor(
|
|
|
247
269
|
// The repository's and vx's own walls are sealed: no write grant may
|
|
248
270
|
// reach inside them either. A nested project is not: a grant into it is
|
|
249
271
|
// as explicit as one into a sibling.
|
|
272
|
+
const git = outsideGit(workspaceRoot)
|
|
250
273
|
const sealed = [
|
|
251
274
|
path.join(workspaceRoot, '.git'),
|
|
275
|
+
...git.dirs,
|
|
252
276
|
path.join(workspaceRoot, '.vx', 'cache'),
|
|
253
277
|
...(cacheDir !== undefined &&
|
|
254
278
|
toRealPath(cacheDir).startsWith(toRealPath(workspaceRoot) + path.sep)
|
|
@@ -269,7 +293,8 @@ export async function sandboxRequestFor(
|
|
|
269
293
|
throw err
|
|
270
294
|
}
|
|
271
295
|
const request: NonNullable<ExecuteRequest['sandbox']> = {
|
|
272
|
-
// Only what the task declared, plus node_modules
|
|
296
|
+
// Only what the task declared, plus node_modules and the outputs of
|
|
297
|
+
// the tasks its key folds (`dependencyReads`). Write paths are
|
|
273
298
|
// readable too: a task that writes `dist/x` expects to read it back
|
|
274
299
|
// (`tsc --incremental` re-reads .tsbuildinfo).
|
|
275
300
|
baseAllowRead: depDirs,
|
|
@@ -286,7 +311,10 @@ export async function sandboxRequestFor(
|
|
|
286
311
|
// readable (tools need `~/.cache`), but a dependency the task runs
|
|
287
312
|
// could copy a key into an output the cache shares (L-41).
|
|
288
313
|
baseDenyRead: [
|
|
289
|
-
...(process.platform === 'darwin'
|
|
314
|
+
...(process.platform === 'darwin'
|
|
315
|
+
? [workspaceRoot, ...walls, ...linkedWalls]
|
|
316
|
+
: [workspaceRoot, ...git.dirs]),
|
|
317
|
+
...(await gitTrees(workspaceRoot, git.trees, config.allowWrite)),
|
|
290
318
|
...(await credentialStores(workspaceRoot, config.allowWrite)),
|
|
291
319
|
...(await vxStores(workspaceRoot, config.allowWrite, cacheDir)),
|
|
292
320
|
],
|
|
@@ -300,14 +328,150 @@ export async function sandboxRequestFor(
|
|
|
300
328
|
reportWithin: node.projectDir,
|
|
301
329
|
// …and of a dependency withheld above: the task reached for it
|
|
302
330
|
// through its own `node_modules`, and that read is the stale hit the
|
|
303
|
-
// withholding exists to stop, not the wall.
|
|
304
|
-
|
|
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
|
+
],
|
|
305
340
|
config,
|
|
306
341
|
...(keyed === undefined ? {} : { keyed: [...keyed] }),
|
|
342
|
+
...(dependencyOutputs.length === 0 ? {} : { dependencyReads: [...dependencyOutputs] }),
|
|
343
|
+
...(dependencyReported.length === 0 ? {} : { dependencyReported: [...dependencyReported] }),
|
|
307
344
|
}
|
|
308
345
|
return { sandbox: request, placeholders, withheld: links.withheld }
|
|
309
346
|
}
|
|
310
347
|
|
|
348
|
+
/**
|
|
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.
|
|
361
|
+
*/
|
|
362
|
+
export async function dependencyReads(
|
|
363
|
+
deps: readonly TaskNode[],
|
|
364
|
+
workspaceRoot: string,
|
|
365
|
+
): Promise<DependencyReads> {
|
|
366
|
+
const reads = new Set<string>()
|
|
367
|
+
const reported = new Set<string>()
|
|
368
|
+
const bytesBy = new Map<string, number>()
|
|
369
|
+
let total = 0
|
|
370
|
+
for (const dep of deps) {
|
|
371
|
+
const outputs = dep.config.outputs
|
|
372
|
+
if (outputs === undefined) continue
|
|
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
|
+
}
|
|
393
|
+
const abs = path.join(base, head)
|
|
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)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
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 }
|
|
473
|
+
}
|
|
474
|
+
|
|
311
475
|
/**
|
|
312
476
|
* The sandbox half of the request, plus the empty files vx created so a
|
|
313
477
|
* literal write grant had something to bind (`placeholders`). They are
|
|
@@ -340,8 +504,10 @@ export interface WithheldLink {
|
|
|
340
504
|
}
|
|
341
505
|
|
|
342
506
|
/**
|
|
343
|
-
*
|
|
344
|
-
*
|
|
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.
|
|
345
511
|
*
|
|
346
512
|
* One level deep, plus one level inside a `@scope/` directory — the shape
|
|
347
513
|
* a package manager writes — and then the same in the `node_modules` of
|
|
@@ -362,16 +528,43 @@ export interface WithheldLink {
|
|
|
362
528
|
* outside the root lies outside the deny anchor, so granting it is a no-op
|
|
363
529
|
* either way. Every side of every comparison is canonical: a target always
|
|
364
530
|
* is, and a directory under a root reached through a link (macOS's `/var`)
|
|
365
|
-
* is not.
|
|
531
|
+
* is not. `keyedGranted` names each granted keyed project as `keyed` does.
|
|
366
532
|
*/
|
|
367
533
|
async function linkedDeps(
|
|
368
|
-
dirs: readonly string[],
|
|
369
534
|
projectDir: string,
|
|
370
535
|
workspaceRoot: string,
|
|
371
536
|
keyed: ReadonlySet<string> | undefined,
|
|
372
|
-
): Promise<{ granted: string[]; withheld: WithheldLink[] }> {
|
|
537
|
+
): Promise<{ granted: string[]; withheld: WithheldLink[]; keyedGranted: string[] }> {
|
|
373
538
|
const [self, root] = await Promise.all([realpath(projectDir), realpath(workspaceRoot)])
|
|
374
|
-
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
|
+
}
|
|
375
568
|
const scan = async (dir: string, scope: string): Promise<Array<[string, string, string]>> => {
|
|
376
569
|
const found: Array<[target: string, link: string, name: string]> = []
|
|
377
570
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
@@ -382,7 +575,7 @@ async function linkedDeps(
|
|
|
382
575
|
if (
|
|
383
576
|
target !== undefined &&
|
|
384
577
|
!realDirs.some((d) => atOrUnder(target, d)) &&
|
|
385
|
-
!
|
|
578
|
+
!walled(target)
|
|
386
579
|
) {
|
|
387
580
|
found.push([target, full, scope + e.name])
|
|
388
581
|
}
|
|
@@ -392,20 +585,26 @@ async function linkedDeps(
|
|
|
392
585
|
}
|
|
393
586
|
return found
|
|
394
587
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
//
|
|
400
|
-
// when both have one. A granted package is scanned in turn: pnpm and
|
|
401
|
-
// Bun link a dependency's own workspace dependencies only under ITS
|
|
402
|
-
// `node_modules`, so `app -> ui -> core` left core hidden and the import
|
|
403
|
-
// 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.
|
|
404
593
|
const targets = new Map<string, { link: string; name: string }>()
|
|
405
|
-
let frontier: Array<[nodeModules: string, owner: string | undefined]> =
|
|
594
|
+
let frontier: Array<[nodeModules: string, owner: string | undefined]> = granted.map((d) => [
|
|
406
595
|
d,
|
|
407
596
|
undefined,
|
|
408
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
|
+
}
|
|
409
608
|
while (frontier.length > 0) {
|
|
410
609
|
const next: typeof frontier = []
|
|
411
610
|
const scans = await Promise.all(frontier.map(([d]) => scan(d, '')))
|
|
@@ -421,11 +620,14 @@ async function linkedDeps(
|
|
|
421
620
|
}
|
|
422
621
|
frontier = next
|
|
423
622
|
}
|
|
424
|
-
const granted: string[] = []
|
|
425
623
|
const withheld: WithheldLink[] = []
|
|
624
|
+
const keyedGranted: string[] = []
|
|
426
625
|
for (const [target, { link, name }] of targets) {
|
|
427
|
-
if (grants(target))
|
|
428
|
-
|
|
626
|
+
if (grants(target)) {
|
|
627
|
+
granted.push(target)
|
|
628
|
+
const dir = keyedDirs?.get(target)
|
|
629
|
+
if (dir !== undefined) keyedGranted.push(dir)
|
|
630
|
+
} else
|
|
429
631
|
withheld.push({
|
|
430
632
|
dir: target,
|
|
431
633
|
name,
|
|
@@ -433,7 +635,7 @@ async function linkedDeps(
|
|
|
433
635
|
link: relPosix(workspaceRoot, link),
|
|
434
636
|
})
|
|
435
637
|
}
|
|
436
|
-
return { granted, withheld }
|
|
638
|
+
return { granted, withheld, keyedGranted }
|
|
437
639
|
}
|
|
438
640
|
|
|
439
641
|
/**
|
|
@@ -567,13 +769,15 @@ function wallOff(
|
|
|
567
769
|
const home = toRealPath(workspaceRoot)
|
|
568
770
|
for (const bind of bindableWrites(config.allowWrite)) {
|
|
569
771
|
// A bind outside the workspace is the user's own path (`/tmp/x`,
|
|
570
|
-
// `~/.cache/y`), spelled there on purpose; only one inside is judged
|
|
571
|
-
|
|
572
|
-
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)))
|
|
573
777
|
if (wall === undefined) continue
|
|
574
778
|
throw new UserError(
|
|
575
779
|
`exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
|
|
576
|
-
`project's directory,
|
|
780
|
+
`project's directory, .git or vx's own. A file grant binds its directory; ` +
|
|
577
781
|
`grant a directory of the task's own instead, such as "dist/".`,
|
|
578
782
|
)
|
|
579
783
|
}
|
|
@@ -652,10 +856,26 @@ export const CREDENTIAL_STORES = [
|
|
|
652
856
|
'.config/gh',
|
|
653
857
|
'.docker/config.json',
|
|
654
858
|
'.netrc',
|
|
859
|
+
// git's credential store, at its home and at its XDG default.
|
|
655
860
|
'.git-credentials',
|
|
861
|
+
'.config/git/credentials',
|
|
656
862
|
'.npmrc',
|
|
863
|
+
// Yarn's: Berry's, and classic's `_authToken`.
|
|
657
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',
|
|
658
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',
|
|
659
879
|
// Registry and service tokens of the same kind: Bun's global bunfig
|
|
660
880
|
// (`[install.scopes]` tokens) at either of its homes, Cargo's, RubyGems'.
|
|
661
881
|
'.bunfig.toml',
|
|
@@ -691,14 +911,16 @@ export const CREDENTIAL_STORES = [
|
|
|
691
911
|
]
|
|
692
912
|
|
|
693
913
|
/**
|
|
694
|
-
* Each store's path: under `home`, and again where `$XDG_CONFIG_HOME
|
|
695
|
-
* `$XDG_DATA_HOME` moves a `.config
|
|
696
|
-
*
|
|
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.
|
|
697
918
|
*/
|
|
698
919
|
export function credentialStorePaths(home: string): string[] {
|
|
699
920
|
const moved = [
|
|
700
921
|
['.config/', process.env['XDG_CONFIG_HOME']],
|
|
701
922
|
['.local/share/', process.env['XDG_DATA_HOME']],
|
|
923
|
+
['.cache/', process.env['XDG_CACHE_HOME']],
|
|
702
924
|
] as const
|
|
703
925
|
const out = new Set(CREDENTIAL_STORES.map((rel) => path.join(home, rel)))
|
|
704
926
|
for (const rel of CREDENTIAL_STORES)
|
|
@@ -752,6 +974,54 @@ async function vxStores(
|
|
|
752
974
|
return (await Promise.all(stores.map((p) => denyAround(p, writes)))).flat()
|
|
753
975
|
}
|
|
754
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
|
+
|
|
755
1025
|
/** `dir` denied, or its entries no write grant lies under or at when one is inside it. */
|
|
756
1026
|
async function denyAround(dir: string, writes: readonly string[]): Promise<string[]> {
|
|
757
1027
|
const inside = writes.filter((w) => atOrUnder(w, dir))
|
|
@@ -774,13 +1044,13 @@ export type WriteReach = 'none' | 'project' | 'workspace'
|
|
|
774
1044
|
* names — the files whose run-start facts (the git snapshot, its index
|
|
775
1045
|
* OIDs) a later key must not reuse (item 743).
|
|
776
1046
|
*
|
|
777
|
-
* A cached task declares its outputs and is held to them
|
|
778
|
-
* `cache`
|
|
779
|
-
* `commandWriteReach` says. An unsandboxed write into another project is
|
|
1047
|
+
* A cached task declares its outputs and is held to them: its save checks
|
|
1048
|
+
* them. A task with no `cache: true` is held to nothing, even when it
|
|
1049
|
+
* declares outputs, so it may have written anywhere `commandWriteReach` says. An unsandboxed write into another project is
|
|
780
1050
|
* out of contract, as it is for a cached task.
|
|
781
1051
|
*/
|
|
782
1052
|
export function undeclaredWriteReach(node: TaskNode, workspaceRoot: string): WriteReach {
|
|
783
|
-
if (node.config.cache
|
|
1053
|
+
if (node.config.cache === true) return 'none'
|
|
784
1054
|
return commandWriteReach(node, workspaceRoot)
|
|
785
1055
|
}
|
|
786
1056
|
|