feat(codex): import provider credentials flexibly - #482
Open
yyyr-p wants to merge 54 commits into
Open
Conversation
An import source may know nothing about the account behind a bearer: an opaque access token carries no claims, and an operator holding only that token has nothing else to supply. Identity fields, the refresh token, and the access token's expiry each become nullable so the stored document can say what it does not know instead of refusing the credential. Absence is written as an explicit null, so a missing key and an empty string stay rejected. Under the one-account-per-upstream invariant a null account id is still a stable join key between config and state.
Only one import path existed, and it required a verbatim ~/.codex/auth.json carrying a full OAuth bundle. An operator holding an account export in another shape, or nothing but a bearer token, had no way in. Every source now funnels through one normalizer that settles identity by a single rule: what the operator typed wins, the id_token fills in what they did not, and the access token covers the remainder. Claims are read decode-only from whichever tokens happen to be JWTs, so an opaque bearer imports fine and simply contributes no claims. JSON detection keys off the envelope rather than asking which format the paste is. Two envelopes in one document is refused rather than ranked. The preview lists each account with identity and lifecycle only, never credential material, so the choice of account happens before anything is persisted. The refresh grant no longer demands an id_token: it re-mints a bearer, and the identity it would restate was already settled at import.
A credential imported without a refresh token has one bearer and no way to mint another, so the lifecycle around it has to stop assuming a refresh is always available. An access-only bearer is spent to its stated expiry with no refresh skew, and an unknown expiry reads as usable rather than as expired — the upstream's rejection is the only expiry signal such a token has. A 401 on that path preserves the upstream status, headers, and body instead of retrying, and marks the row best-effort so a storage failure never replaces what the caller needs to diagnose. Exhausting one reports that a re-import is the recovery, rather than surfacing as a refresh failure it never had. An unknown account id omits the chatgpt-account-id header rather than sending an empty one, and the in-flight refresh key is encoded so a null id stays distinct from the string "null".
…enew Pins the three decisions the new shape rests on: an unknown expiry is usable for an access-only credential and unusable for a renewable one; an unknown account id omits the header rather than sending an empty one; and a 401 on an access-only credential reaches the caller with the upstream's own status, headers, and body even when marking the row fails. Also pins that a null account id and the string "null" do not share an in-flight refresh slot, and that an explicit account id settles a conflict between the two tokens rather than being overruled by them.
The exchange endpoint took a verbatim ~/.codex/auth.json or an OAuth callback and nothing else, so an operator holding an account export in another shape or only a bearer token had no route in. Import now names what it does rather than the protocol it once assumed: POST /upstreams/codex/import/exchange takes exactly one of a pasted document plus the account chosen from it, an OAuth callback, or typed fields. A companion preview endpoint reports the accounts a document holds before anything is persisted, carrying identity and lifecycle only. Only the callback source talks to auth.openai.com, so it is the only one that resolves the upstream's egress chain; a pasted document imports even when the draft names a proxy that no longer exists. Codex config leaves the generic PATCH deny-list. Display metadata an import could not infer is now correctable in place, while the account id stays bound to re-import because the stored credential joins on it. Refresh refuses an access-only credential up front instead of reaching for a refresh token that was never there. The redacted state carries the bearer's expiry and last refresh, matching what the Claude Code branch already exposes, so the dashboard can explain why a credential is or is not usable. The bearer itself stays server-side.
The editor offered one Codex import path — paste auth.json — and read every identity field as a string that must be there. An operator holding an account export in another shape, or only a bearer token, could not get in, and a credential that named no account rendered the word null. The panel now asks which source the operator has. A pasted document is previewed before anything is committed, so the accounts inside it are listed with their identity and lifecycle and one is chosen deliberately; editing the document drops the choice made against it, and a row that cannot be imported says why instead of silently disappearing. The manual form takes a bearer plus whatever else the operator knows, sending only the fields they filled in. The account card reads an absent email, plan or account id as unknown rather than printing it, and says whether the credential can renew itself and until when — an access-only bearer is described as usable until the upstream rejects it, which is the only expiry it has. Claude Code keeps the shared OAuth panel unchanged; only the Codex branch moves to the new form. Test setup installs a localStorage implementation where the environment exposes one without getItem. Node's own global shadows the DOM environment's and is inert unless started with a backing store, so reading the stored session token threw and every component test that makes a request rendered an error instead.
The row described the OAuth client as the way in, which is now one of three.
Integrate 95 upstream commits (Vue→React dashboard already rebuilt, Codex image support, plan observation, quota no-TTL, provider-ollama/platform additions). Reconcile the Codex flexible-import port with upstream's parallel work: - provider-codex: nullable identity/access-only semantics merge with plan observation (planType/planObservedAt on token entries, conditional invalidate, refresh id_token optional for plan reads, image fallback fail-open on unknown plan). - gateway: three-source import API coexists with upstream's newer schemas. - web: codex import panel and card kept current with upstream's directory moves (upstream-editor -> upstreams) and i18n module convention.
…xible-import-port
Route the codex upstream row's plan segment through planLabel so the summary
title words a plan the same way as the signals line below it ("ChatGPT Plus"
instead of the raw wire id like "plus"). planLabel forwards unknown
identifiers verbatim, so no plan value is dropped, and the docstring above
upstreamSummary is updated to match.
Deduplicate the identical plan-observation derivation between access-token.ts and fetch.ts: export the access-token.ts helper as codexPlanObservation and drop fetch.ts's private accessTokenPlan copy, so the coalescing rule (planObservedAt falls back to refreshedAt) is encoded in one place and cannot drift.
ParsedCodexOAuthTokens was a structurally identical intersection of CodexOAuthRefreshTokens with its own optional id_token field, implying a wire-vs-caller distinction the type system does not actually make. Delete the alias, move its explanatory comment onto the interface it describes, and let codexTokenRequest return CodexOAuthRefreshTokens directly.
Route the codex account card's plan badge through planLabel so it words a plan as "ChatGPT Plus" instead of the raw wire identifier "plus", matching the claude-code peer card's subscriptionLabel and the codex signals row's planLabel. Unknown identifiers are forwarded verbatim, and the unknownPlan i18n fallback is unchanged.
…claude-code Extract the authorize-url-prepare / callback-paste / exchange flow that the codex OAuth tab and the claude-code OAuth and Setup-Token tabs each carried into a single OAuthCallbackImport component parameterized by kind and flowKind. Each kind's request bodies are preserved exactly: the codex exchange omits state while claude-code forwards it, and the generation-ref supersession guard now lives in one place. The codex JSON-preview and manual tabs and the claude-code credentials.json tab keep their own submit paths.
The final classification branch of accountStatus in codex-account.ts and claude-code-account.ts was the identical rule: the heaviest window at or above the heavy threshold reads as heavy, anything else reads as active. Extract it into usageStatusFromHeaviest in the shared subscription-quota module so the threshold comparison and Math.round policy are encoded in one place and both selectors read HEAVY_USAGE_THRESHOLD_PERCENT through the same helper.
…pers The control-plane refresh handler hand-wrote two Codex state transitions that the provider already owns: rotating the stored refresh_token and the terminal refresh_failed flip. Add persistCodexRefreshTokenRotation and persistCodexRefreshFailure to provider-codex's state module and have the handler delegate to them, so the control plane and data plane share one rotation path and vendor state-transition knowledge stays in the provider. Both helpers preserve the handler's no-op-on-missing-account semantics.
The four call dispatchers in fetch.ts each repeated the same policy: on a 401 from a renewable credential that has not already been retried, refresh the access token and re-run the operation once, or relay the failure result if the refresh fails. Extract that sequence into a single retryCodexAccess401 helper so the refresh path lives in one place, with each site keeping only its own continuation and failure-result shape.
state.ts imported CodexQuotaSnapshot from quota.ts to type its persisted snapshot entries, while quota.ts imports state helpers from state.ts — the provider's only two-way module edge. Move the CodexQuotaSnapshot and CodexQuotaSnapshotMap declarations into state.ts so the graph reduces to a single one-way quota -> state edge, keeping the public package surface unchanged. The package's quota test imports the moved type from state.ts accordingly.
…s object persistAccessToken carried five positional parameters, the fourth a diagnostic string literal threaded through internals only to prefix console.warn lines. Give it a single options object so the where label is named at each call site and the entry/fallbackPlan pair can no longer be mis-ordered across it. The label strings and the null-invalidation semantics are unchanged.
HEAVY_USAGE_THRESHOLD_PERCENT and UsageHeavyOrActive were exported but no module imported either: the threshold's two former callers were routed through usageStatusFromHeaviest, and the status type is only the local return annotation. Unexport both so the module surface matches what is consumed.
…mula mintCodexAccessToken and importCodexFromCallback each computed the same absolute expiry (Date.now() + expires_in * 1000) from the OAuth response. oauth.ts owns the expires_in contract, so it now exports codexTokenExpiresAt and both call sites delegate to it.
…est object The per-request dispatch took seven positional args, with the derived identity and turn-metadata JSON threaded through as two dangling trailing params. dispatchCodexHttpCall now takes a single CodexHttpCallRequest bundle; the three call sites build the identity and metadata once and hand both over together.
state.ts and quota.ts each carried a copy of the prototype-pollution key guard with only the empty-string policy differing. parse-helpers.ts, the package's shape-guard module, now exports isUnsafeObjectKey and both call sites keep their own empty-string handling locally.
codex-account-card and claude-code-account-card each rendered the per-window quota row with near-identical JSX. QuotaProgressRow now owns the shared clampPercent-driven percent label, ProgressBar, and slot positions, while each card feeds its divergent right/footer content through props.
…e provider codexOAuthRefresh inlined its own access-only classification with a hardcoded re-import message. The provider already owns that rule as CodexAccessOnlyCredentialError, thrown by ensureCodexAccessToken under force. The handler now catches that error and returns err.message, so the access-only rule lives in exactly one place.
…criptor performImageCall threaded path/body/turnId positionally on every recursion even though they are fixed for the whole call. The entry sites now build a CodexImageCallRequest once and the recursive retry re-passes the same bundle, changing only the access token, plan, and the retried flag.
…ns object The centralized 401-retry gate took six positional args plus an optional fallback plan, with the alreadyRetried recursion switch and the run/onRefreshFailure callback pair lined up at every call site. retryCodexAccess401 now takes a single RetryCodexAccess401Options<T> bundle.
…tate helpers createCodexProvider inlined its own refresh-rotation and terminal-state saveState closures while state.ts claimed the data plane already shared its helpers. The effects object now delegates to persistCodexRefreshTokenRotation and the new persistCodexTerminalState with onMissing:'throw', so one rotation write and one terminal flip serve both planes and a lost credential fails loudly.
The four state.ts asserters and config.ts's assertCodexUpstreamConfig each opened with the same plain-object-plus-unexpected-key rejection block. parse-helpers.ts now exports assertAllowedObjectKeys, and each asserter keeps only its per-field checks with a module-scope allowlist set.
The new component imported its same-directory subscription-quota after the parent ../../lib imports; the configured import/order alphabetize rule wants the sibling first.
The P29 import of ./auth/parse-helpers.ts belongs ahead of the external provider type import under the configured import/order alphabetize rule.
…tCodexTerminalState persistCodexRefreshFailure and persistCodexTerminalState shared the same terminal-flip write: stamp an ISO timestamp, then patch state and state_message and clear the cached access token. The refresh-failure helper was just the refresh_failed special case of the general terminal-state helper, so fold it into a one-line wrapper delegating to persistCodexTerminalState. Its exported three-arg signature is unchanged and it still takes no options, which preserves the no-op-on-missing default for the gateway caller.
config.ts and state.ts each reimplemented the same 'value must be a non-empty string or null' predicate — config.ts behind a local assertIdentityValue helper and state.ts inline for chatgptAccountId and refresh_token. Consolidate it into assertStringOrNull in parse-helpers.ts alongside the sibling shape guards and route all three call sites through it. The guard carries the assertion signature so config.ts keeps the string-or-null narrowing it relied on, and every thrown TypeError message is unchanged.
The refresh-failure fold returns persistCodexTerminalState directly; the @typescript-eslint/return-await rule requires awaiting the returned promise in an async function. Add the await to satisfy the lint gate.
…hared shape guard patchCodexIdentityMetadata hand-rolled the same plain-object and allowlist checks that assertAllowedObjectKeys already provides. Route the top-level accounts-key check and the per-account identity-key check through the scaffold so the metadata patch surface shares one guard with assertCodexUpstreamConfig.
…spatchers dispatchCodexHttpCall and dispatchCodexImageCall each built the same headers-plus-POST-plus-classify tail independently. Fold the shared authorization, conditional account-id, wrapUpstreamCall POST, and classifyCodexHttpResponse call into one postCodexJson helper; the two dispatchers keep composing their own header sets and pass their own quota policy.
The codex, claude-code OAuth, and claude-code setup-token authorize-url
endpoints each declared an identical { record, challenge, state } body.
Factor that shape into a shared oauthAuthorizeUrlBody const beside
recordOnlyBody and alias the three exported schemas to it, matching the
file's existing shared-const convention. The exported names are
unchanged, so each route keeps its own RPC-client type and the wire
shapes are byte-identical.
prepareCodexCall and refreshAccessTokenForRetry both handled a CodexOAuthSessionTerminatedError the same way: persist the refresh_failed terminal state and return the synthetic 503 built from the upstream message. Fold that block into a codexRefreshFailed helper so the two catches share one copy, keeping the persist state and message template verbatim.
…criptor dispatchCodexImageCall took five positional arguments while its sibling dispatchCodexHttpCall already accepted a request object. Bundle the access token with the CodexImageCallRequest path/body/turnId into a CodexImageCallDispatchRequest so the dispatch boundary mirrors the HTTP path and performImageCall stops un-bundling the request it already holds.
performImageCall stopped reading opts.fallbackPlanType when the image-call dispatch folded its positional arguments into a request descriptor, so the intersection type is a leftover constraint. Narrow the parameter to the shared CodexBackendCallBase base.
The module exports generic shape guards (isObject, requireObject, assertAllowedObjectKeys) consumed by state, quota, config, and fetch - not auth-only parsing. Name the file for what it holds.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
chatgpt-account-idheader is then omitted), and let the operator explicitly supply email, plan, and user ID. The four identity fields resolveexplicit > id_token > access_token, and the access token'sexp(when present) supplies the expiry.email,chatgptUserId, andplanTypeare editable,chatgptAccountIdstays as imported.Test Plan
chatgpt-account-idheader.pnpm run verify(typegen → lint → typecheck → test → test:installers → check:agents-md → check:generated-assets → check:verify-parity → build:web) green on the final tree.Note