Skip to content

feat(codex): import provider credentials flexibly - #482

Open
yyyr-p wants to merge 54 commits into
Menci:mainfrom
yyyr-p:codex-flexible-import-port
Open

feat(codex): import provider credentials flexibly#482
yyyr-p wants to merge 54 commits into
Menci:mainfrom
yyyr-p:codex-flexible-import-port

Conversation

@yyyr-p

@yyyr-p yyyr-p commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Import a Codex upstream three ways: paste supported credentials JSON (with account picker when the paste holds several), run an OAuth authorize flow by pasting back the callback URL, or fill the token fields by hand.
  • Allow access-only credentials (no refresh token) and a null ChatGPT account ID (the chatgpt-account-id header is then omitted), and let the operator explicitly supply email, plan, and user ID. The four identity fields resolve explicit > id_token > access_token, and the access token's exp (when present) supplies the expiry.
  • Constrain what a post-import metadata patch may change; only email, chatgptUserId, and planType are editable, chatgptAccountId stays as imported.
  • Move the codex refresh handler in the control plane onto the shared provider-owned persist helpers so both planes write through the same state transitions.

Test Plan

  • Manual import (access token only) reproduces no chatgpt-account-id header.
  • Multi-account JSON paste reproduces the preview-and-pick flow.
  • 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

  • The 53 commits are per-slice history from the port; reference the branch's commits for the sliced landing order.

yyyr-p added 30 commits August 5, 2026 01:16
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.
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.
yyyr-p added 24 commits August 16, 2026 11:55
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant