feat(auth): report the agent's auth identity over ACP (authStatus extension) (#1080)

This commit is contained in:
Dmitry Kozhevnikov
2026-09-04 14:55:57 +03:00
committed by GitHub
parent 847a3b6429
commit eb5ca9fd17
6 changed files with 1991 additions and 23 deletions

View File

@@ -55,6 +55,7 @@ import {
StopReason,
} from "@agentclientprotocol/sdk";
import {
AccountInfo,
AgentInfo,
CanUseTool,
deleteSession,
@@ -117,6 +118,19 @@ import {
clientSupportsAsyncTasks,
} from "./async-tasks.js";
import type { AsyncTaskStarted } from "./async-tasks.js";
import {
AUTH_STATUS_PROBE_TIMEOUT_MS,
AUTH_STATUS_UPDATE_METHOD,
type AuthStatus,
type AuthStatusKind,
authStatusCapability,
fromAccountInfo,
fromCliStatus,
gatewayAuthStatus,
mergeAuthStatus,
notLoggedInAuthStatus,
sameAuthStatus,
} from "./auth-status.js";
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
import { execFile } from "node:child_process";
@@ -969,6 +983,11 @@ export type Session = {
/** State of the `--hide-claude-auth` subscription guard for this session.
* Built on the first guarded turn; most sessions never need it. */
claudeSubscriptionGuard?: ClaudeSubscriptionGuardState;
/** Identity kind of the account this session was created on. The CLI probe
* compares its own read against it to notice that the credential behind the
* cached account was swapped. Undefined when the account carried no
* identity signal, which is "nothing to compare", not a match. */
accountKind?: AuthStatusKind;
/** Set under `--hide-claude-auth` when the CLI reported a sign-out during
* this session. The query is closed and the account it cached at
* `initialize` now describes a credential that no longer works, so the next
@@ -1760,6 +1779,25 @@ export class ClaudeAcpAgent {
/** Serializes provider changes while every open query is recreated between turns. */
private providerUpdate: Promise<void> | null = null;
private readonly exitPlan: ExitPlanCoordinator<Session, Turn>;
/** Last auth identity reported to the client, connection-scoped like
* `authenticate`/`logout`. Undefined means "not determined yet". */
currentAuthStatus?: AuthStatus;
/** In-flight `claude auth status --json` probe, shared by every caller so
* a concurrent `initialize` and start-of-prompt read never spawn two CLI
* processes. */
private cliAuthProbe: Promise<AuthStatus | undefined> | null = null;
/** Counts auth-affecting events (`authenticate`, `logout`) on this
* connection. A probe records it at start and publishes only if it has not
* moved, so a slow read can never overwrite a newer login or logout. */
private authEpoch = 0;
/** Keeps the "session account told us nothing" note to one line per process
* instead of one per session. */
private loggedUninformativeAccount = false;
/** Same, for the "client provider override active" note. */
private loggedOverriddenAccount = false;
/** Same, for the "the CLI probe timed out" warning: a wedged CLI stays wedged
* and would otherwise warn once per probe, i.e. once per user prompt. */
private loggedProbeTimeout = false;
/** Grace period before a `session/cancel` forces a wedged prompt loop to
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
* tests can shrink it. */
@@ -1819,6 +1857,13 @@ export class ClaudeAcpAgent {
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
// Learn the auth identity in the background: `initialize` never waits on
// the CLI probe, and no snapshot rides in its response. When the probe
// lands it calls `setAuthStatus`, which pushes `_auth/status_update` — the
// connection's first push, and unconditional, because nothing was reported
// before it. It is therefore sent after this response, never before it.
void this.probeCliAuthStatus();
// Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
// Only offered when the client advertises `auth._meta.gateway` capability.
const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
@@ -1943,6 +1988,11 @@ export class ClaudeAcpAgent {
claudeCode: {
promptQueueing: true,
},
// Capability marker for the `authStatus` extension: presence means
// "this agent pushes its identity" and the object stays empty — it is
// never a status payload. The state itself travels on
// `_auth/status_update`; there is nothing for a client to ask for.
authStatus: authStatusCapability(),
},
promptCapabilities: {
image: true,
@@ -2089,11 +2139,214 @@ export class ClaudeAcpAgent {
}
}
this.gatewayAuthRequest = _params as GatewayAuthRequest;
// The gateway holds the credentials from here on, so it replaces
// whatever the CLI store reported. Bumping the epoch discards any probe
// that started before this login: its answer is now stale.
this.authEpoch += 1;
this.setAuthStatus(
gatewayAuthStatus(gatewayRequestToProviderConfig(this.gatewayAuthRequest)?.baseUrl),
);
return;
}
throw new Error("Method not implemented.");
}
/**
* Stores `next` and pushes it to the client.
*
* A push goes out only when the payload changed. The identity is read on many
* occasions — each session create, each guarded turn, the start of each user
* prompt — and almost all of them see the same login. Clients replace their
* whole state on each update and tolerate duplicates, so a repeat is
* harmless, but it is also pure noise; {@link sameAuthStatus} drops it.
*
* What the agent owes is truth — a stale or uninformative source must not
* reach here at all (see the epoch check in the probe and the guards at
* session create).
*/
setAuthStatus(next: AuthStatus | undefined): void {
if (!next) {
return;
}
if (sameAuthStatus(this.currentAuthStatus, next)) {
return;
}
this.currentAuthStatus = next;
// ACP notifications get no reply, and clients that do not know the method
// drop it silently — send unconditionally and never fail a caller on it.
void Promise.resolve()
.then(() => this.client.extNotification(AUTH_STATUS_UPDATE_METHOD, { authStatus: next }))
.catch((error) => {
this.logger.error("Failed to send _auth/status_update:", error);
});
}
/**
* Report the identity behind a session's `AccountInfo`.
*
* `AccountInfo` is richer than the CLI probe (it is what the live query
* actually authenticates with). Called before the `--hide-claude-auth` guard
* may refuse the session or the turn: that flag blocks the *use* of a
* subscription, it does not make the state secret, and a refusal is when the
* client most needs to know the account it was refused for.
*
* Two cases skip it:
*
* - ACP gateway *authentication* (`gatewayAuthRequest`): the gateway, not
* this account, is the agent-owned identity — already reported as
* `kind: "gateway"` by `authenticate`.
* - A client-driven provider override (`providers/set`): the session then
* routes through the client's endpoint and `AccountInfo` describes that
* route (e.g. `apiProvider: "gateway"`), which is NOT agent-owned state.
* `authStatus` reports the agent's own login only, so the CLI probe —
* which reads the credential store the override never touches — stays
* authoritative.
*
* An account with no identity signal (e.g. `{apiProvider: "firstParty"}`
* under an apiKeyHelper) means "nothing to add", not "logged out" — keep
* what the CLI probe already established instead of overwriting it.
*/
private publishSessionAccountIdentity(account: AccountInfo | undefined): void {
if (this.providerConfig) {
if (!this.loggedOverriddenAccount) {
this.loggedOverriddenAccount = true;
this.logger.log(
"[authStatus] client provider override active; keeping agent-owned probe state",
);
}
return;
}
if (this.gatewayAuthRequest) {
return;
}
const fromSession = fromAccountInfo(account);
if (fromSession) {
this.setAuthStatus(fromSession);
} else if (!this.loggedUninformativeAccount) {
this.loggedUninformativeAccount = true;
this.logger.log("[authStatus] session account carries no identity signal; keeping probe");
}
}
/** Shares one in-flight `claude auth status --json` run between callers. The
* promise is released once settled so a later call re-probes instead of
* replaying a stale verdict. `fresh` forces a new run even when one is in
* flight — `logout` needs a read that started after the credentials were
* cleared. */
private probeCliAuthStatus(options?: { fresh?: boolean }): Promise<AuthStatus | undefined> {
if (!options?.fresh && this.cliAuthProbe) {
return this.cliAuthProbe;
}
const probe = this.runCliAuthProbe();
this.cliAuthProbe = probe;
void probe.finally(() => {
if (this.cliAuthProbe === probe) {
this.cliAuthProbe = null;
}
});
return probe;
}
/** Never rejects: an unavailable CLI means "not reported", not an error. */
private async runCliAuthProbe(): Promise<AuthStatus | undefined> {
// Monotonicity: a read that started before the connection's latest
// auth-affecting event describes a world that no longer exists. Remember
// which one this read belongs to and drop the answer if it moved on.
const epoch = this.authEpoch;
// ACP gateway auth bypasses the CLI credential store entirely, so the
// probe would report an identity that is not the one being used. A mere
// client provider override (`providers/set`) does NOT skip the probe: it
// reroutes traffic without touching the credential store, and the store is
// exactly the agent-owned login `authStatus` reports.
if (this.gatewayAuthRequest) {
return this.currentAuthStatus;
}
let stdout: string;
try {
const cliPath = await claudeCliPath();
({ stdout } = await execFileAsync(cliPath, ["auth", "status", "--json"], {
timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
}));
} catch (error) {
const failed = error as { stdout?: unknown; killed?: boolean; signal?: unknown } | null;
// Node killed the child on the timeout. Whatever it printed so far is a
// truncated fragment, never valid JSON: report nothing and keep the last
// known state, so nothing regresses and no push goes out.
if (failed?.killed === true && failed.signal) {
if (!this.loggedProbeTimeout) {
this.loggedProbeTimeout = true;
this.logger.error(
"claude auth status did not answer within 5 s; the identity is not refreshed",
);
}
return this.currentAuthStatus;
}
// The logged-out case exits 1 while still printing valid JSON, so the
// stdout of a failed exec is parsed just like a successful one.
if (typeof failed?.stdout !== "string" || failed.stdout.trim().length === 0) {
this.logger.error(
"claude auth status failed:",
error instanceof Error ? error.message : String(error),
);
return undefined;
}
stdout = failed.stdout;
}
const status = fromCliStatus(stdout);
if (!status) {
this.logger.error("claude auth status returned unparseable output");
return undefined;
}
if (epoch !== this.authEpoch) {
// An `authenticate` or `logout` landed while this probe was running; the
// newer state wins and this answer is discarded, never published.
this.logger.log("[authStatus] discarding a probe that predates the latest auth change");
return this.currentAuthStatus;
}
// The CLI probe can be poorer than the session `AccountInfo` for the very
// same login (no organization, say). Keep those extra fields rather than
// regressing the payload; a different identity replaces it wholesale.
const merged = mergeAuthStatus(this.currentAuthStatus, status);
this.setAuthStatus(merged);
this.markSessionsWhoseAccountKindChanged(status.kind);
return merged;
}
/**
* Feed the completed probe to the `--hide-claude-auth` guard.
*
* The account cached at `initialize` is the guard's fact, and the CLI can
* swap the credential behind it between two turns (a Console key removed and
* a claude.ai login put in its place) without any turn failing. A read that
* reports a different kind of identity than the session was created on
* proves that fact stale.
*
* The probe decides nothing, and it interrupts nothing. Since the read is
* fired at the start of every user prompt, it usually lands in the middle of
* the turn it belongs to; all it may do there is set a flag. The turn runs to
* its end on the query it started on, the NEXT prompt consumes the flag and
* recreates the query, the new `initialize` reports the real account, and the
* creation guard judges it. So a probe can never refuse or abort a turn, and
* a wrong read costs one query recreation, not a false refusal.
*/
private markSessionsWhoseAccountKindChanged(kind: AuthStatusKind): void {
if (!this.claudeSubscriptionGuardActive()) {
return;
}
for (const [sessionId, session] of Object.entries(this.sessions)) {
// No kind: the account said nothing about the identity, so there is
// nothing this read can contradict.
if (!session.accountKind || session.queryClosed || session.needsSignOutRespawn) {
continue;
}
if (kind !== session.accountKind) {
// `endQuery: false` is the whole turn-boundary contract: the flag is
// set now, the query dies at the recreation the next prompt runs.
this.markSessionForSignOutRespawn(sessionId, session, { endQuery: false });
}
}
}
async unstable_listProviders(_params: ListProvidersRequest): Promise<ListProvidersResponse> {
const config = this.providerConfig ?? this.defaultProviderConfig();
this.logger.log(
@@ -2216,6 +2469,9 @@ export class ClaudeAcpAgent {
// those paths.
this.gatewayAuthRequest = undefined;
this.providerConfig = undefined;
// Any probe already running read the pre-logout world; the bump makes its
// answer unpublishable so it cannot resurrect the identity being cleared.
this.authEpoch += 1;
// Learned context windows are per-account state too: 1M-context
// entitlement is gated per org/tier, and an OAuth re-login is invisible to
// the env-derived provider cache key, so windows learned under the old
@@ -2239,6 +2495,15 @@ export class ClaudeAcpAgent {
`claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
// Re-read the store rather than assuming "none": an API key from the env
// or a key helper survives `auth logout`. The read must start after the
// credentials were cleared, so it never joins a probe that is already in
// flight. If it can't be read, the logout still happened — drop the
// now-stale identity instead of reporting it further.
if (!(await this.probeCliAuthStatus({ fresh: true }))) {
this.setAuthStatus(notLoggedInAuthStatus());
}
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
@@ -2247,6 +2512,15 @@ export class ClaudeAcpAgent {
if (!session) {
throw new Error("Session not found");
}
// The one re-read per user prompt, fired here and never awaited: a prompt
// must not wait on a CLI process, and its result is pushed on change
// whenever it lands, mid-turn included. It runs BEFORE the guard on
// purpose — a refused prompt is exactly the one whose retry follows a
// sign-in in the terminal, and that retry is a new prompt, so the read
// that finds the new credential must not be the one the refusal skipped.
// The guard itself consumes the result only at the next turn boundary (see
// `markSessionsWhoseAccountKindChanged`).
void this.probeCliAuthStatus();
const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session);
if (signOutRespawn) session = await signOutRespawn;
// The SDK query stream already terminated (see `queryClosed`); its iterator
@@ -2333,31 +2607,49 @@ export class ClaudeAcpAgent {
return shouldHideClaudeAuth() && this.resolveProviderConfig() === null;
}
/** Record that the CLI signed out during this session, and end the query.
/** Record that the account cached at `initialize` is wrong, and — unless the
* caller defers it — end the query.
*
* Only under `--hide-claude-auth`. The account cached at `initialize` is
* this integration's source of truth, and a sign-out proves that account
* wrong: the credential behind it is gone, and whatever replaces it is
* invisible until a new `initialize` runs. Closing the query here makes the
* next turn recreate it, so the creation guard decides on the real account.
* Only under `--hide-claude-auth`. That cached account is this
* integration's source of truth, and two things prove it wrong: a sign-out
* during the session, and a CLI probe that finds a different identity. In
* both cases the credential behind the cached account is gone, and whatever
* replaces it is invisible until a new `initialize` runs. The flag makes
* the next prompt recreate the query, so the creation guard decides on the
* real account.
*
* `endQuery` says whether the query dies now. A sign-out has already killed
* the turn, so its stream is closed at once. A probe has killed nothing: it
* runs beside a turn that is still producing output, and closing there would
* abort a turn the user is watching. It therefore leaves the stream alone
* and lets the recreation at the next prompt close it (`recreateSignedOutQuery`
* closes it too, and both are idempotent).
*
* Idempotent: one sign-out reaches this twice (the synthetic login message
* and the turn's error-shaped result), and the second call must not close
* a stream the first one already closed. Closing settles the queued turns
* through the consumer's end-of-stream path, which rejects each one. */
private markSessionForSignOutRespawn(sessionId: string, session: Session): void {
private markSessionForSignOutRespawn(
sessionId: string,
session: Session,
options?: { endQuery?: boolean },
): void {
if (!shouldHideClaudeAuth() || session.needsSignOutRespawn) {
return;
}
if (!session.creationParams) {
this.logger.error(
`Session ${sessionId}: signed out, but the creation params are missing; cannot recreate the query`,
`Session ${sessionId}: the identity behind the cached account changed, but the creation params are missing; cannot recreate the query`,
);
return;
}
session.needsSignOutRespawn = true;
this.logger.log(`Session ${sessionId}: signed out; the query is recreated on the next turn`);
this.closeQueryStream(session);
this.logger.log(
`Session ${sessionId}: the identity behind the cached account changed (sign-out, or a probe that found a different login); the query is recreated on the next turn`,
);
if (options?.endQuery !== false) {
this.closeQueryStream(session);
}
}
/** Recreate the query of a signed-out session, keeping the ACP session id and
@@ -2377,7 +2669,14 @@ export class ClaudeAcpAgent {
*
* Returns `undefined`, not a resolved promise, when no recreation is due:
* the callers enqueue their turn in one synchronous section, and an extra
* microtask there would let the caller observe a half-built turn. */
* microtask there would let the caller observe a half-built turn.
*
* A live turn also postpones it. A probe marks the session without ending
* the query, so a turn can still be running when the mark is read — by a
* `steer` injecting into it, say. Recreating there would close the stream
* under the turn and kill output the user is watching, so the flag waits for
* the next turn boundary. A sign-out is not affected: it closed the stream
* when it marked the session, and a closed stream recreates immediately. */
private respawnSignedOutSession(
sessionId: string,
session: Session,
@@ -2385,6 +2684,9 @@ export class ClaudeAcpAgent {
if (!session.needsSignOutRespawn) {
return undefined;
}
if (!session.queryClosed && (session.turnQueue ?? []).some((turn) => !turn.settled)) {
return undefined;
}
return this.awaitSignOutRespawn(sessionId, session);
}
@@ -2465,6 +2767,9 @@ export class ClaudeAcpAgent {
query: session.query,
guardState: (session.claudeSubscriptionGuard ??= {}),
logger: this.logger,
// The guard reads the account anyway; reuse that read to keep the
// reported identity current, refusal or not.
onAccount: (account) => this.publishSessionAccountIdentity(account),
sessionFailures: () =>
new SessionFailureController({
sessionId,
@@ -7593,6 +7898,11 @@ export class ClaudeAcpAgent {
throw error;
}
// Publish the identity BEFORE the guard can refuse this session. A
// refusal is exactly when the client most needs to know which account it
// was refused for.
this.publishSessionAccountIdentity(initializationResult.account);
// Shared with the per-turn guard, so "warn once" spans the whole session.
const claudeSubscriptionGuard: ClaudeSubscriptionGuardState = {};
if (this.claudeSubscriptionGuardActive()) {
@@ -7809,6 +8119,7 @@ export class ClaudeAcpAgent {
messageIdToUuid: new Map(),
sessionFailureState: createSessionFailureState(),
claudeSubscriptionGuard,
accountKind: fromAccountInfo(initializationResult.account)?.kind,
fileChangeReportRequestIds: new Set(),
fileChangeAuditSupport,
};

337
src/auth-status.ts Normal file
View File

@@ -0,0 +1,337 @@
/**
* `authStatus` — interim `_meta`-based ACP extension that reports which auth
* identity the agent process itself uses (Claude subscription, API key,
* gateway, external cloud credentials, or nothing).
*
* Push only. One carrier, connection-scoped (never per session): the
* notification `_auth/status_update` → `{authStatus}`. The client never asks;
* there is no request method. The empty `agentCapabilities._meta.authStatus`
* marker means "this agent pushes its identity", nothing more.
*
* The agent reads the identity at these moments:
* - `initialize`, with an asynchronous CLI probe. Nothing waits for it, and its
* result is the first push of the connection;
* - a completed `authenticate` or a `logout`;
* - each `createSession`, from the session account. A refused session reports
* too: the client must know which account was refused;
* - each turn the `--hide-claude-auth` guard checks. The guard reads the
* account anyway, so this costs nothing;
* - the START of each user prompt, with an asynchronous CLI probe. This finds a
* login or a logout done in another terminal, including the sign-in a client
* performs after a refusal, because the retried prompt is a new prompt.
* Nothing waits for it and the push goes out mid-turn if that is when the
* read lands.
*
* A probe whose kind differs from a live session's account marks that session
* for recreation under `--hide-claude-auth`. The mark is a flag, consumed at
* the next turn boundary: a running turn is never interrupted. The probe judges
* nothing itself — the next turn runs a new `initialize`, and the creation
* guard decides on the account it reports.
*
* A read does not always cause a push. The agent sends `_auth/status_update`
* only when the payload is different from the last one it sent.
*
* "Cannot determine" is expressed by silence: an agent that cannot read its own
* identity pushes nothing, and the client shows "not reported". A known
* logged-out state is a payload of its own (`kind: "none"`), which is what
* distinguishes the two.
*
* Reporting only — there is no write path in v1. When the upstream RFD lands,
* the same payload moves from `_meta` to first-class fields and this module
* is retired.
*/
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
/** Change notification, agent → client. Fire and forget: clients that do not
* know the method drop it silently, so it is sent unconditionally. */
export const AUTH_STATUS_UPDATE_METHOD = "_auth/status_update";
/** Upper bound on one `claude auth status --json` run, so a hung CLI cannot
* wedge every later probe that joins it. */
export const AUTH_STATUS_PROBE_TIMEOUT_MS = 5000;
export type AuthStatusKind = "account" | "api_key" | "gateway" | "external" | "none";
export type AuthStatusAccount = {
email?: string;
organization?: string;
/** Vendor plan/license string, not normalized. */
plan?: string;
};
export type AuthStatus = {
kind: AuthStatusKind;
/** Human-readable and usable as a UI string on its own. The "type" line:
* "Claude Max", "Anthropic API key", "AWS Bedrock". */
label: string;
/** Optional second line carrying the specifics (key source, gateway host, …).
* Clients render it under `label`, falling back to `account.email`. */
detail?: string;
account?: AuthStatusAccount;
/** Vendor-namespaced extras, e.g. `{claudeCode: {...}}`. */
vendor?: Record<string, unknown>;
};
export type AuthStatusUpdateNotification = { authStatus: AuthStatus };
/** Advertisement carried in `agentCapabilities._meta.authStatus`, never a
* status payload: its mere presence means "this agent pushes its identity",
* and it stays empty. Informational — a client that never saw it still
* accepts `_auth/status_update`, because there is nothing to ask for. */
export type AuthStatusCapability = Record<string, never>;
export function authStatusCapability(): AuthStatusCapability {
return {};
}
/** Shape of `claude auth status --json`. Absent fields are omitted, not null,
* and the logged-out case exits 1 while still printing valid JSON. */
export type CliAuthStatus = {
loggedIn?: boolean;
authMethod?: string;
apiProvider?: string;
apiKeySource?: string;
email?: string;
orgId?: string;
orgName?: string;
subscriptionType?: string;
};
const NOT_LOGGED_IN: AuthStatus = {
kind: "none",
label: "Not logged in",
};
/** Display names for the non-firstParty backends, where auth lives outside the
* agent (AWS credentials, gcloud ADC, …). */
const EXTERNAL_PROVIDER_LABELS: Record<string, string> = {
bedrock: "AWS Bedrock",
vertex: "Google Vertex AI",
foundry: "Azure AI Foundry",
anthropicAws: "Anthropic on AWS",
anthropicGoogleCloud: "Anthropic on Google Cloud",
mantle: "Mantle",
};
export function notLoggedInAuthStatus(): AuthStatus {
return { ...NOT_LOGGED_IN };
}
/** ACP-level gateway auth (`authenticate` with `gateway`/`gateway-bedrock`).
* The gateway owns the credentials, so it wins over anything the CLI reports. */
export function gatewayAuthStatus(baseUrl?: string): AuthStatus {
let host: string | undefined;
if (baseUrl) {
try {
host = new URL(baseUrl).host;
} catch {
host = baseUrl;
}
}
return {
kind: "gateway",
label: "Custom model gateway",
...(host ? { detail: host } : {}),
};
}
/**
* Does this `AccountInfo` say anything about the identity?
*
* The SDK always fills `apiProvider`, but the identity fields come from the
* claude.ai profile: with an `apiKeyHelper` and no subscription login the
* session reports `{apiProvider: "firstParty"}` and nothing else. That is "no
* information", not "logged out" — an empty read must never be mapped to
* `none`, or it destroys what the CLI probe already established.
*
* `tokenSource` is deliberately not a signal: the CLI sets it to the OAuth
* token's origin (`CLAUDE_CODE_OAUTH_TOKEN`,
* `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`, …), never to a key source, so it
* cannot attribute a key and carries no plan of its own.
*/
export function accountInfoHasIdentitySignal(account: AccountInfo | undefined): boolean {
if (!account) return false;
return Boolean(
account.apiKeySource ||
account.subscriptionType ||
(account.apiProvider && account.apiProvider !== "firstParty"),
);
}
/** Session-time mapping from the SDK's `AccountInfo`, the richest source when
* it is populated. Returns `undefined` when the account carries no identity
* signal, so callers keep the status they already know. */
export function fromAccountInfo(account: AccountInfo | undefined): AuthStatus | undefined {
if (!account || !accountInfoHasIdentitySignal(account)) {
return undefined;
}
return mapAuthFields({
apiProvider: account.apiProvider,
subscriptionType: account.subscriptionType,
apiKeySource: account.apiKeySource,
email: account.email,
organization: account.organization,
});
}
/** Pre-session mapping from `claude auth status --json` stdout. Returns
* `undefined` when the output is not the expected JSON object, so callers can
* report "not known" instead of guessing. */
export function fromCliStatus(stdout: string): AuthStatus | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
return undefined;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return undefined;
}
const status = parsed as CliAuthStatus;
if (typeof status.loggedIn !== "boolean") {
return undefined;
}
// `loggedIn` tracks the claude.ai login only. On a 3P backend (Bedrock,
// Vertex, …) it is false while the credentials live outside the agent, so the
// backend must decide the kind before the logged-out shortcut runs.
const externalBackend = Boolean(status.apiProvider && status.apiProvider !== "firstParty");
if (!externalBackend && !status.loggedIn && !status.apiKeySource) {
return notLoggedInAuthStatus();
}
return mapAuthFields({
apiProvider: status.apiProvider,
subscriptionType: status.subscriptionType,
apiKeySource: status.apiKeySource,
email: status.email,
organization: status.orgName,
});
}
/** Single mapping table shared by both sources; see the `kind`/`label` table in
* `extension-authstatus-meta.md`. Order matters: a 3P backend or a gateway
* decides the kind before any firstParty identity fields are considered, and
* an API key outranks a stored subscription login.
*
* Both can be reported at once — an `apiKeyHelper` in settings.json next to a
* claude.ai OAuth login yields `apiKeySource` AND `subscriptionType`. Claude
* Code's authentication precedence puts `apiKeyHelper` above the `/login`
* subscription, so the key is what actually pays for the turn and is what we
* report. */
function mapAuthFields(fields: {
apiProvider?: string;
subscriptionType?: string;
apiKeySource?: string;
email?: string;
organization?: string;
}): AuthStatus {
const { apiProvider, subscriptionType, apiKeySource, email, organization } = fields;
if (apiProvider === "gateway") {
return gatewayAuthStatus();
}
if (apiProvider && apiProvider !== "firstParty") {
return {
kind: "external",
label: EXTERNAL_PROVIDER_LABELS[apiProvider] ?? apiProvider,
};
}
if (apiKeySource) {
return {
kind: "api_key",
label: "Anthropic API key",
// The source ("apiKeyHelper", "env", …) is the specifics line.
detail: apiKeySource,
};
}
if (subscriptionType) {
// `account.plan` keeps the raw vendor string; only the label is titled.
const account: AuthStatusAccount = { plan: subscriptionType };
if (email) account.email = email;
if (organization) account.organization = organization;
return {
kind: "account",
label: planLabel(subscriptionType),
// No detail: the client falls back to `account.email` for line 2.
account,
};
}
return notLoggedInAuthStatus();
}
/** Names the plan with exactly one "Claude". A newer CLI already reports
* "Claude Max", an older one reports "max", and both must read "Claude Max".
* Each word is title-cased; a word that already carries capitals is kept. */
function planLabel(plan: string): string {
const titled = plan.replace(/\S+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1));
return /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`;
}
/**
* Do two payloads describe the same login? Compared on the field that
* identifies each kind — the key source for `api_key`, the email for
* `account`, the kind alone for the rest, which carry no per-identity key.
*/
export function sameIdentity(a: AuthStatus, b: AuthStatus): boolean {
if (a.kind !== b.kind) {
return false;
}
switch (a.kind) {
case "api_key":
return a.detail === b.detail;
case "account":
return (a.account?.email ?? "") === (b.account?.email ?? "");
default:
return true;
}
}
/**
* Folds a fresh read into what is already known. Sources differ in richness for
* the very same login: the SDK's session `AccountInfo` can carry an
* organization the CLI probe omits. So when the read describes the same
* identity, its values win field by field but the fields it lacks are kept;
* when it describes a different identity, it replaces the old one wholesale.
*/
export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStatus): AuthStatus {
if (!previous || !sameIdentity(previous, next)) {
return next;
}
const merged: AuthStatus = { ...next };
if (merged.detail === undefined && previous.detail !== undefined) {
merged.detail = previous.detail;
}
if (previous.account || next.account) {
merged.account = { ...previous.account, ...next.account };
}
if (merged.vendor === undefined && previous.vendor !== undefined) {
merged.vendor = previous.vendor;
}
return merged;
}
/**
* Do two payloads carry the same information? Unlike {@link sameIdentity},
* which asks "is this the same login", this compares every rendered field, so
* a richer read of one login is NOT the same. Callers use it to suppress a
* push that would tell the client nothing new.
*
* `a` undefined means "nothing reported yet", which never equals a payload.
*/
export function sameAuthStatus(a: AuthStatus | undefined, b: AuthStatus): boolean {
if (!a) {
return false;
}
return (
a.kind === b.kind &&
a.label === b.label &&
a.detail === b.detail &&
a.account?.email === b.account?.email &&
a.account?.organization === b.account?.organization &&
a.account?.plan === b.account?.plan &&
// Vendor extras are an open bag, so compare them structurally. Both sides
// are built by this module from the same key order, so a string compare is
// enough and costs nothing on the usual `undefined`/`undefined` pair.
JSON.stringify(a.vendor) === JSON.stringify(b.vendor)
);
}

View File

@@ -21,13 +21,14 @@
* 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.
* A file-based credential replaced by a subscription between two turns is seen
* by the CLI probe the agent fires at the START of every user prompt. That read
* is never awaited, so it usually lands mid-turn; all it does there is mark the
* session. The guard consumes the mark at the turn boundary — the next prompt
* recreates the query and layer 1 judges the account the new `initialize`
* reports — so a probe never interrupts a running turn. A swap that happens
* after the last probe stays unseen until the next prompt reads the store
* again. Closing that window would cost a blocking read per turn.
*/
import { RequestError } from "@agentclientprotocol/sdk";
@@ -192,6 +193,12 @@ type GuardOptions = {
/** Built lazily: most turns pass and never need a controller. */
sessionFailures: () => SessionFailureController;
logger: GuardLogger;
/** Display hook: called with the account the guard just read, before the
* guard decides on it. It exists so a refused turn still reports which
* account it was refused for. Purely observational — this module knows
* nothing about what the caller does with it, and a throw from here never
* changes the verdict. */
onAccount?: (account: AccountInfo) => void;
};
/**
@@ -241,6 +248,16 @@ async function evaluateGuard(options: GuardOptions): Promise<RequestError | unde
if (!account) {
return undefined;
}
// Before the decision, so the client learns the identity even when the next
// lines refuse the turn. A display hook must never fail a guard.
try {
options.onAccount?.(account);
} catch (error) {
options.logger.error(
`Session ${options.sessionId}: the guard's onAccount hook threw:`,
error instanceof Error ? error.message : String(error),
);
}
if (billsClaudeSubscription(account)) {
await publishRefusal(options.sessionFailures(), {
details: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,

View File

@@ -18,6 +18,15 @@ export {
toolUpdateFromToolResult,
} from "./tools.js";
export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
// The `authStatus` extension's wire surface: the notification's method name and
// the payload types needed to read the traffic. The mappers stay internal.
export {
AUTH_STATUS_UPDATE_METHOD,
type AuthStatus,
type AuthStatusAccount,
type AuthStatusKind,
type AuthStatusUpdateNotification,
} from "./auth-status.js";
// Export types
export type { ClaudePlanEntry } from "./tools.js";

View File

@@ -0,0 +1,947 @@
import type { Mock } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType, Logger } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../acp-agent.js";
import type { AuthStatus } from "../auth-status.js";
import { fromAccountInfo, fromCliStatus, mergeAuthStatus, sameIdentity } from "../auth-status.js";
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "./helpers.js";
import { randomUUID } from "node:crypto";
const mockQuery = vi.hoisted(() => vi.fn());
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
)),
query: mockQuery,
}));
/** Shared spy for both `claude auth status --json` and `claude auth logout`.
* Tests set `execFileResults` per invoked subcommand. */
const execFileSpy = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, execFile: execFileSpy };
});
/** CLI outputs verified against the real binary (see the extension spec). */
const CLI_SUBSCRIPTION = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
email: "user@example.com",
orgId: "org-1",
orgName: "ACME",
subscriptionType: "max",
});
const CLI_API_KEY = JSON.stringify({
loggedIn: true,
authMethod: "none",
apiProvider: "firstParty",
apiKeySource: "apiKeyHelper",
});
/** An `apiKeyHelper` in settings.json next to a stored claude.ai login: the CLI
* reports both identities at once, and the helper key is what actually bills. */
const CLI_KEY_HELPER_AND_SUBSCRIPTION = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
apiKeySource: "apiKeyHelper",
email: "user@example.com",
orgName: "ACME",
subscriptionType: "max",
});
const CLI_LOGGED_OUT = JSON.stringify({
loggedIn: false,
authMethod: "none",
apiProvider: "firstParty",
});
/** The same login as CLI_SUBSCRIPTION, read from a source that omits the org. */
const CLI_SUBSCRIPTION_NO_ORG = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
email: "user@example.com",
subscriptionType: "max",
});
/** A different person logged in since the last read. */
const CLI_OTHER_ACCOUNT = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
email: "other@example.com",
subscriptionType: "pro",
});
/** A 3P backend: no claude.ai login, yet the agent is authenticated through
* credentials it does not own (AWS). `loggedIn` is false here. */
const CLI_BEDROCK = JSON.stringify({
loggedIn: false,
authMethod: "none",
apiProvider: "bedrock",
});
const MOCK_MODELS = [
{ value: "id", displayName: "name", description: "description", supportsAutoMode: true },
];
/** One scripted SDK query: it echoes each pushed user message and answers it
* with a successful result, so a real `newSession` + `prompt` round trip runs
* against it and the turn-boundary probes fire. */
function scriptedTurnQuery(args: any, account: Record<string, unknown>) {
const input = args.prompt;
async function* messages() {
const iterator = input[Symbol.asyncIterator]();
for (;;) {
const { value, done } = await iterator.next();
if (done || !value) return;
yield {
type: "user",
message: value.message,
parent_tool_use_id: null,
uuid: value.uuid,
session_id: "test-session",
isReplay: true,
};
yield {
type: "result",
subtype: "success",
stop_reason: "end_turn",
is_error: false,
result: "done",
errors: [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
uuid: randomUUID(),
session_id: "test-session",
};
}
}
const query: any = messages();
query.initializationResult = async () => ({ models: MOCK_MODELS, account });
query.accountInfo = async () => account;
query.setModel = async () => {};
query.setPermissionMode = async () => {};
query.supportedCommands = async () => [];
query.mcpServerStatus = async () => [];
query.getContextUsage = async () => DEFAULT_CONTEXT_USAGE;
query.close = vi.fn();
query.interrupt = vi.fn(async () => {});
query.stopTask = vi.fn(async () => {});
return query;
}
describe("auth status mappers", () => {
it("maps a first-party subscription account", () => {
expect(
fromAccountInfo({
apiProvider: "firstParty",
subscriptionType: "max",
email: "user@example.com",
organization: "ACME",
}),
).toEqual({
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
});
});
it.each([
["max", "Claude Max"],
["Claude Max", "Claude Max"],
["claude max", "Claude Max"],
["Team Premium", "Claude Team Premium"],
])("names the plan %j once, as %j", (subscriptionType, label) => {
// Regression: a newer CLI reports the plan already prefixed, and the label
// read "Claude Claude Max". The plan itself stays the raw vendor string.
expect(fromAccountInfo({ apiProvider: "firstParty", subscriptionType })).toEqual({
kind: "account",
label,
account: { plan: subscriptionType },
});
});
it("maps an API key source", () => {
expect(
fromAccountInfo({ apiProvider: "firstParty", apiKeySource: "ANTHROPIC_API_KEY" }),
).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "ANTHROPIC_API_KEY",
});
});
it("prefers the API key over a stored subscription when both are reported", () => {
// Claude Code's authentication precedence ranks an apiKeyHelper key above
// the `/login` subscription, so the key is the identity that pays.
expect(fromCliStatus(CLI_KEY_HELPER_AND_SUBSCRIPTION)).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
});
expect(
fromAccountInfo({
apiProvider: "firstParty",
apiKeySource: "apiKeyHelper",
subscriptionType: "max",
email: "user@example.com",
organization: "ACME",
}),
).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
});
});
it("maps third-party backends to external auth", () => {
expect(fromAccountInfo({ apiProvider: "bedrock" })).toEqual({
kind: "external",
label: "AWS Bedrock",
});
expect(fromAccountInfo({ apiProvider: "vertex" })).toEqual({
kind: "external",
label: "Google Vertex AI",
});
});
it("maps the gateway backend", () => {
expect(fromAccountInfo({ apiProvider: "gateway" })).toEqual({
kind: "gateway",
label: "Custom model gateway",
});
});
it("reports an account with no identity signal as no information", () => {
// The SDK always fills apiProvider; under an apiKeyHelper that is all a
// session account carries. Mapping it to `none` would claim "logged out".
expect(fromAccountInfo({ apiProvider: "firstParty" })).toBeUndefined();
expect(fromAccountInfo({})).toBeUndefined();
expect(fromAccountInfo(undefined)).toBeUndefined();
// `tokenSource` names the OAuth token's origin, never a key source.
expect(
fromAccountInfo({ apiProvider: "firstParty", tokenSource: "CLAUDE_CODE_OAUTH_TOKEN" }),
).toBeUndefined();
});
it("maps CLI probe output", () => {
expect(fromCliStatus(CLI_SUBSCRIPTION)).toEqual({
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
});
expect(fromCliStatus(CLI_API_KEY)).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
});
expect(fromCliStatus(CLI_LOGGED_OUT)).toEqual({
kind: "none",
label: "Not logged in",
});
});
it("lets a third-party backend outrank the logged-out flag", () => {
// `loggedIn` tracks the claude.ai login only; on Bedrock the credentials
// are external and the agent is authenticated all the same.
expect(fromCliStatus(CLI_BEDROCK)).toEqual({ kind: "external", label: "AWS Bedrock" });
});
it("reports unparseable CLI output as not known", () => {
expect(fromCliStatus("")).toBeUndefined();
expect(fromCliStatus("Usage: claude auth status")).toBeUndefined();
expect(fromCliStatus("[]")).toBeUndefined();
expect(fromCliStatus('{"foo": 1}')).toBeUndefined();
});
it("merges a poorer read into the same identity and replaces a different one", () => {
const stored = {
kind: "account" as const,
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
};
// Same login, read from a source without the organization: it survives.
expect(
mergeAuthStatus(stored, {
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com" },
}),
).toEqual(stored);
// A different login replaces everything, organization included.
expect(
mergeAuthStatus(stored, {
kind: "account",
label: "Claude Pro",
account: { plan: "pro", email: "other@example.com" },
}),
).toEqual({
kind: "account",
label: "Claude Pro",
account: { plan: "pro", email: "other@example.com" },
});
// A different kind never merges, even when it looks adjacent.
expect(
mergeAuthStatus(stored, { kind: "api_key", label: "Anthropic API key", detail: "env" }),
).toEqual({ kind: "api_key", label: "Anthropic API key", detail: "env" });
// Key sources identify api_key payloads.
expect(
sameIdentity(
{ kind: "api_key", label: "Anthropic API key", detail: "apiKeyHelper" },
{ kind: "api_key", label: "Anthropic API key", detail: "env" },
),
).toBe(false);
});
});
describe("auth status over ACP", () => {
let agent: ClaudeAcpAgentType;
let extNotification: ReturnType<typeof vi.fn>;
/** stdout the fake `claude auth status --json` prints, and whether the exec
* fails (the logged-out case exits 1 with JSON still on stdout). */
let statusStdout: string;
let statusFails: boolean;
/** While true, probes hang until `flushStatus()` releases them, so a test can
* interleave an `authenticate`/`logout` with a probe that is still running.
* Each probe captures the output configured when it was spawned. */
let deferStatus: boolean;
let pendingStatus: Array<() => void>;
/** Let every already-scheduled promise chain run to completion. */
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
/** Release one pending probe by spawn order, so a test can decide which of
* two in-flight reads lands first. */
async function releaseStatus(index: number) {
const [release] = pendingStatus.splice(index, 1);
release();
await settle();
}
/** Release the probes spawned so far and let their promises settle, repeating
* so a probe spawned by that settling (e.g. logout's fresh read) is released
* too. */
async function flushStatus(rounds = 3) {
for (let round = 0; round < rounds; round++) {
const pending = pendingStatus;
pendingStatus = [];
for (const release of pending) release();
await settle();
}
}
beforeEach(() => {
// Skip native-binary resolution; the exec itself is mocked.
process.env.CLAUDE_CODE_EXECUTABLE = "claude";
statusStdout = CLI_SUBSCRIPTION;
statusFails = false;
deferStatus = false;
pendingStatus = [];
// The probe passes an options object and `logout` does not, so the
// callback is read off the end rather than from a fixed position.
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const args = invocation[1] as string[];
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
if (args[1] === "status") {
const stdout = statusStdout;
const fails = statusFails;
const answer = () => {
if (fails) {
cb(Object.assign(new Error("exit 1"), { stdout, stderr: "" }));
} else {
cb(null, { stdout, stderr: "" });
}
};
if (deferStatus) {
pendingStatus.push(answer);
} else {
answer();
}
return;
}
cb(null, { stdout: "", stderr: "" });
});
mockQuery.mockImplementation(() => makeMockQuery());
extNotification = vi.fn().mockResolvedValue(undefined);
agent = new ClaudeAcpAgent({
sessionUpdate: async () => {},
extNotification,
} as unknown as AcpClient);
});
afterEach(() => {
delete process.env.CLAUDE_CODE_EXECUTABLE;
vi.resetAllMocks();
});
function statusCalls() {
return execFileSpy.mock.calls.filter((call) => (call[1] as string[])[1] === "status");
}
function updates() {
return extNotification.mock.calls.filter((call) => call[0] === "_auth/status_update");
}
/** The last payload pushed, which is also what the connection now holds. */
function lastPush() {
return (updates().at(-1)?.[1] as { authStatus: AuthStatus } | undefined)?.authStatus;
}
/** `initialize` is where the connection learns its identity. It fires the
* probe and never waits for it, so let the push land before asserting. */
async function initialize() {
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await settle();
return response;
}
/** A session whose scripted query answers every prompt, so a real `prompt()`
* round trip runs and its start-of-prompt probe fires. */
async function scriptedSession(account: Record<string, unknown>) {
mockQuery.mockImplementation((args: any) => scriptedTurnQuery(args, account));
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
return sessionId;
}
async function promptOnce(sessionId: string) {
const response = await agent.prompt({
sessionId,
prompt: [{ type: "text" as const, text: "hi" }],
});
await settle();
return response;
}
it("pushes the logged-out CLI verdict even though the probe exits non-zero", async () => {
statusStdout = CLI_LOGGED_OUT;
statusFails = true;
await initialize();
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
});
it("pushes nothing when the probe cannot be read", async () => {
statusStdout = "";
statusFails = true;
await initialize();
// "Cannot determine" is silence: no push at all, and the client keeps
// showing "not reported". A known logged-out state would push a payload
// of kind "none" instead.
expect(updates()).toHaveLength(0);
expect(agent.currentAuthStatus).toBeUndefined();
});
it("advertises the extension in agentCapabilities._meta", async () => {
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
// Presence is the whole advertisement — "this agent pushes its identity".
// The marker stays empty, and a status payload there would violate the
// contract.
expect(response.agentCapabilities?._meta?.authStatus).toEqual({});
// The marker lives in agentCapabilities only — never at the top level.
expect(response._meta?.authStatus).toBeUndefined();
});
it("pushes the first identity after the initialize response, never before it", async () => {
// The client registers its receiver around `initialize`; a push that
// overtook the response could be dropped. The probe is asynchronous, so
// the response always wins the race.
const order: string[] = [];
extNotification.mockImplementation(async (method: string) => {
order.push(method);
});
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
order.push("initialize:returned");
await vi.waitFor(() => expect(updates()).toHaveLength(1));
expect(order).toEqual(["initialize:returned", "_auth/status_update"]);
// No snapshot rides in the initialize response — only the notification.
expect(response._meta?.authStatus).toBeUndefined();
});
it("shares one in-flight probe between initialize and a start-of-prompt read", async () => {
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
deferStatus = true;
const init = agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
const turn = agent.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
await settle();
// Two readers, one CLI process.
expect(statusCalls()).toHaveLength(1);
await flushStatus();
await Promise.all([init, turn]);
expect(lastPush()?.kind).toBe("account");
});
it("re-probes on every prompt, so an external logout is seen", async () => {
// The start of a prompt is the connection's "re-check now": a logout
// performed in another terminal shows up without a session create.
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
await promptOnce(sessionId);
expect(lastPush()).toEqual({
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
});
statusStdout = CLI_LOGGED_OUT;
statusFails = true;
await promptOnce(sessionId);
expect(statusCalls()).toHaveLength(2);
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
});
it("keeps richer stored fields when a poorer probe reports the same identity", async () => {
// The session AccountInfo knows the organization; the CLI probe here does
// not. Re-reading the same login must not regress the payload.
const sessionId = await scriptedSession({
apiProvider: "firstParty",
subscriptionType: "max",
email: "user@example.com",
organization: "ACME",
});
statusStdout = CLI_SUBSCRIPTION_NO_ORG;
await promptOnce(sessionId);
expect(agent.currentAuthStatus).toEqual({
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
});
});
it("lets a probe of a different identity replace the stored one wholesale", async () => {
const sessionId = await scriptedSession({
apiProvider: "firstParty",
subscriptionType: "max",
email: "user@example.com",
organization: "ACME",
});
// Someone logged in as another user in a terminal: no field survives.
statusStdout = CLI_OTHER_ACCOUNT;
await promptOnce(sessionId);
expect(lastPush()).toEqual({
kind: "account",
label: "Claude Pro",
account: { plan: "pro", email: "other@example.com" },
});
});
it("pushes _auth/status_update when the initialize probe resolves", async () => {
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await vi.waitFor(() => expect(updates()).toHaveLength(1));
expect(updates()).toEqual([
[
"_auth/status_update",
{
authStatus: {
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
},
},
],
]);
});
it("upgrades the identity from AccountInfo on session create", async () => {
statusStdout = CLI_API_KEY;
await initialize();
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({
models: MOCK_MODELS,
account: { apiProvider: "firstParty", subscriptionType: "pro", email: "u@x.com" },
}),
}),
);
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(updates().map((call) => call[1])).toEqual([
{
authStatus: {
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
},
},
{
authStatus: {
kind: "account",
label: "Claude Pro",
account: { plan: "pro", email: "u@x.com" },
},
},
]);
});
it.each([
["only apiProvider", { apiProvider: "firstParty" }],
["an empty object", {}],
])("keeps the probed API key when the session account carries %s", async (_name, account) => {
// Regression: an apiKeyHelper setup yields a truthy but empty account, and
// mapping it to `none` used to overwrite the correct api_key status ~0.5 s
// after connect.
statusStdout = CLI_API_KEY;
await initialize();
expect(updates()).toHaveLength(1);
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({ models: MOCK_MODELS, account }),
}),
);
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(updates()).toHaveLength(1);
expect(agent.currentAuthStatus).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
});
});
it("ignores the session account while a client provider override is active", async () => {
// `providers/set` routing is the client's, not the agent's login: the
// session then reports `apiProvider: "gateway"`, which must stay invisible
// to authStatus. The CLI probe keeps describing the agent-owned store.
statusStdout = CLI_API_KEY;
await initialize();
expect(updates()).toHaveLength(1);
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://client-gateway.example/v1",
headers: {},
});
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({
models: MOCK_MODELS,
account: { apiProvider: "gateway" },
}),
}),
);
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(updates()).toHaveLength(1);
expect(agent.currentAuthStatus).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
});
});
it("still probes the agent's own login under a client provider override", async () => {
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://client-gateway.example/v1",
headers: {},
});
await initialize();
expect(statusCalls()).toHaveLength(1);
expect(updates()).toHaveLength(1);
});
it("pushes an unchanged identity once, however often it is read", async () => {
// The identity is read on many occasions and almost all of them see the
// same login. A repeat tells the client nothing, so it is not sent.
const authStatus = {
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com" },
};
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({
models: MOCK_MODELS,
account: {
apiProvider: "firstParty",
subscriptionType: "max",
email: "user@example.com",
},
}),
}),
);
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(updates().map((call) => call[1])).toEqual([{ authStatus }]);
});
it("pushes again as soon as a field of the payload changes", async () => {
let account: Record<string, unknown> = {
apiProvider: "firstParty",
subscriptionType: "max",
email: "user@example.com",
};
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({ models: MOCK_MODELS, account }),
}),
);
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
// Same person, upgraded plan: the label changes, so the client hears it.
account = { ...account, subscriptionType: "enterprise" };
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(
updates().map((call) => (call[1] as { authStatus: { label: string } }).authStatus.label),
).toEqual(["Claude Max", "Claude Enterprise"]);
});
it("reads the store once per user prompt, at the start", async () => {
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
// Session creation reads the account the SDK already has; it spawns no CLI.
expect(statusCalls()).toHaveLength(0);
await expect(promptOnce(sessionId)).resolves.toMatchObject({ stopReason: "end_turn" });
// Exactly one process per user prompt, spawned before the turn runs so a
// sign-in done between two prompts is seen by the prompt that follows it.
expect(statusCalls()).toHaveLength(1);
await promptOnce(sessionId);
expect(statusCalls()).toHaveLength(2);
});
it("never makes a prompt wait for the probe", async () => {
// The read is fire-and-forget: a CLI that has not answered yet — or never
// answers — must not hold the turn.
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
deferStatus = true;
await expect(promptOnce(sessionId)).resolves.toMatchObject({ stopReason: "end_turn" });
expect(statusCalls()).toHaveLength(1);
// Still pending when the turn is long over; its result lands afterwards.
expect(pendingStatus).toHaveLength(1);
await flushStatus();
expect(lastPush()?.kind).toBe("account");
});
it("reports the gateway identity after gateway authentication", async () => {
await agent.authenticate({
methodId: "gateway",
_meta: { gateway: { baseUrl: "https://gw.example.com/v1", headers: {} } },
} as never);
expect(updates().map((call) => call[1])).toEqual([
{
authStatus: {
kind: "gateway",
label: "Custom model gateway",
detail: "gw.example.com",
},
},
]);
});
it("discards a probe that a gateway authenticate overtook", async () => {
// The initialize-time probe is slower than the login: releasing it after
// `authenticate` must not repaint the gateway identity with the CLI store's.
deferStatus = true;
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await agent.authenticate({
methodId: "gateway",
_meta: { gateway: { baseUrl: "https://gw.example.com/v1", headers: {} } },
} as never);
await flushStatus();
const gateway = {
kind: "gateway",
label: "Custom model gateway",
detail: "gw.example.com",
};
// The overtaken read published nothing: one push, the gateway's.
expect(updates().map((call) => call[1])).toEqual([{ authStatus: gateway }]);
expect(agent.currentAuthStatus).toEqual(gateway);
});
it("ignores a pre-logout probe and pushes the fresh one", async () => {
// A probe started before `logout` still describes the logged-in world: it
// must neither be reused by logout nor be published when it lands.
deferStatus = true;
void agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await settle();
statusStdout = CLI_LOGGED_OUT;
statusFails = true;
const logout = agent.logout({});
await flushStatus();
await logout;
// Two reads: the stale one, plus the fresh one logout started itself
// instead of joining it.
expect(statusCalls()).toHaveLength(2);
// The logged-in identity the stale probe carried was never published.
expect(updates().map((call) => call[1])).toEqual([
{ authStatus: { kind: "none", label: "Not logged in" } },
]);
expect(agent.currentAuthStatus).toEqual({ kind: "none", label: "Not logged in" });
});
it("discards the pre-logout read even when it lands after the logout's own", async () => {
// Both reads are in flight at once. The logout's lands first and pushes
// "logged out"; the older one lands after and must publish nothing, or a
// slow read would resurrect the identity the logout destroyed.
deferStatus = true;
void agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await settle();
statusStdout = CLI_LOGGED_OUT;
statusFails = true;
const logout = agent.logout({});
await vi.waitFor(() => expect(pendingStatus).toHaveLength(2));
await releaseStatus(1);
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
await releaseStatus(0);
await logout;
expect(updates()).toHaveLength(1);
expect(agent.currentAuthStatus).toEqual({ kind: "none", label: "Not logged in" });
});
it("keeps the last known state when a probe is readable but empty", async () => {
// The CLI cannot be read at all, yet a session already told us who we are:
// keep that rather than pretending the state is unknown.
const published = { kind: "api_key" as const, label: "Anthropic API key", detail: "env" };
agent.setAuthStatus(published);
await settle();
extNotification.mockClear();
statusStdout = "";
statusFails = true;
await initialize();
expect(updates()).toHaveLength(0);
expect(agent.currentAuthStatus).toEqual(published);
});
it("drops a stale identity when the post-logout probe is unreadable", async () => {
await initialize();
statusStdout = "";
statusFails = true;
await agent.logout({});
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
});
it("re-probes after logout and reports the resulting state", async () => {
await initialize();
statusStdout = CLI_LOGGED_OUT;
statusFails = true;
await agent.logout({});
expect(statusCalls()).toHaveLength(2);
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
});
describe("when the CLI never answers", () => {
/** The last known state, so a timed-out probe has something to fall back
* to and a wrong "cannot determine" answer would be visible. */
const KNOWN: AuthStatus = { kind: "api_key", label: "Anthropic API key", detail: "env" };
let logger: { log: Mock; error: Mock; warn: Mock };
beforeEach(() => {
vi.useFakeTimers();
// A wedged `claude auth status --json`: it never answers by itself, and
// the fake child dies the way Node kills a timed-out exec — the error
// carries `killed`/`signal`, and any partial stdout is a fragment.
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const args = invocation[1] as string[];
const options = invocation[2] as { timeout?: number } | undefined;
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
if (args[1] !== "status") {
cb(null, { stdout: "", stderr: "" });
return;
}
if (options?.timeout === undefined) return;
setTimeout(() => {
cb(
Object.assign(new Error("Command failed"), {
killed: true,
signal: "SIGTERM",
stdout: "",
stderr: "",
}),
);
}, options.timeout);
});
logger = { log: vi.fn(), error: vi.fn(), warn: vi.fn() };
extNotification = vi.fn().mockResolvedValue(undefined);
agent = new ClaudeAcpAgent(
{ sessionUpdate: async () => {}, extNotification } as unknown as AcpClient,
logger as Logger,
);
agent.setAuthStatus({ ...KNOWN });
});
afterEach(() => {
vi.useRealTimers();
});
it("bounds the probe at 5 s and keeps the last known state", async () => {
agent.setAuthStatus({ ...KNOWN });
const pushesBefore = updates().length;
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
// The exec is spawned on a microtask (`claudeCliPath` is awaited first),
// so let those run before the timer that kills it.
await vi.advanceTimersByTimeAsync(0);
expect(statusCalls()).toHaveLength(1);
expect(statusCalls()[0]?.[2]).toMatchObject({ timeout: 5000 });
await vi.advanceTimersByTimeAsync(5000);
// Node killed the child, so the read reports nothing of its own: the
// state stays what was known before it, and no push goes out.
expect(agent.currentAuthStatus).toEqual(KNOWN);
expect(updates()).toHaveLength(pushesBefore);
expect(logger.error).toHaveBeenCalledWith(
"claude auth status did not answer within 5 s; the identity is not refreshed",
);
});
it("warns once however many probes time out", async () => {
// A wedged CLI stays wedged: every later prompt would repeat the same
// line otherwise.
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await vi.advanceTimersByTimeAsync(5000);
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await vi.advanceTimersByTimeAsync(5000);
expect(statusCalls()).toHaveLength(2);
expect(
logger.error.mock.calls.filter((call) => String(call[0]).includes("did not answer")),
).toHaveLength(1);
});
});
});

View File

@@ -32,9 +32,30 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
query: mockQuery,
}));
/** The agent probes `claude auth status --json` at the start of every user
* prompt. Stub the exec so no test here spawns a real CLI: the probe is left
* hanging, which reports nothing and therefore never pushes an identity of its
* own. What these tests watch is the account the session and the guard
* report. */
const execFileSpy = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, execFile: execFileSpy };
});
describe("authorization", () => {
beforeEach(() => {
vi.useFakeTimers();
// Skip native-binary resolution; the exec itself is stubbed.
process.env.CLAUDE_CODE_EXECUTABLE = "claude";
execFileSpy.mockImplementation(
(_file: string, args: string[], cb: (...a: unknown[]) => void) => {
// `auth status` never answers, so the probe stays pending and silent.
if (args[1] === "status") return;
cb(null, { stdout: "", stderr: "" });
},
);
// Set here (not in vi.fn(impl) at hoist time) so the helper import is
// available; afterEach's resetAllMocks clears it, beforeEach re-sets it.
mockQuery.mockImplementation(() =>
@@ -58,18 +79,31 @@ describe("authorization", () => {
vi.runAllTimers();
vi.useRealTimers();
delete process.env.CLAUDE_CODE_EXECUTABLE;
vi.unstubAllGlobals();
vi.resetAllMocks();
});
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
/** Third element: the `extNotification` spy, so a test can read the
* `_auth/status_update` pushes the agent sent. */
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock, Mock]> {
const extNotification = vi.fn(async () => {});
const connectionMock = {
sessionUpdate: async (_: any) => {},
} as AcpClient;
extNotification,
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(connectionMock);
return [agent, mockQuery];
return [agent, mockQuery, extNotification];
}
/** The `_auth/status_update` payloads pushed through an `extNotification` spy,
* in the order they were sent. */
function authStatusUpdates(extNotification: Mock) {
return extNotification.mock.calls
.filter((call) => call[0] === "_auth/status_update")
.map((call) => (call[1] as { authStatus: any }).authStatus);
}
it("gateway auth not offered without capability", async () => {
@@ -297,20 +331,22 @@ describe("authorization", () => {
function createRecordingAgent(
capable: boolean,
logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void; warn?: any },
): [ClaudeAcpAgent, any[]] {
): [ClaudeAcpAgent, any[], Mock] {
const updates: any[] = [];
const extNotification = vi.fn(async () => {});
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: any) => {
updates.push(update);
},
extNotification,
} as unknown as AcpClient,
logger,
);
if (capable) {
(agent as any).clientCapabilities = airSessionFailureCapabilities;
}
return [agent, updates];
return [agent, updates, extNotification];
}
function failuresIn(updates: any[]) {
@@ -516,6 +552,29 @@ describe("authorization", () => {
await expect(agent.newSession(newSessionParams())).rejects.toMatchObject(refusal);
});
it("reports the refused account before the refusal reaches the client", async () => {
const [agent, , extNotification] = await createAgentMock();
hideClaudeAuth();
mockAccount({ subscriptionType: "pro" });
// A refusal without an account behind it is unreadable in the UI, so
// the identity must be on the wire before `newSession` rejects.
const order: string[] = [];
extNotification.mockImplementation(async (method: string) => {
if (method === "_auth/status_update") order.push("update");
});
await agent.newSession(newSessionParams()).then(
() => order.push("resolved"),
() => order.push("rejected"),
);
expect(order).toEqual(["update", "rejected"]);
expect(authStatusUpdates(extNotification)).toEqual([
{ kind: "account", label: "Claude Pro", account: { plan: "pro" } },
]);
});
it("rejects loadSession the same way", async () => {
const [agent] = await createAgentMock();
hideClaudeAuth();
@@ -643,6 +702,21 @@ describe("authorization", () => {
expect(agent.resolveProviderConfig()).toBeNull();
});
it("reports no identity for a gateway payload it rejected", async () => {
const [agent, , extNotification] = await createAgentMock();
await expect(
agent.authenticate({
methodId: "gateway",
_meta: { gateway: { baseUrl: "not-a-url" } },
} as any),
).rejects.toMatchObject({ code: INVALID_PARAMS_CODE });
// The identity is assigned only after the payload was accepted and
// stored, so a refused login leaves the reported state untouched.
expect(authStatusUpdates(extNotification)).toEqual([]);
expect(agent.currentAuthStatus).toBeUndefined();
});
it("rejects an empty baseUrl instead of disabling the guard", async () => {
const [agent] = await createAgentMock();
hideClaudeAuth();
@@ -680,6 +754,35 @@ describe("authorization", () => {
expect(agent.sessions[sessionId].turnQueue ?? []).toHaveLength(0);
});
it("reports the account of a refused turn before the turn rejects", async () => {
const [agent, , extNotification] = await createAgentMock();
hideClaudeAuth();
// Spawn-time account passes; the store changes underneath the session.
mockAccount(SIGNED_IN_WITH_KEY, {
accountInfo: async () => ({ subscriptionType: "pro" }),
});
const { sessionId } = await agent.newSession(newSessionParams());
// Drop the create-time `api_key` push; this test is about the turn.
extNotification.mockClear();
const order: string[] = [];
extNotification.mockImplementation(async (method: string) => {
if (method === "_auth/status_update") order.push("update");
});
await agent.prompt(promptParams(sessionId)).then(
() => order.push("resolved"),
() => order.push("rejected"),
);
expect(order).toEqual(["update", "rejected"]);
// The session was created on a key; the guard read the subscription
// the store now holds, and that is what the client is told.
expect(authStatusUpdates(extNotification)).toEqual([
{ kind: "account", label: "Claude Pro", account: { plan: "pro" } },
]);
});
it("publishes one session-scoped access failure for capable clients", async () => {
const [agent, updates] = createRecordingAgent(true);
hideClaudeAuth();
@@ -1017,6 +1120,28 @@ describe("authorization", () => {
expect(mockQuery).toHaveBeenCalledTimes(2);
});
it("reports the identity the respawned session logged back in with", async () => {
const [agent, , extNotification] = createRecordingAgent(true);
hideClaudeAuth();
// Signed out on a key from the environment, back in on a helper key.
mockQueries(
{ account: SIGNED_IN_WITH_KEY, script: [[signOutResult()]] },
{ account: { apiKeySource: "apiKeyHelper" }, script: [[successResult()]] },
);
const sessionId = await signOutDuringTurn(agent);
await expect(agent.prompt(promptParams(sessionId))).resolves.toMatchObject({
stopReason: "end_turn",
});
// The respawn runs a full `createSession`, which reports the new
// account by itself — no CLI probe is needed for the post-login state.
expect(authStatusUpdates(extNotification)).toEqual([
{ kind: "api_key", label: "Anthropic API key", detail: "ANTHROPIC_API_KEY" },
{ kind: "api_key", label: "Anthropic API key", detail: "apiKeyHelper" },
]);
});
it("answers a cancel during the dead state", async () => {
const [agent] = createRecordingAgent(true);
hideClaudeAuth();
@@ -1125,6 +1250,228 @@ describe("authorization", () => {
// One recreate attempt, no fallback.
expect(mockQuery).toHaveBeenCalledTimes(2);
});
describe("recreates when the start-of-prompt probe finds another identity", () => {
/** `claude auth status --json` on a claude.ai subscription. */
const PROBE_SUBSCRIPTION = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
subscriptionType: "max",
email: "x@y",
});
/** The same kind of identity the session was created on. */
const PROBE_KEY = JSON.stringify({
loggedIn: true,
apiKeySource: "ANTHROPIC_API_KEY",
apiProvider: "firstParty",
});
/** An OAuth token names no identity: `accountInfoHasIdentitySignal`
* ignores `tokenSource`, so the session gets no `accountKind`. */
const SIGNED_IN_WITH_TOKEN = { tokenSource: "CLAUDE_CODE_OAUTH_TOKEN" };
/** What the fake `claude auth status --json` prints. `undefined` keeps
* the outer stub, where the probe never answers at all. */
let probeStdout: string | undefined;
beforeEach(() => {
probeStdout = undefined;
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const args = invocation[1] as string[];
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
if (args[1] === "status") {
if (probeStdout !== undefined) cb(null, { stdout: probeStdout, stderr: "" });
return;
}
cb(null, { stdout: "", stderr: "" });
});
});
/** The probe is never awaited, so let its promise chain run. */
async function settleProbe() {
for (let round = 0; round < 5; round++) await vi.advanceTimersByTimeAsync(0);
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((settle) => (resolve = settle));
return { promise, resolve };
}
/** Spawn scripting whose FIRST query hangs after echoing the prompt,
* until `held` resolves. That is the window in which the probe lands:
* the turn is live and producing. Later spawns are ordinary scripted
* queries, so the respawn behaves as everywhere else. */
function mockHeldFirstTurn(
account: Record<string, unknown>,
held: Promise<void>,
...later: Array<{ account: Record<string, unknown>; script: any[][] }>
) {
let spawn = 0;
mockQuery.mockImplementation((args: any) => {
const index = spawn++;
if (index > 0) {
const next = later[Math.min(index - 1, later.length - 1)];
return scriptedQuery(args, next.account, next.script);
}
const input = args.prompt;
async function* messages() {
const iterator = input[Symbol.asyncIterator]();
for (;;) {
const { value, done } = await iterator.next();
if (done || !value) return;
yield {
type: "user",
message: value.message,
parent_tool_use_id: null,
uuid: value.uuid,
session_id: "test-session",
isReplay: true,
};
await held;
yield successResult();
}
}
const query: any = messages();
query.initializationResult = async () => ({ models, account });
query.accountInfo = async () => account;
query.setModel = async () => {};
query.setPermissionMode = async () => {};
query.supportedCommands = async () => [];
query.mcpServerStatus = async () => [];
query.getContextUsage = async () => DEFAULT_CONTEXT_USAGE;
query.close = vi.fn();
query.interrupt = vi.fn(async () => {});
query.stopTask = vi.fn(async () => {});
return query;
});
}
it("lets the marked turn finish and recreates at the NEXT prompt", async () => {
// The probe now fires at the start of the prompt, so its answer
// usually arrives while that very turn is still running. The mark is
// a flag and nothing more: the turn keeps its query to the end, and
// only the following prompt consumes it.
const [agent] = createRecordingAgent(true);
hideClaudeAuth();
probeStdout = PROBE_SUBSCRIPTION;
const held = deferred();
mockHeldFirstTurn(SIGNED_IN_WITH_KEY, held.promise, {
account: { subscriptionType: "pro" },
script: [],
});
const { sessionId } = await agent.newSession(newSessionParams());
const turn = agent.prompt(promptParams(sessionId));
await settleProbe();
// Marked mid-turn, but the query is untouched and no respawn ran.
expect(agent.sessions[sessionId].needsSignOutRespawn).toBe(true);
expect(agent.sessions[sessionId].queryClosed).toBeFalsy();
expect(mockQuery).toHaveBeenCalledTimes(1);
held.resolve();
await expect(turn).resolves.toMatchObject({ stopReason: "end_turn" });
// The turn completed on the query it started on.
expect(mockQuery).toHaveBeenCalledTimes(1);
// Now the boundary: the next prompt recreates and the creation guard
// judges the subscription the CLI swapped in.
await expect(agent.prompt(promptParams(sessionId))).rejects.toMatchObject(refusal);
expect(mockQuery).toHaveBeenCalledTimes(2);
expect(mockQuery.mock.calls[1][0].options).toMatchObject({ resume: sessionId });
});
it("marks the session when the probe kind differs from the account", async () => {
const [agent] = createRecordingAgent(true);
hideClaudeAuth();
probeStdout = PROBE_SUBSCRIPTION;
mockQueries(
{ account: SIGNED_IN_WITH_KEY, script: [[successResult()]] },
{ account: { subscriptionType: "pro" }, script: [] },
);
const { sessionId } = await agent.newSession(newSessionParams());
await expect(agent.prompt(promptParams(sessionId))).resolves.toMatchObject({
stopReason: "end_turn",
});
await settleProbe();
expect(agent.sessions[sessionId].needsSignOutRespawn).toBe(true);
// The probe refuses nothing itself: the next prompt recreates the
// query, and the creation guard judges the account it reports.
await expect(agent.prompt(promptParams(sessionId))).rejects.toMatchObject(refusal);
expect(mockQuery).toHaveBeenCalledTimes(2);
expect(mockQuery.mock.calls[1][0].options).toMatchObject({ resume: sessionId });
});
it("leaves the session alone when the probe finds the same kind", async () => {
const [agent] = createRecordingAgent(true);
hideClaudeAuth();
probeStdout = PROBE_KEY;
mockQueries({
account: SIGNED_IN_WITH_KEY,
script: [[successResult()], [successResult()]],
});
const { sessionId } = await agent.newSession(newSessionParams());
await expect(agent.prompt(promptParams(sessionId))).resolves.toMatchObject({
stopReason: "end_turn",
});
await settleProbe();
expect(agent.sessions[sessionId].needsSignOutRespawn).toBeUndefined();
await expect(agent.prompt(promptParams(sessionId))).resolves.toMatchObject({
stopReason: "end_turn",
});
expect(mockQuery).toHaveBeenCalledTimes(1);
});
it("never marks a session without the flag", async () => {
const [agent] = createRecordingAgent(true);
probeStdout = PROBE_SUBSCRIPTION;
mockQueries({
account: SIGNED_IN_WITH_KEY,
script: [[successResult()], [successResult()]],
});
const { sessionId } = await agent.newSession(newSessionParams());
await expect(agent.prompt(promptParams(sessionId))).resolves.toMatchObject({
stopReason: "end_turn",
});
await settleProbe();
expect(agent.sessions[sessionId].needsSignOutRespawn).toBeUndefined();
await expect(agent.prompt(promptParams(sessionId))).resolves.toMatchObject({
stopReason: "end_turn",
});
expect(mockQuery).toHaveBeenCalledTimes(1);
});
it("never marks a session whose account named no identity", async () => {
const [agent] = createRecordingAgent(true);
hideClaudeAuth();
probeStdout = PROBE_SUBSCRIPTION;
mockQueries({
account: SIGNED_IN_WITH_TOKEN,
script: [[successResult()], [successResult()]],
});
const { sessionId } = await agent.newSession(newSessionParams());
await expect(agent.prompt(promptParams(sessionId))).resolves.toMatchObject({
stopReason: "end_turn",
});
await settleProbe();
// Nothing to compare: an absent kind is not a mismatch.
expect(agent.sessions[sessionId].accountKind).toBeUndefined();
expect(agent.sessions[sessionId].needsSignOutRespawn).toBeUndefined();
await expect(agent.prompt(promptParams(sessionId))).resolves.toMatchObject({
stopReason: "end_turn",
});
expect(mockQuery).toHaveBeenCalledTimes(1);
});
});
});
describe("reason-aware failure dedupe", () => {