@bitkyc08/opencodex 2.88.0 → 2.90.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 (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
@@ -0,0 +1,171 @@
1
+ import { captureAccountAutoPauseFence, accountAutoPauseFenceIsLocal, accountAutoPauseFenceMatchesDisk } from "../config/account-auto-pause-revisions";
2
+ import type { OcxConfig, OcxProviderConfig } from "../types";
3
+ import type { OAuthAccessSnapshot } from "./index";
4
+ import type { CodexAuthContext } from "../codex/auth-context";
5
+ import type { AccountPauseReason } from "../types/account-auto-pause";
6
+ import { credentialGeneration, getAccountSet, setAccountPaused } from "./store";
7
+ import { mutatePersistedConfig } from "../config";
8
+ import { isCodexAccountGenerationLive, readCodexAccountRecord } from "../codex/account-store";
9
+ import { captureMainQuotaDispatch, isMainQuotaDispatchLive } from "../codex/main-account-cache";
10
+ import { isCodexAccountPaused, setCodexAccountPaused } from "../codex/account-pause";
11
+ import { apiKeyPoolEntryId } from "../providers/api-keys";
12
+ import { resolveProviderApiKey } from "../providers/api-key-resolve";
13
+ import { createHash } from "node:crypto";
14
+ import { setProviderApiKeyPaused } from "../providers/api-key-pause";
15
+ import { AccountRefusalStreaks, autoPauseThreshold, permanentAccountRefusal } from "./account-auto-pause-policy";
16
+ import { clearThreadAccountMapForAccount, reconcileCodexActiveAfterExclusion } from "../codex/routing";
17
+ import { isNonReplayableResponse } from "../lib/upstream-retry";
18
+ import { hasUpstreamRewriters, upstreamRewriterEpoch } from "../plugins/upstream-hooks";
19
+
20
+ const streaks = new WeakMap<OcxConfig, AccountRefusalStreaks>();
21
+ export { wasAccountAutoPaused, wasPermanentAccountRefusal } from "../lib/account-auto-pause-response";
22
+ import { markAccountAutoPaused, markPermanentAccountRefusal } from "../lib/account-auto-pause-response";
23
+ export interface AccountAutoPauseBinding {
24
+ config: OcxConfig;
25
+ oauth?: OAuthAccessSnapshot;
26
+ codex?: CodexAuthContext;
27
+ }
28
+
29
+ /** Capture before transport. A later response cannot acquire authority over a newer login/resume. */
30
+ export type AccountAutoPauseObserver = ((response: Response) => Promise<Response>) & {
31
+ /** Final-send report: only a response whose wire credential and destination match is counted. */
32
+ physical: (sameDestination: boolean, headers: Headers) => void;
33
+ };
34
+ const CREDENTIAL_HEADERS = ["authorization", "x-api-key", "x-goog-api-key", "api-key"] as const;
35
+ const CODEX_SELECTOR_HEADERS = ["openai-organization", "openai-project"] as const;
36
+ // Nested capture sites (a providerFetch observer around a dispatchOverride's own observer) see the
37
+ // same physical response. It counts once per account, and a response whose final send was reported
38
+ // to any observer is never attributed by an observer that received no report.
39
+ const countedResponses = new WeakMap<Response, Set<string>>();
40
+ const reportedResponses = new WeakSet<Response>();
41
+ export function captureAccountAutoPauseAttempt(name: string, provider: OcxProviderConfig, binding?: AccountAutoPauseBinding, headers?: Headers):
42
+ AccountAutoPauseObserver | undefined {
43
+ if (!binding) return;
44
+ const { config, oauth, codex } = binding;
45
+ const threshold = autoPauseThreshold(config.providers[name]?.accountAutoPause);
46
+ if (!threshold) return;
47
+ const configured = config.providers[name];
48
+ if (!configured || configured.disabled) return;
49
+ const fence = captureAccountAutoPauseFence(config, name);
50
+ if (!fence) return;
51
+ const route = JSON.stringify([configured.baseUrl, configured.adapter, configured.authMode]);
52
+ const policyCurrent = () => accountAutoPauseFenceIsLocal(name, fence) && !config.providers[name]?.disabled
53
+ && autoPauseThreshold(config.providers[name]?.accountAutoPause) === threshold
54
+ && JSON.stringify([config.providers[name]?.baseUrl, config.providers[name]?.adapter, config.providers[name]?.authMode]) === route;
55
+ let key: string, incarnation: string, current: () => boolean, owns: (wire: Headers) => boolean;
56
+ let pause: (reason: AccountPauseReason) => Promise<boolean>;
57
+ if (provider.authMode === "oauth") {
58
+ if (!oauth || oauth.provider !== name || headers?.get("authorization") !== `Bearer ${oauth.accessToken}` || headers.has("x-api-key")) return;
59
+ const row = getAccountSet(name)?.accounts.find(a => a.id === oauth.accountId);
60
+ if (!row || row.paused || credentialGeneration(row.credential) !== oauth.generation) return;
61
+ const revision = row.pauseRevision;
62
+ owns = wire => wire.get("authorization") === `Bearer ${oauth.accessToken}` && !wire.has("x-api-key");
63
+ key = `${name}\0oauth\0${oauth.accountId}`;
64
+ incarnation = `${row.loginId ?? row.addedAt ?? oauth.accountId}\0${revision ?? ""}`;
65
+ current = () => {
66
+ const live = getAccountSet(name)?.accounts.find(a => a.id === oauth.accountId);
67
+ return !!live && !live.paused && live.pauseRevision === revision && credentialGeneration(live.credential) === oauth.generation;
68
+ };
69
+ pause = async reason => (await setAccountPaused(name, oauth.accountId, true, {
70
+ generation: oauth.generation, pauseRevision: revision, isCurrent: policyCurrent,
71
+ assertPolicyBeforePersist: () => { if (!accountAutoPauseFenceMatchesDisk(name, fence)) throw new Error("Account auto-pause policy changed before persistence"); }, details: { reason, pausedAt: Date.now() },
72
+ })).status === "updated";
73
+ } else if (provider.authMode === "forward") {
74
+ if (!codex || codex.kind !== "pool" && codex.kind !== "main-pool") return;
75
+ const { accountId } = codex;
76
+ // The bearer and the workspace together name the Codex account; organization/project selectors
77
+ // must stay as captured, so a rewrite that keeps the bearer cannot redirect the refusal.
78
+ const selectors = CODEX_SELECTOR_HEADERS.map(header => headers?.get(header) ?? null);
79
+ const codexOwns = (wire: Headers) => wire.get("authorization") === `Bearer ${codex.accessToken}`
80
+ && wire.get("chatgpt-account-id") === codex.chatgptAccountId
81
+ && CODEX_SELECTOR_HEADERS.every((header, index) => wire.get(header) === selectors[index]);
82
+ if (!headers || !codexOwns(headers)) return;
83
+ owns = codexOwns;
84
+ const revision = config.codexAccountPauseState?.[accountId]?.revision;
85
+ const mainDispatch = codex.kind === "main-pool" ? captureMainQuotaDispatch(codex.accessToken, codex.chatgptAccountId, codex.writerGeneration) : undefined;
86
+ const credentialCurrent = () => codex.kind === "pool"
87
+ ? isCodexAccountGenerationLive(accountId, codex.generation)
88
+ : !!mainDispatch && isMainQuotaDispatchLive(mainDispatch);
89
+ key = `${name}\0codex\0${accountId}`;
90
+ incarnation = `${codex.kind === "pool" ? readCodexAccountRecord(accountId)?.quotaHistoryIdentity ?? codex.generation : mainDispatch?.credentialGeneration}\0${revision ?? ""}`;
91
+ current = () => !isCodexAccountPaused(config, accountId)
92
+ && config.codexAccountPauseState?.[accountId]?.revision === revision && credentialCurrent();
93
+ pause = async reason => {
94
+ let pauseState: OcxConfig["codexAccountPauseState"];
95
+ const outcome = mutatePersistedConfig(fresh => {
96
+ if (!policyCurrent() || !accountAutoPauseFenceMatchesDisk(name, fence, fresh) || !credentialCurrent() || isCodexAccountPaused(fresh, accountId)
97
+ || fresh.codexAccountPauseState?.[accountId]?.revision !== revision
98
+ || autoPauseThreshold(fresh.providers[name]?.accountAutoPause) !== threshold) return { changed: false, value: false };
99
+ setCodexAccountPaused(fresh, accountId, true, { reason, pausedAt: Date.now() });
100
+ pauseState = structuredClone(fresh.codexAccountPauseState);
101
+ return { changed: true, value: true };
102
+ });
103
+ if (outcome.status !== "committed" || !outcome.value) return false;
104
+ config.pausedCodexAccountIds = [...new Set([...(config.pausedCodexAccountIds ?? []), accountId])];
105
+ config.codexAccountPauseState = pauseState;
106
+ clearThreadAccountMapForAccount(accountId);
107
+ reconcileCodexActiveAfterExclusion(config, accountId);
108
+ return true;
109
+ };
110
+ } else {
111
+ const selection = provider._apiKeyAttempt;
112
+ if (!headers || !provider.apiKey || ![headers.get("authorization"), headers.get("x-api-key"), headers.get("x-goog-api-key")].some(value => value === provider.apiKey || value === `Bearer ${provider.apiKey}`)) return;
113
+ const entry = configured.apiKeyPool?.find(e => e.id === selection?.entryId && e.key === selection.reference)
114
+ ?? (!configured.apiKeyPool?.length && configured.apiKey ? { id: apiKeyPoolEntryId(configured.apiKey), key: configured.apiKey, disabled: false, pauseRevision: undefined } : undefined);
115
+ if (!entry || entry.disabled || !selection) return;
116
+ const { id, key: reference, pauseRevision } = entry;
117
+ key = `${name}\0key\0${id}`;
118
+ const resolvedKey = provider.apiKey;
119
+ owns = wire => {
120
+ const sent = CREDENTIAL_HEADERS.map(header => wire.get(header)).filter((value): value is string => value !== null);
121
+ return sent.length > 0 && sent.every(value => value === resolvedKey || value === `Bearer ${resolvedKey}`);
122
+ };
123
+ incarnation = `${createHash("sha256").update(resolvedKey).digest("hex")}\0${pauseRevision ?? ""}`;
124
+ current = () => {
125
+ const configured = config.providers[name];
126
+ const live = configured?.apiKeyPool?.find(e => e.id === id) ?? (!configured?.apiKeyPool?.length && configured?.apiKey === reference ? entry : undefined);
127
+ return !!live && !live.disabled && live.key === reference && live.pauseRevision === pauseRevision && resolveProviderApiKey(reference) === resolvedKey;
128
+ };
129
+ pause = async reason => setProviderApiKeyPaused(config, name, id, true, {
130
+ selection, pauseRevision, resolvedKey, fence, details: { reason, pausedAt: Date.now() },
131
+ });
132
+ }
133
+ incarnation += `\0${fence.diskRevision}\0${fence.localGen}`;
134
+ let tracker = streaks.get(config);
135
+ if (!tracker) { tracker = new AccountRefusalStreaks(); streaks.set(config, tracker); }
136
+ const observed = new WeakSet<Response>();
137
+ // undefined: no HTTP final-send report (Codex WebSocket). Such a send is attributable only when
138
+ // no rewriter was registered at capture and none was registered or removed before the response,
139
+ // so no plugin could have rewritten the dial in between.
140
+ const rewriterEpoch = hasUpstreamRewriters() ? undefined : upstreamRewriterEpoch();
141
+ let wireOwned: boolean | undefined;
142
+ const observer = (async (response: Response) => {
143
+ if (observed.has(response) || isNonReplayableResponse(response)) return response;
144
+ observed.add(response);
145
+ if (wireOwned !== undefined) reportedResponses.add(response);
146
+ else if (reportedResponses.has(response)) return response;
147
+ if (!(wireOwned ?? (rewriterEpoch !== undefined && upstreamRewriterEpoch() === rewriterEpoch))) return response;
148
+ const counted = countedResponses.get(response) ?? new Set<string>();
149
+ if (counted.has(key)) return response;
150
+ countedResponses.set(response, counted.add(key));
151
+ try {
152
+ const verdict = permanentAccountRefusal(response).then(reason => {
153
+ if (reason) markPermanentAccountRefusal(response);
154
+ return reason;
155
+ });
156
+ const paused = await tracker.observe(key, incarnation, threshold, verdict, response.ok,
157
+ () => policyCurrent() && current(), pause);
158
+ if (paused) {
159
+ markAccountAutoPaused(response);
160
+ // Fixed codes only. Never print the upstream body, provider label, or account identifier.
161
+ console.warn(`[account-auto-pause] paused after ${threshold} consecutive permanent account refusals`);
162
+ }
163
+ } catch {
164
+ // Optional persistence must preserve the original provider response.
165
+ console.warn("[account-auto-pause] could not persist account pause; upstream response preserved");
166
+ }
167
+ return response;
168
+ }) as AccountAutoPauseObserver;
169
+ observer.physical = (sameDestination, wire) => { wireOwned = sameDestination && owns(wire); };
170
+ return observer;
171
+ }
@@ -1,6 +1,7 @@
1
1
  import { validateAntigravityImportCredential } from "../google-antigravity";
2
2
  import { upsertCredentialByIdentity } from "../store";
3
3
  import type { OAuthCredentials } from "../types";
4
+ import { probeAntigravityUsageQuota } from "../../providers/quota/antigravity";
4
5
  import {
5
6
  ACCOUNT_IMPORT_FORMAT,
6
7
  ACCOUNT_IMPORT_MAX_EMAIL_LENGTH,
@@ -14,6 +15,26 @@ import {
14
15
  export interface AntigravityImportAdapterDeps {
15
16
  validate(refreshToken: string, signal?: AbortSignal): Promise<ValidatedAntigravityCredential>;
16
17
  upsert(credential: OAuthCredentials): Promise<"inserted" | "updated">;
18
+ /**
19
+ * Prove that the freshly validated account token can use an operator-supplied project.
20
+ * Without this owner a fallback project is never admitted.
21
+ */
22
+ verifyProject?(accessToken: string, projectId: string, signal?: AbortSignal): Promise<boolean>;
23
+ }
24
+
25
+ /**
26
+ * The account-bound admission probe for a fallback project: the same Cloud Code Assist quota
27
+ * probe the account pool runs, authenticated with this account's own access token against the
28
+ * named project. Only a parsed quota answer admits it; denial, rate limiting, transport failure
29
+ * or an unusable body all keep the record out.
30
+ */
31
+ export async function verifyAntigravityImportProject(accessToken: string, projectId: string, signal?: AbortSignal): Promise<boolean> {
32
+ if (signal?.aborted) return false;
33
+ try {
34
+ return (await probeAntigravityUsageQuota(accessToken, projectId, signal)).kind === "available" && !signal?.aborted;
35
+ } catch {
36
+ return false;
37
+ }
17
38
  }
18
39
 
19
40
  function safeProviderEmail(value: unknown): string | null {
@@ -32,6 +53,7 @@ export function createAntigravityAccountImportAdapter(
32
53
  deps: AntigravityImportAdapterDeps = {
33
54
  validate: validateAntigravityImportCredential,
34
55
  upsert: credential => upsertCredentialByIdentity(ACCOUNT_IMPORT_PROVIDER, credential),
56
+ verifyProject: verifyAntigravityImportProject,
35
57
  },
36
58
  ): AccountImportAdapter {
37
59
  return {
@@ -52,15 +74,29 @@ export function createAntigravityAccountImportAdapter(
52
74
  const providerEmail = safeProviderEmail(credential.email);
53
75
  if (!providerEmail) return { status: "failed", code: "credential_rejected" };
54
76
  if (providerEmail !== record.email) return { status: "failed", code: "identity_mismatch" };
55
- if (typeof credential.projectId !== "string" || credential.projectId.length === 0) {
56
- return { status: "failed", code: "missing_project" };
77
+ // Automatic discovery wins: a discovered project is used as-is and the record's project_id
78
+ // is ignored. The record's project is a fallback only, admitted after an account-bound probe.
79
+ let projectId = typeof credential.projectId === "string" && credential.projectId.length > 0
80
+ ? credential.projectId
81
+ : undefined;
82
+ if (!projectId && record.projectId && deps.verifyProject) {
83
+ let admitted = false;
84
+ try {
85
+ admitted = await deps.verifyProject(credential.access, record.projectId, signal);
86
+ } catch {
87
+ admitted = false;
88
+ }
89
+ throwIfAccountImportAborted(signal);
90
+ if (admitted) projectId = record.projectId;
57
91
  }
92
+ if (!projectId) return { status: "failed", code: "missing_project" };
58
93
 
59
94
  throwIfAccountImportAborted(signal);
60
95
  try {
61
96
  const disposition = await deps.upsert({
62
97
  ...credential,
63
98
  email: providerEmail,
99
+ projectId,
64
100
  source: "credential-file",
65
101
  });
66
102
  return disposition === "updated"
@@ -1,13 +1,17 @@
1
1
  import {
2
2
  ACCOUNT_IMPORT_MAX_BYTES,
3
3
  ACCOUNT_IMPORT_MAX_EMAIL_LENGTH,
4
+ ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH,
4
5
  ACCOUNT_IMPORT_MAX_RECORDS,
5
6
  ACCOUNT_IMPORT_MAX_REFRESH_TOKEN_BYTES,
6
7
  type ParsedCockpitRecord,
7
8
  } from "./types";
8
9
 
9
10
  const encoder = new TextEncoder();
10
- const ALLOWED_RECORD_KEYS = new Set(["email", "refresh_token", "tags", "notes"]);
11
+ const ALLOWED_RECORD_KEYS = new Set(["email", "refresh_token", "project_id", "tags", "notes"]);
12
+ // Google Cloud project id grammar: a lowercase letter, then lowercase letters, digits or hyphens,
13
+ // not ending in a hyphen. Exact match only — no trimming, so whitespace or controls are rejected.
14
+ const PROJECT_ID_SHAPE = /^[a-z][a-z0-9-]*[a-z0-9]$/;
11
15
 
12
16
  export type CockpitDocumentParseResult =
13
17
  | { ok: true; records: ParsedCockpitRecord[] }
@@ -50,10 +54,18 @@ function validMetadata(record: Record<string, unknown>): boolean {
50
54
  return record.notes === undefined || typeof record.notes === "string";
51
55
  }
52
56
 
57
+ function validProjectId(value: unknown): value is string | undefined {
58
+ if (value === undefined) return true;
59
+ return typeof value === "string"
60
+ && value.length >= 6
61
+ && value.length <= ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH
62
+ && PROJECT_ID_SHAPE.test(value);
63
+ }
64
+
53
65
  function parseRecord(value: unknown, index: number): ParsedCockpitRecord {
54
66
  if (!isPlainRecord(value)) return { index, code: "invalid_record" };
55
67
  if (Object.keys(value).some(key => !ALLOWED_RECORD_KEYS.has(key))) return { index, code: "invalid_record" };
56
- if (!validEmail(value.email) || !validRefreshToken(value.refresh_token) || !validMetadata(value)) {
68
+ if (!validEmail(value.email) || !validRefreshToken(value.refresh_token) || !validProjectId(value.project_id) || !validMetadata(value)) {
57
69
  return { index, code: "invalid_record" };
58
70
  }
59
71
  return {
@@ -61,6 +73,7 @@ function parseRecord(value: unknown, index: number): ParsedCockpitRecord {
61
73
  record: {
62
74
  email: value.email.toLowerCase(),
63
75
  refreshToken: value.refresh_token,
76
+ ...(typeof value.project_id === "string" ? { projectId: value.project_id } : {}),
64
77
  },
65
78
  };
66
79
  }
@@ -11,6 +11,8 @@ export const ACCOUNT_IMPORT_MAX_REQUEST_BYTES = ACCOUNT_IMPORT_MAX_BYTES + 1024;
11
11
  export const ACCOUNT_IMPORT_DEADLINE_MS = 600_000;
12
12
  export const ACCOUNT_IMPORT_MAX_RECORDS = 25;
13
13
  export const ACCOUNT_IMPORT_MAX_EMAIL_LENGTH = 254;
14
+ /** Google Cloud project ids are 6–30 characters; anything longer cannot name a project. */
15
+ export const ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH = 30;
14
16
  export const ACCOUNT_IMPORT_MAX_REFRESH_TOKEN_BYTES = 16 * 1024;
15
17
 
16
18
  export type AccountImportStatus = "imported" | "updated" | "failed" | "unsupported";
@@ -65,6 +67,11 @@ export function throwIfAccountImportAborted(signal?: AbortSignal): void {
65
67
  export interface CockpitAccountRecord {
66
68
  email: string;
67
69
  refreshToken: string;
70
+ /**
71
+ * Operator-supplied Cloud Code Assist project, used only when automatic discovery finds none
72
+ * and only after the account's own token proves it can use that project.
73
+ */
74
+ projectId?: string;
68
75
  }
69
76
 
70
77
  export type ParsedCockpitRecord =
@@ -153,7 +153,8 @@ export function accountResetTimestamp(
153
153
  const quota = getCachedProviderAccountQuota(provider, accountId);
154
154
  if (!quota) return null;
155
155
  const family = classifyModelFamilyForQuota(provider, requestedModelId);
156
- const windows = (quota.customWindows ?? []).filter(w => !family || windowMatchesFamily(w.label, family));
156
+ // Model-attributed rows are diagnostics; one must never become the account's reset time.
157
+ const windows = (quota.customWindows ?? []).filter(w => w.modelId === undefined && (!family || windowMatchesFamily(w.label, family)));
157
158
  const weekly = windows.find(w => /week/i.test(w.label));
158
159
  let rawReset = weekly?.resetAt ?? quota.weeklyResetAt;
159
160
  if (rawReset === undefined && provider !== "google-antigravity" && provider !== "anthropic") {
@@ -1,3 +1,4 @@
1
+ import { wasAccountAutoPaused } from "./account-auto-pause";
1
2
  import { anthropicModelFamily } from "./anthropic-model-quota";
2
3
  /** Narrow pre-output account recovery, fenced to the bearer that physically sent the turn. */
3
4
  import { readBoundedResponseBody } from "../lib/bounded-body";
@@ -117,6 +118,11 @@ export async function rotateAnthropicAccountOnResponseForInstance(
117
118
  const { pauseAnthropicRateAdmission, anthropicRatePauseUntil } = anthropicRatePolicyFor(instance);
118
119
  const { recordAnthropicAccountRefusal, rotateAnthropicAccountOnRefusal,
119
120
  hasAnthropicFailoverQuorum, isAnthropicAccountPoolEnabled, pickAlternateAnthropicAccount } = routing;
121
+ if (wasAccountAutoPaused(response)) {
122
+ if (!options.canRetry || options.signal?.aborted) return null;
123
+ return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(),
124
+ options.currentDecision ? options.currentDecision() : options.decision ?? null, options.model, options.excludedAccountIds);
125
+ }
120
126
  const ownedCurrent = () => {
121
127
  if (configuredAnthropicInstance(options.config, instance) !== instance
122
128
  || !anthropicPhysicalSendOwnershipIsCurrent(sent)) return undefined;
@@ -0,0 +1,8 @@
1
+ /** Pure family identity shared by existing hard policy and loaded-only advisory evidence. */
2
+ export type AnthropicModelFamily = "Fable" | "Opus" | "Sonnet";
3
+ /** Return the Anthropic model family (Fable, Opus or Sonnet) named by a model id, or undefined for other models. */
4
+ export function anthropicModelFamily(model?: string): AnthropicModelFamily | undefined {
5
+ return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable"
6
+ : /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus"
7
+ : /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined;
8
+ }
@@ -6,12 +6,8 @@ import type { AnthropicInstanceId } from "../providers/anthropic-instance-id";
6
6
  import type { GenerationContext } from "../lib/state-store-sweeper";
7
7
 
8
8
  export const ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS = 30 * 60_000;
9
- export type AnthropicModelFamily = "Fable" | "Opus" | "Sonnet";
10
- export function anthropicModelFamily(model?: string): AnthropicModelFamily | undefined {
11
- return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable"
12
- : /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus"
13
- : /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined;
14
- }
9
+ import { anthropicModelFamily } from "./anthropic-model-family";
10
+ export { anthropicModelFamily, type AnthropicModelFamily } from "./anthropic-model-family";
15
11
 
16
12
  export function anthropicModelPercents(quota: ProviderQuota | null, model?: string): number[] {
17
13
  const family = anthropicModelFamily(model);
@@ -0,0 +1,37 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+ import { sanitizeApiKeyValue } from "../providers/api-keys";
3
+ import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause";
4
+
5
+ /** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
6
+ export function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
7
+ if (!Array.isArray(value)) return undefined;
8
+ const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
9
+ const ids = new Set<string>();
10
+ const keys = new Set<string>();
11
+ for (const entry of value as unknown[]) {
12
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
13
+ const candidate = entry as Record<string, unknown>;
14
+ const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
15
+ const key = sanitizeApiKeyValue(candidate.key);
16
+ if (!id || !key || ids.has(id) || keys.has(key)) continue;
17
+ const label = typeof candidate.label === "string" ? candidate.label : undefined;
18
+ const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
19
+ ? candidate.addedAt
20
+ : undefined;
21
+ const details = accountPauseDetailsSchema.safeParse(candidate.pauseDetails);
22
+ ids.add(id);
23
+ keys.add(key);
24
+ pool.push({
25
+ id,
26
+ key,
27
+ ...(label !== undefined ? { label } : {}),
28
+ ...(addedAt !== undefined ? { addedAt } : {}),
29
+ ...(typeof candidate.disabled === "boolean" ? { disabled: candidate.disabled } : {}),
30
+ ...(typeof candidate.pauseRevision === "string" ? { pauseRevision: candidate.pauseRevision } : {}),
31
+ ...(details.success ? { pauseDetails: details.data } : {}),
32
+ });
33
+ }
34
+ // Retain paused slots too: their metadata and credentials are needed for explicit resume.
35
+ return pool.length > 0 ? pool : undefined;
36
+ }
37
+
@@ -46,7 +46,16 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u
46
46
  if (!token) continue;
47
47
  const payload = decodeJwtPayload(token);
48
48
  if (!payload) continue;
49
- if (typeof payload.email === "string") return payload.email.toLowerCase();
49
+ if (typeof payload.email === "string" && payload.email.trim()) {
50
+ return payload.email.trim().toLowerCase();
51
+ }
52
+ const profile = payload["https://api.openai.com/profile"];
53
+ if (profile && typeof profile === "object" && !Array.isArray(profile)) {
54
+ const email = (profile as Record<string, unknown>).email;
55
+ if (typeof email === "string" && email.trim()) {
56
+ return email.trim().toLowerCase();
57
+ }
58
+ }
50
59
  }
51
60
  return undefined;
52
61
  }
package/src/oauth/dsh.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { OAuthController, OAuthCredentials } from "./types";
12
+ import { readBoundedResponseBody } from "../lib/bounded-body";
12
13
  import { homedir } from "node:os";
13
14
  import { join } from "node:path";
14
15
  import { existsSync, readFileSync } from "node:fs";
@@ -16,6 +17,20 @@ import { existsSync, readFileSync } from "node:fs";
16
17
  export const DSH_PLATFORM_ORIGIN = "https://platform.deepseek.com";
17
18
  export const DSH_INFERENCE_ORIGIN = "https://api.deepseek.com/anthropic";
18
19
 
20
+ /**
21
+ * Byte ceiling for the profile document returned by /auth-api/v0/users/current.
22
+ *
23
+ * A legitimate profile is a small fixed-shape JSON object (code, data.biz_code,
24
+ * biz_data.id/email/id_profile{name,picture}) well under a few KiB; 64 KiB matches
25
+ * the shared bounded-body ceiling and still leaves orders of magnitude of
26
+ * headroom. Anything larger is rejected before parsing so a hostile or broken
27
+ * upstream cannot turn an import validation into unbounded memory work.
28
+ */
29
+ export const DSH_PROFILE_MAX_BYTES = 64 * 1024;
30
+
31
+ /** Wall-clock budget for the profile request and its body read, in milliseconds. */
32
+ const DSH_PROFILE_FETCH_TIMEOUT_MS = 10_000;
33
+
19
34
  export interface DshAccountDetectionResult {
20
35
  detected: boolean;
21
36
  token?: string;
@@ -80,7 +95,7 @@ export async function validateDshAccountToken(
80
95
  signal?: AbortSignal,
81
96
  ): Promise<DshValidatedAccountIdentity> {
82
97
  try {
83
- const timeoutSignal = AbortSignal.timeout(10_000);
98
+ const timeoutSignal = AbortSignal.timeout(DSH_PROFILE_FETCH_TIMEOUT_MS);
84
99
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
85
100
  const response = await fetch(`${DSH_PLATFORM_ORIGIN}/auth-api/v0/users/current`, {
86
101
  method: "GET",
@@ -99,7 +114,35 @@ export async function validateDshAccountToken(
99
114
  return { valid: false, error: `Platform returned HTTP ${response.status}` };
100
115
  }
101
116
 
102
- const body = (await response.json()) as {
117
+ // A declared length past the cap is rejected without touching the stream.
118
+ const declaredLength = Number(response.headers.get("content-length"));
119
+ if (Number.isFinite(declaredLength) && declaredLength > DSH_PROFILE_MAX_BYTES) {
120
+ try {
121
+ void response.body?.cancel(
122
+ new DOMException("DSH profile response is too large", "QuotaExceededError"),
123
+ ).catch(() => undefined);
124
+ } catch {
125
+ // Best-effort cancellation only.
126
+ }
127
+ return { valid: false, error: "PROFILE_VALIDATION_FAILED" };
128
+ }
129
+
130
+ // Missing or understated Content-Length still meets the cap on the decoded
131
+ // stream itself: once an over-limit byte is observed the retained buffer is
132
+ // discarded and cancellation is requested on the body. Malformed UTF-8 fails
133
+ // closed so a replacement character can never alter the authoritative ID.
134
+ const bounded = await readBoundedResponseBody(response, {
135
+ signal: combinedSignal,
136
+ maxBytes: DSH_PROFILE_MAX_BYTES,
137
+ totalTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS,
138
+ inactivityTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS,
139
+ fatalUtf8: true,
140
+ });
141
+ if (bounded.oversized || bounded.truncated || !bounded.displaySafe) {
142
+ return { valid: false, error: "PROFILE_VALIDATION_FAILED" };
143
+ }
144
+
145
+ const body = JSON.parse(bounded.text) as {
103
146
  code?: number;
104
147
  data?: {
105
148
  biz_code?: number;
@@ -55,6 +55,7 @@ import { loginOrcaRouter, orcaRouterInferenceBaseUrl, refreshOrcaRouterKey } fro
55
55
  import { ANTIGRAVITY_DISCOVERY_UA } from "../adapters/google-antigravity-wire";
56
56
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
57
57
  import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
58
+ import { preservableApiKeyPool } from "./api-key-pool-preserve";
58
59
  import { effectiveGoogleMode, getProviderRegistryEntry, mergeRegistryStaticHeaders, providerMatchesRegistryTransport } from "../providers/registry";
59
60
  import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
60
61
  import { resolveProviderTransport } from "../providers/xai-transport";
@@ -1513,36 +1514,6 @@ export function reconcileOAuthProviders(config: OcxConfig, persist = true): bool
1513
1514
  return true;
1514
1515
  }
1515
1516
 
1516
- /** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
1517
- function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
1518
- if (!Array.isArray(value)) return undefined;
1519
- const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
1520
- const ids = new Set<string>();
1521
- const keys = new Set<string>();
1522
- for (const entry of value as unknown[]) {
1523
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
1524
- const candidate = entry as Record<string, unknown>;
1525
- const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
1526
- const key = sanitizeApiKeyValue(candidate.key);
1527
- if (!id || !key || ids.has(id) || keys.has(key)) continue;
1528
- const label = typeof candidate.label === "string" ? candidate.label : undefined;
1529
- const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
1530
- ? candidate.addedAt
1531
- : undefined;
1532
- ids.add(id);
1533
- keys.add(key);
1534
- pool.push({
1535
- id,
1536
- key,
1537
- ...(label !== undefined ? { label } : {}),
1538
- ...(addedAt !== undefined ? { addedAt } : {}),
1539
- });
1540
- }
1541
- // `apiKey` remains the routing source of truth. Keep valid alternate slots even when a
1542
- // hand-edited config left the pool out of sync, rather than deleting usable credentials.
1543
- return pool.length > 0 ? pool : undefined;
1544
- }
1545
-
1546
1517
  const OAUTH_LOGIN_OWNED_PROVIDER_FIELDS = [
1547
1518
  "adapter",
1548
1519
  "baseUrl",
@@ -1597,11 +1568,10 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1597
1568
  // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
1598
1569
  let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
1599
1570
  const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool);
1600
- // Unsafe/blank active key with a usable pool: promote the first safe pool entry so
1601
- // key billing keeps working instead of falling back to oauth while pool keys remain.
1602
- if (storedApiKey === undefined && storedApiKeyPool && storedApiKeyPool.length > 0) {
1603
- storedApiKey = storedApiKeyPool[0]!.key;
1604
- }
1571
+ // Retain all-paused pools without activating any paused credential during login.
1572
+ if (storedApiKeyPool?.some(entry => entry.key === storedApiKey && entry.disabled)) storedApiKey = undefined;
1573
+ if (storedApiKey === undefined) storedApiKey = storedApiKeyPool?.find(entry => !entry.disabled)?.key;
1574
+ if (storedApiKeyPool) next.apiKeyPool = storedApiKeyPool;
1605
1575
  if (storedApiKey !== undefined) {
1606
1576
  const pool = storedApiKeyPool ? [...storedApiKeyPool] : [];
1607
1577
  // Keep routing and listProviderApiKeys in sync: never leave a hidden active key that
package/src/oauth/kiro.ts CHANGED
@@ -205,11 +205,54 @@ function profileArnFromWhoami(parsed: Record<string, unknown>): string | undefin
205
205
  : undefined);
206
206
  }
207
207
 
208
+ /**
209
+ * `kiro-cli whoami --format json` prints its identity as one JSON object, but once an IAM Identity
210
+ * Center profile is selected kiro-cli 2.32 follows that object with a human-readable `Profile:`
211
+ * block on stdout. Parsing the whole stream then failed, which silently dropped the user email that
212
+ * tells organization users apart (#6641). Only the leading top-level object is identity evidence;
213
+ * it must start the output and end its line, so text on later lines is ignored while a second
214
+ * value on the same line makes the identity unreadable.
215
+ */
216
+ function parseWhoamiObject(stdout: string): Record<string, unknown> | undefined {
217
+ const start = stdout.search(/\S/);
218
+ if (start < 0 || stdout[start] !== "{") return undefined;
219
+ let depth = 0;
220
+ let inString = false;
221
+ let escaped = false;
222
+ for (let index = start; index < stdout.length; index++) {
223
+ const char = stdout[index];
224
+ if (inString) {
225
+ if (escaped) escaped = false;
226
+ else if (char === "\\") escaped = true;
227
+ else if (char === "\"") inString = false;
228
+ continue;
229
+ }
230
+ if (char === "\"") inString = true;
231
+ else if (char === "{" || char === "[") depth++;
232
+ else if (char === "}" || char === "]") {
233
+ depth--;
234
+ if (depth > 0) continue;
235
+ if (!/^[ \t]*(?:\r?\n|$)/.test(stdout.slice(index + 1))) return undefined;
236
+ const parsed: unknown = JSON.parse(stdout.slice(start, index + 1));
237
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
238
+ ? parsed as Record<string, unknown>
239
+ : undefined;
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ /**
246
+ * Same-session identity reported by `kiro-cli whoami --format json`: the lower-cased user email
247
+ * and a structurally valid profile ARN. Any failure (non-zero exit, unreadable output, a thrown
248
+ * runner) yields an empty identity, so a login falls back to the SQLite import instead of failing.
249
+ */
208
250
  async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal): Promise<{ email?: string; profileArn?: string }> {
209
251
  try {
210
252
  const result = await runner(["whoami", "--format", "json"], signal);
211
253
  if (result.exitCode !== 0) return {};
212
- const parsed = JSON.parse(result.stdout) as Record<string, unknown>;
254
+ const parsed = parseWhoamiObject(result.stdout);
255
+ if (!parsed) return {};
213
256
  const email = typeof parsed.email === "string" ? parsed.email.trim().toLowerCase() : "";
214
257
  const profileArn = profileArnFromWhoami(parsed);
215
258
  return {
@@ -297,14 +297,23 @@ export function clearLoginState(provider: string): void {
297
297
  /**
298
298
  * Cancel one login. `flowId` names it exactly; without one the provider's newest in-flight flow
299
299
  * is cancelled, which is every single-flow caller (GUI, CLI) unchanged. A flow id belonging to a
300
- * different provider is refused rather than cancelled.
300
+ * different provider is refused rather than cancelled. Only the public OAuth cancellation
301
+ * endpoint opts into failed-outcome dismissal; callers using this boolean as proof of an
302
+ * aborted controller (Codex flow ownership) retain their original contract.
301
303
  */
302
- export function cancelLoginFlow(provider: string, flowId?: string): boolean {
304
+ export function cancelLoginFlow(provider: string, flowId?: string, options?: { dismissSettledFailure?: boolean }): boolean {
303
305
  const flowKey = flowId ?? latestLoginFlowKey(provider);
304
306
  if (flowKey === undefined) return false;
305
307
  const active = loginAbort.get(flowKey);
306
308
  const existing = loginState.get(flowKey);
307
309
  if ((active?.provider ?? loginFlowProvider(flowKey)) !== provider) return false;
310
+ if (options?.dismissSettledFailure && !active && existing?.done && existing.error) {
311
+ // A failed add-account attempt does not invalidate a previously signed-in account. Let
312
+ // its owner dismiss the stale UI outcome without erasing credentials or another flow.
313
+ clearManualCodeSlot(flowKey);
314
+ dropLoginFlow(flowKey);
315
+ return true;
316
+ }
308
317
  if (!active && (!existing || existing.done)) return false;
309
318
  abandonLoginFlow(flowKey, { error: "Login cancelled", errorCode: "no_pending_login" });
310
319
  return true;