@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.
Files changed (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  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 +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. 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
@@ -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(depDirs, node.projectDir, workspaceRoot, keyed)
247
- depDirs.push(...links.granted, ...dependencyOutputs)
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' ? [workspaceRoot, ...walls] : [workspaceRoot]),
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
- 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
+ ],
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
- * The paths a sandboxed task may read of its dependencies' declared
321
- * outputs (`readableDeps`): each output glob's fixed head, or, for a glob
322
- * with none (`*.tsbuildinfo`), the files it matches now, so a root-level
323
- * output never grants the dependency's whole project. Asked when the task
324
- * runs, after its dependencies ran or were restored: a head that names
325
- * nothing is left out, as bwrap binds no missing path.
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<string[]> {
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 globs = [
336
- ...outputs.files.map((g) => [dep.projectDir, g] as const),
337
- ...(outputs.workspaceFiles ?? []).map((g) => [workspaceRoot, g] as const),
338
- ]
339
- for (const [base, glob] of globs) {
340
- if (glob.startsWith('!')) continue
341
- const head = grantPrefix(glob)
342
- if (head !== '' && head !== '.') {
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
- if (existsSync(abs)) reads.add(abs)
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
- return [...reads]
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
- * Where the workspace links in `dirs` actually point, split into what the
387
- * 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.
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 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
+ }
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
- !atOrUnder(self, target)
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
- const keyedDirs =
439
- keyed === undefined ? undefined : new Set(await Promise.all([...keyed].map((d) => realpath(d))))
440
- const grants = (target: string): boolean =>
441
- keyedDirs === undefined || !atOrUnder(target, root) || keyedDirs.has(target)
442
- // Merged in `dirs` order, so the link a hint names is the project's own
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]> = dirs.map((d) => [
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)) granted.push(target)
471
- else
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
- if (!atOrUnder(bind, home)) continue
615
- 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)))
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` or
738
- * `$XDG_DATA_HOME` moves a `.config` or `.local/share` one. A task sees
739
- * 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.
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))