@edgehero/pi-dispatch 4.3.1 → 5.1.0

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 (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +118 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +136 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
package/src/import-pi.mjs CHANGED
@@ -29,7 +29,8 @@ import { execFile } from "node:child_process";
29
29
  import { randomBytes } from "node:crypto";
30
30
  import { join, resolve } from "node:path";
31
31
  import { promisify } from "node:util";
32
- import { FROM_HOST, PACKAGES_SUBDIR, RESOURCE_DIRS, STAGE_MANIFEST, mergeHostPackages, parsePackagesFile } from "./packages.mjs";
32
+ import { FROM_HOST, PACKAGES_SUBDIR, mergeHostPackages, parsePackagesFile } from "./packages.mjs";
33
+ import { newStagingDir, preparePackage, pruneStagedTrees, stagedTreeName, withStageLock, writeStageManifest } from "./package-stage.mjs";
33
34
  import { agentDirFrom, readHostPi } from "./host-pi.mjs";
34
35
  import { ENTRY_NAME_RE, copyDirContents, copySkillTree } from "./copy-tree.mjs";
35
36
  import { MCP_CONFIG_FILE_NAME, MCP_CONFIG_MAX_BYTES, mcpServerWhere, overlayMcpText, parseOverlayMcp } from "./mcp-config.mjs";
@@ -489,8 +490,6 @@ async function stagePackages({ fs, exec, out, packagesFile, to, platform = proce
489
490
  return { error: error.message };
490
491
  }
491
492
  }
492
- const { entries, overrides, dropped } = mergeHostPackages(declared, discovered);
493
-
494
493
  const packagesRoot = join(to, PACKAGES_SUBDIR);
495
494
  const rootExisted = fs.existsSync(packagesRoot);
496
495
  fs.mkdirSync(packagesRoot, { recursive: true });
@@ -498,183 +497,72 @@ async function stagePackages({ fs, exec, out, packagesFile, to, platform = proce
498
497
  const npmBin = platform === "win32" ? "npm.cmd" : "npm";
499
498
  const stagingDirs = [];
500
499
  const prepared = [];
501
- const renamed = [];
500
+ const moved = [];
502
501
  const warnings = [];
503
502
  const softDropped = [];
504
503
 
505
- try {
506
- for (const [index, entry] of entries.entries()) {
507
- const staging = join(packagesRoot, `.staging-${index}`);
508
- fs.rmSync(staging, { recursive: true, force: true }); // a crashed earlier run may have left one
509
- stagingDirs.push(staging);
510
- let source;
511
- try {
512
- source = await prepareOne({ fs, exec, out, entry, staging, npmBin, platform, warnings });
513
- } catch (error) {
514
- // The declared/discovered split: an entry the operator pinned is fatal, one we inferred from
515
- // their pi setup is dropped by name so the rest of the stage still lands.
516
- if (entry.from === FROM_HOST) {
517
- softDropped.push({ name: entry.name, reason: error.message });
518
- continue;
504
+ // One stage at a time on this overlay, also from another host on a shared home. Staging names are unique and nothing already
505
+ // staged is touched, so a failure below cannot damage what jobs are loading: the manifest still names the old trees.
506
+ let overrides = [], dropped = [];
507
+ const staged = await withStageLock({ fs, packagesRoot, holder: "import-pi" }, async ({ stillHeld }) => {
508
+ try {
509
+ // Read again under the lock: a person's Adopt on this overlay may have just pinned a version in this file, and staging what the file said a moment ago would undo it.
510
+ const current = haveFile ? parsePackagesFile(fs.readFileSync(packagesFile, "utf8"), packagesFile) : declared;
511
+ const merged = mergeHostPackages(current, discovered);
512
+ const entries = merged.entries;
513
+ ({ overrides, dropped } = merged);
514
+ for (const entry of entries) {
515
+ const staging = newStagingDir(packagesRoot);
516
+ stagingDirs.push(staging);
517
+ let source;
518
+ try {
519
+ source = await preparePackage({ fs, exec, out, entry, staging, npmBin, platform, warnings });
520
+ } catch (error) {
521
+ // The declared/discovered split: an entry the operator pinned is fatal, one we inferred from
522
+ // their pi setup is dropped by name so the rest of the stage still lands.
523
+ if (entry.from === FROM_HOST) {
524
+ softDropped.push({ name: entry.name, reason: error.message });
525
+ continue;
526
+ }
527
+ throw error;
519
528
  }
520
- throw error;
529
+ prepared.push({ entry, source });
521
530
  }
522
- prepared.push({ entry, source });
523
- }
524
-
525
- // Renames happen only after EVERY package has passed, so a failure on the last one cannot leave the
526
- // earlier ones swapped in beside a stale manifest.
527
- for (const { entry, source } of prepared) {
528
- const dest = join(packagesRoot, entry.dir);
529
- fs.rmSync(dest, { recursive: true, force: true }); // replace a previous stage of the same package
530
- // renameSync, never copyTree: copyTree's symlink guard uses statSync, which FOLLOWS links, so it
531
- // would copy the target of every node_modules/.bin symlink instead of skipping it.
532
- fs.renameSync(source, dest);
533
- renamed.push(dest);
534
- }
535
- } catch (error) {
536
- for (const dest of renamed) fs.rmSync(dest, { recursive: true, force: true });
537
- for (const staging of stagingDirs) fs.rmSync(staging, { recursive: true, force: true });
538
- if (!rootExisted) fs.rmSync(packagesRoot, { recursive: true, force: true });
539
- return { error: `${error.message}\n Nothing was staged -- fix ${packagesFile} (or the package) and re-run with --with-packages.` };
540
- }
541
531
 
542
- for (const staging of stagingDirs) fs.rmSync(staging, { recursive: true, force: true });
543
-
544
- // A dropped entry never made it into `prepared`, so it must not appear in the receipt either.
545
- const staged = entries.filter((entry) => prepared.some((p) => p.entry === entry));
546
- // `from` and nothing more. This receipt is bind-mounted into every job container, so it must never carry
547
- // an install path off the operator's machine -- provenance is the fact doctor needs, the host path is not.
548
- const stageManifest = { stagedAt: new Date().toISOString(), packages: staged.map(({ name, version, dir, from }) => ({ name, version, dir, from })) };
549
- fs.writeFileSync(join(packagesRoot, STAGE_MANIFEST), `${JSON.stringify(stageManifest, null, 2)}\n`);
550
- return { packages: staged, warnings, overrides, dropped: [...dropped, ...softDropped] };
551
- }
552
-
553
- /**
554
- * Install and ASSERT one package inside its private staging dir, returning the path to assert-clean source.
555
- * Throws on any failure; the caller decides whether that is fatal (a declared pin) or a drop (a discovered
556
- * one). Every assertion here is about one property: the staged dir must be SELF-CONTAINED, because at job
557
- * time it is resolved from a read-only mount with no network and no install step.
558
- */
559
- async function prepareOne({ fs, exec, out, entry, staging, npmBin, platform, warnings }) {
560
- fs.mkdirSync(staging, { recursive: true });
561
- // A private root package.json pins npm's idea of "the project" to the staging dir, so it cannot
562
- // walk up and install into (or read config from) the operator's own checkout.
563
- fs.writeFileSync(join(staging, "package.json"), `${JSON.stringify({ name: "pi-dispatch-staging", private: true }, null, 2)}\n`);
564
-
565
- // ARRAY argv, never a shell string: the name and version come from a config file and must never be
566
- // able to become shell syntax on the operator's host. The install target is the exec's `cwd`, NOT a
567
- // `--prefix <staging>` pair -- npm installs into the cwd's node_modules by default, and dropping the
568
- // flag removes the only filesystem PATH from argv. What is left is nothing but literal flags and one
569
- // `name@version` token already validated against NPM_NAME_RE + EXACT_VERSION_RE; that is the property
570
- // npmExecOptions relies on below.
571
- //
572
- // --ignore-scripts is load-bearing: without it the lifecycle scripts of this package AND of every
573
- // transitive dependency would run as the operator, on the operator's host, at stage time.
574
- // --omit=peer because pi aliases its own packages for extensions at load time, so a staged peer
575
- // copy is ignored dead weight -- and a floating pi version at that (CONST-PI-VERSION-PINNED).
576
- // --install-strategy=nested asks npm to keep every dependency inside the package dir; step 4 below
577
- // ASSERTS the result rather than trusting the flag, whose name and default have moved across npm
578
- // versions.
579
- const args = [
580
- "install",
581
- `${entry.name}@${entry.version}`,
582
- "--omit=dev",
583
- "--omit=peer",
584
- "--omit=optional",
585
- "--ignore-scripts",
586
- "--install-strategy=nested",
587
- "--no-audit",
588
- "--no-fund",
589
- "--loglevel=error",
590
- ];
591
- out(` staging ${entry.name}@${entry.version} -> packages/${entry.dir}\n`);
592
- try {
593
- await exec(npmBin, args, npmExecOptions(platform, staging));
594
- } catch (error) {
595
- const detail = String(error?.stderr ?? error?.message ?? "").trim();
596
- throw new Error(`npm install failed for ${entry.name}@${entry.version}: ${detail}`);
597
- }
598
-
599
- const source = join(staging, "node_modules", entry.name);
600
- let pkg;
601
- try {
602
- pkg = JSON.parse(fs.readFileSync(join(source, "package.json"), "utf8"));
603
- } catch {
604
- throw new Error(`${entry.name}@${entry.version}: npm reported success but there is no readable package.json at ${join(source, "package.json")}`);
605
- }
606
- if (pkg.version !== entry.version) {
607
- throw new Error(`${entry.name}: npm staged version ${JSON.stringify(pkg.version)}, not the pinned ${JSON.stringify(entry.version)} (CONST-PI-VERSION-PINNED)`);
608
- }
609
-
610
- // Dependency completeness -- catches hoisting whatever npm's flag defaults do this month. A
611
- // hoisted dependency would only surface as an import failure inside a job, hours later.
612
- for (const dep of Object.keys(pkg.dependencies ?? {})) {
613
- if (!fs.existsSync(join(source, "node_modules", dep))) {
614
- throw new Error(`${entry.name}: dependency "${dep}" is not inside the package dir -- npm hoisted it out, so the staged copy could not import it at run time (no network, no install)`);
615
- }
616
- }
617
-
618
- // A package that contributes no pi resources loads as a silent no-op; staging exists to turn that
619
- // run-time nothing into a stage-time error the operator can act on.
620
- const manifest = pkg.pi !== null && typeof pkg.pi === "object" ? pkg.pi : null;
621
- const hasResourceDir = RESOURCE_DIRS.some((name) => fs.existsSync(join(source, name)));
622
- if (!manifest && !hasResourceDir) {
623
- throw new Error(`${entry.name} is not a pi package -- no "pi" manifest in package.json and none of ${RESOURCE_DIRS.join("/")}; it would load as a silent no-op`);
624
- }
625
-
626
- // Containment: manifest entries are resolved relative to the package dir at job time, so one that
627
- // climbs out of it would reach the rest of the read-only overlay.
628
- const escaping = manifest && findEscapingEntry(manifest);
629
- if (escaping) {
630
- throw new Error(`${entry.name}: pi manifest entry ${JSON.stringify(escaping)} leaves the package dir (no ".." segment, no leading "/")`);
631
- }
632
-
633
- // Warn, do not refuse: --ignore-scripts means a build/postinstall step did NOT run and an optional
634
- // dependency was NOT fetched, so such a package is staged INCOMPLETE and may fail at run time.
635
- const scriptKeys = ["install", "preinstall", "postinstall"].filter((key) => typeof pkg.scripts?.[key] === "string");
636
- const hasOptional = Object.keys(pkg.optionalDependencies ?? {}).length > 0;
637
- if (scriptKeys.length > 0 || hasOptional) {
638
- const declares = [...scriptKeys.map((key) => `scripts.${key}`), ...(hasOptional ? ["optionalDependencies"] : [])].join(", ");
639
- warnings.push({ dir: entry.dir, reason: `${entry.name} declares ${declares} -- staged with --ignore-scripts, so it is INCOMPLETE and may fail at run time` });
640
- }
641
-
642
- return source;
643
- }
644
-
645
- /**
646
- * The exec options for one `npm install`: always `cwd: <staging>` (that IS the install target now that
647
- * `--prefix` is gone), plus `shell: true` on win32 and nowhere else.
648
- *
649
- * WHY shell:true is REQUIRED on win32: npm ships there as `npm.cmd`, and since Node 18.20.2 / 20.12.2
650
- * (CVE-2024-27980) spawning a `.cmd`/`.bat` WITHOUT a shell throws EINVAL outright. This package floors at
651
- * Node >=22.19, so every Node it can run on has that behaviour -- without this, `--with-packages` fails on
652
- * every Windows host with a misleading "spawn npm.cmd EINVAL" and the branch above is dead on arrival.
653
- *
654
- * WHY shell:true is SAFE HERE SPECIFICALLY, which it would NOT be in general: with `--prefix` replaced by
655
- * `cwd`, argv holds no filesystem path at all -- only literal flags this file spells out, plus the single
656
- * `name@version` token, and BOTH halves of that token were validated before anything was created (packages.mjs
657
- * rejects any name failing NPM_NAME_RE and any version failing EXACT_VERSION_RE, neither of which admits a
658
- * space, quote, or cmd metacharacter). So no operator-supplied string that could survive as shell syntax ever
659
- * reaches the command line. Re-introducing a path -- or loosening either regex -- breaks that argument, so
660
- * this option must be revisited together with them.
661
- */
662
- function npmExecOptions(platform, staging) {
663
- return platform === "win32" ? { cwd: staging, shell: true } : { cwd: staging };
664
- }
665
-
666
- /** The first string anywhere in a `pi` manifest that leaves the package dir, or null when all are contained. */
667
- function findEscapingEntry(value) {
668
- if (typeof value === "string") {
669
- return /^[\\/]/.test(value) || value.split(/[\\/]/).includes("..") ? value : null;
670
- }
671
- if (Array.isArray(value) || (value !== null && typeof value === "object")) {
672
- for (const child of Object.values(value)) {
673
- const hit = findEscapingEntry(child);
674
- if (hit) return hit;
532
+ // Moves happen only after EVERY package has passed. Each goes to a new directory, so a failure on the last one
533
+ // removes only what this run made, and what jobs are loading was never touched.
534
+ const trees = new Map();
535
+ for (const { entry, source } of prepared) {
536
+ const dir = stagedTreeName(entry.dir, entry.version);
537
+ // renameSync, never copyTree: copyTree's symlink guard uses statSync, which FOLLOWS links, so it
538
+ // would copy the target of every node_modules/.bin symlink instead of skipping it.
539
+ fs.renameSync(source, join(packagesRoot, dir));
540
+ moved.push(join(packagesRoot, dir));
541
+ trees.set(entry, dir);
542
+ }
543
+ // A dropped entry never made it into `prepared`, so it must not appear in the receipt either.
544
+ const landed = entries.filter((entry) => trees.has(entry)).map((entry) => ({ ...entry, dir: trees.get(entry) }));
545
+ // `from` and nothing more. This receipt is bind-mounted into every job container, so it must never carry
546
+ // an install path off the operator's machine -- provenance is the fact doctor needs, the host path is not.
547
+ const stageManifest = { stagedAt: new Date().toISOString(), packages: landed.map(({ name, version, dir, from }) => ({ name, version, dir, from })) };
548
+ if (!stillHeld()) throw new Error("another stage took over this overlay's lock while this one ran");
549
+ writeStageManifest({ fs, packagesRoot, manifest: stageManifest });
550
+ pruneStagedTrees({ fs, packagesRoot, manifest: stageManifest });
551
+ // A warning names its package's base directory, which only this stage knows the tree of.
552
+ for (const warning of warnings) warning.dir = [...trees].find(([entry]) => entry.dir === warning.dir)?.[1] ?? warning.dir;
553
+ return { landed };
554
+ } catch (error) {
555
+ for (const dest of moved) fs.rmSync(dest, { recursive: true, force: true });
556
+ return { failed: error };
557
+ } finally {
558
+ for (const staging of stagingDirs) fs.rmSync(staging, { recursive: true, force: true });
675
559
  }
560
+ }).catch((error) => ({ failed: error }));
561
+ if (staged.failed) {
562
+ if (!rootExisted && fs.readdirSync(packagesRoot).length === 0) fs.rmSync(packagesRoot, { recursive: true, force: true });
563
+ return { error: `${staged.failed.message}\n Nothing was staged -- fix ${packagesFile} (or the package) and re-run with --with-packages.` };
676
564
  }
677
- return null;
565
+ return { packages: staged.landed, warnings, overrides, dropped: [...dropped, ...softDropped] };
678
566
  }
679
567
 
680
568
  function flagValue(argv, flag) {