@edgehero/pi-dispatch 4.3.1 → 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 (256) hide show
  1. package/.env.example +84 -3
  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 +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 +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 +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The one version rule for a store that had no version and may need one (issue #761, `DES-STORE-VERSION-WHEN-NEEDED`).
3
+ * A LEAF: it imports nothing, so the admin console, the receiver and every worker module can share it.
4
+ *
5
+ * A version is an optional integer. A file or job without one is version 1, and a writer writes the LOWEST version its
6
+ * content needs, which is nothing at all while that is 1: so a store whose content every release can read stays
7
+ * byte-identical, and an older reader is turned away only by content it would misread. What needs a version is
8
+ * declared per store in a table, `field -> the version that introduced it`, frozen and empty until a field needs one;
9
+ * the store's own `*_VERSION` constant is the highest version its build reads and equals the table's largest value
10
+ * (or 1), which `versionTableProblems` holds every store to.
11
+ *
12
+ * Why not stamp the current version on every write: every reader older than the writer would then refuse every file,
13
+ * including the ones it could read perfectly well. Why not compare release numbers: a release number says nothing
14
+ * about which fields a file uses, so it refuses too much and protects nothing a field table does not.
15
+ *
16
+ * A field table cannot say "this EXISTING field now takes a wider value" (a new condition kind in a list, a new form of
17
+ * a scope), and an older build meeting such a value refuses it as broken, with no stamp to say an upgrade is the fix. So
18
+ * a store can also declare a value table, `field -> (value) => the version that value needs`, frozen and empty until a
19
+ * value needs one, and its writer stamps the version for exactly the values that need it (`valueNeededVersion`).
20
+ *
21
+ * Two different failures, told apart on purpose: a version newer than this build reads is not an error in the file
22
+ * (`newer: true`, the caller defers or refuses with an upgrade to name), while a version that is not an integer of 1
23
+ * or more is a broken file and keeps that store's ordinary refusal. The broken-file message never repeats the value it
24
+ * judged, and the newer one names only two integers: the version declared and the one this build reads.
25
+ */
26
+
27
+ /**
28
+ * The problem with a declared version, or null when it is readable here: missing (version 1) or an integer from 1 to
29
+ * `supported`. `what` names the store in the message ("settings file", "pause windows file", ...).
30
+ */
31
+ export function versionProblem(declared, { supported, what }) {
32
+ if (declared === undefined) return null;
33
+ if (!Number.isSafeInteger(declared) || declared < 1) return { newer: false, message: `${what} "version" must be an integer of 1 or more` };
34
+ if (declared > supported) return { newer: true, message: `${what} written by a newer pi-dispatch (version ${declared}; this build understands ${supported})` };
35
+ return null;
36
+ }
37
+
38
+ /** The lowest version that expresses content using `fields` (an iterable of names): 1 when no field needs more. */
39
+ export function neededVersion(fields, table) {
40
+ let needed = 1;
41
+ for (const field of fields ?? []) {
42
+ if (typeof field === "string" && Object.hasOwn(table, field) && table[field] > needed) needed = table[field];
43
+ }
44
+ return needed;
45
+ }
46
+
47
+ /**
48
+ * The first field whose version is above the one the file declares (`declared` missing reads as 1), as
49
+ * `{ field, needs }`, or null. A file that uses such a field without saying so was written by hand or by a writer that
50
+ * dropped the stamp, and an older reader would read it without the field: refused like a broken file.
51
+ */
52
+ export function underDeclared(fields, table, declared) {
53
+ const have = declared === undefined ? 1 : declared;
54
+ for (const field of fields ?? []) {
55
+ if (typeof field === "string" && Object.hasOwn(table, field) && table[field] > have) return { field, needs: table[field] };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * The version `value` needs under `field`'s rule in `valueTable`: 1 without an own rule, or when the rule answers no
62
+ * integer above 1. A rule that THROWS answers 1 too: it runs over a file's raw content, and a value it was not written
63
+ * for (a `null` where it expects a list) is the store's own validator's to judge, never a reason to crash the reader.
64
+ */
65
+ function ruleVersion(valueTable, field, value) {
66
+ if (!Object.hasOwn(valueTable, field) || typeof valueTable[field] !== "function") return 1;
67
+ let needs;
68
+ try {
69
+ needs = valueTable[field](value);
70
+ } catch {
71
+ return 1;
72
+ }
73
+ return Number.isSafeInteger(needs) && needs > 1 ? needs : 1;
74
+ }
75
+
76
+ /**
77
+ * The lowest version that expresses the VALUES of `record`'s own fields under `valueTable` (`field -> (value) =>
78
+ * version`, for a wider range of an existing field): the highest version any rule answers, 1 when none answers an
79
+ * integer above 1. A field-name table says which fields need a version; this says which values of a field do.
80
+ */
81
+ export function valueNeededVersion(record, valueTable) {
82
+ let needed = 1;
83
+ if (record === null || typeof record !== "object") return needed;
84
+ for (const field of Object.keys(record)) {
85
+ const needs = ruleVersion(valueTable, field, record[field]);
86
+ if (needs > needed) needed = needs;
87
+ }
88
+ return needed;
89
+ }
90
+
91
+ /**
92
+ * The first own field of `record` whose value needs a version above the declared one (`declared` missing reads as 1),
93
+ * as `{ field, needs }`, or null: `underDeclared` for a value table, refused by the reader for the same reason.
94
+ */
95
+ export function valueUnderDeclared(record, valueTable, declared) {
96
+ const have = declared === undefined ? 1 : declared;
97
+ if (record === null || typeof record !== "object") return null;
98
+ for (const field of Object.keys(record)) {
99
+ const needs = ruleVersion(valueTable, field, record[field]);
100
+ if (needs > have) return { field, needs };
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Every way a store's table breaks the rule, as sentences (empty when it holds): a key that is not a field the store
107
+ * knows, a value that is not an integer from 2 to the store's version, and a version that is not the table's largest
108
+ * value (or 1). A store's test calls this with its own table, its known fields and its version.
109
+ */
110
+ export function versionTableProblems(table, { known, version }) {
111
+ const problems = [];
112
+ if (!Object.isFrozen(table)) problems.push("the table is not frozen");
113
+ const knownSet = new Set(known);
114
+ let largest = 1;
115
+ for (const [field, needs] of Object.entries(table)) {
116
+ if (!knownSet.has(field)) problems.push(`${field} is not a field this store knows`);
117
+ if (!Number.isSafeInteger(needs) || needs < 2 || needs > version) problems.push(`${field} needs version ${needs}, outside 2 to ${version}`);
118
+ else if (needs > largest) largest = needs;
119
+ }
120
+ if (version !== largest) problems.push(`the store's version is ${version}, but its table's largest value is ${largest}`);
121
+ return problems;
122
+ }
123
+
124
+ /**
125
+ * `versionTableProblems` for a value table, as far as a rule can be judged without running it: frozen, every key a
126
+ * field the store knows and every value a function, and the store's version an integer of 1 or more. Which versions a
127
+ * rule answers depends on the value it is given, so the store's test probes each rule with the values that need it.
128
+ */
129
+ export function valueTableProblems(table, { known, version }) {
130
+ const problems = [];
131
+ if (!Object.isFrozen(table)) problems.push("the table is not frozen");
132
+ const knownSet = new Set(known);
133
+ for (const [field, rule] of Object.entries(table)) {
134
+ if (!knownSet.has(field)) problems.push(`${field} is not a field this store knows`);
135
+ if (typeof rule !== "function") problems.push(`${field}'s rule is not a function of the value`);
136
+ }
137
+ if (!Number.isSafeInteger(version) || version < 1) problems.push(`the store's version is ${version}, not an integer of 1 or more`);
138
+ return problems;
139
+ }
@@ -0,0 +1,56 @@
1
+ import * as realFs from "node:fs";
2
+ import { dirname, relative, resolve } from "node:path";
3
+ import { createAuthService, parseAuthConnections } from "./auth-connections.mjs";
4
+ import { makeSubscriptionQuotaStore } from "./subscription-quota.mjs";
5
+ import { makeSubscriptionRuntime } from "./subscription-runtime.mjs";
6
+ import { readOverlayModels } from "./model-endpoints.mjs";
7
+ import { failoverModelView } from "./model-status.mjs";
8
+ import { CONTAINER_GLOBAL_PI_DIR } from "./container-spec.mjs";
9
+ import { readStageManifest, containerPackagePaths } from "./packages.mjs";
10
+ import { authError } from "./auth-store.mjs";
11
+ import { COMMUNITY_AUTH_ADAPTERS } from "./community-auth-adapters.mjs";
12
+ import { insideDirectory, privateStoreExposed } from "./private-store-boundary.mjs";
13
+ import { isDeterminateFsCode } from "./transient.mjs";
14
+
15
+ const inside = insideDirectory;
16
+ export function readAuthPolicy(path, { uid = process.getuid?.(), fs = realFs } = {}) {
17
+ try {
18
+ const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
19
+ try {
20
+ const stat = fs.fstatSync(fd);
21
+ if (!stat.isFile() || stat.size > 65_536 || (stat.mode & 0o022) !== 0 || (uid !== undefined && stat.uid !== uid && stat.uid !== 0)) throw Object.assign(new Error("the subscription connection policy must be a private, owned regular file"), { code: "auth-policy-invalid" });
22
+ return parseAuthConnections(fs.readFileSync(fd, "utf8"));
23
+ } finally { fs.closeSync(fd); }
24
+ } catch (error) {
25
+ if (error.code?.startsWith("auth-")) throw error;
26
+ const transient = !isDeterminateFsCode(error?.code) && !(error instanceof SyntaxError);
27
+ throw authError(transient ? "auth-policy-unavailable" : "auth-policy-invalid", { transient });
28
+ }
29
+ }
30
+
31
+ export function configuredSubscriptions({ config, redis, now = Date.now, adapters, fetch } = {}) {
32
+ if (!config.authConnectionsFile) return null;
33
+ const storeDir = config.authStoreDir ?? resolve(dirname(config.settingsFile), "subscription-auth");
34
+ for (const root of [config.globalPiDir, config.jobsDir, config.sandboxDir]) if (root && privateStoreExposed(root, storeDir)) throw Object.assign(new Error("the private subscription store must be outside every job or overlay root"), { code: "auth-store-location" });
35
+ const readPolicy = () => readAuthPolicy(config.authConnectionsFile);
36
+ readPolicy();
37
+ const auth = createAuthService({ readPolicy, storeDir, host: config.workerName, now, ...(adapters ? { adapters } : {}), ...(fetch ? { fetch } : {}) });
38
+ if (!redis) return { auth, quota: null, runtime: null, storeDir };
39
+ const quota = makeSubscriptionQuotaStore({ redis, now });
40
+ const runtime = makeSubscriptionRuntime({ auth, quota, now, defaultBackend: config.defaultBackend, isPrivateStoreExposed: (job) => job.kind === "local" && privateStoreExposed(job.folder, storeDir), lookup: (provider, id) => failoverModelView(`${provider}/${id}`, { models: readOverlayModels(config.globalPiDir) }), packageRootFor: (adapter) => {
41
+ const row = readPolicy().connections.find((entry) => entry.adapter === adapter && entry.ownerHost === config.workerName);
42
+ if (!row?.root || !config.globalPiDir || !inside(resolve(config.globalPiDir, "packages"), row.root)) return null;
43
+ const root = `${CONTAINER_GLOBAL_PI_DIR}/${relative(config.globalPiDir, row.root).split("\\").join("/")}`;
44
+ const manifest = readStageManifest({ globalPiDir: config.globalPiDir });
45
+ if (!manifest || !containerPackagePaths(manifest).includes(root)) return null;
46
+ if (stagedAuthConflict(manifest, adapter)) return { refused: "auth-adapter-conflicting" };
47
+ return { root, digest: row.digest };
48
+ } });
49
+ return { auth, quota, runtime, storeDir };
50
+ }
51
+
52
+ /** Both reviewed Claude packages replace the same provider; this conflict is knowable without executing either. */
53
+ export function stagedAuthConflict(manifest, adapter) {
54
+ const selected = COMMUNITY_AUTH_ADAPTERS[adapter];
55
+ return selected?.community === true && (manifest?.packages ?? []).filter((row) => COMMUNITY_AUTH_ADAPTERS[`${row.name}@${row.version}`]?.provider === selected.provider).length > 1;
56
+ }
@@ -0,0 +1,3 @@
1
+ /** Shared bounds for reviewed connections, quota persistence and safe status. */
2
+ export const SUBSCRIPTION_CONNECTION_LIMIT = 32;
3
+ export const SUBSCRIPTION_QUOTA_BYTES = 65_536;
@@ -0,0 +1,259 @@
1
+ import { SUBSCRIPTION_QUOTA_BYTES } from "./subscription-limits.mjs";
2
+ /** Subscription evidence is account policy, separate from model cooldowns and dollar reservations. */
3
+ import { createHash, randomUUID } from "node:crypto";
4
+
5
+ export const SUBSCRIPTION_QUOTA_CODES = Object.freeze(["usage_limit_reached", "usage_not_included", "rate_limit_exceeded", "quota_exhausted"]);
6
+ export const SUBSCRIPTION_QUOTA_REASONS = Object.freeze(["subscription-unknown", "subscription-available", "subscription-trial", "subscription-quota", "subscription-extra-usage", "subscription-stale-evidence", "subscription-stale-state", "subscription-stale-binding", "subscription-observation-required"]);
7
+ export const SUBSCRIPTION_QUOTA_LIMITS = Object.freeze({ payloadBytes: SUBSCRIPTION_QUOTA_BYTES, windows: 64, limits: 32, freshMs: 300_000, casAttempts: 8, resetFutureMs: 32 * 86_400_000 });
8
+ const WINDOWS = ["five-hour", "seven-day", "other"];
9
+ const SOURCES = ["claude-community", "openai", "provider-error"];
10
+ const hash = value => createHash("sha256").update(value).digest("hex");
11
+ const token = value => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
12
+ const instant = value => Number.isSafeInteger(value) && value >= 0 && value <= 253_402_300_799_999;
13
+ const percent = value => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 10_000 ? value : null;
14
+ const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
15
+ const emptyExtra = () => ({ enabled: null, usedPercent: null, usedCredits: null, monthlyLimit: null, currency: null, decimalPlaces: null });
16
+ function quotaError(code) { return Object.assign(new Error(code), { code }); }
17
+ function boundedObject(raw) {
18
+ try {
19
+ const text = typeof raw === "string" ? raw : JSON.stringify(raw);
20
+ if (typeof text !== "string" || Buffer.byteLength(text) > SUBSCRIPTION_QUOTA_LIMITS.payloadBytes) return null;
21
+ const parsed = JSON.parse(text);
22
+ return object(parsed) ? parsed : null;
23
+ } catch { return null; }
24
+ }
25
+ function atOf(options) {
26
+ if (!instant(options?.observedAt)) throw quotaError("subscription-invalid-observation");
27
+ return options.observedAt;
28
+ }
29
+ function reset(value, at, seconds = false) {
30
+ const parsed = typeof value === "string" && /^\d{4}-\d\d-\d\dT\d\d:\d\d(?::\d\d(?:\.\d{1,3})?)?(?:Z|[+-]\d\d:\d\d)$/.test(value) ? Date.parse(value) : seconds && Number.isSafeInteger(value) ? value * 1000 : null;
31
+ return instant(parsed) && parsed <= at + SUBSCRIPTION_QUOTA_LIMITS.resetFutureMs ? parsed : null;
32
+ }
33
+ function row(id, window, scope, usedPercent, resetAt, exhausted = false) {
34
+ return { id, window, scope, usedPercent, resetAt, state: exhausted || usedPercent !== null && usedPercent >= 100 ? "exhausted" : usedPercent === null ? "unknown" : "available" };
35
+ }
36
+ function strongerRow(old, next) {
37
+ if (old.usedPercent === null || next.usedPercent !== null && next.usedPercent > old.usedPercent) return next;
38
+ if (next.usedPercent !== old.usedPercent) return old;
39
+ return { ...old, resetAt: old.resetAt === null || next.resetAt === null ? null : Math.max(old.resetAt, next.resetAt) };
40
+ }
41
+ function extraOf(raw) {
42
+ const extra = object(raw) ? raw : {};
43
+ const number = value => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
44
+ return { enabled: typeof extra.is_enabled === "boolean" ? extra.is_enabled : null, usedPercent: percent(extra.utilization), usedCredits: number(extra.used_credits), monthlyLimit: number(extra.monthly_limit), currency: typeof extra.currency === "string" && /^[A-Z]{3}$/.test(extra.currency) ? extra.currency : null, decimalPlaces: Number.isInteger(extra.decimal_places) && extra.decimal_places >= 0 && extra.decimal_places <= 4 ? extra.decimal_places : null };
45
+ }
46
+ function completeOf(windows) {
47
+ return windows.length > 0 && windows.every(value => value.state !== "unknown") && ["five-hour", "seven-day"].every(window => windows.some(value => value.id === window && value.state !== "unknown"));
48
+ }
49
+ function observation(source, at, windows, extraUsage = emptyExtra()) {
50
+ return { version: 1, source, observedAt: at, complete: completeOf(windows), windows, extraUsage };
51
+ }
52
+
53
+ /** Fixed fields only. Display labels from the community endpoint are not public identity or authority. */
54
+ export function normalizeClaudeQuota(raw, options) {
55
+ const at = atOf(options), parsed = boundedObject(raw);
56
+ const base = new Map(["five-hour", "seven-day"].map(window => [window, row(window, window, "account", null, null)]));
57
+ const scoped = [];
58
+ if (!parsed || parsed.limits !== undefined && parsed.limits !== null && (!Array.isArray(parsed.limits) || parsed.limits.length > SUBSCRIPTION_QUOTA_LIMITS.limits)) return observation("claude-community", at, [...base.values()]);
59
+ for (const [key, window] of [["five_hour", "five-hour"], ["seven_day", "seven-day"]]) {
60
+ const value = parsed[key];
61
+ if (object(value)) base.set(window, row(window, window, "account", percent(value.utilization), reset(value.resets_at, at)));
62
+ }
63
+ for (const value of parsed.limits ?? []) {
64
+ if (!object(value)) continue;
65
+ const window = value.kind === "session" ? "five-hour" : value.group === "weekly" || typeof value.kind === "string" && value.kind.startsWith("weekly") ? "seven-day" : "other";
66
+ // Unknown model/surface names do not imply a physical model mapping. Their identity is opaque;
67
+ // without a reviewed mapping, an exhausted scoped limit conservatively blocks this application bucket.
68
+ const scope = object(value.scope) && (object(value.scope.model) || value.scope.surface !== undefined) || value.kind === "weekly_scoped" ? "scoped" : "account";
69
+ const id = scope === "account" && window !== "other" ? window : `${window}:${hash(JSON.stringify([value.kind ?? null, value.group ?? null, value.scope ?? null]))}`;
70
+ const next = row(id, window, scope, percent(value.percent ?? value.utilization), reset(value.resets_at, at));
71
+ if (scope === "account" && window !== "other") {
72
+ const old = base.get(window);
73
+ base.set(window, strongerRow(old, next));
74
+ } else if (!scoped.some(entry => entry.id === id)) scoped.push(next);
75
+ }
76
+ for (const [key, value] of Object.entries(parsed)) {
77
+ if (!(key === "iguana_necktie" || /^seven_day_[a-z0-9_]{1,64}$/.test(key)) || !object(value)) continue;
78
+ const id = `seven-day:${hash(key)}`;
79
+ if (scoped.length >= SUBSCRIPTION_QUOTA_LIMITS.windows - 2) return observation("claude-community", at, [...base.values()]);
80
+ scoped.push(row(id, "seven-day", "scoped", percent(value.utilization), reset(value.resets_at, at)));
81
+ }
82
+ return observation("claude-community", at, [...base.values(), ...scoped], extraOf(parsed.extra_usage));
83
+ }
84
+
85
+ /** OpenAI App Server's typed rate-limit fields, without interpreting diagnostic prose. */
86
+ export function normalizeOpenAIQuota(raw, options) {
87
+ const at = atOf(options), parsed = boundedObject(raw);
88
+ const limits = object(parsed?.rateLimits) ? parsed.rateLimits : parsed;
89
+ const rows = new Map(["five-hour", "seven-day"].map(window => [window, row(window, window, "account", null, null)]));
90
+ for (const [key, value] of Object.entries(limits ?? {})) {
91
+ if (!["primary", "secondary"].includes(key) || !object(value)) continue;
92
+ const window = value.windowDurationMins === 300 ? "five-hour" : value.windowDurationMins === 10_080 ? "seven-day" : "other";
93
+ const id = window === "other" ? `other:${key}` : window;
94
+ const next = row(id, window, "account", percent(value.usedPercent), reset(value.resetsAt, at, true));
95
+ rows.set(id, rows.has(id) ? strongerRow(rows.get(id), next) : next);
96
+ }
97
+ return observation("openai", at, [...rows.values()]);
98
+ }
99
+
100
+ /** Only a verified transport's closed code/status may establish quota exhaustion. */
101
+ export function normalizeSubscriptionQuotaError(value, options) {
102
+ const at = atOf(options);
103
+ if (!object(value) || !["anthropic", "openai", "openai-codex"].includes(value.provider) || !SUBSCRIPTION_QUOTA_CODES.includes(value.code) || value.status === 401 || value.status === 403 || value.status !== undefined && value.status !== 429) return null;
104
+ const window = WINDOWS.includes(value.window) ? value.window : "other";
105
+ const resetAt = instant(value.resetAt) && value.resetAt <= at + SUBSCRIPTION_QUOTA_LIMITS.resetFutureMs ? value.resetAt : null;
106
+ const result = observation("provider-error", at, [row(window === "other" ? "provider-error" : window, window, "account", null, resetAt, true)]);
107
+ return { ...result, complete: false, code: value.code };
108
+ }
109
+
110
+ function identityOf(value) {
111
+ if (!object(value) || !token(value.connectionId) || !token(value.owner) || !token(value.bucket) || typeof value.accountDigest !== "string" || !/^(?:[a-f0-9]{64}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/.test(value.accountDigest) || !Number.isSafeInteger(value.generation) || value.generation < 1) throw quotaError("subscription-invalid-binding");
112
+ return { connectionId: value.connectionId, owner: value.owner, accountDigest: value.accountDigest, bucket: value.bucket, generation: value.generation };
113
+ }
114
+ export function subscriptionQuotaKey(value) {
115
+ const identity = identityOf(value);
116
+ return `dispatch:subscription-quota:${hash(JSON.stringify([identity.connectionId, identity.accountDigest, identity.bucket]))}`;
117
+ }
118
+ function cleanObservation(value) {
119
+ const parsed = boundedObject(value);
120
+ if (!parsed || parsed.version !== 1 || !SOURCES.includes(parsed.source) || !instant(parsed.observedAt) || !Array.isArray(parsed.windows) || parsed.windows.length > SUBSCRIPTION_QUOTA_LIMITS.windows || typeof parsed.complete !== "boolean") throw quotaError("subscription-invalid-observation");
121
+ const ids = new Set();
122
+ const windows = parsed.windows.map(value => {
123
+ if (!object(value) || !token(value.id) || ids.has(value.id) || !WINDOWS.includes(value.window) || !["account", "scoped"].includes(value.scope) || value.usedPercent !== null && percent(value.usedPercent) === null || value.resetAt !== null && (!instant(value.resetAt) || value.resetAt > parsed.observedAt + SUBSCRIPTION_QUOTA_LIMITS.resetFutureMs) || !["unknown", "available", "exhausted"].includes(value.state)) throw quotaError("subscription-invalid-observation");
124
+ ids.add(value.id);
125
+ if (value.state === "available" && (value.usedPercent === null || value.usedPercent >= 100) || value.state === "unknown" && value.usedPercent !== null || value.state === "exhausted" && value.usedPercent !== null && value.usedPercent < 100) throw quotaError("subscription-invalid-observation");
126
+ return row(value.id, value.window, value.scope, value.usedPercent, value.resetAt, value.state === "exhausted");
127
+ });
128
+ const extra = parsed.extraUsage;
129
+ if (!object(extra)) throw quotaError("subscription-invalid-observation");
130
+ const normalized = observation(parsed.source, parsed.observedAt, windows, extraOf({ is_enabled: extra.enabled, utilization: extra.usedPercent, used_credits: extra.usedCredits, monthly_limit: extra.monthlyLimit, currency: extra.currency, decimal_places: extra.decimalPlaces }));
131
+ if (parsed.complete !== normalized.complete) throw quotaError("subscription-invalid-observation");
132
+ return { ...normalized, ...(parsed.source === "provider-error" && SUBSCRIPTION_QUOTA_CODES.includes(parsed.code) ? { code: parsed.code } : {}) };
133
+ }
134
+ function emptyState(identity) { return { version: 0, identity, source: null, observedAt: null, windows: [], extraUsage: emptyExtra(), trial: null, probeRequired: false, invalidatedAt: null }; }
135
+ function parseState(raw, identity) {
136
+ if (raw === null || raw === undefined) return emptyState(identity);
137
+ const value = boundedObject(raw);
138
+ if (!value || !Number.isSafeInteger(value.version) || value.version < 1 || !instant(value.observedAt) || !Array.isArray(value.windows) || value.windows.length > SUBSCRIPTION_QUOTA_LIMITS.windows || !SOURCES.includes(value.source)) throw quotaError("subscription-store-invalid");
139
+ try {
140
+ const bound = identityOf(value.identity);
141
+ if (subscriptionQuotaKey(bound) !== subscriptionQuotaKey(identity)) throw new Error();
142
+ const clean = cleanObservation({ version: 1, source: value.source, observedAt: value.observedAt, complete: completeOf(value.windows), windows: value.windows, extraUsage: value.extraUsage });
143
+ // Stored rows retain their individual observation times when a later partial read omits them.
144
+ const windows = clean.windows.map((row, index) => {
145
+ if (!instant(value.windows[index].observedAt) || value.windows[index].observedAt > value.observedAt) throw new Error();
146
+ return { ...row, observedAt: value.windows[index].observedAt };
147
+ });
148
+ if (value.trial !== null && (!object(value.trial) || !/^[a-f0-9-]{36}$/.test(value.trial.id) || value.trial.usedBy !== null && !/^[a-f0-9]{64}$/.test(value.trial.usedBy))) throw new Error();
149
+ if (value.probeRequired !== undefined && typeof value.probeRequired !== "boolean" || value.probeRequired === true && !instant(value.invalidatedAt)) throw new Error();
150
+ return { version: value.version, identity: bound, source: value.source, observedAt: value.observedAt, windows, extraUsage: clean.extraUsage, trial: value.trial === null ? null : { id: value.trial.id, usedBy: value.trial.usedBy }, probeRequired: value.probeRequired === true, invalidatedAt: value.probeRequired === true ? value.invalidatedAt : null };
151
+ } catch { throw quotaError("subscription-store-invalid"); }
152
+ }
153
+ function bindingValid(state, identity) { return identity.generation > state.identity.generation || identity.generation === state.identity.generation && identity.owner === state.identity.owner; }
154
+ function statusOf(state, identity, at) {
155
+ const windows = state.windows.map(value => ({ ...value, fresh: !state.probeRequired && at - value.observedAt <= SUBSCRIPTION_QUOTA_LIMITS.freshMs && value.observedAt <= at, state: value.state === "exhausted" && (value.resetAt === null || value.resetAt > at) ? "exhausted" : !state.probeRequired && value.state === "available" && value.observedAt <= at && at - value.observedAt <= SUBSCRIPTION_QUOTA_LIMITS.freshMs && (value.resetAt === null || value.resetAt > at) ? "available" : "unknown" }));
156
+ const blocked = windows.filter(value => value.state === "exhausted");
157
+ return { connectionId: identity.connectionId, accountDigest: identity.accountDigest, bucket: identity.bucket, generation: state.identity.generation, version: state.version, observedAt: state.observedAt, source: state.source, fresh: !state.probeRequired && state.observedAt !== null && at - state.observedAt <= SUBSCRIPTION_QUOTA_LIMITS.freshMs, bindingValid: bindingValid(state, identity), state: blocked.length ? "exhausted" : windows.length && windows.every(value => value.state === "available") ? "available" : "unknown", until: blocked.length && blocked.every(value => value.resetAt !== null) ? Math.max(...blocked.map(value => value.resetAt)) : null, blockedUnknownReset: blocked.some(value => value.resetAt === null), trialAvailable: state.trial !== null && state.trial.usedBy === null, probeRequired: state.probeRequired, invalidatedAt: state.invalidatedAt, windows, extraUsage: state.probeRequired ? emptyExtra() : state.extraUsage };
158
+ }
159
+ const CAS = `local old = redis.call('GET', KEYS[1]); if (old or '') ~= ARGV[1] then return 0 end; redis.call('SET', KEYS[1], ARGV[2]); return 1`;
160
+
161
+ /** Bounded GET/CAS only. Exhaustion without a reset has no TTL and cannot disappear through expiry. */
162
+ export function makeSubscriptionQuotaStore({ redis, now = Date.now } = {}) {
163
+ if (typeof redis?.get !== "function" || typeof redis?.eval !== "function") throw quotaError("subscription-store-unavailable");
164
+ const readRaw = async identity => {
165
+ const raw = await redis.get(subscriptionQuotaKey(identity));
166
+ if (raw !== null && raw !== undefined && (typeof raw !== "string" || Buffer.byteLength(raw) > SUBSCRIPTION_QUOTA_LIMITS.payloadBytes)) throw quotaError("subscription-store-invalid");
167
+ return { raw: raw ?? "", state: parseState(raw, identity) };
168
+ };
169
+ async function mutate(value, fn, { bind = false } = {}) {
170
+ const identity = identityOf(value);
171
+ for (let attempt = 0; attempt < SUBSCRIPTION_QUOTA_LIMITS.casAttempts; attempt++) {
172
+ const { raw, state } = await readRaw(identity), at = now();
173
+ if (!instant(at)) throw quotaError("subscription-invalid-clock");
174
+ const status = statusOf(state, identity, at);
175
+ if (!status.bindingValid) return { ok: false, reason: "subscription-stale-binding", status };
176
+ let result = fn(state, status, at);
177
+ // A selected prepare publishes the newer owner/generation even when quota refuses it. Merely
178
+ // considering a fallback must remain read-only and cannot claim a binding or a person's trial.
179
+ if (bind && state.version > 0 && identity.generation > state.identity.generation && !result.next) result = { ...result, next: state };
180
+ if (!result.next) return result;
181
+ const next = { ...result.next, version: state.version + 1, identity };
182
+ const serialized = JSON.stringify(next);
183
+ if (Buffer.byteLength(serialized) > SUBSCRIPTION_QUOTA_LIMITS.payloadBytes) throw quotaError("subscription-store-capacity");
184
+ if (await redis.eval(CAS, 1, subscriptionQuotaKey(identity), raw, serialized) === 1) {
185
+ const { next: _next, ...returned } = result;
186
+ return { ...returned, status: statusOf(next, identity, at) };
187
+ }
188
+ }
189
+ throw quotaError("subscription-store-conflict");
190
+ }
191
+ return {
192
+ async read(value) { const identity = identityOf(value); return statusOf((await readRaw(identity)).state, identity, now()); },
193
+ async invalidate(value, { observedAt } = {}) {
194
+ if (!instant(observedAt)) throw quotaError("subscription-invalid-observation");
195
+ return mutate(value, (state, status, at) => {
196
+ if (observedAt > at || state.observedAt !== null && observedAt < state.observedAt || state.invalidatedAt !== null && observedAt < state.invalidatedAt) return { ok: false, reason: "subscription-stale-evidence", status };
197
+ return { ok: true, next: { ...state, source: state.source ?? "provider-error", observedAt: state.observedAt ?? observedAt, probeRequired: true, invalidatedAt: observedAt } };
198
+ });
199
+ },
200
+ async record(value, evidence, { expectedVersion } = {}) {
201
+ const incoming = cleanObservation(evidence);
202
+ return mutate(value, (state, status, at) => {
203
+ if (expectedVersion !== undefined && expectedVersion !== state.version) return { ok: false, reason: "subscription-stale-state", status };
204
+ const clearsProbe = state.probeRequired && incoming.source !== "provider-error" && expectedVersion === state.version && incoming.observedAt >= state.invalidatedAt;
205
+ if (state.probeRequired && incoming.source !== "provider-error" && !clearsProbe) return { ok: false, reason: "subscription-stale-state", status };
206
+ if (incoming.observedAt > at || state.observedAt !== null && incoming.observedAt < state.observedAt) return { ok: false, reason: "subscription-stale-evidence", status };
207
+ const tied = incoming.observedAt === state.observedAt;
208
+ if (tied && !clearsProbe && !incoming.windows.some(row => {
209
+ const old = state.windows.find(value => value.id === row.id);
210
+ return row.state === "exhausted" && (old?.state !== "exhausted" || old.resetAt !== null && (row.resetAt === null || row.resetAt > old.resetAt));
211
+ })) return { ok: false, reason: "subscription-stale-evidence", status };
212
+ const rows = new Map(state.windows.map(row => [row.id, row]));
213
+ if (incoming.complete && (!tied || clearsProbe)) rows.delete("provider-error");
214
+ for (const row of incoming.windows) {
215
+ const old = rows.get(row.id);
216
+ if (tied && !clearsProbe && row.state !== "exhausted") continue;
217
+ if (tied && !clearsProbe && old?.state === "exhausted") {
218
+ rows.set(row.id, { ...row, resetAt: old.resetAt === null || row.resetAt === null ? null : Math.max(old.resetAt, row.resetAt), observedAt: incoming.observedAt });
219
+ continue;
220
+ }
221
+ if (row.state === "unknown" && old?.state === "exhausted") continue;
222
+ rows.set(row.id, { ...row, observedAt: incoming.observedAt });
223
+ }
224
+ if (rows.size > SUBSCRIPTION_QUOTA_LIMITS.windows) throw quotaError("subscription-store-capacity");
225
+ return { ok: true, next: { ...state, source: incoming.source, observedAt: incoming.observedAt, windows: [...rows.values()], extraUsage: incoming.source === "provider-error" ? state.extraUsage : incoming.extraUsage, trial: null, ...(clearsProbe ? { probeRequired: false, invalidatedAt: null } : {}) } };
226
+ });
227
+ },
228
+ async admit(value, { windows = ["five-hour", "seven-day"], attemptId, consumeTrial = true, allowExtraUsage = false, maxCreditMicros = null, requireExtraUsageKnownDisabled = false } = {}) {
229
+ if (!Array.isArray(windows) || windows.some(value => !["five-hour", "seven-day"].includes(value)) || new Set(windows).size !== windows.length || allowExtraUsage && (!Number.isSafeInteger(maxCreditMicros) || maxCreditMicros <= 0)) throw quotaError("subscription-invalid-policy");
230
+ return mutate(value, (state, status) => {
231
+ if (state.probeRequired) {
232
+ if (status.state === "exhausted") return { ok: false, reason: "subscription-quota", until: status.until, status };
233
+ return { ok: consumeTrial === false, reason: "subscription-observation-required", requiresUsage: true, status };
234
+ }
235
+ if (requireExtraUsageKnownDisabled && status.extraUsage.enabled !== false && !allowExtraUsage) return { ok: false, reason: "subscription-extra-usage", until: null, status };
236
+ // Monitoring preferences do not authorize spending against any observed exhausted bucket.
237
+ const blocked = status.windows.filter(row => row.state === "exhausted");
238
+ if (blocked.length === 0) return { ok: true, reason: status.state === "unknown" ? "subscription-unknown" : "subscription-available", ...(allowExtraUsage ? { creditCapMicros: maxCreditMicros } : {}), status };
239
+ if (allowExtraUsage && status.extraUsage.enabled === true) return { ok: true, reason: "subscription-available", creditCapMicros: maxCreditMicros, status };
240
+ if (blocked.every(row => row.resetAt === null) && state.trial !== null) {
241
+ if (consumeTrial === false && state.trial.usedBy === null) return { ok: true, reason: "subscription-trial", status };
242
+ if (typeof attemptId !== "string" || attemptId.length === 0 || attemptId.length > 256) throw quotaError("subscription-invalid-trial");
243
+ const usedBy = hash(attemptId);
244
+ if (state.trial.usedBy === usedBy) return { ok: true, reason: "subscription-trial", status };
245
+ if (state.trial.usedBy === null) return { ok: true, reason: "subscription-trial", next: { ...state, trial: { ...state.trial, usedBy } } };
246
+ }
247
+ return { ok: false, reason: "subscription-quota", until: blocked.every(row => row.resetAt !== null) ? Math.max(...blocked.map(row => row.resetAt)) : null, status };
248
+ }, { bind: consumeTrial !== false });
249
+ },
250
+ async grantOneTrial(value, { expectedVersion, actorKind } = {}) {
251
+ if (actorKind !== "person" || !Number.isSafeInteger(expectedVersion) || expectedVersion < 0) throw quotaError("subscription-trial-person-required");
252
+ return mutate(value, (state, status) => {
253
+ if (state.version !== expectedVersion) return { ok: false, reason: "subscription-stale-state", status };
254
+ if (!status.blockedUnknownReset || status.windows.some(row => row.state === "exhausted" && row.resetAt !== null) || status.trialAvailable) return { ok: false, reason: "subscription-stale-state", status };
255
+ return { ok: true, next: { ...state, trial: { id: randomUUID(), usedBy: null } } };
256
+ });
257
+ },
258
+ };
259
+ }
@@ -0,0 +1,123 @@
1
+ import { REASONS } from "./reason-catalog.mjs";
2
+ /** Host-only orchestration. Private snapshots never become job data, run records or telemetry. */
3
+ import { COMMUNITY_AUTH_ADAPTERS } from "./community-auth-metadata.mjs";
4
+ import { builtinModel } from "./model-catalog.mjs";
5
+ import { splitModelEntry } from "./model-ref.mjs";
6
+ import { normalizeClaudeQuota, normalizeOpenAIQuota, normalizeSubscriptionQuotaError } from "./subscription-quota.mjs";
7
+ import { LOCAL_FOLDER_HOLDS_SUBSCRIPTION_AUTH } from "./private-store-boundary.mjs";
8
+
9
+ // Lifecycle errors remain precise on the host terminal; run records use the closed operator catalog.
10
+ function recordRefusal(value) {
11
+ if (!value?.reason?.startsWith("auth-") || Object.hasOwn(REASONS, value.reason)) return value;
12
+ const reason = value.reason.startsWith("auth-adapter-") ? "auth-adapter-unavailable" : value.reason.startsWith("auth-store-") ? "auth-store-unavailable" : value.reason.startsWith("auth-policy-") ? "auth-policy-invalid" : "auth-reauth-required";
13
+ return { ...value, reason };
14
+ }
15
+
16
+ export const subscriptionIdentity = (row) => ({ connectionId: row.connectionId ?? row.id, owner: row.ownerHost, accountDigest: row.accountGroup, bucket: `${row.provider}:subscription`, generation: row.generation });
17
+ export function subscriptionRoutes(job) {
18
+ const refs = new Map();
19
+ const add = (provider, id) => { if (provider && id) refs.set(`${provider}/${id}`, { provider, id }); };
20
+ add(job.provider, job.model);
21
+ for (const entry of job.models ?? []) { const ref = splitModelEntry(entry); if (ref) add(ref.provider, ref.model); }
22
+ for (const role of [job.watchDecisionModel, job.questionGroupModel, job.answerDecisionModel]) add(role?.provider, role?.id);
23
+ return [...refs.values()];
24
+ }
25
+
26
+ export function makeSubscriptionRuntime({ auth, quota, now = Date.now, lookup = builtinModel, packageRootFor = () => null, defaultBackend = "local", isPrivateStoreExposed = () => false } = {}) {
27
+ async function check(job, { attemptId = null, consumeTrial = false } = {}) {
28
+ try {
29
+ if (await isPrivateStoreExposed(job)) return { ok: false, managed: true, reason: LOCAL_FOLDER_HOLDS_SUBSCRIPTION_AUTH };
30
+ } catch (error) {
31
+ return { ok: false, managed: true, reason: "auth-store-unavailable", unavailable: error?.transient !== false };
32
+ }
33
+ const managed = [];
34
+ let unmanaged = false;
35
+ for (const ref of subscriptionRoutes(job)) {
36
+ const model = lookup(ref.provider, ref.id);
37
+ const row = await auth.check(ref.provider, { model: ref.id, api: model?.api, baseUrl: model?.baseUrl });
38
+ if (!row.managed) { unmanaged = true; continue; }
39
+ if (!row.ok) return row.reason === "auth-owner-host" ? { ok: false, managed: true, wait: "subscription-owner", until: now() + 60_000 } : recordRefusal(row);
40
+ if (!model?.api || !model?.baseUrl) return { ok: false, managed: true, reason: "auth-model-unverified" };
41
+ // A finite list fences tools, child sessions and compaction as well as the root call. An unrestricted
42
+ // subscription job could otherwise switch to an unadmitted provider after this free gate.
43
+ if (!Array.isArray(job.models) || job.models.length === 0) return { ok: false, managed: true, reason: "auth-route-unbounded" };
44
+ if (ref.provider === "openai" && (job.watchDecisionModel?.provider === "openai" || job.questionGroupModel?.provider === "openai" || job.answerDecisionModel?.provider === "openai")) return { ok: false, managed: true, reason: "auth-capability-unsupported" };
45
+ if (["openshell", "openshell-bind"].includes(job.backend ?? defaultBackend) || job.canary === true) return { ok: false, managed: true, reason: "auth-venue-unsupported" };
46
+ if (ref.provider === "anthropic") {
47
+ // Excluded by name too (issue #800): the adapter is a staged package, and left out of PI_PACKAGES it would stop the job at its load, inside a started
48
+ // container with its budget held. `row.adapter` is the policy's key, `pi-anthropic-oauth@0.3.6`, which the loader refuses as an extension's name; a trigger
49
+ // names the package, as the worker omits it (run-container.mjs) and `isSubscriptionAdapter` reads it, so the key's `package` is what compares.
50
+ if (job.packages === false || (Array.isArray(job.excludeExtensions) && job.excludeExtensions.includes(COMMUNITY_AUTH_ADAPTERS[row.adapter]?.package))) return { ok: false, managed: true, reason: "auth-adapter-disabled" };
51
+ const staged = packageRootFor(row.adapter);
52
+ if (!staged) return { ok: false, managed: true, reason: "auth-adapter-unavailable" };
53
+ if (staged.refused) return { ok: false, managed: true, reason: staged.refused };
54
+ }
55
+ const paid = Number.isSafeInteger(row.extraUsageCreditMicros) && row.extraUsageCreditMicros > 0;
56
+ if (paid && (!Number.isSafeInteger(job.maxCostMicros) || job.maxCostMicros > row.extraUsageCreditMicros)) return { ok: false, managed: true, reason: "auth-credit-cap-required" };
57
+ const available = await quota.admit(subscriptionIdentity(row), { attemptId, consumeTrial, windows: row.quotaWindows ?? ["five-hour", "seven-day"], allowExtraUsage: paid, maxCreditMicros: row.extraUsageCreditMicros, requireExtraUsageKnownDisabled: ref.provider === "anthropic" });
58
+ const needsUsage = ref.provider === "anthropic" && (available.requiresUsage === true || available.status?.fresh === false && available.status?.state !== "exhausted");
59
+ if (!available.ok && !(consumeTrial === false && needsUsage && available.reason === "subscription-extra-usage" && available.status?.extraUsage?.enabled === null)) return available.reason === "subscription-quota" ? { ...available, managed: true, wait: "subscription-quota", until: available.until ?? now() + 60_000 } : { ...available, managed: true };
60
+ if (!managed.some((entry) => entry.provider === row.provider)) managed.push({ ...row, needsUsage });
61
+ }
62
+ if (managed.length && unmanaged) return { ok: false, managed: true, reason: "auth-mixed-billing" };
63
+ return { ok: true, managed };
64
+ }
65
+ async function prepare(job, { deadlineAt, signal, attemptId } = {}) {
66
+ const preview = await check(job);
67
+ if (!preview.ok) return preview;
68
+ for (const row of preview.managed) if (row.needsUsage) {
69
+ const result = await usage(row.id, { signal });
70
+ if (result.kind !== "observation") return { ok: false, managed: true, reason: "auth-usage-unavailable", unavailable: true };
71
+ }
72
+ const checked = await check(job, { attemptId, consumeTrial: true });
73
+ if (!checked.ok) return checked;
74
+ const snapshots = [];
75
+ for (const row of checked.managed) {
76
+ const prepared = await auth.prepareSnapshot(row.provider, { deadlineAt, signal });
77
+ if (!prepared.ok) return recordRefusal(prepared);
78
+ if (!prepared.snapshot) return { ok: false, managed: true, reason: "auth-binding-changed" };
79
+ snapshots.push({ ...prepared.snapshot, ownerHost: row.ownerHost, ...packageRootFor(row.adapter) });
80
+ }
81
+ return { ok: true, snapshots };
82
+ }
83
+ async function validate(snapshots) {
84
+ for (const snapshot of snapshots) {
85
+ if (!(await auth.validateSnapshot(snapshot))) return false;
86
+ if (snapshot.provider === "anthropic") {
87
+ const staged = packageRootFor(snapshot.adapter);
88
+ if (!staged || staged.refused || staged.root !== snapshot.root || staged.digest !== snapshot.digest) return false;
89
+ }
90
+ }
91
+ return true;
92
+ }
93
+ async function observe(job, result, snapshots) {
94
+ if (result?.exitAuth !== "verified" || result?.exitLineCode !== result?.code || result?.aborted === true) return;
95
+ const snapshot = snapshots.find((row) => row.provider === result.providerFailure?.subscriptionProvider);
96
+ if (snapshot?.provider === "anthropic" && ["quota-exhausted", "rate-limited"].includes(result.providerFailure?.kind)) {
97
+ await quota.invalidate(subscriptionIdentity(snapshot), { observedAt: now() });
98
+ const refreshed = await usage(snapshot.connectionId, { signal: AbortSignal.timeout(15_000) });
99
+ if (refreshed.kind !== "observation") throw new Error("subscription usage unavailable after throttling");
100
+ return;
101
+ }
102
+ if (!snapshot || result.providerFailure?.kind !== "quota-exhausted" || !result.providerFailure.subscriptionCode) return;
103
+ const observation = normalizeSubscriptionQuotaError({ provider: snapshot.provider, code: result.providerFailure.subscriptionCode, status: 429, resetAt: result.providerFailure.resetAt }, { observedAt: now() });
104
+ if (observation) await quota.record(subscriptionIdentity(snapshot), observation);
105
+ }
106
+ async function usage(id, options) {
107
+ const before = (await auth.status()).find((entry) => entry.id === id && entry.active);
108
+ if (!before?.accountGroup) return { kind: "unavailable", reason: "auth-binding-changed" };
109
+ const version = (await quota.read(subscriptionIdentity(before))).version;
110
+ const result = await auth.usage(id, options);
111
+ if (result.kind !== "observation") return result;
112
+ const row = (await auth.status()).find((entry) => entry.id === id && entry.active);
113
+ if (!row || row.provider !== result.provider || row.ownerHost !== result.ownerHost || row.accountGroup !== result.accountGroup || row.generation !== result.generation) return { kind: "unavailable", reason: "auth-binding-changed" };
114
+ const observation = (row.provider === "anthropic" ? normalizeClaudeQuota : normalizeOpenAIQuota)(result.payload, { observedAt: result.observedAt });
115
+ const written = await quota.record(subscriptionIdentity(row), observation, { expectedVersion: version });
116
+ return { kind: written.ok ? "observation" : "unavailable", reason: written.reason ?? null, quota: written.status };
117
+ }
118
+ async function status() {
119
+ const rows = await auth.status();
120
+ return { state: "ready", partial: false, connections: await Promise.all(rows.map(async (row) => ({ ...row, ...(row.accountGroup ? { quota: await quota.read(subscriptionIdentity(row)) } : {}) }))) };
121
+ }
122
+ return { check, prepare, validate, observe, usage, status };
123
+ }