@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.
Files changed (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. 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 { SandboxViolation } from '../exec/index.js'
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 spells it: project-relative inside the project, `~/…`
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: the flags the parse drops
75
- // decide. A file the host has was hidden from a read; one it lacks was
76
- // being created.
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 grant = grantPath(v.path, projectDir)
112
- fix.read.add(grant)
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(grantPath(v.via, projectDir))
116
- if (keyed !== undefined && inProject(grant) && !keyed(grant)) fix.inputs.add(grant)
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(grantPath(v.path, projectDir), isDir, inputs ?? []))
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(depDirs, node.projectDir, workspaceRoot, keyed)
241
- depDirs.push(...links.granted)
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. Write paths are
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' ? [workspaceRoot, ...walls] : [workspaceRoot]),
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
- reportLinked: links.withheld.map((w) => w.dir),
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
- * Where the workspace links in `dirs` actually point, split into what the
344
- * task is granted and what it is not.
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 realDirs = dirs.map(toRealPath)
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
- !atOrUnder(self, target)
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
- const keyedDirs =
396
- keyed === undefined ? undefined : new Set(await Promise.all([...keyed].map((d) => realpath(d))))
397
- const grants = (target: string): boolean =>
398
- keyedDirs === undefined || !atOrUnder(target, root) || keyedDirs.has(target)
399
- // Merged in `dirs` order, so the link a hint names is the project's own
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]> = dirs.map((d) => [
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)) granted.push(target)
428
- else
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
- if (!atOrUnder(bind, home)) continue
572
- const wall = walls.find((w) => atOrUnder(w, bind)) ?? sealed.find((w) => atOrUnder(bind, w))
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, the repository or vx's own. A file grant binds its 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` or
695
- * `$XDG_DATA_HOME` moves a `.config` or `.local/share` one. A task sees
696
- * both, so a CLI reads its login there. A relative value is no XDG path.
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. A task with no
778
- * `cache` block can declare none, so it may have written anywhere
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 !== undefined) return 'none'
1053
+ if (node.config.cache === true) return 'none'
784
1054
  return commandWriteReach(node, workspaceRoot)
785
1055
  }
786
1056