fix: OAuth refresh failures report reauth instead of stale success (#99134)

Fail closed when managed OpenAI OAuth refresh fails instead of silently falling back to stale external Codex CLI credentials.

Make managed provider OAuth authoritative after bootstrap, preserve API-key and non-OpenAI external CLI behavior, and surface targeted re-auth guidance without exposing profile IDs in group/channel replies.

Fixes #99120.

Co-authored-by: Eva <239388517+100yenadmin@users.noreply.github.com>
This commit is contained in:
Eva
2026-07-02 22:50:25 +02:00
committed by GitHub
parent c20171ddfc
commit 46598a120f
18 changed files with 688 additions and 144 deletions

View File

@@ -55,9 +55,9 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**:
- external CLI reuse is provider-specific: Codex CLI can bootstrap an empty
`openai:default` profile, but once OpenClaw has a local OAuth profile,
the local refresh token is canonical. If that local refresh token is rejected,
OpenClaw can use a usable same-account Codex CLI token as a runtime-only
fallback; other integrations can remain externally managed and re-read their
CLI auth store
OpenClaw reports the managed profile for re-authentication instead of using
Codex CLI token material as a sibling runtime fallback. Other integrations can
remain externally managed and re-read their CLI auth store
- status and startup paths that already know the configured provider set scope
external CLI discovery to that set, so an unrelated CLI login store is not
probed for a single-provider setup
@@ -166,11 +166,12 @@ At runtime:
the secondary agent store
- 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:default` profile, then OpenClaw-owned refreshes keep the local
profile canonical. If the local Codex refresh fails and Codex CLI has a
usable token for the same account, OpenClaw may use that token for the current
runtime request without writing it back to `auth-profiles.json`.
Codex CLI bootstrap is intentionally narrower: it can seed an empty
`openai:default` or explicitly requested OpenAI profile only before OpenClaw
owns OAuth for the provider. After that, OpenClaw-owned refreshes keep local
profiles canonical and discovery does not add Codex CLI auth in any sibling
slot. If a managed refresh fails, OpenClaw reports the affected profile for
re-authentication instead of returning external CLI token material.
The refresh flow is automatic; you generally don't need to manage tokens manually.

View File

@@ -232,6 +232,7 @@ function buildProfileHealth(params: {
}
const effectiveCredential = resolveEffectiveOAuthCredential({
store,
profileId,
credential: healthCredential,
allowKeychainPrompt,

View File

@@ -300,6 +300,7 @@ describe("external cli oauth resolution", () => {
);
const credential = readExternalCliBootstrapCredential({
store: makeStore(),
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: makeOAuthCredential({ provider: "openai" }),
});
@@ -333,6 +334,77 @@ describe("external cli oauth resolution", () => {
);
});
it("does not add Codex CLI as a sibling to a named managed OpenAI profile", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
accountId: "acct-codex",
}),
);
const profiles = resolveExternalCliAuthProfiles(
makeStore(
"openai:user@example.com",
makeOAuthCredential({
provider: "openai",
access: "managed-access",
refresh: "managed-refresh",
expires: Date.now() - 5_000,
accountId: "acct-codex",
}),
),
{
providerIds: ["openai"],
},
);
expect(profiles).toStrictEqual([]);
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
});
it("does not fill an empty default slot beside a named managed OpenAI profile", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
accountId: "acct-codex",
}),
);
const profiles = resolveExternalCliAuthProfiles(
{
version: 1,
profiles: {
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
type: "oauth",
provider: "openai",
access: "",
refresh: "",
expires: 0,
},
"openai:user@example.com": makeOAuthCredential({
provider: "openai",
access: "managed-access",
refresh: "managed-refresh",
expires: Date.now() - 5_000,
accountId: "acct-codex",
}),
},
},
{
providerIds: ["openai"],
profileIds: [OPENAI_CODEX_DEFAULT_PROFILE_ID],
},
);
expect(profiles).toStrictEqual([]);
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
});
it("keeps any existing default codex oauth over Codex CLI bootstrap credentials", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
@@ -366,6 +438,7 @@ describe("external cli oauth resolution", () => {
);
const credential = readExternalCliBootstrapCredential({
store: makeStore(),
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: makeOAuthCredential({ provider: "anthropic" }),
});

View File

@@ -41,6 +41,7 @@ describe("resolveEffectiveOAuthCredential", () => {
expect(
resolveEffectiveOAuthCredential({
store: { version: 1, profiles: {} },
profileId: "openai:default",
credential: makeCredential(),
}),
@@ -62,6 +63,7 @@ describe("resolveEffectiveOAuthCredential", () => {
expect(
resolveEffectiveOAuthCredential({
store: { version: 1, profiles: {} },
profileId: "openai:default",
credential: local,
}),
@@ -79,6 +81,7 @@ describe("resolveEffectiveOAuthCredential", () => {
expect(
resolveEffectiveOAuthCredential({
store: { version: 1, profiles: {} },
profileId: "openai:default",
credential: local,
}),

View File

@@ -5,19 +5,22 @@
*/
import { readExternalCliBootstrapCredential } from "./external-cli-sync.js";
import { resolveEffectiveOAuthCredential as resolveManagedOAuthCredential } from "./oauth-manager.js";
import type { OAuthCredential } from "./types.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
/** Resolves the effective OAuth credential, optionally reading external CLI bootstrap state. */
export function resolveEffectiveOAuthCredential(params: {
store: AuthProfileStore;
profileId: string;
credential: OAuthCredential;
allowKeychainPrompt?: boolean;
}): OAuthCredential {
return resolveManagedOAuthCredential({
store: params.store,
profileId: params.profileId,
credential: params.credential,
readBootstrapCredential: ({ profileId, credential }) =>
readBootstrapCredential: ({ store, profileId, credential }) =>
readExternalCliBootstrapCredential({
store,
profileId,
credential,
allowKeychainPrompt: params.allowKeychainPrompt ?? false,

View File

@@ -174,8 +174,21 @@ function hasInlineOAuthTokenMaterial(credential: OAuthCredential): boolean {
);
}
function hasManagedProviderOAuth(
store: AuthProfileStore,
providerConfig: ExternalCliSyncProvider,
): boolean {
return Object.values(store.profiles).some(
(credential) =>
credential?.type === "oauth" &&
listExternalCliProviderIds(providerConfig).includes(credential.provider) &&
hasInlineOAuthTokenMaterial(credential),
);
}
/** Read a CLI credential only for safe bootstrap of an unusable local profile. */
export function readExternalCliBootstrapCredential(params: {
store: AuthProfileStore;
profileId: string;
credential: OAuthCredential;
allowInlineOAuthTokenMaterial?: boolean;
@@ -185,6 +198,9 @@ export function readExternalCliBootstrapCredential(params: {
if (!provider) {
return null;
}
if (provider.bootstrapOnly && hasManagedProviderOAuth(params.store, provider)) {
return null;
}
if (
provider.bootstrapOnly &&
!params.allowInlineOAuthTokenMaterial &&
@@ -198,26 +214,6 @@ export function readExternalCliBootstrapCredential(params: {
);
}
/** Read a CLI credential as a fallback for refresh/runtime auth recovery. */
export function readExternalCliFallbackCredential(params: {
profileId: string;
credential: OAuthCredential;
allowKeychainPrompt?: boolean;
}): OAuthCredential | null {
const provider =
resolveExternalCliSyncProvider(params) ??
EXTERNAL_CLI_SYNC_PROVIDERS.find((entry) =>
listExternalCliProviderIds(entry).includes(params.credential.provider),
);
if (!provider) {
return null;
}
return normalizeExternalCliCredentialProvider(
provider.readCredentials({ allowKeychainPrompt: params.allowKeychainPrompt }),
params.credential.provider,
);
}
function normalizeProviderScope(values: Iterable<string> | undefined): Set<string> | undefined {
if (values === undefined) {
return undefined;
@@ -278,6 +274,12 @@ function listScopedExternalCliProfileIds(params: {
options?: ExternalCliAuthProfileOptions;
}): string[] {
const { options, providerConfig, store } = params;
// Bootstrap-only CLI state must not enter any sibling slot once OpenClaw
// owns OAuth for the provider, regardless of how discovery was scoped.
if (providerConfig.bootstrapOnly && hasManagedProviderOAuth(store, providerConfig)) {
return [];
}
const requestedProfileIds = Array.from(options?.profileIds ?? [])
.map((value) => value.trim())
.filter((value) => value.length > 0);

View File

@@ -187,6 +187,9 @@ describe("auth external oauth helpers", () => {
expect(overlaidProfile.refresh).toBe("fresh-cli-refresh-token");
expect(overlaidProfile.accountId).toBe("acct-cli");
const managedCredential = readExternalCliBootstrapCredential({
store: createStore({
"openai:default": tokenlessCredential,
}),
profileId: "openai:default",
credential: tokenlessCredential,
});

View File

@@ -561,6 +561,48 @@ describe("createOAuthManager", () => {
});
});
it("fails closed after managed refresh failure", async () => {
await withOAuthAgentDirs("oauth-manager-refresh-fail-closed-", async ({ agentDir }) => {
const profileId = "openai:user@example.com";
const managedCredential = createCredential({
access: "managed-expired-access",
refresh: "managed-refresh",
expires: Date.now() - 60_000,
email: "user@example.com",
accountId: "acct-123",
});
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: managedCredential,
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
const manager = createOAuthManager({
buildApiKey: async (_provider, credential) => credential.access,
refreshCredential: vi.fn(async () => {
throw new Error("refresh rejected managed profile");
}),
readBootstrapCredential: () => null,
isRefreshTokenReusedError: () => false,
});
await expect(
manager.resolveOAuthAccess({
store: ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
allowKeychainPrompt: false,
}),
profileId,
credential: managedCredential,
agentDir,
}),
).rejects.toBeInstanceOf(OAuthManagerRefreshError);
});
});
it("redacts the external oauth credential attempted during refresh failures", async () => {
await withOAuthTempRoot("oauth-manager-refresh-redact-", async (tempRoot) => {
const agentDir = path.join(tempRoot, "agents", "sub", "agent");

View File

@@ -18,7 +18,6 @@ import {
} from "./oauth-refresh-lock-errors.js";
import {
areOAuthCredentialsEquivalent,
hasMatchingOAuthIdentity,
hasUsableOAuthCredential,
isSafeToAdoptBootstrapOAuthIdentity,
isSafeToAdoptMainStoreOAuthIdentity,
@@ -46,10 +45,7 @@ export type OAuthManagerAdapter = {
) => Promise<string>;
refreshCredential: (credential: OAuthCredential) => Promise<OAuthCredentials | null>;
readBootstrapCredential: (params: {
profileId: string;
credential: OAuthCredential;
}) => OAuthCredential | null;
readFallbackCredential?: (params: {
store: AuthProfileStore;
profileId: string;
credential: OAuthCredential;
}) => OAuthCredential | null;
@@ -63,7 +59,7 @@ export type ResolvedOAuthAccess = {
/** Refresh failure that preserves a redacted refreshed store and credential. */
export class OAuthManagerRefreshError extends OAuthRefreshFailureError {
readonly profileId: string;
override readonly profileId: string;
readonly code?: string;
readonly lockPath?: string;
readonly #refreshedStore: AuthProfileStore;
@@ -93,6 +89,7 @@ export class OAuthManagerRefreshError extends OAuthRefreshFailureError {
const causeMessage = formatRedactedOAuthRefreshError(params.cause, secrets);
super({
provider: params.credential.provider,
profileId: params.profileId,
message: `OAuth token refresh failed for ${params.credential.provider}: ${causeMessage}`,
cause: createRedactedOAuthRefreshCause(delegatedCause, secrets),
});
@@ -270,11 +267,13 @@ async function loadFreshStoredOAuthCredential(params: {
/** Select local OAuth unless a safe external bootstrap credential should win. */
export function resolveEffectiveOAuthCredential(params: {
store: AuthProfileStore;
profileId: string;
credential: OAuthCredential;
readBootstrapCredential: OAuthManagerAdapter["readBootstrapCredential"];
}): OAuthCredential {
const imported = params.readBootstrapCredential({
store: params.store,
profileId: params.profileId,
credential: params.credential,
});
@@ -538,6 +537,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
}
const externallyManaged = adapter.readBootstrapCredential({
store,
profileId: params.profileId,
credential: cred,
});
@@ -686,6 +686,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
credential: params.credential,
}) ?? params.credential;
const effectiveCredential = resolveEffectiveOAuthCredential({
store: params.store,
profileId: params.profileId,
credential: adoptedCredential,
readBootstrapCredential: adapter.readBootstrapCredential,
@@ -806,34 +807,6 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
// keep the original refresh error below
}
}
const fallback = adapter.readFallbackCredential?.({
profileId: params.profileId,
credential: effectiveCredential,
});
if (
fallback &&
fallback.provider === params.credential.provider &&
hasUsableOAuthCredential(fallback) &&
hasMatchingOAuthIdentity(params.credential, fallback) &&
canReuseOAuthCredentialAfterRefreshFailure({
forceRefresh: params.forceRefresh,
attempted: effectiveCredential,
candidate: fallback,
})
) {
log.info("using external OAuth credential after refresh failure", {
profileId: params.profileId,
provider: fallback.provider,
expires: new Date(fallback.expires).toISOString(),
});
return {
apiKey: await adapter.buildApiKey(fallback.provider, fallback, {
cfg: params.cfg,
agentDir: params.agentDir,
}),
credential: fallback,
};
}
throw new OAuthManagerRefreshError({
credential: params.credential,
attemptedCredentials: [effectiveCredential, ...attemptedCredentials],

View File

@@ -8,6 +8,7 @@ import {
buildOAuthRefreshFailureLoginCommand,
classifyOAuthRefreshFailure,
classifyOAuthRefreshFailureError,
formatOAuthRefreshFailureLoginCommandMarkdown,
OAuthRefreshFailureError,
} from "./oauth-refresh-failure.js";
@@ -24,20 +25,56 @@ describe("oauth refresh failure hints", () => {
);
});
it("includes the profile id in refresh-failure login hints when known", () => {
expect(
buildOAuthRefreshFailureLoginCommand("openai", {
profileId: "Work Profile",
}),
).toBe("openclaw models auth login --provider openai --profile-id 'Work Profile'");
});
it("renders login commands containing backticks as valid Markdown code spans", () => {
const command = buildOAuthRefreshFailureLoginCommand("openai", {
profileId: "openai:work`slot",
});
expect(formatOAuthRefreshFailureLoginCommandMarkdown(command)).toBe(
"``openclaw models auth login --provider openai --profile-id 'openai:work`slot'``",
);
});
it("classifies typed refresh failures without parsing the display message", () => {
expect(
classifyOAuthRefreshFailureError(
new OAuthRefreshFailureError({
provider: "openai",
profileId: "openai:user@example.com",
message: "invalid_grant",
}),
),
).toEqual({
provider: "openai",
profileId: "openai:user@example.com",
reason: "invalid_grant",
});
});
it("classifies typed refresh failures through wrapper causes", () => {
const refreshError = new OAuthRefreshFailureError({
provider: "openai",
profileId: "openai:user@example.com",
message: "invalid_grant",
});
expect(classifyOAuthRefreshFailureError(new Error("wrapped", { cause: refreshError }))).toEqual(
{
provider: "openai",
profileId: "openai:user@example.com",
reason: "invalid_grant",
},
);
});
it("classifies token invalidation refresh failures", () => {
expect(
classifyOAuthRefreshFailure(

View File

@@ -17,18 +17,21 @@ export type OAuthRefreshFailureReason =
type OAuthRefreshFailure = {
provider: string | null;
profileId?: string;
reason: OAuthRefreshFailureReason | null;
};
/** Error type that carries provider and classified OAuth refresh failure reason. */
export class OAuthRefreshFailureError extends Error {
readonly provider: string;
readonly profileId?: string;
readonly reason: OAuthRefreshFailureReason | null;
constructor(params: { provider: string; message: string; cause?: unknown }) {
constructor(params: { provider: string; profileId?: string; message: string; cause?: unknown }) {
super(params.message, { cause: params.cause });
this.name = "OAuthRefreshFailureError";
this.provider = params.provider;
this.profileId = params.profileId;
this.reason = classifyOAuthRefreshFailureReason(params.message);
}
}
@@ -57,6 +60,27 @@ function sanitizeOAuthRefreshFailureProvider(provider: string | null | undefined
return normalized && SAFE_PROVIDER_ID_RE.test(normalized) ? normalized : null;
}
function sanitizeOAuthRefreshFailureProfileId(profileId: string | null | undefined): string | null {
const sanitized = profileId ? sanitizeForLog(profileId).trim() : "";
return sanitized || null;
}
function quoteShellArg(value: string): string {
const escaped =
process.platform === "win32" ? value.replaceAll("'", "''") : value.replaceAll("'", "'\\''");
return `'${escaped}'`;
}
/** Wrap a rendered login command in a Markdown code span that survives embedded backticks. */
export function formatOAuthRefreshFailureLoginCommandMarkdown(command: string): string {
let fence = "`";
while (command.includes(fence)) {
fence += "`";
}
const padding = command.startsWith("`") || command.endsWith("`") ? " " : "";
return `${fence}${padding}${command}${padding}${fence}`;
}
/** Classify a raw OAuth refresh failure message into a stable reason code. */
export function classifyOAuthRefreshFailureReason(
message: string,
@@ -96,19 +120,38 @@ export function classifyOAuthRefreshFailure(message: string): OAuthRefreshFailur
/** Classify provider/reason from the structured OAuth refresh failure error. */
export function classifyOAuthRefreshFailureError(err: unknown): OAuthRefreshFailure | null {
if (!(err instanceof OAuthRefreshFailureError)) {
return null;
const seen = new Set<object>();
let candidate = err;
while (candidate && typeof candidate === "object") {
if (candidate instanceof OAuthRefreshFailureError) {
const profileId = sanitizeOAuthRefreshFailureProfileId(candidate.profileId);
return {
provider: sanitizeOAuthRefreshFailureProvider(candidate.provider),
...(profileId ? { profileId } : {}),
reason: candidate.reason,
};
}
if (seen.has(candidate)) {
return null;
}
seen.add(candidate);
candidate = (candidate as { cause?: unknown }).cause;
}
return {
provider: sanitizeOAuthRefreshFailureProvider(err.provider),
reason: err.reason,
};
return null;
}
/** Build the login command operators should run after OAuth refresh failure. */
export function buildOAuthRefreshFailureLoginCommand(provider: string | null | undefined): string {
export function buildOAuthRefreshFailureLoginCommand(
provider: string | null | undefined,
options?: { profileId?: string | null },
): string {
const sanitizedProvider = sanitizeOAuthRefreshFailureProvider(provider);
const sanitizedProfileId = sanitizeOAuthRefreshFailureProfileId(options?.profileId);
return sanitizedProvider
? formatCliCommand(`openclaw models auth login --provider ${sanitizedProvider}`)
? formatCliCommand(
sanitizedProfileId
? `openclaw models auth login --provider ${sanitizedProvider} --profile-id ${quoteShellArg(sanitizedProfileId)}`
: `openclaw models auth login --provider ${sanitizedProvider}`,
)
: formatCliCommand("openclaw models auth login");
}

View File

@@ -1,7 +1,7 @@
/**
* Tests OpenAI/Codex OAuth refresh fallback behavior.
* Covers CLI bootstrap and profile success state when refresh recovery has to
* fall back across auth sources.
* Covers CLI bootstrap and ensures refresh failures fail closed instead of
* being masked by external CLI credentials.
*/
import fs from "node:fs/promises";
import os from "node:os";
@@ -23,6 +23,7 @@ import {
import type { AuthProfileStore, OAuthCredential } from "./types.js";
let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile;
let resolveApiKeyForProvider: typeof import("../model-auth.js").resolveApiKeyForProvider;
let hasAvailableAuthForProvider: typeof import("../model-auth.js").hasAvailableAuthForProvider;
let markAuthProfileSuccess: typeof import("./profiles.js").markAuthProfileSuccess;
type GetOAuthApiKey = typeof import("../../llm/oauth.js").getOAuthApiKey;
@@ -147,7 +148,7 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => {
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-refresh-fallback-"));
({ resolveApiKeyForProfile } = await import("./oauth.js"));
({ resolveApiKeyForProvider } = await import("../model-auth.js"));
({ hasAvailableAuthForProvider, resolveApiKeyForProvider } = await import("../model-auth.js"));
({ markAuthProfileSuccess } = await import("./profiles.js"));
});
@@ -185,7 +186,7 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => {
await fs.rm(tempRoot, { recursive: true, force: true });
});
it("falls back to matching cached Codex CLI credentials when openai refresh fails", async () => {
it("fails closed instead of using matching cached Codex CLI credentials when openai refresh fails", async () => {
const profileId = "openai:default";
saveAuthProfileStore(
createExpiredOauthStore({
@@ -205,18 +206,54 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => {
accountId: "acct-cached",
});
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
await expect(
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}),
).rejects.toThrow(/OAuth token refresh failed for openai/);
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
});
it("does not fill an explicit empty default profile beside managed OpenAI OAuth", async () => {
const profileId = "openai:default";
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "openai",
access: "",
refresh: "",
expires: 0,
},
"openai:user@example.com": {
type: "oauth",
provider: "openai",
access: "managed-access-token",
refresh: "managed-refresh-token",
expires: Date.now() - 60_000,
accountId: "acct-managed",
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
readCodexCliCredentialsCachedMock.mockReturnValue({
type: "oauth",
provider: "openai",
access: "codex-cli-access-token",
refresh: "codex-cli-refresh-token",
expires: Date.now() + 86_400_000,
accountId: "acct-codex",
});
expect(result).toEqual({
apiKey: "cached-access-token", // pragma: allowlist secret
provider: "openai",
email: undefined,
});
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
await expect(resolveOpenAICodexProfile({ profileId, agentDir })).resolves.toBeNull();
expect(readCodexCliCredentialsCachedMock).not.toHaveBeenCalled();
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
});
it("refreshes near-expiry openai credentials before hard expiry", async () => {
@@ -481,7 +518,7 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => {
});
});
it("uses same-account Codex CLI credentials after forced local refresh fails", async () => {
it("does not use same-account Codex CLI credentials after forced local refresh fails", async () => {
const profileId = "openai:default";
saveAuthProfileStore(
{
@@ -520,16 +557,8 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => {
agentDir,
forceRefresh: true,
}),
).resolves.toEqual({
apiKey: "codex-cli-access-token",
provider: "openai",
email: undefined,
});
).rejects.toThrow(/OAuth token refresh failed for openai/);
expect(readCodexCliCredentialsCachedMock).toHaveBeenCalledWith({
ttlMs: expect.any(Number),
allowKeychainPrompt: false,
});
const persisted = await readPersistedStore(agentDir);
const persistedProfile = requireOAuthProfile(persisted, profileId);
expect(persistedProfile.accountId).toBe("acct-shared");
@@ -539,7 +568,58 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => {
expect(JSON.stringify(persisted)).not.toContain("codex-cli-refresh-token");
});
it("uses same-account Codex CLI credentials for named Codex profiles after forced local refresh fails", async () => {
it("does not use same-account Codex CLI credentials when default-agent store omits agentDir", async () => {
const profileId = "openai:user@example.com";
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "openai",
access: "local-access-token",
refresh: "local-refresh-token",
expires: Date.now() + 86_400_000,
accountId: "acct-shared",
email: "user@example.com",
},
},
},
agentDir,
);
readCodexCliCredentialsCachedMock.mockReturnValue({
type: "oauth",
provider: "openai",
access: "codex-cli-access-token",
refresh: "codex-cli-refresh-token",
expires: Date.now() + 86_400_000,
accountId: "acct-shared",
});
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => {
throw new Error(
'401 {"error":{"message":"Your refresh token is expired.","code":"refresh_token_expired"}}',
);
});
await expect(
resolveApiKeyForProvider({
provider: "openai",
store: ensureAuthProfileStore(agentDir),
profileId,
forceRefresh: true,
}),
).rejects.toThrow(/OAuth token refresh failed for openai/);
const persisted = await readPersistedStore(agentDir);
const persistedProfile = requireOAuthProfile(persisted, profileId);
expect(persistedProfile.accountId).toBe("acct-shared");
expect(persistedProfile.access).toBe("local-access-token");
expect(persistedProfile.refresh).toBe("local-refresh-token");
expect(JSON.stringify(persisted)).not.toContain("codex-cli-access-token");
expect(JSON.stringify(persisted)).not.toContain("codex-cli-refresh-token");
});
it("does not use same-account Codex CLI credentials for named Codex profiles after forced local refresh fails", async () => {
const profileId = "openai:user@example.com";
saveAuthProfileStore(
{
@@ -579,11 +659,7 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => {
agentDir,
forceRefresh: true,
}),
).resolves.toEqual({
apiKey: "codex-cli-access-token",
provider: "openai",
email: "user@example.com",
});
).rejects.toThrow(/OAuth token refresh failed for openai/);
const persisted = await readPersistedStore(agentDir);
const persistedProfile = requireOAuthProfile(persisted, profileId);
@@ -593,6 +669,119 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => {
expect(JSON.stringify(persisted)).not.toContain("codex-cli-refresh-token");
});
it("fails closed instead of selecting Codex CLI after an unpinned managed refresh fails", async () => {
const profileId = "openai:user@example.com";
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider: "openai",
accountId: "acct-shared",
}),
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
readCodexCliCredentialsCachedMock.mockReturnValue({
type: "oauth",
provider: "openai",
access: "stale-codex-cli-access-token",
refresh: "stale-codex-cli-refresh-token",
expires: Date.now() + 86_400_000,
accountId: "acct-shared",
});
refreshProviderOAuthCredentialWithPluginMock.mockRejectedValueOnce(
new Error(
'401 {"error":{"message":"Your refresh token is expired.","code":"refresh_token_expired"}}',
),
);
await expect(
resolveApiKeyForProvider({
provider: "openai",
agentDir,
}),
).rejects.toMatchObject({
name: "OAuthRefreshFailureError",
provider: "openai",
profileId,
});
});
it("does not refresh managed OAuth for direct OpenAI API-key models", async () => {
const profileId = "openai:user@example.com";
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider: "openai",
accountId: "acct-shared",
}),
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
readCodexCliCredentialsCachedMock.mockReturnValue({
type: "oauth",
provider: "openai",
access: "stale-codex-cli-access-token",
refresh: "stale-codex-cli-refresh-token",
expires: Date.now() + 86_400_000,
accountId: "acct-shared",
});
await expect(
resolveApiKeyForProvider({
provider: "openai",
modelApi: "openai-responses",
agentDir,
}),
).rejects.toThrow('No API key found for provider "openai"');
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
});
it("rejects explicit managed OAuth before refreshing for direct OpenAI API-key models", async () => {
const profileId = "openai:user@example.com";
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider: "openai",
accountId: "acct-shared",
}),
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
await expect(
resolveApiKeyForProvider({
provider: "openai",
modelApi: "openai-responses",
profileId,
lockedProfile: true,
agentDir,
}),
).rejects.toThrow(/requires an OpenAI API key profile/);
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
});
it("does not refresh managed OAuth while checking direct OpenAI auth availability", async () => {
const profileId = "openai:user@example.com";
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider: "openai",
accountId: "acct-shared",
}),
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
await expect(
hasAvailableAuthForProvider({
provider: "openai",
modelApi: "openai-responses",
agentDir,
}),
).resolves.toBe(false);
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
});
it("rejects mismatched Codex CLI fallback after forced local refresh fails", async () => {
const profileId = "openai:default";
saveAuthProfileStore(

View File

@@ -28,10 +28,7 @@ import {
resolveTokenExpiryState,
} from "./credential-state.js";
import { formatAuthDoctorHint } from "./doctor.js";
import {
readExternalCliBootstrapCredential,
readExternalCliFallbackCredential,
} from "./external-cli-sync.js";
import { readExternalCliBootstrapCredential } from "./external-cli-sync.js";
import { createOAuthManager, OAuthManagerRefreshError } from "./oauth-manager.js";
import { OAuthRefreshFailureError } from "./oauth-refresh-failure.js";
import { assertNoOAuthSecretRefPolicyViolations } from "./policy.js";
@@ -234,19 +231,12 @@ export async function refreshOAuthCredentialForRuntime(params: {
const oauthManager = createOAuthManager({
buildApiKey: buildOAuthApiKey,
refreshCredential: refreshOAuthCredential,
readBootstrapCredential: ({ profileId, credential }) =>
readBootstrapCredential: ({ store, profileId, credential }) =>
readExternalCliBootstrapCredential({
store,
profileId,
credential,
}),
readFallbackCredential: ({ profileId, credential }) =>
credential.provider === "openai"
? readExternalCliFallbackCredential({
profileId,
credential,
allowKeychainPrompt: false,
})
: null,
isRefreshTokenReusedError,
});
@@ -521,6 +511,7 @@ export async function resolveApiKeyForProfile(
});
throw new OAuthRefreshFailureError({
provider: cred.provider,
profileId,
message:
`OAuth token refresh failed for ${cred.provider}: ${message}. ` +
"Please try again or re-authenticate." +

View File

@@ -44,6 +44,7 @@ import {
resolveAuthProfileOrder,
resolveAuthStorePathForDisplay,
} from "./auth-profiles.js";
import { OAuthRefreshFailureError } from "./auth-profiles/oauth-refresh-failure.js";
import * as cliCredentials from "./cli-credentials.js";
import { resolveProviderEnvAuthLookupMaps } from "./model-auth-env-vars.js";
import {
@@ -1039,6 +1040,15 @@ export async function resolveApiKeyForProvider(params: {
profileId,
preferredProfile,
});
const configuredProfileType = store.profiles[profileId]?.type;
if (configuredProfileType) {
assertAuthModeAllowedForModel({
provider,
modelApi: params.modelApi,
profileId,
mode: profileTypeToAuthMode(configuredProfileType),
});
}
const resolved = await resolveApiKeyForProfile({
cfg,
store,
@@ -1236,7 +1246,9 @@ export async function resolveApiKeyForProvider(params: {
preferredProfile,
});
let deferredAuthProfileResult: ResolvedProviderAuth | null = null;
let refreshFailure: OAuthRefreshFailureError | undefined;
for (const candidate of order) {
let candidateMode: ResolvedProviderAuth["mode"] | undefined;
try {
const awsSdkProfileAuth = resolveConfiguredAwsSdkProfileAuth({
cfg,
@@ -1246,6 +1258,18 @@ export async function resolveApiKeyForProvider(params: {
if (awsSdkProfileAuth) {
return awsSdkProfileAuth;
}
const candidateType = store.profiles[candidate]?.type;
candidateMode = candidateType ? profileTypeToAuthMode(candidateType) : undefined;
if (
candidateMode &&
!isAuthModeAllowedForModel({
provider,
modelApi: params.modelApi,
mode: candidateMode,
})
) {
continue;
}
const resolved = await resolveApiKeyForProfile({
cfg,
store,
@@ -1288,6 +1312,18 @@ export async function resolveApiKeyForProvider(params: {
return result;
}
} catch (err) {
if (
!refreshFailure &&
err instanceof OAuthRefreshFailureError &&
(!candidateMode ||
isAuthModeAllowedForModel({
provider,
modelApi: params.modelApi,
mode: candidateMode,
}))
) {
refreshFailure = err;
}
log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`);
}
}
@@ -1332,6 +1368,10 @@ export async function resolveApiKeyForProvider(params: {
return syntheticLocalAuth;
}
if (refreshFailure) {
throw refreshFailure;
}
const hasInlineConfiguredModels =
Array.isArray(providerConfig?.models) && providerConfig.models.length > 0;
const owningPluginIds = !hasInlineConfiguredModels
@@ -1493,6 +1533,17 @@ export async function hasAvailableAuthForProvider(params: {
if (resolveConfiguredAwsSdkProfileAuth({ cfg, provider, profileId: candidate })) {
return true;
}
const candidateType = store.profiles[candidate]?.type;
if (
candidateType &&
!isAuthModeAllowedForModel({
provider,
modelApi: params.modelApi,
mode: profileTypeToAuthMode(candidateType),
})
) {
continue;
}
const resolved = await resolveApiKeyForProfile({
cfg,
store,

View File

@@ -7111,6 +7111,7 @@ describe("runAgentTurnWithFallback", () => {
state.runEmbeddedAgentMock.mockRejectedValueOnce(
new OAuthRefreshFailureError({
provider: "openai",
profileId: "openai:user@example.com",
message: "invalid_grant",
}),
);
@@ -7121,11 +7122,106 @@ describe("runAgentTurnWithFallback", () => {
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toBe(
"⚠️ Model login expired on the gateway for openai. Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again.",
"⚠️ Model login expired on the gateway for openai. Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai --profile-id 'openai:user@example.com'` in a terminal, then try again.",
);
}
});
it("preserves OAuth profile guidance through failover wrappers", async () => {
const refreshError = new OAuthRefreshFailureError({
provider: "openai",
profileId: "openai:user@example.com",
message: "invalid_grant",
});
state.runEmbeddedAgentMock.mockRejectedValueOnce(
new FailoverError("OpenAI OAuth failed", {
reason: "auth",
provider: "openai",
model: "gpt-5.5",
profileId: "openai:user@example.com",
authProfileFailure: { allInCooldown: false },
status: 401,
cause: refreshError,
}),
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams());
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toContain("--profile-id 'openai:user@example.com'");
}
});
it("preserves OAuth profile guidance through fallback summaries", async () => {
const refreshError = new OAuthRefreshFailureError({
provider: "openai",
profileId: "openai:user@example.com",
message: "invalid_grant",
});
const failoverError = new FailoverError("OpenAI OAuth failed", {
reason: "auth",
provider: "openai",
model: "gpt-5.5",
profileId: "openai:user@example.com",
authProfileFailure: { allInCooldown: false },
status: 401,
cause: refreshError,
});
const summaryError = new Error("All models failed", { cause: failoverError });
summaryError.name = "FallbackSummaryError";
Object.assign(summaryError, {
attempts: [
{
provider: "openai",
model: "gpt-5.5",
error: "OpenAI OAuth failed",
reason: "auth",
},
],
soonestCooldownExpiry: null,
});
state.runEmbeddedAgentMock.mockRejectedValueOnce(summaryError);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams());
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toContain("--profile-id 'openai:user@example.com'");
}
});
it("omits OAuth profile ids from group reauth guidance", async () => {
state.runEmbeddedAgentMock.mockRejectedValueOnce(
new OAuthRefreshFailureError({
provider: "openai",
profileId: "openai:user@example.com",
message: "invalid_grant",
}),
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
sessionCtx: {
Provider: "whatsapp",
MessageSid: "msg",
ChatType: "group",
} as unknown as TemplateContext,
}),
);
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toContain(
"openclaw models auth login --provider openai` in a terminal",
);
expect(result.payload.text).not.toContain("user@example.com");
}
});
it("keeps non-OpenAI OAuth refresh failures on provider-specific terminal guidance", async () => {
state.runEmbeddedAgentMock.mockRejectedValueOnce(
new OAuthRefreshFailureError({
@@ -7209,6 +7305,7 @@ describe("runAgentTurnWithFallback", () => {
new FailoverError("Auth profile failover exhausted for provider openai", {
reason: "auth",
provider: "openai",
status: 401,
authProfileFailure: { allInCooldown: true },
cause: new Error("invalid_grant"),
}),

View File

@@ -23,6 +23,7 @@ import {
buildOAuthRefreshFailureLoginCommand,
classifyOAuthRefreshFailure,
classifyOAuthRefreshFailureError,
formatOAuthRefreshFailureLoginCommandMarkdown,
} from "../../agents/auth-profiles/oauth-refresh-failure.js";
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
import type { BootstrapContextRunKind } from "../../agents/bootstrap-mode.js";
@@ -962,11 +963,41 @@ function supportsChannelCodexLogin(provider: string | null | undefined): boolean
function buildExternalRunFailureReply(
input: ExternalRunFailureInput,
options?: { includeDetails?: boolean; isHeartbeat?: boolean },
options?: {
includeAuthProfileId?: boolean;
includeDetails?: boolean;
isHeartbeat?: boolean;
},
): ExternalRunFailureReply {
const message = typeof input === "string" ? input : input.message;
const error = typeof input === "string" ? undefined : input.error;
const normalizedMessage = collapseRepeatedFailureDetail(message);
const oauthRefreshFailure =
classifyOAuthRefreshFailureError(error) ?? classifyOAuthRefreshFailure(normalizedMessage);
if (oauthRefreshFailure) {
const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider, {
profileId: options?.includeAuthProfileId ? oauthRefreshFailure.profileId : undefined,
});
const loginCommandMarkdown = formatOAuthRefreshFailureLoginCommandMarkdown(loginCommand);
const providerText = oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : "";
const supportsCodexLogin = supportsChannelCodexLogin(oauthRefreshFailure.provider);
const channelLoginHint = supportsCodexLogin
? "Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth"
: "Re-auth";
const retryLoginHint = supportsCodexLogin
? "send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth"
: "re-auth";
if (oauthRefreshFailure.reason) {
return {
text: `⚠️ Model login expired on the gateway${providerText}. ${channelLoginHint} with ${loginCommandMarkdown} in a terminal, then try again.`,
isGenericRunnerFailure: false,
};
}
return {
text: `⚠️ Model login failed on the gateway${providerText}. Please try again. If this keeps happening, ${retryLoginHint} with ${loginCommandMarkdown} in a terminal.`,
isGenericRunnerFailure: false,
};
}
const authProfileFailoverFailure = buildAuthProfileFailoverFailureText(error);
if (authProfileFailoverFailure) {
return { text: authProfileFailoverFailure, isGenericRunnerFailure: false };
@@ -985,29 +1016,6 @@ function buildExternalRunFailureReply(
if (missingApiKeyFailure) {
return { text: missingApiKeyFailure, isGenericRunnerFailure: false };
}
const oauthRefreshFailure =
classifyOAuthRefreshFailureError(error) ?? classifyOAuthRefreshFailure(normalizedMessage);
if (oauthRefreshFailure) {
const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider);
const providerText = oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : "";
const supportsCodexLogin = supportsChannelCodexLogin(oauthRefreshFailure.provider);
const channelLoginHint = supportsCodexLogin
? "Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth"
: "Re-auth";
const retryLoginHint = supportsCodexLogin
? "send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth"
: "re-auth";
if (oauthRefreshFailure.reason) {
return {
text: `⚠️ Model login expired on the gateway${providerText}. ${channelLoginHint} with \`${loginCommand}\` in a terminal, then try again.`,
isGenericRunnerFailure: false,
};
}
return {
text: `⚠️ Model login failed on the gateway${providerText}. Please try again. If this keeps happening, ${retryLoginHint} with \`${loginCommand}\` in a terminal.`,
isGenericRunnerFailure: false,
};
}
if (options?.isHeartbeat) {
return { text: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, isGenericRunnerFailure: false };
}
@@ -1108,6 +1116,7 @@ export function buildKnownAgentRunFailureReplyPayload(params: {
const externalRunFailureReply = buildExternalRunFailureReply(
{ message, error: params.err },
{
includeAuthProfileId: !isNonDirectConversationContext(params.sessionCtx),
includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
},
);
@@ -3204,8 +3213,16 @@ export async function runAgentTurnWithFallback(params: {
: isBillingErrorMessage(message);
const isContextOverflow = !isBilling && isLikelyContextOverflowError(message);
const isCompactionFailure = !isBilling && isCompactionFailureError(message);
const oauthRefreshFailure =
classifyOAuthRefreshFailureError(err) ?? classifyOAuthRefreshFailure(message);
const hasAuthProfileFailoverFailure = buildAuthProfileFailoverFailureText(err) !== null;
const providerRequestError =
!isBilling && !shouldSurfaceToControlUi ? classifyProviderRequestError(err) : undefined;
!isBilling &&
!oauthRefreshFailure &&
!hasAuthProfileFailoverFailure &&
!shouldSurfaceToControlUi
? classifyProviderRequestError(err)
: undefined;
const isTransientHttp = isTransientHttpError(message);
// Drain/restart aborts stay silent and defer to post-restart
@@ -3343,6 +3360,7 @@ export async function runAgentTurnWithFallback(params: {
? buildExternalRunFailureReply(
{ message, error: err },
{
includeAuthProfileId: !isNonDirectConversationContext(params.sessionCtx),
includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
isHeartbeat: params.isHeartbeat,
},

View File

@@ -88,6 +88,19 @@ describe("resolveUnusableProfileHint", () => {
);
});
it("quotes exact current profile ids in OAuth reauth guidance", () => {
expect(
formatOAuthRefreshFailureDoctorLine({
profileId: "OpenAI Work Profile",
provider: "openai",
message:
"OAuth token refresh failed for openai: invalid_grant. Please try again or re-authenticate.",
}),
).toBe(
"- OpenAI Work Profile: re-auth required [invalid_grant] — Run `openclaw models auth login --provider openai --profile-id 'OpenAI Work Profile'`.",
);
});
it("drops the provider-specific command when the parsed provider is unsafe", () => {
expect(
formatOAuthRefreshFailureDoctorLine({

View File

@@ -24,6 +24,7 @@ import { formatAuthDoctorHint } from "../agents/auth-profiles/doctor.js";
import {
buildOAuthRefreshFailureLoginCommand,
classifyOAuthRefreshFailure,
formatOAuthRefreshFailureLoginCommandMarkdown,
type OAuthRefreshFailureReason,
} from "../agents/auth-profiles/oauth-refresh-failure.js";
import { resolveAuthStorePathForDisplay } from "../agents/auth-profiles/path-resolve.js";
@@ -238,11 +239,14 @@ export function formatOAuthRefreshFailureDoctorLine(params: {
const provider = rawProvider
? (DOCTOR_REAUTH_PROVIDER_ALIASES[rawProvider] ?? rawProvider)
: null;
const command = buildOAuthRefreshFailureLoginCommand(provider);
const command = buildOAuthRefreshFailureLoginCommand(provider, {
profileId: provider === rawProvider ? params.profileId : undefined,
});
const commandMarkdown = formatOAuthRefreshFailureLoginCommandMarkdown(command);
if (classified.reason) {
return `- ${params.profileId}: re-auth required [${formatOAuthRefreshFailureReason(classified.reason)}] — Run \`${command}\`.`;
return `- ${params.profileId}: re-auth required [${formatOAuthRefreshFailureReason(classified.reason)}] — Run ${commandMarkdown}.`;
}
return `- ${params.profileId}: OAuth refresh failed — Try again; if this persists, run \`${command}\`.`;
return `- ${params.profileId}: OAuth refresh failed — Try again; if this persists, run ${commandMarkdown}.`;
}
async function resolveAuthIssueHint(