@yansigit/opencodex 2.31.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.
- package/AGENTS_INSTALL.md +109 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/claude-code-models.gif +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +587 -0
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-BNJ7r4Gd.js +102 -0
- package/gui/dist/assets/index-CGoDO3uO.css +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/icons.svg +24 -0
- package/gui/dist/index.html +25 -0
- package/gui/dist/logo.png +0 -0
- package/gui/dist/provider-icons/alibaba-color.svg +1 -0
- package/gui/dist/provider-icons/antigravity-color.svg +1 -0
- package/gui/dist/provider-icons/claude-color.svg +1 -0
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -0
- package/gui/dist/provider-icons/cursor-color.svg +2 -0
- package/gui/dist/provider-icons/deepseek-color.svg +1 -0
- package/gui/dist/provider-icons/discord.svg +1 -0
- package/gui/dist/provider-icons/firepass-color.svg +1 -0
- package/gui/dist/provider-icons/fireworks-color.svg +1 -0
- package/gui/dist/provider-icons/gemini-color.svg +1 -0
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
- package/gui/dist/provider-icons/grok.svg +1 -0
- package/gui/dist/provider-icons/groq-color.svg +1 -0
- package/gui/dist/provider-icons/huggingface-color.svg +1 -0
- package/gui/dist/provider-icons/kimi-color.svg +1 -0
- package/gui/dist/provider-icons/kiro-color.svg +15 -0
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
- package/gui/dist/provider-icons/mistral-color.svg +1 -0
- package/gui/dist/provider-icons/moonshot-color.svg +1 -0
- package/gui/dist/provider-icons/nvidia-color.svg +1 -0
- package/gui/dist/provider-icons/ollama-color.svg +1 -0
- package/gui/dist/provider-icons/openai.svg +1 -0
- package/gui/dist/provider-icons/opencode.svg +2 -0
- package/gui/dist/provider-icons/openrouter-color.svg +1 -0
- package/gui/dist/provider-icons/pi.svg +21 -0
- package/gui/dist/provider-icons/qianfan-color.svg +1 -0
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
- package/gui/dist/provider-icons/telegram.svg +1 -0
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/vllm-color.svg +1 -0
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
- package/package.json +108 -0
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic-image-guard.ts +251 -0
- package/src/adapters/anthropic-image-normalize.ts +518 -0
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +1327 -0
- package/src/adapters/azure.ts +36 -0
- package/src/adapters/base.ts +121 -0
- package/src/adapters/client-fingerprint.ts +65 -0
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +601 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +104 -0
- package/src/adapters/cursor/checkpoint-store.ts +303 -0
- package/src/adapters/cursor/cursor-errors.ts +288 -0
- package/src/adapters/cursor/discovery.ts +333 -0
- package/src/adapters/cursor/effort-map.ts +151 -0
- package/src/adapters/cursor/exec-policy.ts +88 -0
- package/src/adapters/cursor/framing.ts +250 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/http1-bidi.ts +361 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/kv-store.ts +52 -0
- package/src/adapters/cursor/live-models.ts +269 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +1653 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +333 -0
- package/src/adapters/cursor/message-mapper.ts +49 -0
- package/src/adapters/cursor/native-exec-common.ts +76 -0
- package/src/adapters/cursor/native-exec-desktop.ts +184 -0
- package/src/adapters/cursor/native-exec-fs.ts +332 -0
- package/src/adapters/cursor/native-exec-mcp.ts +153 -0
- package/src/adapters/cursor/native-exec-network.ts +43 -0
- package/src/adapters/cursor/native-exec-shell.ts +547 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +663 -0
- package/src/adapters/cursor/protobuf-events.ts +1381 -0
- package/src/adapters/cursor/protobuf-request.ts +1032 -0
- package/src/adapters/cursor/request-builder.ts +461 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/tool-definitions.ts +735 -0
- package/src/adapters/cursor/tool-result-normalize.ts +92 -0
- package/src/adapters/cursor/transport-retry.ts +132 -0
- package/src/adapters/cursor/transport.ts +79 -0
- package/src/adapters/cursor/types.ts +79 -0
- package/src/adapters/cursor.ts +322 -0
- package/src/adapters/google-antigravity-hosts.ts +48 -0
- package/src/adapters/google-antigravity-replay.ts +827 -0
- package/src/adapters/google-antigravity-tools.ts +105 -0
- package/src/adapters/google-antigravity-wire.ts +141 -0
- package/src/adapters/google-errors.ts +92 -0
- package/src/adapters/google-http.ts +460 -0
- package/src/adapters/google-tool-schema.ts +238 -0
- package/src/adapters/google-truncation.ts +24 -0
- package/src/adapters/google-wire-compiler.ts +232 -0
- package/src/adapters/google.ts +1377 -0
- package/src/adapters/identity.ts +77 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/kiro-constants.ts +16 -0
- package/src/adapters/kiro-errors.ts +208 -0
- package/src/adapters/kiro-events.ts +197 -0
- package/src/adapters/kiro-images.ts +129 -0
- package/src/adapters/kiro-retry.ts +312 -0
- package/src/adapters/kiro-thinking.ts +112 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +224 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +129 -0
- package/src/adapters/kiro.ts +1936 -0
- package/src/adapters/mimo-free.ts +280 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1980 -0
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +1911 -0
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/run-turn-queue.ts +114 -0
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +154 -0
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/bridge.ts +1986 -0
- package/src/chat/inbound.ts +319 -0
- package/src/chat/outbound.ts +821 -0
- package/src/claude/agents-inject.ts +266 -0
- package/src/claude/alias.ts +149 -0
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/context-windows.ts +205 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p-paths.ts +84 -0
- package/src/claude/desktop-3p.ts +615 -0
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/gateway-cache.ts +107 -0
- package/src/claude/inbound-debug.ts +163 -0
- package/src/claude/inbound.ts +578 -0
- package/src/claude/model-info.ts +174 -0
- package/src/claude/outbound.ts +926 -0
- package/src/cli/access.ts +108 -0
- package/src/cli/account-api.ts +302 -0
- package/src/cli/account-auth.ts +250 -0
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +737 -0
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +299 -0
- package/src/cli/agent-driven.ts +70 -0
- package/src/cli/agent.ts +290 -0
- package/src/cli/catalog-prewarm.ts +27 -0
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +213 -0
- package/src/cli/claude.ts +355 -0
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/codex-shim-autorestore.ts +47 -0
- package/src/cli/codex-shim-readiness.ts +76 -0
- package/src/cli/combo.ts +127 -0
- package/src/cli/config-command.ts +209 -0
- package/src/cli/debug.ts +228 -0
- package/src/cli/dispatch.ts +585 -0
- package/src/cli/doctor.ts +1202 -0
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +213 -0
- package/src/cli/help.ts +101 -0
- package/src/cli/index.ts +973 -0
- package/src/cli/init.ts +211 -0
- package/src/cli/integrations.ts +260 -0
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/lab.ts +607 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/minimax.ts +497 -0
- package/src/cli/models-runtime.ts +245 -0
- package/src/cli/models.ts +422 -0
- package/src/cli/observe.ts +206 -0
- package/src/cli/opencode.ts +588 -0
- package/src/cli/provider-replit.ts +232 -0
- package/src/cli/provider-runtime.ts +179 -0
- package/src/cli/provider.ts +492 -0
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +422 -0
- package/src/cli/replit-gateway-key-input.ts +138 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +328 -0
- package/src/cli/star-prompt.ts +211 -0
- package/src/cli/status-oauth.ts +78 -0
- package/src/cli/status.ts +328 -0
- package/src/cli/system-command.ts +112 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +199 -0
- package/src/cli/v2.ts +268 -0
- package/src/cli.ts +10 -0
- package/src/clients/config-export.ts +1704 -0
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-label.ts +47 -0
- package/src/codex/account-lifecycle.ts +172 -0
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +195 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/account-runtime-state.ts +31 -0
- package/src/codex/account-store.ts +544 -0
- package/src/codex/account-usability.ts +43 -0
- package/src/codex/admission.ts +256 -0
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/agent-roles-sync.ts +225 -0
- package/src/codex/agent-roles.ts +238 -0
- package/src/codex/app-server-processes.ts +1143 -0
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +2147 -0
- package/src/codex/auth-collision.ts +109 -0
- package/src/codex/auth-context.ts +665 -0
- package/src/codex/autostart-health.ts +156 -0
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +436 -0
- package/src/codex/catalog/bundled.ts +549 -0
- package/src/codex/catalog/effort.ts +446 -0
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +664 -0
- package/src/codex/catalog/native-models.ts +72 -0
- package/src/codex/catalog/parsing.ts +650 -0
- package/src/codex/catalog/provider-fetch.ts +2064 -0
- package/src/codex/catalog/sync.ts +1883 -0
- package/src/codex/catalog-admission.ts +199 -0
- package/src/codex/catalog-refresh-status.ts +105 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +14 -0
- package/src/codex/codex-write-lock.ts +384 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +651 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/features.ts +1566 -0
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +407 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +108 -0
- package/src/codex/history-provider.ts +979 -0
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +220 -0
- package/src/codex/home.ts +206 -0
- package/src/codex/inject-coordination.ts +290 -0
- package/src/codex/inject.ts +1733 -0
- package/src/codex/injected-marker.ts +106 -0
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +225 -0
- package/src/codex/log-guard/inspect.ts +506 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +88 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/main-account-cache.ts +56 -0
- package/src/codex/main-account.ts +68 -0
- package/src/codex/management-convergence.ts +167 -0
- package/src/codex/model-cache.ts +273 -0
- package/src/codex/model-entitlements.ts +353 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +178 -0
- package/src/codex/native-main-lock-file.ts +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +492 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +682 -0
- package/src/codex/paths.ts +144 -0
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +40 -0
- package/src/codex/plugins-doctor.ts +242 -0
- package/src/codex/pool-rotation.ts +295 -0
- package/src/codex/project-config-warnings.ts +425 -0
- package/src/codex/prompt-journal.ts +352 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +573 -0
- package/src/codex/refresh.ts +62 -0
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +1888 -0
- package/src/codex/runtime.ts +659 -0
- package/src/codex/shim.ts +2170 -0
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +784 -0
- package/src/codex/sync.ts +319 -0
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +298 -0
- package/src/codex/websocket-registry.ts +100 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +160 -0
- package/src/combos/index.ts +45 -0
- package/src/combos/request.ts +94 -0
- package/src/combos/resolve.ts +232 -0
- package/src/combos/types.ts +398 -0
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +4041 -0
- package/src/fork/register.ts +3 -0
- package/src/generated/compatibility-version.json +3116 -0
- package/src/generated/model-metadata.ts +106 -0
- package/src/github/star-state.ts +203 -0
- package/src/grok/inject.ts +530 -0
- package/src/grok/inspect.ts +45 -0
- package/src/grok/status.ts +121 -0
- package/src/grok/sync.ts +66 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +149 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +955 -0
- package/src/images/plan.ts +143 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/index.ts +22 -0
- package/src/integrations/config-io.ts +269 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/omp-yaml-source.ts +358 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +159 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +361 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +691 -0
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/automation/budgets.ts +78 -0
- package/src/lab/automation/config-persistence.ts +256 -0
- package/src/lab/automation/constants.ts +39 -0
- package/src/lab/automation/cooldown.ts +103 -0
- package/src/lab/automation/dispatch.ts +211 -0
- package/src/lab/automation/index.ts +13 -0
- package/src/lab/automation/orchestrator.ts +499 -0
- package/src/lab/automation/persistence.ts +512 -0
- package/src/lab/automation/planner.ts +371 -0
- package/src/lab/automation/policy.ts +136 -0
- package/src/lab/automation/queue.ts +191 -0
- package/src/lab/automation/recovery.ts +24 -0
- package/src/lab/automation/route-context.ts +21 -0
- package/src/lab/automation/run-key.ts +44 -0
- package/src/lab/automation/runs-query.ts +34 -0
- package/src/lab/automation/types.ts +160 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +64 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +164 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +68 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +214 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +229 -0
- package/src/lab/events/validate.ts +781 -0
- package/src/lab/fabric/constants.ts +40 -0
- package/src/lab/fabric/executor.ts +492 -0
- package/src/lab/fabric/index.ts +80 -0
- package/src/lab/fabric/manifest.ts +222 -0
- package/src/lab/fabric/observe.ts +489 -0
- package/src/lab/fabric/patch.ts +79 -0
- package/src/lab/fabric/producer-child.ts +139 -0
- package/src/lab/fabric/producer-isolate.ts +276 -0
- package/src/lab/fabric/producer-protocol.ts +61 -0
- package/src/lab/fabric/scratch.ts +439 -0
- package/src/lab/fabric/subject.ts +106 -0
- package/src/lab/fabric/types.ts +134 -0
- package/src/lab/fabric/verifier.ts +98 -0
- package/src/lab/index.ts +54 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +310 -0
- package/src/lab/ledger/store.ts +532 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +336 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +118 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +301 -0
- package/src/lab/observe/from-live.ts +117 -0
- package/src/lab/paths.ts +153 -0
- package/src/lab/projection/rebuild.ts +495 -0
- package/src/lab/projection/schema.ts +135 -0
- package/src/lab/projection/verdicts.ts +474 -0
- package/src/lab/projection/verification.ts +412 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +243 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lab/query/catalog.ts +101 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/freshness.ts +53 -0
- package/src/lab/query/index.ts +45 -0
- package/src/lab/query/latest-observation.ts +59 -0
- package/src/lab/query/passive-production.ts +159 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +112 -0
- package/src/lab/subject/protocol-subject.ts +80 -0
- package/src/lab/subject/route-subject.ts +74 -0
- package/src/lib/abort.ts +146 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +195 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bounded-body.ts +346 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +184 -0
- package/src/lib/bun-stream-caps.ts +130 -0
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/config-ownership.ts +364 -0
- package/src/lib/crash-guard.ts +344 -0
- package/src/lib/debug-log-buffer.ts +83 -0
- package/src/lib/debug-settings.ts +108 -0
- package/src/lib/debug.ts +31 -0
- package/src/lib/destination-policy.ts +380 -0
- package/src/lib/errors.ts +406 -0
- package/src/lib/eventstream-decoder.ts +253 -0
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -0
- package/src/lib/gcp-adc.ts +341 -0
- package/src/lib/injection-debug-log.ts +58 -0
- package/src/lib/lab-activation.ts +223 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +56 -0
- package/src/lib/lab-live-route-production.ts +130 -0
- package/src/lib/lab-passive-linker-registration.ts +26 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/local-management-capability.ts +100 -0
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/lib/open-url.ts +25 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/pinned-http.ts +270 -0
- package/src/lib/privacy.ts +20 -0
- package/src/lib/process-control.ts +168 -0
- package/src/lib/provider-outbound.ts +210 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/redact.ts +521 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/service-secrets.ts +25 -0
- package/src/lib/shadow-call.ts +61 -0
- package/src/lib/sidecar-tracker.ts +52 -0
- package/src/lib/sse-decoder.ts +364 -0
- package/src/lib/state-store-registrations.ts +119 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/token-estimate.ts +86 -0
- package/src/lib/tool-argument-integers.ts +202 -0
- package/src/lib/translator-budget.ts +400 -0
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-reachability.ts +95 -0
- package/src/lib/upstream-retry.ts +392 -0
- package/src/lib/win-exec.ts +115 -0
- package/src/lib/win-paths.ts +68 -0
- package/src/lib/windows-atomic-replace.ts +156 -0
- package/src/lib/windows-elevation.ts +773 -0
- package/src/lib/windows-secret-acl.ts +854 -0
- package/src/lib/windows-service-wrappers.ts +72 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +341 -0
- package/src/lib/winsw.ts +403 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
- package/src/oauth/account-import/index.ts +15 -0
- package/src/oauth/account-import/parser.ts +83 -0
- package/src/oauth/account-import/registry.ts +18 -0
- package/src/oauth/account-import/service.ts +75 -0
- package/src/oauth/account-import/types.ts +91 -0
- package/src/oauth/anthropic-routing.ts +594 -0
- package/src/oauth/anthropic.ts +188 -0
- package/src/oauth/antigravity-routing.ts +151 -0
- package/src/oauth/callback-server.ts +300 -0
- package/src/oauth/chatgpt.ts +161 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +252 -0
- package/src/oauth/github-copilot.ts +428 -0
- package/src/oauth/google-antigravity.ts +262 -0
- package/src/oauth/health.ts +407 -0
- package/src/oauth/index.ts +1504 -0
- package/src/oauth/key-providers.ts +124 -0
- package/src/oauth/kimi.ts +227 -0
- package/src/oauth/kiro-credentials.ts +726 -0
- package/src/oauth/kiro.ts +621 -0
- package/src/oauth/local-token-detect.ts +130 -0
- package/src/oauth/log.ts +50 -0
- package/src/oauth/login-cli.ts +223 -0
- package/src/oauth/nous.ts +798 -0
- package/src/oauth/pkce.ts +15 -0
- package/src/oauth/store.ts +728 -0
- package/src/oauth/token-guardian.ts +309 -0
- package/src/oauth/types.ts +62 -0
- package/src/oauth/xai.ts +241 -0
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +156 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/antigravity-models.ts +695 -0
- package/src/providers/antigravity-quota.ts +216 -0
- package/src/providers/api-keys.ts +140 -0
- package/src/providers/base-url-choices.ts +74 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +144 -0
- package/src/providers/context-cap.ts +82 -0
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +586 -0
- package/src/providers/fastwire.ts +501 -0
- package/src/providers/free-directory.ts +187 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +271 -0
- package/src/providers/kiro-models.ts +67 -0
- package/src/providers/label.ts +19 -0
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +449 -0
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/openai-sidecar.ts +243 -0
- package/src/providers/openai-tier-startup.ts +56 -0
- package/src/providers/openai-tiers.ts +423 -0
- package/src/providers/openai-virtual-models.ts +83 -0
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +185 -0
- package/src/providers/quota.ts +2345 -0
- package/src/providers/registry.ts +2918 -0
- package/src/providers/replit/constants.ts +27 -0
- package/src/providers/replit/derive.ts +85 -0
- package/src/providers/replit/headers.ts +28 -0
- package/src/providers/replit/origin.ts +55 -0
- package/src/providers/replit/pair-install-response.ts +72 -0
- package/src/providers/replit/probe.ts +199 -0
- package/src/providers/replit/setup.ts +350 -0
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +103 -0
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/providers/xai-transport.ts +148 -0
- package/src/reasoning-effort.ts +183 -0
- package/src/responses/compaction.ts +142 -0
- package/src/responses/custom-tool-compat.ts +266 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +355 -0
- package/src/responses/parser.ts +838 -0
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/reasoning-envelope.ts +60 -0
- package/src/responses/reasoning-replay-cache.ts +426 -0
- package/src/responses/schema.ts +165 -0
- package/src/responses/spill-store.ts +459 -0
- package/src/responses/state.ts +1433 -0
- package/src/responses/thought-signature-replay.ts +347 -0
- package/src/responses/tool-groups.ts +19 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/router.ts +761 -0
- package/src/routing/analytics.ts +378 -0
- package/src/routing/capability.ts +244 -0
- package/src/routing/compatibility/assemble.ts +73 -0
- package/src/routing/compatibility/behavior.ts +278 -0
- package/src/routing/compatibility/catalog.ts +99 -0
- package/src/routing/compatibility/endpoint.ts +52 -0
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/policy.ts +181 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/routing/compatibility/reader.ts +110 -0
- package/src/routing/compatibility/subject.ts +191 -0
- package/src/routing/compatibility/types.ts +64 -0
- package/src/routing/compatibility/version.ts +104 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +495 -0
- package/src/routing/health.ts +412 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +547 -0
- package/src/routing/quota.ts +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +53 -0
- package/src/server/auth-cors.ts +751 -0
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +442 -0
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +426 -0
- package/src/server/claude-messages.ts +1030 -0
- package/src/server/direct-local-http.ts +347 -0
- package/src/server/effort-policy.ts +190 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +152 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/images.ts +568 -0
- package/src/server/index.ts +1813 -0
- package/src/server/lifecycle.ts +498 -0
- package/src/server/live.ts +717 -0
- package/src/server/local-management-read-client.ts +90 -0
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/agent-settings-routes.ts +1433 -0
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/api-key-usage.ts +193 -0
- package/src/server/management/body.ts +41 -0
- package/src/server/management/combo-routes.ts +263 -0
- package/src/server/management/config-routes.ts +835 -0
- package/src/server/management/context.ts +113 -0
- package/src/server/management/integration-routes.ts +498 -0
- package/src/server/management/lab-automation-routes.ts +206 -0
- package/src/server/management/lab-routes.ts +563 -0
- package/src/server/management/logs-usage-routes.ts +586 -0
- package/src/server/management/model-routes.ts +560 -0
- package/src/server/management/model-rows.ts +163 -0
- package/src/server/management/native-integration-routes.ts +769 -0
- package/src/server/management/oauth-account-routes.ts +637 -0
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +1033 -0
- package/src/server/management/replit-provider-routes.ts +86 -0
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +385 -0
- package/src/server/management/shared.ts +286 -0
- package/src/server/management/sidebar-routes.ts +106 -0
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +435 -0
- package/src/server/management/system-routes.ts +194 -0
- package/src/server/management/usage-summary-cache.ts +94 -0
- package/src/server/management/vision-sidecar-options.ts +167 -0
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/management-api.ts +314 -0
- package/src/server/management-auth.ts +482 -0
- package/src/server/memory-watchdog.ts +156 -0
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/port-reclaim.ts +307 -0
- package/src/server/ports.ts +156 -0
- package/src/server/proxy-liveness.ts +328 -0
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +353 -0
- package/src/server/relay.ts +1209 -0
- package/src/server/request-decompress.ts +239 -0
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +1259 -0
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +465 -0
- package/src/server/responses/collaboration.ts +551 -0
- package/src/server/responses/compact.ts +771 -0
- package/src/server/responses/core.ts +5389 -0
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/encrypted-payload.ts +331 -0
- package/src/server/responses/fetch-helpers.ts +232 -0
- package/src/server/responses/input-admission.ts +185 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/passthrough-error.ts +78 -0
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +251 -0
- package/src/server/responses/terminal-guard.ts +251 -0
- package/src/server/responses/upstream-error.ts +53 -0
- package/src/server/responses/ws-upstream.ts +308 -0
- package/src/server/responses-custom-tool-repair.ts +282 -0
- package/src/server/responses-image-gen-repair.ts +132 -0
- package/src/server/responses-item-id-repair.ts +272 -0
- package/src/server/responses-json-events.ts +90 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses-terminal-repair.ts +342 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/responses.ts +25 -0
- package/src/server/search.ts +201 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +263 -0
- package/src/server/startup-action-control.ts +315 -0
- package/src/server/startup-health-cache.ts +131 -0
- package/src/server/system-env.ts +484 -0
- package/src/server/windows-tcp-drop.ts +184 -0
- package/src/server/windows-tray-control.ts +41 -0
- package/src/server/ws-bridge.ts +472 -0
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +3575 -0
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/stall-timeout.ts +20 -0
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +3085 -0
- package/src/storage/policy-job.ts +457 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +59 -0
- package/src/storage/policy.ts +527 -0
- package/src/storage/restore-job.ts +299 -0
- package/src/storage/restore-worker.ts +58 -0
- package/src/storage/scanner.ts +238 -0
- package/src/storage/storage-mutation-coordinator.ts +139 -0
- package/src/storage/worker-lifecycle.ts +215 -0
- package/src/tray/assets/opencodex-tray-offline.ico +0 -0
- package/src/tray/assets/opencodex-tray-online.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning.ico +0 -0
- package/src/tray/assets/opencodex-tray.png +0 -0
- package/src/tray/windows-tray.ps1 +364 -0
- package/src/tray/windows.ts +757 -0
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +876 -0
- package/src/types/provider.ts +545 -0
- package/src/types/request.ts +384 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +106 -0
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +415 -0
- package/src/update/job.ts +1887 -0
- package/src/update/notify.ts +263 -0
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +259 -0
- package/src/update/tray-update-plan.d.mts +18 -0
- package/src/update/tray-update-plan.mjs +38 -0
- package/src/usage/cost.ts +625 -0
- package/src/usage/debug.ts +97 -0
- package/src/usage/expected-prices.ts +416 -0
- package/src/usage/log.ts +1223 -0
- package/src/usage/summary.ts +753 -0
- package/src/usage/totals.ts +14 -0
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/anthropic-describe.ts +189 -0
- package/src/vision/backends.ts +97 -0
- package/src/vision/describe.ts +131 -0
- package/src/vision/eligibility.ts +250 -0
- package/src/vision/index.ts +681 -0
- package/src/vision/reasoning.ts +55 -0
- package/src/vision/routed-describe.ts +175 -0
- package/src/vision/timeout-bounds.ts +9 -0
- package/src/web-search/anthropic-executor.ts +195 -0
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +88 -0
- package/src/web-search/executor.ts +113 -0
- package/src/web-search/format-result.ts +89 -0
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +331 -0
- package/src/web-search/loop.ts +896 -0
- package/src/web-search/parse.ts +315 -0
- package/src/web-search/progress-stream.ts +342 -0
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/synthetic-tool.ts +47 -0
- package/src/web-search/xai-executor.ts +219 -0
|
@@ -0,0 +1,2147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigMutationLockError,
|
|
3
|
+
loadConfig,
|
|
4
|
+
mutatePersistedConfig,
|
|
5
|
+
saveConfigPreservingClaudeCode,
|
|
6
|
+
withConfigMutationLockSync,
|
|
7
|
+
} from "../config";
|
|
8
|
+
import { codexAccountLogLabel, withCodexAccountLogLabel } from "./account-label";
|
|
9
|
+
import {
|
|
10
|
+
getCodexAccountCredential,
|
|
11
|
+
getValidCodexToken,
|
|
12
|
+
isCodexAccountGenerationLive,
|
|
13
|
+
markCodexAccountValidated,
|
|
14
|
+
readCodexAccountRecord,
|
|
15
|
+
saveCodexAccountCredential,
|
|
16
|
+
CodexCredentialGenerationConflictError,
|
|
17
|
+
CodexCredentialRefreshLockTimeoutError,
|
|
18
|
+
CodexCredentialRefreshBusyError,
|
|
19
|
+
CodexCredentialRefreshStaleError,
|
|
20
|
+
TokenRefreshError,
|
|
21
|
+
} from "./account-store";
|
|
22
|
+
import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
|
|
23
|
+
import {
|
|
24
|
+
appendDefaultCodexAccountNamespace,
|
|
25
|
+
codexAccountPickerEnabled,
|
|
26
|
+
} from "./account-namespaces";
|
|
27
|
+
import {
|
|
28
|
+
catalogRefreshIsPending,
|
|
29
|
+
normalizeCatalogDisposition,
|
|
30
|
+
} from "./catalog-refresh-status";
|
|
31
|
+
import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
|
|
32
|
+
import {
|
|
33
|
+
clearCodexAccountPin,
|
|
34
|
+
getCodexAccountPriority,
|
|
35
|
+
isCodexAccountPriorityKey,
|
|
36
|
+
pinnedCodexAccountId,
|
|
37
|
+
setCodexAccountPin,
|
|
38
|
+
setCodexAccountPriority,
|
|
39
|
+
} from "./account-priority";
|
|
40
|
+
import {
|
|
41
|
+
claimDueCodexQuotaRecoveryProbes,
|
|
42
|
+
clearCodexAccountCooldown,
|
|
43
|
+
clearThreadAccountMapForAccount,
|
|
44
|
+
getEffectiveActiveCodexAccountId,
|
|
45
|
+
isEffectiveCodexAccountPinned,
|
|
46
|
+
reconcileCodexActiveAfterExclusion,
|
|
47
|
+
resetCodexRoutingForManualSelection,
|
|
48
|
+
settleCodexQuotaRecoveryProbe,
|
|
49
|
+
} from "./routing";
|
|
50
|
+
import {
|
|
51
|
+
DEFAULT_ACCOUNT_PRIORITY,
|
|
52
|
+
MAX_ACCOUNT_PRIORITY,
|
|
53
|
+
MIN_ACCOUNT_PRIORITY,
|
|
54
|
+
normalizeAccountPoolStickyLimit,
|
|
55
|
+
normalizeAccountPoolStrategy,
|
|
56
|
+
parseAccountPoolStickyLimit,
|
|
57
|
+
parseAccountPoolStrategy,
|
|
58
|
+
parseAccountPriority,
|
|
59
|
+
} from "./pool-rotation";
|
|
60
|
+
import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
|
|
61
|
+
import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "./plan";
|
|
62
|
+
export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
|
|
63
|
+
export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
64
|
+
import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
65
|
+
import {
|
|
66
|
+
clearAccountQuota,
|
|
67
|
+
getAccountQuota,
|
|
68
|
+
isCompleteCodexQuotaRecoverySnapshot,
|
|
69
|
+
isCodexQuotaExhausted,
|
|
70
|
+
listAccountQuotas,
|
|
71
|
+
parseUsageQuota,
|
|
72
|
+
setAccountQuotaFromParsed,
|
|
73
|
+
updateAccountQuota,
|
|
74
|
+
type StoredAccountQuota,
|
|
75
|
+
type WhamUsageResponse,
|
|
76
|
+
} from "./quota";
|
|
77
|
+
export {
|
|
78
|
+
applyAccountQuotaFromUpstreamHeaders,
|
|
79
|
+
clearAccountQuota,
|
|
80
|
+
getAccountQuota,
|
|
81
|
+
parseUsageQuota,
|
|
82
|
+
setAccountQuotaFromParsed,
|
|
83
|
+
updateAccountQuota,
|
|
84
|
+
} from "./quota";
|
|
85
|
+
import { extractAccountId } from "../oauth/chatgpt";
|
|
86
|
+
import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
87
|
+
import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper";
|
|
88
|
+
import { reconcileLiveStateStores } from "../lib/state-store-registrations";
|
|
89
|
+
import {
|
|
90
|
+
captureMainAccountIdentityGeneration,
|
|
91
|
+
clearMainAccountInfoCache,
|
|
92
|
+
getMainAccountCredentialPresence,
|
|
93
|
+
getMainAccountInfoCache,
|
|
94
|
+
isMainAccountIdentityGenerationLive,
|
|
95
|
+
setMainAccountCredentialPresence,
|
|
96
|
+
setMainAccountInfoCache,
|
|
97
|
+
type MainAccountInfo,
|
|
98
|
+
} from "./main-account-cache";
|
|
99
|
+
export { clearMainAccountInfoCache } from "./main-account-cache";
|
|
100
|
+
import { maskEmail } from "../lib/privacy";
|
|
101
|
+
import { codexWarmupFailureReason, warmCodexAccount } from "./warmup";
|
|
102
|
+
export { maskEmail } from "../lib/privacy";
|
|
103
|
+
import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types";
|
|
104
|
+
import type { CatalogDisposition } from "./convergence-types";
|
|
105
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
106
|
+
import { providerCodexAccountMode } from "../providers/registry";
|
|
107
|
+
import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBody } from "../lib/bounded-body";
|
|
108
|
+
import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
|
|
109
|
+
import {
|
|
110
|
+
oauthAccountHealthFields,
|
|
111
|
+
projectCodexAccountHealth,
|
|
112
|
+
type OAuthAccountHealth,
|
|
113
|
+
type OAuthHealthLabel,
|
|
114
|
+
} from "../oauth/health";
|
|
115
|
+
import {
|
|
116
|
+
CODEX_ACCOUNT_ID_RE,
|
|
117
|
+
hasLegacyMainCodexPoolAccount,
|
|
118
|
+
isSelectableCodexPoolAccount,
|
|
119
|
+
isValidCodexAccountId,
|
|
120
|
+
} from "./account-id";
|
|
121
|
+
import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
|
|
122
|
+
import { ResourceAdmissionError, type AdmissionLease } from "../lib/admission";
|
|
123
|
+
import { tryAcquireNativeMainProfileClaim } from "./native-main-admission";
|
|
124
|
+
import { withNativeMainSharedClaim } from "./native-main-claim";
|
|
125
|
+
import { resolveNativeProfileContext } from "./native-profile-store";
|
|
126
|
+
import { NativeProfileError } from "./native-profile-types";
|
|
127
|
+
|
|
128
|
+
function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError {
|
|
129
|
+
return error instanceof NativeProfileError
|
|
130
|
+
&& (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function withNativeMainCredentialClaim<T>(operation: () => Promise<T>): Promise<T> {
|
|
134
|
+
return withNativeMainSharedClaim(resolveNativeProfileContext(), operation);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
138
|
+
return new Response(JSON.stringify(data), {
|
|
139
|
+
status,
|
|
140
|
+
headers: { "Content-Type": "application/json" },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function nativeMainProfileBusyResponse(): Response {
|
|
145
|
+
const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
|
|
146
|
+
response.headers.set("Retry-After", "1");
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const CODEX_CREDENTIAL_PERSISTENCE_ERROR = "Account was saved, but credential setup did not complete. Reauthenticate or remove the account.";
|
|
151
|
+
const CODEX_CREDENTIAL_PERSISTENCE_CODE = "codex_credential_persistence_failed";
|
|
152
|
+
|
|
153
|
+
const MAX_CODEX_LOGIN_STATE_ROWS = 32;
|
|
154
|
+
const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000;
|
|
155
|
+
interface CodexLoginStateRow {
|
|
156
|
+
status: string;
|
|
157
|
+
startedAt: number;
|
|
158
|
+
accountId?: string;
|
|
159
|
+
email?: string;
|
|
160
|
+
error?: string;
|
|
161
|
+
code?: string;
|
|
162
|
+
needsReauth?: boolean;
|
|
163
|
+
catalogRefreshPending?: boolean;
|
|
164
|
+
doneAt?: number;
|
|
165
|
+
}
|
|
166
|
+
const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
|
|
167
|
+
export class CodexLoginStateBusyError extends ResourceAdmissionError {
|
|
168
|
+
constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function setCodexLoginState(flowId: string, patch: Partial<CodexLoginStateRow>): void {
|
|
172
|
+
const row = codexAuthLoginState.get(flowId);
|
|
173
|
+
if (row) Object.assign(row, patch);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function pruneCodexLoginState(now = Date.now()): void {
|
|
177
|
+
for (const [id, row] of codexAuthLoginState) {
|
|
178
|
+
if (row.doneAt !== undefined && now - row.doneAt >= CODEX_LOGIN_TERMINAL_TTL_MS) codexAuthLoginState.delete(id);
|
|
179
|
+
}
|
|
180
|
+
while (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
|
|
181
|
+
const terminal = [...codexAuthLoginState].filter(([, row]) => row.doneAt !== undefined)
|
|
182
|
+
.sort((a, b) => (a[1].doneAt ?? 0) - (b[1].doneAt ?? 0))[0];
|
|
183
|
+
if (!terminal) break;
|
|
184
|
+
codexAuthLoginState.delete(terminal[0]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
|
|
189
|
+
if (!isValidCodexAccountId(accountId)) return null;
|
|
190
|
+
return (config.codexAccounts ?? [])
|
|
191
|
+
.find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function codexAccountPersistenceConflict(
|
|
195
|
+
config: OcxConfig,
|
|
196
|
+
accountId: string,
|
|
197
|
+
mode: "create" | "reauth",
|
|
198
|
+
): string | undefined {
|
|
199
|
+
if (mode === "reauth") {
|
|
200
|
+
return configuredPoolAccount(config, accountId)
|
|
201
|
+
? undefined
|
|
202
|
+
: "Pool account was removed while login was in progress. Add it again as a new account.";
|
|
203
|
+
}
|
|
204
|
+
const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId);
|
|
205
|
+
if (namespaceCollision) return namespaceCollision;
|
|
206
|
+
return (config.codexAccounts ?? []).some(account => account.id === accountId)
|
|
207
|
+
|| Boolean(getCodexAccountCredential(accountId))
|
|
208
|
+
? `Account id already exists: ${accountId}`
|
|
209
|
+
: undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
|
|
213
|
+
quota: T,
|
|
214
|
+
plan: unknown,
|
|
215
|
+
): T {
|
|
216
|
+
if (!quota || !isThirtyDayOnlyCodexPlan(plan)) return quota;
|
|
217
|
+
return {
|
|
218
|
+
...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
|
|
219
|
+
...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
|
|
220
|
+
// A 30-day plan can still carry a burst window, and it blocks the account on its own.
|
|
221
|
+
// Dropping it here would show a healthy card for an account upstream is refusing (#1791).
|
|
222
|
+
...(quota.shortPercent !== undefined ? { shortPercent: quota.shortPercent } : {}),
|
|
223
|
+
...(quota.shortResetAt !== undefined ? { shortResetAt: quota.shortResetAt } : {}),
|
|
224
|
+
...(quota.shortWindowSeconds !== undefined ? { shortWindowSeconds: quota.shortWindowSeconds } : {}),
|
|
225
|
+
...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}),
|
|
226
|
+
...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}),
|
|
227
|
+
} as T;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function poolAccountDto(
|
|
231
|
+
account: CodexAccount,
|
|
232
|
+
quotaResult: PoolQuotaResult,
|
|
233
|
+
hasCredential: boolean,
|
|
234
|
+
paused: boolean,
|
|
235
|
+
priority: number,
|
|
236
|
+
): CodexAuthAccountDto {
|
|
237
|
+
const plan = codexPlanValue(account.plan);
|
|
238
|
+
const quota = quotaForPlan(quotaResult.quota, plan);
|
|
239
|
+
const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
|
|
240
|
+
const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
|
|
241
|
+
return {
|
|
242
|
+
id: account.id,
|
|
243
|
+
email: maskEmail(account.email) ?? account.email,
|
|
244
|
+
...(account.alias !== undefined ? { alias: account.alias } : {}),
|
|
245
|
+
...(plan !== undefined ? { plan } : {}),
|
|
246
|
+
logLabel: codexAccountLogLabel(account),
|
|
247
|
+
isMain: false,
|
|
248
|
+
paused,
|
|
249
|
+
priority,
|
|
250
|
+
quota: quota ? { ...quota } : null,
|
|
251
|
+
needsReauth,
|
|
252
|
+
hasCredential,
|
|
253
|
+
...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
|
|
254
|
+
...oauthAccountHealthFields("codex", account.id, health),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface ResetCreditAuth {
|
|
259
|
+
isMain: boolean;
|
|
260
|
+
accessToken: string;
|
|
261
|
+
chatgptAccountId: string;
|
|
262
|
+
nativeMainLease?: AdmissionLease;
|
|
263
|
+
nativeMainSharedClaimHeld?: true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function withResetCreditAuth<T>(
|
|
267
|
+
runtimeConfig: OcxConfig,
|
|
268
|
+
accountId: string,
|
|
269
|
+
operation: (auth: ResetCreditAuth) => Promise<T>,
|
|
270
|
+
): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
|
|
271
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
272
|
+
if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
|
|
273
|
+
return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) };
|
|
274
|
+
}
|
|
275
|
+
const nativeMainLease = tryAcquireNativeMainProfileClaim();
|
|
276
|
+
if (!nativeMainLease) return { ok: false, response: nativeMainProfileBusyResponse() };
|
|
277
|
+
try {
|
|
278
|
+
try {
|
|
279
|
+
return await withNativeMainCredentialClaim(async () => {
|
|
280
|
+
const tokens = readCodexTokens();
|
|
281
|
+
if (!tokens) {
|
|
282
|
+
return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
value: await operation({
|
|
287
|
+
isMain: true,
|
|
288
|
+
accessToken: tokens.access_token,
|
|
289
|
+
chatgptAccountId: tokens.account_id,
|
|
290
|
+
nativeMainLease,
|
|
291
|
+
nativeMainSharedClaimHeld: true,
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (isNativeMainClaimUnavailable(error)) {
|
|
297
|
+
return { ok: false, response: nativeMainProfileBusyResponse() };
|
|
298
|
+
}
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
} finally {
|
|
302
|
+
nativeMainLease.release();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!isValidCodexAccountId(accountId)) {
|
|
306
|
+
return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) };
|
|
307
|
+
}
|
|
308
|
+
if (!configuredPoolAccount(runtimeConfig, accountId)) {
|
|
309
|
+
return { ok: false, response: jsonResponse({ error: "Unknown Codex account" }, 404) };
|
|
310
|
+
}
|
|
311
|
+
const cred = await getValidCodexToken(accountId);
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
value: await operation({
|
|
315
|
+
isMain: false,
|
|
316
|
+
accessToken: cred.accessToken,
|
|
317
|
+
chatgptAccountId: cred.chatgptAccountId,
|
|
318
|
+
}),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; expires_at: string }[]; available_count?: number } {
|
|
323
|
+
const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
|
|
324
|
+
const rawCredits = Array.isArray(obj.credits) ? obj.credits : [];
|
|
325
|
+
const credits = rawCredits.flatMap((raw): { granted_at: string; expires_at: string }[] => {
|
|
326
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
327
|
+
const credit = raw as Record<string, unknown>;
|
|
328
|
+
return typeof credit.granted_at === "string" && typeof credit.expires_at === "string"
|
|
329
|
+
? [{ granted_at: credit.granted_at, expires_at: credit.expires_at }]
|
|
330
|
+
: [];
|
|
331
|
+
});
|
|
332
|
+
const rawAvailable = (obj.rate_limit_reset_credits as { available_count?: unknown } | null | undefined)?.available_count
|
|
333
|
+
?? obj.available_count;
|
|
334
|
+
return {
|
|
335
|
+
credits,
|
|
336
|
+
...(typeof rawAvailable === "number" && Number.isFinite(rawAvailable) ? { available_count: rawAvailable } : {}),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function safeResetCreditConsumeDto(input: unknown): { code: string } {
|
|
341
|
+
const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
|
|
342
|
+
return { code: typeof obj.code === "string" ? obj.code : "unknown" };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
type ResetCreditJsonRead =
|
|
346
|
+
| { ok: true; value: unknown }
|
|
347
|
+
| { ok: false };
|
|
348
|
+
|
|
349
|
+
function cancelResponseBodyWithoutWaiting(body: ReadableStream<Uint8Array> | null): void {
|
|
350
|
+
if (!body) return;
|
|
351
|
+
try {
|
|
352
|
+
void body.cancel().catch(() => undefined);
|
|
353
|
+
} catch {
|
|
354
|
+
// Some stream implementations throw synchronously from cancel().
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function readResetCreditJson(
|
|
359
|
+
response: Response,
|
|
360
|
+
signal: AbortSignal,
|
|
361
|
+
): Promise<ResetCreditJsonRead> {
|
|
362
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
363
|
+
if (Number.isSafeInteger(declaredLength)
|
|
364
|
+
&& declaredLength >= 0
|
|
365
|
+
&& declaredLength > BOUNDED_BODY_MAX_BYTES) {
|
|
366
|
+
cancelResponseBodyWithoutWaiting(response.body);
|
|
367
|
+
return { ok: false };
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
const body = await readBoundedResponseBody(response, {
|
|
371
|
+
signal,
|
|
372
|
+
maxBytes: BOUNDED_BODY_MAX_BYTES,
|
|
373
|
+
fatalUtf8: true,
|
|
374
|
+
});
|
|
375
|
+
if (!body.displaySafe || body.truncated || !body.text.trim()) return { ok: false };
|
|
376
|
+
return { ok: true, value: JSON.parse(body.text) as unknown };
|
|
377
|
+
} catch {
|
|
378
|
+
return { ok: false };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function manualImportDisabledResponse(): Response {
|
|
383
|
+
return jsonResponse({
|
|
384
|
+
error: "Manual Codex account import is disabled. Use OAuth login to add a pool account.",
|
|
385
|
+
code: "manual_import_disabled",
|
|
386
|
+
}, 403);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function verifyCodexAccountWarmup(
|
|
390
|
+
accountId: string,
|
|
391
|
+
accessToken: string,
|
|
392
|
+
chatgptAccountId: string,
|
|
393
|
+
): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response }> {
|
|
394
|
+
try {
|
|
395
|
+
await warmCodexAccount({ accessToken, chatgptAccountId });
|
|
396
|
+
return { ok: true, validatedAt: Date.now() };
|
|
397
|
+
} catch (err) {
|
|
398
|
+
const reason = codexWarmupFailureReason(err);
|
|
399
|
+
return {
|
|
400
|
+
ok: false,
|
|
401
|
+
response: jsonResponse({
|
|
402
|
+
error: "Codex account warmup failed. Reauthenticate the account and try again.",
|
|
403
|
+
code: "codex_warmup_failed",
|
|
404
|
+
reason,
|
|
405
|
+
accountId,
|
|
406
|
+
}, 401),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
|
|
412
|
+
const ids = flowId
|
|
413
|
+
? [flowId]
|
|
414
|
+
: [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id);
|
|
415
|
+
for (const id of ids) {
|
|
416
|
+
let owner = codexAuthLoginState.get(id);
|
|
417
|
+
if (!owner) {
|
|
418
|
+
pruneCodexLoginState();
|
|
419
|
+
if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) continue;
|
|
420
|
+
owner = { status: "error", startedAt: Date.now() };
|
|
421
|
+
codexAuthLoginState.set(id, owner);
|
|
422
|
+
}
|
|
423
|
+
Object.assign(owner, { status: "error", error, doneAt: Date.now() });
|
|
424
|
+
setTimeout(() => { if (codexAuthLoginState.get(id) === owner) codexAuthLoginState.delete(id); }, 30_000);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const MAIN_CACHE_TTL = 5 * 60_000;
|
|
429
|
+
const POOL_CACHE_TTL = 5 * 60_000;
|
|
430
|
+
const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
|
|
431
|
+
|
|
432
|
+
function nonEmptyPlan(value: unknown): string | null {
|
|
433
|
+
return codexPlanValue(value) ?? null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function isRuntimeConfig(config: OcxConfig): boolean {
|
|
437
|
+
return !!config && typeof config === "object" && !!config.providers;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function getRuntimeConfig(config: OcxConfig): OcxConfig {
|
|
441
|
+
return isRuntimeConfig(config) ? config : loadConfig();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void {
|
|
445
|
+
saveConfigPreservingClaudeCode(nextConfig);
|
|
446
|
+
if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return;
|
|
447
|
+
for (const key of Object.keys(sourceConfig) as Array<keyof OcxConfig>) {
|
|
448
|
+
delete sourceConfig[key];
|
|
449
|
+
}
|
|
450
|
+
Object.assign(sourceConfig, nextConfig);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
interface StagedNewCodexAccountState {
|
|
454
|
+
credential: CodexAccountCredentials;
|
|
455
|
+
validatedAt: number;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
type PersistNewCodexAccountOutcome =
|
|
459
|
+
| { status: "committed"; pickerVisibilityChanged: boolean }
|
|
460
|
+
| { status: "publication-failed"; pickerVisibilityChanged: boolean };
|
|
461
|
+
|
|
462
|
+
function codexCredentialPersistenceFailure(accountId: string, catalogRefreshPending: boolean) {
|
|
463
|
+
return {
|
|
464
|
+
error: CODEX_CREDENTIAL_PERSISTENCE_ERROR,
|
|
465
|
+
code: CODEX_CREDENTIAL_PERSISTENCE_CODE,
|
|
466
|
+
accountId,
|
|
467
|
+
needsReauth: true as const,
|
|
468
|
+
...(catalogRefreshPending ? { catalogRefreshPending: true as const } : {}),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Persist config before publishing secret or runtime state under the shared mutation coordinator. */
|
|
473
|
+
function persistNewCodexAccount(
|
|
474
|
+
sourceConfig: OcxConfig,
|
|
475
|
+
runtimeConfig: OcxConfig,
|
|
476
|
+
addedAccount: CodexAccount,
|
|
477
|
+
staged: StagedNewCodexAccountState,
|
|
478
|
+
): PersistNewCodexAccountOutcome {
|
|
479
|
+
return withConfigMutationLockSync(() => {
|
|
480
|
+
const previousConfig = { ...runtimeConfig };
|
|
481
|
+
let pickerVisibilityChanged: boolean;
|
|
482
|
+
try {
|
|
483
|
+
const accounts = [...(runtimeConfig.codexAccounts ?? [])];
|
|
484
|
+
const retainedPickerBindingRestored = codexAccountPickerEnabled(runtimeConfig)
|
|
485
|
+
&& Object.values(runtimeConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id);
|
|
486
|
+
accounts.push(addedAccount);
|
|
487
|
+
runtimeConfig.codexAccounts = accounts;
|
|
488
|
+
|
|
489
|
+
// Presence of the explicit flag distinguishes a dashboard-managed map from
|
|
490
|
+
// a hand-authored legacy map. Preserve manual maps exactly.
|
|
491
|
+
const tracksPickerNamespaces = runtimeConfig.codexAccountPickerEnabled !== undefined;
|
|
492
|
+
if (tracksPickerNamespaces && runtimeConfig.codexAccountNamespaces) {
|
|
493
|
+
runtimeConfig.codexAccountNamespaces = { ...runtimeConfig.codexAccountNamespaces };
|
|
494
|
+
}
|
|
495
|
+
const namespaceAdded = tracksPickerNamespaces
|
|
496
|
+
&& appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount);
|
|
497
|
+
pickerVisibilityChanged = namespaceAdded || retainedPickerBindingRestored;
|
|
498
|
+
saveRuntimeConfig(sourceConfig, runtimeConfig);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
for (const key of Object.keys(runtimeConfig) as Array<keyof OcxConfig>) {
|
|
501
|
+
delete runtimeConfig[key];
|
|
502
|
+
}
|
|
503
|
+
Object.assign(runtimeConfig, previousConfig);
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
saveCodexAccountCredential(addedAccount.id, staged.credential);
|
|
509
|
+
markCodexAccountValidated(addedAccount.id, staged.validatedAt);
|
|
510
|
+
clearAccountNeedsReauth(addedAccount.id);
|
|
511
|
+
} catch {
|
|
512
|
+
// Config is already durable. Return the failure outcome through the coordinator so its
|
|
513
|
+
// generation commit is not rolled back while config.json remains changed.
|
|
514
|
+
return { status: "publication-failed" as const, pickerVisibilityChanged };
|
|
515
|
+
}
|
|
516
|
+
return { status: "committed" as const, pickerVisibilityChanged };
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Bounded catalog-convergence callback supplied by the management dispatcher. */
|
|
521
|
+
export type CodexAuthCatalogConvergence = () => Promise<CatalogDisposition>;
|
|
522
|
+
|
|
523
|
+
interface AccountNamespaceCatalogRefresh {
|
|
524
|
+
catalogRefreshPending: boolean;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Collapse post-persistence convergence into the one public recovery bit. */
|
|
528
|
+
async function convergeAccountNamespaceCatalog(
|
|
529
|
+
config: OcxConfig,
|
|
530
|
+
changed: boolean,
|
|
531
|
+
convergeCodexCatalog?: CodexAuthCatalogConvergence,
|
|
532
|
+
): Promise<AccountNamespaceCatalogRefresh> {
|
|
533
|
+
if (!changed || !codexAccountPickerEnabled(config)) {
|
|
534
|
+
return { catalogRefreshPending: false };
|
|
535
|
+
}
|
|
536
|
+
if (!convergeCodexCatalog) return { catalogRefreshPending: true };
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
const catalogRefresh = normalizeCatalogDisposition(await convergeCodexCatalog());
|
|
540
|
+
if (!catalogRefresh) return { catalogRefreshPending: true };
|
|
541
|
+
return { catalogRefreshPending: catalogRefreshIsPending(catalogRefresh) };
|
|
542
|
+
} catch {
|
|
543
|
+
return { catalogRefreshPending: true };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function mapWithConcurrency<T, R>(
|
|
548
|
+
items: T[],
|
|
549
|
+
concurrency: number,
|
|
550
|
+
mapper: (item: T) => Promise<R>,
|
|
551
|
+
): Promise<R[]> {
|
|
552
|
+
const results = new Array<R>(items.length);
|
|
553
|
+
let next = 0;
|
|
554
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
555
|
+
while (next < items.length) {
|
|
556
|
+
const index = next++;
|
|
557
|
+
results[index] = await mapper(items[index]!);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
await Promise.all(workers);
|
|
561
|
+
return results;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const MAIN_TERMINAL_AUTH_CODES = new Set([
|
|
565
|
+
"invalid_workspace_selected",
|
|
566
|
+
"invalid_refresh_token",
|
|
567
|
+
]);
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* A WHAM 401 is not itself proof the local credential died. Upstream edges can
|
|
571
|
+
* transiently reject a still-valid access token (region/anti-abuse/rotation
|
|
572
|
+
* races), and fail-closing on every bare 401 makes a healthy main account flip
|
|
573
|
+
* needs-reauth on the next GUI quota poll. Only treat the response as terminal
|
|
574
|
+
* when the body carries a known terminal code or the local access token is not
|
|
575
|
+
* verifiably live (`accessTokenLive`). Liveness must be strict: a JWT whose
|
|
576
|
+
* `exp` cannot be decoded is NOT live — an undecodable token that vouched for
|
|
577
|
+
* itself would make a real 401 permanently transient.
|
|
578
|
+
*/
|
|
579
|
+
async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise<boolean> {
|
|
580
|
+
if (resp.status === 401) {
|
|
581
|
+
if (!accessTokenLive) return true;
|
|
582
|
+
const code = await readMainAuthErrorCode(resp);
|
|
583
|
+
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
|
|
584
|
+
}
|
|
585
|
+
if (resp.status !== 403) return false;
|
|
586
|
+
const code = await readMainAuthErrorCode(resp);
|
|
587
|
+
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function readMainAuthErrorCode(resp: Response): Promise<unknown> {
|
|
591
|
+
try {
|
|
592
|
+
const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 });
|
|
593
|
+
if (!body.displaySafe) return undefined;
|
|
594
|
+
const parsed = JSON.parse(body.text) as {
|
|
595
|
+
detail?: { code?: unknown } | string;
|
|
596
|
+
error?: { code?: unknown } | string;
|
|
597
|
+
code?: unknown;
|
|
598
|
+
};
|
|
599
|
+
const code = typeof parsed.detail === "object" && parsed.detail !== null
|
|
600
|
+
? parsed.detail.code
|
|
601
|
+
: typeof parsed.error === "object" && parsed.error !== null
|
|
602
|
+
? parsed.error.code
|
|
603
|
+
: parsed.code;
|
|
604
|
+
return code;
|
|
605
|
+
} catch {
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
interface MainAccountInfoFetchResult {
|
|
611
|
+
info: MainAccountInfo;
|
|
612
|
+
/** Whether this attempt safely inspected the physical native-main credential. */
|
|
613
|
+
credentialChecked: boolean;
|
|
614
|
+
/** Meaningful only when credentialChecked is true. */
|
|
615
|
+
hasCredential: boolean;
|
|
616
|
+
/** Main identity generation captured while the native-main claim was held. */
|
|
617
|
+
identityGeneration?: number;
|
|
618
|
+
/** Present only when this call freshly parsed a WHAM usage response. */
|
|
619
|
+
freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
|
|
620
|
+
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
621
|
+
freshResetCredits?: number;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export interface MainAccountInfoSnapshot {
|
|
625
|
+
info: MainAccountInfo;
|
|
626
|
+
mainIdentityGeneration: number;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise<MainAccountInfoSnapshot> {
|
|
630
|
+
const result = await fetchMainAccountInfoAttempt(forceRefresh, 1);
|
|
631
|
+
return {
|
|
632
|
+
info: result.info,
|
|
633
|
+
mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
|
|
638
|
+
return (await fetchMainAccountInfoSnapshot(forceRefresh)).info;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };
|
|
642
|
+
|
|
643
|
+
async function retryMainAccountInfoIfIdentityChanged(
|
|
644
|
+
requestAccountId: string | null,
|
|
645
|
+
retriesRemaining: number,
|
|
646
|
+
nativeMainLease: AdmissionLease,
|
|
647
|
+
explicitRefresh: boolean,
|
|
648
|
+
): Promise<MainAccountInfoFetchResult | null> {
|
|
649
|
+
const currentAccountId = getMainChatgptAccountId();
|
|
650
|
+
if (currentAccountId === null || currentAccountId === requestAccountId) return null;
|
|
651
|
+
reconcileMainCodexAccountRuntimeState();
|
|
652
|
+
return retriesRemaining > 0
|
|
653
|
+
? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh)
|
|
654
|
+
: { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function fetchMainAccountInfoAttempt(
|
|
658
|
+
forceRefresh: boolean,
|
|
659
|
+
retriesRemaining: number,
|
|
660
|
+
existingNativeMainLease?: AdmissionLease,
|
|
661
|
+
nativeMainSharedClaimHeld = false,
|
|
662
|
+
): Promise<MainAccountInfoFetchResult> {
|
|
663
|
+
const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim();
|
|
664
|
+
if (!nativeMainLease) {
|
|
665
|
+
return {
|
|
666
|
+
info: EMPTY_MAIN_ACCOUNT_INFO,
|
|
667
|
+
credentialChecked: false,
|
|
668
|
+
hasCredential: false,
|
|
669
|
+
identityGeneration: captureMainAccountIdentityGeneration(),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
const operation = async () => ({
|
|
674
|
+
...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease),
|
|
675
|
+
identityGeneration: captureMainAccountIdentityGeneration(),
|
|
676
|
+
});
|
|
677
|
+
if (nativeMainSharedClaimHeld) return await operation();
|
|
678
|
+
try {
|
|
679
|
+
return await withNativeMainCredentialClaim(operation);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if (isNativeMainClaimUnavailable(error)) {
|
|
682
|
+
return {
|
|
683
|
+
info: EMPTY_MAIN_ACCOUNT_INFO,
|
|
684
|
+
credentialChecked: false,
|
|
685
|
+
hasCredential: false,
|
|
686
|
+
identityGeneration: captureMainAccountIdentityGeneration(),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
} finally {
|
|
692
|
+
if (!existingNativeMainLease) nativeMainLease.release();
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function fetchMainAccountInfoWhileOwned(
|
|
697
|
+
forceRefresh: boolean,
|
|
698
|
+
retriesRemaining: number,
|
|
699
|
+
nativeMainLease: AdmissionLease,
|
|
700
|
+
/**
|
|
701
|
+
* Whether the *caller* asked for this refresh. `forceRefresh` also means "bypass the
|
|
702
|
+
* cache", and `retryMainAccountInfoIfIdentityChanged` re-enters with it set purely to
|
|
703
|
+
* re-read after the identity changed. Keeping the two apart stops that retry from
|
|
704
|
+
* promoting a background poll into operator intent below.
|
|
705
|
+
*/
|
|
706
|
+
explicitRefresh: boolean = forceRefresh,
|
|
707
|
+
): Promise<MainAccountInfoFetchResult> {
|
|
708
|
+
const writerGeneration = captureConfigGeneration();
|
|
709
|
+
reconcileMainCodexAccountRuntimeState();
|
|
710
|
+
const tokenRead = readCodexTokensResult();
|
|
711
|
+
setMainAccountCredentialPresence(tokenRead.status === "ok");
|
|
712
|
+
if (tokenRead.status !== "ok") {
|
|
713
|
+
// A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite
|
|
714
|
+
// gap, and malformed JSON can be a half-written file. Clearing the cache and marking the
|
|
715
|
+
// account for reauth here destroyed healthy email/plan/quota state and pinned a working
|
|
716
|
+
// account as unusable. Preserve what we already know and let the caller retry; request
|
|
717
|
+
// routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the
|
|
718
|
+
// account DTO still reports hasCredential=false while the file is unreadable.
|
|
719
|
+
const preserved = getMainAccountInfoCache();
|
|
720
|
+
return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false };
|
|
721
|
+
}
|
|
722
|
+
const tokens = tokenRead.tokens;
|
|
723
|
+
const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
|
|
724
|
+
const cached = getMainAccountInfoCache();
|
|
725
|
+
if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
|
|
726
|
+
return { info: cached, credentialChecked: true, hasCredential: true };
|
|
727
|
+
}
|
|
728
|
+
try {
|
|
729
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
730
|
+
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
731
|
+
signal: AbortSignal.timeout(8000),
|
|
732
|
+
});
|
|
733
|
+
if (!resp.ok) {
|
|
734
|
+
const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
|
|
735
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
|
|
736
|
+
if (retried) return retried;
|
|
737
|
+
if (terminalAuthFailure) {
|
|
738
|
+
clearMainAccountInfoCache();
|
|
739
|
+
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
|
|
740
|
+
}
|
|
741
|
+
return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
|
|
742
|
+
}
|
|
743
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
744
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
|
|
745
|
+
if (retried) return retried;
|
|
746
|
+
const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
|
|
747
|
+
const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
|
|
748
|
+
const freshResetCredits = quota?.resetCredits;
|
|
749
|
+
const result = {
|
|
750
|
+
email: data.email ?? null,
|
|
751
|
+
plan,
|
|
752
|
+
quota,
|
|
753
|
+
ts: Date.now(),
|
|
754
|
+
};
|
|
755
|
+
setMainAccountInfoCache(result);
|
|
756
|
+
// Only an explicit refresh may retract a reauth quarantine. A 200 from
|
|
757
|
+
// /wham/usage proves the token authenticates to the usage endpoint; it does not
|
|
758
|
+
// prove the account can serve Responses traffic, which is a different backend path
|
|
759
|
+
// and still answers 403 for a workspace the token may no longer select (#327).
|
|
760
|
+
// Letting the background poll clear the flag put such an account straight back into
|
|
761
|
+
// rotation: the next request failed the same way and re-marked it, so needsReauth
|
|
762
|
+
// never settled and the dashboard kept showing nothing — the symptom #327 reported.
|
|
763
|
+
// An explicit refresh is an operator asking to re-evaluate, normally right after
|
|
764
|
+
// signing in again, so it stays authoritative.
|
|
765
|
+
if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
766
|
+
// Mirror main quota + plan into the shared stores so the rotation engine can
|
|
767
|
+
// score and auto-switch the main account exactly like a pool account (Option A).
|
|
768
|
+
setMainAccountPlan(result.plan);
|
|
769
|
+
if (result.quota) {
|
|
770
|
+
setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration);
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
info: result,
|
|
774
|
+
credentialChecked: true,
|
|
775
|
+
hasCredential: true,
|
|
776
|
+
...(quota ? { freshQuota: quota } : {}),
|
|
777
|
+
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
778
|
+
};
|
|
779
|
+
} catch {
|
|
780
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
|
|
781
|
+
return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
interface PoolQuotaResult {
|
|
786
|
+
quota: StoredAccountQuota | null;
|
|
787
|
+
needsReauth: boolean;
|
|
788
|
+
/** Credential generation whose cache or network result this DTO state belongs to. */
|
|
789
|
+
credentialGeneration?: number;
|
|
790
|
+
/** Present only when this call freshly parsed a WHAM usage response. */
|
|
791
|
+
freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
|
|
792
|
+
/** Present only when this call's WHAM response included a non-empty `plan_type`. */
|
|
793
|
+
freshPlan?: string;
|
|
794
|
+
/** Credential generation used by this fresh quota request. */
|
|
795
|
+
freshCredentialGeneration?: number;
|
|
796
|
+
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
797
|
+
freshResetCredits?: number;
|
|
798
|
+
quotaProbeSkipped?: true;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
interface PoolQuotaRefreshFlight {
|
|
802
|
+
state: {
|
|
803
|
+
startCredentialGeneration?: number;
|
|
804
|
+
resolvedCredentialGeneration?: number;
|
|
805
|
+
};
|
|
806
|
+
promise: Promise<PoolQuotaResult>;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const poolQuotaRefreshInFlight = new Map<string, Set<PoolQuotaRefreshFlight>>();
|
|
810
|
+
const MAX_POOL_QUOTA_FLIGHTS = 16;
|
|
811
|
+
|
|
812
|
+
export class PoolQuotaProbeBusyError extends ResourceAdmissionError {
|
|
813
|
+
constructor() {
|
|
814
|
+
super("pool_quota_flights", MAX_POOL_QUOTA_FLIGHTS);
|
|
815
|
+
this.name = "PoolQuotaProbeBusyError";
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function poolQuotaFlightCount(): number {
|
|
820
|
+
let count = 0;
|
|
821
|
+
for (const flights of poolQuotaRefreshInFlight.values()) count += flights.size;
|
|
822
|
+
return count;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Focused admission tests only; returns cleanup for the synthetic owners it inserts. */
|
|
826
|
+
export function seedCodexAuthAdmissionForTests(options: { loginFlows?: number; quotaFlights?: number }): () => void {
|
|
827
|
+
const prefix = `admission-test-${crypto.randomUUID()}`;
|
|
828
|
+
for (let index = 0; index < (options.loginFlows ?? 0); index++) {
|
|
829
|
+
codexAuthLoginState.set(`${prefix}-login-${index}`, { status: "starting", startedAt: Date.now() });
|
|
830
|
+
}
|
|
831
|
+
for (let index = 0; index < (options.quotaFlights ?? 0); index++) {
|
|
832
|
+
poolQuotaRefreshInFlight.set(`${prefix}-quota-${index}`, new Set([{
|
|
833
|
+
state: {},
|
|
834
|
+
promise: new Promise<PoolQuotaResult>(() => {}),
|
|
835
|
+
}]));
|
|
836
|
+
}
|
|
837
|
+
return () => {
|
|
838
|
+
for (const key of [...codexAuthLoginState.keys()]) if (key.startsWith(prefix)) codexAuthLoginState.delete(key);
|
|
839
|
+
for (const key of [...poolQuotaRefreshInFlight.keys()]) if (key.startsWith(prefix)) poolQuotaRefreshInFlight.delete(key);
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export interface CodexAuthAccountDto {
|
|
844
|
+
id: string;
|
|
845
|
+
alias?: string;
|
|
846
|
+
email: string;
|
|
847
|
+
plan?: string | null;
|
|
848
|
+
logLabel?: string;
|
|
849
|
+
isMain: boolean;
|
|
850
|
+
paused: boolean;
|
|
851
|
+
/** Selection order; higher is used earlier. Always present, 0 when unset. */
|
|
852
|
+
priority: number;
|
|
853
|
+
quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
|
|
854
|
+
needsReauth?: boolean;
|
|
855
|
+
hasCredential: boolean;
|
|
856
|
+
health: OAuthAccountHealth;
|
|
857
|
+
healthLabel: OAuthHealthLabel;
|
|
858
|
+
healthSummary: string;
|
|
859
|
+
healthAction?: string;
|
|
860
|
+
quotaProbeSkipped?: true;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
interface FreshPoolPlanUpdate {
|
|
864
|
+
accountId: string;
|
|
865
|
+
plan: string;
|
|
866
|
+
credentialGeneration: number;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save
|
|
871
|
+
* the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while
|
|
872
|
+
* WHAM requests were in flight. Missing or malformed files fail closed: a read path must not
|
|
873
|
+
* recreate a deleted config from the server's older in-memory snapshot.
|
|
874
|
+
*/
|
|
875
|
+
function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void {
|
|
876
|
+
if (updates.length === 0) return;
|
|
877
|
+
let outcome: ReturnType<typeof mutatePersistedConfig<FreshPoolPlanUpdate[]>>;
|
|
878
|
+
try {
|
|
879
|
+
outcome = mutatePersistedConfig(persistedConfig => {
|
|
880
|
+
const accepted: FreshPoolPlanUpdate[] = [];
|
|
881
|
+
let changed = false;
|
|
882
|
+
for (const update of updates) {
|
|
883
|
+
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
|
|
884
|
+
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
|
|
885
|
+
const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId);
|
|
886
|
+
if (!liveAccount || !persistedAccount) continue;
|
|
887
|
+
accepted.push(update);
|
|
888
|
+
if (persistedAccount.plan !== update.plan) {
|
|
889
|
+
persistedAccount.plan = update.plan;
|
|
890
|
+
// WHAM is the authoritative plan source: stamp provenance so a later JWT
|
|
891
|
+
// reconcile cannot overwrite this observation within the same credential
|
|
892
|
+
// generation (src/codex/plan-from-token.ts jwtMayWritePlan). Stamped only
|
|
893
|
+
// alongside a real plan change: a steady-state refresh whose plan is
|
|
894
|
+
// unchanged must stay write-free (no-config-write contract), and an
|
|
895
|
+
// unchanged value needs no fence — a JWT rewrite to the same text is a
|
|
896
|
+
// no-op under the caller's own equality check.
|
|
897
|
+
persistedAccount.planSource = "wham";
|
|
898
|
+
persistedAccount.planCredentialGeneration = update.credentialGeneration;
|
|
899
|
+
changed = true;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return { changed, value: accepted };
|
|
903
|
+
});
|
|
904
|
+
} catch (error) {
|
|
905
|
+
// Plan persistence is derived metadata on a read route. Contention must fail closed without
|
|
906
|
+
// turning account listing into a 500; a later refresh can retry against the latest files.
|
|
907
|
+
if (error instanceof ConfigMutationLockError) return;
|
|
908
|
+
throw error;
|
|
909
|
+
}
|
|
910
|
+
if (outcome.status === "unavailable") return;
|
|
911
|
+
for (const update of outcome.value) {
|
|
912
|
+
// A replacement immediately after the durable commit is allowed to supersede the result, but
|
|
913
|
+
// the long-lived object must never be updated from that stale generation.
|
|
914
|
+
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
|
|
915
|
+
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
|
|
916
|
+
if (liveAccount) {
|
|
917
|
+
liveAccount.plan = update.plan;
|
|
918
|
+
liveAccount.planSource = "wham";
|
|
919
|
+
liveAccount.planCredentialGeneration = update.credentialGeneration;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function fetchFreshPoolAccountQuota(
|
|
925
|
+
accountId: string,
|
|
926
|
+
existing: StoredAccountQuota | null,
|
|
927
|
+
configuredPlan?: string,
|
|
928
|
+
onCredentialGeneration?: (generation: number) => void,
|
|
929
|
+
): Promise<PoolQuotaResult> {
|
|
930
|
+
const writerGeneration = captureConfigGeneration();
|
|
931
|
+
let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
|
|
932
|
+
try {
|
|
933
|
+
const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId);
|
|
934
|
+
requestCredentialGeneration = generation;
|
|
935
|
+
onCredentialGeneration?.(generation);
|
|
936
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
937
|
+
headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
|
|
938
|
+
signal: AbortSignal.timeout(8000),
|
|
939
|
+
});
|
|
940
|
+
if (!resp.ok) {
|
|
941
|
+
return {
|
|
942
|
+
quota: existing ?? null,
|
|
943
|
+
needsReauth: resp.status === 401,
|
|
944
|
+
credentialGeneration: generation,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
948
|
+
const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
|
|
949
|
+
const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
|
|
950
|
+
const freshResetCredits = quota?.resetCredits;
|
|
951
|
+
if (!quota) {
|
|
952
|
+
return {
|
|
953
|
+
quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
|
|
954
|
+
needsReauth: false,
|
|
955
|
+
credentialGeneration: generation,
|
|
956
|
+
...(freshPlan !== undefined
|
|
957
|
+
? { freshPlan, freshCredentialGeneration: generation }
|
|
958
|
+
: {}),
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
if (!isCodexAccountGenerationLive(accountId, generation)) {
|
|
962
|
+
return { quota: null, needsReauth: false, credentialGeneration: generation };
|
|
963
|
+
}
|
|
964
|
+
setAccountQuotaFromParsed(accountId, quota, writerGeneration);
|
|
965
|
+
return {
|
|
966
|
+
quota: getAccountQuota(accountId),
|
|
967
|
+
needsReauth: false,
|
|
968
|
+
credentialGeneration: generation,
|
|
969
|
+
freshQuota: quota,
|
|
970
|
+
freshCredentialGeneration: generation,
|
|
971
|
+
...(freshPlan !== undefined ? { freshPlan } : {}),
|
|
972
|
+
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
973
|
+
};
|
|
974
|
+
} catch (e) {
|
|
975
|
+
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
|
|
976
|
+
|| e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
977
|
+
return {
|
|
978
|
+
quota: existing ?? null,
|
|
979
|
+
needsReauth: false,
|
|
980
|
+
credentialGeneration: requestCredentialGeneration,
|
|
981
|
+
...(e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError
|
|
982
|
+
? { quotaProbeSkipped: true as const }
|
|
983
|
+
: {}),
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
if (e instanceof TokenRefreshError) {
|
|
987
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration };
|
|
988
|
+
}
|
|
989
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration };
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
|
|
994
|
+
const existing = getAccountQuota(accountId);
|
|
995
|
+
if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
|
|
996
|
+
return {
|
|
997
|
+
quota: existing,
|
|
998
|
+
needsReauth: false,
|
|
999
|
+
credentialGeneration: readCodexAccountRecord(accountId)?.generation,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
// A token refresh may increment the generation (and rotate the refresh token) before WHAM
|
|
1003
|
+
// completes. Join a flight whose starting or resolved generation is still current, but let a
|
|
1004
|
+
// replacement credential with the same pool id start its own request.
|
|
1005
|
+
const record = readCodexAccountRecord(accountId);
|
|
1006
|
+
const flights = poolQuotaRefreshInFlight.get(accountId);
|
|
1007
|
+
const current = flights && [...flights].find(flight => {
|
|
1008
|
+
const generation = flight.state.resolvedCredentialGeneration
|
|
1009
|
+
?? flight.state.startCredentialGeneration;
|
|
1010
|
+
return generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
|
|
1011
|
+
});
|
|
1012
|
+
if (current) return current.promise;
|
|
1013
|
+
if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
|
|
1014
|
+
|
|
1015
|
+
const state: PoolQuotaRefreshFlight["state"] = {
|
|
1016
|
+
startCredentialGeneration: record?.generation,
|
|
1017
|
+
};
|
|
1018
|
+
const refresh = fetchFreshPoolAccountQuota(
|
|
1019
|
+
accountId,
|
|
1020
|
+
existing,
|
|
1021
|
+
configuredPlan,
|
|
1022
|
+
generation => { state.resolvedCredentialGeneration = generation; },
|
|
1023
|
+
);
|
|
1024
|
+
const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
|
|
1025
|
+
const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
|
|
1026
|
+
activeFlights.add(flight);
|
|
1027
|
+
if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
|
|
1028
|
+
try {
|
|
1029
|
+
return await refresh;
|
|
1030
|
+
} finally {
|
|
1031
|
+
activeFlights.delete(flight);
|
|
1032
|
+
if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
|
|
1033
|
+
poolQuotaRefreshInFlight.delete(accountId);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
let primeInFlight: Promise<void> | null = null;
|
|
1039
|
+
let cooldownRecoveryInFlight: Promise<void> | null = null;
|
|
1040
|
+
|
|
1041
|
+
export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Date.now()): Promise<void> {
|
|
1042
|
+
const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
|
|
1043
|
+
if (!openai
|
|
1044
|
+
|| openai.disabled === true
|
|
1045
|
+
|| !isCanonicalOpenAiForwardProvider(openai)
|
|
1046
|
+
|| providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool") return;
|
|
1047
|
+
if (cooldownRecoveryInFlight) return cooldownRecoveryInFlight;
|
|
1048
|
+
cooldownRecoveryInFlight = (async () => {
|
|
1049
|
+
const claims = claimDueCodexQuotaRecoveryProbes(config, POOL_QUOTA_REFRESH_CONCURRENCY, now);
|
|
1050
|
+
await mapWithConcurrency(claims, POOL_QUOTA_REFRESH_CONCURRENCY, async claim => {
|
|
1051
|
+
const account = configuredPoolAccount(config, claim.accountId);
|
|
1052
|
+
if (!account) {
|
|
1053
|
+
settleCodexQuotaRecoveryProbe(claim, false, {}, now);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
try {
|
|
1057
|
+
const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan);
|
|
1058
|
+
// Defence in depth: `spark` is already excluded at the claim site, since generic WHAM
|
|
1059
|
+
// cannot prove a spark recovery. Keep the settle-side guard so a future claim change
|
|
1060
|
+
// cannot silently start clearing spark on generic evidence.
|
|
1061
|
+
const recovered = claim.scope !== "spark"
|
|
1062
|
+
&& isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
|
|
1063
|
+
settleCodexQuotaRecoveryProbe(claim, recovered, {
|
|
1064
|
+
credentialGeneration: result.freshCredentialGeneration,
|
|
1065
|
+
}, now);
|
|
1066
|
+
} catch {
|
|
1067
|
+
settleCodexQuotaRecoveryProbe(claim, false, {}, now);
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
})().catch(() => {
|
|
1071
|
+
// Background recovery is best-effort; routing keeps the cooldown on failure.
|
|
1072
|
+
}).finally(() => { cooldownRecoveryInFlight = null; });
|
|
1073
|
+
return cooldownRecoveryInFlight;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void {
|
|
1077
|
+
registerStateSweepAfterTick({
|
|
1078
|
+
name: "codex-cooldown-recovery",
|
|
1079
|
+
afterTick: () => { void runCodexCooldownRecoveryProbes(config); },
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
export interface PrimeCodexPoolQuotasOptions {
|
|
1084
|
+
/** Test seams for proving fenced/recovery priming performs no native-main work. */
|
|
1085
|
+
reconcileMainAccount?: typeof reconcileMainCodexAccountRuntimeState;
|
|
1086
|
+
readMainTokens?: typeof readCodexTokens;
|
|
1087
|
+
fetchMainInfo?: typeof fetchMainAccountInfo;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function tryAcquireNativeMainPrimeLease(): AdmissionLease | null {
|
|
1091
|
+
return tryAcquireNativeMainProfileClaim();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Best-effort prime of pool-account (and main) quota so the rotation engine has
|
|
1096
|
+
* real usage scores instead of leaving every account at the unknown sentinel.
|
|
1097
|
+
*
|
|
1098
|
+
* Quota is otherwise populated only from live upstream headers (an idle pool
|
|
1099
|
+
* account never serves traffic, so it never gets scored) or from the dashboard
|
|
1100
|
+
* WHAM fetch (a CLI-only user never opens it). Without priming, every account
|
|
1101
|
+
* stays unknown and auto-switch cannot move (see Phase 10). This runs at startup
|
|
1102
|
+
* and lazily before routing when the active account is unknown.
|
|
1103
|
+
*
|
|
1104
|
+
* Single-flight: concurrent callers share one pass instead of stampeding N WHAM
|
|
1105
|
+
* fetches. Per-fetch 8s timeouts and the 5-minute POOL_CACHE_TTL already bound
|
|
1106
|
+
* cost, so the worst case is one WHAM call per account per TTL window. Failures
|
|
1107
|
+
* are swallowed: a blocked WSL network must never crash startup or a request.
|
|
1108
|
+
*/
|
|
1109
|
+
export async function primeCodexPoolQuotas(
|
|
1110
|
+
config: OcxConfig,
|
|
1111
|
+
reason: string,
|
|
1112
|
+
options: PrimeCodexPoolQuotasOptions = {},
|
|
1113
|
+
): Promise<void> {
|
|
1114
|
+
const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
|
|
1115
|
+
if (
|
|
1116
|
+
!openai
|
|
1117
|
+
|| openai.disabled === true
|
|
1118
|
+
|| !isCanonicalOpenAiForwardProvider(openai)
|
|
1119
|
+
|| providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
|
|
1120
|
+
) return;
|
|
1121
|
+
if (primeInFlight) return primeInFlight;
|
|
1122
|
+
primeInFlight = (async () => {
|
|
1123
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1124
|
+
const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
1125
|
+
const stale = pool.filter(a => {
|
|
1126
|
+
const q = getAccountQuota(a.id);
|
|
1127
|
+
return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL;
|
|
1128
|
+
});
|
|
1129
|
+
const primeMain = async () => {
|
|
1130
|
+
const mainLease = tryAcquireNativeMainPrimeLease();
|
|
1131
|
+
if (!mainLease) return;
|
|
1132
|
+
try {
|
|
1133
|
+
try {
|
|
1134
|
+
await withNativeMainCredentialClaim(async () => {
|
|
1135
|
+
// Keep one local owner and one cross-process reader from physical
|
|
1136
|
+
// identity reconciliation through WHAM and all quota publication.
|
|
1137
|
+
(options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)();
|
|
1138
|
+
if (getAccountQuota(MAIN_CODEX_ACCOUNT_ID)) return;
|
|
1139
|
+
if (!(options.readMainTokens ?? readCodexTokens)()) return;
|
|
1140
|
+
if (options.fetchMainInfo) await options.fetchMainInfo(false);
|
|
1141
|
+
else await fetchMainAccountInfoAttempt(false, 1, mainLease, true);
|
|
1142
|
+
});
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
if (!isNativeMainClaimUnavailable(error)) throw error;
|
|
1145
|
+
}
|
|
1146
|
+
} finally {
|
|
1147
|
+
mainLease.release();
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
try {
|
|
1151
|
+
await Promise.allSettled([
|
|
1152
|
+
primeMain(),
|
|
1153
|
+
mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
|
|
1154
|
+
if (!getCodexAccountCredential(a.id)) return;
|
|
1155
|
+
await fetchPoolAccountQuota(a.id, false, a.plan);
|
|
1156
|
+
}),
|
|
1157
|
+
]);
|
|
1158
|
+
} catch {
|
|
1159
|
+
// Priming is best-effort; never propagate.
|
|
1160
|
+
}
|
|
1161
|
+
if (process.env.OPENCODEX_DEBUG_QUOTA === "1") {
|
|
1162
|
+
console.warn(`[codex-quota] prime done (reason=${reason}, pool=${pool.length}, refreshed=${stale.length})`);
|
|
1163
|
+
}
|
|
1164
|
+
})().finally(() => { primeInFlight = null; });
|
|
1165
|
+
return primeInFlight;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/** Test-only: drop any in-flight prime pass so a leaked single-flight promise
|
|
1169
|
+
* from another suite cannot coalesce into the next prime. */
|
|
1170
|
+
export function clearCodexQuotaPrimeState(): void {
|
|
1171
|
+
primeInFlight = null;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** Test-only reset for the worker-level single-flight. */
|
|
1175
|
+
export function clearCodexCooldownRecoveryProbeState(): void {
|
|
1176
|
+
cooldownRecoveryInFlight = null;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
export function effectiveCodexAuthAccountId(config: OcxConfig): string {
|
|
1180
|
+
return getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
export interface CodexAuthAccountsSnapshot {
|
|
1184
|
+
accounts: CodexAuthAccountDto[];
|
|
1185
|
+
mainIdentityGeneration: number;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export async function listCodexAuthAccountsSnapshot(
|
|
1189
|
+
config: OcxConfig,
|
|
1190
|
+
forceRefresh = false,
|
|
1191
|
+
): Promise<CodexAuthAccountsSnapshot> {
|
|
1192
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1193
|
+
const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
1194
|
+
const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
|
|
1195
|
+
const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
|
|
1196
|
+
const cred = getCodexAccountCredential(account.id);
|
|
1197
|
+
let quotaResult: PoolQuotaResult;
|
|
1198
|
+
if (!cred) {
|
|
1199
|
+
quotaResult = { quota: null, needsReauth: true };
|
|
1200
|
+
} else {
|
|
1201
|
+
try {
|
|
1202
|
+
quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan);
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
|
|
1205
|
+
quotaResult = {
|
|
1206
|
+
quota: getAccountQuota(account.id),
|
|
1207
|
+
needsReauth: false,
|
|
1208
|
+
credentialGeneration: readCodexAccountRecord(account.id)?.generation,
|
|
1209
|
+
quotaProbeSkipped: true,
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return { accountId: account.id, quotaResult };
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
// WHAM plan_type is authoritative only for the credential generation that fetched it. Collect
|
|
1217
|
+
// changes after every parallel read settles, then apply one narrow disk patch for the batch.
|
|
1218
|
+
const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => {
|
|
1219
|
+
const plan = quotaResult.freshPlan;
|
|
1220
|
+
const credentialGeneration = quotaResult.freshCredentialGeneration;
|
|
1221
|
+
return plan && credentialGeneration !== undefined
|
|
1222
|
+
? [{ accountId, plan, credentialGeneration }]
|
|
1223
|
+
: [];
|
|
1224
|
+
});
|
|
1225
|
+
reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates);
|
|
1226
|
+
|
|
1227
|
+
const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => {
|
|
1228
|
+
const currentAccount = configuredPoolAccount(runtimeConfig, accountId);
|
|
1229
|
+
if (!currentAccount) return [];
|
|
1230
|
+
const currentCredential = getCodexAccountCredential(accountId);
|
|
1231
|
+
if (!currentCredential) {
|
|
1232
|
+
return [poolAccountDto(
|
|
1233
|
+
currentAccount,
|
|
1234
|
+
{ quota: null, needsReauth: true },
|
|
1235
|
+
false,
|
|
1236
|
+
isCodexAccountPaused(runtimeConfig, accountId),
|
|
1237
|
+
getCodexAccountPriority(runtimeConfig, accountId),
|
|
1238
|
+
)];
|
|
1239
|
+
}
|
|
1240
|
+
const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
|
|
1241
|
+
const generationLive = resultGeneration === undefined
|
|
1242
|
+
|| isCodexAccountGenerationLive(accountId, resultGeneration);
|
|
1243
|
+
const effectiveQuotaResult = !generationLive
|
|
1244
|
+
? { quota: null, needsReauth: false }
|
|
1245
|
+
: quotaResult;
|
|
1246
|
+
// Response DTO can show the WHAM plan even when disk persistence fails closed (lock busy /
|
|
1247
|
+
// missing config). Persistence still remains generation-gated via reconcileFreshPoolAccountPlans.
|
|
1248
|
+
const dtoAccount = generationLive && quotaResult.freshPlan
|
|
1249
|
+
? { ...currentAccount, plan: quotaResult.freshPlan }
|
|
1250
|
+
: currentAccount;
|
|
1251
|
+
return [poolAccountDto(
|
|
1252
|
+
dtoAccount,
|
|
1253
|
+
effectiveQuotaResult,
|
|
1254
|
+
true,
|
|
1255
|
+
isCodexAccountPaused(runtimeConfig, accountId),
|
|
1256
|
+
getCodexAccountPriority(runtimeConfig, accountId),
|
|
1257
|
+
)];
|
|
1258
|
+
});
|
|
1259
|
+
const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
|
|
1260
|
+
const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
|
|
1261
|
+
const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO;
|
|
1262
|
+
const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
|
|
1263
|
+
? mainResult.hasCredential
|
|
1264
|
+
: getMainAccountCredentialPresence() ?? false;
|
|
1265
|
+
const mainNeedsReauth = (mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential)
|
|
1266
|
+
|| isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
1267
|
+
const mainHealth = projectCodexAccountHealth({
|
|
1268
|
+
accountId: MAIN_CODEX_ACCOUNT_ID,
|
|
1269
|
+
needsReauth: mainNeedsReauth,
|
|
1270
|
+
});
|
|
1271
|
+
const main: CodexAuthAccountDto = {
|
|
1272
|
+
id: MAIN_CODEX_ACCOUNT_ID,
|
|
1273
|
+
email: maskEmail(mainInfo.email) ?? "Codex App login",
|
|
1274
|
+
plan: mainInfo.plan,
|
|
1275
|
+
logLabel: "main",
|
|
1276
|
+
isMain: true,
|
|
1277
|
+
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
1278
|
+
priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
1279
|
+
hasCredential: hasMainCredential,
|
|
1280
|
+
needsReauth: mainNeedsReauth,
|
|
1281
|
+
quota: mainInfo.quota ? {
|
|
1282
|
+
...quotaForPlan({
|
|
1283
|
+
...mainInfo.quota,
|
|
1284
|
+
updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
|
|
1285
|
+
}, mainInfo.plan),
|
|
1286
|
+
} : null,
|
|
1287
|
+
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
|
|
1288
|
+
};
|
|
1289
|
+
return {
|
|
1290
|
+
accounts: [main, ...withQuota],
|
|
1291
|
+
mainIdentityGeneration: mainSnapshotLive
|
|
1292
|
+
? fetchedMainGeneration
|
|
1293
|
+
: captureMainAccountIdentityGeneration(),
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
|
|
1298
|
+
return (await listCodexAuthAccountsSnapshot(config, forceRefresh)).accounts;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
interface PauseExhaustedResult {
|
|
1302
|
+
pausedAccountIds: string[];
|
|
1303
|
+
checkedAccountCount: number;
|
|
1304
|
+
failedAccountCount: number;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void {
|
|
1308
|
+
reconcileCodexActiveAfterExclusion(config, pausedActiveId);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async function pauseExhaustedCodexAccounts(
|
|
1312
|
+
config: OcxConfig,
|
|
1313
|
+
persistPausedAccounts: () => void,
|
|
1314
|
+
): Promise<PauseExhaustedResult> {
|
|
1315
|
+
const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain);
|
|
1316
|
+
const nativeMainLease = tryAcquireNativeMainProfileClaim();
|
|
1317
|
+
try {
|
|
1318
|
+
const performPause = async (mainLease?: AdmissionLease): Promise<PauseExhaustedResult> => {
|
|
1319
|
+
const mainWork = async (): Promise<{
|
|
1320
|
+
shouldPause: boolean;
|
|
1321
|
+
checkedAccountCount: number;
|
|
1322
|
+
failedAccountCount: number;
|
|
1323
|
+
}> => {
|
|
1324
|
+
if (!mainLease) return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
|
|
1325
|
+
const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true);
|
|
1326
|
+
if (!mainResult.credentialChecked || !mainResult.hasCredential) {
|
|
1327
|
+
return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 0 };
|
|
1328
|
+
}
|
|
1329
|
+
if (!mainResult.freshQuota || !mainResult.info.plan) {
|
|
1330
|
+
return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
|
|
1331
|
+
}
|
|
1332
|
+
return {
|
|
1333
|
+
shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
|
|
1334
|
+
&& isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan),
|
|
1335
|
+
checkedAccountCount: 1,
|
|
1336
|
+
failedAccountCount: 0,
|
|
1337
|
+
};
|
|
1338
|
+
};
|
|
1339
|
+
const [mainResult, poolResults] = await Promise.all([
|
|
1340
|
+
mainWork(),
|
|
1341
|
+
mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
|
|
1342
|
+
if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
|
|
1343
|
+
try {
|
|
1344
|
+
return {
|
|
1345
|
+
account,
|
|
1346
|
+
quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
|
|
1347
|
+
};
|
|
1348
|
+
} catch {
|
|
1349
|
+
// Settle each pool probe independently so a busy/failing account cannot
|
|
1350
|
+
// abandon an already-confirmed main decision before atomic publication.
|
|
1351
|
+
return { account, quotaResult: null };
|
|
1352
|
+
}
|
|
1353
|
+
}),
|
|
1354
|
+
]);
|
|
1355
|
+
|
|
1356
|
+
let checkedAccountCount = mainResult.checkedAccountCount;
|
|
1357
|
+
let failedAccountCount = mainResult.failedAccountCount;
|
|
1358
|
+
const exhaustedIds: string[] = mainResult.shouldPause ? [MAIN_CODEX_ACCOUNT_ID] : [];
|
|
1359
|
+
for (const { account, quotaResult } of poolResults) {
|
|
1360
|
+
const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
|
|
1361
|
+
if (!currentAccount) continue;
|
|
1362
|
+
const generation = quotaResult?.freshCredentialGeneration;
|
|
1363
|
+
const plan = quotaResult?.freshPlan ?? currentAccount.plan;
|
|
1364
|
+
if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
|
|
1365
|
+
failedAccountCount += 1;
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
checkedAccountCount += 1;
|
|
1369
|
+
if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
|
|
1370
|
+
exhaustedIds.push(account.id);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
for (const id of exhaustedIds) {
|
|
1375
|
+
setCodexAccountPaused(config, id, true);
|
|
1376
|
+
clearThreadAccountMapForAccount(id);
|
|
1377
|
+
}
|
|
1378
|
+
for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
|
|
1379
|
+
const result = {
|
|
1380
|
+
pausedAccountIds: exhaustedIds,
|
|
1381
|
+
checkedAccountCount,
|
|
1382
|
+
failedAccountCount,
|
|
1383
|
+
};
|
|
1384
|
+
// Persist while both the in-process admission and cross-process shared
|
|
1385
|
+
// claim still own the physical-main identity used for the decision.
|
|
1386
|
+
if (result.pausedAccountIds.length > 0) persistPausedAccounts();
|
|
1387
|
+
return result;
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
if (!nativeMainLease) return await performPause();
|
|
1391
|
+
try {
|
|
1392
|
+
return await withNativeMainCredentialClaim(() => performPause(nativeMainLease));
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
if (isNativeMainClaimUnavailable(error)) return await performPause();
|
|
1395
|
+
throw error;
|
|
1396
|
+
}
|
|
1397
|
+
} finally {
|
|
1398
|
+
nativeMainLease?.release();
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
export async function handleCodexAuthAPI(
|
|
1403
|
+
req: Request,
|
|
1404
|
+
url: URL,
|
|
1405
|
+
config: OcxConfig,
|
|
1406
|
+
convergeCodexCatalog?: CodexAuthCatalogConvergence,
|
|
1407
|
+
): Promise<Response | null> {
|
|
1408
|
+
|
|
1409
|
+
if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
|
|
1410
|
+
const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
|
|
1411
|
+
return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) });
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
|
|
1415
|
+
return manualImportDisabledResponse();
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
|
|
1419
|
+
const id = url.searchParams.get("id");
|
|
1420
|
+
if (!id) return jsonResponse({ error: "Missing id" }, 400);
|
|
1421
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1422
|
+
const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id)
|
|
1423
|
+
&& (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id);
|
|
1424
|
+
if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) {
|
|
1425
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1426
|
+
}
|
|
1427
|
+
const pickerVisibilityChanged = deleteCodexAccount(runtimeConfig, id);
|
|
1428
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1429
|
+
reconcileLiveStateStores();
|
|
1430
|
+
const catalogRefresh = await convergeAccountNamespaceCatalog(
|
|
1431
|
+
runtimeConfig,
|
|
1432
|
+
pickerVisibilityChanged,
|
|
1433
|
+
convergeCodexCatalog,
|
|
1434
|
+
);
|
|
1435
|
+
return jsonResponse({ ok: true, ...catalogRefresh });
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") {
|
|
1439
|
+
const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown };
|
|
1440
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1441
|
+
const alias = typeof body.alias === "string" ? body.alias.trim() : "";
|
|
1442
|
+
if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400);
|
|
1443
|
+
if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1444
|
+
if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
|
|
1445
|
+
return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400);
|
|
1446
|
+
}
|
|
1447
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1448
|
+
const account = (runtimeConfig.codexAccounts ?? []).find(candidate => candidate.id === id && !candidate.isMain);
|
|
1449
|
+
if (!account) return jsonResponse({ error: "Account not found" }, 404);
|
|
1450
|
+
if (alias) account.alias = alias;
|
|
1451
|
+
else delete account.alias;
|
|
1452
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1453
|
+
return jsonResponse({ ok: true, id, alias: alias || null });
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") {
|
|
1457
|
+
const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown };
|
|
1458
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1459
|
+
if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
|
|
1460
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1461
|
+
}
|
|
1462
|
+
if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400);
|
|
1463
|
+
|
|
1464
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1465
|
+
const exists = id === MAIN_CODEX_ACCOUNT_ID
|
|
1466
|
+
|| (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
|
|
1467
|
+
if (!exists) return jsonResponse({ error: "Account not found" }, 404);
|
|
1468
|
+
|
|
1469
|
+
setCodexAccountPaused(runtimeConfig, id, body.paused);
|
|
1470
|
+
if (body.paused) {
|
|
1471
|
+
clearThreadAccountMapForAccount(id);
|
|
1472
|
+
selectFallbackAfterPause(runtimeConfig, id);
|
|
1473
|
+
}
|
|
1474
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1475
|
+
return jsonResponse({
|
|
1476
|
+
ok: true,
|
|
1477
|
+
id,
|
|
1478
|
+
paused: body.paused,
|
|
1479
|
+
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1480
|
+
appliesImmediately: true,
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Deliberately a route of its own rather than a field on the alias PATCH: aliases
|
|
1485
|
+
// are display-only and reject __main__, while selection order is routing metadata
|
|
1486
|
+
// that the Desktop account must be able to carry. Re-ordering never kicks a live
|
|
1487
|
+
// thread, so there is no affinity clearing and no appliesImmediately here.
|
|
1488
|
+
if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") {
|
|
1489
|
+
let parsedBody: unknown;
|
|
1490
|
+
try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1491
|
+
if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
|
|
1492
|
+
return jsonResponse({ error: "body must be an object" }, 400);
|
|
1493
|
+
}
|
|
1494
|
+
const body = parsedBody as { id?: unknown; priority?: unknown };
|
|
1495
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1496
|
+
if (!isCodexAccountPriorityKey(id)) {
|
|
1497
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
let priority = DEFAULT_ACCOUNT_PRIORITY;
|
|
1501
|
+
if (body.priority !== null) {
|
|
1502
|
+
const parsed = parseAccountPriority(body.priority);
|
|
1503
|
+
if (parsed === null) {
|
|
1504
|
+
return jsonResponse({
|
|
1505
|
+
error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`,
|
|
1506
|
+
}, 400);
|
|
1507
|
+
}
|
|
1508
|
+
priority = parsed;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1512
|
+
const exists = id === MAIN_CODEX_ACCOUNT_ID
|
|
1513
|
+
|| (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
|
|
1514
|
+
if (!exists) return jsonResponse({ error: "Account not found" }, 404);
|
|
1515
|
+
|
|
1516
|
+
setCodexAccountPriority(runtimeConfig, id, priority);
|
|
1517
|
+
// Both a pin and an order are the operator saying which account to use, so the newer
|
|
1518
|
+
// statement wins. Without this a pin made before any order existed — an ordinary
|
|
1519
|
+
// account switch — would outrank the order forever: it blocks preemption and caps
|
|
1520
|
+
// every eligibility list at its own tier until that account drains or is paused.
|
|
1521
|
+
clearCodexAccountPin(runtimeConfig);
|
|
1522
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1523
|
+
return jsonResponse({
|
|
1524
|
+
ok: true,
|
|
1525
|
+
id,
|
|
1526
|
+
priority,
|
|
1527
|
+
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
|
|
1532
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1533
|
+
const result = await pauseExhaustedCodexAccounts(
|
|
1534
|
+
runtimeConfig,
|
|
1535
|
+
() => saveRuntimeConfig(config, runtimeConfig),
|
|
1536
|
+
);
|
|
1537
|
+
const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result;
|
|
1538
|
+
if (checkedAccountCount === 0 && failedAccountCount > 0) {
|
|
1539
|
+
return jsonResponse({
|
|
1540
|
+
ok: false,
|
|
1541
|
+
error: "Failed to refresh any Codex account quota",
|
|
1542
|
+
checkedAccountCount,
|
|
1543
|
+
failedAccountCount,
|
|
1544
|
+
}, 502);
|
|
1545
|
+
}
|
|
1546
|
+
return jsonResponse({
|
|
1547
|
+
ok: true,
|
|
1548
|
+
pausedAccountIds,
|
|
1549
|
+
pausedCount: pausedAccountIds.length,
|
|
1550
|
+
checkedAccountCount,
|
|
1551
|
+
failedAccountCount,
|
|
1552
|
+
complete: failedAccountCount === 0,
|
|
1553
|
+
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1554
|
+
appliesImmediately: true,
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only
|
|
1559
|
+
// model path for Codex Desktop, so a cooldown that outlives the real upstream limit
|
|
1560
|
+
// otherwise leaves editing config.toml as the user's only recovery.
|
|
1561
|
+
//
|
|
1562
|
+
// Existence is deliberately NOT disclosed: an unknown id returns 200 with cleared:false
|
|
1563
|
+
// exactly like an account that simply had no live cooldown, so this route cannot be used
|
|
1564
|
+
// to enumerate configured accounts. Cooldown state is runtime-only and independent of the
|
|
1565
|
+
// account list, so 404 would carry no useful meaning anyway.
|
|
1566
|
+
if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") {
|
|
1567
|
+
const body = await req.json().catch(() => ({})) as { id?: unknown };
|
|
1568
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1569
|
+
if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
|
|
1570
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1571
|
+
}
|
|
1572
|
+
return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) });
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
|
|
1576
|
+
let body: { accountId: string | null };
|
|
1577
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1578
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1579
|
+
const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID;
|
|
1580
|
+
if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
|
|
1581
|
+
return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409);
|
|
1582
|
+
}
|
|
1583
|
+
if (isCodexAccountPaused(runtimeConfig, targetAccountId)) {
|
|
1584
|
+
return jsonResponse({ error: "Account is paused" }, 409);
|
|
1585
|
+
}
|
|
1586
|
+
if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) {
|
|
1587
|
+
if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1588
|
+
const exists = (runtimeConfig.codexAccounts ?? [])
|
|
1589
|
+
.some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId);
|
|
1590
|
+
if (!exists) return jsonResponse({ error: "Account not found" }, 400);
|
|
1591
|
+
}
|
|
1592
|
+
runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
|
|
1593
|
+
// "Use this account now" outranks selection order until the account is spent:
|
|
1594
|
+
// persisted here rather than in resetCodexRoutingForManualSelection, which is
|
|
1595
|
+
// runtime state only. A null id clears the selection instead of making one, so it
|
|
1596
|
+
// must release the pin rather than record one: pinning the `targetAccountId`
|
|
1597
|
+
// fallback would leave a pin that no effective active account matches, which
|
|
1598
|
+
// `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still
|
|
1599
|
+
// honours it as a ceiling — invisibly capping the pool at the main account's tier.
|
|
1600
|
+
if (body.accountId == null) clearCodexAccountPin(runtimeConfig);
|
|
1601
|
+
else setCodexAccountPin(runtimeConfig, targetAccountId);
|
|
1602
|
+
resetCodexRoutingForManualSelection(targetAccountId);
|
|
1603
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1604
|
+
return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
|
|
1608
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1609
|
+
return jsonResponse({
|
|
1610
|
+
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1611
|
+
pinned: isEffectiveCodexAccountPinned(runtimeConfig),
|
|
1612
|
+
// Which account carries the pin, not just whether the active one does. Under
|
|
1613
|
+
// round-robin or fill-first the pin caps the tier ceiling at its own tier while the
|
|
1614
|
+
// strategy cursor moves freely inside that tier, so `pinned` alone goes false on a
|
|
1615
|
+
// sibling's turn even though the pin is still suppressing every higher tier. The id
|
|
1616
|
+
// lets a surface mark the account the operator actually chose.
|
|
1617
|
+
pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
|
|
1618
|
+
autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
|
|
1619
|
+
upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
|
|
1620
|
+
accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
|
|
1621
|
+
accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") {
|
|
1626
|
+
let body: { threshold: number };
|
|
1627
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1628
|
+
if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) {
|
|
1629
|
+
return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400);
|
|
1630
|
+
}
|
|
1631
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1632
|
+
runtimeConfig.autoSwitchThreshold = body.threshold;
|
|
1633
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1634
|
+
return jsonResponse({ ok: true });
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
if (
|
|
1638
|
+
url.pathname === "/api/codex-auth/pool-strategy"
|
|
1639
|
+
&& (req.method === "PUT" || req.method === "PATCH")
|
|
1640
|
+
) {
|
|
1641
|
+
let parsedBody: unknown;
|
|
1642
|
+
try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1643
|
+
if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
|
|
1644
|
+
return jsonResponse({ error: "body must be an object" }, 400);
|
|
1645
|
+
}
|
|
1646
|
+
const body = parsedBody as { strategy?: unknown; stickyLimit?: unknown };
|
|
1647
|
+
if (body.strategy === undefined && body.stickyLimit === undefined) {
|
|
1648
|
+
return jsonResponse({ error: "strategy or stickyLimit required" }, 400);
|
|
1649
|
+
}
|
|
1650
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1651
|
+
let nextStrategy: NonNullable<ReturnType<typeof parseAccountPoolStrategy>> | undefined;
|
|
1652
|
+
let nextSticky: NonNullable<ReturnType<typeof parseAccountPoolStickyLimit>> | undefined;
|
|
1653
|
+
if (body.strategy !== undefined) {
|
|
1654
|
+
const parsed = parseAccountPoolStrategy(body.strategy);
|
|
1655
|
+
if (parsed === null) {
|
|
1656
|
+
return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400);
|
|
1657
|
+
}
|
|
1658
|
+
nextStrategy = parsed;
|
|
1659
|
+
}
|
|
1660
|
+
if (body.stickyLimit !== undefined) {
|
|
1661
|
+
const parsed = parseAccountPoolStickyLimit(body.stickyLimit);
|
|
1662
|
+
if (parsed === null) {
|
|
1663
|
+
return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
|
|
1664
|
+
}
|
|
1665
|
+
nextSticky = parsed;
|
|
1666
|
+
}
|
|
1667
|
+
if (nextStrategy !== undefined) runtimeConfig.accountPoolStrategy = nextStrategy;
|
|
1668
|
+
if (nextSticky !== undefined) runtimeConfig.accountPoolStickyLimit = nextSticky;
|
|
1669
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1670
|
+
return jsonResponse({
|
|
1671
|
+
ok: true,
|
|
1672
|
+
accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
|
|
1673
|
+
accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") {
|
|
1678
|
+
let body: { threshold: number };
|
|
1679
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1680
|
+
if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 20) {
|
|
1681
|
+
return jsonResponse({ error: "Threshold must be an integer 0-20" }, 400);
|
|
1682
|
+
}
|
|
1683
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1684
|
+
runtimeConfig.upstreamFailoverThreshold = body.threshold;
|
|
1685
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1686
|
+
return jsonResponse({ ok: true });
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") {
|
|
1690
|
+
const quotas: Record<string, unknown> = {};
|
|
1691
|
+
for (const [id, q] of listAccountQuotas()) quotas[id] = q;
|
|
1692
|
+
return jsonResponse({ quotas });
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
if (url.pathname === "/api/codex-auth/reset-credits" && req.method === "GET") {
|
|
1696
|
+
const accountId = url.searchParams.get("accountId");
|
|
1697
|
+
if (!accountId) return jsonResponse({ error: "accountId required" }, 400);
|
|
1698
|
+
|
|
1699
|
+
try {
|
|
1700
|
+
const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
|
|
1701
|
+
const linkedSignal = signalWithTimeout(8000, req.signal);
|
|
1702
|
+
let detachBodyAbort = () => {};
|
|
1703
|
+
try {
|
|
1704
|
+
let resp: Response;
|
|
1705
|
+
try {
|
|
1706
|
+
resp = await fetch(
|
|
1707
|
+
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
|
|
1708
|
+
{
|
|
1709
|
+
headers: {
|
|
1710
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
1711
|
+
"ChatGPT-Account-Id": auth.chatgptAccountId,
|
|
1712
|
+
},
|
|
1713
|
+
signal: linkedSignal.signal,
|
|
1714
|
+
},
|
|
1715
|
+
);
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
if (linkedSignal.signal.aborted) {
|
|
1718
|
+
return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502);
|
|
1719
|
+
}
|
|
1720
|
+
throw error;
|
|
1721
|
+
}
|
|
1722
|
+
// Own the response body before the bounded reader attaches. If the client
|
|
1723
|
+
// disconnects in that narrow window, Bun otherwise tears down the native
|
|
1724
|
+
// body off the awaited path and can report an unhandled rejection.
|
|
1725
|
+
detachBodyAbort = cancelBodyOnAbort(resp.body, linkedSignal.signal);
|
|
1726
|
+
if (!resp.ok) {
|
|
1727
|
+
await resp.body?.cancel().catch(() => {});
|
|
1728
|
+
return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
|
|
1729
|
+
}
|
|
1730
|
+
const parsed = await readResetCreditJson(resp, linkedSignal.signal);
|
|
1731
|
+
if (!parsed.ok) {
|
|
1732
|
+
return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502);
|
|
1733
|
+
}
|
|
1734
|
+
return jsonResponse(safeResetCreditsDto(parsed.value));
|
|
1735
|
+
} finally {
|
|
1736
|
+
detachBodyAbort();
|
|
1737
|
+
linkedSignal.cleanup();
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
return result.ok ? result.value : result.response;
|
|
1741
|
+
} catch (e) {
|
|
1742
|
+
return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") {
|
|
1747
|
+
const body = (await req.json().catch(() => ({}))) as { accountId?: string };
|
|
1748
|
+
if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
|
|
1749
|
+
const accountId = body.accountId;
|
|
1750
|
+
|
|
1751
|
+
try {
|
|
1752
|
+
const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
|
|
1753
|
+
const idempotencyKey = crypto.randomUUID();
|
|
1754
|
+
const resp = await fetch(
|
|
1755
|
+
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
|
|
1756
|
+
{
|
|
1757
|
+
method: "POST",
|
|
1758
|
+
headers: {
|
|
1759
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
1760
|
+
"ChatGPT-Account-Id": auth.chatgptAccountId,
|
|
1761
|
+
"Content-Type": "application/json",
|
|
1762
|
+
},
|
|
1763
|
+
body: JSON.stringify({ redeem_request_id: idempotencyKey }),
|
|
1764
|
+
signal: AbortSignal.timeout(10_000),
|
|
1765
|
+
},
|
|
1766
|
+
);
|
|
1767
|
+
if (!resp.ok) {
|
|
1768
|
+
await resp.body?.cancel().catch(() => {});
|
|
1769
|
+
return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
|
|
1770
|
+
}
|
|
1771
|
+
const result = safeResetCreditConsumeDto(await resp.json());
|
|
1772
|
+
// After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
|
|
1773
|
+
// and return remaining only when that refresh freshly parsed available_count.
|
|
1774
|
+
// Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
|
|
1775
|
+
if (result.code === "reset" || result.code === "already_redeemed") {
|
|
1776
|
+
let freshResetCredits: number | undefined;
|
|
1777
|
+
if (auth.isMain) {
|
|
1778
|
+
({ freshResetCredits } = await fetchMainAccountInfoAttempt(
|
|
1779
|
+
true,
|
|
1780
|
+
1,
|
|
1781
|
+
auth.nativeMainLease,
|
|
1782
|
+
auth.nativeMainSharedClaimHeld === true,
|
|
1783
|
+
));
|
|
1784
|
+
} else {
|
|
1785
|
+
const account = configuredPoolAccount(getRuntimeConfig(config), accountId);
|
|
1786
|
+
({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan));
|
|
1787
|
+
}
|
|
1788
|
+
return jsonResponse({
|
|
1789
|
+
code: result.code,
|
|
1790
|
+
...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
|
|
1791
|
+
? { remaining: freshResetCredits }
|
|
1792
|
+
: {}),
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
return jsonResponse(result);
|
|
1796
|
+
});
|
|
1797
|
+
return operation.ok ? operation.value : operation.response;
|
|
1798
|
+
} catch (e) {
|
|
1799
|
+
if (e instanceof PoolQuotaProbeBusyError) {
|
|
1800
|
+
const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
|
|
1801
|
+
response.headers.set("Retry-After", "1");
|
|
1802
|
+
return response;
|
|
1803
|
+
}
|
|
1804
|
+
return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
|
|
1809
|
+
const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean };
|
|
1810
|
+
const requestedAccountId = body.id?.trim();
|
|
1811
|
+
const reauth = body.reauth === true;
|
|
1812
|
+
if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
|
|
1813
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1814
|
+
}
|
|
1815
|
+
const accountId = requestedAccountId || `chatgpt-${Date.now()}`;
|
|
1816
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1817
|
+
const preflightConflict = !reauth
|
|
1818
|
+
? codexAccountPersistenceConflict(runtimeConfig, accountId, "create")
|
|
1819
|
+
: undefined;
|
|
1820
|
+
if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400);
|
|
1821
|
+
if (reauth) {
|
|
1822
|
+
if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400);
|
|
1823
|
+
if (!configuredPoolAccount(runtimeConfig, accountId)) {
|
|
1824
|
+
return jsonResponse({ error: "Unknown pool account for reauth" }, 404);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
pruneCodexLoginState();
|
|
1828
|
+
if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
|
|
1829
|
+
const busy = new CodexLoginStateBusyError();
|
|
1830
|
+
const response = jsonResponse({ error: busy.message, code: busy.code }, 503);
|
|
1831
|
+
response.headers.set("Retry-After", "1");
|
|
1832
|
+
return response;
|
|
1833
|
+
}
|
|
1834
|
+
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1835
|
+
const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() };
|
|
1836
|
+
codexAuthLoginState.set(flowId, loginOwner);
|
|
1837
|
+
try {
|
|
1838
|
+
const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../oauth");
|
|
1839
|
+
const result = await startLoginFlow("chatgpt", { forceLogin: true });
|
|
1840
|
+
|
|
1841
|
+
// Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
|
|
1842
|
+
// The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
|
|
1843
|
+
if (result.url) {
|
|
1844
|
+
const { openUrl } = await import("../lib/open-url");
|
|
1845
|
+
openUrl(result.url);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
(async () => {
|
|
1849
|
+
try {
|
|
1850
|
+
let completed = false;
|
|
1851
|
+
for (let i = 0; i < 150; i++) {
|
|
1852
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1853
|
+
const st = getLoginStatus("chatgpt");
|
|
1854
|
+
if (st.done && st.loggedIn) {
|
|
1855
|
+
const { getCredential } = await import("../oauth/store");
|
|
1856
|
+
const cred = getCredential("chatgpt");
|
|
1857
|
+
if (cred) {
|
|
1858
|
+
const oauthAccountId = cred.accountId;
|
|
1859
|
+
if (!oauthAccountId) {
|
|
1860
|
+
setCodexLoginState(flowId, {
|
|
1861
|
+
status: "error",
|
|
1862
|
+
error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.",
|
|
1863
|
+
doneAt: Date.now(),
|
|
1864
|
+
});
|
|
1865
|
+
completed = true;
|
|
1866
|
+
break;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
let email = cred.email || accountId;
|
|
1870
|
+
let plan: string | undefined;
|
|
1871
|
+
let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
1872
|
+
try {
|
|
1873
|
+
const tokens = { access_token: cred.access, account_id: oauthAccountId };
|
|
1874
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
1875
|
+
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
1876
|
+
signal: AbortSignal.timeout(8000),
|
|
1877
|
+
});
|
|
1878
|
+
if (resp.ok) {
|
|
1879
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
1880
|
+
email = data.email ?? email;
|
|
1881
|
+
plan = nonEmptyPlan(data.plan_type) ?? undefined;
|
|
1882
|
+
quota = parseUsageQuota(data);
|
|
1883
|
+
}
|
|
1884
|
+
} catch { /* wham fetch is non-blocking */ }
|
|
1885
|
+
// Reauth must refresh the same ChatGPT identity already bound to this pool slot.
|
|
1886
|
+
// Otherwise a different login would silently overwrite credentials under a trusted id.
|
|
1887
|
+
if (reauth) {
|
|
1888
|
+
const existingCred = getCodexAccountCredential(accountId);
|
|
1889
|
+
const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId);
|
|
1890
|
+
const expectedChatgptId = existingCred?.chatgptAccountId?.trim();
|
|
1891
|
+
const expectedEmail = poolAccount?.email?.trim().toLowerCase();
|
|
1892
|
+
const gotEmail = email.trim().toLowerCase();
|
|
1893
|
+
if (expectedChatgptId) {
|
|
1894
|
+
if (expectedChatgptId !== oauthAccountId) {
|
|
1895
|
+
setCodexLoginState(flowId, {
|
|
1896
|
+
status: "error",
|
|
1897
|
+
error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
|
|
1898
|
+
doneAt: Date.now(),
|
|
1899
|
+
});
|
|
1900
|
+
completed = true;
|
|
1901
|
+
break;
|
|
1902
|
+
}
|
|
1903
|
+
} else if (expectedEmail) {
|
|
1904
|
+
if (!gotEmail || gotEmail !== expectedEmail) {
|
|
1905
|
+
setCodexLoginState(flowId, {
|
|
1906
|
+
status: "error",
|
|
1907
|
+
error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
|
|
1908
|
+
doneAt: Date.now(),
|
|
1909
|
+
});
|
|
1910
|
+
completed = true;
|
|
1911
|
+
break;
|
|
1912
|
+
}
|
|
1913
|
+
} else {
|
|
1914
|
+
// No chatgptAccountId and no pool email — refuse silent identity replacement
|
|
1915
|
+
// (including empty credential slots that still have a pool row).
|
|
1916
|
+
setCodexLoginState(flowId, {
|
|
1917
|
+
status: "error",
|
|
1918
|
+
error: "Cannot verify account identity for reauth. Remove this account and add it again.",
|
|
1919
|
+
doneAt: Date.now(),
|
|
1920
|
+
});
|
|
1921
|
+
completed = true;
|
|
1922
|
+
break;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
|
|
1927
|
+
const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
|
|
1928
|
+
if (collision.collision) {
|
|
1929
|
+
setCodexLoginState(flowId, {
|
|
1930
|
+
status: "error", error: collision.reason, doneAt: Date.now(),
|
|
1931
|
+
});
|
|
1932
|
+
completed = true;
|
|
1933
|
+
break;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
|
|
1937
|
+
if (!warmup.ok) {
|
|
1938
|
+
const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
|
|
1939
|
+
setCodexLoginState(flowId, {
|
|
1940
|
+
status: "error",
|
|
1941
|
+
error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
|
|
1942
|
+
doneAt: Date.now(),
|
|
1943
|
+
});
|
|
1944
|
+
completed = true;
|
|
1945
|
+
break;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const latestConfig = getRuntimeConfig(config);
|
|
1949
|
+
const accounts = latestConfig.codexAccounts ?? [];
|
|
1950
|
+
const existingIdx = accounts.findIndex(account => account.id === accountId);
|
|
1951
|
+
let pickerVisibilityChanged = false;
|
|
1952
|
+
let newAccountPersistence: PersistNewCodexAccountOutcome | null = null;
|
|
1953
|
+
const commitConflict = codexAccountPersistenceConflict(
|
|
1954
|
+
latestConfig,
|
|
1955
|
+
accountId,
|
|
1956
|
+
reauth ? "reauth" : "create",
|
|
1957
|
+
);
|
|
1958
|
+
if (commitConflict) {
|
|
1959
|
+
setCodexLoginState(flowId, {
|
|
1960
|
+
status: "error",
|
|
1961
|
+
error: commitConflict,
|
|
1962
|
+
doneAt: Date.now(),
|
|
1963
|
+
});
|
|
1964
|
+
completed = true;
|
|
1965
|
+
break;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
const credential: CodexAccountCredentials = {
|
|
1969
|
+
accessToken: cred.access,
|
|
1970
|
+
refreshToken: cred.refresh,
|
|
1971
|
+
expiresAt: cred.expires,
|
|
1972
|
+
chatgptAccountId: oauthAccountId,
|
|
1973
|
+
};
|
|
1974
|
+
|
|
1975
|
+
if (existingIdx >= 0) {
|
|
1976
|
+
saveCodexAccountCredential(accountId, credential);
|
|
1977
|
+
// A successful reauthentication replaces the credential generation. Do not let a
|
|
1978
|
+
// failed optional WHAM probe make the replacement inherit quota from the old record.
|
|
1979
|
+
if (reauth) clearAccountQuota(accountId);
|
|
1980
|
+
markCodexAccountValidated(accountId, warmup.validatedAt);
|
|
1981
|
+
clearAccountNeedsReauth(accountId);
|
|
1982
|
+
if (quota) setAccountQuotaFromParsed(accountId, quota);
|
|
1983
|
+
// Keep the pool id stable; refresh display metadata after a successful login/reauth.
|
|
1984
|
+
accounts[existingIdx] = withCodexAccountLogLabel({
|
|
1985
|
+
...accounts[existingIdx],
|
|
1986
|
+
email,
|
|
1987
|
+
plan: plan ?? accounts[existingIdx].plan,
|
|
1988
|
+
isMain: false,
|
|
1989
|
+
}, accounts);
|
|
1990
|
+
latestConfig.codexAccounts = accounts;
|
|
1991
|
+
saveRuntimeConfig(config, latestConfig);
|
|
1992
|
+
} else {
|
|
1993
|
+
const addedAccount = withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts);
|
|
1994
|
+
newAccountPersistence = persistNewCodexAccount(
|
|
1995
|
+
config,
|
|
1996
|
+
latestConfig,
|
|
1997
|
+
addedAccount,
|
|
1998
|
+
{
|
|
1999
|
+
credential,
|
|
2000
|
+
validatedAt: warmup.validatedAt,
|
|
2001
|
+
},
|
|
2002
|
+
);
|
|
2003
|
+
pickerVisibilityChanged = newAccountPersistence.pickerVisibilityChanged;
|
|
2004
|
+
}
|
|
2005
|
+
reconcileLiveStateStores();
|
|
2006
|
+
if (newAccountPersistence?.status === "publication-failed") {
|
|
2007
|
+
markAccountNeedsReauth(accountId);
|
|
2008
|
+
}
|
|
2009
|
+
// A new quota row is generation-gated by live account ownership. Reconcile the
|
|
2010
|
+
// durable config owner first so a partial prior sweep cannot reject this write.
|
|
2011
|
+
if (newAccountPersistence?.status === "committed" && quota) {
|
|
2012
|
+
setAccountQuotaFromParsed(accountId, quota);
|
|
2013
|
+
}
|
|
2014
|
+
const { catalogRefreshPending } = await convergeAccountNamespaceCatalog(
|
|
2015
|
+
latestConfig,
|
|
2016
|
+
pickerVisibilityChanged,
|
|
2017
|
+
convergeCodexCatalog,
|
|
2018
|
+
);
|
|
2019
|
+
if (newAccountPersistence?.status === "publication-failed") {
|
|
2020
|
+
setCodexLoginState(flowId, {
|
|
2021
|
+
status: "error",
|
|
2022
|
+
...codexCredentialPersistenceFailure(accountId, catalogRefreshPending),
|
|
2023
|
+
doneAt: Date.now(),
|
|
2024
|
+
});
|
|
2025
|
+
completed = true;
|
|
2026
|
+
} else {
|
|
2027
|
+
setCodexLoginState(flowId, {
|
|
2028
|
+
status: "done",
|
|
2029
|
+
accountId,
|
|
2030
|
+
email,
|
|
2031
|
+
...(catalogRefreshPending ? { catalogRefreshPending: true } : {}),
|
|
2032
|
+
doneAt: Date.now(),
|
|
2033
|
+
});
|
|
2034
|
+
completed = true;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
break;
|
|
2038
|
+
}
|
|
2039
|
+
if (st.done && st.error) {
|
|
2040
|
+
setCodexLoginState(flowId, {
|
|
2041
|
+
status: "error",
|
|
2042
|
+
// startLoginFlow projects background failures before storing login status, so
|
|
2043
|
+
// fixed actionable OAuth messages retain their type-derived remediation here.
|
|
2044
|
+
error: st.error,
|
|
2045
|
+
doneAt: Date.now(),
|
|
2046
|
+
});
|
|
2047
|
+
completed = true;
|
|
2048
|
+
break;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
if (!completed) {
|
|
2052
|
+
setCodexLoginState(flowId, {
|
|
2053
|
+
status: "error",
|
|
2054
|
+
error: "Login timed out before OAuth completed.",
|
|
2055
|
+
doneAt: Date.now(),
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
} catch (error) {
|
|
2059
|
+
const message = error instanceof ConfigMutationLockError
|
|
2060
|
+
|| error instanceof CodexCredentialRefreshLockTimeoutError
|
|
2061
|
+
? "Configuration is busy; retry login shortly."
|
|
2062
|
+
: error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
|
|
2063
|
+
? "Credential refresh is busy; retry login shortly."
|
|
2064
|
+
: publicOAuthAuthenticationErrorMessage(error);
|
|
2065
|
+
setCodexLoginState(flowId, {
|
|
2066
|
+
status: "error",
|
|
2067
|
+
error: message,
|
|
2068
|
+
doneAt: Date.now(),
|
|
2069
|
+
});
|
|
2070
|
+
} finally {
|
|
2071
|
+
// TTL: keep completed flow state available for clients that miss a short polling window.
|
|
2072
|
+
setTimeout(() => { if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); }, CODEX_LOGIN_TERMINAL_TTL_MS);
|
|
2073
|
+
}
|
|
2074
|
+
})();
|
|
2075
|
+
|
|
2076
|
+
setCodexLoginState(flowId, { status: "pending" });
|
|
2077
|
+
return jsonResponse({ ok: true, flowId, url: result.url, instructions: result.instructions });
|
|
2078
|
+
} catch (e) {
|
|
2079
|
+
if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
|
|
2080
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2081
|
+
if (msg === "A login for chatgpt is already in progress") {
|
|
2082
|
+
return jsonResponse({ error: msg, status: "pending" }, 409);
|
|
2083
|
+
}
|
|
2084
|
+
if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
2085
|
+
const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
|
|
2086
|
+
response.headers.set("Retry-After", "1");
|
|
2087
|
+
return response;
|
|
2088
|
+
}
|
|
2089
|
+
const { publicOAuthAuthenticationErrorMessage } = await import("../oauth");
|
|
2090
|
+
return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(e) }, 500);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
if (url.pathname === "/api/codex-auth/login/code" && req.method === "POST") {
|
|
2095
|
+
const body = (await req.json().catch(() => ({}))) as { flowId?: unknown; input?: unknown };
|
|
2096
|
+
const flowId = typeof body.flowId === "string" ? body.flowId.trim() : "";
|
|
2097
|
+
const input = typeof body.input === "string" ? body.input : "";
|
|
2098
|
+
if (!flowId) return jsonResponse({ error: "flowId required" }, 400);
|
|
2099
|
+
if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
|
|
2100
|
+
|
|
2101
|
+
// Import may yield; validate afterwards so cancel/replace cannot race a stale flow through.
|
|
2102
|
+
const { submitManualLoginCode } = await import("../oauth");
|
|
2103
|
+
const flow = codexAuthLoginState.get(flowId);
|
|
2104
|
+
if (!flow) return jsonResponse({ error: "login flow expired or unknown" }, 400);
|
|
2105
|
+
if (flow.status !== "pending") return jsonResponse({ error: "login flow is not pending" }, 400);
|
|
2106
|
+
|
|
2107
|
+
const result = submitManualLoginCode("chatgpt", input);
|
|
2108
|
+
if (!result.ok) return jsonResponse({ error: result.error }, 400);
|
|
2109
|
+
return jsonResponse({ ok: true }, 202);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
|
|
2113
|
+
const body = (await req.json().catch(() => ({}))) as { flowId?: string };
|
|
2114
|
+
const { cancelLoginFlow } = await import("../oauth");
|
|
2115
|
+
const cancelled = cancelLoginFlow("chatgpt");
|
|
2116
|
+
expireCodexAuthFlow(body.flowId ?? null);
|
|
2117
|
+
return jsonResponse({ ok: true, cancelled });
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
|
|
2121
|
+
const flowId = url.searchParams.get("flowId");
|
|
2122
|
+
const accountId = url.searchParams.get("accountId")?.trim();
|
|
2123
|
+
// Reauth always has a pre-existing credential; never treat "credential exists" as success
|
|
2124
|
+
// when the flow map entry is gone (would false-complete on lost/expired flow state).
|
|
2125
|
+
const reauthStatus = url.searchParams.get("reauth") === "1";
|
|
2126
|
+
if (flowId) {
|
|
2127
|
+
const st = codexAuthLoginState.get(flowId);
|
|
2128
|
+
if (
|
|
2129
|
+
!st
|
|
2130
|
+
&& accountId
|
|
2131
|
+
&& !reauthStatus
|
|
2132
|
+
&& !isAccountNeedsReauth(accountId)
|
|
2133
|
+
&& getCodexAccountCredential(accountId)
|
|
2134
|
+
) {
|
|
2135
|
+
return jsonResponse({ status: "done", accountId });
|
|
2136
|
+
}
|
|
2137
|
+
return jsonResponse(st ? { ...st, email: maskEmail(st.email) ?? undefined } : { status: "expired" });
|
|
2138
|
+
}
|
|
2139
|
+
// Legacy fallback: return latest pending flow
|
|
2140
|
+
for (const [, st] of codexAuthLoginState) {
|
|
2141
|
+
if (st.status === "pending") return jsonResponse({ ...st, email: maskEmail(st.email) ?? undefined });
|
|
2142
|
+
}
|
|
2143
|
+
return jsonResponse({ status: "idle" });
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
return null;
|
|
2147
|
+
}
|