Compare commits

...

1 Commits

Author SHA1 Message Date
Ben Brandt
fccaebcf87 fix: detect "Run /login" login-expired messages as authRequired (#1481)
The CLI now reports an expired login (no refresh token) as:

Login expired · Run /login to sign in again, or re-authenticate your
Anthropic profile

and tags the synthetic assistant message `error: "invalid_request"`, not
`authentication_failed`. The adapter only recognised the exact phrase
"Please run /login", so this message streamed to the client as agent
text and the turn failed with a generic internal error instead of
`RequestError.authRequired()`. Clients never learned auth was required,
so they couldn't offer their terminal auth methods. "Not logged in · Run
/login" had the same gap.

Changes:
- Add `isLoginRequiredText()`, which matches "run /login"
case-insensitively, and use it in `isSyntheticLoginMessage()` (live loop
and replay) and the two result-text checks.
- Exclude synthetic usage-limit banners from the login match, since some
mention `/login` to switch accounts and have their own handling.

Tests cover both new wordings, the usage-limit exclusion, and an
end-to-end turn where the `invalid_request`-tagged "Login expired"
message must reject with `authRequired` and not leak "/login" text to
the client.
2026-10-29 13:17:53 +00:00
2 changed files with 81 additions and 8 deletions

View File

@@ -2319,6 +2319,17 @@ function isTaskNotificationRecord(message: unknown): boolean {
return origin?.kind === "task-notification" && origin.subkind === undefined;
}
/**
* True when CLI error text tells the user to sign in with `/login`. The CLI
* words this several ways — "Not logged in · Please run /login", "Not logged
* in · Run /login", "Login expired · Run /login to sign in again, or
* re-authenticate your Anthropic profile" — so match the "run /login"
* instruction rather than one sentence.
*/
export function isLoginRequiredText(text: string): boolean {
return /\brun \/login\b/i.test(text);
}
/**
* True for the synthetic assistant message the CLI injects into the transcript
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
@@ -2331,9 +2342,13 @@ function isTaskNotificationRecord(message: unknown): boolean {
* session/load even after the user has logged back in.
*
* Takes the API message (`message.message`), which replay only knows as
* `unknown`. The persisted record's structured `error: "authentication_failed"`
* marker is stripped by `getSessionMessages`, so the synthetic model + text is
* all both paths have to match on.
* `unknown`. The persisted record's structured `error` marker is stripped by
* `getSessionMessages`, and the CLI does not always set it to
* `authentication_failed` anyway: an expired login with no refresh token
* ("Login expired · Run /login to sign in again, …") arrives as
* `invalid_request`. So the synthetic model + text is what both paths match on.
* Usage-limit banners are excluded because some of them also mention `/login`
* (to switch accounts) and have their own handling.
*/
export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
if (!apiMessage || typeof apiMessage !== "object") {
@@ -2348,7 +2363,8 @@ export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
!!block &&
block.type === "text" &&
typeof block.text === "string" &&
block.text.includes("Please run /login")
isLoginRequiredText(block.text) &&
!isSyntheticUsageLimitMessage(apiMessage)
);
}
@@ -7105,7 +7121,7 @@ export class ClaudeAcpAgent {
message.stop_reason === "refusal" ||
("result" in message &&
message.is_error &&
message.result.includes("Please run /login"));
isLoginRequiredText(message.result));
if (goalCommandFailed) {
await this.publishGoal(params.sessionId, pendingGoalUpdate.previous ?? null);
}
@@ -7335,7 +7351,7 @@ export class ClaudeAcpAgent {
// drained hold at their results is as good as the idle
// fallback.) Then stop: everything below is user-turn
// lifecycle, and an autonomous outcome must never touch it —
// its is_error or "Please run /login" text would otherwise
// its is_error or "run /login" text would otherwise
// failActive a live turn (the held one, or the user's next
// prompt) whose own result recorded a different outcome.
if (isAutonomousResult) {
@@ -7589,7 +7605,7 @@ export class ClaudeAcpAgent {
switch (message.subtype) {
case "success": {
if (message.is_error && message.result.includes("Please run /login")) {
if (message.is_error && isLoginRequiredText(message.result)) {
await failActiveWithSessionFailure(
"auth_required",
RequestError.authRequired(),
@@ -9678,7 +9694,7 @@ export class ClaudeAcpAgent {
replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
}
// The live prompt loop converts the synthetic "Please run /login"
// The live prompt loop converts the synthetic "run /login"
// assistant message into an authRequired error instead of showing its
// TUI-specific text; skip it on replay too (issue #863).
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {

View File

@@ -2629,6 +2629,15 @@ describe("synthetic login message (issue #863)", () => {
content: [{ type: "text", text: "Session expired. Please run /login to sign in again." }],
}),
).toBe(true);
// Newer CLI wording that drops "Please".
for (const text of [
"Not logged in · Run /login",
"Login expired · Run /login to sign in again, or re-authenticate your Anthropic profile",
]) {
expect(
isSyntheticLoginMessage({ ...syntheticLoginApiMessage, content: [{ type: "text", text }] }),
).toBe(true);
}
});
it("isSyntheticLoginMessage does not match real assistant messages", () => {
@@ -2646,6 +2655,19 @@ describe("synthetic login message (issue #863)", () => {
content: [{ type: "text", text: "API Error: 500 Internal Server Error" }],
}),
).toBe(false);
// Usage-limit banners can mention /login (to switch accounts) but are
// handled as usage limits, not as a sign-out.
expect(
isSyntheticLoginMessage({
...syntheticLoginApiMessage,
content: [
{
type: "text",
text: "You've hit your limit · resets 5pm · run /login to switch to an API usage-billed account.",
},
],
}),
).toBe(false);
expect(isSyntheticLoginMessage(undefined)).toBe(false);
expect(isSyntheticLoginMessage("Not logged in · Please run /login")).toBe(false);
});
@@ -12100,6 +12122,41 @@ describe("stop reason propagation", () => {
expect(logged.join("\n")).not.toContain("cannot fail active turn");
});
it("rejects an expired login the CLI reports as invalid_request with authRequired", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) => {
updates.push(update);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, {});
// An expired token with no refresh token: the CLI tags this synthetic
// message `invalid_request`, not `authentication_failed`.
const text =
"Login expired · Run /login to sign in again, or re-authenticate your Anthropic profile";
const loginExpired = createAssistantError("invalid_request");
loginExpired.message.model = "<synthetic>";
loginExpired.message.content = [{ type: "text", text, citations: null }] as any;
injectSession(agent, [
loginExpired,
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: text,
}),
]);
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
).rejects.toMatchObject({ code: RequestError.authRequired().code });
await agent.sessions["test-session"]?.consumer;
expect(JSON.stringify(updates)).not.toContain("/login");
});
it("does not treat a successful answer mentioning /login as auth_required", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(