mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat(auth): report the agent's auth identity over ACP (authStatus extension) (#1080)
This commit is contained in:
committed by
GitHub
parent
847a3b6429
commit
eb5ca9fd17
333
src/acp-agent.ts
333
src/acp-agent.ts
@@ -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
337
src/auth-status.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
947
src/tests/auth-status.test.ts
Normal file
947
src/tests/auth-status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user