@edgehero/pi-dispatch 4.3.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +118 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +136 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -28,6 +28,7 @@
28
28
  * `redis` is any ioredis-compatible client, injected so the logic is testable without a server.
29
29
  */
30
30
 
31
+ import { createHash } from "node:crypto";
31
32
  import { DAY_TTL_SECONDS, MONTH_TTL_SECONDS, WEEK_TTL_SECONDS, dayKey, monthKey, weekKey } from "./budget.mjs";
32
33
  import { MICROS_PER_USD, optionalUsdMicros } from "./money.mjs";
33
34
 
@@ -366,8 +367,216 @@ export const MODEL_BASIS = Object.freeze(["metered", "floor", "refunded"]);
366
367
  * The run record's `dollars` object (INT-RUN-HISTORY-FILE-CONTRACT): an explicit literal of integers and fixed
367
368
  * tokens. `modelBasis` is how the job's MODEL windows settled (issue #502 part 6): `metered` when every one settled
368
369
  * to its own usage row, `floor` when any kept at least its reservation, `refunded` when they were given back, and
369
- * `null` when the job held no model window.
370
+ * `null` when the job held no model window. `reservedIn` (issue #761) is `deploymentWindowsOf`'s answer, carried
371
+ * only when it is one, so a record of a job that reserved in no deployment window keeps the shape it had.
370
372
  */
371
- export function dollarsRecord({ reservedMicros, settledMicros, basis, modelBasis = null }) {
372
- return { reservedMicros, settledMicros, basis, modelBasis: MODEL_BASIS.includes(modelBasis) ? modelBasis : null };
373
+ export function dollarsRecord({ reservedMicros, settledMicros, basis, modelBasis = null, reservedIn = null }) {
374
+ return { reservedMicros, settledMicros, basis, modelBasis: MODEL_BASIS.includes(modelBasis) ? modelBasis : null, ...(reservedIn ? { reservedIn } : {}) };
375
+ }
376
+
377
+ // ---- which deployment windows a record's money is in (issue #761, the dollar window reset check) ----
378
+ //
379
+ // A dollar counter holds the history a cap is judged against, and Valkey is its only copy: a flushed, replaced or
380
+ // restored Valkey, an evicted key or a hand edit reopens the cap with nothing saying so. A record cannot be bucketed by
381
+ // its own times to find out (the reservation's clock is neither `startedAt` nor `endedAt`, and a window whose cap was
382
+ // set mid-window was never written by the records before it), so a record says which deployment windows its settled
383
+ // amount is in, `dollars.reservedIn`, and doctor sums this host's records per window against the counter
384
+ // (`dollarWindowFloors`). Only the deployment's day, week and month: a scoped, project or model window's key is a hash
385
+ // per ledger, and a job-count window holds no amount a record states.
386
+
387
+ /** The deployment's three dollar windows, in the order a hold reserves them (`activeWindows`). */
388
+ export const DEPLOYMENT_WINDOWS = Object.freeze(["day", "week", "month"]);
389
+
390
+ /** A Valkey endpoint tag: 16 lowercase hex characters (`valkeyEndpointTag`). */
391
+ export const VALKEY_TAG_RE = /^[0-9a-f]{16}$/;
392
+
393
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
394
+ // The inverse of budget.mjs's `dayKey`, `weekKey` and `monthKey` under the deployment's own prefix, exactly: a scoped
395
+ // or model key (`budget:usd:s:<hash>`, `budget:usd:mdl:<hash>`) starts with the same prefix and matches none of them.
396
+ const WINDOW_KEY_RES = Object.freeze({
397
+ day: new RegExp(`^${escapeRe(DOLLAR_KEY_PREFIX)}:(\\d{4}-\\d{2}-\\d{2})$`),
398
+ week: new RegExp(`^${escapeRe(DOLLAR_KEY_PREFIX)}:w:(\\d{4}-\\d{2}-\\d{2})$`),
399
+ month: new RegExp(`^${escapeRe(DOLLAR_KEY_PREFIX)}:m:(\\d{4}-\\d{2})$`),
400
+ });
401
+
402
+ /** Which deployment window `key` is and its bucket id (`{ window, bucket }`, the text after the prefix), or null. */
403
+ export function deploymentWindowOfKey(key) {
404
+ if (typeof key !== "string") return null;
405
+ for (const window of DEPLOYMENT_WINDOWS) {
406
+ const m = key.match(WINDOW_KEY_RES[window]);
407
+ if (m) return { window, bucket: m[1] };
408
+ }
409
+ return null;
410
+ }
411
+
412
+ /**
413
+ * The deployment's three windows at `now`, `{ day, week, month }`, each `{ key, bucket, startMs, endMs, ttlSeconds }`:
414
+ * the key a reservation at `now` writes (budget.mjs's key functions, so a reader and the worker cannot disagree about a
415
+ * boundary), its bucket id, when the bucket begins and ends (UTC: the day, the week from Monday, the month), and the
416
+ * TTL the worker sets on the key's first write.
417
+ */
418
+ export function deploymentWindowsAt(now) {
419
+ const d = new Date(now);
420
+ const y = d.getUTCFullYear();
421
+ const mo = d.getUTCMonth();
422
+ const day = Date.UTC(y, mo, d.getUTCDate());
423
+ const monday = day - ((new Date(day).getUTCDay() + 6) % 7) * 86_400_000;
424
+ const at = (key, startMs, endMs, ttlSeconds) => ({ key, bucket: deploymentWindowOfKey(key)?.bucket ?? null, startMs, endMs, ttlSeconds });
425
+ return {
426
+ day: at(dayKey(d, DOLLAR_KEY_PREFIX), day, Date.UTC(y, mo, d.getUTCDate() + 1), DAY_TTL_SECONDS),
427
+ week: at(weekKey(d, DOLLAR_KEY_PREFIX), monday, monday + 7 * 86_400_000, WEEK_TTL_SECONDS),
428
+ month: at(monthKey(d, DOLLAR_KEY_PREFIX), Date.UTC(y, mo, 1), Date.UTC(y, mo + 1, 1), MONTH_TTL_SECONDS),
429
+ };
430
+ }
431
+
432
+ /**
433
+ * The tag of the Valkey a deployment's counters live in: the first 16 hex characters of SHA-256 over `host:port/db`, so
434
+ * two deployments that share a logs directory and a worker name (the default per-account `~/.pi-dispatch/logs` and the
435
+ * hostname) but not a Valkey never add up each other's records. `endpoint` is a VALKEY_URL or a client's connection
436
+ * options (`{ host, port, db }`, connection.mjs `parseConnection`, which reads them off the URL by the rules below), so
437
+ * the worker, which holds the options its clients connect with, and doctor, which holds the URL it talks to, compute the
438
+ * same tag for the same endpoint. Both take the JUDGED address (`pinnedValkeyUrl`: the literal a local Valkey is pinned
439
+ * to on Linux), the one the worker really dials. A unix socket (`{ path }`, which no client of this project builds
440
+ * today) is tagged by its path. Credentials and the scheme are never part of it; the host is lowercased, an IPv6
441
+ * literal's brackets dropped, and a missing host, port or database is ioredis's and `parseConnection`'s default
442
+ * (127.0.0.1, 6379, 0). Null for anything else (a URL that does not parse, a database that is not a number). A tag that
443
+ * differs where the endpoint is the same (`localhost` against `127.0.0.1` on a host that pins nothing) only leaves
444
+ * records out of the floor, which can miss a reset and never invents one. The console's Spend page tags the
445
+ * VALKEY_URL it reads the counters at as it holds it: its client pins the address on connect but never rewrites the
446
+ * URL. There a tag that differs only leaves a record counted by its end time, as the page counted before (issue #807).
447
+ */
448
+ export function valkeyEndpointTag(endpoint) {
449
+ let host;
450
+ let port;
451
+ let db;
452
+ if (typeof endpoint === "string") {
453
+ let u;
454
+ try {
455
+ u = new URL(endpoint);
456
+ } catch {
457
+ return null;
458
+ }
459
+ host = u.hostname;
460
+ port = u.port;
461
+ db = u.pathname && u.pathname !== "/" ? u.pathname.slice(1) : "";
462
+ } else if (endpoint !== null && typeof endpoint === "object") {
463
+ if (typeof endpoint.path === "string" && endpoint.path !== "") return hash16(`unix:${endpoint.path}`);
464
+ ({ host, port, db } = endpoint);
465
+ } else {
466
+ return null;
467
+ }
468
+ const h = String(host ?? "").replace(/^\[(.*)\]$/, "$1").toLowerCase() || "127.0.0.1";
469
+ const p = port === undefined || port === null || port === "" ? 6379 : Number(port);
470
+ const n = db === undefined || db === null || db === "" ? 0 : Number(db);
471
+ if (!Number.isSafeInteger(p) || p < 0 || !Number.isSafeInteger(n) || n < 0) return null;
472
+ return hash16(`${h}:${p}/${n}`);
473
+ }
474
+
475
+ function hash16(text) {
476
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
477
+ }
478
+
479
+ /**
480
+ * The record's `dollars.reservedIn` for a hold (issue #761): `{ day?, week?, month?, valkey }`, the bucket of each
481
+ * DEPLOYMENT window whose counter holds at least the amount the record states, and the endpoint tag of the Valkey it is
482
+ * in (`valkeyEndpointTag`). Null when there is no such window: a zero hold (no `ledgers`), a hold with no deployment
483
+ * ledger, a malformed tag, or every key below the record. PURE.
484
+ *
485
+ * - `hold` is the hold the settlement or release WALKED (`settleDollars`' `hold`: the job's part for a settle, the
486
+ * whole hold for a release), so `applied` counts along its `keys`. The deployment ledger is the one whose
487
+ * `keyPrefix` is exactly `DOLLAR_KEY_PREFIX`, never a prefix match: every dollar key starts with it.
488
+ * - `applied` is how many of those keys the step completed (0 when no step ran); `target` what this job's share of an
489
+ * adjusted key now is (the settled amount, 0 for a release); `recorded` the record's `settledMicros`.
490
+ *
491
+ * A window is in only when its key holds at least `recorded` of this job's money: `target` for a key the step already
492
+ * moved, the reservation otherwise. That is what keeps doctor's sum below the counter on a healthy deployment where a
493
+ * step stopped part-way: a settle whose fault left later keys at the reservation while the record states a metered cost
494
+ * ABOVE it (an overshoot is charged in full), and a release that gave back the first keys before the fault while the
495
+ * record states the whole reservation. Both leave their window out, which only lowers the floor.
496
+ */
497
+ export function deploymentWindowsOf(hold, { applied = 0, target, recorded, valkey } = {}) {
498
+ if (typeof valkey !== "string" || !VALKEY_TAG_RE.test(valkey)) return null;
499
+ const ledger = (Array.isArray(hold?.ledgers) ? hold.ledgers : []).find((l) => l?.keyPrefix === DOLLAR_KEY_PREFIX);
500
+ if (!ledger || !Array.isArray(ledger.keys)) return null;
501
+ const reserved = hold.amountMicros;
502
+ if (!Number.isSafeInteger(reserved) || !Number.isSafeInteger(recorded) || !Number.isSafeInteger(applied)) return null;
503
+ const walked = Array.isArray(hold.keys) ? hold.keys : [];
504
+ const found = {};
505
+ for (const key of ledger.keys) {
506
+ const at = deploymentWindowOfKey(key);
507
+ if (at === null) continue;
508
+ const i = walked.indexOf(key);
509
+ const holds = i >= 0 && i < applied ? target : reserved;
510
+ if (Number.isSafeInteger(holds) && holds >= recorded) found[at.window] = at.bucket;
511
+ }
512
+ const windows = DEPLOYMENT_WINDOWS.filter((w) => Object.hasOwn(found, w));
513
+ return windows.length === 0 ? null : { ...Object.fromEntries(windows.map((w) => [w, found[w]])), valkey };
514
+ }
515
+
516
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
517
+ const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
518
+ /** `text` as a real UTC calendar date (`2026-02-30` is none), or null. */
519
+ function calendarDate(text) {
520
+ if (typeof text !== "string" || !DATE_RE.test(text)) return null;
521
+ const d = new Date(`${text}T00:00:00.000Z`);
522
+ return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === text ? d : null;
523
+ }
524
+
525
+ /**
526
+ * A record's `dollars.reservedIn` rebuilt from its own keys, or null (INT-RUN-HISTORY-FILE-CONTRACT): `day` a real
527
+ * calendar date, `week` a real date that is a Monday, `month` a real month, at least one of them, and `valkey` a tag
528
+ * (`VALKEY_TAG_RE`). Anything else, a member of the wrong shape included, is null as a whole: a record that carries one
529
+ * no release wrote says nothing trustworthy about where its money is. Only OWN keys are read, so a value inherited
530
+ * through the prototype is no member. The one rule the record writer, doctor's sum and the console's Spend page
531
+ * (admin `spendSummaryOf`, issue #807) read it by.
532
+ */
533
+ export function reservedInOf(value) {
534
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
535
+ const own = (key) => (Object.hasOwn(value, key) ? value[key] : undefined);
536
+ const valkey = own("valkey");
537
+ if (typeof valkey !== "string" || !VALKEY_TAG_RE.test(valkey)) return null;
538
+ const out = {};
539
+ const day = own("day");
540
+ if (day !== undefined) {
541
+ if (calendarDate(day) === null) return null;
542
+ out.day = day;
543
+ }
544
+ const week = own("week");
545
+ if (week !== undefined) {
546
+ if (calendarDate(week)?.getUTCDay() !== 1) return null;
547
+ out.week = week;
548
+ }
549
+ const month = own("month");
550
+ if (month !== undefined) {
551
+ if (typeof month !== "string" || !MONTH_RE.test(month)) return null;
552
+ out.month = month;
553
+ }
554
+ return Object.keys(out).length === 0 ? null : { ...out, valkey };
555
+ }
556
+
557
+ /**
558
+ * Per deployment window in `buckets` (`{ day?, week?, month? }`, each the CURRENT bucket id), the micro-dollars this
559
+ * host's own records settled into it: the sum of `dollars.settledMicros` over the records whose `host` is `host`, whose
560
+ * `reservedIn` (`reservedInOf`) names that bucket for that window, and whose `valkey` is `valkey`. PURE.
561
+ *
562
+ * The counter is at least this sum on a healthy deployment: a reservation adds R, a settlement adds S - R and the record
563
+ * carrying S is written after it, a refunded record is 0, and nothing else lowers a key (`reservedIn` already leaves out
564
+ * a window a step stopped part-way in). Everything that is not counted (another host's records, a record from before
565
+ * `reservedIn`, an earlier attempt a retry overwrote, a hold that crashed, a record over the read's size cap, retention
566
+ * shorter than the window) makes the sum smaller, never larger. So a counter below it lost history.
567
+ */
568
+ export function dollarWindowFloors(records, { host, valkey, buckets }) {
569
+ const floors = {};
570
+ for (const w of DEPLOYMENT_WINDOWS) if (typeof buckets?.[w] === "string") floors[w] = 0;
571
+ for (const r of Array.isArray(records) ? records : []) {
572
+ if (r === null || typeof r !== "object" || r.host !== host) continue;
573
+ const d = r.dollars;
574
+ if (d === null || typeof d !== "object" || !Number.isSafeInteger(d.settledMicros) || d.settledMicros < 0) continue;
575
+ const at = reservedInOf(d.reservedIn);
576
+ if (at === null || at.valkey !== valkey) continue;
577
+ for (const w of Object.keys(floors)) {
578
+ if (at[w] === buckets[w] && Number.isSafeInteger(floors[w] + d.settledMicros)) floors[w] += d.settledMicros;
579
+ }
580
+ }
581
+ return floors;
373
582
  }
@@ -0,0 +1,100 @@
1
+ /** Endpoint occupancy is a display of the admission map, never another source of admission state (#775). */
2
+ import { isPerMachineHost } from "./backends.mjs";
3
+ import { HOST_SET, HOST_TTL_MS, hostKey } from "./host-registry.mjs";
4
+ import { MODEL_ENDPOINT_ID_RE, MAX_SLOTS } from "./model-endpoints.mjs";
5
+ import { WORKER_NAME_RE } from "./worker-name.mjs";
6
+
7
+ export const ENDPOINT_STATUS_VERSION = 1;
8
+ export const ENDPOINT_STATUS_MAX = 32;
9
+ export const ENDPOINT_STATUS_HOSTS_MAX = 64;
10
+ const integer = (n) => Number.isSafeInteger(n) && n >= 0;
11
+
12
+ /** Re-read the loaded file and the SAME map both queue processors claim on every registry beat. */
13
+ export function endpointStatusFields(endpoints, slots, multiHost) {
14
+ const rows = [...(endpoints ?? [])].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
15
+ const listed = rows.slice(0, ENDPOINT_STATUS_MAX).map((ep) => ({ id: ep.id, slots: ep.slots, used: slots.count(ep.id), fleet: multiHost && !isPerMachineHost(ep.host) ? 1 : 0 }));
16
+ return { endpointsVersion: ENDPOINT_STATUS_VERSION, endpointsStartedAt: ({ startedAt } = {}) => startedAt ?? "", endpoints: listed.length ? JSON.stringify(listed) : "", endpointsMore: Math.max(0, rows.length - listed.length) };
17
+ }
18
+
19
+ /** Unknown versions/fields are never interpreted as an empty host. No path, address or operator text survives. */
20
+ export function parseEndpointStatus(row) {
21
+ const absent = { state: "not-reported", endpoints: null, endpointsMore: null };
22
+ if (!row || row.endpointsVersion === undefined || row.endpointsVersion === "") return absent;
23
+ if (String(row.endpointsVersion) !== String(ENDPOINT_STATUS_VERSION)) return { ...absent, state: /^\d+$/.test(String(row.endpointsVersion)) && Number(row.endpointsVersion) > ENDPOINT_STATUS_VERSION ? "unsupported" : "unreadable" };
24
+ // HSET preserves fields unknown to an older worker. Its new startedAt must invalidate old occupancy.
25
+ if (!/^(?:0|[1-9]\d*)$/.test(String(row.endpointsStartedAt)) || String(row.endpointsStartedAt) !== String(row.startedAt)) return { ...absent, state: "unreadable" };
26
+ if (row.endpoints === undefined) return absent;
27
+ const more = /^(?:0|[1-9]\d*)$/.test(String(row.endpointsMore)) ? Number(row.endpointsMore) : NaN;
28
+ if (!integer(more)) return { ...absent, state: "unreadable" };
29
+ if (row.endpoints === "" && more === 0) return { ...absent, endpointsMore: 0 };
30
+ try {
31
+ if (typeof row.endpoints !== "string" || row.endpoints.length > 8192) throw Error();
32
+ const entries = JSON.parse(row.endpoints);
33
+ if (!Array.isArray(entries) || entries.length > ENDPOINT_STATUS_MAX) throw Error();
34
+ let previous = "";
35
+ const endpoints = entries.map((ep) => {
36
+ if (!ep || typeof ep !== "object" || Array.isArray(ep) || typeof ep.id !== "string" || !MODEL_ENDPOINT_ID_RE.test(ep.id) || ep.id <= previous || !Number.isInteger(ep.slots) || ep.slots < 1 || ep.slots > MAX_SLOTS || !integer(ep.used) || ep.used > MAX_SLOTS || ![0, 1].includes(ep.fleet)) throw Error();
37
+ previous = ep.id;
38
+ return { id: ep.id, slots: ep.slots, used: ep.used, fleet: ep.fleet };
39
+ });
40
+ return { state: "reported", endpoints, endpointsMore: more };
41
+ } catch { return { ...absent, state: "unreadable" }; }
42
+ }
43
+
44
+ function bounded(promise, ms) {
45
+ let timer;
46
+ return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(Error("endpoint status timeout")), ms); })]).finally(() => clearTimeout(timer));
47
+ }
48
+
49
+ /** Read at most 64 rows, known live hosts first, without pruning or touching the registry's other projections. */
50
+ export async function readEndpointStatus(redis, options = {}) {
51
+ let stopped = false;
52
+ try { return await bounded(readEndpointStatusInner(redis, { ...options, stopped: () => stopped }), options.timeoutMs ?? 2500); }
53
+ catch { return { state: "unknown", hosts: [], more: 0, partial: true }; }
54
+ finally { stopped = true; }
55
+ }
56
+
57
+ async function readEndpointStatusInner(redis, { hosts = [], now = () => Date.now(), timeoutMs = 2500, stopped } = {}) {
58
+ let indexed = [];
59
+ let partial = false;
60
+ let cardinality = null;
61
+ try {
62
+ cardinality = await bounded(redis.scard(HOST_SET), timeoutMs);
63
+ if (!integer(cardinality)) throw Error();
64
+ let cursor = "0";
65
+ for (let page = 0; page < 4 && !stopped(); page++) {
66
+ const answer = await bounded(redis.sscan(HOST_SET, cursor, "COUNT", ENDPOINT_STATUS_HOSTS_MAX), timeoutMs);
67
+ if (!Array.isArray(answer) || typeof answer[0] !== "string" || !Array.isArray(answer[1])) throw Error();
68
+ indexed.push(...answer[1].slice(0, ENDPOINT_STATUS_HOSTS_MAX));
69
+ if (answer[1].length > ENDPOINT_STATUS_HOSTS_MAX) partial = true;
70
+ cursor = answer[0];
71
+ if (cursor === "0") break;
72
+ if (page === 3) partial = true;
73
+ }
74
+ } catch { partial = true; }
75
+ const valid = (name) => typeof name === "string" && WORKER_NAME_RE.test(name);
76
+ if (indexed.some((name) => !valid(name))) partial = true;
77
+ const known = [...new Set(hosts.map((h) => typeof h === "string" ? h : h?.name).filter(valid))].sort();
78
+ const names = [...new Set([...known, ...indexed.filter(valid).sort()])];
79
+ const more = Math.max(0, Math.max(cardinality ?? 0, names.length) - Math.min(names.length, ENDPOINT_STATUS_HOSTS_MAX));
80
+ const selected = names.slice(0, ENDPOINT_STATUS_HOSTS_MAX);
81
+ if (!selected.length) return { state: partial ? "unknown" : "reported", hosts: [], more, partial: partial || more > 0 };
82
+ if (stopped()) return { state: "unknown", hosts: [], more, partial: true };
83
+ const rows = await Promise.all(selected.map(async (name) => {
84
+ let row;
85
+ let ttl;
86
+ try {
87
+ [row, ttl] = await bounded(Promise.all([redis.hgetall(hostKey(name)), redis.pttl(hostKey(name))]), timeoutMs);
88
+ } catch { partial = true; return { name, at: null, ageMs: null, state: "unreadable", endpoints: null, endpointsMore: null }; }
89
+ if (!row || Object.keys(row).length === 0 || ttl === -2) {
90
+ return known.includes(name) ? { name, at: null, ageMs: null, state: "not-reported", endpoints: null, endpointsMore: null } : null;
91
+ }
92
+ const at = /^(?:0|[1-9]\d*)$/.test(String(row.beatAt)) && integer(Number(row.beatAt)) ? Number(row.beatAt) : null;
93
+ const ageMs = Number.isSafeInteger(ttl) && ttl >= 0 && ttl <= HOST_TTL_MS ? HOST_TTL_MS - ttl : null;
94
+ const parsed = parseEndpointStatus(row);
95
+ if (ageMs === null || row.name !== name) { parsed.state = "unreadable"; parsed.endpoints = null; partial = true; }
96
+ if (["unsupported", "unreadable"].includes(parsed.state) || parsed.endpointsMore > 0) partial = true;
97
+ return { name, at, ageMs, ...parsed };
98
+ }));
99
+ return { state: partial && !rows.filter(Boolean).length ? "unknown" : "reported", hosts: rows.filter(Boolean), more, partial: partial || more > 0 };
100
+ }
@@ -1,3 +1,4 @@
1
+ // env-internal PI_SUBSCRIPTION_AUTH: host-approved access snapshot metadata, never an operator override.
1
2
  /**
2
3
  * Build the EXACT environment a job container receives. Never a pass-through.
3
4
  *
@@ -29,6 +30,7 @@ import { homedir } from "node:os";
29
30
  import { join } from "node:path";
30
31
  import { findEnvKeys } from "@earendil-works/pi-ai/compat";
31
32
  import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
33
+ import { codemodeWorkersOf, DEFAULT_JOB_SIZE } from "./job-size.mjs";
32
34
  import { egressEnv } from "./egress.mjs";
33
35
  import { RECEIVER_ONLY_VARS, forgeSpec } from "./forges.mjs";
34
36
  import { apiKeyVariable } from "./provider-key.mjs";
@@ -351,6 +353,13 @@ function resolveEnvName(provider) {
351
353
  return apiKeyVariable(providerKeyCandidates(provider));
352
354
  }
353
355
 
356
+ /** PI_JOB_LAYOUT's value for `layout`: absent for the default, the name for `sandbox`, refused for anything else. */
357
+ function layoutEnv(layout) {
358
+ if (layout === "mounts") return undefined;
359
+ if (layout === "sandbox") return "sandbox";
360
+ throw new Error(`buildContainerEnv: unknown job layout ${JSON.stringify(layout)} (want "mounts" or "sandbox")`);
361
+ }
362
+
354
363
  /**
355
364
  * Assemble the container env. `hostEnv` is the worker's process.env; `job` carries the resolved
356
365
  * config and the per-job scoped token (GitHub-backed jobs, and local cron jobs that opted in via
@@ -367,18 +376,25 @@ function resolveEnvName(provider) {
367
376
  * `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
368
377
  * caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
369
378
  */
370
- export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], awaitInput = false, allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
379
+ export function buildContainerEnv({ size = DEFAULT_JOB_SIZE, provider, secondaryProvider = null, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], packageCanaryPath = undefined, forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], excludeExtensions = [], excludeExtensionTools = [], excludeToolsStrict = false, mcpServers = [], awaitInput = false, approvalGates = null, approvalDecision = null, reportProgress = false, recover = false, recovering = false, steer = false, layout = "mounts", allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false, transcriptStderr = false, steerStdin = false, bundleEnvFile = false, observableRunner = false, fork = null, thinkingLevel = null, subscriptionAuth = [] }) {
371
380
  // The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
372
381
  // PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
373
382
  // neither source yields one, which the processor turns into a policy refusal that refunds any reserve
374
383
  // (issue #310); the same call is made by its free credential gate, before anything is reserved at all.
375
384
  // `modelEndpoints` is the pickup's snapshot (issue #503): the keyless branch answers `PI_DISPATCH_KEYLESS` in this
376
385
  // map, so the variable is set exactly when the gate passed keyless and is absent on every other job.
377
- const credEnv = resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile, forwardEnv, modelEndpoints });
386
+ const selectedAuth = subscriptionAuth.find((entry) => entry.provider === provider);
387
+ const credEnv = selectedAuth ? selectedAuth.env : resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile, forwardEnv, modelEndpoints });
388
+ // A reviewed watch classifier may use another provider. Resolve only that provider, never a blanket key copy.
389
+ const secondaryAuth = subscriptionAuth.find((entry) => entry.provider === secondaryProvider);
390
+ const secondaryCredEnv = secondaryAuth ? secondaryAuth.env : secondaryProvider && secondaryProvider !== provider ? resolveProviderCredential({ provider: secondaryProvider, hostEnv, authFromPi, agentDir, readFile, forwardEnv: [], modelEndpoints }) : {};
378
391
 
379
392
  const env = {
380
393
  PI_PROVIDER: provider,
381
394
  PI_MODEL: model,
395
+ PI_FORK_ENTRY: recovering === true ? undefined : fork?.fromEntry,
396
+ PI_FORK_POSITION: recovering === true ? undefined : fork?.position,
397
+ PI_THINKING_LEVEL: thinkingLevel ?? undefined,
382
398
  PI_MAX_TURNS: String(maxTurns),
383
399
  // The optional per-job token budget (issue #25). Absent/null => variable omitted (docker-run skips
384
400
  // undefined), so the runner attaches a pure meter with no cap. Never an empty string.
@@ -400,9 +416,14 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
400
416
  // never PI_PACKAGES=. The delimiter is ":" because these are CONTAINER (POSIX) paths -- never the
401
417
  // host's path.delimiter, which is ";" on Windows.
402
418
  PI_PACKAGES: packagePaths.length > 0 ? packagePaths.join(":") : undefined,
403
- // The persisted transcript inside the /session mount (REQ-RESUMABLE-SESSION). Emitted ONLY when
404
- // this job actually has one -- absent means the runner builds pi's ephemeral in-memory session,
405
- // which is every job before this feature and every job whose trigger did not arm run.resume. Never
419
+ // Issue #796: which of those paths is a canary's candidate, so the runner can say whether it loaded. Only on a canary job.
420
+ PI_PACKAGE_CANARY: packageCanaryPath,
421
+ // The transcript file inside the /session mount (REQ-RESUMABLE-SESSION). Emitted ONLY when this job
422
+ // actually has one -- absent means the runner builds pi's ephemeral in-memory session. Since issue #643
423
+ // every prepared job has one (a resume job's staged transcript, or every other job's empty live file,
424
+ // which the worker tails for the live stream and, since issue #644, for run progress), with one exception: with
425
+ // both PI_LIVE_TRANSCRIPTS=0 and PI_RUN_PROGRESS=0 no live file is staged (start.mjs hands the preparer
426
+ // `stageLive: null`), so a non-resume job then has none, as before the feature. Otherwise absence is a caller that staged none, or a staging that failed. Never
406
427
  // an empty string, for PI_PACKAGES' reason: an empty value is a third state neither side reads the
407
428
  // same way, and the one reading a container must not have to infer which was meant.
408
429
  PI_SESSION_FILE: sessionFile || undefined,
@@ -429,6 +450,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
429
450
  // second-validator rule directly above). Absent means the full pinned default set, never an empty
430
451
  // string, for PI_PACKAGES' reason.
431
452
  PI_EXCLUDE_TOOLS: excludeTools.length > 0 ? excludeTools.join(",") : undefined,
453
+ // The trigger's run.excludeExtensions and run.excludeExtensionTools (issue #800): the overlay names the runner leaves out
454
+ // before any factory runs, and the tools it adds to the list above. Comma-safe for that list's reason (the loader's grammar
455
+ // holds no comma). Absent means none, never an empty string, for PI_PACKAGES' reason. PI_EXCLUDE_TOOLS_STRICT is "1" only
456
+ // where a name of the second list that no staged package or overlay extension brought can only be wrong (run-container.mjs says when).
457
+ PI_EXCLUDE_EXTENSIONS: excludeExtensions.length > 0 ? excludeExtensions.join(",") : undefined,
458
+ PI_EXCLUDE_EXTENSION_TOOLS: excludeExtensionTools.length > 0 ? excludeExtensionTools.join(",") : undefined,
459
+ PI_EXCLUDE_TOOLS_STRICT: excludeToolsStrict === true ? "1" : undefined,
432
460
  // The trigger's run.mcp (REQ-MCP-OPERATOR-SERVERS): the overlay servers the job's session connects, so the
433
461
  // runner can hold `/job/mcp.json` to the names the worker meant. Comma-safe for excludeTools' reason (the loader
434
462
  // admits only lowercase letters, digits and `-`). Absent means none, never an empty string.
@@ -436,6 +464,26 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
436
464
  // The trigger's run.awaitInput (issue #625): "1" offers the job the tool a parked run ends on. Absent means not
437
465
  // armed, never "0" or an empty string, for PI_PACKAGES' reason.
438
466
  PI_AWAIT_INPUT: awaitInput === true ? "1" : undefined,
467
+ PI_APPROVAL_GATES: approvalGates ? JSON.stringify(approvalGates) : undefined,
468
+ // Human text travels in the existing read-only event file, never the gateway's logged exec argv.
469
+ PI_APPROVAL_DECISION: approvalDecision ? JSON.stringify({ requestId: approvalDecision.requestId, action: approvalDecision.action, toolCallId: approvalDecision.toolCallId, decision: approvalDecision.decision }) : undefined,
470
+ // The trigger's run.progress (issue #644): "1" offers the job the report_progress tool. Absent means not armed,
471
+ // PI_AWAIT_INPUT's rule.
472
+ PI_REPORT_PROGRESS: reportProgress === true ? "1" : undefined,
473
+ // Issue #645, durable runs, two inputs the runner reads apart. PI_RECOVER: the trigger armed run.recover, so the
474
+ // runner keeps checkpoints a lost run can continue from. PI_RECOVERING: THIS container is that continuation, so
475
+ // the runner resumes the checkpoint instead of starting the prompt over. Each "1" or absent, PI_AWAIT_INPUT's rule;
476
+ // strictly `=== true`, so a truthy non-boolean an upstream bug passed arms nothing.
477
+ PI_RECOVER: recover === true ? "1" : undefined,
478
+ PI_RECOVERING: recovering === true ? "1" : undefined,
479
+ // Issue #646: the trigger armed run.steer, so the runner delivers the files the worker writes into its read-only
480
+ // /inbox mount. "1" or absent, PI_AWAIT_INPUT's rule, strictly `=== true` for PI_RECOVER's reason.
481
+ PI_STEER: steer === true ? "1" : undefined,
482
+ // Issue #683: which file layout the runner reads (worker/src/job-layout.mjs), set only for a venue that copies the
483
+ // job's files (`sandbox`). Absent is `mounts`, so every binding venue's env is byte-identical to one built before
484
+ // the variable existed. Any other value is the caller's bug and is refused rather than written: the runner would
485
+ // refuse it too, but only inside a container the budget already paid for.
486
+ PI_JOB_LAYOUT: layoutEnv(layout),
439
487
  // The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
440
488
  // PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
441
489
  // (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
@@ -453,6 +501,7 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
453
501
 
454
502
  // The provider credential(s), under pi's expected variable name(s), so pi's own auth resolution finds them.
455
503
  Object.assign(env, credEnv);
504
+ Object.assign(env, secondaryCredEnv);
456
505
 
457
506
  // Operator-declared extra vars (PI_FORWARD_ENV), forwarded by EXACT name -- the allowlist
458
507
  // no-broad-env-into-container prescribes, not a host pass-through. This is how a CUSTOM provider's
@@ -494,9 +543,20 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
494
543
  // PI_SESSION_FILE and PI_FLOW already keep. The resolver guarantees non-empty; this is the defense in
495
544
  // depth at the DI seam that the empty-token guard keeps for the mint.
496
545
  for (const [name, value] of Object.entries(secrets ?? {})) {
546
+ if (secondaryProvider && providerKeyCandidates(secondaryProvider).includes(name)) throw configError(`watch classifier credential variable ${name} is reserved`);
497
547
  if (typeof value === "string" && value !== "") env[name] = value;
498
548
  }
499
549
 
550
+ // An explicit subscription binding is authoritative, including against host API keys and trigger secrets.
551
+ // Only access snapshots cross this boundary; the runner receives closed metadata to check before loading code.
552
+ if (subscriptionAuth.length > 0) {
553
+ for (const snapshot of subscriptionAuth) {
554
+ for (const name of providerKeyCandidates(snapshot.provider)) delete env[name];
555
+ Object.assign(env, snapshot.env);
556
+ }
557
+ env.PI_SUBSCRIPTION_AUTH = JSON.stringify(subscriptionAuth.map(({ provider, connectionId, adapter, generation, expiresAt, deadlineAt, root, digest }) => ({ provider, connectionId, adapter, generation, expiresAt, deadlineAt, ...(root ? { root, digest } : {}) })));
558
+ }
559
+
500
560
  // The shipped egress policy's variables (REQ-EGRESS-ALLOWLIST), AFTER the PI_FORWARD_ENV loop so a
501
561
  // forwarded name can never override them -- the same ordering, for the same reason, as the minted token
502
562
  // below (and loadConfig refuses those names outright while the policy is armed anyway).
@@ -544,9 +604,39 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
544
604
  // the cap above, and ONLY for a run the worker actually hands a key, so a runner never blocks on a stdin no one
545
605
  // writes. `=== true`, so only run-container's own boolean asks for it.
546
606
  // env-internal PI_EXIT_AUTH: written into the job's closed env map here, never read from the worker's environment.
547
- if (exitAuth === true) env.PI_EXIT_AUTH = "stdin";
607
+ // Issue #686: `stdin-frames` names the stdin frame channel instead, the key as frame 1 and the pipe left open for
608
+ // message frames: only for an attempt run-container armed for stdin steering (`steerStdin`, a copying venue and an
609
+ // image declaring it), and only beside PI_STEER=1, so every other attempt's value stays `stdin`, byte-identical.
610
+ if (exitAuth === true) env.PI_EXIT_AUTH = steerStdin === true && steer === true ? "stdin-frames" : "stdin";
548
611
  else delete env.PI_EXIT_AUTH;
549
612
 
613
+ // Issue #686: the runner mirrors its session file over stderr as signed records, for an attempt run-container armed
614
+ // (a copying venue, an image that declares `transcriptStderr`, and a key). Settled here like PI_EXIT_AUTH, and only
615
+ // beside it: a record is signed with that key, so without one the runner would mirror nothing anyway. `=== true`.
616
+ // env-internal PI_TRANSCRIPT: written into the job's closed env map here, never read from the worker's environment.
617
+ if (transcriptStderr === true && exitAuth === true) env.PI_TRANSCRIPT = "stderr";
618
+ else delete env.PI_TRANSCRIPT;
619
+
620
+ // Issue #704 part 3: the runner reads a project secret bundle's plain variables from the job directory's
621
+ // bundle-env.json, for an attempt run-container armed (a venue that hands them by file, `openshell`, and a bundle
622
+ // with plain variables). Settled after both loops like the two above, so no forwarded or secret value can set it.
623
+ // env-internal PI_BUNDLE_ENV: written into the job's closed env map here, never read from the worker's environment.
624
+ if (bundleEnvFile === true) env.PI_BUNDLE_ENV = "1";
625
+ else delete env.PI_BUNDLE_ENV;
626
+
627
+ // Issue #730: the readable runner node (image/entrypoint.sh), for a venue whose connections are mediated by a broker
628
+ // that reads the caller's memory (OpenShell, both modes), and no other. Settled after both loops like the flags above,
629
+ // so no forwarded or secret value can set it, and deleted otherwise: on every other venue the key holders stay on
630
+ // the exec-only node.
631
+ // env-internal PI_RUNNER_OBSERVABLE: written into the job's closed env map here, never read from the worker's environment.
632
+ if (observableRunner === true) env.PI_RUNNER_OBSERVABLE = "1";
633
+ else delete env.PI_RUNNER_OBSERVABLE;
634
+
635
+ // Issue #693: only the resolved container size chooses this concurrency limit. Set after forwarding and secrets
636
+ // so even a caller bypassing their reservation cannot replace it with a host or trigger value.
637
+ // env-internal PI_CODEMODE_MAX_WORKERS: written into the job's closed env map here, never read from the worker's environment.
638
+ env.PI_CODEMODE_MAX_WORKERS = String(codemodeWorkersOf(size.memMiB));
639
+
550
640
  // Issue #500: the runner sets these in its own environment, for its child processes, and no value from outside
551
641
  // may arrive first. Both lists refuse them upstream (config.mjs for PI_FORWARD_ENV, the triggers loader for
552
642
  // run.secrets, through RUNNER_ENV_NAMES); this is the line that holds if either refusal is ever bypassed.
package/src/env-file.mjs CHANGED
@@ -775,7 +775,7 @@ function keyAlreadySet(text, key, platform) {
775
775
  * a `# KEY=` comment inside a multi-line value, each left the key unset while the writer reported it written.
776
776
  * `verify` lets a caller add its own condition on the new text.
777
777
  */
778
- function planEnvEdit(raw, path, key, value, { overwrite = false, platform = process.platform, verify } = {}) {
778
+ export function planEnvEdit(raw, path, key, value, { overwrite = false, platform = process.platform, verify } = {}) {
779
779
  const refusal = envFileEditRefusal(raw, path);
780
780
  if (refusal !== null) return { error: refusal };
781
781
  const text = typeof raw === "string" ? raw : Buffer.from(raw).toString("utf8");
@@ -2065,14 +2065,16 @@ export function decodeEnvFile(content, { loader = "systemd" } = {}) {
2065
2065
  * effect, and the reader and writer name it rather than let an operator believe it does anything. PI_ENV_SETUP is on
2066
2066
  * both lists: it is unit configuration and never honoured from the file. The cmd list is compared ignoring case, as
2067
2067
  * `set` does. Kept beside the wrappers' text by a test, which fails when either wrapper's own variables change.
2068
+ * PI_WRAPPER_NO_DOTENV (issue #708) joins both: the console's unit sets it, the wrappers test it BEFORE the load and
2069
+ * unset it after, so a `.env` line naming it can neither start nor stop the load.
2068
2070
  */
2069
2071
  export const WRAPPER_INTERNAL_KEYS = Object.freeze({
2070
- shell: Object.freeze(["PI_ENV_SETUP", "env_setup", "signaled", "child", "rc"]),
2071
- cmd: Object.freeze(["PI_ENV_SETUP", "ENV_SETUP", "RC", "ERRORLEVEL"]),
2072
+ shell: Object.freeze(["PI_ENV_SETUP", "PI_WRAPPER_NO_DOTENV", "env_setup", "signaled", "child", "rc"]),
2073
+ cmd: Object.freeze(["PI_ENV_SETUP", "PI_WRAPPER_NO_DOTENV", "ENV_SETUP", "RC", "ERRORLEVEL"]),
2072
2074
  });
2073
2075
 
2074
2076
  /** What a line assigning one of the wrapper's own variables is, and what to do, in the words doctor and the writer use. */
2075
- const WRAPPER_INTERNAL_WHAT = (name) => `assigns ${name}, a variable the service wrapper keeps for itself (deploy/worker-env-wrapper.sh on macOS, .cmd on Windows) and assigns again after loading this file, so the line has no effect${/^pi_env_setup$/i.test(name) ? "; PI_ENV_SETUP is never honoured from this file" : ""}`;
2077
+ const WRAPPER_INTERNAL_WHAT = (name) => `assigns ${name}, a variable the service wrapper keeps for itself (deploy/worker-env-wrapper.sh on macOS, .cmd on Windows) and assigns again after loading this file, so the line has no effect${/^pi_env_setup$/i.test(name) ? "; PI_ENV_SETUP is never honoured from this file" : /^pi_wrapper_no_dotenv$/i.test(name) ? "; PI_WRAPPER_NO_DOTENV is unit configuration (the console's service sets it) and never honoured from this file" : ""}`;
2076
2078
  const WRAPPER_INTERNAL_FIX = "remove the line (a setup script is named with pi-dispatch service install --env-setup <path>, never in .env)";
2077
2079
 
2078
2080
  /** A `envFileWrapperInternal` finding as one sentence: the line, what it is, and the fix. */
package/src/envelope.mjs CHANGED
@@ -290,7 +290,7 @@ function jobPathCandidates(path, cwd, env, statSync) {
290
290
  * to write instead, when there is one. The FILE must exist (the check runs with a successful load) and must have one
291
291
  * link: a hard link elsewhere is the same file under a name this check cannot see, and it could sit in a job path.
292
292
  */
293
- function canonicalEnvelopePath(path, realpath, statSync) {
293
+ function canonicalEnvelopePath(path, realpath, statSync, directory = false) {
294
294
  const text = String(path);
295
295
  if (!isAbsolute(text)) throw configError(`the envelope path ${JSON.stringify(text)} must be absolute: write its canonical path, with no symlink, "." or ".." in it`);
296
296
  let real;
@@ -304,6 +304,8 @@ function canonicalEnvelopePath(path, realpath, statSync) {
304
304
  }
305
305
  const stat = statSync(text, { bigint: true });
306
306
  // Before the link count: a directory's nlink counts its subdirectories, so "has 3 hard links" would misname it.
307
+ // `directory`: the console's device store is a folder, judged by the same identity rules. A folder has no link count to speak of.
308
+ if (directory && stat.isDirectory()) return text;
307
309
  if (!stat.isFile()) {
308
310
  throw configError(`the envelope path ${JSON.stringify(text)} is not a regular file; point it at the envelope file itself`);
309
311
  }
@@ -366,8 +368,8 @@ export function fileIdentity(stat) {
366
368
  * relative, and when a path cannot be read for a reason other than its absence. `realpathSync`, `statSync`, `cwd` and
367
369
  * `env` are injectable for tests.
368
370
  */
369
- export function envelopeInsideJobPaths(envelopePath, { cronFolders = [], runRoots = [], skillsDirs = [], globalPiDir = null } = {}, { realpathSync = fsRealpathSync.native, statSync = fsStatSync, cwd = process.cwd(), env = process.env } = {}) {
370
- const real = canonicalEnvelopePath(envelopePath, realpathSync, statSync);
371
+ export function envelopeInsideJobPaths(envelopePath, { cronFolders = [], runRoots = [], skillsDirs = [], globalPiDir = null } = {}, { realpathSync = fsRealpathSync.native, statSync = fsStatSync, cwd = process.cwd(), env = process.env, directory = false } = {}) {
372
+ const real = canonicalEnvelopePath(envelopePath, realpathSync, statSync, directory);
371
373
  // A relative run root is refused under an envelope, as `PI_GLOBAL_PI_DIR` and `run.skillsDir` already must be
372
374
  // absolute: the worker would judge it against its own working directory, while the admin's `dispatch_run` resolves
373
375
  // it in the operator's pi process, so the two could mean different folders.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The re-check a confirmed write makes right before its rename (issue #642): the files the change was BUILT from, as
3
+ * `{ path, text }` snapshots taken before an operator's confirm (the admin's `writeInputs`, stored in the console's
4
+ * ticket), compared with the files as they are now. A difference refuses, with nothing written, so a confirm applies
5
+ * exactly what was previewed and a second session's write in between is a refusal, never a silent lost update.
6
+ *
7
+ * One encoding, the admin's `fileSnapshot` (admin/src/read-model.mjs), and a test holds the two to it: the text, `null`
8
+ * for a file that does not exist (ENOENT and only ENOENT), or `UNREADABLE` plus the error code for any other read
9
+ * failure, a string no file's text can equal. A snapshot taken by one and compared by the other must agree, because the
10
+ * console takes it through the admin and the triggers and settings writers compare it here.
11
+ *
12
+ * Pure apart from the reads, and import-free, so `runtime-settings.mjs` (which the admin bundles) can take it.
13
+ */
14
+
15
+ /** What `fileSnapshot` returns for a file that is there but cannot be read: a string no file's text can equal. */
16
+ const UNREADABLE = "\u0000unreadable:";
17
+
18
+ /** A file's text, `null` when it does not exist, or `UNREADABLE` + the error code for any other read failure. */
19
+ export function fileSnapshot(fs, path) {
20
+ try {
21
+ return fs.readFileSync(path, "utf8");
22
+ } catch (e) {
23
+ if (e?.code === "ENOENT") return null;
24
+ return `${UNREADABLE}${typeof e?.code === "string" ? e.code : "error"}`;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * The refusal for the first snapshot in `expect` whose file changed, or null when every one still holds (and when
30
+ * `expect` is not an array: no expectation, nothing to refuse). Every confirmed write says a lost race in these words:
31
+ * the triggers and settings writers here, `writePauseWindows` and `replaceFile` through the package's `./file-expect`.
32
+ *
33
+ * A difference one side of which is a failed read is no lost race, and "another session wrote it" would send a person
34
+ * looking for a session that never ran, so it says what happened. Two failed reads with one code still compare equal: a
35
+ * writer that means to replace the file has read it first, and refuses there.
36
+ */
37
+ export function expectRefusal(fs, expect) {
38
+ if (!Array.isArray(expect)) return null;
39
+ for (const input of expect) {
40
+ const now = fileSnapshot(fs, input?.path);
41
+ if (now === input?.text) continue;
42
+ const failed = [now, input?.text].find((snapshot) => typeof snapshot === "string" && snapshot.startsWith(UNREADABLE));
43
+ if (failed !== undefined) return `${input?.path} could not be read (${failed.slice(UNREADABLE.length)}) when this change was checked against it; nothing was written. Look again and retry`;
44
+ return `${input?.path} changed after this change was built from it (another session wrote it); nothing was written. Look again and retry`;
45
+ }
46
+ return null;
47
+ }