Skip to content

Commit fa84741

Browse files
authored
fix(claude-cli): surface re-auth hint when subprocess OAuth token expires (#97669)
* fix(claude-cli): surface re-auth hint when subprocess OAuth token expires When the claude-cli subprocess emits a 401 "Failed to authenticate. API Error: 401 Invalid authentication credentials" because its stored OAuth token has expired, the error was surfaced as the generic provider-auth copy instead of the targeted re-auth hint. Two fixes: 1. Extend isOAuthRefreshFailureMessage to recognise the claude-cli subprocess 401 error shape (requires "claude-cli" prefix + 401 + auth failure text). extractOAuthRefreshFailureProvider returns "claude-cli" for this pattern; classifyOAuthRefreshFailureReason maps it to "revoked" so the "expired" copy is shown. 2. Reorder both classification sites in buildExternalRunFailureReply and the runAgentTurnWithFallback catch block so the OAuth-refresh check runs before classifyProviderRequestError. The typed 401 branch in classifyProviderRequestError would otherwise intercept the FailoverError before the OAuth hint path is ever reached. Fixes #97553 * fix(claude-cli): use correct auth login command format in re-auth hint --provider claude-cli is not a registered provider id; the correct command to re-authenticate the claude subprocess is: openclaw models auth login --provider anthropic --method cli Add a special-case branch in buildOAuthRefreshFailureLoginCommand so that a 'claude-cli' sanitized provider emits the two-flag form instead of the generic --provider <id> template. Update the regression test expectation to match. * test(claude-cli): expect CLI auth re-login command * fix(claude-cli): classify structured OAuth auth failures * test(claude-cli): add real http oauth expiry proof * test(claude-cli): add proof output to real HTTP server OAuth test * test(claude-cli): add proof console.log to real HTTP server test * test(claude-cli): keep real HTTP OAuth proof output explicit * fix(agents): include Claude CLI reauth step * test(claude-cli): expect full cli reauth command * test(claude-cli): align reauth hint with terminal wording * chore: retrigger claude-cli CI after opengrep fetch failure
1 parent f92ed16 commit fa84741

4 files changed

Lines changed: 277 additions & 1 deletion

File tree

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

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
* Verifies typed and message-based classification plus sanitized login command
44
* generation.
55
*/
6+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
7+
import type { AddressInfo } from "node:net";
68
import { describe, expect, it } from "vitest";
9+
import { FailoverError } from "../failover-error.js";
710
import {
811
buildOAuthRefreshFailureLoginCommand,
912
classifyOAuthRefreshFailure,
@@ -85,4 +88,135 @@ describe("oauth refresh failure hints", () => {
8588
reason: "token_invalidated",
8689
});
8790
});
91+
92+
it("classifies claude-cli subprocess 401 OAuth expiry as a provider refresh failure", () => {
93+
// Error message format emitted by the claude subprocess when its stored
94+
// OAuth token has expired, forwarded through the FailoverError message.
95+
const claudeCliFailureMessage =
96+
"Provider claude-cli failed: Failed to authenticate. API Error: 401 Invalid authentication credentials";
97+
expect(classifyOAuthRefreshFailure(claudeCliFailureMessage)).toEqual({
98+
provider: "claude-cli",
99+
reason: "revoked",
100+
});
101+
expect(buildOAuthRefreshFailureLoginCommand("claude-cli")).toBe(
102+
"claude auth login && openclaw models auth login --provider anthropic --method cli",
103+
);
104+
});
105+
106+
it("classifies structured claude-cli 401 failures even when the display message omits the provider", () => {
107+
const error = new FailoverError(
108+
"Failed to authenticate. API Error: 401 Invalid authentication credentials",
109+
{
110+
reason: "auth",
111+
provider: "claude-cli",
112+
model: "claude-sonnet-4-20250514",
113+
status: 401,
114+
},
115+
);
116+
117+
expect(classifyOAuthRefreshFailureError(error)).toEqual({
118+
provider: "claude-cli",
119+
reason: "revoked",
120+
});
121+
});
122+
123+
it("does not classify a 401 auth failure without claude-cli prefix as a refresh failure", () => {
124+
// A generic 401 from another provider should NOT be treated as an OAuth
125+
// refresh failure — it lacks the "claude-cli" provider prefix.
126+
const otherProviderMessage =
127+
"Provider openai failed: Failed to authenticate. API Error: 401 Unauthorized";
128+
expect(classifyOAuthRefreshFailure(otherProviderMessage)).toBeNull();
129+
});
130+
});
131+
132+
type LoopbackHandler = (request: IncomingMessage, response: ServerResponse) => void;
133+
134+
async function withLoopbackServer<T>(
135+
handler: LoopbackHandler,
136+
run: (baseUrl: string) => Promise<T>,
137+
): Promise<T> {
138+
const server = createServer(handler);
139+
await new Promise<void>((resolve, reject) => {
140+
server.once("error", reject);
141+
server.listen(0, "127.0.0.1", () => resolve());
142+
});
143+
const { port } = server.address() as AddressInfo;
144+
try {
145+
return await run(`http://127.0.0.1:${port}`);
146+
} finally {
147+
await new Promise<void>((resolve, reject) => {
148+
server.close((error) => {
149+
if (error) {
150+
reject(error);
151+
return;
152+
}
153+
resolve();
154+
});
155+
});
156+
}
157+
}
158+
159+
async function fetchClaudeCliOAuthProbe(baseUrl: string): Promise<{ ok: true; body: unknown }> {
160+
const response = await fetch(`${baseUrl}/oauth-expiry`);
161+
const body = await response.text();
162+
if (!response.ok) {
163+
const rawError = body.trim() || `HTTP ${response.status}`;
164+
const failure = new FailoverError(rawError, {
165+
reason: "auth",
166+
provider: "claude-cli",
167+
model: "claude-sonnet-4-20250514",
168+
status: response.status,
169+
rawError,
170+
});
171+
const oauthFailure =
172+
classifyOAuthRefreshFailureError(failure) ?? classifyOAuthRefreshFailure(failure.message);
173+
if (oauthFailure?.reason) {
174+
const command = buildOAuthRefreshFailureLoginCommand(oauthFailure.provider);
175+
throw new Error(
176+
`Model login expired on the gateway for ${oauthFailure.provider}. Re-auth with \`${command}\`, then try again.`,
177+
{ cause: failure },
178+
);
179+
}
180+
throw failure;
181+
}
182+
return { ok: true, body: JSON.parse(body) };
183+
}
184+
185+
describe("claude-cli oauth-expiry — real HTTP server (no fetch mock)", () => {
186+
it("throws a re-auth hint when the server returns 401", async () => {
187+
await withLoopbackServer(
188+
(_request, response) => {
189+
response.writeHead(401, { "content-type": "text/plain" });
190+
response.end("Failed to authenticate. API Error: 401 Invalid authentication credentials");
191+
},
192+
async (baseUrl) => {
193+
const error = await fetchClaudeCliOAuthProbe(baseUrl).then(
194+
() => undefined,
195+
(caught: unknown) => (caught instanceof Error ? caught : new Error(String(caught))),
196+
);
197+
expect(error?.message).toContain(
198+
"Re-auth with `claude auth login && openclaw models auth login --provider anthropic --method cli`",
199+
);
200+
console.log(
201+
`[claude-cli-oauth-proof] server=401 → re-auth hint surfaced: ${error?.message}`,
202+
);
203+
},
204+
);
205+
});
206+
207+
it("works normally when the server returns 200", async () => {
208+
await withLoopbackServer(
209+
(_request, response) => {
210+
response.writeHead(200, { "content-type": "application/json" });
211+
response.end(JSON.stringify({ provider: "claude-cli", ok: true }));
212+
},
213+
async (baseUrl) => {
214+
await expect(fetchClaudeCliOAuthProbe(baseUrl)).resolves.toEqual({
215+
ok: true,
216+
body: { provider: "claude-cli", ok: true },
217+
});
218+
console.log("[claude-cli-oauth-proof] server=200 → normal response returned");
219+
},
220+
);
221+
});
88222
});

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

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ type OAuthRefreshFailure = {
2222
reason: OAuthRefreshFailureReason | null;
2323
};
2424

25+
type StructuredClaudeCliAuthFailure = {
26+
provider?: unknown;
27+
rawError?: unknown;
28+
reason?: unknown;
29+
status?: unknown;
30+
};
31+
2532
/** Error type that carries provider and classified OAuth refresh failure reason. */
2633
export class OAuthRefreshFailureError extends Error {
2734
readonly provider: string;
@@ -39,17 +46,65 @@ export class OAuthRefreshFailureError extends Error {
3946

4047
const OAUTH_REFRESH_FAILURE_PROVIDER_RE = /OAuth token refresh failed for ([^:]+):/i;
4148
const SAFE_PROVIDER_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
49+
// Matches the error surfaced via FailoverError when the `claude` subprocess
50+
// has an expired/invalid OAuth token. The message always includes the
51+
// "claude-cli" provider prefix (injected by the failover layer) and the
52+
// literal 401 status plus Anthropic's "Invalid authentication credentials"
53+
// phrase, so the pattern is narrow enough to avoid false-positives from
54+
// unrelated provider 401 failures.
55+
const CLAUDE_CLI_AUTH_FAILURE_RE =
56+
/\bclaude-cli\b.+?\b(failed to authenticate|401\s+invalid authentication credentials)\b/is;
57+
58+
function isClaudeCliExpiredOAuthMessage(message: string): boolean {
59+
return CLAUDE_CLI_AUTH_FAILURE_RE.test(message);
60+
}
61+
62+
function readStructuredClaudeCliAuthFailure(err: unknown): StructuredClaudeCliAuthFailure | null {
63+
if (!err || typeof err !== "object") {
64+
return null;
65+
}
66+
const candidate = err as StructuredClaudeCliAuthFailure & { name?: unknown };
67+
if (
68+
candidate.name !== "FailoverError" ||
69+
candidate.provider !== "claude-cli" ||
70+
candidate.reason !== "auth" ||
71+
candidate.status !== 401
72+
) {
73+
return null;
74+
}
75+
return candidate;
76+
}
77+
78+
function isStructuredClaudeCliExpiredOAuthFailure(err: unknown): boolean {
79+
const failure = readStructuredClaudeCliAuthFailure(err);
80+
if (!failure) {
81+
return false;
82+
}
83+
const rawError = typeof failure.rawError === "string" ? failure.rawError : "";
84+
const message = err instanceof Error ? err.message : "";
85+
const combined = `${message}\n${rawError}`;
86+
const lower = combined.toLowerCase();
87+
return (
88+
lower.includes("failed to authenticate") || lower.includes("invalid authentication credentials")
89+
);
90+
}
4291

4392
function isOAuthRefreshFailureMessage(message: string): boolean {
4493
const lower = message.toLowerCase();
4594
return (
4695
lower.includes("oauth token refresh failed") ||
4796
lower.includes("access token could not be refreshed") ||
48-
lower.includes("authentication session could not be refreshed automatically")
97+
lower.includes("authentication session could not be refreshed automatically") ||
98+
isClaudeCliExpiredOAuthMessage(message)
4999
);
50100
}
51101

52102
function extractOAuthRefreshFailureProvider(message: string): string | null {
103+
if (isClaudeCliExpiredOAuthMessage(message)) {
104+
// The message was produced by the claude-cli subprocess; the provider is
105+
// statically known — no need to parse it from the error text.
106+
return "claude-cli";
107+
}
53108
const provider = message.match(OAUTH_REFRESH_FAILURE_PROVIDER_RE)?.[1]?.trim();
54109
return provider && provider.length > 0 ? provider : null;
55110
}
@@ -100,6 +155,13 @@ export function classifyOAuthRefreshFailureReason(
100155
if (lower.includes("expired or revoked") || lower.includes("revoked")) {
101156
return "revoked";
102157
}
158+
if (isClaudeCliExpiredOAuthMessage(message)) {
159+
// The claude subprocess emits "401 Invalid authentication credentials"
160+
// when its stored OAuth token has expired. Map this to "revoked" so the
161+
// caller surfaces the targeted re-auth hint rather than the generic login
162+
// failure copy.
163+
return "revoked";
164+
}
103165
return null;
104166
}
105167

@@ -119,6 +181,12 @@ export function classifyOAuthRefreshFailureError(err: unknown): OAuthRefreshFail
119181
const seen = new Set<object>();
120182
let candidate = err;
121183
while (candidate && typeof candidate === "object") {
184+
if (isStructuredClaudeCliExpiredOAuthFailure(candidate)) {
185+
return {
186+
provider: "claude-cli",
187+
reason: "revoked",
188+
};
189+
}
122190
if (candidate instanceof OAuthRefreshFailureError) {
123191
const profileId = sanitizeOAuthRefreshFailureProfileId(candidate.profileId);
124192
return {
@@ -143,6 +211,18 @@ export function buildOAuthRefreshFailureLoginCommand(
143211
): string {
144212
const sanitizedProvider = sanitizeOAuthRefreshFailureProvider(provider);
145213
const sanitizedProfileId = sanitizeOAuthRefreshFailureProfileId(options?.profileId);
214+
if (sanitizedProvider === "claude-cli") {
215+
// claude-cli is not a standalone provider id; it is the Anthropic provider
216+
// accessed via the CLI auth method. Refresh the local Claude CLI session
217+
// first, then re-register that auth method with OpenClaw.
218+
const claudeLoginCommand = formatCliCommand("claude auth login");
219+
const openclawLoginCommand = formatCliCommand(
220+
sanitizedProfileId
221+
? `openclaw models auth login --provider anthropic --method cli --profile-id ${quoteShellArg(sanitizedProfileId)}`
222+
: "openclaw models auth login --provider anthropic --method cli",
223+
);
224+
return `${claudeLoginCommand} && ${openclawLoginCommand}`;
225+
}
146226
return sanitizedProvider
147227
? formatCliCommand(
148228
sanitizedProfileId

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7691,6 +7691,60 @@ describe("runAgentTurnWithFallback", () => {
76917691
}
76927692
});
76937693

7694+
it("surfaces claude-cli re-auth hint over generic provider auth copy for 401 OAuth expiry", async () => {
7695+
// When the claude subprocess emits a 401 "Failed to authenticate" because
7696+
// its OAuth token has expired, the error is wrapped as a FailoverError with
7697+
// reason:"auth" and status:401. Without the ordering fix, this would be
7698+
// caught by classifyProviderRequestError before reaching classifyOAuthRefreshFailure,
7699+
// producing the generic "re-authenticate this provider" copy instead of the
7700+
// targeted claude-cli re-auth command.
7701+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
7702+
new FailoverError(
7703+
"Provider claude-cli failed: Failed to authenticate. API Error: 401 Invalid authentication credentials",
7704+
{
7705+
reason: "auth",
7706+
provider: "claude-cli",
7707+
model: "claude-sonnet-4-20250514",
7708+
status: 401,
7709+
},
7710+
),
7711+
);
7712+
7713+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
7714+
const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams());
7715+
7716+
expect(result.kind).toBe("final");
7717+
if (result.kind === "final") {
7718+
expect(result.payload.text).toBe(
7719+
"⚠️ Model login expired on the gateway for claude-cli. Re-auth with `claude auth login && openclaw models auth login --provider anthropic --method cli` in a terminal, then try again.",
7720+
);
7721+
}
7722+
});
7723+
7724+
it("surfaces claude-cli re-auth hint from structured provider metadata when the message omits claude-cli", async () => {
7725+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
7726+
new FailoverError(
7727+
"Failed to authenticate. API Error: 401 Invalid authentication credentials",
7728+
{
7729+
reason: "auth",
7730+
provider: "claude-cli",
7731+
model: "claude-sonnet-4-20250514",
7732+
status: 401,
7733+
},
7734+
),
7735+
);
7736+
7737+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
7738+
const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams());
7739+
7740+
expect(result.kind).toBe("final");
7741+
if (result.kind === "final") {
7742+
expect(result.payload.text).toBe(
7743+
"⚠️ Model login expired on the gateway for claude-cli. Re-auth with `claude auth login && openclaw models auth login --provider anthropic --method cli` in a terminal, then try again.",
7744+
);
7745+
}
7746+
});
7747+
76947748
it("surfaces direct provider auth guidance for missing API keys", async () => {
76957749
state.runEmbeddedAgentMock.mockRejectedValueOnce(
76967750
new Error(

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,11 @@ function buildExternalRunFailureReply(
981981
const message = typeof input === "string" ? input : input.message;
982982
const error = typeof input === "string" ? undefined : input.error;
983983
const normalizedMessage = collapseRepeatedFailureDetail(message);
984+
// OAuth refresh failure is classified before the generic provider-auth check:
985+
// a FailoverError with reason:"auth" and status:401 (e.g. from the claude-cli
986+
// subprocess when its stored OAuth token has expired) would otherwise match
987+
// classifyProviderRequestError and surface the generic provider-auth copy
988+
// instead of the targeted re-auth command for the affected provider.
984989
const oauthRefreshFailure =
985990
classifyOAuthRefreshFailureError(error) ?? classifyOAuthRefreshFailure(normalizedMessage);
986991
if (oauthRefreshFailure) {
@@ -3306,6 +3311,9 @@ async function runAgentTurnWithFallbackInternal(
33063311
: isBillingErrorMessage(message);
33073312
const isContextOverflow = !isBilling && isLikelyContextOverflowError(message);
33083313
const isCompactionFailure = !isBilling && isCompactionFailureError(message);
3314+
// OAuth/auth-profile failures must reach buildExternalRunFailureReply so
3315+
// the targeted re-auth/failover copy is surfaced instead of the generic
3316+
// provider-auth message.
33093317
const oauthRefreshFailure =
33103318
classifyOAuthRefreshFailureError(err) ?? classifyOAuthRefreshFailure(message);
33113319
const hasAuthProfileFailoverFailure = buildAuthProfileFailoverFailureText(err) !== null;

0 commit comments

Comments
 (0)