diff --git a/src/agents/auth-profiles/oauth-refresh-failure.test.ts b/src/agents/auth-profiles/oauth-refresh-failure.test.ts index 87d0e3d809dc..cba6a936450f 100644 --- a/src/agents/auth-profiles/oauth-refresh-failure.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-failure.test.ts @@ -120,6 +120,20 @@ describe("oauth refresh failure hints", () => { }); }); + it("classifies structured claude-cli logged-out failures without the provider prefix in the message", () => { + const error = new FailoverError("Not logged in \u00b7 Please run /login", { + reason: "auth", + provider: "claude-cli", + model: "claude-sonnet-4-20250514", + status: 401, + }); + + expect(classifyOAuthRefreshFailureError(error)).toEqual({ + provider: "claude-cli", + reason: "sign_in_again", + }); + }); + it("does not classify a 401 auth failure without claude-cli prefix as a refresh failure", () => { // A generic 401 from another provider should NOT be treated as an OAuth // refresh failure — it lacks the "claude-cli" provider prefix. diff --git a/src/agents/auth-profiles/oauth-refresh-failure.ts b/src/agents/auth-profiles/oauth-refresh-failure.ts index 183a5b6f2320..93a138409296 100644 --- a/src/agents/auth-profiles/oauth-refresh-failure.ts +++ b/src/agents/auth-profiles/oauth-refresh-failure.ts @@ -75,18 +75,24 @@ function readStructuredClaudeCliAuthFailure(err: unknown): StructuredClaudeCliAu return candidate; } -function isStructuredClaudeCliExpiredOAuthFailure(err: unknown): boolean { +function classifyStructuredClaudeCliOAuthFailureReason( + err: unknown, +): OAuthRefreshFailureReason | null { const failure = readStructuredClaudeCliAuthFailure(err); if (!failure) { - return false; + return null; } const rawError = typeof failure.rawError === "string" ? failure.rawError : ""; const message = err instanceof Error ? err.message : ""; const combined = `${message}\n${rawError}`; const lower = combined.toLowerCase(); - return ( - lower.includes("failed to authenticate") || lower.includes("invalid authentication credentials") - ); + if (/\bnot logged in\b\s*·\s*please run \/login\b/i.test(combined)) { + return "sign_in_again"; + } + const hasExpiredTokenSignal = + lower.includes("failed to authenticate") || + lower.includes("invalid authentication credentials"); + return hasExpiredTokenSignal ? "revoked" : null; } function isOAuthRefreshFailureMessage(message: string): boolean { @@ -181,10 +187,11 @@ export function classifyOAuthRefreshFailureError(err: unknown): OAuthRefreshFail const seen = new Set(); let candidate = err; while (candidate && typeof candidate === "object") { - if (isStructuredClaudeCliExpiredOAuthFailure(candidate)) { + const claudeCliReason = classifyStructuredClaudeCliOAuthFailureReason(candidate); + if (claudeCliReason) { return { provider: "claude-cli", - reason: "revoked", + reason: claudeCliReason, }; } if (candidate instanceof OAuthRefreshFailureError) { diff --git a/src/agents/embedded-agent-helpers/errors.test.ts b/src/agents/embedded-agent-helpers/errors.test.ts index 51e657c96b36..f3ef20bc8830 100644 --- a/src/agents/embedded-agent-helpers/errors.test.ts +++ b/src/agents/embedded-agent-helpers/errors.test.ts @@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../../shared/assistant-error-format.js"; import { makeAssistantMessageFixture } from "../test-helpers/assistant-message-fixtures.js"; import { + classifyFailoverReason, extractFailoverSignalDetails, formatAssistantErrorText, isLikelyContextOverflowError, @@ -23,6 +24,16 @@ vi.mock("../../logging/subsystem.js", () => ({ }), })); +describe("Claude CLI logged-out failures", () => { + const loggedOutMessage = "Not logged in · Please run /login"; + + it("classifies the logged-out response as auth only for claude-cli", () => { + expect(classifyFailoverReason(loggedOutMessage, { provider: "claude-cli" })).toBe("auth"); + expect(classifyFailoverReason(loggedOutMessage, { provider: "openai" })).toBeNull(); + expect(classifyFailoverReason(loggedOutMessage)).toBeNull(); + }); +}); + describe("formatAssistantErrorText streaming JSON parse classification", () => { beforeEach(() => { toolPolicyAuditInfo.mockClear(); diff --git a/src/agents/embedded-agent-helpers/errors.ts b/src/agents/embedded-agent-helpers/errors.ts index 6e6a4b6b0826..09341c4c7dd8 100644 --- a/src/agents/embedded-agent-helpers/errors.ts +++ b/src/agents/embedded-agent-helpers/errors.ts @@ -989,6 +989,15 @@ function isExactUnknownNoDetailsError(raw: string): boolean { ); } +function isClaudeCliLoggedOutError(raw: string, provider?: string): boolean { + // This upstream phrase is generic prose. Provider identity must come from + // the runner metadata so other providers cannot inherit Claude CLI policy. + if (normalizeOptionalLowercaseString(provider)?.trim() !== "claude-cli") { + return false; + } + return /\bnot logged in\b\s*·\s*please run \/login\b/i.test(raw); +} + function classifyFailoverClassificationFromMessage( raw: string, provider?: string, @@ -1053,6 +1062,9 @@ function classifyFailoverClassificationFromMessage( // Auth classifiers run before the broad isJsonApiInternalServerError check so that // provider errors like {"type":"api_error","message":"invalid api key"} are // correctly classified as "auth" rather than "timeout". + if (isClaudeCliLoggedOutError(raw, provider)) { + return toReasonClassification("auth"); + } const oauthRefreshFailure = classifyOAuthRefreshFailure(raw); if (oauthRefreshFailure?.reason) { return toReasonClassification("auth_permanent"); diff --git a/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts index d61c7ce6546f..4d7d68062bd4 100644 --- a/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts @@ -242,6 +242,27 @@ describe("runAgentTurnWithFallback: authentication failures", () => { } }); + it("surfaces the claude-cli re-auth hint when the CLI session is logged out", async () => { + state.runEmbeddedAgentMock.mockRejectedValueOnce( + new FailoverError("Not logged in · Please run /login", { + reason: "auth", + provider: "claude-cli", + model: "claude-sonnet-4-20250514", + status: 401, + }), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toBe( + "⚠️ 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.", + ); + } + }); + it("surfaces direct provider auth guidance for missing API keys", async () => { state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error(