feat(auth): refuse claude.ai subscriptions under --hide-claude-auth (#1079)

Stacked on #1071. The first three commits are that PR; review the last
commit here.

## What

`--hide-claude-auth` hid the claude.ai login method from `initialize`,
but nothing stopped a session from billing a subscription that was
already stored. This moves the decision into `src/hide-claude-auth.ts`
with two layers:

1. Session creation refuses an account that holds no credential this
integration accepts, so a logged-out session never exists.
2. A sign-out during the session ends the query. The next turn recreates
it, so the next `initialize` reports the real account.

A per-turn guard repeats the check on the account the SDK cached at
`initialize`. It reads no process and costs microseconds. It covers a
session created under a provider override that `providers/disable` later
removed.

"Bills a subscription" is an allowlist of `apiKeySource` values that
outrank a stored subscription, so an unknown SDK value keeps the guard
on. The guard fails open when the account cannot be read at all, and
warns once per session.

## Client-visible additions (all additive)

- The `authRequired` JSON-RPC error carries `data.reason:
"claude_subscription_not_supported"` when the guard blocks a turn. Same
error code and same message string as before. A plain sign-out still
carries no `data`.
- `PublishedSessionFailure` gains an optional `reason`, emitted in the
failure `_meta` only when set. `hasActiveSessionError` matches on it, so
the subscription refusal and a plain sign-out cannot suppress each
other.

## Behavior changes not behind the flag

- `providers/set` and `providers/disable` no longer reject the whole
call when one session cannot be recreated. The session gets a
per-session failure row, the switch goes on to the next session. The
affected session is the one signal for that case; AIR never reaches it,
because it sends `providers/set` only while a pooled client is created,
before any session exists.
- `authenticate` validates a gateway payload that is present. A call
with no `_meta.gateway` keeps its historical meaning: no override,
success.
- A failed session creation closes the query it spawned instead of
leaking the CLI process.

## Compatibility

Checked against Zed and the JetBrains clients (`plugins/llm`, AIR main
and stable). No consumer sends a gateway `authenticate` without a
payload, none passes `--hide-claude-auth` by default, every one already
handles the `auth_required` JSON-RPC rejection, and every `_meta` /
error `data` parser ignores unknown fields.

## Test plan

- `npm run build`, `npm run check`: clean.
- `npm run test:run`: 1153 passed, 27 skipped.
- New tests in `src/tests/authorization.test.ts` cover the guard end to
end: creation refusal, per-turn refusal, sign-out respawn, provider
override on/off, gateway payload validation, degraded guard.

## Follow-up commit in this PR

- `fix(auth): start a fresh query when a signed-out session cannot be
resumed` — when the CLI never persisted the session, `resume` answers
`resourceNotFound`; the respawn now starts a fresh query under the same
ACP session id instead of failing every later prompt.

Tests after it: 1156 passed, 27 skipped.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dmitry Kozhevnikov
2026-09-04 12:56:28 +03:00
committed by GitHub
parent ea7076c0bc
commit da9f7e5695
5 changed files with 2262 additions and 272 deletions

File diff suppressed because it is too large Load Diff

294
src/hide-claude-auth.ts Normal file
View File

@@ -0,0 +1,294 @@
/**
* `--hide-claude-auth`: this integration must never bill a claude.ai
* subscription. The flag hides the claude.ai login method from `initialize`
* and makes the agent behave like a logged-out CLI whenever a subscription
* would pay for a turn. `acp-agent.ts` decides *when* the guard applies (flag
* on, no provider override) and calls in here for the *what*.
*
* The account the CLI reports at `initialize` is the source of truth. The SDK
* memoizes it, so it is a constant for the life of a query, and two layers
* keep it honest:
*
* 1. Session creation refuses an account that holds no credential this
* integration accepts. A logged-out session never exists, so the client's
* sign-in always leads to a new session with a fresh `initialize`. The
* per-turn guard repeats the check on the same cached account, which costs
* microseconds and covers a session created under a provider override that
* `providers/disable` later removed.
* 2. A sign-out during the session ends the query. The next turn recreates it,
* so the next `initialize` reports the real account and layer 1 judges it.
* The recreation resumes the stored conversation. When the CLI never wrote
* one, because the first turn was the one that signed out, the recreation
* starts a fresh query under the same session id instead.
*
* One case is left open. The CLI re-reads its credential store on its own. A
* file-based credential can therefore be removed and replaced by a claude.ai
* subscription between two turns. This covers a managed Console key and a key
* from a settings helper. No turn fails in between, so nothing ends the query,
* and the cached account still names the key that is gone. Closing this case
* would cost one `initialize` control request per turn. That buys too little
* for the price.
*/
import { RequestError } from "@agentclientprotocol/sdk";
import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import type { SessionFailureController } from "./session-failure-extension.js";
export function shouldHideClaudeAuth(): boolean {
return process.argv.includes("--hide-claude-auth");
}
/**
* `data.reason` carried by the `authRequired` JSON-RPC error when the flag
* blocks a turn. From the client's point of view the agent is logged out (same
* error code and flow as a signed-out CLI); the reason lets it render a
* different hint. A string enum so further reasons can be added without a
* shape change.
*/
export const CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON = "claude_subscription_not_supported";
export type AuthRequiredReason = typeof CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON;
export const CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE =
"This integration does not support using claude.ai subscriptions.";
/** The message of the plain sign-out refusal. It carries no `reason`: the
* account holds nothing this integration can bill, which is the ordinary
* signed-out state every ACP client already knows how to answer. */
export const CLAUDE_LOGIN_REQUIRED_MESSAGE =
"Sign in with an API key or a Console account to use this integration.";
/**
* The `apiKeySource` values that outrank a stored subscription.
*
* The SDK documents its `ApiKeySource` type as the origin of the credential
* used for API requests. `ANTHROPIC_API_KEY` is the environment variable,
* `apiKeyHelper` is the configured helper command, and `/login managed key` is
* a key that `/login` created with an Anthropic Console account. Each of the
* three is an API key that pays instead of the subscription. Every other
* member means no API key is in use: `none`, and the legacy `user`, `project`,
* `org`, `temporary` and `oauth` that current CLIs never emit.
*/
const API_KEY_SOURCES_ABOVE_SUBSCRIPTION: ReadonlySet<string> = new Set([
"ANTHROPIC_API_KEY",
"apiKeyHelper",
"/login managed key",
]);
/**
* True when a turn on this account is paid by a claude.ai subscription.
*
* The SDK types `apiKeySource` and `tokenSource` as an open `string`, so this
* is an allowlist. An account bills the subscription only when every field
* proves it, and an unknown value keeps the guard on.
*
* - `subscriptionType` must be set. Without it the CLI reports no subscription.
* - `tokenSource` must be absent. The CLI sets it in place of the subscription
* when a bearer or OAuth environment token pays for the turn.
* - `apiProvider` must be absent or `firstParty`. The SDK documents that the
* Anthropic OAuth login applies only to `firstParty`; for a third-party
* backend such as `bedrock`, `vertex`, `foundry` or `gateway` the other
* fields are absent and the authentication is external.
* - `apiKeySource` must not be one of {@link API_KEY_SOURCES_ABOVE_SUBSCRIPTION}.
* Claude Code's credential precedence puts those keys above the `/login`
* subscription, so the key pays and the stored subscription is inert.
*/
export function billsClaudeSubscription(account: AccountInfo | undefined): boolean {
if (!account?.subscriptionType) {
return false;
}
if (account.tokenSource) {
return false;
}
if (account.apiProvider !== undefined && account.apiProvider !== "firstParty") {
return false;
}
return !API_KEY_SOURCES_ABOVE_SUBSCRIPTION.has(account.apiKeySource ?? "");
}
/**
* True when the account already holds a credential this integration accepts:
* a third-party backend, a bearer or OAuth environment token, or one of the
* API key sources in {@link API_KEY_SOURCES_ABOVE_SUBSCRIPTION}.
*
* False therefore covers both the signed-out CLI and the account whose only
* credential is a claude.ai subscription. {@link billsClaudeSubscription} runs
* first and separates the two, so a caller that reaches a `false` here is
* looking at a session with no way to pay.
*/
export function holdsNonSubscriptionCredential(account: AccountInfo | undefined): boolean {
if (!account) {
return false;
}
if (account.apiProvider !== undefined && account.apiProvider !== "firstParty") {
return true;
}
if (account.tokenSource) {
return true;
}
return API_KEY_SOURCES_ABOVE_SUBSCRIPTION.has(account.apiKeySource ?? "");
}
export function claudeSubscriptionNotSupportedError(): RequestError {
return RequestError.authRequired(
{ reason: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON satisfies AuthRequiredReason },
CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,
);
}
/** The signed-out refusal. Deliberately without `data`, so a client that reads
* `data.reason` sees the plain ACP sign-out it already handles. */
export function claudeLoginRequiredError(): RequestError {
return RequestError.authRequired(undefined, CLAUDE_LOGIN_REQUIRED_MESSAGE);
}
/**
* The state the guard keeps for one session. It lives on the session so that a
* concurrent pair of turns shares one account read and one failure row, and so
* that each degraded-guard warning is logged once.
*/
export type ClaudeSubscriptionGuardState = {
/** The in-flight guard run. Set before the first `await`. */
pendingRun?: Promise<RequestError | undefined>;
/** True after the "no account source" warning was logged for this session. */
degradedWarningLogged?: boolean;
};
export type GuardLogger = {
error: (...args: unknown[]) => void;
warn?: (...args: unknown[]) => void;
};
function warn(logger: GuardLogger, message: string): void {
(logger.warn ?? logger.error).call(logger, message);
}
/**
* Report once per session that the guard cannot read the account at all. The
* guard is then absent, not passing. The CLI build decides this, not the user,
* so a message on every turn would only add noise.
*/
export function warnClaudeSubscriptionGuardDegraded(options: {
sessionId: string;
guardState: ClaudeSubscriptionGuardState;
logger: GuardLogger;
cause: string;
}): void {
const { guardState } = options;
if (guardState.degradedWarningLogged) {
return;
}
guardState.degradedWarningLogged = true;
warn(
options.logger,
`Session ${options.sessionId}: the --hide-claude-auth subscription guard is degraded ` +
`(${options.cause}). This session cannot detect a claude.ai subscription.`,
);
}
type GuardOptions = {
sessionId: string;
query: Pick<Query, "accountInfo">;
guardState: ClaudeSubscriptionGuardState;
/** Built lazily: most turns pass and never need a controller. */
sessionFailures: () => SessionFailureController;
logger: GuardLogger;
};
/**
* The per-turn guard for a live session.
*
* It decides on the account the SDK cached at `initialize`, which is this
* integration's source of truth. That account is a constant for the life of
* the query, so the check costs microseconds. It still earns its place: it
* covers a session created under a provider override that `providers/disable`
* later removed, so the create-time check never ran for the routing in force
* now. A sign-out ends the query (see `markSessionForSignOutRespawn` in
* `acp-agent.ts`), so the next turn recreates it and `initialize` reports the
* real account. This guard does not see the residual case the module header
* names either.
*
* A turn is refused when the account bills a subscription, and also when it
* holds nothing this integration accepts, which is the user signing out in
* mid-session. The refusal mirrors the signed-out path exactly (see
* `failActiveWithSessionFailure` for `auth_required` in `acp-agent.ts`): every
* client gets the `authRequired` JSON-RPC rejection that starts its own auth
* flow, and a capable client additionally gets one session-scoped access
* failure whose `login` action stays the way back in.
*
* Fails open when no account can be read at all: session creation already
* refused every account without a usable credential, and an unavailable probe
* must not block a session that passed that check.
*/
export async function refuseClaudeSubscriptionTurn(options: GuardOptions): Promise<void> {
// Concurrent turns must not each re-read the account and each publish a row.
// Store the run before the first await, and let the others await the same one.
const { guardState } = options;
const run = (guardState.pendingRun ??= evaluateGuard(options).finally(() => {
if (guardState.pendingRun === run) {
guardState.pendingRun = undefined;
}
}));
const refusal = await run;
if (refusal) {
throw refusal;
}
}
/** Read the account, publish at most one failure row, and return the error the
* turn must be rejected with. Never throws: an unreadable account fails open. */
async function evaluateGuard(options: GuardOptions): Promise<RequestError | undefined> {
const account = await readGuardAccount(options);
if (!account) {
return undefined;
}
if (billsClaudeSubscription(account)) {
await publishRefusal(options.sessionFailures(), {
details: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,
reason: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON,
});
return claudeSubscriptionNotSupportedError();
}
if (!holdsNonSubscriptionCredential(account)) {
await publishRefusal(options.sessionFailures(), { details: CLAUDE_LOGIN_REQUIRED_MESSAGE });
return claudeLoginRequiredError();
}
return undefined;
}
/** The account the SDK cached at `initialize`. `undefined` when the query
* cannot answer at all. */
async function readGuardAccount(options: GuardOptions): Promise<AccountInfo | undefined> {
const { query } = options;
if (typeof query.accountInfo !== "function") {
warnClaudeSubscriptionGuardDegraded({
...options,
cause: "the SDK query has no accountInfo method",
});
return undefined;
}
try {
return await query.accountInfo();
} catch (error) {
options.logger.error(
`Session ${options.sessionId}: accountInfo failed; skipping the subscription check for this turn:`,
error instanceof Error ? error.message : String(error),
);
return undefined;
}
}
/** The refusal state does not change between turns: publish once, and skip a
* republish while the previous failure with this reason is still active. */
async function publishRefusal(
sessionFailures: SessionFailureController,
failure: { details: string; reason?: string },
): Promise<void> {
if (sessionFailures.hasActiveSessionError("auth_required", failure.reason)) {
return;
}
await sessionFailures.publish("auth_required", {
sessionScoped: true,
details: failure.details,
...(failure.reason ? { reason: failure.reason } : {}),
});
}

View File

@@ -48,6 +48,10 @@ export type PublishedSessionFailure = {
severity: AirSessionFailureSeverity;
title: string;
details?: string;
/** A machine-readable refinement of `kind`, e.g. the `--hide-claude-auth`
* subscription guard's reason on an `auth_required` failure. Absent for a
* plain sign-out. */
reason?: string;
actions: AirSessionFailureAction[];
recoveryPolicy: SessionFailureRecoveryPolicy;
};
@@ -58,6 +62,7 @@ type SessionFailureOptions = {
title?: string;
details?: string;
severity?: AirSessionFailureSeverity;
reason?: string;
};
export type SessionFailureState = {
@@ -158,6 +163,7 @@ export function sessionFailureMeta(failure: PublishedSessionFailure) {
severity: failure.severity,
title: failure.title,
...(failure.details ? { details: failure.details } : {}),
...(failure.reason ? { reason: failure.reason } : {}),
actions: failure.actions,
});
}
@@ -296,6 +302,29 @@ export class SessionFailureController {
}
}
/** Whether a session-scoped error of `kind` is active. A turn-scoped
* warning of the same kind (an `api_retry` notice) does not count: it
* reports a retry in progress, not the state the error would publish.
*
* `reason` refines `kind`, so the active record must carry the same one,
* and an omitted `reason` matches only a record with no reason. A plain
* sign-out and the `--hide-claude-auth` subscription refusal are both
* `auth_required`, but they tell the user different things. Neither may
* suppress the other. */
hasActiveSessionError(kind: ClaudeFailureKind, reason?: string): boolean {
for (const failure of this.state.active.values()) {
if (
failure.kind === kind &&
failure.severity === "error" &&
failure.turnId === undefined &&
failure.reason === reason
) {
return true;
}
}
return false;
}
recordActive(failure: PublishedSessionFailure): void {
this.state.revisions.set(failure.id, failure.revision);
this.state.active.set(failure.id, failure);
@@ -357,6 +386,7 @@ export class SessionFailureController {
severity: failureOptions.severity ?? "error",
title,
...(failureOptions.details ? { details: failureOptions.details } : {}),
...(failureOptions.reason ? { reason: failureOptions.reason } : {}),
actions: failureOptions.severity === "warning" ? [] : policy.actions,
recoveryPolicy: sessionFailureRecoveryPolicy(
kind,

View File

@@ -6255,7 +6255,6 @@ describe("stop reason propagation", () => {
});
it.each([
["authentication_failed", "access"],
["billing_error", "limit"],
["account_on_hold", "limit"],
["rate_limit", "limit"],
@@ -6392,6 +6391,52 @@ describe("stop reason propagation", () => {
expect(JSON.stringify(updates)).not.toContain("sessionFailure");
});
const sessionFailuresFromUpdates = (updates: SessionNotification[]) =>
updates.map((u) => (u.update as any)?._meta?.jetbrains?.air?.sessionFailure).filter(Boolean);
it("rejects authentication_failed and publishes a session-scoped access failure", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) => {
updates.push(update);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
(agent as any).clientCapabilities = airSessionFailureCapabilities;
injectSession(agent, [
createAssistantError("authentication_failed", "Claude request failed."),
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "Claude request failed.",
}),
]);
// The auth flow hangs off the JSON-RPC rejection (the client parks the
// prompt and runs its own sign-in), so the turn is rejected even for
// capable clients; the signed-out state travels separately as one
// session-scoped failure with a client-neutral title.
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
).rejects.toThrow();
await agent.sessions["test-session"]?.consumer;
const failures = sessionFailuresFromUpdates(updates);
expect(failures).toHaveLength(1);
expect(failures[0]).toEqual(
expect.objectContaining({
category: "access",
severity: "error",
title: "Sign in to continue using Claude.",
details: "Claude request failed.",
actions: ["login"],
}),
);
expect(failures[0].id).toContain("session-error");
});
it("recovers auth_required internally after a successful auth_status", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
@@ -6420,22 +6465,23 @@ describe("stop reason propagation", () => {
},
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
).rejects.toThrow();
await agent.sessions["test-session"]?.consumer;
const active = sessionFailureFromResponse(response);
expect(active).toEqual(
const failures = sessionFailuresFromUpdates(updates);
expect(failures).toHaveLength(1);
expect(failures[0]).toEqual(
expect.objectContaining({
category: "access",
revision: 1,
title: "Authentication required.",
details: "Authentication required.",
}),
);
expect(JSON.stringify(updates)).not.toContain("sessionFailure");
expect(agent.sessions["test-session"].sessionFailureState.active.has(active.id)).toBe(false);
expect(JSON.stringify({ response, updates })).not.toContain("private login token");
expect(agent.sessions["test-session"].sessionFailureState.active.has(failures[0].id)).toBe(
false,
);
expect(JSON.stringify(updates)).not.toContain("private login token");
});
it("keeps auth_required active when auth_status reports an error", async () => {
@@ -6467,17 +6513,203 @@ describe("stop reason propagation", () => {
},
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
).rejects.toThrow();
await agent.sessions["test-session"]?.consumer;
expect(sessionFailureFromResponse(response)).toEqual(
expect.objectContaining({ category: "access", severity: "error" }),
const failures = sessionFailuresFromUpdates(updates);
expect(failures).toHaveLength(1);
expect(failures[0]).toEqual(expect.objectContaining({ category: "access", severity: "error" }));
expect(agent.sessions["test-session"].sessionFailureState.active.has(failures[0].id)).toBe(
true,
);
expect(JSON.stringify(updates)).not.toContain("sessionFailure");
expect(JSON.stringify({ response, updates })).not.toContain("private login token");
expect(JSON.stringify({ response, updates })).not.toContain("private login error");
expect(JSON.stringify(updates)).not.toContain("private login token");
expect(JSON.stringify(updates)).not.toContain("private login error");
});
it("does not republish auth_required when the result repeats the login text", async () => {
const updates: SessionNotification[] = [];
const logged: string[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) => {
updates.push(update);
},
} as unknown as AcpClient,
{
log: () => {},
error: (message: unknown) => {
logged.push(String(message));
},
},
);
(agent as any).clientCapabilities = airSessionFailureCapabilities;
// The CLI reports one signed-out turn twice: the synthetic login assistant
// message rejects the turn, then the result repeats the same text. One
// session-scoped failure covers both; its title is the client-neutral
// fallback while the CLI's TUI advice travels as details.
const syntheticLogin = createAssistantError("authentication_failed");
syntheticLogin.message.model = "<synthetic>";
syntheticLogin.message.content = [
{ type: "text", text: "Not logged in · Please run /login", citations: null },
] as any;
injectSession(agent, [
syntheticLogin,
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "Not logged in · Please run /login",
}),
]);
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
).rejects.toThrow();
await agent.sessions["test-session"]?.consumer;
const failures = sessionFailuresFromUpdates(updates);
expect(failures).toHaveLength(1);
expect(failures[0]).toEqual(
expect.objectContaining({
category: "access",
severity: "error",
title: "Sign in to continue using Claude.",
details: "Not logged in · Please run /login",
}),
);
expect([...agent.sessions["test-session"].sessionFailureState.active.keys()]).toEqual([
failures[0].id,
]);
// The first delivery already rejected the turn, so the second one has no
// turn to fail. That is expected here and must not be logged as a fault.
expect(logged.join("\n")).not.toContain("cannot fail active turn");
});
it("republishes the signed-out state after a real model answer cleared the last one", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) => {
updates.push(update);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
(agent as any).clientCapabilities = airSessionFailureCapabilities;
const signedOut = () => {
const message = createAssistantError("authentication_failed");
message.message.model = "<synthetic>";
message.message.content = [
{ type: "text", text: "Not logged in \u00b7 Please run /login", citations: null },
] as any;
return message;
};
const input = new Pushable<any>();
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
for (const turn of [
[signedOut()],
[
createAssistantError(undefined, "signed in again"),
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: false,
result: "signed in again",
}),
],
[signedOut()],
]) {
const next = await iter.next();
yield {
type: "user",
message: next.value.message,
parent_tool_use_id: null,
uuid: next.value.uuid,
session_id: "test-session",
isReplay: true,
};
yield* turn;
}
}
agent.sessions["test-session"] = mockSessionState({
query: wrapQuery(messageGenerator()),
input,
});
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "one" }] }),
).rejects.toThrow();
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "two" }] }),
).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
// A client can sign the user in out of band — AIR runs `claude /login` in a
// terminal — and then no `auth_status` message arrives. A real model answer
// is the proof that the session is signed in again.
expect(agent.sessions["test-session"].sessionFailureState.active.size).toBe(0);
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "three" }] }),
).rejects.toThrow();
// The next sign-out reaches the client as its own row, not as silence.
const failures = sessionFailuresFromUpdates(updates);
expect(failures).toHaveLength(2);
expect(failures[1].id).not.toEqual(failures[0].id);
expect(failures[1]).toEqual(
expect.objectContaining({ category: "access", severity: "error", actions: ["login"] }),
);
});
it("publishes no retry warning for an authentication_failed api_retry", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) => {
updates.push(update);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
(agent as any).clientCapabilities = airSessionFailureCapabilities;
// The 401 retry is the CLI's credential re-check. The sign-out that
// follows is the signal, so the retry publishes nothing and the only
// failure is the session-scoped error with the `login` action.
injectSession(agent, [
{
type: "system",
subtype: "api_retry",
attempt: 1,
max_retries: 10,
retry_delay_ms: 100,
error_status: 401,
error: "authentication_failed",
uuid: randomUUID(),
session_id: "test-session",
},
createAssistantError("authentication_failed", "Claude request failed."),
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "Claude request failed.",
}),
]);
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
).rejects.toThrow();
await agent.sessions["test-session"]?.consumer;
const failures = sessionFailuresFromUpdates(updates);
expect(failures).toHaveLength(1);
expect(failures[0]).toEqual(
expect.objectContaining({
category: "access",
severity: "error",
title: "Sign in to continue using Claude.",
actions: ["login"],
}),
);
expect(failures[0].id).toContain("session-error");
});
it("keeps the historical failure record unchanged after recovery", async () => {

File diff suppressed because it is too large Load Diff