@bitkyc08/opencodex 2.89.0 → 2.91.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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Assemble `ocx.quota-pool/1` (`GET /api/quota-pool`) from what this Home already holds.
3
+ *
4
+ * Strictly passive. Every source below is a cache another path filled; this module starts no
5
+ * probe, token refresh, quota refresh or upstream call and writes nothing. The whole assembly runs
6
+ * inside `runPassiveRead`, so even the shared readers it calls (the Codex account list, the OAuth
7
+ * store) take their passive branch: no WHAM probe, no native-main claim, no file hardening or
8
+ * malformed-store backup. Sources:
9
+ *
10
+ * - Codex pool accounts: the passive `listCodexAuthAccountsSnapshot` (stored `StoredAccountQuota`
11
+ * per account plus the cached main-account reading);
12
+ * - provider-level reports: `heldProviderQuotaReports`, the reports the Home last published
13
+ * (for the Codex pool provider this is OpenCodex's own pool aggregate, reported as `pooled`);
14
+ * - per-account OAuth quota (Anthropic instances, Kiro, Antigravity, xAI, Cursor, Kimi, Command
15
+ * Code, Devin, DSH, Meta): the last-good per-account cache;
16
+ * - Claude CLI: the usage-admission snapshot (5-hour, weekly and family windows, plus a parsed
17
+ * CLI limit refusal).
18
+ *
19
+ * An unknown window is omitted, never emitted as 0%. A window whose reset has passed is unknown
20
+ * again and is omitted too. Optional fields are left out instead of set to null.
21
+ */
22
+ import type { OcxConfig, OcxProviderConfig } from "../types";
23
+ import type { ProviderQuota, ProviderQuotaWindow } from "../providers/quota-types";
24
+ import type { StoredAccountQuota } from "../codex/quota-types";
25
+ import { isTerminalShortWindow, resetAtToMs } from "../codex/quota-types";
26
+ import { runPassiveRead } from "../lib/passive-read";
27
+ import { packageVersion } from "../lib/package-version";
28
+ import { listCodexAuthAccountsSnapshot } from "../codex/auth-api";
29
+ import { latestCodexQuotaObservationSource } from "../codex/quota";
30
+ import { ACCOUNT_LOG_LABEL_RE, oauthAccountLogLabel } from "../codex/account-label";
31
+ import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers-destination";
32
+ import { hasCodexPoolProvider, heldProviderQuotaReports, type ProviderQuotaReport } from "../providers/quota/report-cache";
33
+ import {
34
+ accountCacheKey,
35
+ accountQuotaCache,
36
+ getCachedProviderAccountQuota,
37
+ hasPassiveAccountQuota,
38
+ supportsPerAccountQuota,
39
+ } from "../providers/quota/account-cache";
40
+ import { parseQuotaFailureCode } from "../providers/quota-types";
41
+ import { getAccountSet } from "../oauth/store";
42
+ import { peekClaudeUsageObservation } from "../adapters/claude-cli/usage-admission";
43
+ import {
44
+ QUOTA_POOL_SCHEMA,
45
+ type QuotaPool,
46
+ type QuotaPoolAccount,
47
+ type QuotaPoolAccountState,
48
+ type QuotaPoolProvider,
49
+ type QuotaPoolRole,
50
+ type QuotaPoolSource,
51
+ type QuotaPoolWindow,
52
+ } from "./contract";
53
+ import { quotaPoolAccountRef, readQuotaPoolIdentity, type QuotaPoolIdentity } from "./identity";
54
+
55
+ const VERSION = packageVersion("0.0.0");
56
+ const SHORT_BURST_MAX_SECONDS = 6 * 3600;
57
+ const SHORT_DAILY_MAX_SECONDS = 36 * 3600;
58
+
59
+ export interface QuotaPoolDeps {
60
+ now?: () => number;
61
+ /** Test seam for the Claude CLI admission file. */
62
+ claudeUsageStatePath?: string;
63
+ }
64
+
65
+ export type QuotaPoolResult = { ok: true; pool: QuotaPool } | { ok: false; error: "quota_pool_identity_unavailable" };
66
+
67
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
68
+
69
+ function iso(ms: number): string | undefined {
70
+ if (!finite(ms) || ms <= 0) return undefined;
71
+ const date = new Date(ms);
72
+ return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
73
+ }
74
+
75
+ /** A used percent inside the contract's 0..100, or undefined when there is no reading. */
76
+ function percent(value: unknown): number | undefined {
77
+ if (!finite(value) || value < 0) return undefined;
78
+ return Math.min(100, value);
79
+ }
80
+
81
+ type WindowDraft = Omit<QuotaPoolWindow, "observedAt" | "source"> & { resetMs?: number };
82
+
83
+ /**
84
+ * Finish a window, or drop it when it carries nothing (no percent, no counters) or its reset has
85
+ * already passed: an elapsed window is unknown now, not empty.
86
+ */
87
+ function finish(draft: WindowDraft, source: QuotaPoolSource, observedMs: number, now: number): QuotaPoolWindow | null {
88
+ const observedAt = iso(observedMs);
89
+ if (!observedAt) return null;
90
+ const { resetMs, ...rest } = draft;
91
+ if (resetMs !== undefined && resetMs <= now) return null;
92
+ if (rest.usedPercent === undefined && rest.used === undefined && rest.limit === undefined && rest.exhausted === undefined) return null;
93
+ const resetsAt = resetMs !== undefined ? iso(resetMs) : undefined;
94
+ const exhausted = rest.exhausted ?? (rest.usedPercent !== undefined ? rest.usedPercent >= 100 : undefined);
95
+ const window: QuotaPoolWindow = { ...rest, source, observedAt };
96
+ if (resetsAt) window.resetsAt = resetsAt;
97
+ if (exhausted !== undefined) window.exhausted = exhausted;
98
+ return window;
99
+ }
100
+
101
+ function resetMs(value: unknown): number | undefined {
102
+ return finite(value) && value > 0 ? resetAtToMs(value) : undefined;
103
+ }
104
+
105
+ function shortKind(seconds: number | undefined): Pick<QuotaPoolWindow, "id" | "kind" | "label"> {
106
+ if (seconds === undefined || seconds <= SHORT_BURST_MAX_SECONDS) return { id: "5h", kind: "burst_5h", label: "5-hour" };
107
+ if (seconds <= SHORT_DAILY_MAX_SECONDS) return { id: "daily", kind: "daily", label: "Daily" };
108
+ return { id: "short", kind: "custom", label: "Short window" };
109
+ }
110
+
111
+ /** Codex `StoredAccountQuota`, mapped as the header and WHAM parsers stored it. */
112
+ export function codexQuotaWindows(quota: StoredAccountQuota, source: QuotaPoolSource, now: number): QuotaPoolWindow[] {
113
+ const out: QuotaPoolWindow[] = [];
114
+ const push = (window: QuotaPoolWindow | null) => { if (window) out.push(window); };
115
+ const shortPercent = percent(quota.shortPercent);
116
+ const shortSeconds = finite(quota.shortWindowSeconds) && quota.shortWindowSeconds > 0 ? quota.shortWindowSeconds : undefined;
117
+ if (shortPercent !== undefined) {
118
+ push(finish({
119
+ ...shortKind(shortSeconds),
120
+ usedPercent: shortPercent,
121
+ ...(shortSeconds !== undefined ? { windowSeconds: shortSeconds } : {}),
122
+ resetMs: resetMs(quota.shortResetAt),
123
+ exhausted: isTerminalShortWindow(quota, now),
124
+ }, source, quota.shortObservedAt ?? quota.updatedAt, now));
125
+ }
126
+ const weekly = percent(quota.weeklyPercent);
127
+ if (weekly !== undefined) {
128
+ push(finish({ id: "weekly", kind: "weekly", label: "Weekly", usedPercent: weekly, resetMs: resetMs(quota.weeklyResetAt) }, source, quota.updatedAt, now));
129
+ }
130
+ const monthly = percent(quota.monthlyPercent);
131
+ if (monthly !== undefined) {
132
+ push(finish({ id: "monthly", kind: "monthly", label: "Monthly", usedPercent: monthly, resetMs: resetMs(quota.monthlyResetAt) }, source, quota.updatedAt, now));
133
+ }
134
+ for (const custom of quota.customWindows ?? []) {
135
+ const used = percent(custom?.percent);
136
+ if (typeof custom?.label !== "string" || !custom.label.trim() || used === undefined) continue;
137
+ push(finish({ id: `custom:${custom.label}`, kind: "custom", label: custom.label, usedPercent: used, resetMs: resetMs(custom.resetAt) }, source, quota.updatedAt, now));
138
+ }
139
+ return out;
140
+ }
141
+
142
+ function customWindow(window: ProviderQuotaWindow, source: QuotaPoolSource, updatedAt: number, now: number): QuotaPoolWindow | null {
143
+ if (typeof window?.label !== "string" || !window.label.trim()) return null;
144
+ // An exact-model row (Antigravity diagnostics, CCA fallback) restates the pool its family
145
+ // window already reports and carries no proven scope; reporting it unscoped would let it gate
146
+ // every model.
147
+ if (window.modelId !== undefined && window.scope !== "model") return null;
148
+ const scoped = window.scope === "model";
149
+ const passive = finite(window.passiveObservedAt);
150
+ return finish({
151
+ id: scoped ? `family:${window.label.toLowerCase()}` : `custom:${window.label}`,
152
+ kind: "custom",
153
+ label: window.label,
154
+ ...(percent(window.percent) !== undefined ? { usedPercent: percent(window.percent) } : {}),
155
+ ...(scoped ? { scopeModel: window.label } : {}),
156
+ ...(window.rejected === true ? { exhausted: true } : {}),
157
+ resetMs: resetMs(window.resetAt),
158
+ }, passive ? "header" : source, passive ? window.passiveObservedAt! : updatedAt, now);
159
+ }
160
+
161
+ /** A `ProviderQuota` (provider report or per-account cache row). */
162
+ export function providerQuotaWindows(quota: ProviderQuota, source: QuotaPoolSource, now: number): QuotaPoolWindow[] {
163
+ const out: QuotaPoolWindow[] = [];
164
+ const push = (window: QuotaPoolWindow | null) => { if (window) out.push(window); };
165
+ const at = quota.updatedAt;
166
+ const fixed = [
167
+ ["5h", "burst_5h", "5-hour", quota.fiveHourPercent, quota.fiveHourResetAt],
168
+ ["weekly", "weekly", "Weekly", quota.weeklyPercent, quota.weeklyResetAt],
169
+ ["monthly", "monthly", "Monthly", quota.monthlyPercent, quota.monthlyResetAt],
170
+ ] as const;
171
+ for (const [id, kind, label, value, reset] of fixed) {
172
+ const used = percent(value);
173
+ if (used === undefined) continue;
174
+ push(finish({ id, kind, label, usedPercent: used, resetMs: resetMs(reset) }, source, at, now));
175
+ }
176
+ const kiroUsed = finite(quota.kiroCreditsUsed) && quota.kiroCreditsUsed >= 0 ? quota.kiroCreditsUsed : undefined;
177
+ const kiroLimit = finite(quota.kiroCreditsLimit) && quota.kiroCreditsLimit > 0 ? quota.kiroCreditsLimit : undefined;
178
+ if (kiroUsed !== undefined || kiroLimit !== undefined) {
179
+ push(finish({
180
+ id: "credits:kiro", kind: "credits", label: "Kiro credits", unit: "credits",
181
+ ...(kiroUsed !== undefined ? { used: kiroUsed } : {}),
182
+ ...(kiroLimit !== undefined ? { limit: kiroLimit } : {}),
183
+ ...(kiroUsed !== undefined && kiroLimit !== undefined ? { usedPercent: percent((kiroUsed / kiroLimit) * 100) } : {}),
184
+ }, source, at, now));
185
+ }
186
+ const usd = quota.creditsUsd;
187
+ if (usd && finite(usd.used) && usd.used >= 0) {
188
+ const unlimited = usd.unlimited === true;
189
+ push(finish({
190
+ id: "credits:usd", kind: "credits", label: "USD credits", unit: "usd", used: usd.used,
191
+ ...(!unlimited && finite(usd.limit) && usd.limit > 0 ? { limit: usd.limit } : {}),
192
+ ...(!unlimited && percent(usd.percent) !== undefined ? { usedPercent: percent(usd.percent) } : {}),
193
+ ...(unlimited ? { exhausted: false } : finite(usd.remaining) ? { exhausted: usd.remaining <= 0 } : {}),
194
+ }, source, at, now));
195
+ }
196
+ for (const custom of quota.customWindows ?? []) push(customWindow(custom, source, at, now));
197
+ return out;
198
+ }
199
+
200
+ function accountState(input: { reauth: boolean; paused: boolean; windows: readonly QuotaPoolWindow[] }): QuotaPoolAccountState {
201
+ if (input.reauth) return "reauth";
202
+ if (input.paused) return "paused";
203
+ // A model-scoped window never gates the account as a whole.
204
+ if (input.windows.some(window => window.exhausted === true && window.scopeModel === undefined)) return "exhausted";
205
+ return input.windows.length === 0 ? "unknown" : "available";
206
+ }
207
+
208
+ function nextResetAt(windows: readonly QuotaPoolWindow[]): string | undefined {
209
+ const resets = windows.filter(window => window.scopeModel === undefined && window.resetsAt).map(window => window.resetsAt!).sort();
210
+ return resets[0];
211
+ }
212
+
213
+ function reportSource(report: ProviderQuotaReport): QuotaPoolSource {
214
+ if (report.aggregation) return "estimated";
215
+ return report.observed === true ? "header" : "measured";
216
+ }
217
+
218
+ function codexAccounts(identity: QuotaPoolIdentity, accounts: Awaited<ReturnType<typeof listCodexAuthAccountsSnapshot>>["accounts"], now: number): QuotaPoolAccount[] {
219
+ const provider = OPENAI_CODEX_PROVIDER_ID;
220
+ return accounts.map(dto => {
221
+ const source: QuotaPoolSource = latestCodexQuotaObservationSource(dto.id) === "response-header" ? "header" : "measured";
222
+ const windows = dto.quota ? codexQuotaWindows(dto.quota as StoredAccountQuota, source, now) : [];
223
+ const account: QuotaPoolAccount = {
224
+ provider,
225
+ accountRef: quotaPoolAccountRef(identity, provider, dto.id),
226
+ // needsReauth only: a passive read leaves hasCredential false when it did not check, and an
227
+ // account without a credential already carries needsReauth.
228
+ state: accountState({ reauth: dto.needsReauth === true, paused: dto.paused, windows }),
229
+ windows,
230
+ };
231
+ if (typeof dto.logLabel === "string" && ACCOUNT_LOG_LABEL_RE.test(dto.logLabel)) account.label = dto.logLabel;
232
+ if (typeof dto.plan === "string" && dto.plan.trim()) account.plan = dto.plan.trim();
233
+ return account;
234
+ });
235
+ }
236
+
237
+ function oauthAccounts(identity: QuotaPoolIdentity, provider: string, now: number): QuotaPoolAccount[] {
238
+ const set = getAccountSet(provider);
239
+ if (!set) return [];
240
+ return set.accounts.map(row => {
241
+ const quota = getCachedProviderAccountQuota(provider, row.id);
242
+ const entry = accountQuotaCache.get(accountCacheKey(provider, row.id));
243
+ const windows = quota ? providerQuotaWindows(quota, "measured", now) : [];
244
+ const account: QuotaPoolAccount = {
245
+ provider,
246
+ accountRef: quotaPoolAccountRef(identity, provider, row.id),
247
+ label: oauthAccountLogLabel(row.id, provider),
248
+ state: accountState({ reauth: row.needsReauth === true, paused: row.paused === true, windows }),
249
+ windows,
250
+ };
251
+ const failure = entry?.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? parseQuotaFailureCode(entry.quotaFailure) : undefined;
252
+ if (failure) account.failure = failure;
253
+ return account;
254
+ });
255
+ }
256
+
257
+ function claudeCliAccounts(identity: QuotaPoolIdentity, provider: string, deps: QuotaPoolDeps, now: number): QuotaPoolAccount[] {
258
+ const observation = peekClaudeUsageObservation(deps.claudeUsageStatePath ? { statePath: deps.claudeUsageStatePath } : {});
259
+ if (!observation) return [];
260
+ const windows = observation.quota ? providerQuotaWindows(observation.quota, "measured", now) : [];
261
+ if (finite(observation.refusalUntil) && observation.refusalUntil > now) {
262
+ const refusal = finish({ id: "refusal", kind: "custom", label: "Claude CLI limit", exhausted: true, resetMs: observation.refusalUntil },
263
+ "estimated", observation.checkedAt, now);
264
+ if (refusal) windows.push(refusal);
265
+ }
266
+ return [{
267
+ provider,
268
+ accountRef: quotaPoolAccountRef(identity, provider, `claude-cli:${observation.identity}`),
269
+ state: accountState({ reauth: false, paused: false, windows }),
270
+ windows,
271
+ }];
272
+ }
273
+
274
+ function role(config: OcxConfig): QuotaPoolRole {
275
+ return config.runtimeRole === "hub" || config.runtimeRole === "client" ? config.runtimeRole : "standalone";
276
+ }
277
+
278
+ function enabled(provider: OcxProviderConfig | undefined): provider is OcxProviderConfig {
279
+ return !!provider && provider.disabled !== true;
280
+ }
281
+
282
+ async function assemble(config: OcxConfig, identity: QuotaPoolIdentity, deps: QuotaPoolDeps): Promise<QuotaPool> {
283
+ const now = (deps.now ?? Date.now)();
284
+ const names = Object.keys(config.providers).filter(name => enabled(config.providers[name])).sort();
285
+ const reports = new Map(heldProviderQuotaReports(names).reports.map(report => [report.provider, report]));
286
+ const codexPool = hasCodexPoolProvider(config);
287
+ const codex = codexPool ? (await listCodexAuthAccountsSnapshot(config)).accounts : [];
288
+ const providers: QuotaPoolProvider[] = [];
289
+ for (const name of names) {
290
+ const provider = config.providers[name]!;
291
+ let accounts: QuotaPoolAccount[] = [];
292
+ let tracked = false;
293
+ if (codexPool && name === OPENAI_CODEX_PROVIDER_ID) {
294
+ tracked = true;
295
+ accounts = codexAccounts(identity, codex, now);
296
+ } else if (provider.adapter === "claude-cli") {
297
+ tracked = true;
298
+ accounts = claudeCliAccounts(identity, name, deps, now);
299
+ } else if (supportsPerAccountQuota(name) || hasPassiveAccountQuota(name)) {
300
+ tracked = true;
301
+ accounts = oauthAccounts(identity, name, now);
302
+ }
303
+ const report = reports.get(name);
304
+ const pooledWindows = report ? providerQuotaWindows(report.quota, reportSource(report), now) : [];
305
+ if (!tracked && !report) continue;
306
+ const pooled: QuotaPoolProvider["pooled"] = { windows: pooledWindows };
307
+ const next = nextResetAt(pooledWindows);
308
+ if (next) pooled.nextResetAt = next;
309
+ providers.push({ provider: name, accounts, pooled });
310
+ }
311
+ return {
312
+ schema: QUOTA_POOL_SCHEMA,
313
+ producer: { version: VERSION, role: role(config), homeId: identity.homeId },
314
+ generatedAt: iso(now) ?? new Date().toISOString(),
315
+ providers,
316
+ };
317
+ }
318
+
319
+ /** The quota pool, or an identity error. Always a passive read, whoever asks. */
320
+ export function readQuotaPool(config: OcxConfig, deps: QuotaPoolDeps = {}): Promise<QuotaPoolResult> {
321
+ return runPassiveRead(async () => {
322
+ const identity = readQuotaPoolIdentity();
323
+ if (!identity) return { ok: false, error: "quota_pool_identity_unavailable" } as const;
324
+ return { ok: true, pool: await assemble(config, identity, deps) } as const;
325
+ });
326
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Wire shape of `GET /api/quota-pool`: the GOAT contract `ocx.quota-pool/1`.
3
+ *
4
+ * GOAT owns the decoder (`packages/contracts/src/quotaPool.ts` in goat-si/goat); OpenCodex is
5
+ * the producer. Every optional field is OMITTED when OpenCodex does not know it. A missing
6
+ * window means unknown, never 0%, and no field is ever `null`.
7
+ */
8
+
9
+ export const QUOTA_POOL_SCHEMA = "ocx.quota-pool/1" as const;
10
+
11
+ export type QuotaPoolWindowKind = "burst_5h" | "daily" | "weekly" | "monthly" | "credits" | "custom";
12
+ /** measured: a usage probe body; header: response rate-limit headers; estimated: derived by OpenCodex. */
13
+ export type QuotaPoolSource = "measured" | "header" | "estimated";
14
+ export type QuotaPoolUnit = "requests" | "tokens" | "credits" | "usd";
15
+ export type QuotaPoolAccountState = "available" | "exhausted" | "paused" | "reauth" | "unknown";
16
+ export type QuotaPoolRole = "standalone" | "hub" | "client";
17
+
18
+ export interface QuotaPoolWindow {
19
+ id: string;
20
+ kind: QuotaPoolWindowKind;
21
+ label: string;
22
+ usedPercent?: number;
23
+ used?: number;
24
+ limit?: number;
25
+ unit?: QuotaPoolUnit;
26
+ resetsAt?: string;
27
+ windowSeconds?: number;
28
+ scopeModel?: string;
29
+ source: QuotaPoolSource;
30
+ observedAt: string;
31
+ exhausted?: boolean;
32
+ }
33
+
34
+ export interface QuotaPoolAccount {
35
+ provider: string;
36
+ driver?: string;
37
+ /** Opaque, stable for this install, never an email or a raw account id (see identity.ts). */
38
+ accountRef: string;
39
+ label?: string;
40
+ plan?: string;
41
+ state: QuotaPoolAccountState;
42
+ windows: QuotaPoolWindow[];
43
+ /** A closed `QuotaFailureCode`; never upstream text. */
44
+ failure?: string;
45
+ }
46
+
47
+ export interface QuotaPoolProvider {
48
+ provider: string;
49
+ accounts: QuotaPoolAccount[];
50
+ pooled: { windows: QuotaPoolWindow[]; headroom?: number; nextResetAt?: string };
51
+ }
52
+
53
+ export interface QuotaPool {
54
+ schema: typeof QUOTA_POOL_SCHEMA;
55
+ producer: { version: string; role: QuotaPoolRole; homeId: string };
56
+ generatedAt: string;
57
+ providers: QuotaPoolProvider[];
58
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The install identity behind `ocx.quota-pool/1`: the Home's `homeId` and the secret that keys
3
+ * every `accountRef`.
4
+ *
5
+ * Both live in `<OPENCODEX_HOME>/quota-pool-identity.json` (owner-only). The server creates the
6
+ * file once, in the background after it starts listening (`ensureQuotaPoolIdentity`); the
7
+ * `/api/quota-pool` read only reads it, so the read path never writes. A read that finds no
8
+ * valid file reports the identity as unavailable instead of minting one.
9
+ *
10
+ * `accountRef` is `acct_` + base64url(HMAC-SHA256(secret, "ocx.quota-pool/1" NUL provider NUL
11
+ * accountId)) truncated to 128 bits. It is stable across restarts because the secret is
12
+ * persisted, distinct per provider, and not reversible: without the per-install secret a
13
+ * recipient cannot confirm a guessed email or account id against it, and it never contains
14
+ * either. Deleting the file rotates `homeId` and every `accountRef` together.
15
+ */
16
+ import { createHmac, randomBytes, randomUUID } from "node:crypto";
17
+ import { closeSync, constants as fsConstants, fsyncSync, linkSync, lstatSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { getConfigDir } from "../config/paths";
20
+
21
+ export const QUOTA_POOL_IDENTITY_FILE = "quota-pool-identity.json";
22
+
23
+ export interface QuotaPoolIdentity {
24
+ homeId: string;
25
+ secret: string;
26
+ }
27
+
28
+ const HOME_ID = /^home_[0-9a-f]{32}$/;
29
+ const SECRET = /^[0-9a-f]{64}$/;
30
+ let memo: { path: string; identity: QuotaPoolIdentity } | null = null;
31
+
32
+ export function quotaPoolIdentityPath(configDir = getConfigDir()): string {
33
+ return join(configDir, QUOTA_POOL_IDENTITY_FILE);
34
+ }
35
+
36
+ function parseIdentity(text: string): QuotaPoolIdentity | null {
37
+ try {
38
+ const raw = JSON.parse(text) as Record<string, unknown>;
39
+ if (raw?.version !== 1 || typeof raw.homeId !== "string" || typeof raw.secret !== "string") return null;
40
+ if (!HOME_ID.test(raw.homeId) || !SECRET.test(raw.secret)) return null;
41
+ return { homeId: raw.homeId, secret: raw.secret };
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** Read-only: the persisted identity, or null when it is missing or malformed. Never writes. */
48
+ export function readQuotaPoolIdentity(configDir = getConfigDir()): QuotaPoolIdentity | null {
49
+ const path = quotaPoolIdentityPath(configDir);
50
+ if (memo?.path === path) return memo.identity;
51
+ try {
52
+ const stat = lstatSync(path);
53
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) return null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ let identity: QuotaPoolIdentity | null;
58
+ try { identity = parseIdentity(readFileSync(path, "utf8")); } catch { return null; }
59
+ if (identity) memo = { path, identity };
60
+ return identity;
61
+ }
62
+
63
+ const NOFOLLOW = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
64
+ /** How long a loser waits for a concurrent winner's file to become readable before giving up. */
65
+ const PUBLISHED_READ_ATTEMPTS = 50;
66
+ const PUBLISHED_READ_INTERVAL_MS = 10;
67
+
68
+ /** Test seams for the publication windows; production never sets them. */
69
+ export interface QuotaPoolIdentityTestHooks {
70
+ /** After the first read found no identity, before the existence check. */
71
+ afterAbsentRead?: (path: string) => void;
72
+ /** After a file was exclusively created, before its bytes are written. */
73
+ afterCreate?: (path: string) => void;
74
+ /** Replace `linkSync` (to force the in-place fallback). */
75
+ link?: (existing: string, target: string) => void;
76
+ /** Override the bounded wait for a concurrent winner. */
77
+ publishedReadAttempts?: number;
78
+ }
79
+ let hooks: QuotaPoolIdentityTestHooks = {};
80
+
81
+ export function setQuotaPoolIdentityHooksForTests(next: QuotaPoolIdentityTestHooks = {}): void {
82
+ hooks = next;
83
+ }
84
+
85
+ /** Write a whole new owner-only file, flushed to disk; fails if anything (a symlink included) already exists. */
86
+ function createExclusive(path: string, content: string): void {
87
+ const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NOFOLLOW, 0o600);
88
+ try {
89
+ hooks.afterCreate?.(path);
90
+ writeSync(fd, content);
91
+ fsyncSync(fd);
92
+ } finally { closeSync(fd); }
93
+ }
94
+
95
+ function errorCode(error: unknown): string | undefined {
96
+ return (error as NodeJS.ErrnoException | null)?.code;
97
+ }
98
+
99
+ /**
100
+ * Make the new directory entry durable too: fsyncing the file alone can lose the entry on power
101
+ * loss, and a lost entry would mint a new identity and rotate every accountRef on the next start.
102
+ * Best effort; Windows cannot open a directory for fsync.
103
+ */
104
+ function syncParentDirectory(path: string): void {
105
+ if (process.platform === "win32") return;
106
+ try {
107
+ const fd = openSync(dirname(path), fsConstants.O_RDONLY);
108
+ try { fsyncSync(fd); } finally { closeSync(fd); }
109
+ } catch { /* the file itself is flushed; a directory fsync is an extra guarantee */ }
110
+ }
111
+
112
+ function pause(ms: number): void {
113
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
114
+ }
115
+
116
+ /**
117
+ * Something already sits at the path: adopt it once it reads as a valid identity.
118
+ *
119
+ * A concurrent winner's file is complete the moment it appears when it was hard-linked, but the
120
+ * in-place fallback creates the file before writing it, so a loser can briefly see it empty. The
121
+ * wait is bounded; an invalid result is never returned or memoized. A symlink, or a file that stays
122
+ * invalid past the bound, is refused and left alone.
123
+ */
124
+ function adoptPublishedIdentity(configDir: string, path: string): QuotaPoolIdentity {
125
+ const attempts = hooks.publishedReadAttempts ?? PUBLISHED_READ_ATTEMPTS;
126
+ for (let attempt = 0; attempt < attempts; attempt++) {
127
+ let stat;
128
+ try { stat = lstatSync(path); } catch (error) {
129
+ if (errorCode(error) !== "ENOENT") throw error;
130
+ stat = null;
131
+ }
132
+ if (stat && (stat.isSymbolicLink() || !stat.isFile())) break;
133
+ const identity = readQuotaPoolIdentity(configDir);
134
+ if (identity) return identity;
135
+ if (attempt + 1 < attempts) pause(PUBLISHED_READ_INTERVAL_MS);
136
+ }
137
+ throw new Error("quota-pool identity file is not a valid regular file; remove it to rotate the identity");
138
+ }
139
+
140
+ /**
141
+ * Create the identity once. Called off the request path (server startup); idempotent.
142
+ *
143
+ * Publication is exclusive and never follows a link: the secret is written and flushed to a
144
+ * private temp file, then hard-linked into place, which fails if anything already sits at the path.
145
+ * Where hard links are unavailable it is created in place exclusively. Two servers starting on one
146
+ * fresh home therefore cannot each keep a different identity: a loser, including one that finds
147
+ * the winner's file appear between its own checks, re-reads within a bound and adopts the winner's.
148
+ * A symlink or a persistently malformed file at the path is refused and left alone (the read keeps
149
+ * reporting the identity as unavailable until an operator removes it); it is never overwritten.
150
+ */
151
+ export function ensureQuotaPoolIdentity(configDir = getConfigDir()): QuotaPoolIdentity {
152
+ const existing = readQuotaPoolIdentity(configDir);
153
+ if (existing) return existing;
154
+ const path = quotaPoolIdentityPath(configDir);
155
+ hooks.afterAbsentRead?.(path);
156
+ let present = true;
157
+ try { lstatSync(path); } catch (error) { if (errorCode(error) !== "ENOENT") throw error; present = false; }
158
+ if (present) return adoptPublishedIdentity(configDir, path);
159
+ const identity: QuotaPoolIdentity = {
160
+ homeId: `home_${randomUUID().replaceAll("-", "")}`,
161
+ secret: randomBytes(32).toString("hex"),
162
+ };
163
+ const content = `${JSON.stringify({ version: 1, ...identity })}\n`;
164
+ const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
165
+ let published = false;
166
+ try {
167
+ createExclusive(temp, content);
168
+ try {
169
+ (hooks.link ?? linkSync)(temp, path);
170
+ published = true;
171
+ } catch (error) {
172
+ const code = errorCode(error);
173
+ if (code === "EEXIST") return adoptPublishedIdentity(configDir, path);
174
+ // No hard links on this filesystem: exclusive create in place is still never a replacement.
175
+ if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV" && code !== "ENOSYS") throw error;
176
+ }
177
+ } finally {
178
+ try { unlinkSync(temp); } catch { /* best effort */ }
179
+ }
180
+ if (!published) {
181
+ try {
182
+ createExclusive(path, content);
183
+ } catch (error) {
184
+ if (errorCode(error) !== "EEXIST") throw error;
185
+ return adoptPublishedIdentity(configDir, path);
186
+ }
187
+ }
188
+ syncParentDirectory(path);
189
+ memo = { path, identity };
190
+ return identity;
191
+ }
192
+
193
+ /** The opaque account reference: keyed by the install secret, never the raw id or an email. */
194
+ export function quotaPoolAccountRef(identity: QuotaPoolIdentity, provider: string, accountId: string): string {
195
+ const digest = createHmac("sha256", Buffer.from(identity.secret, "hex"))
196
+ .update(`ocx.quota-pool/1\0${provider}\0${accountId}`)
197
+ .digest();
198
+ return `acct_${digest.subarray(0, 16).toString("base64url")}`;
199
+ }
200
+
201
+ export function resetQuotaPoolIdentityMemoForTests(): void {
202
+ memo = null;
203
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The one registry of peer-visible features this build serves, advertised in `GET /readyz`
3
+ * `features` so a Hub, a Child or GOAT decides compatibility by feature instead of parsing versions.
4
+ *
5
+ * Every entry reads the module that owns the capability; nothing here restates a fact by hand:
6
+ *
7
+ * - `quota-pool/1` when the management route registry declares `GET /api/quota-pool`. The registry
8
+ * is reconciled against dispatch source (`tests/server/management-route-registry.test.ts`), so the
9
+ * feature appears exactly when the route is reachable.
10
+ * - `fallback-policy/1` when the registry declares `GET /api/fallback` (OCX-11). GOAT reads a Home
11
+ * without it as "fallback state unknown".
12
+ * - `link-management-relay` when the Remote Link management read table is non-empty.
13
+ * - `served-by-header` when the served-by emitter says responses carry the header.
14
+ * - `relay-auth/<n>` with the connection-bound link relay auth version.
15
+ * - `link-peer-report/<n>` with the version of the report a Child sends on the relay challenge and a
16
+ * Home records for `ocx link status` (`src/link/peer-report.ts`).
17
+ *
18
+ * The ids are static strings. They carry no account, credential or configuration data, which is
19
+ * what keeps the unauthenticated `/readyz` body safe. `parseRemoteReadyMetadata` accepts at most
20
+ * 64 unique ids of at most 80 characters, and `tests/server/readyz-features.test.ts` holds the
21
+ * full registry to that.
22
+ */
23
+ import { LINK_MANAGEMENT_READ_PATHS } from "../link/management-routes";
24
+ import { LINK_PEER_REPORT_VERSION } from "../link/peer-report";
25
+ import { LINK_RELAY_AUTH_VERSION } from "../link/relay-auth";
26
+ import { MANAGEMENT_ROUTES, type ManagementRoute } from "../server/management/route-registry";
27
+ import { SERVED_BY_HEADER_EMITTED } from "./served-by";
28
+
29
+ export const QUOTA_POOL_PATH = "/api/quota-pool";
30
+ export const FALLBACK_STATE_PATH = "/api/fallback";
31
+
32
+ /** The owning sources the registry reads. Tests pass their own to drive presence and absence. */
33
+ export interface PeerFeatureSources {
34
+ readonly managementRoutes: readonly Pick<ManagementRoute, "method" | "path">[];
35
+ readonly linkManagementReadPaths: readonly string[];
36
+ readonly servedByHeaderEmitted: boolean;
37
+ readonly relayAuthVersion: string;
38
+ readonly linkPeerReportVersion: string;
39
+ }
40
+
41
+ /** One feature: its id when the sources say it is served, otherwise null. */
42
+ export type PeerFeature = (sources: PeerFeatureSources) => string | null;
43
+
44
+ /** Ordered; `/readyz` lists the served ids in this order. */
45
+ export const PEER_FEATURES: readonly PeerFeature[] = [
46
+ s => s.managementRoutes.some(route => route.method === "GET" && route.path === QUOTA_POOL_PATH) ? "quota-pool/1" : null,
47
+ s => s.managementRoutes.some(route => route.method === "GET" && route.path === FALLBACK_STATE_PATH) ? "fallback-policy/1" : null,
48
+ s => s.linkManagementReadPaths.length > 0 ? "link-management-relay" : null,
49
+ s => s.servedByHeaderEmitted ? "served-by-header" : null,
50
+ s => `relay-auth/${s.relayAuthVersion}`,
51
+ s => `link-peer-report/${s.linkPeerReportVersion}`,
52
+ ];
53
+
54
+ export function currentPeerFeatureSources(): PeerFeatureSources {
55
+ return {
56
+ managementRoutes: MANAGEMENT_ROUTES,
57
+ linkManagementReadPaths: LINK_MANAGEMENT_READ_PATHS,
58
+ servedByHeaderEmitted: SERVED_BY_HEADER_EMITTED,
59
+ relayAuthVersion: LINK_RELAY_AUTH_VERSION,
60
+ linkPeerReportVersion: LINK_PEER_REPORT_VERSION,
61
+ };
62
+ }
63
+
64
+ export function servedPeerFeatures(sources: PeerFeatureSources = currentPeerFeatureSources()): string[] {
65
+ return PEER_FEATURES.map(feature => feature(sources)).filter((id): id is string => id !== null);
66
+ }
67
+
68
+ let buildFeatures: readonly string[] | undefined;
69
+
70
+ /** This build's served features. The sources are build constants, so the list is computed once. */
71
+ export function buildPeerFeatures(): string[] {
72
+ buildFeatures ??= Object.freeze(servedPeerFeatures());
73
+ return [...buildFeatures];
74
+ }
@@ -1,4 +1,5 @@
1
1
  import type { OcxConfig } from "../types/config";
2
+ import { buildPeerFeatures } from "./features";
2
3
 
3
4
  export const REMOTE_HUB_PROTOCOL = 1;
4
5
  export const MINIMUM_REMOTE_CLIENT_PROTOCOL = 1;
@@ -53,6 +54,7 @@ export function readyProtocolMetadata(config: OcxConfig, req: Request): RemoteRe
53
54
  protocol: REMOTE_HUB_PROTOCOL,
54
55
  minimumClientProtocol: MINIMUM_REMOTE_CLIENT_PROTOCOL,
55
56
  managementUrl,
57
+ features: buildPeerFeatures(),
56
58
  };
57
59
  }
58
60