@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -55,7 +55,6 @@ import {
55
55
  realpathSync,
56
56
  statSync,
57
57
  utimesSync,
58
- writeFileSync,
59
58
  } from 'node:fs'
60
59
  import type { Stats } from 'node:fs'
61
60
  import {
@@ -74,7 +73,7 @@ import {
74
73
  writeFile,
75
74
  } from 'node:fs/promises'
76
75
  import path from 'node:path'
77
- import { UserError } from '../util/index.js'
76
+ import { ignoreDir, UserError, writableUnder } from '../util/index.js'
78
77
  import { WORKSPACE_OUTPUT_PREFIX } from './layer.js'
79
78
  import {
80
79
  TarFormatError,
@@ -140,6 +139,7 @@ class EntrySum {
140
139
  */
141
140
  class SumCheck {
142
141
  private readonly sum = new EntrySum()
142
+ private readonly names = new Set<string>()
143
143
  private seen: boolean = false
144
144
  /** The entry's body, hashed as the caller reads it; null for the sum entry itself (checked here). */
145
145
  async body(
@@ -157,6 +157,10 @@ class SumCheck {
157
157
  enter(name: string): boolean {
158
158
  if (this.seen) throw new TarFormatError(`entry ${name} follows the artifact's checksum`)
159
159
  if (name === SUM_ENTRY) return true
160
+ // vx never packs a name twice, and a restore staged both: their renames
161
+ // ran together, so which body landed was a race the index never saw.
162
+ if (this.names.has(name)) throw new ArchiveSecurityError(`archive holds ${name} twice`)
163
+ this.names.add(name)
160
164
  this.sum.entry(name)
161
165
  return false
162
166
  }
@@ -213,6 +217,14 @@ interface MetaFile {
213
217
  * Turbo and Nx do, where it once packed a copy of the target (v45).
214
218
  */
215
219
  links?: Record<string, string>
220
+ /**
221
+ * The directories under the outputs whose mode withholds a permission
222
+ * 0755 grants (`chmod 700 dist/keys`), entry name → permission bits
223
+ * (v49): a hit made each missing directory with mkdir's default, so a
224
+ * directory that kept its files to their owner came back readable by
225
+ * every user.
226
+ */
227
+ dirModes?: Record<string, number>
216
228
  /**
217
229
  * What the PRODUCING execution used (2026-09-12, additive: an artifact
218
230
  * without it reads as before). Rides the artifact so a machine that
@@ -265,6 +277,8 @@ interface PackArgs {
265
277
  exec?: ExecUsage | undefined
266
278
  /** The workspace root an output symlink's target must stay inside; absent, every link is refused. */
267
279
  root?: string
280
+ /** Where `outputs/` names lie, for the directory modes (`dirModes`); absent, none is recorded. */
281
+ projectDir?: string
268
282
  }
269
283
 
270
284
  /** The largest second count ustar's 12-byte mtime field holds: 11 octal digits. */
@@ -332,7 +346,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
332
346
  `${shown} is a symlink to ${to}, outside the workspace: vx restores a link as written, and a project's outputs stay in its workspace — emit a file there, or take it back with a '!' entry`,
333
347
  )
334
348
  }
335
- return { name, link: to }
349
+ return { name, link: to, abs, shown }
336
350
  }
337
351
  if (!st.isFile()) {
338
352
  throw new UserError(
@@ -353,6 +367,19 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
353
367
  }
354
368
  }),
355
369
  )
370
+ const linked = new Map<string, string>()
371
+ const symlinks = files.filter(
372
+ (f): f is Extract<(typeof files)[number], { link: string }> => 'link' in f,
373
+ )
374
+ for (const f of symlinks) linked.set(f.abs, f.shown)
375
+ for (const f of symlinks) {
376
+ const via = throughLink(path.dirname(f.abs), f.link, linked)
377
+ if (via !== undefined) {
378
+ throw new UserError(
379
+ `${f.shown} is a symlink to ${f.link} through ${linked.get(via)}, another link: vx restores each link as written and checks where it points alone — point it at the target directly, or take it back with a '!' entry`,
380
+ )
381
+ }
382
+ }
356
383
  // In the outputs' order, not as each stat settled: one tree, one artifact.
357
384
  const regular: Array<Exclude<(typeof files)[number], { link: string }>> = []
358
385
  for (const f of files) {
@@ -362,6 +389,17 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
362
389
  regular.push(f)
363
390
  }
364
391
  }
392
+ if (args.projectDir !== undefined) {
393
+ const modes = narrowDirModes(
394
+ [...args.outputs.keys(), ...(args.dirs ?? [])],
395
+ args.dirs ?? [],
396
+ (name) =>
397
+ name.startsWith('outputs/')
398
+ ? path.join(args.projectDir!, name.slice('outputs/'.length))
399
+ : path.join(args.root ?? args.projectDir!, name.slice(WORKSPACE_PREFIX.length)),
400
+ )
401
+ if (modes !== undefined) meta.dirModes = modes
402
+ }
365
403
  const stdout = new TextEncoder().encode(args.stdout)
366
404
  const metaBytes = new TextEncoder().encode(JSON.stringify(meta))
367
405
  const inputs: TarInput[] = [
@@ -378,6 +416,36 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
378
416
  return { inputs, size: tarSize([...inputs, sumInput('0'.repeat(SUM_SIZE))]) }
379
417
  }
380
418
 
419
+ /**
420
+ * The modes `MetaFile.dirModes` keeps: of each directory on the way to an
421
+ * output name below its namespace, and of each empty output directory, the
422
+ * ones that withhold a permission 0755 grants. A wider mode is left to the
423
+ * restoring umask, as a plain mkdir's is, so most artifacts carry none.
424
+ */
425
+ function narrowDirModes(
426
+ names: readonly string[],
427
+ emptyDirs: readonly string[],
428
+ abs: (name: string) => string,
429
+ ): Record<string, number> | undefined {
430
+ const seen = new Set<string>()
431
+ let out: Record<string, number> | undefined
432
+ const visit = (dir: string): void => {
433
+ if (seen.has(dir)) return
434
+ seen.add(dir)
435
+ const st = statSync(abs(dir), { throwIfNoEntry: false })
436
+ if (st?.isDirectory() !== true || (st.mode & 0o755) === 0o755) return
437
+ ;(out ??= {})[dir] = st.mode & 0o777
438
+ }
439
+ for (const d of emptyDirs) visit(d)
440
+ for (const name of names) {
441
+ const top = name.startsWith('outputs/') ? 'outputs/'.length : WORKSPACE_PREFIX.length
442
+ for (let i = name.lastIndexOf('/'); i > top; i = name.lastIndexOf('/', i - 1)) {
443
+ visit(name.slice(0, i))
444
+ }
445
+ }
446
+ return out
447
+ }
448
+
381
449
  /** The tar as a stream: each output is read from disk as it is written. */
382
450
  export function packArtifactStream(plan: ArtifactPlan): ReadableStream<Uint8Array> {
383
451
  return streamOf(tarPack(summed(plan.inputs)))
@@ -503,7 +571,10 @@ export async function writeTree(plan: ArtifactPlan, dir: string): Promise<Artifa
503
571
  plan.inputs.map(async (i) => {
504
572
  if (!(i.body instanceof Blob)) return
505
573
  const to = path.join(dir, i.name)
506
- await Bun.write(to, i.body)
574
+ // The tar form's refusal (`tarPackInto`): an output that grew or shrank
575
+ // since the plan's stat is not the file its row describes.
576
+ const n = await Bun.write(to, i.body)
577
+ if (n !== i.size) throw new TarFormatError(`${i.name}: ${n} bytes, ${i.size} declared`)
507
578
  const at = new Date(times[i.name]![1])
508
579
  await utimes(to, at, at)
509
580
  }),
@@ -526,6 +597,7 @@ export async function sealTree(dir: string, scan: ArtifactScan): Promise<void> {
526
597
  }
527
598
  if (scan.dirs.length > 0) meta.dirs = scan.dirs
528
599
  if (scan.links.length > 0) meta.links = Object.fromEntries(scan.links)
600
+ if (scan.dirModes.length > 0) meta.dirModes = Object.fromEntries(scan.dirModes)
529
601
  if (scan.exec !== undefined) meta.exec = scan.exec
530
602
  await writeFile(path.join(dir, META_ENTRY), JSON.stringify(meta))
531
603
  }
@@ -537,10 +609,14 @@ export function planOfTree(dir: string, stdout: string): ArtifactPlan {
537
609
  const out = new TextEncoder().encode(stdout)
538
610
  const inputs: TarInput[] = [{ name: STDOUT_ENTRY, size: out.byteLength, body: out }]
539
611
  for (const [name, [mode, mtimeMs]] of Object.entries(meta.files)) {
612
+ // Bun.file reads through a link: only a regular file is packed as one.
613
+ const st = lstatSync(path.join(dir, name))
614
+ if (!st.isFile())
615
+ throw new ArchiveSecurityError(`kept tree entry ${name} is not a regular file`)
540
616
  const file = Bun.file(path.join(dir, name))
541
617
  inputs.push({
542
618
  name,
543
- size: statSync(path.join(dir, name)).size,
619
+ size: st.size,
544
620
  mode,
545
621
  mtime: Math.min(USTAR_MTIME_MAX, Math.max(0, Math.floor(mtimeMs / 1000))),
546
622
  body: file,
@@ -557,6 +633,8 @@ interface ArtifactScan {
557
633
  dirs: string[]
558
634
  /** The sidecar's symlinks, `[name, target]`, each name and target proven safe. */
559
635
  links: Array<[name: string, target: string]>
636
+ /** The sidecar's directory modes, each a directory the artifact holds something in or is. */
637
+ dirModes: Array<[name: string, mode: number]>
560
638
  stdout: string | null
561
639
  exec: ExecUsage | undefined
562
640
  key: string | undefined
@@ -568,15 +646,17 @@ class ScanFold {
568
646
  private files: MetaFile['files'] = {}
569
647
  private dirs: string[] = []
570
648
  private links: Array<[string, string]> = []
649
+ private dirModes: Array<[string, number]> = []
571
650
  private exec: ExecUsage | undefined
572
651
  private key: string | undefined
573
652
  stdout: string | null = null
574
653
 
575
654
  sidecar(text: string): void {
576
655
  const parsed = JSON.parse(text) as MetaFile
577
- this.files = parsed.files ?? {}
656
+ this.files = filesOf(parsed)
578
657
  this.dirs = dirsOf(parsed)
579
658
  this.links = linksOf(parsed)
659
+ this.dirModes = dirModesOf(parsed)
580
660
  // The ingest side is the untrusted boundary: a foreign sidecar's
581
661
  // usage is taken only when it is a plain non-negative number.
582
662
  this.exec = usageOf(parsed.exec)
@@ -588,6 +668,26 @@ class ScanFold {
588
668
  }
589
669
 
590
670
  result(): ArtifactScan {
671
+ // A mode names a directory the artifact holds, never one a restore
672
+ // would otherwise not make: the row it becomes is a directory a hit
673
+ // makes and asks to stand.
674
+ if (this.dirModes.length > 0) {
675
+ const held = new Set(this.dirs)
676
+ const hold = (name: string): void => {
677
+ for (let i = name.indexOf('/'); i !== -1; i = name.indexOf('/', i + 1))
678
+ held.add(name.slice(0, i))
679
+ }
680
+ for (const s of this.seen) hold(s.name)
681
+ for (const d of this.dirs) hold(d)
682
+ for (const [name] of this.links) hold(name)
683
+ for (const [name] of this.dirModes) {
684
+ if (!held.has(name)) {
685
+ throw new ArchiveSecurityError(
686
+ `archive directory mode names no output directory: ${name}`,
687
+ )
688
+ }
689
+ }
690
+ }
591
691
  return {
592
692
  entries: this.seen.map((s) => {
593
693
  const m = this.files[s.name]
@@ -598,6 +698,7 @@ class ScanFold {
598
698
  }),
599
699
  dirs: this.dirs,
600
700
  links: this.links,
701
+ dirModes: this.dirModes,
601
702
  stdout: this.stdout,
602
703
  exec: this.exec,
603
704
  key: this.key,
@@ -663,6 +764,37 @@ export function scanTarBytes(tar: Uint8Array): ArtifactScan {
663
764
  return fold.result()
664
765
  }
665
766
 
767
+ /** The largest millisecond count a Date holds (ECMA-262 §21.4.1.1). */
768
+ const DATE_MAX_MS = 8.64e15
769
+
770
+ /**
771
+ * The sidecar's stats, each an integer mode in 0..0o7777 and an integer
772
+ * mtime a Date holds: one taken as is reached the index, and its restore
773
+ * threw a TypeError out of `utimesSync` after renames had begun.
774
+ */
775
+ function filesOf(meta: MetaFile): MetaFile['files'] {
776
+ const raw: unknown = meta.files
777
+ if (raw === undefined || raw === null) return {}
778
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
779
+ throw new ArchiveSecurityError('archive sidecar files is not a map')
780
+ }
781
+ for (const [name, pair] of Object.entries(raw as Record<string, unknown>)) {
782
+ if (
783
+ !Array.isArray(pair) ||
784
+ !Number.isInteger(pair[0]) ||
785
+ pair[0] < 0 ||
786
+ pair[0] > 0o7777 ||
787
+ !Number.isInteger(pair[1]) ||
788
+ Math.abs(pair[1]) > DATE_MAX_MS
789
+ ) {
790
+ throw new ArchiveSecurityError(
791
+ `archive sidecar has a bad stat for ${name}: ${JSON.stringify(pair)}`,
792
+ )
793
+ }
794
+ }
795
+ return raw as MetaFile['files']
796
+ }
797
+
666
798
  /**
667
799
  * The sidecar's directory names, each held to the rules a file entry's
668
800
  * name is (the sidecar is as attacker-reachable as the tar), and only
@@ -707,6 +839,73 @@ function linksOf(meta: MetaFile): Array<[string, string]> {
707
839
  })
708
840
  }
709
841
 
842
+ /**
843
+ * The first of `links` (absolute paths) that `to` passes through, walked
844
+ * from `dir` a segment at a time. Each link's target is checked as text,
845
+ * but the kernel follows one link while it resolves the next: `up -> ..`
846
+ * and `esc -> up/../../x` each read as inside while `esc` lands outside.
847
+ */
848
+ function throughLink(
849
+ dir: string,
850
+ to: string,
851
+ links: ReadonlyMap<string, string>,
852
+ ): string | undefined {
853
+ let at = dir
854
+ for (const seg of to.split('/')) {
855
+ if (seg === '' || seg === '.') continue
856
+ at = seg === '..' ? path.dirname(at) : path.join(at, seg)
857
+ if (links.has(at)) return at
858
+ }
859
+ return undefined
860
+ }
861
+
862
+ /**
863
+ * The sidecar's directory modes, each name held to a file entry's rules and
864
+ * each mode to permission bits.
865
+ */
866
+ function dirModesOf(meta: MetaFile): Array<[string, number]> {
867
+ const raw: unknown = meta.dirModes
868
+ if (raw === undefined) return []
869
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
870
+ throw new ArchiveSecurityError('archive sidecar dirModes is not a map')
871
+ }
872
+ return Object.entries(raw as Record<string, unknown>).map(([name, mode]) => {
873
+ if (!(name.startsWith('outputs/') || name.startsWith(WORKSPACE_PREFIX))) {
874
+ throw new ArchiveSecurityError(`archive directory is not an output: ${name}`)
875
+ }
876
+ assertSafeName(name)
877
+ if (typeof mode !== 'number' || !Number.isInteger(mode) || mode < 0 || mode > 0o777) {
878
+ throw new ArchiveSecurityError(
879
+ `archive directory ${name} has an unsafe mode: ${String(mode)}`,
880
+ )
881
+ }
882
+ return [name, mode]
883
+ })
884
+ }
885
+
886
+ /**
887
+ * A file this user cannot read, or one gone since its stat, is not the
888
+ * same bytes: the rename replaces it. Any other failure is the restore's:
889
+ * an EMFILE read as "differs" sent the restore on to stage, and its error
890
+ * named the staging file.
891
+ */
892
+ function sameBytes(file: string, body: Uint8Array): boolean {
893
+ try {
894
+ return readFileSync(file).equals(body)
895
+ } catch (err) {
896
+ const code = (err as NodeJS.ErrnoException).code
897
+ if (code === 'EACCES' || code === 'EPERM' || code === 'ENOENT') return false
898
+ throw err
899
+ }
900
+ }
901
+
902
+ /** The deepest directory holding both `a` and `b`. */
903
+ function commonDir(a: string, b: string): string {
904
+ let dir = a
905
+ while (!inside(b, dir) && path.dirname(dir) !== dir) dir = path.dirname(dir)
906
+ return dir
907
+ }
908
+
710
909
  /** `p` is `root` or under it, lexically. */
711
910
  const inside = (p: string, root: string): boolean => {
712
911
  const r = path.resolve(root)
@@ -757,6 +956,7 @@ export async function extractArtifactStream(
757
956
  let meta: MetaFile['files'] = {}
758
957
  let dirs: string[] = []
759
958
  let links: Array<[string, string]> = []
959
+ let dirModes: Array<[string, number]> = []
760
960
  const fold = onScan === undefined ? undefined : new ScanFold()
761
961
  try {
762
962
  const check = new SumCheck()
@@ -768,9 +968,10 @@ export async function extractArtifactStream(
768
968
  const text = await textOf(body)
769
969
  fold?.sidecar(text)
770
970
  const parsed = JSON.parse(text) as MetaFile
771
- meta = parsed.files ?? {}
971
+ meta = filesOf(parsed)
772
972
  dirs = dirsOf(parsed)
773
973
  links = linksOf(parsed)
974
+ dirModes = dirModesOf(parsed)
774
975
  continue
775
976
  }
776
977
  assertSafeName(e.name)
@@ -811,8 +1012,23 @@ export async function extractArtifactStream(
811
1012
  provided.add(`${name}/`)
812
1013
  }
813
1014
  // A link may point only inside the workspace (the project, without one).
814
- const bound = stageRoot ?? workspaceDest ?? destDir
1015
+ // Unpacked into a cache tree (no stage root), `outputs/` and
1016
+ // `workspace-outputs/` are siblings: bounded by the latter alone, a
1017
+ // `dist/link -> big.bin` pointed "out" and the ingest failed. The tree
1018
+ // bounds by their parent; its restore checks against the workspace.
1019
+ const bound =
1020
+ stageRoot ?? (workspaceDest === undefined ? destDir : commonDir(destDir, workspaceDest))
1021
+ const linked = new Map<string, string>()
1022
+ for (const [name] of links) {
1023
+ const dest = x.destFor(name)
1024
+ if (dest !== null && dest.rel.length > 0) linked.set(path.join(dest.base, dest.rel), name)
1025
+ }
815
1026
  for (const [name, to] of links) {
1027
+ // Renamed over an entry just written, the link would stand where the
1028
+ // sidecar says a file is, and a re-pack of the tree reads through it.
1029
+ if (provided.has(name) || [...provided].some((p) => p.startsWith(`${name}/`))) {
1030
+ throw new ArchiveSecurityError(`archive link ${name} is also a file or directory entry`)
1031
+ }
816
1032
  if (accept !== undefined && !accept(name)) {
817
1033
  throw new ArchiveSecurityError(
818
1034
  `archive link is not one of the task's declared outputs: ${name}`,
@@ -822,12 +1038,30 @@ export async function extractArtifactStream(
822
1038
  if (dest === null || dest.rel.length === 0) continue
823
1039
  const target = path.join(dest.base, dest.rel)
824
1040
  await x.assertContained(dest.base, target, name)
825
- if (!inside(path.resolve(path.dirname(target), to), bound)) {
1041
+ const at = path.resolve(path.dirname(target), to)
1042
+ if (!inside(at, bound)) {
826
1043
  throw new ArchiveSecurityError(`archive link ${name} points out of the workspace: ${to}`)
827
1044
  }
1045
+ const via = throughLink(path.dirname(target), to, linked)
1046
+ if (via !== undefined) {
1047
+ throw new ArchiveSecurityError(
1048
+ `archive link ${name} points through ${linked.get(via)}, another link it restores: ${to}`,
1049
+ )
1050
+ }
1051
+ // Lexical: a write through a link is judged where it resolves, so an
1052
+ // alias chain into .git gets no further than this link would.
1053
+ if (!writableUnder(at, path.resolve(bound))) {
1054
+ throw new ArchiveSecurityError(`archive link ${name} points into .git: ${to}`)
1055
+ }
828
1056
  await x.stageLink(name, dest.base, target, to)
829
1057
  provided.add(name)
830
1058
  }
1059
+ // A moded directory is one the entries above make; its row, like an
1060
+ // empty directory's, names it with a trailing slash. The cache applies
1061
+ // the mode (`Cache.restoreOutputs`), to the directories a hit makes.
1062
+ for (const [name] of dirModes) {
1063
+ if (x.destFor(name) !== null) provided.add(`${name}/`)
1064
+ }
831
1065
  verify?.(provided)
832
1066
  // The sidecar's stat is the truth, a mode of 000 and an mtime at or
833
1067
  // before the epoch included (`SOURCE_DATE_EPOCH=0`). Without one, a
@@ -887,7 +1121,8 @@ function keep(s: Staged, ino: number, mode: number, mtimeMs: number | undefined)
887
1121
  const fd = openSync(s.target, constants.O_RDONLY | constants.O_NOFOLLOW)
888
1122
  try {
889
1123
  const st = fstatSync(fd)
890
- if (st.ino !== ino || st.nlink !== 1) {
1124
+ // An in-place write since the compare keeps the inode and bumps the ctime.
1125
+ if (st.ino !== ino || st.nlink !== 1 || st.ctimeMs !== s.sameCtimeMs) {
891
1126
  throw new Error(`${s.target} changed while it was restored`)
892
1127
  }
893
1128
  if ((st.mode & 0o7777) !== restoredMode(mode)) fchmodSync(fd, restoredMode(mode))
@@ -915,6 +1150,8 @@ interface Staged {
915
1150
  base: string
916
1151
  /** The target already holds the entry's bytes: nothing was written, and only its mode and mtime may change. */
917
1152
  same?: number
1153
+ /** The kept target's ctime when its bytes were compared. */
1154
+ sameCtimeMs?: number
918
1155
  /** `tmp` was copied beside `target`, which is on another filesystem than the staging (`crossOver`). */
919
1156
  beside?: true
920
1157
  /** A symlink to this target, made at `tmp`: no mode, no mtime. */
@@ -938,6 +1175,8 @@ interface Tree {
938
1175
  */
939
1176
  class Extractor {
940
1177
  private readonly staged: Staged[] = []
1178
+ /** Directories given owner write for a rename into them, with the mode to put back. */
1179
+ private readonly reopened = new Map<string, number>()
941
1180
  /** Empty output directories made, with the top of each chain created. */
942
1181
  private readonly madeDirs: Array<{ dir: string; created: string }> = []
943
1182
  private inflight: Promise<unknown>[] = []
@@ -1061,7 +1300,7 @@ class Extractor {
1061
1300
  )
1062
1301
  if (real !== null) {
1063
1302
  const realBase = await this.realBaseOf(base)
1064
- if (real !== realBase && !real.startsWith(realBase + path.sep)) {
1303
+ if (!writableUnder(real, realBase)) {
1065
1304
  throw await linkOutError(baseResolved, probe, realBase)
1066
1305
  }
1067
1306
  return
@@ -1090,7 +1329,7 @@ class Extractor {
1090
1329
  if (err.code !== 'EEXIST' && err.code !== 'ENOENT' && err.code !== 'ELOOP') throw err
1091
1330
  const real = await resolveThrough(dir)
1092
1331
  const realBase = await this.realBaseOf(base)
1093
- if (real !== realBase && !real.startsWith(realBase + path.sep)) {
1332
+ if (!writableUnder(real, realBase)) {
1094
1333
  throw await linkOutError(path.resolve(base), dir, realBase)
1095
1334
  }
1096
1335
  // What stands there without a link on the way (a file named like the
@@ -1219,14 +1458,21 @@ class Extractor {
1219
1458
  // file costs ~0.4 ms on ext4 (auto_da_alloc), and a hit that changed
1220
1459
  // one of 2,000 outputs paid it 2,000 times. Bytes, not a stat stamp: a
1221
1460
  // reproducible build gives every output one mtime (item 886).
1461
+ const st = created === undefined ? lstatSync(target, { throwIfNoEntry: false }) : undefined
1462
+ // A directory there fails its rename at commit, after the renames
1463
+ // before it landed: refused now, no output is replaced.
1464
+ if (st?.isDirectory() === true)
1465
+ throw Object.assign(
1466
+ new Error(`EISDIR: illegal operation on a directory, rename '${target}'`),
1467
+ { code: 'EISDIR', path: target },
1468
+ )
1222
1469
  if (body instanceof Uint8Array && created === undefined) {
1223
- const st = lstatSync(target, { throwIfNoEntry: false })
1224
1470
  if (
1225
1471
  st !== undefined &&
1226
1472
  st.isFile() &&
1227
1473
  st.nlink === 1 &&
1228
1474
  st.size === body.byteLength &&
1229
- readFileSync(target).equals(body)
1475
+ sameBytes(target, body)
1230
1476
  ) {
1231
1477
  this.staged.push({
1232
1478
  staged: false,
@@ -1237,6 +1483,7 @@ class Extractor {
1237
1483
  streamed: false,
1238
1484
  base,
1239
1485
  same: st.ino,
1486
+ sameCtimeMs: st.ctimeMs,
1240
1487
  })
1241
1488
  return
1242
1489
  }
@@ -1260,7 +1507,7 @@ class Extractor {
1260
1507
  // fail-closed outcome the plain write had.
1261
1508
  // Staged under `.vx/tmp/`, so no output folder ever holds a temp: a
1262
1509
  // reader or a key taken meanwhile saw `.vx-tmp-*` files beside the
1263
- // outputs, and a killed restore left them there (M, 2026-10-16).
1510
+ // outputs, and a killed restore left them there (2026-10-16).
1264
1511
  const staged = this.stageRoot !== undefined
1265
1512
  const tmp = staged
1266
1513
  ? path.join(await stagingDir(this.stageRoot!), (tmpSeq++).toString(36))
@@ -1360,6 +1607,16 @@ class Extractor {
1360
1607
  */
1361
1608
  async commit(
1362
1609
  metaFor: (name: string) => [mode: number, mtimeMs: number | undefined],
1610
+ ): Promise<void> {
1611
+ try {
1612
+ await this.commitAll(metaFor)
1613
+ } finally {
1614
+ this.closeReopened()
1615
+ }
1616
+ }
1617
+
1618
+ private async commitAll(
1619
+ metaFor: (name: string) => [mode: number, mtimeMs: number | undefined],
1363
1620
  ): Promise<void> {
1364
1621
  await this.drain()
1365
1622
  // One stat per directory, shared by the filesystem check and the group.
@@ -1472,11 +1729,49 @@ class Extractor {
1472
1729
  const dir = path.dirname(s.target)
1473
1730
  await this.makeDirIn(t.base, dir)
1474
1731
  await this.assertStillContained(t.base, dir, checked)
1475
- await rename(s.tmp, s.target)
1732
+ await this.renameInto(s.tmp, s.target)
1476
1733
  }
1477
1734
  await rm(t.at, { recursive: true, force: true })
1478
1735
  }
1479
1736
 
1737
+ /**
1738
+ * `rename`, into a directory of this user's that withholds its own write
1739
+ * (a task's `chmod 555 dist/ro`, which a hit now restores): the
1740
+ * directory gets owner write and search for the rest of the commit, and
1741
+ * its mode back after. Without it every later hit that changed a file
1742
+ * there failed EACCES, as only root could replace it.
1743
+ */
1744
+ private async renameInto(from: string, to: string): Promise<void> {
1745
+ try {
1746
+ await rename(from, to)
1747
+ } catch (err) {
1748
+ if ((err as NodeJS.ErrnoException).code !== 'EACCES' || !this.reopen(path.dirname(to)))
1749
+ throw err
1750
+ await rename(from, to)
1751
+ }
1752
+ }
1753
+
1754
+ private reopen(dir: string): boolean {
1755
+ if (this.reopened.has(dir)) return false
1756
+ const st = lstatSync(dir, { throwIfNoEntry: false })
1757
+ if (st?.isDirectory() !== true || st.uid !== process.getuid?.()) return false
1758
+ if ((st.mode & 0o300) === 0o300) return false
1759
+ this.reopened.set(dir, st.mode & 0o7777)
1760
+ chmodSync(dir, (st.mode & 0o7777) | 0o300)
1761
+ return true
1762
+ }
1763
+
1764
+ private closeReopened(): void {
1765
+ for (const [dir, mode] of this.reopened) {
1766
+ try {
1767
+ chmodSync(dir, mode)
1768
+ } catch {
1769
+ // gone with an abort's cleanup
1770
+ }
1771
+ }
1772
+ this.reopened.clear()
1773
+ }
1774
+
1480
1775
  private async makeDirIn(base: string, dir: string): Promise<void> {
1481
1776
  try {
1482
1777
  await mkdir(dir, { recursive: true })
@@ -1529,7 +1824,7 @@ class Extractor {
1529
1824
  */
1530
1825
  private async place(s: Staged, mode: number, t: Date | undefined): Promise<void> {
1531
1826
  try {
1532
- await rename(s.tmp, s.target)
1827
+ await this.renameInto(s.tmp, s.target)
1533
1828
  } catch (err) {
1534
1829
  if (!s.staged || (err as NodeJS.ErrnoException).code !== 'EXDEV') throw err
1535
1830
  const side = siblingTemp(s.target)
@@ -1540,7 +1835,7 @@ class Extractor {
1540
1835
  chmodSync(side, restoredMode(mode))
1541
1836
  if (t !== undefined) utimesSync(side, t, t)
1542
1837
  }
1543
- await rename(side, s.target)
1838
+ await this.renameInto(side, s.target)
1544
1839
  } catch (e) {
1545
1840
  await unlink(side).catch(() => undefined)
1546
1841
  throw e
@@ -1564,7 +1859,7 @@ class Extractor {
1564
1859
  checked.add(dir)
1565
1860
  const realBase = await this.realBaseOf(base)
1566
1861
  const real = await realOf(dir, true)
1567
- if (real !== realBase && !real.startsWith(realBase + path.sep)) {
1862
+ if (!writableUnder(real, realBase)) {
1568
1863
  throw await linkOutError(path.resolve(base), dir, realBase)
1569
1864
  }
1570
1865
  }
@@ -1590,7 +1885,7 @@ class Extractor {
1590
1885
  for (const [dir, { base, placed }] of landed) {
1591
1886
  const realBase = await this.realBaseOf(base)
1592
1887
  const real = await realOf(dir, true).catch(() => null)
1593
- if (real === null || real === realBase || real.startsWith(realBase + path.sep)) continue
1888
+ if (real === null || writableUnder(real, realBase)) continue
1594
1889
  await Promise.allSettled(placed.map((p) => rm(p, { recursive: true, force: true })))
1595
1890
  throw await linkOutError(path.resolve(base), dir, realBase)
1596
1891
  }
@@ -1681,7 +1976,7 @@ async function settle(renames: readonly Promise<void>[]): Promise<void> {
1681
1976
  let tmpSeq = 0
1682
1977
 
1683
1978
  /** `.vx/<STAGING>/`: where restores stage, each process in its own `<pid>-0` directory. */
1684
- export const STAGING = 'tmp'
1979
+ const STAGING = 'tmp'
1685
1980
 
1686
1981
  const stagingMade = new Set<string>()
1687
1982
 
@@ -1698,7 +1993,7 @@ async function stagingDir(root: string): Promise<string> {
1698
1993
  await mkdir(own, { recursive: true })
1699
1994
  // Ignored by git whatever `.vx/.gitignore` an older init committed.
1700
1995
  try {
1701
- writeFileSync(path.join(top, '.gitignore'), '*\n', { flag: 'wx' })
1996
+ ignoreDir(top)
1702
1997
  } catch {
1703
1998
  // there already, or the restore fails on its first write anyway
1704
1999
  }
@@ -1735,7 +2030,13 @@ async function linkOutError(base: string, probe: string, realBase: string): Prom
1735
2030
  for (const part of path.relative(base, probe).split(path.sep)) {
1736
2031
  dir = path.join(dir, part)
1737
2032
  real = await resolveThrough(dir)
1738
- if (real !== realBase && !real.startsWith(realBase + path.sep)) break
2033
+ if (!writableUnder(real, realBase)) break
2034
+ }
2035
+ if (real.startsWith(realBase + path.sep)) {
2036
+ return new UserError(
2037
+ `${dir} is a symbolic link to ${real}, Git's metadata — a cache restore never writes ` +
2038
+ 'into a .git directory. Remove the link and re-run, or stop declaring outputs under it.',
2039
+ )
1739
2040
  }
1740
2041
  return new UserError(
1741
2042
  `${dir} is a symbolic link to ${real}, outside ${base} — a cache restore never writes ` +