mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 19:32:21 +00:00
fix: distinguish Ultra from Max in auth status (#1574)
## Summary - Read the optional `planTier` field from the existing `claude auth status --json` probe. Display Max subscriptions with `max_20x` as **Claude Ultra**, while `max_5x` remains **Claude Max**. - Fall back to the existing subscription label when the tier is missing, unknown, or malformed. Preserve auth-source precedence and raw plan metadata. - Keep the CLI-derived label when a session SDK account read reports the same identity and plan without a tier. A fresh CLI probe can still replace the label, including when its tier is absent. - Cover ACP v1 and v2 notifications and responses, and document the display behavior. The SDK AccountInfo omits the plan tier, but the bundled CLI already emits it in auth-status JSON. This uses that CLI-only field as best-effort display metadata, without additional probes, direct credential-file reads, or entitlement checks. ## Validation - `npm run build` - `npm run check` - `npm run test:run` — 4,002 passed, 33 skipped - Targeted v1 auth-status and v2 protocol tests — 341 passed - Local bundled CLI smoke check: `subscriptionType: "max"`, `planTier: "max_20x"` maps to `Claude Ultra` - Live model integration tests were not run.
This commit is contained in:
@@ -240,6 +240,8 @@ Matching is case-insensitive and accepts multiple whitespace characters between
|
||||
|
||||
Other active helpers use `label: "Anthropic API key"` and `detail: "API key helper"`; unknown key-source identifiers are not exposed as display text. CLI and SDK reads share this presentation, retain stale-probe protection, and suppress unchanged notifications.
|
||||
|
||||
For Max subscriptions, the existing `claude auth status --json` probe's optional `planTier` distinguishes `max_20x` (displayed as `Claude Ultra`) from `max_5x` (`Claude Max`). Missing, unrecognized, or malformed tiers fall back to the subscription's usual label. Other subscriptions and auth sources keep their existing classification. The raw `account.plan` and ACP `_meta.claudeCode.plan` remain unchanged. SDK account reads lack the tier, so they preserve the known label for the same account and plan; a fresh CLI read replaces it, including when the tier is absent. This is display metadata, not an entitlement check, and requires no additional probe or direct credential-file reads.
|
||||
|
||||
## Zed conventions
|
||||
|
||||
The adapter keeps these Zed and upstream conventions unchanged for every client.
|
||||
|
||||
@@ -188,8 +188,8 @@ export class AuthStatusReporter {
|
||||
/**
|
||||
* Report the identity behind a session's `AccountInfo`.
|
||||
*
|
||||
* `AccountInfo` is richer than the CLI probe (it is what the live query
|
||||
* actually authenticates with). The v1 core calls it before the
|
||||
* `AccountInfo` describes what the live query authenticates with, but lacks
|
||||
* the CLI's plan tier. The v1 core calls it 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
|
||||
@@ -225,7 +225,7 @@ export class AuthStatusReporter {
|
||||
}
|
||||
const fromSession = fromAccountInfo(account, apiKeyHelper);
|
||||
if (fromSession) {
|
||||
this.set(fromSession);
|
||||
this.set(mergeAuthStatus(this.current, fromSession, "session"));
|
||||
} else if (!this.loggedUninformativeAccount) {
|
||||
this.loggedUninformativeAccount = true;
|
||||
this.options.logger.log(
|
||||
@@ -337,7 +337,7 @@ export class AuthStatusReporter {
|
||||
// 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.current, status);
|
||||
const merged = mergeAuthStatus(this.current, status, "cli");
|
||||
this.set(merged);
|
||||
this.options.onProbe?.(status.kind);
|
||||
return merged;
|
||||
|
||||
@@ -144,6 +144,8 @@ export type CliAuthStatus = {
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
subscriptionType?: string;
|
||||
/** CLI-only tier, e.g. "max_5x" or "max_20x"; not in SDK AccountInfo. */
|
||||
planTier?: string;
|
||||
};
|
||||
|
||||
const NOT_LOGGED_IN: AuthStatus = {
|
||||
@@ -270,6 +272,7 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus
|
||||
return mapAuthFields({
|
||||
apiProvider: status.apiProvider,
|
||||
subscriptionType: status.subscriptionType,
|
||||
planTier: status.planTier,
|
||||
apiKeySource: status.apiKeySource,
|
||||
email: status.email,
|
||||
organization: status.orgName,
|
||||
@@ -290,6 +293,7 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus
|
||||
function mapAuthFields(fields: {
|
||||
apiProvider?: string;
|
||||
subscriptionType?: string;
|
||||
planTier?: string;
|
||||
apiKeySource?: string;
|
||||
email?: string;
|
||||
organization?: string;
|
||||
@@ -329,7 +333,7 @@ function mapAuthFields(fields: {
|
||||
if (organization) account.organization = organization;
|
||||
return {
|
||||
kind: "account",
|
||||
label: planLabel(subscriptionType),
|
||||
label: planLabel(subscriptionType, fields.planTier),
|
||||
// No detail: the client falls back to `account.email` for line 2.
|
||||
account,
|
||||
};
|
||||
@@ -337,12 +341,14 @@ function mapAuthFields(fields: {
|
||||
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 {
|
||||
/** Names the plan with exactly one "Claude". Without a tier, "Claude Max"
|
||||
* from a newer CLI and "max" from an older one both read "Claude Max".
|
||||
* Each word is title-cased; a word that already carries capitals is kept.
|
||||
* The CLI's max_20x tier identifies Ultra; other tiers keep the plan label. */
|
||||
function planLabel(plan: string, planTier?: 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}`;
|
||||
const label = /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`;
|
||||
return label.toLowerCase() === "claude max" && planTier === "max_20x" ? "Claude Ultra" : label;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,8 +376,14 @@ export function sameIdentity(a: AuthStatus, b: AuthStatus): boolean {
|
||||
* 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.
|
||||
* Session reads lack the CLI's plan tier, so keep the label for the same plan.
|
||||
* A CLI read always replaces the label, including when its tier is absent.
|
||||
*/
|
||||
export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStatus): AuthStatus {
|
||||
export function mergeAuthStatus(
|
||||
previous: AuthStatus | undefined,
|
||||
next: AuthStatus,
|
||||
source: "cli" | "session",
|
||||
): AuthStatus {
|
||||
if (!previous || !sameIdentity(previous, next)) {
|
||||
return next;
|
||||
}
|
||||
@@ -382,6 +394,14 @@ export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStat
|
||||
if (previous.account || next.account) {
|
||||
merged.account = { ...previous.account, ...next.account };
|
||||
}
|
||||
if (
|
||||
source === "session" &&
|
||||
previous.account?.plan &&
|
||||
next.account?.plan &&
|
||||
planLabel(previous.account.plan).toLowerCase() === planLabel(next.account.plan).toLowerCase()
|
||||
) {
|
||||
merged.label = previous.label;
|
||||
}
|
||||
if (merged.vendor === undefined && previous.vendor !== undefined) {
|
||||
merged.vendor = previous.vendor;
|
||||
}
|
||||
|
||||
@@ -363,6 +363,48 @@ describe("auth status mappers", () => {
|
||||
).toEqual({ kind: "account", label: "Claude subscription", detail: "OAuth token" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["max", "max_20x", "Claude Ultra"],
|
||||
["Claude Max", "max_20x", "Claude Ultra"],
|
||||
["max", "max_5x", "Claude Max"],
|
||||
["max", "max_other", "Claude Max"],
|
||||
["max", undefined, "Claude Max"],
|
||||
["max", "future_tier", "Claude Max"],
|
||||
["max", null, "Claude Max"],
|
||||
["max", 20, "Claude Max"],
|
||||
["max", { tier: "max_20x" }, "Claude Max"],
|
||||
["max", ["max_20x"], "Claude Max"],
|
||||
["pro", "max_20x", "Claude Pro"],
|
||||
["Future Plan", "future_tier", "Claude Future Plan"],
|
||||
])("labels CLI plan %j with tier %j as %j", (subscriptionType, planTier, label) => {
|
||||
expect(
|
||||
fromCliStatus(
|
||||
JSON.stringify({
|
||||
...JSON.parse(CLI_SUBSCRIPTION),
|
||||
subscriptionType,
|
||||
planTier,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
kind: "account",
|
||||
label,
|
||||
account: { plan: subscriptionType, email: "user@example.com", organization: "ACME" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ apiKeySource: "env" }, { kind: "api_key", label: "Anthropic API key", detail: "env" }],
|
||||
[{ apiProvider: "bedrock" }, { kind: "external", label: "AWS Bedrock" }],
|
||||
[{ apiProvider: "gateway" }, { kind: "gateway", label: "Custom model gateway" }],
|
||||
[{ loggedIn: false }, { kind: "none", label: "Not logged in" }],
|
||||
])("does not let a subscription tier override auth fields %j", (fields, expected) => {
|
||||
expect(
|
||||
fromCliStatus(
|
||||
JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier: "max_20x", ...fields }),
|
||||
),
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -384,19 +426,27 @@ describe("auth status mappers", () => {
|
||||
};
|
||||
// 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" },
|
||||
}),
|
||||
mergeAuthStatus(
|
||||
stored,
|
||||
{
|
||||
kind: "account",
|
||||
label: "Claude Max",
|
||||
account: { plan: "max", email: "user@example.com" },
|
||||
},
|
||||
"cli",
|
||||
),
|
||||
).toEqual(stored);
|
||||
// A different login replaces everything, organization included.
|
||||
expect(
|
||||
mergeAuthStatus(stored, {
|
||||
kind: "account",
|
||||
label: "Claude Pro",
|
||||
account: { plan: "pro", email: "other@example.com" },
|
||||
}),
|
||||
mergeAuthStatus(
|
||||
stored,
|
||||
{
|
||||
kind: "account",
|
||||
label: "Claude Pro",
|
||||
account: { plan: "pro", email: "other@example.com" },
|
||||
},
|
||||
"cli",
|
||||
),
|
||||
).toEqual({
|
||||
kind: "account",
|
||||
label: "Claude Pro",
|
||||
@@ -404,7 +454,11 @@ describe("auth status mappers", () => {
|
||||
});
|
||||
// A different kind never merges, even when it looks adjacent.
|
||||
expect(
|
||||
mergeAuthStatus(stored, { kind: "api_key", label: "Anthropic API key", detail: "env" }),
|
||||
mergeAuthStatus(
|
||||
stored,
|
||||
{ kind: "api_key", label: "Anthropic API key", detail: "env" },
|
||||
"cli",
|
||||
),
|
||||
).toEqual({ kind: "api_key", label: "Anthropic API key", detail: "env" });
|
||||
// Key sources identify api_key payloads.
|
||||
expect(
|
||||
@@ -414,6 +468,29 @@ describe("auth status mappers", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each<["cli" | "session", string, string, string | undefined, string]>([
|
||||
["session", "max", "user@example.com", undefined, "Claude Ultra"],
|
||||
["session", "Claude Max", "user@example.com", undefined, "Claude Ultra"],
|
||||
["session", "pro", "user@example.com", undefined, "Claude Pro"],
|
||||
["session", "max", "other@example.com", undefined, "Claude Max"],
|
||||
["cli", "max", "user@example.com", "max_5x", "Claude Max"],
|
||||
["cli", "max", "user@example.com", undefined, "Claude Max"],
|
||||
["cli", "max", "user@example.com", "future_tier", "Claude Max"],
|
||||
])(
|
||||
"merges a %s read of %s for %s with tier %s as %s",
|
||||
(source, subscriptionType, email, planTier, label) => {
|
||||
const previous = fromCliStatus(
|
||||
JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier: "max_20x" }),
|
||||
);
|
||||
const fields = { apiProvider: "firstParty" as const, subscriptionType, email };
|
||||
const next =
|
||||
source === "session"
|
||||
? fromAccountInfo(fields)
|
||||
: fromCliStatus(JSON.stringify({ ...fields, loggedIn: true, planTier }));
|
||||
expect(mergeAuthStatus(previous, next!, source).label).toBe(label);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("auth status over ACP", () => {
|
||||
@@ -859,6 +936,32 @@ describe("auth status over ACP", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["max_5x", undefined, "future_tier"])(
|
||||
"keeps Ultra across session reads and refreshes the label for CLI tier %j",
|
||||
async (planTier) => {
|
||||
statusStdout = JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier: "max_20x" });
|
||||
await initialize();
|
||||
const sessionId = await scriptedSession({
|
||||
apiProvider: "firstParty",
|
||||
subscriptionType: "Claude Max",
|
||||
email: "user@example.com",
|
||||
});
|
||||
await promptOnce(sessionId);
|
||||
expect(updates().every((call) => call[1].authStatus.label === "Claude Ultra")).toBe(true);
|
||||
expect(await agent.authStatusResponse()).toMatchObject({
|
||||
accounts: [{ label: "Claude Ultra", _meta: { claudeCode: { plan: "max" } } }],
|
||||
});
|
||||
await settle();
|
||||
|
||||
statusStdout = JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier });
|
||||
await promptOnce(sessionId);
|
||||
expect(lastPush()?.label).toBe("Claude Max");
|
||||
expect(authStatusUpdate.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
accounts: [{ label: "Claude Max" }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("lets a probe of a different identity replace the stored one wholesale", async () => {
|
||||
const sessionId = await scriptedSession({
|
||||
apiProvider: "firstParty",
|
||||
|
||||
@@ -9379,6 +9379,48 @@ describe("ACP v2 auth", () => {
|
||||
expect(acpAuthUpdates).toEqual([status]);
|
||||
});
|
||||
|
||||
it.each(["max_5x", undefined, "future_tier"])(
|
||||
"keeps Ultra across session reads and refreshes the label for CLI tier %j",
|
||||
async (nextTier) => {
|
||||
let planTier: string | undefined = "max_20x";
|
||||
execFileSpy.mockImplementation((...invocation: unknown[]) => {
|
||||
const callback = invocation[invocation.length - 1] as (...a: unknown[]) => void;
|
||||
callback(null, {
|
||||
stdout: JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier }),
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
const account = {
|
||||
apiProvider: "firstParty",
|
||||
subscriptionType: "Claude Max",
|
||||
email: "user@example.com",
|
||||
};
|
||||
const initializationResult = sdk.initializationResult;
|
||||
sdk.initializationResult = async () => ({ ...(await initializationResult()), account });
|
||||
sdk.accountInfo = async () => account;
|
||||
const { app, authUpdate, acpAuthUpdates } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
expect((await authUpdate(1)).authStatus.label).toBe("Claude Ultra");
|
||||
await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
expect(await agent.request(v2.methods.agent.auth.status, {})).toMatchObject({
|
||||
accounts: [{ label: "Claude Ultra" }],
|
||||
});
|
||||
expect(
|
||||
acpAuthUpdates.every((update) => update.accounts?.[0].label === "Claude Ultra"),
|
||||
).toBe(true);
|
||||
|
||||
planTier = nextTier;
|
||||
await agent.request(v2.methods.agent.auth.status, {});
|
||||
await vi.waitFor(() =>
|
||||
expect(acpAuthUpdates.at(-1)).toMatchObject({
|
||||
accounts: [{ label: "Claude Max", _meta: { claudeCode: { plan: "max" } } }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* In a remote environment with --hide-claude-auth, initialize offers the
|
||||
* console login as a terminal method and hides the TUI subscription login.
|
||||
|
||||
Reference in New Issue
Block a user