@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
@@ -13,6 +13,8 @@
13
13
  * Custom: per-scheduler stall accounting; BullMQ's maxStalledCount does not cover scheduler jobs -- CONST-RETRY-INFRA-ONLY carve-out ("BullMQ will never do this for us")
14
14
  */
15
15
 
16
+ import { cronRunOf, isSchedulerJobId } from "./bullmq-internals.mjs";
17
+
16
18
  // The prefix every stall counter lives under, so `KEYS pi-dispatch:sched-stalls*` still shows an operator
17
19
  // the whole feature -- the affordance `wait:`, `slot:` and `budget:` all assume. Exported alongside the
18
20
  // builder so the admin panel and the integration teardown compose keys through one definition and cannot
@@ -36,8 +38,8 @@ export const stallKey = (schedulerId) => `${STALL_KEY}:${schedulerId}`;
36
38
  // stalls, opposite outcome, decided by an unrelated trigger (issue #267).
37
39
  //
38
40
  // Per-field TTLs would have fixed it in place and are not available: `HEXPIRE` does not exist on the pinned
39
- // `valkey/valkey:8` (verified, `ERR unknown command`, recorded under DES-HOST-REGISTRY). A key per entity is
40
- // the only shape that gets per-entity expiry.
41
+ // `valkey/valkey:8.1` (`VALKEY_IMAGE`; verified on 8.1.11, `ERR unknown command`, recorded under DES-HOST-REGISTRY).
42
+ // A key per entity is the only shape that gets per-entity expiry.
41
43
  //
42
44
  // The EXPIRE still ROLLS on every stall, deliberately, and that is not `budget.mjs`'s set-once rule being
43
45
  // broken. A budget window is a CALENDAR window and must not be pushed forward by traffic or a busy day
@@ -59,16 +61,20 @@ const STALL_WINDOW_SECONDS = 24 * 60 * 60;
59
61
  export function makeStallGuard({ redis, threshold, removeJobScheduler, log }) {
60
62
  return async function onStalled(jobId) {
61
63
  try {
62
- // Ordinary jobs are bounded by `maxStalledCount: 0`; only scheduler jobs reach this accounting.
63
- if (!jobId.startsWith("repeat:")) return;
64
+ // Ordinary jobs are bounded by `maxStalledCount: 0`; only scheduler jobs reach this accounting. A hand-fired
65
+ // `manual:` run is an ordinary job, so it is not counted here either.
66
+ if (!isSchedulerJobId(jobId)) return;
64
67
 
65
- const schedulerId = jobId.slice("repeat:".length, jobId.lastIndexOf(":"));
66
- if (schedulerId === "") {
67
- // Degenerate `repeat:<n>` / `repeat::<n>` with no scheduler segment: an empty hash field would
68
- // pool every such id into one counter, so log and skip rather than hincrby an empty key.
68
+ // Any id length: a scheduler an earlier release made for a longer cron id still stalls, and this guard is the
69
+ // one that stops it re-paying.
70
+ const run = cronRunOf(jobId);
71
+ if (run === null) {
72
+ // A `repeat:` id with no scheduler segment (`repeat:<n>`, `repeat::<n>`) or none BullMQ mints: an empty or
73
+ // guessed counter would pool unrelated ids into one, so log and skip rather than count a key.
69
74
  log("scheduler_stall_unparsed", { jobId });
70
75
  return;
71
76
  }
77
+ const schedulerId = run.id;
72
78
 
73
79
  const key = stallKey(schedulerId);
74
80
  const count = Number(await redis.incr(key));
package/src/schedules.mjs CHANGED
@@ -17,6 +17,8 @@ import { isAbsolute } from "node:path";
17
17
  import { configError } from "./config.mjs";
18
18
  import { triggerCapAboveDeployment } from "./money.mjs";
19
19
  import { parseTriggers } from "./triggers.mjs";
20
+ import { withJobDataVersion } from "./job-data-version.mjs";
21
+ import { LOCAL_JOB_KEPT_COMPLETED_S, LOCAL_JOB_KEPT_FAILED_S } from "./retention.mjs";
20
22
 
21
23
  /**
22
24
  * Parse, validate, and select the cron schedules from the triggers file named by `config.triggersFile`.
@@ -24,7 +26,7 @@ import { parseTriggers } from "./triggers.mjs";
24
26
  * valid deployment. `readFileSync`/`existsSync` are injectable so tests exercise the full path with no
25
27
  * real filesystem.
26
28
  */
27
- export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync, fleet = false } = {}) {
29
+ export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync, fleet = false, dataVersions = {} } = {}) {
28
30
  const path = config.triggersFile;
29
31
  if (path === null || path === undefined) return []; // cron disabled
30
32
 
@@ -44,7 +46,7 @@ export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSyn
44
46
  throw configError(`trigger at index ${above}: run.maxCostUsd is above this deployment's PI_MAX_COST_USD. A trigger can only narrow the per-job cost cap, so lower it or remove it (${path})`);
45
47
  }
46
48
 
47
- return triggers.filter((t) => t.on.type === "cron").map((t) => normalizeCronSchedule(t, path, existsSync, fleet));
49
+ return triggers.filter((t) => t.on.type === "cron").map((t) => normalizeCronSchedule(t, path, existsSync, fleet, dataVersions));
48
50
  }
49
51
 
50
52
  /**
@@ -120,7 +122,7 @@ export function cronPlacement(run, { existsSync = fsExistsSync, fleet = false }
120
122
  return fleet ? "elsewhere" : "refused";
121
123
  }
122
124
 
123
- function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
125
+ function normalizeCronSchedule({ on, run }, path, existsSync, fleet, dataVersions = {}) {
124
126
  // The pure validator already guaranteed a non-empty, `:`-free, charset-valid, unique id and a
125
127
  // well-formed pattern; folder existence is the one fs-dependent check it deferred to here.
126
128
  //
@@ -182,10 +184,21 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
182
184
  // `portfolio` (issue #505) is conditional like `command`, so an unflagged schedule grows no key, and it rides at JOB
183
185
  // level, never inside `trigger`: `trigger` is copied verbatim into `/job/event.json`, and whether a job may send a
184
186
  // budget plan is the worker's decision to re-check against the live file, not a fact for the agent to read.
185
- const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.mcp !== undefined && { mcp: run.mcp }), ...(run.models !== undefined && { models: run.models }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), trigger: { id: on.id, pattern: on.pattern } };
187
+ //
188
+ // `progress` (issue #644) only when armed, as on a forge job, so an unflagged schedule grows no key. A cron job never
189
+ // carries `statusComment`: the loader refuses `true` there, with no issue or pull request to post on. Nor `recover`
190
+ // (issue #645), for the same posture: the loader refuses `true` on cron, so there is no armed value to carry.
191
+ // `steer` (issue #646) DOES ride a cron job, progress's rule: only when armed, at JOB level (never inside `trigger`,
192
+ // which becomes /job/event.json), and never part of the repeat:<id>:<millis> id. An operator can steer a folder job.
193
+ //
194
+ // Stamped like every builder's (issue #761, `withJobDataVersion`): the template is what every tick of this schedule
195
+ // stores, so a field that needs a version above 1 says so on every job, and nothing is added while none does.
196
+ // `dataVersions` is the version rule, injectable for tests.
197
+ const data = withJobDataVersion({ kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.excludeExtensions !== undefined && { excludeExtensions: run.excludeExtensions }), ...(run.excludeExtensionTools !== undefined && { excludeExtensionTools: run.excludeExtensionTools }), ...(run.mcp !== undefined && { mcp: run.mcp }), ...(run.models !== undefined && { models: run.models }), ...(run.fallbackModels !== undefined && { fallbackModels: run.fallbackModels }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), ...(run.progress === true && { progress: true }), ...(run.steer === true && { steer: true }), trigger: { id: on.id, pattern: on.pattern } }, dataVersions);
186
198
  // Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
187
- // scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
188
- const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
199
+ // scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff. A local job's retention, in
200
+ // SECONDS (BullMQ's `age`), from the one place the console's re-run reads it too (retention.mjs).
201
+ const opts = { removeOnComplete: { age: LOCAL_JOB_KEPT_COMPLETED_S }, removeOnFail: { age: LOCAL_JOB_KEPT_FAILED_S } };
189
202
 
190
203
  return { schedulerId: on.id, name: "local", pattern: on.pattern, data, opts };
191
204
  }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Project secret bundles (issue #704, REQ-TRIGGER-SECRETS, DES-PROJECT-SECRET-BUNDLES): a project's whole
3
+ * environment, read HOST-SIDE by ONE resolver call per job, before the container starts.
4
+ *
5
+ * `run.secrets` grants per trigger and per field, and its resolver runs once per reference. A bundle grants per
6
+ * project and runs once per JOB: the triggers file's `secretBundles` binds a project to a bundle profile and a
7
+ * reference, and the resolver prints every variable of that environment on stdout. A variable the operator adds
8
+ * or removes in their manager (or file) reaches the next job with no edit to the reviewed file. That is the
9
+ * asked-for trade, recorded where `DES-PER-TRIGGER-SECRET-PROFILE` once rejected it: the reviewed file no longer
10
+ * lists the fields, so the review of WHAT a project holds moves to the manager.
11
+ *
12
+ * THE SEAM IS STILL A COMMAND. `secrets.mjs`' rule holds verbatim: the reference grammar belongs to the
13
+ * operator's script, nothing here recognises a manager's notation, and the resolver's exit code is the runner's
14
+ * (`INT-RUNNER-EXIT-CODE-PROTOCOL`): 0 carries the environment, 2 says the reference is wrong (refused, never
15
+ * retried), anything else says the manager could not be reached (retried). `runResolver` is imported, not
16
+ * copied, so the spawn, the stdio, the timeout, the abort and the kill escalation are the ones `run.secrets` has.
17
+ *
18
+ * WHAT IS DIFFERENT, and each for a reason:
19
+ * - the byte cap is the whole environment's, `MAX_BUNDLE_BYTES`, and every value still holds to
20
+ * `MAX_SECRET_BYTES`, so a bundle cannot carry a value a single reference could not;
21
+ * - the output is PARSED, by a declared format, strictly (`parseBundleOutput`);
22
+ * - a reserved name is a REFUSAL naming the variable, never a drop. A variable an operator added in their
23
+ * manager that silently never arrives is this project's worst outcome, and dropping is that;
24
+ * - errors and log lines name VARIABLES (and dotenv line numbers), never a value, a reference or a byte the
25
+ * resolver printed. A variable name is the operator's own choice from their own manager, the class
26
+ * `run.secrets` names freely in its logs; a line that failed to parse is not named even by its first word,
27
+ * because a malformed line can be the second half of a value.
28
+ */
29
+
30
+ import { spawn } from "node:child_process";
31
+ import { providerKeyCandidates } from "./env-allowlist.mjs";
32
+ import { findDuplicateKey } from "./json-duplicates.mjs";
33
+ import { mergeSecretProfiles, withinRoots } from "./secret-profiles.mjs";
34
+ import { MAX_SECRET_BYTES, realExecutable, runResolver } from "./secrets.mjs";
35
+ import { ENV_NAME, RESERVED_ENV_NAMES, SECRET_BUNDLE_VARS_MAX } from "./triggers.mjs";
36
+
37
+ /**
38
+ * The largest output a bundle resolver may print: 256 KiB. Four `MAX_SECRET_BYTES`, so a bundle holds a few PEM
39
+ * chains and a service-account JSON beside its plain keys, and the whole of it sits in one worker's memory once
40
+ * per job start (never per reference). On overflow the child is killed and the job refuses, NEVER truncated, the
41
+ * rule `MAX_SECRET_BYTES` states: a cut environment is a variable silently missing.
42
+ */
43
+ export const MAX_BUNDLE_BYTES = 256 * 1024;
44
+
45
+ /**
46
+ * Names a BUNDLE may not carry, beyond the ones `run.secrets` may not bind (issue #704 review). A bundle's contents are
47
+ * not reviewed: they are whatever the operator's manager holds at the job's start, written by whoever can write that
48
+ * manager, so a variable that changes what CODE runs in the job, or how it trusts the network, is refused by name
49
+ * rather than injected. Each one steers a loader or a tool the job image runs, whatever the agent does:
50
+ * - the dynamic loaders (`LD_*`, `DYLD_*`) and `PATH`, which choose the binaries and libraries every process loads;
51
+ * - Node's `NODE_OPTIONS` (the runner gives its children a metering preload through it, `image/runner/src/child-watch.mjs`,
52
+ * so a bundle value would also blind the child meter) and `NODE_PATH`, and TLS trust (`NODE_EXTRA_CA_CERTS`,
53
+ * `NODE_TLS_REJECT_UNAUTHORIZED`, `SSL_CERT_FILE`, `SSL_CERT_DIR`), which `provider-steering.mjs` deliberately leaves
54
+ * bindable by a REVIEWED trigger and which an unreviewed bundle must not set;
55
+ * - the shells' start-up hooks (`BASH_ENV`, `ENV`, `PROMPT_COMMAND`), run by every non-interactive shell the agent opens;
56
+ * - git's command and config hooks (`GIT_SSH`, `GIT_SSH_COMMAND`, `GIT_ASKPASS`, `GIT_EXEC_PATH`, `GIT_PROXY_COMMAND`,
57
+ * `SSH_ASKPASS`, and every `GIT_CONFIG*`, which can set `core.sshCommand` or a credential helper), which run on the
58
+ * push of the job's own branch with its forge token in hand;
59
+ * - the interpreters' start-up hooks (`PYTHONPATH`, `PYTHONHOME`, `PYTHONSTARTUP`, `PERL5OPT`, `PERL5LIB`, `RUBYOPT`,
60
+ * `RUBYLIB`, `JAVA_TOOL_OPTIONS`), git's template directory (`GIT_TEMPLATE_DIR`, hooks copied into every clone);
61
+ * - the other tools' TLS trust (`GIT_SSL_NO_VERIFY`, `GIT_SSL_CAINFO`, `GIT_SSL_CAPATH`, `REQUESTS_CA_BUNDLE`,
62
+ * `CURL_CA_BUNDLE`) and npm's whole configuration (`npm_config_*`, matched CASE-INSENSITIVELY because npm reads it
63
+ * so: a registry, a script shell, an `ignore-scripts` turned off).
64
+ * The table is held to `docs/secrets.md`'s list between its markers by a test, both directions.
65
+ * A REFUSAL naming the variable (`secret-name-reserved`), never a drop, the header's rule. `run.secrets` keeps its own
66
+ * narrower set: a trigger is reviewed, and a deploy flow may legitimately bind, say, `PYTHONPATH`.
67
+ */
68
+ export const BUNDLE_LOADER_NAMES = Object.freeze(["PATH", "NODE_OPTIONS", "NODE_PATH", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED", "SSL_CERT_FILE", "SSL_CERT_DIR", "BASH_ENV", "ENV", "PROMPT_COMMAND", "GIT_SSH", "GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_EXEC_PATH", "GIT_PROXY_COMMAND", "GIT_TEMPLATE_DIR", "GIT_SSL_NO_VERIFY", "GIT_SSL_CAINFO", "GIT_SSL_CAPATH", "SSH_ASKPASS", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PERL5OPT", "PERL5LIB", "RUBYOPT", "RUBYLIB", "JAVA_TOOL_OPTIONS"]);
69
+ /** Prefixes, exact case; and `npm_config_`, compared case-insensitively. */
70
+ export const BUNDLE_LOADER_PREFIXES = Object.freeze(["LD_", "DYLD_", "GIT_CONFIG"]);
71
+ export const BUNDLE_LOADER_PREFIXES_ANY_CASE = Object.freeze(["npm_config_"]);
72
+ const LOADER_NAME_SET = new Set(BUNDLE_LOADER_NAMES);
73
+
74
+ /** Whether a bundle may not carry `name` for the loader reason above. Exported for doctor and the docs' test. */
75
+ export function bundleLoaderName(name) {
76
+ return LOADER_NAME_SET.has(name) || BUNDLE_LOADER_PREFIXES.some((prefix) => name.startsWith(prefix)) || BUNDLE_LOADER_PREFIXES_ANY_CASE.some((prefix) => name.toLowerCase().startsWith(prefix));
77
+ }
78
+
79
+ /**
80
+ * Parse a bundle resolver's stdout by its DECLARED format. `{ vars }` (an object of own string properties, in the
81
+ * resolver's order), or `{ failure, name?, line? }` with `failure` one of OUR words:
82
+ *
83
+ * - `malformed` a dotenv line that is not `NAME=value`, a blank or a `#` comment; JSON that is not one object;
84
+ * - `quoted` a dotenv value that starts with a quote: no quoting is the grammar, and a manager that quotes
85
+ * (or an operator who did) would otherwise hand the container the quotes as part of the value;
86
+ * - `not-string` a JSON value that is not a string (a number, a nested object), named by its variable;
87
+ * - `bad-name` a name that is not an environment variable name (ENV_NAME), or `__proto__`; not named;
88
+ * - `duplicate` one name twice: which value wins would be the parser's choice, never the operator's;
89
+ * - `empty-value` an empty value: a set-but-empty variable is how a broken manager reads as configured;
90
+ * - `nul` a value with a NUL, which `-e NAME=VALUE` would silently cut;
91
+ * - `value-too-large` a value over `MAX_SECRET_BYTES`;
92
+ * - `too-many` over `SECRET_BUNDLE_VARS_MAX` variables;
93
+ * - `empty` no variable at all: exit 0 that grants nothing is the silent-no-op class `runResolver` refuses
94
+ * for a single value, and an operator who emptied an environment by accident must hear it.
95
+ *
96
+ * DOTENV IS STRICT: `NAME=value` per line, the value verbatim to the end of the line, no quoting, no `export `, no
97
+ * spaces around `=`, no expansion, no continuation. Every dotenv dialect differs on exactly those, and a parser
98
+ * that accepts one dialect's quotes silently mangles another's values. One trailing `\r` per line is removed, so a
99
+ * resolver on Windows (CRLF) reads as it was written; a value that needs a `\r`, a newline or a leading quote goes
100
+ * through `json`, which is what that format is for (PEMs and other multi-line values).
101
+ */
102
+ export function parseBundleOutput(text, format) {
103
+ const vars = {};
104
+ const seen = new Set();
105
+ const add = (name, value, line = null) => {
106
+ if (typeof name !== "string" || !ENV_NAME.test(name) || name === "__proto__") return { failure: "bad-name", line };
107
+ if (seen.has(name)) return { failure: "duplicate", name };
108
+ if (typeof value !== "string") return { failure: "not-string", name };
109
+ if (value === "") return { failure: "empty-value", name };
110
+ if (value.includes("\u0000")) return { failure: "nul", name };
111
+ if (Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) return { failure: "value-too-large", name };
112
+ seen.add(name);
113
+ if (seen.size > SECRET_BUNDLE_VARS_MAX) return { failure: "too-many" };
114
+ // An own data property whatever the name (the charset is checked above, `__proto__` refused): never `vars[name] =`.
115
+ Object.defineProperty(vars, name, { value, enumerable: true, writable: true, configurable: true });
116
+ return null;
117
+ };
118
+ if (format === "json") {
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(text);
122
+ } catch {
123
+ return { failure: "malformed" };
124
+ }
125
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { failure: "malformed" };
126
+ // A non-string value first, so a duplicate key INSIDE a nested object is reported as what it is (a value that is
127
+ // not a string), never as a duplicate variable named after a key no variable has.
128
+ const nested = Object.keys(parsed).find((name) => typeof parsed[name] !== "string");
129
+ if (nested !== undefined) return ENV_NAME.test(nested) && nested !== "__proto__" ? { failure: "not-string", name: nested } : { failure: "bad-name" };
130
+ // JSON keeps the LAST of two equal keys, so a duplicate would be a value silently won: the triggers file's rule.
131
+ const duplicate = findDuplicateKey(text);
132
+ if (duplicate) return ENV_NAME.test(duplicate.key) ? { failure: "duplicate", name: duplicate.key } : { failure: "bad-name" };
133
+ for (const name of Object.keys(parsed)) {
134
+ const bad = add(name, parsed[name]);
135
+ if (bad) return bad.failure === "bad-name" ? { failure: "bad-name" } : bad;
136
+ }
137
+ } else if (format === "dotenv") {
138
+ const lines = text.split("\n");
139
+ for (let i = 0; i < lines.length; i++) {
140
+ const raw = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
141
+ if (/^[ \t]*$/.test(raw) || /^[ \t]*#/.test(raw)) continue;
142
+ const eq = raw.indexOf("=");
143
+ if (eq <= 0) return { failure: "malformed", line: i + 1 };
144
+ const name = raw.slice(0, eq);
145
+ const value = raw.slice(eq + 1);
146
+ if (!ENV_NAME.test(name)) return { failure: name.trim() !== name || name.startsWith("export ") ? "malformed" : "bad-name", line: i + 1 };
147
+ if (value.startsWith('"') || value.startsWith("'")) return { failure: "quoted", name };
148
+ // A carriage return left after the one a CRLF line ends with is part of no dotenv value anyone means to write;
149
+ // a value that needs one goes through json.
150
+ if (value.includes("\r")) return { failure: "malformed", line: i + 1 };
151
+ const bad = add(name, value, i + 1);
152
+ if (bad) return bad;
153
+ }
154
+ } else {
155
+ // Unreachable through the declaration parsers, which refuse any other format at boot or overlay read.
156
+ return { failure: "malformed" };
157
+ }
158
+ if (seen.size === 0) return { failure: "empty" };
159
+ return { vars };
160
+ }
161
+
162
+ /**
163
+ * Build the bundle seam the processor injects: `{ lookup, resolve }`.
164
+ *
165
+ * - `lookup(profile, { overlayBundles })` answers, with no spawn, whether this host can serve the profile:
166
+ * `{ ok }`, `{ profileUnknown }` or `{ ambiguous }`. FREE, so the processor asks it before the mint, with the
167
+ * other free refusals (CONST-BUDGET-BEFORE-TOKENS); its rule is `makeSecretsResolver`'s lookup verbatim (own
168
+ * names only, the realpath probe, an overlay path bounded by PI_SECRET_RESOLVER_ROOTS, a name declared in both
169
+ * sources ambiguous).
170
+ * - `resolve(job, binding, { signal, overlayBundles })` runs the resolver ONCE for `binding.ref` and returns
171
+ * `{ ok, vars }`, `{ profileUnknown }`, `{ ambiguous }`, `{ reserved: name }`, `{ unresolved, failure, ... }`
172
+ * or `{ unreachable, failure, ... }`. `unresolved` and `unreachable` are `true`, never a name: one call holds
173
+ * every variable, so there is no single variable to blame for an exit code.
174
+ *
175
+ * `envBundles` is parsed at BOOT (config.mjs `secretBundleProfiles`), `overlayBundles` per job, `secretProfiles`'
176
+ * split and its reason. NOTHING IS ZEROED, `makeSecretsResolver`'s honest half, and the same property holds: the
177
+ * returned map is a local of `runJob`, never attached to the job, never logged, never in a result or a record
178
+ * (the record carries names, rebuilt in run-history.mjs).
179
+ */
180
+ export function makeBundleResolver({ envBundles = {}, roots = [], forwardEnv = [], timeoutMs = 10000, spawnFn = spawn, hostEnv = process.env, realExecutablePath = realExecutable, log = () => {} } = {}) {
181
+ function lookup(profile, { overlayBundles = {} } = {}) {
182
+ const merged = mergeSecretProfiles(envBundles, overlayBundles);
183
+ if (merged.ambiguous) return { ambiguous: merged.ambiguous };
184
+ const declared = Object.hasOwn(merged.profiles, profile) ? merged.profiles[profile] : undefined;
185
+ if (declared === null || typeof declared !== "object" || typeof declared.path !== "string") return { profileUnknown: profile };
186
+ let path;
187
+ try {
188
+ path = realExecutablePath(declared.path);
189
+ } catch {
190
+ path = null;
191
+ }
192
+ if (!path) return { profileUnknown: profile };
193
+ // The bound on the REALPATH, in the worker, for env-declared profiles exempt: secrets.mjs gives the reasons.
194
+ if (!Object.hasOwn(envBundles, profile) && !withinRoots(path, roots)) return { profileUnknown: profile };
195
+ return { ok: true, path, format: declared.format };
196
+ }
197
+
198
+ async function resolve(job, binding, { signal, overlayBundles = {} } = {}) {
199
+ const found = lookup(binding?.profile, { overlayBundles });
200
+ if (!found.ok) return found;
201
+ const outcome = await runResolver(spawnFn, found.path, binding.ref, { timeoutMs, hostEnv, signal, maxBytes: MAX_BUNDLE_BYTES });
202
+ if (outcome.failure) {
203
+ // The bundle ID, our own failure vocabulary, the script's small integer exit and a byte COUNT; never the
204
+ // reference, the path, or the resolver's words.
205
+ log("secret_bundle_resolve_failed", { bundle: binding.id ?? null, failure: outcome.failure, detail: outcome.detail, code: outcome.code ?? null, stderrBytes: outcome.stderrBytes });
206
+ const shape = { failure: outcome.detail, code: outcome.code ?? null, stderrBytes: outcome.stderrBytes };
207
+ return outcome.failure === "infra" ? { unreachable: true, ...shape } : { unresolved: true, ...shape };
208
+ }
209
+ const parsed = parseBundleOutput(outcome.value, found.format);
210
+ if (parsed.failure) {
211
+ log("secret_bundle_resolve_failed", { bundle: binding.id ?? null, failure: "policy", detail: parsed.failure, name: parsed.name ?? null, line: parsed.line ?? null });
212
+ return { unresolved: true, failure: parsed.failure, name: parsed.name ?? null, line: parsed.line ?? null, code: 0, stderrBytes: outcome.stderrBytes };
213
+ }
214
+ // The reserved names, all three sets `run.secrets` is held to, asked of the WHOLE bundle: the statically
215
+ // knowable set the triggers file refuses at load (it cannot see a bundle's names, which exist only once read),
216
+ // this job's provider credential variables, and PI_FORWARD_ENV. A REFUSAL naming the first one in the
217
+ // resolver's order, never a drop (see the header), and never resolved by ordering: buildContainerEnv writes
218
+ // the provider credential BEFORE the secrets loop, so a bundle carrying ANTHROPIC_API_KEY would silently
219
+ // replace the key every job of the project spends (secrets.mjs records the class).
220
+ const reserved = new Set([...RESERVED_ENV_NAMES, ...providerKeyCandidates(job?.provider), ...forwardEnv]);
221
+ const clash = Object.keys(parsed.vars).find((name) => reserved.has(name) || bundleLoaderName(name));
222
+ if (clash !== undefined) return { reserved: clash };
223
+ return { ok: true, vars: parsed.vars };
224
+ }
225
+
226
+ return { lookup, resolve };
227
+ }
228
+
229
+ /**
230
+ * Merge a bundle's variables with the trigger's own `run.secrets` values (issue #704). `run.secrets` WINS a name
231
+ * clash: it is the narrower grant, written for this one trigger in the reviewed file, and the bundle is the
232
+ * project's default. The shadowed names are returned, sorted, for the record, so an override is visible rather
233
+ * than inferred. Own properties throughout, so no name can reach a prototype.
234
+ */
235
+ export function mergeBundleSecrets(bundleVars = {}, triggerSecrets = {}) {
236
+ const merged = {};
237
+ const define = (name, value) => Object.defineProperty(merged, name, { value, enumerable: true, writable: true, configurable: true });
238
+ const shadowed = [];
239
+ for (const [name, value] of Object.entries(bundleVars)) {
240
+ if (Object.hasOwn(triggerSecrets, name)) shadowed.push(name);
241
+ else define(name, value);
242
+ }
243
+ for (const [name, value] of Object.entries(triggerSecrets)) define(name, value);
244
+ return { secrets: merged, shadowed: shadowed.sort() };
245
+ }
@@ -60,17 +60,27 @@ export function parseSecretProfiles(raw) {
60
60
  if (!PROFILE_NAME.test(name)) {
61
61
  throw configError(`PI_SECRET_PROFILES profile name ${JSON.stringify(name)} may use letters, digits, dot, dash and underscore only`);
62
62
  }
63
- if (name in profiles) {
63
+ if (Object.hasOwn(profiles, name)) {
64
64
  throw configError(`PI_SECRET_PROFILES declares ${JSON.stringify(name)} twice -- one of the two is not the resolver you think is running`);
65
65
  }
66
66
  if (path === "" || !isAbsolutePath(path)) {
67
67
  throw configError(`PI_SECRET_PROFILES profile ${JSON.stringify(name)} needs an ABSOLUTE path to its resolver -- a service manager's working directory is not your shell's, so a relative path is a different file on every host`);
68
68
  }
69
- profiles[name] = path;
69
+ defineProfile(profiles, name, path);
70
70
  }
71
71
  return profiles;
72
72
  }
73
73
 
74
+ /**
75
+ * OWN properties only, here and in every lookup over a profile table (issue #704 review). The charset admits
76
+ * `toString`, `constructor` and `__proto__`, so `name in table` answers "declared" for a name nobody wrote,
77
+ * and `table[name] = path` with `__proto__` sets the object's prototype instead of declaring a profile. A
78
+ * defined property is an own, enumerable data property whatever the name.
79
+ */
80
+ function defineProfile(table, name, path) {
81
+ Object.defineProperty(table, name, { value: path, enumerable: true, writable: true, configurable: true });
82
+ }
83
+
74
84
  /** Absolute on this platform, accepting a Windows drive letter or UNC root as `service.mjs` does. */
75
85
  function isAbsolutePath(path) {
76
86
  return isAbsolute(path) || /^([A-Za-z]:[\\/]|\\\\)/.test(path);
@@ -92,7 +102,7 @@ function isAbsolutePath(path) {
92
102
  */
93
103
  export function mergeSecretProfiles(envProfiles = {}, overlayProfiles = {}) {
94
104
  for (const name of Object.keys(overlayProfiles)) {
95
- if (name in envProfiles) return { ambiguous: name };
105
+ if (Object.hasOwn(envProfiles, name)) return { ambiguous: name };
96
106
  }
97
107
  return { profiles: { ...envProfiles, ...overlayProfiles } };
98
108
  }
@@ -118,3 +128,93 @@ export function withinRoots(candidate, roots = []) {
118
128
  return target === base || target.startsWith(base + sep);
119
129
  });
120
130
  }
131
+
132
+ /**
133
+ * The profiles a host would actually resolve, for its capability row and for doctor (issue #704): every
134
+ * env-declared profile, plus each overlay-declared one whose resolver is an executable file whose REALPATH
135
+ * sits inside `roots`. That is the lookup `secrets.mjs -> makeSecretsResolver` makes per job, so a host
136
+ * never advertises an overlay profile it would answer `secret-profile-unknown` for, and never hides one it
137
+ * would serve.
138
+ *
139
+ * `realExecutablePath` is REQUIRED and injected, which keeps this module fs-free: the worker passes
140
+ * `secrets.mjs -> realExecutable`, the probe its resolver uses, and that module is not imported here
141
+ * because it loads pi through `env-allowlist.mjs`.
142
+ *
143
+ * Env profiles are taken as declared, with no probe, which is what the row always published: doctor is
144
+ * where a missing env resolver is reported, and probing it here would change an existing host's row.
145
+ *
146
+ * `{ ambiguous }` when a name is declared in both sources (`mergeSecretProfiles`). The resolver then
147
+ * refuses EVERY secrets job on this host, whatever profile it names, so the caller advertises no secret
148
+ * profile at all rather than one it would refuse.
149
+ */
150
+ export function servableSecretProfiles({ envProfiles = {}, overlayProfiles = {}, roots = [], realExecutablePath }) {
151
+ const merged = mergeSecretProfiles(envProfiles, overlayProfiles);
152
+ if (merged.ambiguous) return { ambiguous: merged.ambiguous };
153
+ const profiles = { ...envProfiles };
154
+ for (const [name, declared] of Object.entries(overlayProfiles)) {
155
+ if (typeof declared !== "string") continue;
156
+ let path;
157
+ try {
158
+ path = realExecutablePath(declared);
159
+ } catch {
160
+ path = null;
161
+ }
162
+ if (path && withinRoots(path, roots)) defineProfile(profiles, name, declared);
163
+ }
164
+ return { profiles };
165
+ }
166
+
167
+ /**
168
+ * The output formats a bundle resolver may declare (issue #704). DECLARED, never sniffed: `{"A":"1"}` is one
169
+ * malformed dotenv line and `A=1` is malformed JSON, so a sniffer would have to pick, and the pick would change
170
+ * the day a manager prints a JSON-looking value. The operator says which one their script prints.
171
+ */
172
+ export const BUNDLE_FORMATS = Object.freeze(["dotenv", "json"]);
173
+
174
+ /**
175
+ * Parse `PI_SECRET_BUNDLES`: `name:/abs/path:format,other:/abs/other:format` (issue #704). Returns
176
+ * `{ [name]: { path, format } }`, empty when unset. Throws (config-tagged, so the worker refuses to boot) on
177
+ * anything malformed, `parseSecretProfiles`' doctrine and for its reason.
178
+ *
179
+ * ITS OWN NAMESPACE, never a flag on a `PI_SECRET_PROFILES` entry. The two scripts answer different questions
180
+ * (one prints one value for one reference, the other prints a whole environment) and are called with different
181
+ * caps and parsers: a profile reachable as a bundle would have its single value parsed as dotenv and refused
182
+ * as `malformed` on every job, and a bundle reachable as a profile would put its whole environment into ONE
183
+ * variable. Two variables keep each script reachable only as what it was written to be, the reason
184
+ * `PI_WAIT_PROFILES` is separate from both.
185
+ *
186
+ * The NAME splits on the FIRST colon and the FORMAT on the LAST one, so `shop:C:\pi\shop.cmd:json` keeps its
187
+ * drive letter (the hazard `parseSecretProfiles` states). The format is REQUIRED: a missing one is a boot
188
+ * refusal naming the two choices, never a default, because a default would be the sniffing above with a
189
+ * slower fuse (the first operator whose script prints JSON gets every job refused as `malformed`).
190
+ */
191
+ export function parseSecretBundleProfiles(raw) {
192
+ if (raw === undefined || raw === null || String(raw).trim() === "") return {};
193
+ const bundles = {};
194
+ for (const entry of String(raw).split(",")) {
195
+ const text = entry.trim();
196
+ if (text === "") continue;
197
+ const cut = text.indexOf(":");
198
+ const last = text.lastIndexOf(":");
199
+ if (cut <= 0 || last === cut) {
200
+ throw configError(`PI_SECRET_BUNDLES entries must be name:/absolute/path:format (format ${BUNDLE_FORMATS.join(" or ")}), got ${JSON.stringify(text)}`);
201
+ }
202
+ const name = text.slice(0, cut).trim();
203
+ const path = text.slice(cut + 1, last).trim();
204
+ const format = text.slice(last + 1).trim();
205
+ if (!PROFILE_NAME.test(name)) {
206
+ throw configError(`PI_SECRET_BUNDLES profile name ${JSON.stringify(name)} may use letters, digits, dot, dash and underscore only`);
207
+ }
208
+ if (!BUNDLE_FORMATS.includes(format)) {
209
+ throw configError(`PI_SECRET_BUNDLES profile ${JSON.stringify(name)} must end in :dotenv or :json, the format its resolver prints -- it is never guessed from the output`);
210
+ }
211
+ if (Object.hasOwn(bundles, name)) {
212
+ throw configError(`PI_SECRET_BUNDLES declares ${JSON.stringify(name)} twice -- one of the two is not the resolver you think is running`);
213
+ }
214
+ if (path === "" || !isAbsolutePath(path)) {
215
+ throw configError(`PI_SECRET_BUNDLES profile ${JSON.stringify(name)} needs an ABSOLUTE path to its resolver -- a service manager's working directory is not your shell's, so a relative path is a different file on every host`);
216
+ }
217
+ Object.defineProperty(bundles, name, { value: Object.freeze({ path, format }), enumerable: true, writable: true, configurable: true });
218
+ }
219
+ return bundles;
220
+ }
package/src/secrets.mjs CHANGED
@@ -84,8 +84,12 @@ export function secretsArmed(job) {
84
84
  *
85
85
  * An unrecognised exit code is INFRA on `decideRetry`'s own reasoning: a runner that exits with something
86
86
  * we do not recognise is one we cannot reason about, and retrying-then-alerting beats accepting it as done.
87
+ *
88
+ * EXPORTED for the bundle resolver (issue #704, `secret-bundles.mjs`), which is this same seam with a larger
89
+ * `maxBytes` (a whole environment, not one value): one spawn, one exit mapping, one timeout and one abort, so a
90
+ * fix to any of them reaches both rather than one of two copies.
87
91
  */
88
- function runResolver(spawnFn, path, reference, { timeoutMs, hostEnv, signal }) {
92
+ export function runResolver(spawnFn, path, reference, { timeoutMs, hostEnv, signal, maxBytes = MAX_SECRET_BYTES }) {
89
93
  return new Promise((resolve) => {
90
94
  let child;
91
95
  try {
@@ -149,7 +153,7 @@ function runResolver(spawnFn, path, reference, { timeoutMs, hostEnv, signal }) {
149
153
 
150
154
  child.stdout?.on("data", (d) => {
151
155
  bytes += d.length;
152
- if (bytes > MAX_SECRET_BYTES) {
156
+ if (bytes > maxBytes) {
153
157
  overflowed = true;
154
158
  stop("overflow");
155
159
  return;
@@ -196,6 +200,15 @@ function stripOneNewline(text) {
196
200
  return text;
197
201
  }
198
202
 
203
+ /** The resolver's executable probe: the realpath when it is an executable file, else null. Throws when
204
+ * the path does not resolve, which every caller folds into "not usable". Exported so the capability row
205
+ * (`servableSecretProfiles`, start.mjs) and doctor ask the question this resolver asks, not a copy of it. */
206
+ export function realExecutable(p) {
207
+ const real = realpathSync(p);
208
+ const st = statSync(real);
209
+ return st.isFile() && (st.mode & 0o111) !== 0 ? real : null;
210
+ }
211
+
199
212
  /**
200
213
  * Build the pre-spend resolver the processor injects.
201
214
  *
@@ -235,11 +248,7 @@ export function makeSecretsResolver({
235
248
  // follows symlinks -- so `lstat` would refuse an ordinary /usr/local/bin/op -> ../Cellar/... The mode is
236
249
  // deliberately NOT checked here: a group- or world-writable resolver is a doctor WARNING, on the
237
250
  // env-setup precedent, because refusing a job over a mode bit at job time is a new class of refusal.
238
- realExecutablePath = (p) => {
239
- const real = realpathSync(p);
240
- const st = statSync(real);
241
- return st.isFile() && (st.mode & 0o111) !== 0 ? real : null;
242
- },
251
+ realExecutablePath = realExecutable,
243
252
  log = () => {},
244
253
  } = {}) {
245
254
  return async function resolveSecrets(job, { signal, overlayProfiles = {} } = {}) {
@@ -247,7 +256,8 @@ export function makeSecretsResolver({
247
256
  if (merged.ambiguous) return { ambiguous: merged.ambiguous };
248
257
 
249
258
  const wanted = typeof job?.secretsProfile === "string" && job.secretsProfile !== "" ? job.secretsProfile : DEFAULT_SECRETS_PROFILE;
250
- const declared = merged.profiles[wanted];
259
+ // Own properties only: the charset admits `toString` and `constructor`, which every object inherits.
260
+ const declared = Object.hasOwn(merged.profiles, wanted) ? merged.profiles[wanted] : undefined;
251
261
  // A LOOKUP, never a fallback. There is deliberately no "if only one profile is declared, use it"
252
262
  // rule: env-allowlist.mjs records what the last `else` of that shape cost, and a default whose
253
263
  // meaning changes the day a second profile appears is that mistake with a slower fuse.
@@ -267,7 +277,7 @@ export function makeSecretsResolver({
267
277
  // executable on the host". An env-declared profile is exempt: PI_SECRET_PROFILES already lives
268
278
  // beside the forge tokens and the App key, so requiring roots for it would bound nothing and would
269
279
  // break every deployment that never wanted panel authoring.
270
- const fromOverlay = !(wanted in envProfiles);
280
+ const fromOverlay = !Object.hasOwn(envProfiles, wanted);
271
281
  if (fromOverlay && !withinRoots(path, roots)) return { profileUnknown: wanted };
272
282
 
273
283
  const references = job?.secrets ?? {};
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * Nothing here loads a `.env` into a process (`docs/secrets.md`): the result is an object the caller judges from.
23
23
  */
24
- import { constants as fsConstants } from "node:fs";
24
+ import { constants as fsConstants, readFileSync } from "node:fs";
25
25
  import { userInfo } from "node:os";
26
26
  import { dirname, join } from "node:path";
27
27
  import { SYSTEMD_HAZARD_SHAPES, decodeEnvFile, envFileHazard, readEnvAssignments } from "./env-file.mjs";
@@ -36,6 +36,8 @@ export function serviceEnvLoader(platform) {
36
36
  * The deployment `.env` as the service is judged by (issue #464, gate round 3): decoded from its BYTES with the
37
37
  * hardened reader, and `hazard` set to the first thing the service's loader reads differently from this reader, or
38
38
  * refuses to load (a NUL, invalid UTF-8, an environment too big to exec, a lone CR), as `{ line, what, fix }`, or null.
39
+ * `raw` is the content as given, for a reader that judges the bytes itself (`readValkeyKeys`), since decoding erases
40
+ * invalid UTF-8.
39
41
  */
40
42
  export function serviceEnvFileOf(raw, path, loader) {
41
43
  const { text, loadHazard } = decodeEnvFile(raw, { loader });
@@ -43,9 +45,9 @@ export function serviceEnvFileOf(raw, path, loader) {
43
45
  let hazard = null;
44
46
  if (h !== null) {
45
47
  const shape = h.shape !== undefined ? SYSTEMD_HAZARD_SHAPES[h.shape] : null;
46
- hazard = shape ? { line: h.line, what: `has ${shape.what}${h.detail ? ` (${h.detail})` : ""}`, fix: shape.fix } : { line: h.line, what: "is one this command cannot read the way the service's loader will (an open quote, a continuation, or a line that runs)", fix: "fix that line, then re-run doctor" };
48
+ hazard = shape ? { line: h.line, shape: h.shape, what: `has ${shape.what}${h.detail ? ` (${h.detail})` : ""}`, fix: shape.fix } : { line: h.line, what: "is one this command cannot read the way the service's loader will (an open quote, a continuation, or a line that runs)", fix: "fix that line, then re-run doctor" };
47
49
  }
48
- return { text, path, loader, hazard };
50
+ return { text, raw, path, loader, hazard };
49
51
  }
50
52
 
51
53
  /**
@@ -236,12 +238,43 @@ export function readDeploymentEnv({ dir, fs, uid = process.geteuid?.(), names =
236
238
  * The `.env` of the deployment in `dir`, read the way doctor reads it (a regular file only, as bytes, one read) and
237
239
  * resolved for `keys` (`resolveServiceEnv`). For a caller that has no file of its own in hand (the admin panel).
238
240
  * Returns the resolution plus `path`, `unreadable` (a reason, when something is at the path and could not be read) and
239
- * `untrusted` (`envFileTrust`'s reason); in either case nothing was taken from it.
241
+ * `untrusted` (`envFileTrust`'s reason); in either case nothing was taken from it. `shellRunsLine` is the line a sourcing shell does
242
+ * not read as an assignment, whatever keys were asked for: it can set any of them without spelling one (`. ./more.env`, `eval`), so
243
+ * `hazardSkipped`, which needs the key spelled, says nothing of it. Only for that loader: systemd ignores such a line and the cmd wrapper
244
+ * splits it on its `=`. A CR on a comment line is left out, since the shell reads past it and only a writer of this file minds.
240
245
  */
241
246
  export function deploymentServiceEnv({ env, dir, keys, platform = process.platform, fs, uid = process.geteuid?.() }) {
242
247
  // Issue #471 (gate rounds 1 and 2): one descriptor, and nothing taken from a file another account can change.
243
248
  const { path, bytes, unreadable, untrusted } = readDeploymentEnv({ dir, fs, uid });
244
249
  const raw = untrusted ? null : bytes;
245
250
  const file = raw === null ? null : serviceEnvFileOf(raw, path, serviceEnvLoader(platform));
246
- return { ...resolveServiceEnv({ env, file, keys }), path, unreadable, untrusted, hazard: file?.hazard ?? null };
251
+ const shellRunsLine = file?.loader === "shell" && file.hazard !== null && file.hazard.shape !== "shell-crlf-comment" ? file.hazard.line : null;
252
+ return { ...resolveServiceEnv({ env, file, keys }), path, unreadable, untrusted, hazard: file?.hazard ?? null, shellRunsLine };
253
+ }
254
+
255
+ /**
256
+ * Deployment keys for a CLI verb that acts for the deployment (review of PR #575): `keys` resolved by issue #471's rule
257
+ * (`resolveServiceEnv`, above), the one doctor and `up` read the service's keys by. This shell's value where
258
+ * it sets the key, else the `.env` in `cwd` from a line the service's loader reads as written. Where the two disagree,
259
+ * or a line naming the key cannot be read as the loader reads it, or the file cannot be read and this shell sets none,
260
+ * the answer is unknown and the verb refuses (`problem`), as `up` refuses on a PI_JOB_IMAGE disagreement: a job queued
261
+ * on a queue no worker drains, or from a triggers file the worker does not read, is a silent no-op. Returns
262
+ * `{ env }` (this shell's with the file's values filled in) or `{ problem }`.
263
+ */
264
+ export function cliDeploymentEnv(env, keys, { cwd = process.cwd(), platform = process.platform, readFile = (p) => readFileSync(p) } = {}) {
265
+ const envPath = join(cwd, ".env");
266
+ let file;
267
+ try {
268
+ file = serviceEnvFileOf(readFile(envPath), envPath, serviceEnvLoader(platform));
269
+ } catch (error) {
270
+ const unset = keys.filter((k) => typeof env[k] !== "string");
271
+ if (error?.code !== "ENOENT" && unset.length > 0) return { problem: `${envPath} could not be read (${error?.code ?? "error"}), so ${unset.join(" and ")} cannot be told` };
272
+ return { env };
273
+ }
274
+ const read = resolveServiceEnv({ env, file, keys });
275
+ const [d] = read.disagreements;
276
+ if (d) return { problem: `${d.key} is ${JSON.stringify(d.shell)} in this shell and ${JSON.stringify(d.file)} in ${envPath}: make them agree (the service runs the file's)` };
277
+ const unread = [...read.unread.map((u) => u.key), ...read.hazardSkipped];
278
+ if (unread.length > 0) return { problem: `${envPath} has a line for ${unread.join(" and ")} that the service's loader may read differently, so it cannot be told (pi-dispatch doctor names the line)` };
279
+ return { env: read.env };
247
280
  }