@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
@@ -0,0 +1,188 @@
1
+ import { cleanTouch, touchScopeOf } from "./touch-state.mjs";
2
+ /** Host orchestration of durable approvals. No queue field can manufacture a system expiry decision: it must match
3
+ * the private store and the one deterministic expiry job. All refusals happen before the caller's spend gates. */
4
+ import { approvalActor, approvalAudit, approvalReply, approvalRequestId, validateApprovalConfig } from "./approval-policy.mjs";
5
+
6
+ export const APPROVAL_EXPIRED_COMMENT = "The approval request expired and was declined. The run will continue with that decision.";
7
+ export const APPROVAL_REVOKED_COMMENT = "The approval request was withdrawn because its trigger policy changed. No continuation was started.";
8
+ export const approvalExpiryJobId = (requestId) => `approval-expiry-${requestId}`;
9
+ const failed = (out) => out?.error ? { error: out.error } : { refused: typeof out?.refused === "string" && out.refused.startsWith("approval-") ? out.refused : "approval-state-conflict" };
10
+
11
+ /** checkPolicy(job) returns {ok:true,digest}; enqueueExpiry(request) must use approvalExpiryJobId(request.requestId).
12
+ * The comment hook receives (request, fixedText); it owns forge delivery/idempotence, never a model summary. */
13
+ export function makeApprovalRuntime({ store, checkPolicy, now = () => Date.now(), host, hostQueue = null, requireSessionDigest = false, enqueueExpiry, comment = async () => {}, touchStore = null, touchOutbox = null } = {}) {
14
+ if (!store || typeof checkPolicy !== "function" || typeof host !== "string" || !host) throw new TypeError("approval runtime needs store, checkPolicy and host");
15
+ // `{ newer: true }` (issue #761) passes through untouched: the triggers file was written by a newer pi-dispatch, so
16
+ // this build cannot judge the declaration either way. It is neither a refusal nor a changed policy, so no request is
17
+ // cancelled, declined, decided or claimed on it; the processor defers the job (`newer-release`) and the sweep skips it.
18
+ const policy = async (job, request = null) => {
19
+ let out;
20
+ try { out = await checkPolicy(job); } catch { return { refused: "approval-policy-unavailable" }; }
21
+ if (out?.newer === true) return { newer: true };
22
+ if (!out?.ok) return failed(out);
23
+ if (request && (out.digest !== request.configDigest || !job?.gates?.includes(request.action))) return { refused: "approval-policy-changed" };
24
+ return out;
25
+ };
26
+ const waitWriter = touchOutbox ?? (touchStore ? { begin: typeof touchStore.begin === "function" ? (event) => touchStore.begin(event) : async (event) => ({ event }), applied: (intent) => touchStore.write(intent.event), abort: (intent) => touchStore.abort?.(intent.event) } : null);
27
+ const waitEvent = (request, reason, at = now()) => {
28
+ if (reason === "approval-expired") {
29
+ if (request.decision?.actor?.kind !== "system" || request.decision.actor.id !== "expiry") return null;
30
+ at = Number.isSafeInteger(request.expiresAt) ? Math.min(request.expiresAt, request.decision.at) : request.decision.at;
31
+ } else if (Number.isSafeInteger(request.decision?.at)) {
32
+ at = request.decision.actor?.kind === "system" && request.decision.actor.id === "expiry" && Number.isSafeInteger(request.expiresAt) ? Math.min(request.expiresAt, request.decision.at) : request.decision.at;
33
+ }
34
+ return cleanTouch({ type: "wait-end", reason, runId: request.runId, at, parkedAt: request.createdAt, scopeId: touchScopeOf(request.resumeJob, host), nonce: request.requestId });
35
+ };
36
+ const beginWait = async (event) => {
37
+ if (!waitWriter || !event) return null;
38
+ try { return await waitWriter.begin(event); } catch { return { event, error: "touch-index-unavailable" }; }
39
+ };
40
+ const publishWait = async (request, reason, intent = null, at = now()) => {
41
+ const event = waitEvent(request, reason, at);
42
+ if (!waitWriter || !event) return null;
43
+ try {
44
+ intent ??= await beginWait(event);
45
+ const sent = await waitWriter.applied({ ...intent, event });
46
+ return sent?.ok ? null : "touch-tracking-incomplete";
47
+ } catch { return "touch-tracking-incomplete"; }
48
+ };
49
+ const terminalMove = async (request, reason, move) => {
50
+ const at = now();
51
+ // Persist intent before the approval transition. A later publication fault repairs only this fact,
52
+ // never repeats the cancellation, decline or paid continuation that already happened.
53
+ const intent = await beginWait(cleanTouch({ type: "wait-end", reason, runId: request.runId, at, parkedAt: request.createdAt, scopeId: touchScopeOf(request.resumeJob, host), nonce: request.requestId }));
54
+ const result = await move();
55
+ if (!result.ok) { try { if (intent) await waitWriter.abort(intent); } catch {} return result; }
56
+ const touchWarning = await publishWait(result.request, reason, intent, at);
57
+ return touchWarning ? { ...result, touchWarning } : result;
58
+ };
59
+ return {
60
+ finish: ({ requestId, jobId } = {}) => store.finish({ requestId, jobId }),
61
+ async before(job) {
62
+ let configInvalid = false;
63
+ if (job.gates !== undefined || job.gateTimeout !== undefined || job.gateBudgetRaiseUsd !== undefined) {
64
+ try { validateApprovalConfig(job); } catch { configInvalid = true; }
65
+ }
66
+ const found = await store.find(job);
67
+ if (!found.ok) return failed(found);
68
+ let request = found.request;
69
+ if (!request) {
70
+ if (job.approvalRequestId) return { refused: "approval-request-stale" };
71
+ if (configInvalid) return { refused: "approval-config-invalid" };
72
+ return job.gates !== undefined ? policy(job) : { ok: true };
73
+ }
74
+ if (request.host !== host) return { refused: "approval-host-mismatch" };
75
+ if (requireSessionDigest && !/^[a-f0-9]{64}$/.test(request.sessionDigest ?? "")) return { refused: "approval-session-unavailable" };
76
+ if (job.approvalRequestId && job.approvalRequestId !== request.requestId) return { refused: "approval-request-stale" };
77
+ if (typeof job.id !== "string" || !job.id) return { refused: "approval-job-id-missing" };
78
+ if (job.approvalRequestId !== request.requestId) return { refused: job.trigger?.comment ? "approval-request-unbound" : "approval-pending" };
79
+ // The reply and the initiating trigger are independent declarations. Keeping the comment rule must
80
+ // not preserve an approval whose original label/PR rule was revoked, including requests without a deadline.
81
+ const initiating = await policy(request.resumeJob, request);
82
+ const checked = initiating.ok ? await policy(job, request) : initiating;
83
+ if (!checked.ok) {
84
+ if (checked.refused !== "approval-policy-changed") return checked;
85
+ const cancelled = await terminalMove(request, "approval-policy-changed", () => store.cancel({ requestId: request.requestId, jobId: job.id }));
86
+ return cancelled.ok ? { refused: "approval-policy-changed", request: cancelled.request, audit: approvalAudit(cancelled.request), ...(cancelled.touchWarning ? { touchWarning: cancelled.touchWarning } : {}) } : failed(cancelled);
87
+ }
88
+ if (configInvalid) return { refused: "approval-config-invalid" };
89
+ const expiryJob = job.id === approvalExpiryJobId(request.requestId) && job.approvalRequestId === request.requestId && job.trigger?.deliveryId === `expiry-${request.requestId}`;
90
+ if (expiryJob) {
91
+ if (request.decision?.actor?.kind !== "system" || request.decision?.actor?.id !== "expiry" || request.decision?.decision !== "declined") return { refused: "approval-expiry-invalid" };
92
+ await publishWait(request, "approval-expired");
93
+ } else {
94
+ if (request.decision?.actor?.kind === "system") { const touchWarning = await publishWait(request, "approval-expired"); return { refused: "approval-expired", ...(touchWarning ? { touchWarning } : {}) }; }
95
+ if (!job.trigger?.comment || typeof job.trigger.comment.body !== "string") return { refused: "approval-pending" };
96
+ if (job.approvalRequestId !== request.requestId) return { refused: "approval-request-unbound" };
97
+ const actor = approvalActor(job);
98
+ if (!actor) return { refused: "approval-author-missing" };
99
+ const touch = { runId: request.runId, cause: "approval-decision", at: now(), parkedAt: request.createdAt, scopeId: touchScopeOf(request.resumeJob, host), nonce: job.id };
100
+ const writer = touchOutbox ?? (touchStore?.begin ? { begin: (event) => touchStore.begin(event), applied: (intent) => touchStore.write(intent.event), abort: (intent) => touchStore.abort(intent.event) } : null);
101
+ const intent = writer ? await writer.begin(touch) : null;
102
+ const decided = await store.decide({ requestId: request.requestId, decisionId: job.id, ...approvalReply(job.trigger.comment.body, { phrase: job.trigger?.matched?.phrase }), actor });
103
+ if (!decided.ok) { if (intent) await writer.abort(intent); return failed(decided); }
104
+ request = decided.request;
105
+ // A human racing the exact deadline may cause the store to persist an expiry decline. Only its own
106
+ // synthetic job may consume it; otherwise both the human job and the sweep could start containers.
107
+ if (request.decision.actor.kind === "system") { if (intent) await writer.abort(intent); const touchWarning = await publishWait(request, "approval-expired"); return { refused: "approval-expired", ...(touchWarning ? { touchWarning } : {}) }; }
108
+ const event = cleanTouch({ ...touch, at: request.decision.at, nonce: request.decision.decisionId });
109
+ if (intent) await writer.applied({ ...intent, event });
110
+ else if (touchStore) await touchStore.write(event);
111
+
112
+ }
113
+ const claimed = await store.claim({ requestId: request.requestId, jobId: job.id });
114
+ if (!claimed.ok) return failed(claimed);
115
+ request = claimed.request;
116
+ return {
117
+ ok: true,
118
+ decision: { requestId: request.requestId, action: request.action, toolCallId: request.toolCallId, decision: request.decision.decision, text: request.decision.text },
119
+ request,
120
+ audit: approvalAudit(request),
121
+ ...(request.action === "budget-raise" && request.decision.decision === "approved" ? { budgetCapUsd: job.gateBudgetRaiseUsd } : {}),
122
+ };
123
+ },
124
+ async afterPark({ job, session, metadata, rawJob = job } = {}) {
125
+ if (!metadata || !job?.gates?.includes(metadata.action) || typeof metadata.toolCallId !== "string" || !session?.key || typeof job.id !== "string") return { refused: "approval-park-invalid" };
126
+ if (requireSessionDigest && !/^[a-f0-9]{64}$/.test(session.digest ?? "")) return { refused: "approval-session-unavailable" };
127
+ const checked = await policy(job);
128
+ if (!checked.ok) return checked;
129
+ const requestId = approvalRequestId(job.id, metadata.toolCallId);
130
+ const prior = await store.read(requestId);
131
+ if (!prior.ok) return failed(prior);
132
+ if (prior.request) {
133
+ if (prior.request.runId !== job.id || prior.request.action !== metadata.action || prior.request.configDigest !== checked.digest || prior.request.sessionKey !== session.key || prior.request.host !== host || prior.request.sessionDigest !== session.digest) return { refused: "approval-request-conflict" };
134
+ return { ...prior, audit: approvalAudit(prior.request) };
135
+ }
136
+ const createdAt = now();
137
+ const resumeJob = { ...rawJob };
138
+ for (const field of ["id", "maxCostMicros", "approvalDecision", "approvalRequestId"]) delete resumeJob[field];
139
+ const out = await store.publish({
140
+ requestId, runId: job.id, kind: job.kind, repo: job.repo,
141
+ target: job.target, action: metadata.action, toolCallId: metadata.toolCallId,
142
+ configDigest: checked.digest, sessionKey: session.key, host, ...(hostQueue !== null ? { hostQueue } : {}), createdAt,
143
+ ...(session.digest !== undefined ? { sessionDigest: session.digest } : {}),
144
+ ...(job.gateTimeout !== undefined ? { expiresAt: createdAt + job.gateTimeout * 86400_000 } : {}),
145
+ resumeJob,
146
+ });
147
+ return out.ok ? { ...out, audit: approvalAudit(out.request) } : failed(out);
148
+ },
149
+ async sweep(limit = 100) {
150
+ const due = await store.due(limit, host);
151
+ if (!due.ok) return failed(due);
152
+ let enqueued = 0;
153
+ const failures = [];
154
+ for (const id of due.requestIds) {
155
+ const read = await store.read(id);
156
+ if (!read.ok) { failures.push({ requestId: id, ...failed(read) }); continue; }
157
+ if (!read.request || read.request.host !== host) continue;
158
+ const checked = await policy(read.request.resumeJob, read.request);
159
+ // A newer triggers file (issue #761): neither expired nor revoked here. Left due, so the next sweep asks again,
160
+ // and the build that reads the file decides it.
161
+ if (checked.newer) continue;
162
+ if (!checked.ok) {
163
+ if (checked.refused === "approval-policy-changed") {
164
+ const cancelled = await terminalMove(read.request, "approval-policy-changed", () => store.cancel({ requestId: id, jobId: approvalExpiryJobId(id) }));
165
+ if (!cancelled.ok) failures.push({ requestId: id, ...failed(cancelled) });
166
+ else {
167
+ try { await comment(cancelled.request, APPROVAL_REVOKED_COMMENT); } catch { failures.push({ requestId: id, error: "approval-expiry-comment-failed" }); }
168
+ }
169
+ } else failures.push({ requestId: id, ...checked });
170
+ continue;
171
+ }
172
+ const expired = await terminalMove(read.request, "approval-expired", () => store.expire(id));
173
+ if (!expired.ok) {
174
+ if (expired.refused !== "already-decided" && expired.refused !== "request-stale") failures.push({ requestId: id, ...failed(expired) });
175
+ continue;
176
+ }
177
+ try {
178
+ if (typeof enqueueExpiry !== "function") { failures.push({ requestId: id, refused: "approval-expiry-queue-unavailable" }); continue; }
179
+ const queued = await enqueueExpiry(expired.request);
180
+ if (queued?.error || queued?.refused || queued?.ok === false) { failures.push({ requestId: id, ...failed(queued) }); continue; }
181
+ enqueued++;
182
+ await comment(expired.request, APPROVAL_EXPIRED_COMMENT);
183
+ } catch { failures.push({ requestId: id, error: "approval-expiry-enqueue-failed" }); }
184
+ }
185
+ return { ok: failures.length === 0, enqueued, failures };
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Free, fail-closed approval transcript preflight (issue #649). An approval cannot cold-start: before credentials,
3
+ * clone or reservation, its canonical session must still end on the exact unanswered request. This intentionally
4
+ * validates only a narrow raw shape, not pi's projection algorithm. The runner checks the pinned SDK's projection
5
+ * again on the staged file. Descriptor reads and path identity rechecks prevent a swapped name deciding this gate.
6
+ */
7
+ import * as nodeFs from "node:fs";
8
+ import { createHash } from "node:crypto";
9
+ import { join } from "node:path";
10
+ import { SESSION_KEY_PATTERN } from "./session-key.mjs";
11
+ import { hasEmptyCompaction } from "./session-store.mjs";
12
+ import { MODEL_REF_PATTERN } from "./model-ref.mjs";
13
+
14
+ const REFUSED = Object.freeze({ refused: "approval-session-unavailable" });
15
+ const dirIdentity = (stat) => `${stat.dev}:${stat.ino}`;
16
+ const fileIdentity = (stat) => `${dirIdentity(stat)}:${stat.size}:${stat.mtimeMs}`;
17
+
18
+ /** Pure raw-branch check. Fail closed on ambiguity; the SDK's projected context remains a separate later check. */
19
+ export function approvalTranscriptMatches(text, request, { maxAgeDays = 0, now = () => Date.now() } = {}) {
20
+ try {
21
+ const entries = text.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
22
+ const header = entries.shift();
23
+ if (header?.type !== "session" || typeof header.id !== "string" || entries.length === 0 || hasEmptyCompaction(text)) return false;
24
+ if (maxAgeDays > 0) {
25
+ const created = typeof header.timestamp === "string" ? Date.parse(header.timestamp) : NaN;
26
+ if (!Number.isFinite(created) || now() - created > maxAgeDays * 86400_000) return false;
27
+ }
28
+ const byId = new Map();
29
+ for (const entry of entries) {
30
+ if (!entry || typeof entry.id !== "string" || !entry.id || byId.has(entry.id) || !(entry.parentId === null || typeof entry.parentId === "string")) return false;
31
+ byId.set(entry.id, entry);
32
+ }
33
+ const branch = [];
34
+ let leaf = entries.at(-1);
35
+ const seen = new Set();
36
+ while (leaf) {
37
+ if (seen.has(leaf.id)) return false;
38
+ seen.add(leaf.id);
39
+ branch.unshift(leaf);
40
+ if (leaf.parentId === null) break;
41
+ leaf = byId.get(leaf.parentId);
42
+ if (!leaf) return false;
43
+ }
44
+ const messages = branch.filter((entry) => entry.type === "message");
45
+ const target = messages.at(-1);
46
+ const result = target?.message;
47
+ const caller = messages.at(-2)?.message;
48
+ if (result?.role !== "toolResult" || result.toolName !== "request_approval" || result.toolCallId !== request.toolCallId || result.isError === true || result.details?.approval?.action !== request.action || result.details?.approval?.toolCallId !== request.toolCallId) return false;
49
+ if (caller?.role !== "assistant" || !Array.isArray(caller.content) || !caller.content.some((part) => part?.type === "toolCall" && part.name === "request_approval" && part.id === request.toolCallId && part.arguments?.action === request.action)) return false;
50
+ const at = branch.indexOf(target);
51
+ if (branch.some((entry, index) => entry.type === "context_edit" && entry.targetId === target.id || index > at && ["compaction", "branch_summary", "custom_message"].includes(entry.type))) return false;
52
+ return true;
53
+ } catch { return false; }
54
+ }
55
+
56
+ export function makeApprovalSessionCheck({ sessionsDir, maxBytes = 8 * 1024 * 1024, ttlDays = 0, maxAgeDays = 0, maxResumeChain = 0, maxContextPct = null, now = () => Date.now(), fs = nodeFs } = {}) {
57
+ const boundedRead = (path, bound) => {
58
+ const before = fs.lstatSync(path);
59
+ if (!before.isFile() || before.size <= 0 || !Number.isSafeInteger(before.size) || bound > 0 && before.size > bound) throw new Error("unavailable");
60
+ let fd;
61
+ try {
62
+ fd = fs.openSync(path, nodeFs.constants.O_RDONLY | nodeFs.constants.O_NOFOLLOW);
63
+ const opened = fs.fstatSync(fd);
64
+ if (!opened.isFile() || fileIdentity(before) !== fileIdentity(opened)) throw new Error("unavailable");
65
+ const bytes = Buffer.alloc(before.size + 1);
66
+ let count = 0;
67
+ while (count < bytes.length) {
68
+ const read = fs.readSync(fd, bytes, count, bytes.length - count, count);
69
+ if (read === 0) break;
70
+ count += read;
71
+ }
72
+ if (count !== before.size || fileIdentity(fs.fstatSync(fd)) !== fileIdentity(before) || fileIdentity(fs.lstatSync(path)) !== fileIdentity(before)) throw new Error("unavailable");
73
+ const snapshot = bytes.subarray(0, count);
74
+ return { text: snapshot.toString("utf8"), digest: createHash("sha256").update(snapshot).digest("hex"), stat: before };
75
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
76
+ };
77
+ const validate = (request, expected = {}) => {
78
+ try {
79
+ if (typeof sessionsDir !== "string" || !sessionsDir || !SESSION_KEY_PATTERN.test(request?.sessionKey ?? "") || typeof request.action !== "string" || typeof request.toolCallId !== "string") return REFUSED;
80
+ // The configured root may be a link, as in the existing session store. Only a derived key directory
81
+ // must itself be real; the operator's chosen store location is not an attacker-derived pathname.
82
+ const root = fs.statSync(sessionsDir);
83
+ const directory = join(sessionsDir, request.sessionKey);
84
+ const dir = fs.lstatSync(directory);
85
+ if (!root.isDirectory() || !dir.isDirectory()) return REFUSED;
86
+ const read = boundedRead(join(directory, "current.jsonl"), maxBytes);
87
+ if (request.sessionDigest !== undefined && request.sessionDigest !== read.digest) return REFUSED;
88
+ if (ttlDays > 0 && now() - read.stat.mtimeMs > ttlDays * 86400_000) return REFUSED;
89
+ const sidecar = (name) => {
90
+ try { return boundedRead(join(directory, name), 4096).text.trim(); } catch { return null; }
91
+ };
92
+ if (Object.hasOwn(expected, "piVersion") && (typeof expected.piVersion !== "string" || !expected.piVersion || sidecar("pi-version") !== expected.piVersion)) return REFUSED;
93
+ if (Object.hasOwn(expected, "venue")) {
94
+ let stored;
95
+ try { fs.lstatSync(join(directory, "venue")); stored = sidecar("venue"); } catch (error) { stored = error?.code === "ENOENT" ? "local" : null; }
96
+ if (typeof expected.venue !== "string" || !expected.venue || stored !== expected.venue) return REFUSED;
97
+ }
98
+ // Early preflight has no image/model selection yet. Defer this model-sensitive gate until the caller
99
+ // supplies the current pickup's model, preserving the store's deliberate foreign-model exemption.
100
+ if (maxContextPct !== null && (Object.hasOwn(expected, "modelId") || Object.hasOwn(expected, "provider"))) {
101
+ const raw = sidecar("context");
102
+ if (raw !== null) {
103
+ const [rawTokens, rawWindow, stampedModel] = raw.split(/\s+/);
104
+ const tokens = Number.parseInt(rawTokens, 10);
105
+ const window = Number.parseInt(rawWindow, 10);
106
+ let currentModel = typeof expected.modelId === "string" ? expected.modelId.toLowerCase() : null;
107
+ if (Object.hasOwn(expected, "provider")) {
108
+ const provider = typeof expected.provider === "string" ? expected.provider.toLowerCase() : "";
109
+ currentModel = provider && currentModel && MODEL_REF_PATTERN.test(provider) && MODEL_REF_PATTERN.test(currentModel) ? `${provider}/${currentModel}` : null;
110
+ }
111
+ const foreign = stampedModel !== undefined && currentModel !== null && stampedModel !== currentModel;
112
+ if (Number.isInteger(tokens) && tokens >= 0 && String(tokens) === rawTokens && Number.isInteger(window) && window > 0 && String(window) === rawWindow && !foreign && tokens * 100 / window >= maxContextPct) return REFUSED;
113
+ }
114
+ }
115
+ if (maxResumeChain > 0) {
116
+ let chain = 0;
117
+ try {
118
+ const raw = boundedRead(join(directory, "resume-chain"), 4096).text.trim();
119
+ const value = Number.parseInt(raw, 10);
120
+ if (Number.isInteger(value) && value > 0 && String(value) === raw) chain = value;
121
+ } catch { /* The session store treats an absent or unreadable counter as zero, too. */ }
122
+ if (chain >= maxResumeChain) return REFUSED;
123
+ }
124
+ if (!approvalTranscriptMatches(read.text, request, { maxAgeDays, now })) return REFUSED;
125
+ const rootAfter = fs.statSync(sessionsDir);
126
+ const dirAfter = fs.lstatSync(directory);
127
+ if (!rootAfter.isDirectory() || !dirAfter.isDirectory() || dirIdentity(rootAfter) !== dirIdentity(root) || dirIdentity(dirAfter) !== dirIdentity(dir)) return REFUSED;
128
+ return { ok: true, digest: read.digest };
129
+ } catch { return REFUSED; }
130
+ };
131
+ const check = (request, expected = {}) => {
132
+ const out = validate(request, expected);
133
+ return out.ok ? { ok: true } : out;
134
+ };
135
+ check.fingerprint = validate;
136
+ return check;
137
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Durable approval requests (issue #649). Every transition is a Valkey compare-and-set: a label, queue payload or
3
+ * a second reply cannot replace the first accepted decision. Pending requests never expire as keys: the deadline
4
+ * produces a durable decline instead. Callers authorize the actor before decide and keep this private store's text
5
+ * out of run records and logs. Claiming again with the same job id supports infrastructure retry after queue.add.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { ANSWER_TEXT_MAX_BYTES, scrubAnswerText } from "./console-answer.mjs";
9
+ import { FORGE_JOB_RETENTION_MS } from "./retention.mjs";
10
+
11
+ /**
12
+ * How long a finished or cancelled request is kept, in MILLISECONDS (the `PEXPIRE` below, and the expiry comment's NX
13
+ * mark in start.mjs): a day past a forge job's retention, so the evidence of a decision outlives every queued job that
14
+ * could still carry its request id, the expiry job (a forge-kind job built from the request's `resumeJob`) included.
15
+ * Derived, so lengthening the forge retention cannot leave it shorter than the jobs it answers for. Interpolated into
16
+ * the script as a plain integer, so its text is what it was when this was the literal 32 days.
17
+ */
18
+ export const APPROVAL_FINISHED_TTL_MS = FORGE_JOB_RETENTION_MS + 86_400_000;
19
+ export const APPROVAL_PREFIX = "run:approval:";
20
+ export const APPROVAL_DUE_KEY = `${APPROVAL_PREFIX}due`;
21
+ const ID = /^[A-Za-z0-9._:-]{1,200}$/;
22
+ const word = (v, max = 256) => typeof v === "string" && v.length > 0 && v.length <= max && !/[\u0000-\u001f\u007f]/u.test(v);
23
+ const instant = (v) => Number.isSafeInteger(v) && v >= 0;
24
+ const key = (id) => `${APPROVAL_PREFIX}request:${id}`;
25
+ export const approvalDueKey = (host) => `${APPROVAL_DUE_KEY}:${createHash("sha256").update(host).digest("hex")}`;
26
+
27
+ /** A derived target key, shared by publish and find; no attacker text is put in a Valkey key. */
28
+ export function approvalTargetKey(job) {
29
+ if (!word(job?.kind) || !word(job?.repo) || !["issue", "pull_request"].includes(job?.target?.type) || !Number.isSafeInteger(job.target.number) || job.target.number <= 0) return null;
30
+ return `${APPROVAL_PREFIX}target:${createHash("sha256").update(JSON.stringify([job.kind, job.repo, job.target.type, job.target.number])).digest("hex")}`;
31
+ }
32
+
33
+ function normalized(request) {
34
+ if (request?.version !== undefined && request.version !== 1) return null;
35
+ if (request?.sessionDigest !== undefined && (typeof request.sessionDigest !== "string" || !/^[a-f0-9]{64}$/.test(request.sessionDigest))) return null;
36
+ if (!request || !ID.test(request.requestId ?? "") || !ID.test(request.runId ?? "") || !approvalTargetKey(request) || !word(request.action, 128) || !word(request.toolCallId) || !word(request.configDigest) || !word(request.sessionKey) || !word(request.host) || !instant(request.createdAt)) return null;
37
+ if (request.hostQueue !== undefined && request.hostQueue !== null && (typeof request.hostQueue !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(request.host) || request.hostQueue !== `pi-jobs@${request.host}`)) return null;
38
+ if (request.expiresAt !== undefined && (!instant(request.expiresAt) || request.expiresAt <= request.createdAt)) return null;
39
+ const out = { version: 1, requestId: request.requestId, runId: request.runId, kind: request.kind, repo: request.repo, target: { type: request.target.type, number: request.target.number }, action: request.action, toolCallId: request.toolCallId, configDigest: request.configDigest, sessionKey: request.sessionKey, host: request.host, createdAt: request.createdAt };
40
+ if (request.expiresAt !== undefined) out.expiresAt = request.expiresAt;
41
+ if (request.sessionDigest !== undefined) out.sessionDigest = request.sessionDigest;
42
+ if (request.hostQueue !== undefined && request.hostQueue !== null) out.hostQueue = request.hostQueue;
43
+ // Routing data is worker-authored and private; its JSON must remain bounded before it reaches Valkey.
44
+ if (request.resumeJob !== undefined) {
45
+ if (!request.resumeJob || typeof request.resumeJob !== "object" || Array.isArray(request.resumeJob)) return null;
46
+ out.resumeJob = request.resumeJob;
47
+ }
48
+ if (Buffer.byteLength(JSON.stringify(out)) > 128 * 1024) return null;
49
+ return out;
50
+ }
51
+
52
+ const PUBLISH = `local prior = redis.call('GET', KEYS[1])
53
+ if prior then
54
+ local valid, r = pcall(cjson.decode, prior)
55
+ if not valid or type(r) ~= 'table' or r.version ~= 1 then return {'refused', 'state-invalid'} end
56
+ if r.identity ~= ARGV[1] then return {'refused', 'request-conflict'} end
57
+ return {'ok', prior}
58
+ end
59
+ if redis.call('EXISTS', KEYS[2]) == 1 then return {'refused', 'target-pending'} end
60
+ local r = cjson.decode(ARGV[1]); r.state = 'pending'; r.identity = ARGV[1]
61
+ local raw = cjson.encode(r)
62
+ redis.call('SET', KEYS[1], raw); redis.call('SET', KEYS[2], r.requestId)
63
+ if r.expiresAt then redis.call('ZADD', KEYS[3], r.expiresAt, r.requestId); redis.call('ZADD', KEYS[4], r.expiresAt, r.requestId) end
64
+ return {'ok', raw}`;
65
+
66
+ const MOVE = `local raw = redis.call('GET', KEYS[1])
67
+ if not raw then return {'refused', 'request-missing'} end
68
+ local valid, r = pcall(cjson.decode, raw)
69
+ if not valid or type(r) ~= 'table' or r.version ~= 1 then return {'refused', 'state-invalid'} end
70
+ if (ARGV[1] == 'finish' or ARGV[1] == 'cancel') and r.state == 'finished' and r.jobId == ARGV[3] then return {'ok', raw} end
71
+ if redis.call('GET', KEYS[2]) ~= r.requestId then return {'refused', 'request-stale'} end
72
+ local op = ARGV[1]; local at = tonumber(ARGV[2])
73
+ if op == 'decide' or op == 'expire' then
74
+ if r.state ~= 'pending' then
75
+ if op == 'decide' and r.decision and r.decision.decisionId == ARGV[3] then return {'ok', raw} end
76
+ if op == 'expire' and r.decision and r.decision.actor.kind == 'system' then return {'ok', raw} end
77
+ return {'refused', 'already-decided'}
78
+ end
79
+ if op == 'expire' and (not r.expiresAt or at < r.expiresAt) then return {'refused', 'not-expired'} end
80
+ if r.expiresAt and at >= r.expiresAt then
81
+ r.decision = {decisionId = 'expiry-' .. r.requestId, decision = 'declined', actor = {kind = 'system', id = 'expiry'}, text = '', at = at}
82
+ else
83
+ r.decision = cjson.decode(ARGV[4]); r.decision.at = at
84
+ end
85
+ r.state = 'decided'
86
+ if r.decision.actor.kind ~= 'system' then redis.call('ZREM', KEYS[3], r.requestId); redis.call('ZREM', KEYS[4], r.requestId) end
87
+ elseif op == 'claim' then
88
+ if r.state == 'claimed' and r.jobId == ARGV[3] then return {'ok', raw} end
89
+ if r.state ~= 'decided' then return {'refused', 'not-decided'} end
90
+ r.state = 'claimed'; r.jobId = ARGV[3]; redis.call('ZREM', KEYS[3], r.requestId); redis.call('ZREM', KEYS[4], r.requestId)
91
+ elseif op == 'finish' then
92
+ if r.state ~= 'claimed' or r.jobId ~= ARGV[3] then return {'refused', 'not-claimed'} end
93
+ r.state = 'finished'; redis.call('DEL', KEYS[2]); redis.call('ZREM', KEYS[3], r.requestId); redis.call('ZREM', KEYS[4], r.requestId)
94
+ elseif op == 'cancel' then
95
+ if r.state == 'claimed' and r.jobId ~= ARGV[3] then return {'refused', 'not-claimed'} end
96
+ if r.state ~= 'pending' and r.state ~= 'decided' and r.state ~= 'claimed' then return {'refused', 'request-stale'} end
97
+ if not r.decision then r.decision = {decisionId = 'revocation-' .. r.requestId, decision = 'declined', actor = {kind = 'system', id = 'revocation'}, text = '', at = at} end
98
+ r.state = 'finished'; r.jobId = ARGV[3]; redis.call('DEL', KEYS[2]); redis.call('ZREM', KEYS[3], r.requestId); redis.call('ZREM', KEYS[4], r.requestId)
99
+ else return {'refused', 'operation-invalid'} end
100
+ raw = cjson.encode(r); redis.call('SET', KEYS[1], raw)
101
+ if op == 'finish' or op == 'cancel' then redis.call('PEXPIRE', KEYS[1], ${APPROVAL_FINISHED_TTL_MS}) end
102
+ return {'ok', raw}`;
103
+
104
+ const exposed = (raw) => {
105
+ try {
106
+ const request = JSON.parse(raw);
107
+ if (request?.version !== 1 || !normalized(request) || !["pending", "decided", "claimed", "finished"].includes(request.state)) return null;
108
+ if (request.state !== "pending" && (!request.decision || !["approved", "declined", "question"].includes(request.decision.decision) || !["forge", "operator", "panel", "system"].includes(request.decision.actor?.kind) || !word(request.decision.actor.id, 200) || !instant(request.decision.at) || typeof request.decision.text !== "string")) return null;
109
+ delete request.identity;
110
+ return request;
111
+ } catch { return null; }
112
+ };
113
+
114
+ /** Methods return {ok, request}, {refused}, or {error}; missing read/find returns request:null. */
115
+ export function makeApprovalStore({ redis, now = () => Date.now() } = {}) {
116
+ if (!redis || typeof redis.eval !== "function") throw new TypeError("makeApprovalStore needs redis.eval");
117
+ const safe = async (fn) => {
118
+ try { return await fn(); } catch (error) { return { error: typeof error?.code === "string" && /^[A-Z0-9_]{1,40}$/.test(error.code) ? error.code : "approval-store-error" }; }
119
+ };
120
+ const read = (id) => safe(async () => {
121
+ if (!ID.test(id ?? "")) return { refused: "request-invalid" };
122
+ const raw = await redis.get(key(id));
123
+ if (raw === null) return { ok: true, request: null };
124
+ const request = exposed(raw);
125
+ return request ? { ok: true, request } : { refused: "state-invalid" };
126
+ });
127
+ const decode = (result) => {
128
+ if (result[0] !== "ok") return { refused: result[1] };
129
+ const request = exposed(result[1]);
130
+ return request ? { ok: true, request } : { refused: "state-invalid" };
131
+ };
132
+ const move = (op, id, marker = "", decision = null) => safe(async () => {
133
+ const got = await read(id);
134
+ if (!got.ok) return got;
135
+ if (!got.request) return { refused: "request-missing" };
136
+ const at = now();
137
+ if (!instant(at)) return { refused: "clock-invalid" };
138
+ return decode(await redis.eval(MOVE, 4, key(id), approvalTargetKey(got.request), APPROVAL_DUE_KEY, approvalDueKey(got.request.host), op, String(at), marker, JSON.stringify(decision)));
139
+ });
140
+ return {
141
+ publish: (request) => safe(async () => {
142
+ const r = normalized(request);
143
+ if (!r) return { refused: "request-invalid" };
144
+ return decode(await redis.eval(PUBLISH, 4, key(r.requestId), approvalTargetKey(r), APPROVAL_DUE_KEY, approvalDueKey(r.host), JSON.stringify(r)));
145
+ }),
146
+ read,
147
+ find: (job) => safe(async () => {
148
+ const target = approvalTargetKey(job);
149
+ if (!target) return { refused: "target-invalid" };
150
+ const id = await redis.get(target);
151
+ return id === null ? { ok: true, request: null } : read(id);
152
+ }),
153
+ decide: ({ requestId, decisionId, decision, actor, text = "" } = {}) => {
154
+ if (!ID.test(decisionId ?? "") || !["approved", "declined", "question"].includes(decision) || !["forge", "operator", "panel"].includes(actor?.kind) || !word(actor?.id, 200) || typeof text !== "string" || Buffer.byteLength(text) > ANSWER_TEXT_MAX_BYTES || (typeof text.isWellFormed === "function" && !text.isWellFormed())) return Promise.resolve({ refused: "decision-invalid" });
155
+ return move("decide", requestId, decisionId, { decisionId, decision, actor: { kind: actor.kind, id: actor.id }, text: scrubAnswerText(text) });
156
+ },
157
+ claim: ({ requestId, jobId } = {}) => ID.test(jobId ?? "") ? move("claim", requestId, jobId) : Promise.resolve({ refused: "job-invalid" }),
158
+ finish: ({ requestId, jobId } = {}) => ID.test(jobId ?? "") ? move("finish", requestId, jobId) : Promise.resolve({ refused: "job-invalid" }),
159
+ cancel: ({ requestId, jobId } = {}) => ID.test(jobId ?? "") ? move("cancel", requestId, jobId) : Promise.resolve({ refused: "job-invalid" }),
160
+ expire: (id) => move("expire", id),
161
+ hasPending: (host) => safe(async () => {
162
+ if (!word(host)) return { refused: "host-invalid" };
163
+ return { ok: true, pending: (await redis.zcard(approvalDueKey(host))) > 0 };
164
+ }),
165
+ due: (limit = 100, host = null) => safe(async () => {
166
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000 || !instant(now()) || host !== null && !word(host)) return { refused: "limit-invalid" };
167
+ return { ok: true, requestIds: await redis.zrangebyscore(host === null ? APPROVAL_DUE_KEY : approvalDueKey(host), "-inf", String(now()), "LIMIT", 0, limit) };
168
+ }),
169
+ };
170
+ }
@@ -0,0 +1,86 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { loadConfig } from "./config.mjs";
3
+ import { configuredSubscriptions } from "./subscription-config.mjs";
4
+ import { subscriptionIdentity } from "./subscription-runtime.mjs";
5
+ import { makeRedisClient } from "./connection.mjs";
6
+ import { openBrowser } from "./open-browser.mjs";
7
+ import { authError } from "./auth-store.mjs";
8
+ import { OPENAI_LOGIN_INSTRUCTIONS } from "./oauth-openai.mjs";
9
+
10
+ /** Native pi selects use `id`; older community callbacks use `value`. Only an offered choice can leave the prompt. */
11
+ export function authSelectAnswer(question, answer) {
12
+ const options = Array.isArray(question?.options) ? question.options : [];
13
+ const values = options.map((option) => option?.value ?? option?.id);
14
+ if (!values.length || values.some((value) => typeof value !== "string" || !value)) throw authError("auth-selection-invalid");
15
+ const input = typeof answer === "string" ? answer.trim() : "";
16
+ if (/^\d+$/.test(input)) {
17
+ const index = Number(input) - 1;
18
+ if (!Number.isSafeInteger(index) || index < 0 || index >= values.length) throw authError("auth-selection-invalid");
19
+ return values[index];
20
+ }
21
+ if (!values.includes(input)) throw authError("auth-selection-invalid");
22
+ return input;
23
+ }
24
+
25
+ export async function runAuth(args, { env = process.env, write = (value) => process.stdout.write(value), writeErr = (value) => process.stderr.write(value), configure = configuredSubscriptions, configFn = loadConfig, redisFn = makeRedisClient, interaction = null, now = Date.now, terminalInput = process.stdin, terminalOutput = process.stdout, createPrompt = createInterface, openBrowserFn = openBrowser } = {}) {
26
+ const [command = "status", id, ...rest] = args;
27
+ if (!["status", "login", "logout", "usage", "trial"].includes(command) || rest.length > 0 || (command === "status" ? id !== undefined : !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id ?? ""))) {
28
+ writeErr("usage: pi-dispatch auth status | login CONNECTION | logout CONNECTION | usage CONNECTION | trial CONNECTION\n");
29
+ return 2;
30
+ }
31
+ let redis;
32
+ let prompt;
33
+ try {
34
+ const config = configFn(env);
35
+ if (!config.authConnectionsFile) { writeErr("Set PI_AUTH_CONNECTIONS_FILE to an operator-reviewed subscription connection policy.\n"); return 2; }
36
+ redis = redisFn(config.valkeyUrl);
37
+ const service = configure({ config, redis, now });
38
+ if (!interaction) interaction = {
39
+ async notify(event) {
40
+ if (event?.type === "auth_url" && typeof event.url === "string" && event.url.startsWith("https://")) {
41
+ write(`Open the authorization page on this host: ${event.url}\n`);
42
+ if (URL.canParse(event.url) && new URL(event.url).origin === "https://auth.openai.com") write(`${OPENAI_LOGIN_INSTRUCTIONS}\n`);
43
+ openBrowserFn(event.url);
44
+ }
45
+ if (event?.type === "device_code") write(`Authorization code: ${event.code ?? event.userCode ?? "shown in browser"}\n`);
46
+ },
47
+ async prompt(question) {
48
+ if (!terminalInput.isTTY) throw Object.assign(new Error("interactive terminal required"), { code: "auth-terminal-required" });
49
+ prompt ??= createPrompt({ input: terminalInput, output: terminalOutput });
50
+ if (question.type === "select" && Array.isArray(question.options)) write(`${question.options.map((option, i) => `${i + 1}. ${option.label ?? option.value ?? option.id}`).join("\n")}\n`);
51
+ const answer = await prompt.question(`${question.message ?? "Authorization response"}: `, question.signal ? { signal: question.signal } : {});
52
+ if (question.type === "select") return authSelectAnswer(question, answer);
53
+ return answer;
54
+ },
55
+ };
56
+ let result;
57
+ if (command === "login") result = await service.auth.login(id, { interaction, signal: AbortSignal.timeout(10 * 60_000) });
58
+ else if (command === "logout") result = await service.auth.logout(id);
59
+ else if (command === "usage") result = await service.runtime.usage(id);
60
+ else if (command === "trial") {
61
+ const row = (await service.auth.status()).find((entry) => entry.id === id && entry.active && entry.ownerHost === config.workerName);
62
+ if (!row) throw Object.assign(new Error("connection inactive"), { code: "auth-connection-inactive" });
63
+ const identity = subscriptionIdentity(row);
64
+ const current = await service.quota.read(identity);
65
+ result = await service.quota.grantOneTrial(identity, { expectedVersion: current.version, actorKind: "person" });
66
+ } else result = await service.runtime.status();
67
+ // A login may return private credentials from an adapter. Print only the host service's safe status after it.
68
+ if (command === "login") result = await service.runtime.status();
69
+ if (command === "logout") {
70
+ const logout = { id, state: "disconnected", remoteRevocationConfirmed: result?.remoteRevocationConfirmed === true };
71
+ let status;
72
+ try { status = await service.runtime.status(); }
73
+ catch { status = { state: "unavailable", partial: true, connections: [] }; }
74
+ result = { ...status, logout };
75
+ }
76
+ write(`${JSON.stringify(result, (key, value) => ["accountGroup", "generation", "accountDigest", "credential", "registration"].includes(key) ? undefined : value, 2)}\n`);
77
+ return result?.ok === false || result?.kind === "unavailable" ? 2 : 0;
78
+ } catch (error) {
79
+ const code = typeof error?.code === "string" && /^(?:auth|subscription)-[a-z-]+$/.test(error.code) ? error.code : "auth-unavailable";
80
+ writeErr(`Subscription authentication: ${code}. Run auth status on the connection owner host.\n`);
81
+ return error?.transient === true ? 1 : 2;
82
+ } finally {
83
+ prompt?.close();
84
+ try { await redis?.quit(); } catch { redis?.disconnect?.(); }
85
+ }
86
+ }