fix(auth): bootstrap codex cli credential without clobbering local (#71310)

* fix(auth): bootstrap codex cli credential without clobbering local

readCodexCliCredentialsCached was imported but never registered in
EXTERNAL_CLI_SYNC_PROVIDERS, so overlayExternalAuthProfiles could not
seed openai-codex:default on fresh agents and runtime surfaced
"No API key found for provider openai-codex" even after a successful
codex login.

Register the provider with a new bootstrapOnly flag. Providers flagged
bootstrapOnly are adopted only to fill an empty slot: the overlay skips
them when a local OAuth credential already exists for the profile, and
readExternalCliBootstrapCredential returns null so the refresh path
never replaces the locally stored canonical refresh token with stale
CLI state. Minimax keeps its existing replace-on-expiry behavior.

* test(auth): cover codex cli bootstrap

---------

Co-authored-by: sudol <sudol@A8Max.localdomain>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Gforce10-design
2026-04-25 09:50:01 +09:00
committed by GitHub
parent 6d49681a62
commit 5a202f6f90
4 changed files with 98 additions and 7 deletions

View File

@@ -106,6 +106,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Auth/Codex: bootstrap `openai-codex:default` from Codex CLI credentials on fresh installs without replacing a locally refreshed OpenClaw OAuth token later. Fixes #71305. Thanks @Gforce10-design.
- Plugin SDK/tool-result transforms: bound middleware `details`, validate in-place result mutations, and mark fail-closed middleware fallbacks with canonical `error` status. Thanks @vincentkoc.
- Discord/gateway: prevent startup from getting stuck at `awaiting gateway readiness` when Carbon gateway registration races with a lifecycle reconnect. Fixes #52372. (#68159) Thanks @IVY-AI-gif.
- Discord/gateway: supervise Carbon's async gateway registration promise so fatal Discord metadata failures surface through startup instead of process-level unhandled rejections. (#62451) Thanks @safzanpirani.

View File

@@ -44,9 +44,10 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**:
- the runtime reads credentials from **one place**
- we can keep multiple profiles and route them deterministically
- when credentials are reused from an external CLI like Codex CLI, OpenClaw
mirrors them with provenance and re-reads that external source instead of
rotating the refresh token itself
- external CLI reuse is provider-specific: Codex CLI can bootstrap an empty
`openai-codex:default` profile, but once OpenClaw has a local OAuth profile,
the local refresh token is canonical; other integrations can remain
externally managed and re-read their CLI auth store
## Storage (where tokens live)
@@ -128,8 +129,11 @@ At runtime:
- if `expires` is in the future → use the stored access token
- if expired → refresh (under a file lock) and overwrite the stored credentials
- exception: reused external CLI credentials stay externally managed; OpenClaw
re-reads the CLI auth store and never spends the copied refresh token itself
- exception: some external CLI credentials stay externally managed; OpenClaw
re-reads those CLI auth stores instead of spending copied refresh tokens.
Codex CLI bootstrap is intentionally narrower: it seeds an empty
`openai-codex:default` profile, then OpenClaw-owned refreshes keep the local
profile canonical.
The refresh flow is automatic; you generally don't need to manage tokens manually.

View File

@@ -236,6 +236,59 @@ describe("external cli oauth resolution", () => {
expect(credential).toBeNull();
});
it("bootstraps the default codex profile from Codex CLI credentials when missing locally", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
accountId: "acct-codex",
}),
);
const profiles = resolveExternalCliAuthProfiles(makeStore());
expect(profiles).toEqual([
{
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: expect.objectContaining({
provider: "openai-codex",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
accountId: "acct-codex",
}),
},
]);
});
it("keeps any existing default codex oauth over Codex CLI bootstrap credentials", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
access: "codex-cli-fresh-access",
refresh: "codex-cli-fresh-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
accountId: "acct-codex",
}),
);
const profiles = resolveExternalCliAuthProfiles(
makeStore(
OPENAI_CODEX_DEFAULT_PROFILE_ID,
makeOAuthCredential({
provider: "openai-codex",
access: "local-expired-access",
refresh: "local-canonical-refresh",
expires: Date.now() - 5_000,
accountId: "acct-codex",
}),
),
);
expect(profiles).toEqual([]);
});
it("returns null when the profile id/provider do not map to the same external source", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({ provider: "openai-codex" }),

View File

@@ -1,5 +1,12 @@
import { readMiniMaxCliCredentialsCached } from "../cli-credentials.js";
import { EXTERNAL_CLI_SYNC_TTL_MS, MINIMAX_CLI_PROFILE_ID } from "./constants.js";
import {
readCodexCliCredentialsCached,
readMiniMaxCliCredentialsCached,
} from "../cli-credentials.js";
import {
EXTERNAL_CLI_SYNC_TTL_MS,
MINIMAX_CLI_PROFILE_ID,
OPENAI_CODEX_DEFAULT_PROFILE_ID,
} from "./constants.js";
import { log } from "./constants.js";
import {
areOAuthCredentialsEquivalent,
@@ -29,6 +36,12 @@ type ExternalCliSyncProvider = {
profileId: string;
provider: string;
readCredentials: () => OAuthCredential | null;
// bootstrapOnly providers adopt the external CLI credential only to
// seed an empty slot; once a local OAuth credential exists for the
// profile, the local refresh token is treated as canonical and the
// CLI state must not replace or shadow it. Codex requires this to
// avoid clobbering a locally refreshed token with stale CLI state.
bootstrapOnly?: boolean;
};
function normalizeAuthIdentityToken(value: string | undefined): string | undefined {
@@ -72,6 +85,12 @@ export function isSafeToUseExternalCliCredential(
}
const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
{
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
provider: "openai-codex",
readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
bootstrapOnly: true,
},
{
profileId: MINIMAX_CLI_PROFILE_ID,
provider: "minimax-portal",
@@ -103,6 +122,13 @@ export function readExternalCliBootstrapCredential(params: {
if (!provider) {
return null;
}
// bootstrapOnly providers must not replace an existing local credential
// during runtime refresh. The oauth-manager only calls this hook when a
// local credential is already present, so returning null here keeps the
// locally stored refresh token canonical.
if (provider.bootstrapOnly) {
return null;
}
return provider.readCredentials();
}
@@ -132,6 +158,13 @@ export function resolveExternalCliAuthProfiles(
});
continue;
}
if (providerConfig.bootstrapOnly && existingOAuth) {
log.debug("kept local oauth over external cli bootstrap-only provider", {
profileId: providerConfig.profileId,
provider: providerConfig.provider,
});
continue;
}
if (existingOAuth && !isSafeToUseExternalCliCredential(existingOAuth, creds)) {
log.warn("refused external cli oauth bootstrap: identity mismatch", {
profileId: providerConfig.profileId,