Skip to content

Commit 06ea905

Browse files
fix: surface Claude CLI OAuth reauth hints
1 parent bfdd60b commit 06ea905

4 files changed

Lines changed: 254 additions & 31 deletions

File tree

src/agents/auth-profiles/oauth-refresh-failure.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,63 @@ describe("oauth refresh failure hints", () => {
7575
);
7676
});
7777

78+
it("classifies Claude CLI OAuth 401 provider errors for re-auth guidance", () => {
79+
expect(
80+
classifyOAuthRefreshFailureError({
81+
provider: "claude-cli",
82+
rawError: "Failed to authenticate. API Error: 401 Invalid authentication credentials",
83+
}),
84+
).toEqual({
85+
provider: "claude-cli",
86+
reason: "sign_in_again",
87+
});
88+
expect(
89+
classifyOAuthRefreshFailureError({
90+
provider: "claude-cli",
91+
rawError: "Failed to authenticate. API Error: 401 Invalid bearer token",
92+
}),
93+
).toEqual({
94+
provider: "claude-cli",
95+
reason: "sign_in_again",
96+
});
97+
expect(
98+
classifyOAuthRefreshFailureError({
99+
provider: "claude-cli",
100+
reason: "auth",
101+
status: 401,
102+
message: "Invalid authentication credentials",
103+
}),
104+
).toEqual({
105+
provider: "claude-cli",
106+
reason: "sign_in_again",
107+
});
108+
expect(
109+
classifyOAuthRefreshFailureError({
110+
provider: "claude-cli",
111+
reason: "auth",
112+
status: 401,
113+
message: "Invalid bearer token",
114+
}),
115+
).toEqual({
116+
provider: "claude-cli",
117+
reason: "sign_in_again",
118+
});
119+
expect(
120+
classifyOAuthRefreshFailureError({
121+
provider: "anthropic",
122+
reason: "auth",
123+
status: 401,
124+
message: "Failed to authenticate. API Error: 401 Invalid authentication credentials",
125+
}),
126+
).toBeNull();
127+
expect(
128+
classifyOAuthRefreshFailureError({
129+
provider: "claude-cli",
130+
message: "Invalid authentication credentials",
131+
}),
132+
).toBeNull();
133+
});
134+
78135
it("classifies token invalidation refresh failures", () => {
79136
expect(
80137
classifyOAuthRefreshFailure(

src/agents/auth-profiles/oauth-refresh-failure.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export class OAuthRefreshFailureError extends Error {
3939

4040
const OAUTH_REFRESH_FAILURE_PROVIDER_RE = /OAuth token refresh failed for ([^:]+):/i;
4141
const SAFE_PROVIDER_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
42+
const CLAUDE_CLI_AUTH_FAILURE_RE =
43+
/\bfailed to authenticate\b[\s\S]*\b401\b[\s\S]*\binvalid (?:authentication credentials|bearer token)\b/i;
44+
const CLAUDE_CLI_AUTH_401_DETAIL_RE = /\binvalid (?:authentication credentials|bearer token)\b/i;
4245

4346
function isOAuthRefreshFailureMessage(message: string): boolean {
4447
const lower = message.toLowerCase();
@@ -114,6 +117,55 @@ export function classifyOAuthRefreshFailure(message: string): OAuthRefreshFailur
114117
};
115118
}
116119

120+
/**
121+
* Claude CLI 401s come from its local OAuth login state, not inherited API keys,
122+
* so route that typed provider failure through the existing re-auth hint path.
123+
*/
124+
function classifyProviderOAuthAuthenticationFailure(params: {
125+
provider: string | null | undefined;
126+
reason?: string | null;
127+
status?: number | null;
128+
message: string;
129+
}): OAuthRefreshFailure | null {
130+
const provider = sanitizeOAuthRefreshFailureProvider(params.provider);
131+
const structuredClaudeCliAuth401 =
132+
params.reason?.trim().toLowerCase() === "auth" &&
133+
params.status === 401 &&
134+
CLAUDE_CLI_AUTH_401_DETAIL_RE.test(params.message);
135+
if (
136+
provider !== "claude-cli" ||
137+
!(CLAUDE_CLI_AUTH_FAILURE_RE.test(params.message) || structuredClaudeCliAuth401)
138+
) {
139+
return null;
140+
}
141+
return {
142+
provider,
143+
reason: "sign_in_again",
144+
};
145+
}
146+
147+
function classifyProviderOAuthAuthenticationFailureObject(
148+
candidate: object,
149+
): OAuthRefreshFailure | null {
150+
const error = candidate as {
151+
message?: unknown;
152+
provider?: unknown;
153+
rawError?: unknown;
154+
reason?: unknown;
155+
status?: unknown;
156+
};
157+
const provider = typeof error.provider === "string" ? error.provider : null;
158+
const reason = typeof error.reason === "string" ? error.reason : null;
159+
const status = typeof error.status === "number" ? error.status : null;
160+
const message =
161+
typeof error.rawError === "string"
162+
? error.rawError
163+
: typeof error.message === "string"
164+
? error.message
165+
: "";
166+
return classifyProviderOAuthAuthenticationFailure({ provider, reason, status, message });
167+
}
168+
117169
/** Classify provider/reason from the structured OAuth refresh failure error. */
118170
export function classifyOAuthRefreshFailureError(err: unknown): OAuthRefreshFailure | null {
119171
const seen = new Set<object>();
@@ -127,6 +179,10 @@ export function classifyOAuthRefreshFailureError(err: unknown): OAuthRefreshFail
127179
reason: candidate.reason,
128180
};
129181
}
182+
const providerAuthFailure = classifyProviderOAuthAuthenticationFailureObject(candidate);
183+
if (providerAuthFailure) {
184+
return providerAuthFailure;
185+
}
130186
if (seen.has(candidate)) {
131187
return null;
132188
}

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js";
2626
import type { GetReplyOptions, ReplyPayload } from "../types.js";
2727
import {
2828
buildEmptyInteractiveReplyPayload,
29+
buildKnownAgentRunFailureReplyPayload,
2930
buildContextOverflowRecoveryText,
3031
computeContextAwareReserveTokensFloor,
3132
MAX_LIVE_SWITCH_RETRIES,
@@ -7628,6 +7629,70 @@ describe("runAgentTurnWithFallback", () => {
76287629
}
76297630
});
76307631

7632+
it("surfaces Claude CLI OAuth reauth guidance for typed 401 auth failures", async () => {
7633+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
7634+
new FailoverError("Invalid bearer token", {
7635+
reason: "auth",
7636+
provider: "claude-cli",
7637+
status: 401,
7638+
}),
7639+
);
7640+
7641+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
7642+
const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams());
7643+
7644+
expect(result.kind).toBe("final");
7645+
if (result.kind === "final") {
7646+
expect(result.payload.text).toBe(
7647+
"⚠️ Model login expired on the gateway for claude-cli. Re-auth with `claude auth login`, then refresh OpenClaw's CLI auth profile with `openclaw models auth login --provider anthropic --method cli`, then try again.",
7648+
);
7649+
}
7650+
});
7651+
7652+
it("surfaces Claude CLI OAuth reauth guidance through known failure payloads", () => {
7653+
const payload = buildKnownAgentRunFailureReplyPayload({
7654+
err: new FailoverError("Invalid bearer token", {
7655+
reason: "auth",
7656+
provider: "claude-cli",
7657+
status: 401,
7658+
}),
7659+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
7660+
resolvedVerboseLevel: "off",
7661+
});
7662+
7663+
expect(payload?.text).toBe(
7664+
"⚠️ Model login expired on the gateway for claude-cli. Re-auth with `claude auth login`, then refresh OpenClaw's CLI auth profile with `openclaw models auth login --provider anthropic --method cli`, then try again.",
7665+
);
7666+
});
7667+
7668+
it("preserves active profile flags in Claude CLI OAuth reauth guidance", async () => {
7669+
vi.stubEnv("OPENCLAW_PROFILE", "work");
7670+
try {
7671+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
7672+
new FailoverError(
7673+
"Failed to authenticate. API Error: 401 Invalid authentication credentials",
7674+
{
7675+
reason: "auth",
7676+
provider: "claude-cli",
7677+
status: 401,
7678+
},
7679+
),
7680+
);
7681+
7682+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
7683+
const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams());
7684+
7685+
expect(result.kind).toBe("final");
7686+
if (result.kind === "final") {
7687+
expect(result.payload.text).toContain(
7688+
"`openclaw --profile work models auth login --provider anthropic --method cli`",
7689+
);
7690+
}
7691+
} finally {
7692+
vi.unstubAllEnvs();
7693+
}
7694+
});
7695+
76317696
it("keeps non-OpenAI OAuth refresh failures on provider-specific terminal guidance", async () => {
76327697
state.runEmbeddedAgentMock.mockRejectedValueOnce(
76337698
new OAuthRefreshFailureError({

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 76 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
resolveAgentRunAbortLifecycleFields,
7272
} from "../../agents/run-termination.js";
7373
import { buildAgentRuntimeOutcomePlan } from "../../agents/runtime-plan/build.js";
74+
import { formatCliCommand } from "../../cli/command-format.js";
7475
import { resolveGroupSessionKey, type SessionEntry } from "../../config/sessions.js";
7576
import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
7677
import { resolveSilentReplyPolicy } from "../../config/silent-reply.js";
@@ -795,6 +796,9 @@ function collapseRepeatedFailureDetail(message: string): string {
795796
}
796797

797798
const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai"]);
799+
const CLAUDE_CLI_LOCAL_AUTH_COMMAND = "claude auth login";
800+
const CLAUDE_CLI_OPENCLAW_AUTH_COMMAND =
801+
"openclaw models auth login --provider anthropic --method cli";
798802
const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900;
799803
const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:";
800804
const PREFLIGHT_COMPACTION_FAILURE_PREFIX = "Preflight compaction required but failed:";
@@ -942,6 +946,52 @@ function buildAuthProfileFailoverFailureText(error: unknown): string | null {
942946
});
943947
}
944948

949+
function buildOAuthFailureReplyForError(
950+
error: unknown,
951+
normalizedMessage: string,
952+
options?: {
953+
includeAuthProfileId?: boolean;
954+
},
955+
): ExternalRunFailureReply | null {
956+
const oauthRefreshFailure =
957+
classifyOAuthRefreshFailureError(error) ?? classifyOAuthRefreshFailure(normalizedMessage);
958+
if (!oauthRefreshFailure) {
959+
return null;
960+
}
961+
if (oauthRefreshFailure.provider === "claude-cli") {
962+
const openClawAuthCommand = formatCliCommand(CLAUDE_CLI_OPENCLAW_AUTH_COMMAND);
963+
const text = oauthRefreshFailure.reason
964+
? "⚠️ Model login expired on the gateway for claude-cli."
965+
: "⚠️ Model login failed on the gateway for claude-cli.";
966+
return {
967+
text: `${text} Re-auth with \`${CLAUDE_CLI_LOCAL_AUTH_COMMAND}\`, then refresh OpenClaw's CLI auth profile with \`${openClawAuthCommand}\`, then try again.`,
968+
isGenericRunnerFailure: false,
969+
};
970+
}
971+
const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider, {
972+
profileId: options?.includeAuthProfileId ? oauthRefreshFailure.profileId : undefined,
973+
});
974+
const loginCommandMarkdown = formatOAuthRefreshFailureLoginCommandMarkdown(loginCommand);
975+
const providerText = oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : "";
976+
const supportsCodexLogin = supportsChannelCodexLogin(oauthRefreshFailure.provider);
977+
const channelLoginHint = supportsCodexLogin
978+
? "Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth"
979+
: "Re-auth";
980+
const retryLoginHint = supportsCodexLogin
981+
? "send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth"
982+
: "re-auth";
983+
if (oauthRefreshFailure.reason) {
984+
return {
985+
text: `⚠️ Model login expired on the gateway${providerText}. ${channelLoginHint} with ${loginCommandMarkdown} in a terminal, then try again.`,
986+
isGenericRunnerFailure: false,
987+
};
988+
}
989+
return {
990+
text: `⚠️ Model login failed on the gateway${providerText}. Please try again. If this keeps happening, ${retryLoginHint} with ${loginCommandMarkdown} in a terminal.`,
991+
isGenericRunnerFailure: false,
992+
};
993+
}
994+
945995
function formatForwardedExternalRunFailureText(message: string): string {
946996
const sanitized = sanitizeUserFacingText(message, { errorContext: true })
947997
.trim()
@@ -981,31 +1031,11 @@ function buildExternalRunFailureReply(
9811031
const message = typeof input === "string" ? input : input.message;
9821032
const error = typeof input === "string" ? undefined : input.error;
9831033
const normalizedMessage = collapseRepeatedFailureDetail(message);
984-
const oauthRefreshFailure =
985-
classifyOAuthRefreshFailureError(error) ?? classifyOAuthRefreshFailure(normalizedMessage);
986-
if (oauthRefreshFailure) {
987-
const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider, {
988-
profileId: options?.includeAuthProfileId ? oauthRefreshFailure.profileId : undefined,
989-
});
990-
const loginCommandMarkdown = formatOAuthRefreshFailureLoginCommandMarkdown(loginCommand);
991-
const providerText = oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : "";
992-
const supportsCodexLogin = supportsChannelCodexLogin(oauthRefreshFailure.provider);
993-
const channelLoginHint = supportsCodexLogin
994-
? "Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth"
995-
: "Re-auth";
996-
const retryLoginHint = supportsCodexLogin
997-
? "send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth"
998-
: "re-auth";
999-
if (oauthRefreshFailure.reason) {
1000-
return {
1001-
text: `⚠️ Model login expired on the gateway${providerText}. ${channelLoginHint} with ${loginCommandMarkdown} in a terminal, then try again.`,
1002-
isGenericRunnerFailure: false,
1003-
};
1004-
}
1005-
return {
1006-
text: `⚠️ Model login failed on the gateway${providerText}. Please try again. If this keeps happening, ${retryLoginHint} with ${loginCommandMarkdown} in a terminal.`,
1007-
isGenericRunnerFailure: false,
1008-
};
1034+
const oauthFailureReply = buildOAuthFailureReplyForError(error, normalizedMessage, {
1035+
includeAuthProfileId: options?.includeAuthProfileId,
1036+
});
1037+
if (oauthFailureReply) {
1038+
return oauthFailureReply;
10091039
}
10101040
const authProfileFailoverFailure = buildAuthProfileFailoverFailureText(error);
10111041
if (authProfileFailoverFailure) {
@@ -3305,14 +3335,19 @@ async function runAgentTurnWithFallbackInternal(
33053335
: isBillingErrorMessage(message);
33063336
const isContextOverflow = !isBilling && isLikelyContextOverflowError(message);
33073337
const isCompactionFailure = !isBilling && isCompactionFailureError(message);
3308-
const oauthRefreshFailure =
3309-
classifyOAuthRefreshFailureError(err) ?? classifyOAuthRefreshFailure(message);
3310-
const hasAuthProfileFailoverFailure = buildAuthProfileFailoverFailureText(err) !== null;
3338+
const oauthFailureReply =
3339+
!isBilling && !shouldSurfaceToControlUi
3340+
? buildOAuthFailureReplyForError(err, collapseRepeatedFailureDetail(message), {
3341+
includeAuthProfileId: !isNonDirectConversationContext(params.sessionCtx),
3342+
})
3343+
: null;
3344+
const hasAuthProfileFailoverFailure =
3345+
!isBilling && !oauthFailureReply && buildAuthProfileFailoverFailureText(err) !== null;
33113346
const providerRequestError =
33123347
!isBilling &&
3313-
!oauthRefreshFailure &&
3314-
!hasAuthProfileFailoverFailure &&
3315-
!shouldSurfaceToControlUi
3348+
!shouldSurfaceToControlUi &&
3349+
!oauthFailureReply &&
3350+
!hasAuthProfileFailoverFailure
33163351
? classifyProviderRequestError(err)
33173352
: undefined;
33183353
const isTransientHttp = isTransientHttpError(message);
@@ -3397,6 +3432,16 @@ async function runAgentTurnWithFallbackInternal(
33973432
}),
33983433
};
33993434
}
3435+
if (oauthFailureReply) {
3436+
takePendingLifecycleTerminal()?.emit("error", err);
3437+
params.replyOperation?.fail("run_failed", err);
3438+
return {
3439+
kind: "final",
3440+
payload: markAgentRunFailureReplyPayload({
3441+
text: oauthFailureReply.text,
3442+
}),
3443+
};
3444+
}
34003445
if (providerRequestError) {
34013446
takePendingLifecycleTerminal()?.emit("error", err);
34023447
params.replyOperation?.fail("run_failed", err);

0 commit comments

Comments
 (0)