mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(agents): surface claude-cli logged-out failures as actionable re-auth errors (#103773) (#103829)
When the Claude CLI session is logged out, the subprocess returns 'Not logged in - Please run /login' (verbatim -p --output-format json result on Claude Code v2.1.206, live-verified with a scratch CLAUDE_CONFIG_DIR). That text matched no auth classifier, so every message failed with the generic 'Something went wrong' copy and /new could not help. Teach the failover auth matcher and the claude-cli OAuth-failure classifiers the logged-out signature: users now get 'Model login expired on the gateway for claude-cli. Re-auth with claude auth login && openclaw models auth login --provider anthropic --method cli', reusing the targeted copy path that already handles expired CLI tokens.
This commit is contained in:
committed by
GitHub
parent
f12b4af021
commit
28323b4480
@@ -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.
|
||||
|
||||
@@ -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<object>();
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user