mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-04 10:43:32 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ function buildProfileHealth(params: {
|
||||
}
|
||||
|
||||
const effectiveCredential = resolveEffectiveOAuthCredential({
|
||||
store,
|
||||
profileId,
|
||||
credential: healthCredential,
|
||||
allowKeychainPrompt,
|
||||
|
||||
@@ -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" }),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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." +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user