@edgehero/pi-dispatch 4.3.0 → 5.0.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 (257) hide show
  1. package/.env.example +94 -4
  2. package/README.md +20 -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 +117 -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 +128 -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 +199 -30
  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 +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1663 -198
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +103 -9
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-auth.mjs +4 -0
  80. package/src/forgejo-host.mjs +127 -31
  81. package/src/forgejo-prompt.mjs +5 -4
  82. package/src/forges.mjs +73 -15
  83. package/src/fork-job.mjs +58 -0
  84. package/src/fork-policy.mjs +23 -0
  85. package/src/fork-store.mjs +338 -0
  86. package/src/get-token.mjs +1 -1
  87. package/src/git-dirty.mjs +119 -14
  88. package/src/git-hardening.mjs +85 -0
  89. package/src/github-host.mjs +31 -6
  90. package/src/github-prompt.mjs +7 -16
  91. package/src/gitlab-host.mjs +42 -3
  92. package/src/gitlab-prompt.mjs +5 -4
  93. package/src/held-document.mjs +23 -0
  94. package/src/held-replies.mjs +325 -0
  95. package/src/held-settle.mjs +266 -0
  96. package/src/hook-runs.mjs +188 -0
  97. package/src/host-budget.mjs +8 -5
  98. package/src/host-clone.mjs +72 -0
  99. package/src/host-loads.mjs +557 -0
  100. package/src/host-registry.mjs +66 -6
  101. package/src/host-telemetry.mjs +92 -0
  102. package/src/http-body.mjs +78 -0
  103. package/src/image-preflight.mjs +156 -13
  104. package/src/import-pi.mjs +60 -172
  105. package/src/inbox-relay.mjs +482 -0
  106. package/src/inbox-settle.mjs +196 -0
  107. package/src/index.mjs +740 -129
  108. package/src/init.mjs +35 -5
  109. package/src/job-data-version.mjs +75 -0
  110. package/src/job-id.mjs +30 -15
  111. package/src/job-layout.mjs +22 -0
  112. package/src/job-size.mjs +16 -0
  113. package/src/live-jobs.mjs +14 -6
  114. package/src/live-probes.mjs +6 -2
  115. package/src/live-transcript.mjs +872 -0
  116. package/src/loaded-record.mjs +609 -0
  117. package/src/mcp-config.mjs +1 -1
  118. package/src/mcp-preflight.mjs +36 -5
  119. package/src/model-attempts.mjs +84 -0
  120. package/src/model-catalog.mjs +83 -0
  121. package/src/model-cooldowns.mjs +22 -0
  122. package/src/model-failover-protocol.mjs +11 -0
  123. package/src/model-failover.mjs +134 -0
  124. package/src/model-observation.mjs +50 -0
  125. package/src/model-proposal-protocol.mjs +21 -0
  126. package/src/model-proposals.mjs +75 -0
  127. package/src/model-ref.mjs +67 -10
  128. package/src/model-roles-file.mjs +24 -0
  129. package/src/model-roles.mjs +73 -0
  130. package/src/model-status.mjs +34 -0
  131. package/src/models-json.mjs +4 -1
  132. package/src/money.mjs +1 -1
  133. package/src/oauth-openai.mjs +121 -0
  134. package/src/older-state.mjs +249 -0
  135. package/src/on-failure.mjs +17 -13
  136. package/src/open-browser.mjs +31 -2
  137. package/src/opener-cli.mjs +135 -0
  138. package/src/opener-protocol.mjs +27 -0
  139. package/src/opener-terminal.mjs +128 -0
  140. package/src/openshell-cli.mjs +155 -0
  141. package/src/openshell-policy.mjs +163 -0
  142. package/src/outbox.mjs +14 -0
  143. package/src/output-cap.mjs +1 -0
  144. package/src/package-canary-record.mjs +26 -0
  145. package/src/package-canary.mjs +135 -0
  146. package/src/package-names.mjs +9 -0
  147. package/src/package-stage.mjs +418 -0
  148. package/src/packages.mjs +24 -16
  149. package/src/parked.mjs +2 -1
  150. package/src/pause-windows.mjs +136 -17
  151. package/src/podman-stack.mjs +40 -14
  152. package/src/portfolio-snapshot.mjs +26 -7
  153. package/src/prepare-github.mjs +41 -77
  154. package/src/prepare-local.mjs +32 -2
  155. package/src/prepare.mjs +45 -11
  156. package/src/private-store-boundary.mjs +70 -0
  157. package/src/processor.mjs +1554 -167
  158. package/src/projects.mjs +23 -0
  159. package/src/prompt-fence.mjs +18 -0
  160. package/src/provider-key.mjs +7 -0
  161. package/src/provider-steering.mjs +1 -0
  162. package/src/question-context.mjs +35 -0
  163. package/src/question-group-runtime.mjs +91 -0
  164. package/src/question-groups.mjs +171 -0
  165. package/src/queue.mjs +296 -55
  166. package/src/reason-catalog.mjs +448 -0
  167. package/src/receiver-status.mjs +230 -0
  168. package/src/record-reason.mjs +37 -0
  169. package/src/recovery-queue.mjs +566 -0
  170. package/src/redact-secrets.mjs +219 -0
  171. package/src/reserve-estimate.mjs +101 -0
  172. package/src/reserved-env.mjs +36 -1
  173. package/src/retention.mjs +67 -0
  174. package/src/run-budget-ledger.mjs +283 -0
  175. package/src/run-checkpoint.mjs +627 -0
  176. package/src/run-container.mjs +749 -33
  177. package/src/run-history.mjs +791 -45
  178. package/src/run-mirror.mjs +75 -7
  179. package/src/run-progress.mjs +751 -0
  180. package/src/runtime-observations.mjs +3 -1
  181. package/src/runtime-settings.mjs +272 -37
  182. package/src/sandbox-cli.mjs +78 -2
  183. package/src/sandbox-deployment.mjs +51 -0
  184. package/src/sandbox-gateway-protocol.mjs +378 -0
  185. package/src/sandbox-hosts.mjs +100 -0
  186. package/src/sandbox-inventory.mjs +83 -0
  187. package/src/sandbox-policy.mjs +225 -0
  188. package/src/sandbox-prepare-requests.mjs +145 -0
  189. package/src/sandbox-prepare.mjs +285 -0
  190. package/src/sandbox-refs.mjs +70 -0
  191. package/src/sandbox-session-monitor.mjs +4 -0
  192. package/src/sandbox-session-watch.mjs +124 -0
  193. package/src/sandbox-sessions.mjs +161 -0
  194. package/src/sandbox-store.mjs +63 -6
  195. package/src/sandbox-workspace-guard.mjs +39 -0
  196. package/src/sandbox.mjs +27 -200
  197. package/src/scheduler-stall-guard.mjs +14 -8
  198. package/src/schedules.mjs +19 -6
  199. package/src/secret-bundles.mjs +245 -0
  200. package/src/secret-profiles.mjs +103 -3
  201. package/src/secrets.mjs +19 -9
  202. package/src/service-env.mjs +38 -5
  203. package/src/service.mjs +638 -106
  204. package/src/session-key.mjs +60 -0
  205. package/src/session-store.mjs +168 -10
  206. package/src/setup-contract.mjs +40 -0
  207. package/src/setup-egress.mjs +113 -0
  208. package/src/setup-host-entry.mjs +33 -0
  209. package/src/setup-host.mjs +242 -0
  210. package/src/setup-lock.mjs +41 -0
  211. package/src/size-records.mjs +15 -3
  212. package/src/start.mjs +1353 -135
  213. package/src/status-comment.mjs +415 -0
  214. package/src/stderr-transcript.mjs +318 -0
  215. package/src/steer-ack.mjs +122 -0
  216. package/src/steer-cli.mjs +213 -0
  217. package/src/steer-framing.mjs +185 -0
  218. package/src/steer-inbox.mjs +693 -0
  219. package/src/store-version.mjs +139 -0
  220. package/src/subscription-config.mjs +56 -0
  221. package/src/subscription-limits.mjs +3 -0
  222. package/src/subscription-quota.mjs +259 -0
  223. package/src/subscription-runtime.mjs +123 -0
  224. package/src/subscription-status.mjs +42 -0
  225. package/src/subscription-wait.mjs +11 -0
  226. package/src/subscriptions.mjs +7 -1
  227. package/src/thinking-level.mjs +19 -0
  228. package/src/touch-outbox.mjs +61 -0
  229. package/src/touch-state.mjs +133 -0
  230. package/src/triggers-file.mjs +299 -39
  231. package/src/triggers.mjs +782 -40
  232. package/src/trust-paths.mjs +171 -0
  233. package/src/up.mjs +398 -51
  234. package/src/updates-cli.mjs +61 -0
  235. package/src/updates-look.mjs +127 -0
  236. package/src/updates-reconcile.mjs +182 -0
  237. package/src/updates-subscription-adapters.mjs +8 -0
  238. package/src/updates.mjs +252 -0
  239. package/src/valkey-auth.mjs +32 -9
  240. package/src/valkey-endpoint.mjs +14 -4
  241. package/src/wait-for.mjs +1 -1
  242. package/src/wait-state.mjs +62 -3
  243. package/src/watch-board.mjs +38 -0
  244. package/src/watch-closer.mjs +1 -1
  245. package/src/watch-contract.mjs +102 -0
  246. package/src/watch-decision.mjs +78 -0
  247. package/src/watch-drainer.mjs +14 -0
  248. package/src/watch-issue-adapter.mjs +44 -0
  249. package/src/watch-issue.mjs +111 -0
  250. package/src/watch-record.mjs +10 -0
  251. package/src/watch-runtime.mjs +167 -0
  252. package/src/watch-store.mjs +183 -0
  253. package/src/watch-trace.mjs +34 -0
  254. package/src/workspace-checkpoint.mjs +1750 -0
  255. package/src/zero-touch-fixes.mjs +99 -0
  256. package/src/zero-touch.mjs +225 -0
  257. package/src/repeat-slot.mjs +0 -13
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { issueBranch } from "./branch.mjs";
3
+ import { cronRunOf } from "./bullmq-internals.mjs";
3
4
  import { isForgeKind } from "./forges.mjs";
4
5
 
5
6
  /**
@@ -37,6 +38,14 @@ import { isForgeKind } from "./forges.mjs";
37
38
  /** Long enough that a collision is not a thing to reason about, short enough to read in a log line. */
38
39
  const KEY_LENGTH = 32;
39
40
 
41
+ /**
42
+ * What a key LOOKS like, derived from KEY_LENGTH rather than restated, because the session reaper (issue #645) uses it
43
+ * to decide which names in PI_SESSIONS_DIR are keys at all: a hand copy of `32` that drifted from this would make the
44
+ * reaper ignore every real key (they would all read as `not-a-key`) or judge names this module never mints.
45
+ */
46
+ export const SESSION_KEY_PATTERN = new RegExp(`^[0-9a-f]{${KEY_LENGTH}}$`);
47
+ export const FORK_SESSION_KEY_PATTERN = new RegExp(`^([0-9a-f]{${KEY_LENGTH}})\\.([0-9a-f]{32})$`);
48
+
40
49
  /**
41
50
  * @param {object} job - The job data (`kind`, `repo`, `target`, `trigger`).
42
51
  * @param {object} [resolved] - `{ headRef, headRepo }` for a pull/merge-request target, from the FORGE
@@ -46,6 +55,20 @@ const KEY_LENGTH = 32;
46
55
  export function sessionKeyFor(job, resolved = {}) {
47
56
  const parts = keyParts(job, resolved);
48
57
  if (parts === null) return null;
58
+ const forkRef = job?.target?.type === "pull_request" ? /^pi\/issue-([1-9][0-9]*)-fork-([0-9a-f]{32})$/.exec(resolved.headRef ?? "") : null;
59
+ if (forkRef) {
60
+ const root = hashParts([parts[0], parts[1], issueBranch(forkRef[1])]);
61
+ return `${root}.${forkRef[2]}`;
62
+ }
63
+ const key = hashParts(parts);
64
+ if (job?.fork !== undefined) {
65
+ if (!job.fork || job.fork.rootKey !== key || typeof job.fork.id !== "string" || !/^[0-9a-f]{32}$/.test(job.fork.id)) return null;
66
+ return `${key}.${job.fork.id}`;
67
+ }
68
+ return key;
69
+ }
70
+
71
+ function hashParts(parts) {
49
72
  // NUL-delimited so ("a", "bc") and ("ab", "c") cannot collide -- the same reasoning job-id.mjs uses.
50
73
  return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, KEY_LENGTH);
51
74
  }
@@ -106,3 +129,40 @@ export function keyParts(job, resolved = {}) {
106
129
  // kind that is neither a forge nor local lands here too, which is the safe direction: no session.
107
130
  return null;
108
131
  }
132
+
133
+ /**
134
+ * A RUN RECORD's session key, re-derived the way `sessionKeyFor` derives a job's (DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED):
135
+ * a forge ISSUE from kind + repo + its number, a cron run from its scheduler id. Null where the record cannot say: a
136
+ * pull or merge request's key needs its head ref, which the record deliberately does not carry (it is attacker-chosen
137
+ * text, and the record holds none, INT-RUN-HISTORY-FILE-CONTRACT).
138
+ *
139
+ * It lives HERE, beside `sessionKeyFor`, and not in the admin's read model where it started (issue #647): the admin's
140
+ * parked-chain counter and the terminal `pi-dispatch chat` fork both need it, and two copies of a key rule are two
141
+ * places for a cross-issue transcript mix-up to start. Still derived, never looked up: there is no index to consult.
142
+ *
143
+ * @param {object} record - A run record (`kind`, `target`, `jobId`, `triggerType`).
144
+ * @returns {string|null} The 32-hex key, or null.
145
+ */
146
+ export function sessionKeyOfRecord(record) {
147
+ if (record?.fork !== undefined) {
148
+ const root = sessionKeyOfRecord({ ...record, fork: undefined });
149
+ return root && typeof record.fork?.id === "string" && /^[0-9a-f]{32}$/.test(record.fork.id) ? `${root}.${record.fork.id}` : null;
150
+ }
151
+ const kind = record?.kind;
152
+ if (kind === "local") {
153
+ // The scheduler id is the middle of a cron (`repeat:`) or manual-fire (`manual:`) job id; the trailing number is
154
+ // the fire's own, which differs every time and must not split one schedule's sessions. Any id length: a record
155
+ // is this deployment's own history, and every release before issue #761 took a cron id of any length.
156
+ const run = cronRunOf(record?.jobId);
157
+ return run ? sessionKeyFor({ kind: "local", trigger: { id: run.id } }) : null;
158
+ }
159
+ if (!isForgeKind(kind) || typeof record?.target !== "string") return null;
160
+ // `!` (a GitLab merge request, an Azure pull request) never matches: a request's key needs the head ref.
161
+ const m = /^(.+)#(\d+)$/.exec(record.target);
162
+ if (!m) return null;
163
+ // `#` is the issue separator on every forge; GitHub and Forgejo also spell a pull request with it, and only the
164
+ // trigger type can say which (a `pull_request` trigger). A comment on a pull request stays ambiguous, so a key that
165
+ // names no directory reads as unknown to its callers, never as a session.
166
+ if (record.triggerType === "pull_request") return null;
167
+ return sessionKeyFor({ kind, repo: m[1], target: { type: "issue", number: Number(m[2]) } });
168
+ }
@@ -16,7 +16,7 @@ import {
16
16
  import { randomBytes } from "node:crypto";
17
17
  import { join } from "node:path";
18
18
  import { scrubCredentials } from "./redact.mjs";
19
- import { sessionKeyFor } from "./session-key.mjs";
19
+ import { FORK_SESSION_KEY_PATTERN, SESSION_KEY_PATTERN, sessionKeyFor } from "./session-key.mjs";
20
20
  import { resolveBackendName } from "./backend-registry.mjs";
21
21
  import { UNATTRIBUTED_BACKEND } from "./backends.mjs";
22
22
  import { MODEL_REF_PATTERN } from "./model-ref.mjs";
@@ -439,7 +439,10 @@ export function makeSessionStore({
439
439
  fs.writeFileSync(staged, "");
440
440
  }
441
441
  log("session_resolved", { key, resume: verdict.resume, reason: verdict.reason });
442
- return { hostDir, key, modelId, venue, ...verdict };
442
+ // Bind answer lineage to the descriptor's transcript, not an earlier park lookup. Another flow on
443
+ // this issue may have promoted a different park while this job was passing its admission gates.
444
+ const parkedSource = verdict.resume ? readBoundParkedSource(key, judged) : null;
445
+ return { hostDir, key, modelId, venue, ...verdict, ...(parkedSource ? { parkedSource } : {}) };
443
446
  } catch (err) {
444
447
  // A history fault must never fail the prepare that asked.
445
448
  log("session_store_failed", { phase: "resolve", reason: err?.message });
@@ -549,7 +552,7 @@ export function makeSessionStore({
549
552
  return st.isDirectory() ? `${st.dev}:${st.ino}` : null;
550
553
  }
551
554
 
552
- function promoteSession(session, { piVersion = null, context = null, parked = false } = {}) {
555
+ function promoteSession(session, { piVersion = null, context = null, parked = false, parkedSource = null } = {}) {
553
556
  // The second DI-seam backstop, and unreachable for the same reason as the `!sessionsDir` return
554
557
  // above: sessionKeyFor is total and binary (null, or 32 hex chars), so resolveSession returns null
555
558
  // rather than a keyless session, and processor.mjs only calls this when prepare handed it one. Kept
@@ -566,6 +569,7 @@ export function makeSessionStore({
566
569
  }
567
570
 
568
571
  const dir = keyDir(session.key);
572
+ if (!ensureForkParents(session.key)) return { promoted: false, reason: "key-not-a-directory" };
569
573
  // LSTAT BEFORE CREATE, and again after (issue #375). Recursive `mkdir` succeeds silently on an
570
574
  // existing link to a directory, so asking it first and checking afterwards would accept exactly the
571
575
  // shape this refuses; asking first means nothing already at the name is ever handed to `mkdir` at all,
@@ -665,7 +669,7 @@ export function makeSessionStore({
665
669
  // (issue #390). Measured with a deterministic probe before this existed: `{"promoted":true,
666
670
  // "reason":"promoted"}` with the transcript in the attacker's directory and the real key holding
667
671
  // only `pi-version`, `resume-chain` and `venue` -- the next job on that key cold-starts as
668
- // `absent` while the record says the work was promoted, which is the silent no-op `CLAUDE.md`
672
+ // `absent` while the record says the work was promoted, which is the silent no-op `AGENTS.md`
669
673
  // calls the worst outcome available. A 45 second unsynchronised live race did not hit the exact
670
674
  // ordering (16,591 promotions, 3 refusals, 0 lies), so this is a deterministic-window finding
671
675
  // rather than a frequent one, and its precondition is the one the whole store concedes: write
@@ -720,7 +724,7 @@ export function makeSessionStore({
720
724
  // it is reporting.
721
725
  writeSidecar(dir, RESUME_CHAIN_FILE, session.key, chainValue(session));
722
726
  writeContextSidecar(dir, session, context);
723
- writeParkedSidecar(dir, session, parked);
727
+ writeParkedSidecar(dir, session, parked, parkedSource);
724
728
  } finally {
725
729
  fs.closeSync(fd);
726
730
  try {
@@ -852,9 +856,12 @@ export function makeSessionStore({
852
856
 
853
857
  // Issue #625: `parked` is set by a run that ended awaiting-input, and cleared by every other promotion, so the
854
858
  // marker always describes the transcript beside it. Caught like its siblings: the promotion already happened.
855
- function writeParkedSidecar(dir, session, parked) {
859
+ function writeParkedSidecar(dir, session, parked, parkedSource = null) {
856
860
  if (parked === true) {
857
- writeSidecar(dir, PARKED_FILE, session.key, String(now()));
861
+ const at = now();
862
+ const transcriptIdentity = readIdentity(canonicalFile(session.key));
863
+ const source = transcriptIdentity && typeof parkedSource?.runId === "string" && /^[A-Za-z0-9._:-]{1,200}$/.test(parkedSource.runId) ? { v: 2, runId: parkedSource.runId, at, transcriptIdentity } : null;
864
+ writeSidecar(dir, PARKED_FILE, session.key, source ? JSON.stringify(source) : String(at));
858
865
  return;
859
866
  }
860
867
  try {
@@ -865,8 +872,19 @@ export function makeSessionStore({
865
872
  }
866
873
 
867
874
  function keyDir(key) {
875
+ const fork = FORK_SESSION_KEY_PATTERN.exec(key);
876
+ if (fork) return join(sessionsDir, fork[1], "forks", fork[2]);
877
+ if (!SESSION_KEY_PATTERN.test(key)) throw new Error("invalid session key");
868
878
  return join(sessionsDir, key);
869
879
  }
880
+ function forkParents(key) {
881
+ const fork = FORK_SESSION_KEY_PATTERN.exec(key);
882
+ return fork ? [join(sessionsDir, fork[1]), join(sessionsDir, fork[1], "forks")] : [];
883
+ }
884
+ function ensureForkParents(key) {
885
+ for (const dir of forkParents(key)) if (ensureKeyDir(dir) === null) return false;
886
+ return true;
887
+ }
870
888
  function canonicalFile(key) {
871
889
  return join(keyDir(key), SESSION_FILE_NAME);
872
890
  }
@@ -950,6 +968,7 @@ export function makeSessionStore({
950
968
  function inspectKeyDir(key) {
951
969
  let st;
952
970
  try {
971
+ for (const dir of forkParents(key)) if (!fs.lstatSync(dir).isDirectory()) return { ok: false, reason: "key-not-a-directory" };
953
972
  st = fs.lstatSync(keyDir(key));
954
973
  } catch {
955
974
  // EVERY failure reads as `absent`, not only ENOENT, and that is this edge's posture rather than an
@@ -1253,6 +1272,21 @@ export function makeSessionStore({
1253
1272
  return;
1254
1273
  }
1255
1274
  for (const name of names) {
1275
+ // ONLY A KEY-SHAPED NAME IS JUDGED (issue #645). Until then every entry of the store was taken for a key, so a
1276
+ // directory that is not one -- the checkpoint store's default home, `<PI_SESSIONS_DIR>/.checkpoints`, or anything
1277
+ // an operator parks here -- was removed whole once its own directory mtime aged past the TTL, by a pass that only
1278
+ // ever had the right to expire sessions. A key is a hash this store mints (session-key.mjs), so the shape is an
1279
+ // exact test and nothing that is a key can fail it.
1280
+ //
1281
+ // A DOT name is skipped silently: that is the namespace for the store's own siblings (`.checkpoints`) and for
1282
+ // tooling litter (`.DS_Store`), and logging them would put a line in every pass that nobody can act on. Any OTHER
1283
+ // non-key name is a verdict, `session_not_reaped` rather than `session_reaper_skipped`, for the reason the #375
1284
+ // comment below gives: the pass established what the entry is and decided to leave it. Once per name per pass,
1285
+ // which the loop gives for free since `readdirSync` names each entry once.
1286
+ if (!SESSION_KEY_PATTERN.test(name)) {
1287
+ if (!name.startsWith(".")) log("session_not_reaped", { key: name, reason: "not-a-key" });
1288
+ continue;
1289
+ }
1256
1290
  try {
1257
1291
  const dir = join(sessionsDir, name);
1258
1292
  // THE ENTRY ITSELF FIRST (issue #375), so nothing here is ever judged THROUGH a link. Before this,
@@ -1270,6 +1304,7 @@ export function makeSessionStore({
1270
1304
  log("session_not_reaped", { key: name, reason: "key-not-a-directory" });
1271
1305
  continue;
1272
1306
  }
1307
+ const retainForks = reapForks(name, dir, cutoff);
1273
1308
  let mtimeMs;
1274
1309
  try {
1275
1310
  mtimeMs = fs.lstatSync(join(dir, SESSION_FILE_NAME)).mtimeMs;
@@ -1296,7 +1331,7 @@ export function makeSessionStore({
1296
1331
  // readable transcript -- the sweep's decision then came from a file outside the store entirely.
1297
1332
  mtimeMs = dst.mtimeMs;
1298
1333
  }
1299
- if (mtimeMs < cutoff) {
1334
+ if (mtimeMs < cutoff && !retainForks) {
1300
1335
  // ONE recursive remove, on the transcript's age where there is one and the directory's own where
1301
1336
  // there is not (above). Removing the
1302
1337
  // transcript first (so an absent stamp, which reads as `local`, can never sit beside a readable
@@ -1313,6 +1348,30 @@ export function makeSessionStore({
1313
1348
  }
1314
1349
  }
1315
1350
 
1351
+ /** Forks have their own expiry clock; an old original must never recursively remove a newer fork. */
1352
+ function reapForks(rootKey, rootDir, cutoff) {
1353
+ const dir = join(rootDir, "forks");
1354
+ let st;
1355
+ try { st = fs.lstatSync(dir); } catch (error) { if (error.code === "ENOENT") return false; throw error; }
1356
+ if (!st.isDirectory()) return true;
1357
+ for (const id of fs.readdirSync(dir)) {
1358
+ if (!/^[0-9a-f]{32}$/.test(id)) continue;
1359
+ const child = join(dir, id);
1360
+ try {
1361
+ const entry = fs.lstatSync(child);
1362
+ if (!entry.isDirectory()) continue;
1363
+ let modified;
1364
+ try { modified = fs.lstatSync(join(child, SESSION_FILE_NAME)).mtimeMs; }
1365
+ catch (error) { if (error.code !== "ENOENT") throw error; modified = entry.mtimeMs; }
1366
+ if (modified < cutoff) {
1367
+ fs.rmSync(child, { recursive: true, force: true });
1368
+ log("reaped_session", { key: `${rootKey}.${id}` });
1369
+ }
1370
+ } catch (error) { log("session_reaper_skipped", { key: `${rootKey}.${id}`, reason: scrubCredentials(error?.message) }); }
1371
+ }
1372
+ return fs.readdirSync(dir).length > 0;
1373
+ }
1374
+
1316
1375
  /**
1317
1376
  * Whether the key a job resolves to holds a `parked` mark (issue #625): the TRUTH the worker gates a phrase-free reply
1318
1377
  * on, where the forge label was only the receiver's hint. Free, read-only and never throws, so it can answer before
@@ -1320,17 +1379,116 @@ export function makeSessionStore({
1320
1379
  * which costs a token to read, which is why a phrase-free reply is routed on issues only and this resolves no PR.
1321
1380
  * The key's own name must `lstat` as a real directory, resolveSession's first gate, so a planted link answers no.
1322
1381
  */
1382
+ function readParkedInfo(job) {
1383
+ if (!sessionsDir || job?.target?.type === "pull_request") return null;
1384
+ const key = sessionKeyFor(job);
1385
+ if (!key) return null;
1386
+ try { if (!inspectKeyDir(key).ok) return null; } catch { return null; }
1387
+ const raw = readSidecar(key, PARKED_FILE);
1388
+ try {
1389
+ const value = JSON.parse(raw);
1390
+ if ([1, 2].includes(value?.v) && /^[A-Za-z0-9._:-]{1,200}$/.test(value.runId ?? "") && Number.isSafeInteger(value.at) && value.at >= 0) return { runId: value.runId, at: value.at };
1391
+ } catch {}
1392
+ // An older timestamp still proves a park, but cannot name a source run or manufacture answer lineage.
1393
+ return null;
1394
+ }
1395
+
1396
+ function readBoundParkedSource(key, transcriptIdentity) {
1397
+ try {
1398
+ const value = JSON.parse(readSidecar(key, PARKED_FILE));
1399
+ if (transcriptIdentity && value?.v === 2 && value.transcriptIdentity === transcriptIdentity && /^[A-Za-z0-9._:-]{1,200}$/.test(value.runId ?? "") && Number.isSafeInteger(value.at) && value.at >= 0) return { runId: value.runId, at: value.at };
1400
+ } catch {}
1401
+ return null;
1402
+ }
1403
+
1323
1404
  function isParked(job) {
1324
1405
  if (!sessionsDir || job?.target?.type === "pull_request") return false;
1325
1406
  const key = sessionKeyFor(job);
1326
1407
  if (!key) return false;
1327
1408
  try {
1328
- if (!fs.lstatSync(keyDir(key)).isDirectory()) return false;
1409
+ if (!inspectKeyDir(key).ok) return false;
1329
1410
  } catch {
1330
1411
  return false;
1331
1412
  }
1332
1413
  return readSidecar(key, PARKED_FILE) !== null;
1333
1414
  }
1334
1415
 
1335
- return { resolveSession, promoteSession, reapSessions, isParked };
1416
+ /**
1417
+ * Stage a RECOVERED run's transcript (issue #645, durable runs): the checkpoint's `transcript.jsonl`, copied to
1418
+ * `jobDir/session/current.jsonl`, the path every job's transcript file has (a resume job's staged copy, or the live
1419
+ * file of issue #643). Returns the session object the processor carries, with `resume: true` and
1420
+ * `reason: "recovered"`, or `null` when nothing could be staged (the caller then does not start the run: a recovery
1421
+ * without its conversation is not a recovery).
1422
+ *
1423
+ * NOT `resolveSession`, and the store is not read at all: the canonical transcript is the one from BEFORE the crashed
1424
+ * run, and the checkpoint is that run's own continuation, written by this worker into its checkpoint store and
1425
+ * hash-checked against its manifest (workspace-checkpoint.mjs `readManifest`). None of resolveSession's gates (age,
1426
+ * chain, context, venue, pi version) applies: the processor already refused a checkpoint from another pi version or
1427
+ * venue before calling this.
1428
+ *
1429
+ * A KEY FOR A RESUME JOB OR AN EXPLICIT MANAGED FORK. Either consent, with a valid derived key, gives the session
1430
+ * its key, model and venue, so a completed recovery promotes exactly as the crashed run would have (`promoteSession`
1431
+ * is unchanged). Any other job gets `key: null`: its transcript is the live file, nothing is promoted, and the
1432
+ * processor hands it over as `prepared.live`, not as a session. `resolved` is the pull request head the preparer
1433
+ * resolved from the forge (`recoveryResolved`), never a payload field: the same rule `resolveSession` keys on.
1434
+ *
1435
+ * Never throws: a fault is logged by phase and returns null, and anything it staged is removed.
1436
+ */
1437
+ function stageRecovered({ jobDir, transcriptPath, piVersion: _piVersion = null, job = null, resolved = {} } = {}) {
1438
+ let hostDir = null;
1439
+ let fd = null;
1440
+ try {
1441
+ if (typeof jobDir !== "string" || jobDir === "" || typeof transcriptPath !== "string" || transcriptPath === "") return null;
1442
+ // lstat, and a regular file: a link at the checkpoint's name is not the checkpoint, whatever it points at.
1443
+ const st = fs.lstatSync(transcriptPath);
1444
+ if (!st.isFile()) {
1445
+ log("session_store_failed", { phase: "stage-recovered", reason: "not-a-regular-file" });
1446
+ return null;
1447
+ }
1448
+ hostDir = join(jobDir, "session");
1449
+ fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 });
1450
+ const staged = join(hostDir, SESSION_FILE_NAME);
1451
+ // Copied off the descriptor whose identity was checked, never re-resolved by path (resolveSession's rule).
1452
+ fd = fs.openSync(transcriptPath, "r");
1453
+ const opened = fs.fstatSync(fd);
1454
+ if (!opened.isFile() || identityOf(opened) !== identityOf(st)) {
1455
+ log("session_store_failed", { phase: "stage-recovered", reason: "transcript-replaced" });
1456
+ fs.rmSync(hostDir, { recursive: true, force: true });
1457
+ return null;
1458
+ }
1459
+ copyFromDescriptor(fd, staged, opened.size);
1460
+ // An explicit managed fork is retention consent in its own right. A recovery keeps that fork's namespace
1461
+ // even when the trigger did not opt ordinary runs into canonical resume; sessionKeyFor validates its root/id.
1462
+ const key = sessionsDir && (job?.resume === true || job?.fork !== undefined) ? sessionKeyFor(job, resolved ?? {}) : null;
1463
+ log("session_resolved", { key, resume: true, reason: "recovered" });
1464
+ return {
1465
+ hostDir,
1466
+ key,
1467
+ ...(key ? { modelId: modelIdentity(job), venue: normaliseVenue(resolveBackendName(job, defaultBackend)) } : {}),
1468
+ resume: true,
1469
+ reason: "recovered",
1470
+ bytes: opened.size,
1471
+ };
1472
+ } catch (err) {
1473
+ log("session_store_failed", { phase: "stage-recovered", reason: err?.code ?? "error" });
1474
+ if (hostDir !== null) {
1475
+ try {
1476
+ fs.rmSync(hostDir, { recursive: true, force: true });
1477
+ } catch {
1478
+ // the job dir is removed at teardown either way
1479
+ }
1480
+ }
1481
+ return null;
1482
+ } finally {
1483
+ if (fd !== null) {
1484
+ try {
1485
+ fs.closeSync(fd);
1486
+ } catch {
1487
+ // already closed or never valid
1488
+ }
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ return { resolveSession, promoteSession, reapSessions, isParked, readParkedInfo, stageRecovered };
1336
1494
  }
@@ -0,0 +1,40 @@
1
+ /** Data-only wire vocabulary for the explicitly granted local setup child (#774). */
2
+ export const SETUP_VERSION = 1;
3
+ export const SETUP_LIMITS = Object.freeze({ inputBytes: 64 * 1024, outputBytes: 256 * 1024, keyBytes: 4096, serviceMs: 60_000, doctorMs: 120_000, killGraceMs: 2000, readinessMs: 90_000 });
4
+ export const SETUP_OPERATIONS = Object.freeze(["setup-worker-install", "setup-worker-start", "setup-worker-stop", "setup-worker-restart", "setup-receiver-install", "setup-receiver-start", "setup-receiver-stop", "setup-receiver-restart", "setup-egress-start", "setup-doctor", "setup-provider-key", "setup-provider-key-replace"]);
5
+ export const SETUP_STATES = Object.freeze(["planned", "unchanged", "applied", "refused", "partial", "unknown"]);
6
+ export const SETUP_REASONS = Object.freeze(["ready", "invalid-request", "unsupported", "unavailable", "unsafe-deployment", "unsafe-entry", "foreign-service", "missing-service", "missing-receiver", "service-exists", "manager-unavailable", "unsafe-dependencies", "plan-stale", "setup-busy", "process-failed", "process-timeout", "readback-unavailable", "already-running", "egress-off", "gateway-egress", "remote-runtime", "unsafe-proxy", "proxy-refresh-required", "provider-unsupported", "provider-host-auth", "provider-shadowed", "provider-managed", "key-present", "key-missing", "key-invalid", "env-unsafe", "doctor-incomplete", "doctor-published"]);
7
+ export const SETUP_EFFECTS = Object.freeze(["write-worker-unit", "write-receiver-unit", "start-worker", "start-receiver", "stop-worker", "stop-receiver", "restart-worker", "restart-receiver", "write-stack-files", "start-valkey", "start-proxy", "start-keeper", "write-provider-key", "publish-doctor"]);
8
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
9
+ const keys = (value, allowed) => object(value) && Object.keys(value).every((key) => allowed.includes(key));
10
+ const short = (value, max = 128) => typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
11
+ export function validSetupRequest(value) {
12
+ if (!keys(value, ["version", "id", "phase", "operation", "expected", "input"]) || value.version !== SETUP_VERSION || !/^[a-f0-9]{32}$/.test(value.id ?? "") || !["plan", "apply", "status"].includes(value.phase) || !SETUP_OPERATIONS.includes(value.operation)) return false;
13
+ if (value.expected !== undefined && !/^[a-f0-9]{64}$/.test(value.expected)) return false;
14
+ if (value.phase === "apply" && value.expected === undefined) return false;
15
+ if (value.input !== undefined && (!keys(value.input, ["provider", "key", "value"]) || Object.entries(value.input).some(([key, v]) => typeof v !== "string" || (key !== "value" && !short(v))))) return false;
16
+ if (!["setup-provider-key", "setup-provider-key-replace"].includes(value.operation) && value.input !== undefined && Object.keys(value.input).length > 0) return false;
17
+ if (value.phase !== "apply" && value.input?.value !== undefined) return false;
18
+ return true;
19
+ }
20
+ export function validSetupResult(value) {
21
+ if (!keys(value, ["version", "id", "operation", "state", "reason", "plan", "effects"]) || value.version !== SETUP_VERSION || !/^[a-f0-9]{32}$/.test(value.id ?? "") || !SETUP_OPERATIONS.includes(value.operation) || !SETUP_STATES.includes(value.state) || !SETUP_REASONS.includes(value.reason)) return false;
22
+ const effects = (list) => Array.isArray(list) && list.length <= SETUP_EFFECTS.length && new Set(list).size === list.length && list.every((v) => SETUP_EFFECTS.includes(v));
23
+ if (value.effects !== undefined && !effects(value.effects)) return false;
24
+ if (value.plan !== undefined) {
25
+ const p = value.plan;
26
+ if (!keys(p, ["digest", "component", "manager", "host", "workerName", "account", "effects", "key", "provider", "mode", "aliases", "source"]) || !/^[a-f0-9]{64}$/.test(p.digest ?? "") || !["worker", "receiver", "egress", "doctor", "provider"].includes(p.component) || !["systemd", "launchd"].includes(p.manager) || !short(p.host, 64) || !short(p.account, 64) || !effects(p.effects)) return false;
27
+ const component = value.operation.startsWith("setup-provider-key") ? "provider" : value.operation === "setup-doctor" ? "doctor" : value.operation.split("-")[1];
28
+ if (p.component !== component) return false;
29
+ if (p.workerName !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(p.workerName)) return false;
30
+ if (p.key !== undefined && !/^[A-Z_][A-Z0-9_]{0,127}$/.test(p.key)) return false;
31
+ if (p.provider !== undefined && !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(p.provider)) return false;
32
+ if (p.component === "provider") {
33
+ if (!p.key || !p.provider || p.mode !== (value.operation === "setup-provider-key-replace" ? "replace" : "add") || !["deployment-env", "pi-api-key", "none"].includes(p.source)) return false;
34
+ if ((p.mode === "replace") !== (p.source === "deployment-env")) return false;
35
+ if (!Array.isArray(p.aliases) || p.aliases.length === 0 || p.aliases.length > 128 || new Set(p.aliases).size !== p.aliases.length || !p.aliases.includes(p.provider) || p.aliases.some((name) => !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(name))) return false;
36
+ } else if (p.mode !== undefined || p.aliases !== undefined || p.source !== undefined || p.key !== undefined || p.provider !== undefined) return false;
37
+ }
38
+ return value.state !== "planned" || value.plan !== undefined;
39
+ }
40
+ export { validSetupRequest as validateSetupRequest, validSetupResult as validateSetupResult };
@@ -0,0 +1,113 @@
1
+ /** Start-only proxy planning, reusing the shipped argv and Quadlet installer; never a browser `up` pass. */
2
+ import { execFile } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { deploymentServiceEnv } from "./service-env.mjs";
8
+ import { egressArmed, egressProxyName, DEFAULT_EGRESS_PROXY } from "./egress.mjs";
9
+ import { venuesOf } from "./backends.mjs";
10
+ import { makeDockerEndpointResolver } from "./backend-local.mjs";
11
+ import { makePodmanInfoReader, decidePodmanJobUser } from "./backend-podman.mjs";
12
+ import { EGRESS_NETWORK_ARGS, EGRESS_RUN_ARGS } from "./up.mjs";
13
+ import { EGRESS_PROXY_IMAGE, PROXY_STATE_FORMAT, jobNetworksOf, parseProxyState, rulesIncludeEndpoints, shippedProxyDrift } from "./egress-proxy-state.mjs";
14
+ import { readPackagedProxyConf } from "./egress-conf-copy.mjs";
15
+ import { applyStack, foreignContainers, planStack, stackComponents, managerEnvRefusal, userBusRefusal } from "./podman-stack.mjs";
16
+
17
+ const digest = (value) => createHash("sha256").update(typeof value === "string" ? value : JSON.stringify(value)).digest("hex");
18
+ const templatesDir = fileURLToPath(new URL("../deploy/", import.meta.url));
19
+ export function setupCommand(bin, args, { cwd, env, timeout = 15_000 } = {}) {
20
+ return new Promise((done) => {
21
+ try { execFile(bin, args, { cwd, env, timeout, maxBuffer: 256 * 1024, killSignal: "SIGKILL" }, (error, stdout, stderr) => done({ code: error ? typeof error.code === "number" ? error.code : null : 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") })); }
22
+ catch { done({ code: null, stdout: "", stderr: "" }); }
23
+ });
24
+ }
25
+ const no = (refusal) => ({ refusal });
26
+
27
+ export async function planSetupEgress(ctx) {
28
+ const { CLI_SERVICE_KEYS } = await import("./doctor.mjs");
29
+ const runtimeKeys = [...new Set([...CLI_SERVICE_KEYS.docker, ...CLI_SERVICE_KEYS.podman])];
30
+ const runtime = deploymentServiceEnv({ env: ctx.env, dir: ctx.dir, keys: runtimeKeys, fs: ctx.fs, uid: ctx.uid, platform: ctx.platform });
31
+ if (runtime.untrusted || runtime.unreadable || runtime.unread?.length || runtime.hazardSkipped?.length || runtime.disagreements?.length) return no("unsafe-deployment");
32
+ const runtimeEnv = runtime.env;
33
+ const run = ctx.run ?? ((bin, args) => setupCommand(bin, args, { cwd: ctx.dir, env: runtimeEnv }));
34
+ const read = deploymentServiceEnv({ env: {}, dir: ctx.dir, keys: ["PI_BACKENDS", "PI_EGRESS", "PI_EGRESS_PROXY"], fs: ctx.fs, uid: ctx.uid, platform: ctx.platform });
35
+ if (read.untrusted || read.unreadable || read.unread?.length || read.hazardSkipped?.length) return no("unsafe-deployment");
36
+ const env = read.env;
37
+ const venues = venuesOf(env);
38
+ if (!egressArmed(env)) return no("egress-off");
39
+ if (!venues.localUsed && !venues.podmanUsed) return no("gateway-egress");
40
+ if (egressProxyName(env) !== DEFAULT_EGRESS_PROXY) return no("unsafe-proxy");
41
+ const files = {};
42
+ try {
43
+ for (const rel of ["deploy/egress-proxy.conf", "egress-allowlist.conf", "model-endpoints.conf"]) {
44
+ const path = join(ctx.dir, rel);
45
+ const st = ctx.fs.lstatSync(path);
46
+ if (!st.isFile() || st.isSymbolicLink() || st.uid !== ctx.uid || (st.mode & 0o022)) return no("unsafe-proxy");
47
+ files[rel] = String(ctx.fs.readFileSync(path, "utf8"));
48
+ }
49
+ } catch { return no("unsafe-proxy"); }
50
+ if (files["deploy/egress-proxy.conf"] !== (ctx.readPackagedConf ?? readPackagedProxyConf)()) return no("proxy-refresh-required");
51
+ if (!venues.localUsed) {
52
+ if (ctx.platform !== "linux") return no("unsupported");
53
+ const info = await (ctx.readPodmanInfo ?? makePodmanInfoReader({ run: (args) => run("podman", args) }))();
54
+ if (decidePodmanJobUser({ platform: ctx.platform, euid: ctx.uid, egid: process.getegid?.(), read: info }).mode !== "worker") return no("remote-runtime");
55
+ const home = ctx.serviceDeps?.home ?? homedir();
56
+ // env-internal USER: this account's manager label, never a declaration of another service account.
57
+ const account = ctx.serviceDeps?.user ?? process.env.USER;
58
+ if (userBusRefusal({ env: runtimeEnv, user: account, euid: ctx.uid })) return no("manager-unavailable");
59
+ const manager = await run("systemctl", ["--user", "show-environment"]);
60
+ if (manager.code !== 0 || managerEnvRefusal(manager, { home, euid: ctx.uid, user: account, realpath: ctx.fs.realpathSync })) return no("manager-unavailable");
61
+ const components = stackComponents({ venues, env, includeValkey: false, armed: true });
62
+ const stack = planStack({ components, templatesDir, deployDir: ctx.dir, home, fs: ctx.fs });
63
+ if (stack.error || stack.files.some((f) => f.state === "changed") || stack.restart?.length) return no("unsafe-dependencies");
64
+ for (const f of stack.files) {
65
+ let path = f.path;
66
+ while (path !== dirname(home)) {
67
+ try { const st = ctx.fs.lstatSync(path); if (st.isSymbolicLink() || (st.mode & 0o022) || st.uid !== ctx.uid) return no("unsafe-dependencies"); } catch (e) { if (e.code !== "ENOENT") return no("unsafe-dependencies"); }
68
+ if (path === home || path === dirname(path)) break;
69
+ path = dirname(path);
70
+ }
71
+ }
72
+ const foreign = await foreignContainers(stack, (bin, args) => run(bin, args));
73
+ if (foreign.found.length || foreign.unknown.length) return no("unsafe-proxy");
74
+ const running = await run("podman", ["inspect", PROXY_STATE_FORMAT, DEFAULT_EGRESS_PROXY]);
75
+ const state = running.code === 0 ? parseProxyState(running.stdout) : null;
76
+ if (state && !["running", "exited", "stopped", "created"].includes(state.status)) return no("unsafe-proxy");
77
+ // Starting a keeper behind an existing proxy can require a disruptive replacement. Leave that repair to the host.
78
+ if (state?.status === "running" && stack.files.some((f) => f.state === "new")) return no("unsafe-dependencies");
79
+ return { effects: [...(stack.files.some((f) => f.state === "new") ? ["write-stack-files"] : []), "start-keeper", "start-proxy"], inputs: { runtime: digest(Object.fromEntries(runtimeKeys.map((key) => [key, runtimeEnv[key] ?? null]))), files: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, digest(v)])), stack, state }, egress: { bin: "podman", stack, run, running: state?.status === "running" } };
80
+ }
81
+ const endpoint = await (ctx.resolveEndpoint ?? makeDockerEndpointResolver({ run: (args) => run("docker", args) }))();
82
+ if (endpoint.local !== true) return no("remote-runtime");
83
+ const daemon = await run("docker", ["info", "--format", "{{.ID}}"]);
84
+ if (daemon.code !== 0 || !daemon.stdout.trim()) return no("unavailable");
85
+ const current = await run("docker", ["inspect", PROXY_STATE_FORMAT, DEFAULT_EGRESS_PROXY]);
86
+ const state = current.code === 0 ? parseProxyState(current.stdout) : null;
87
+ if (current.code === 0 && !state) return no("unsafe-proxy");
88
+ if (!state && !/No such (?:object|container)/i.test(current.stderr)) return no("unavailable");
89
+ if (state) {
90
+ const drift = shippedProxyDrift(state, { cwd: ctx.dir, realpath: ctx.fs.realpathSync, platform: ctx.platform, rulesInclude: rulesIncludeEndpoints(files["deploy/egress-proxy.conf"]) });
91
+ if (drift.drift.length || drift.unknown || jobNetworksOf(state).length || !["running", "exited", "created", "stopped"].includes(state.status)) return no("unsafe-proxy");
92
+ }
93
+ const image = await run("docker", ["image", "inspect", EGRESS_PROXY_IMAGE]);
94
+ if (image.code !== 0) return no("unavailable");
95
+ const network = await run("docker", ["network", "inspect", "pi-dispatch-egress-out"]);
96
+ if (network.code !== 0 && !/No such network/i.test(network.stderr)) return no("unavailable");
97
+ return { effects: ["start-proxy"], inputs: { runtime: digest(Object.fromEntries(runtimeKeys.map((key) => [key, runtimeEnv[key] ?? null]))), files: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, digest(v)])), state, daemon: daemon.stdout.trim(), network: network.code === 0 ? digest(network.stdout) : null }, egress: { bin: "docker", run, state, networkMissing: network.code !== 0, running: state?.status === "running" } };
98
+ }
99
+
100
+ export async function applySetupEgress(plan, ctx) {
101
+ const e = plan.egress;
102
+ if (e.running) return { state: "unchanged", reason: "already-running", effects: [] };
103
+ if (e.bin === "podman") {
104
+ const result = await applyStack(e.stack, { fs: ctx.fs, run: async (bin, args) => (await e.run(bin, args)).code });
105
+ if (!result.ok) return { state: result.ran ? "partial" : "unknown", reason: "process-failed", effects: plan.effects };
106
+ } else {
107
+ if (e.networkMissing && (await e.run("docker", EGRESS_NETWORK_ARGS)).code !== 0) return { state: "unknown", reason: "process-failed" };
108
+ const result = await e.run("docker", e.state ? ["start", DEFAULT_EGRESS_PROXY] : EGRESS_RUN_ARGS);
109
+ if (result.code !== 0) return { state: "partial", reason: "process-failed", effects: plan.effects };
110
+ }
111
+ const read = await e.run(e.bin, ["inspect", PROXY_STATE_FORMAT, DEFAULT_EGRESS_PROXY]);
112
+ return parseProxyState(read.stdout)?.status === "running" ? { state: "applied", reason: "ready", effects: plan.effects } : { state: "unknown", reason: "readback-unavailable", effects: plan.effects };
113
+ }
@@ -0,0 +1,33 @@
1
+ /** Resolve only the installed worker's own entry; npm's disposable execution cache is never a service target. */
2
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export function setupCachePath(path) {
7
+ return /(?:^|[/\\])(?:_npx|_cacache|npm-cache|\.cache)(?:[/\\]|$)/.test(String(path));
8
+ }
9
+ export function trustedSetupFile(entry, { uid = process.geteuid?.(), realpath = realpathSync, stat = statSync } = {}) {
10
+ try {
11
+ const path = realpath(entry);
12
+ const facts = stat(path);
13
+ if (!facts.isFile() || (facts.uid !== uid && facts.uid !== 0) || (facts.mode & 0o022) || setupCachePath(path)) return null;
14
+ for (let parent = dirname(path); ; parent = dirname(parent)) {
15
+ const st = stat(parent);
16
+ if (!st.isDirectory() || (st.uid !== uid && st.uid !== 0) || ((st.mode & 0o022) && !(st.mode & 0o1000))) return null;
17
+ if (parent === dirname(parent)) break;
18
+ }
19
+ return path;
20
+ } catch { return null; }
21
+ }
22
+ export function resolveSetupWorkerEntry({ entry = fileURLToPath(new URL("./cli.mjs", import.meta.url)), uid = process.geteuid?.(), realpath = realpathSync, readFile = readFileSync, stat = statSync, exists = existsSync } = {}) {
23
+ try {
24
+ const path = trustedSetupFile(entry, { uid, realpath, stat });
25
+ if (path === null) return { refusal: "unsafe-entry" };
26
+ const metadata = join(dirname(path), "..", "package.json");
27
+ const pkg = JSON.parse(readFile(metadata, "utf8"));
28
+ const facts = stat(path);
29
+ const safeFile = (st) => st.isFile() && (st.uid === uid || st.uid === 0) && !(st.mode & 0o022);
30
+ if (setupCachePath(path) || !exists(path) || !safeFile(facts) || !safeFile(stat(metadata)) || pkg.name !== "@edgehero/pi-dispatch" || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(pkg.version) || !path.endsWith(`${String(path).includes("\\") ? "\\" : "/"}src${String(path).includes("\\") ? "\\" : "/"}cli.mjs`)) return { refusal: "unsafe-entry" };
31
+ return { entry: path, version: pkg.version };
32
+ } catch { return { refusal: "unsafe-entry" }; }
33
+ }