From d6c371704fb5fd74c3b329a3d756486975e3f180 Mon Sep 17 00:00:00 2001 From: Sergey Shkredov Date: Tue, 13 Oct 2026 14:29:48 +0200 Subject: [PATCH] fix: display active Central helpers as JetBrains Air Gateway (#1266) ### Summary Addresses [IJAI-2956](https://youtrack.jetbrains.com/issue/IJAI-2956). - Recognize Central only when the active key source is `apiKeyHelper` and its effective helper command contains the whole word `central` and the word sequence `proxy start`, case-insensitively, allowing multiple whitespace characters. - Read the effective command through the existing `SettingsManager`/SDK settings resolution, including project/managed overrides, rather than reimplementing configuration merging or executing helpers. - Return `kind: gateway` and the exact label `JetBrains Air Gateway`, without `detail` or invented account identity. URL, flags, routes, and endpoint ownership are intentionally not checked: this is an agreed weak display heuristic, not proof of provenance or entitlement. - Render ordinary helpers as `API key helper` instead of raw `apiKeyHelper`; do not expose unknown key-source identifiers. Preserve external backend and client-owned configuration priority, CLI/SDK merging, stale-probe protection, and duplicate suppression. - Add mapper and ACP-agent regression coverage for positive/negative commands, overrides, CLI/SDK results, and source changes; update `docs/air-extensions.md`. Examples matching the heuristic include `central proxy start` and `/opt/bin/CeNtRaL PrOxY StArT --arbitrary-flag`. `decentralized proxy start`, `central start`, and an inactive helper do not match. ### Sanitized auth status ```json { "kind": "gateway", "label": "JetBrains Air Gateway" } ``` No helper command, key, header, private route, or invented account information is sent. ### Verification Based on fresh upstream `main` at `0724b17`, rechecked immediately before publication. Changes and rebuilt `dist` are available in the main local clone; the user confirmed the local adapters work. ```bash npm run build npm run lint npm run test:run ``` - Build and lint passed. - Full test suite: **2484 passed, 30 existing skipped** across 44 passed test files and 4 skipped files. - No new skip flags or disabled tests were used. ### Scope No AIR UI, provisioning, personal configuration migration, effort/mode changes, or new ACP protocol. Plan files and personal configurations are not committed. --------- Co-authored-by: Junie Co-authored-by: Claude Opus 5.5 Co-authored-by: Andrey Bragin --- docs/air-extensions.md | 14 ++ src/acp-agent.ts | 79 +++++++++- src/auth-status.ts | 29 +++- src/settings.ts | 16 ++ src/tests/acp-agent.test.ts | 173 ++++++++++++++++++++-- src/tests/auth-status.test.ts | 169 +++++++++++++++++++-- src/tests/authorization.test.ts | 18 ++- src/tests/session-doubles.ts | 7 +- src/tests/settings-api-key-helper.test.ts | 40 +++++ 9 files changed, 506 insertions(+), 39 deletions(-) create mode 100644 src/tests/settings-api-key-helper.test.ts diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 73c0c754..bf5ccedb 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -206,6 +206,20 @@ The adapter keeps them where they are. | `quota` | `PromptResponse._meta.quota` | Token usage of the turn, also split by model. | | `authStatus` | `initialize` response `agentCapabilities._meta.authStatus` | The agent pushes `_auth/status_update`. The object carries no payload. | +## Auth source display + +The connection-scoped `_auth/status_update` continues to report agent-owned authentication, not client-owned `providers/set` routing. When the active key source is `apiKeyHelper`, the adapter reads the effective helper command through SDK `resolveSettings` / the session's `SettingsManager`, including programmatic settings overrides. + +A command containing the whole word `central` and the word sequence `proxy start` is displayed as: + +```json +{ "authStatus": { "kind": "gateway", "label": "JetBrains Air Gateway" } } +``` + +Matching is case-insensitive and accepts multiple whitespace characters between `proxy` and `start`. The command's flags, URL, host and route are not checked. This is a weak display heuristic, not proof of configuration provenance, account ownership or entitlement. Inactive helpers and external cloud backends retain their existing classification. No helper command, key, headers, private route or inferred account is included in this payload. + +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. + ## Zed conventions The adapter keeps these Zed and upstream conventions unchanged for every client. diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 417a8e4d..4f74eef3 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -63,6 +63,7 @@ import { CanUseTool, FastModeDisabledReason, FastModeState, + filterEscalatingDefaultMode, getSessionMessages, getSubagentMessages, HookCallback, @@ -79,6 +80,7 @@ import { PreToolUseHookInput, Query, query, + resolveSettings, SDKAssistantMessageError, SDKActiveGoalMessage, SDKMessage, @@ -2558,6 +2560,12 @@ export class ClaudeAcpAgent { /** Last auth identity reported to the client, connection-scoped like * `authenticate`/`logout`. Undefined means "not determined yet". */ currentAuthStatus?: AuthStatus; + private authStatusSettings?: { + manager: SettingsManager; + override?: Settings; + env?: Options["env"]; + }; + private authStatusHelper?: string; /** 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. */ @@ -3152,7 +3160,31 @@ export class ClaudeAcpAgent { * 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 { + private useAuthStatusSettings( + manager: SettingsManager, + settingsOverride?: Settings, + launchEnv?: Options["env"], + ): void { + const helper = manager.getApiKeyHelper(settingsOverride); + const changed = + manager.getCwd() !== (this.authStatusSettings?.manager.getCwd() ?? process.cwd()) || + helper !== this.authStatusHelper || + JSON.stringify(launchEnv) !== JSON.stringify(this.authStatusSettings?.env); + this.authStatusSettings = { manager, override: settingsOverride, env: launchEnv }; + this.authStatusHelper = helper; + if (changed) { + // The epoch bump discards any probe that read the old context, and the + // session account may carry no identity signal (an apiKeyHelper), so + // only a probe in the new context can classify it. + this.authEpoch += 1; + void this.probeCliAuthStatus({ fresh: true }); + } + } + + private publishSessionAccountIdentity( + account: AccountInfo | undefined, + apiKeyHelper?: string, + ): void { if (this.providerConfig) { if (!this.loggedOverriddenAccount) { this.loggedOverriddenAccount = true; @@ -3165,7 +3197,7 @@ export class ClaudeAcpAgent { if (this.gatewayAuthRequest) { return; } - const fromSession = fromAccountInfo(account); + const fromSession = fromAccountInfo(account, apiKeyHelper); if (fromSession) { this.setAuthStatus(fromSession); } else if (!this.loggedUninformativeAccount) { @@ -3199,6 +3231,8 @@ export class ClaudeAcpAgent { // 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; + const context = this.authStatusSettings; + const apiKeyHelper = context?.manager.getApiKeyHelper(context.override); // 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 @@ -3210,8 +3244,12 @@ export class ClaudeAcpAgent { let stdout: string; try { const cliPath = await claudeCliPath(); - ({ stdout } = await execFileAsync(cliPath, ["auth", "status", "--json"], { + const args = ["auth", "status", "--json"]; + if (context?.override) args.push("--settings", JSON.stringify(context.override)); + ({ stdout } = await execFileAsync(cliPath, args, { timeout: AUTH_STATUS_PROBE_TIMEOUT_MS, + cwd: context?.manager.getCwd() ?? process.cwd(), + env: context?.env ? { ...process.env, ...context.env } : process.env, })); } catch (error) { const failed = error as { stdout?: unknown; killed?: boolean; signal?: unknown } | null; @@ -3238,7 +3276,15 @@ export class ClaudeAcpAgent { } stdout = failed.stdout; } - const status = fromCliStatus(stdout); + let status = fromCliStatus(stdout, apiKeyHelper); + if (!context && status?.kind === "api_key" && status.detail === "API key helper") { + try { + const resolved = await resolveSettings({ cwd: process.cwd() }); + status = fromCliStatus(stdout, filterEscalatingDefaultMode(resolved).apiKeyHelper); + } catch { + this.logger.error("Could not resolve auth source settings"); + } + } if (!status) { this.logger.error("claude auth status returned unparseable output"); return undefined; @@ -3539,6 +3585,11 @@ export class ClaudeAcpAgent { // 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`). + this.useAuthStatusSettings( + session.settingsManager, + session.effortSettingsOverride, + (session.creationParams?._meta as NewSessionMeta | undefined)?.claudeCode?.options?.env, + ); void this.probeCliAuthStatus(); const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session); if (signOutRespawn) session = await signOutRespawn; @@ -3791,7 +3842,11 @@ export class ClaudeAcpAgent { 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), + onAccount: (account) => + this.publishSessionAccountIdentity( + account, + session.settingsManager.getApiKeyHelper(session.effortSettingsOverride), + ), }); } @@ -10338,6 +10393,8 @@ export class ClaudeAcpAgent { prompt: input, options, }); + this.useAuthStatusSettings(settingsManager, configuredSettingsObject, userProvidedOptions?.env); + const authSettingsEpoch = this.authEpoch; timing.phase("prepare-query"); // `query()` spawns the CLI at once. Any throw between here and the @@ -10363,7 +10420,12 @@ export class ClaudeAcpAgent { // 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); + if (authSettingsEpoch === this.authEpoch) { + this.publishSessionAccountIdentity( + initializationResult.account, + settingsManager.getApiKeyHelper(configuredSettingsObject), + ); + } // Shared with the per-turn guard, so "warn once" spans the whole session. const claudeSubscriptionGuard: ClaudeSubscriptionGuardState = {}; @@ -10606,7 +10668,10 @@ export class ClaudeAcpAgent { sessionFailureState: creationOpts.replayState?.sessionFailureState ?? createSessionFailureState(), claudeSubscriptionGuard, - accountKind: fromAccountInfo(initializationResult.account)?.kind, + accountKind: fromAccountInfo( + initializationResult.account, + settingsManager.getApiKeyHelper(configuredSettingsObject), + )?.kind, fileChangeReporter, }; timing.phase("register"); diff --git a/src/auth-status.ts b/src/auth-status.ts index 3922db06..7ce775ae 100644 --- a/src/auth-status.ts +++ b/src/auth-status.ts @@ -103,6 +103,13 @@ const NOT_LOGGED_IN: AuthStatus = { label: "Not logged in", }; +const API_KEY_SOURCE_DETAILS = new Map([ + ["apiKeyHelper", "API key helper"], + ["env", "env"], + ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"], + ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN"], +]); + /** Display names for the non-firstParty backends, where auth lives outside the * agent (AWS credentials, gcloud ADC, …). */ const EXTERNAL_PROVIDER_LABELS: Record = { @@ -162,7 +169,10 @@ export function accountInfoHasIdentitySignal(account: AccountInfo | undefined): /** 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 { +export function fromAccountInfo( + account: AccountInfo | undefined, + apiKeyHelper?: string, +): AuthStatus | undefined { if (!account || !accountInfoHasIdentitySignal(account)) { return undefined; } @@ -172,13 +182,14 @@ export function fromAccountInfo(account: AccountInfo | undefined): AuthStatus | apiKeySource: account.apiKeySource, email: account.email, organization: account.organization, + apiKeyHelper, }); } /** 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 { +export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus | undefined { let parsed: unknown; try { parsed = JSON.parse(stdout); @@ -205,6 +216,7 @@ export function fromCliStatus(stdout: string): AuthStatus | undefined { apiKeySource: status.apiKeySource, email: status.email, organization: status.orgName, + apiKeyHelper, }); } @@ -224,6 +236,7 @@ function mapAuthFields(fields: { apiKeySource?: string; email?: string; organization?: string; + apiKeyHelper?: string; }): AuthStatus { const { apiProvider, subscriptionType, apiKeySource, email, organization } = fields; @@ -237,11 +250,19 @@ function mapAuthFields(fields: { }; } if (apiKeySource) { + if ( + apiKeySource === "apiKeyHelper" && + typeof fields.apiKeyHelper === "string" && + /\bcentral\b/i.test(fields.apiKeyHelper) && + /\bproxy\s+start\b/i.test(fields.apiKeyHelper) + ) { + return { kind: "gateway", label: "JetBrains Air Gateway" }; + } + const detail = API_KEY_SOURCE_DETAILS.get(apiKeySource); return { kind: "api_key", label: "Anthropic API key", - // The source ("apiKeyHelper", "env", …) is the specifics line. - detail: apiKeySource, + ...(detail ? { detail } : {}), }; } if (subscriptionType) { diff --git a/src/settings.ts b/src/settings.ts index 9f5819d7..26ab85ab 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -50,6 +50,7 @@ export class SettingsManager { private cwd: string; private effective: Settings = {}; private managedDeniedModels: string[] = []; + private managedApiKeyHelper?: string; private watchers: fs.FSWatcher[] = []; private onChange?: () => void; private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void }; @@ -111,10 +112,15 @@ export class SettingsManager { this.managedDeniedModels = resolved.sources .filter((entry) => entry.source === "managed") .flatMap((entry) => entry.settings.deniedModels ?? []); + this.managedApiKeyHelper = + resolved.provenance.apiKeyHelper?.source === "managed" + ? resolved.effective.apiKeyHelper + : undefined; } catch (error) { this.logger.error("Failed to resolve settings:", error); this.effective = {}; this.managedDeniedModels = []; + this.managedApiKeyHelper = undefined; } } @@ -177,6 +183,16 @@ export class SettingsManager { return this.effective; } + /** + * The `apiKeyHelper` the CLI runs: managed settings outrank the `--settings` + * override, which outranks the user/project/local files. + */ + getApiKeyHelper(settingsOverride?: Settings): string | undefined { + return ( + this.managedApiKeyHelper ?? settingsOverride?.apiKeyHelper ?? this.effective.apiKeyHelper + ); + } + /** * `deniedModels` entries from the managed tier, the only tier the CLI * honors them from. diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 2ae6306c..c9400bca 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -54,7 +54,8 @@ import { import { SessionTitles } from "../session-titles.js"; import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "../usage-markdown.js"; import { Pushable } from "../utils.js"; -import { initializeClient } from "./helpers.js"; +import { initializeClient, makeMockQuery } from "./helpers.js"; +import * as agentSdk from "@anthropic-ai/claude-agent-sdk"; import { deleteSession, forkSession, @@ -792,6 +793,114 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration" }, 60000); }); +describe("Central auth settings integration", () => { + let fixture: string; + let originalConfigDir: string | undefined; + let agent: ClaudeAcpAgent; + let notification: ReturnType; + let querySpy: ReturnType; + + beforeEach(async () => { + fixture = await mkdtemp(path.join(os.tmpdir(), "central-auth-integration-")); + originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = path.join(fixture, "user"); + await mkdir(process.env.CLAUDE_CONFIG_DIR); + await mkdir(path.join(fixture, ".claude")); + await writeFile( + path.join(process.env.CLAUDE_CONFIG_DIR, "settings.json"), + JSON.stringify({ apiKeyHelper: "central proxy start" }), + ); + notification = vi.fn(async () => {}); + agent = new ClaudeAcpAgent( + { sessionUpdate: async () => {}, extNotification: notification } as unknown as AcpClient, + { log: () => {}, error: () => {} }, + ); + querySpy = vi.spyOn(agentSdk, "query").mockImplementation( + () => + makeMockQuery({ + initializationResult: async () => ({ + models: [{ value: "id", displayName: "name", description: "description" }], + account: { apiProvider: "firstParty", apiKeySource: "apiKeyHelper" }, + }), + }) as unknown as agentSdk.Query, + ); + }); + + afterEach(async () => { + for (const sessionId of Object.keys(agent.sessions)) await agent.closeSession({ sessionId }); + querySpy.mockRestore(); + if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + await rm(fixture, { recursive: true, force: true }); + }); + + it("uses the local project override instead of an unused user Central helper", async () => { + await writeFile( + path.join(fixture, ".claude", "settings.json"), + JSON.stringify({ apiKeyHelper: "central proxy start" }), + ); + await writeFile( + path.join(fixture, ".claude", "settings.local.json"), + JSON.stringify({ apiKeyHelper: "ordinary-helper" }), + ); + await agent.newSession({ cwd: fixture, mcpServers: [] }); + expect(notification).toHaveBeenLastCalledWith("_auth/status_update", { + authStatus: { kind: "api_key", label: "Anthropic API key", detail: "API key helper" }, + }); + }); + + it.each(["central proxy start", "", "ordinary-helper"])( + "honors the programmatic helper override %j", + async (helper) => { + await writeFile( + path.join(fixture, ".claude", "settings.json"), + JSON.stringify({ apiKeyHelper: "decentralized proxy start" }), + ); + await agent.newSession({ + cwd: fixture, + mcpServers: [], + _meta: { claudeCode: { options: { settings: { apiKeyHelper: helper } } } }, + }); + expect(notification).toHaveBeenLastCalledWith("_auth/status_update", { + authStatus: + helper === "central proxy start" + ? { kind: "gateway", label: "JetBrains Air Gateway" } + : { kind: "api_key", label: "Anthropic API key", detail: "API key helper" }, + }); + }, + ); + + it("does not let a late SDK result restore the previous helper name", async () => { + let finishOld!: (value: any) => void; + const oldAccount = new Promise((resolve) => { + finishOld = resolve; + }); + querySpy.mockImplementationOnce(() => + makeMockQuery({ initializationResult: () => oldAccount }), + ); + const oldSession = agent.newSession({ + cwd: fixture, + mcpServers: [], + _meta: { claudeCode: { options: { settings: { apiKeyHelper: "ordinary-helper" } } } }, + }); + await vi.waitFor(() => expect(querySpy).toHaveBeenCalledOnce()); + await agent.newSession({ cwd: fixture, mcpServers: [] }); + finishOld({ + models: [{ value: "id", displayName: "name", description: "description" }], + account: { apiKeySource: "apiKeyHelper" }, + }); + await oldSession; + expect(notification.mock.calls).toEqual([ + [ + "_auth/status_update", + { + authStatus: { kind: "gateway", label: "JetBrains Air Gateway" }, + }, + ], + ]); + }); +}); + describe("tool conversions", () => { it("should handle Bash nicely", () => { const tool_use = { @@ -4071,7 +4180,11 @@ describe("permission request cancellation", () => { modes: { currentModeId: "default", availableModes: [] }, models: { currentModelId: "default", availableModels: [] }, modelInfos: [], - settingsManager: { dispose: vi.fn() } as any, + settingsManager: { + dispose: vi.fn(), + getApiKeyHelper: () => undefined, + getCwd: () => "/test", + } as any, accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -7945,13 +8058,17 @@ describe("stop reason propagation", () => { }, }, }); - expect(updates).toContainEqual({ - sessionId: "test-session", - update: { - sessionUpdate: "session_info_update", - _meta: { jetbrains: { air: { version: 1, goal: null } } }, - }, - }); + // The turn settles at its `result`, and the cleared goal arrives after it, + // so prompt() can resolve before the consumer has published `goal: null`. + await vi.waitFor(() => + expect(updates).toContainEqual({ + sessionId: "test-session", + update: { + sessionUpdate: "session_info_update", + _meta: { jetbrains: { air: { version: 1, goal: null } } }, + }, + }), + ); expect(updates.some((notification) => notification.update?._meta?.claudeCode?.goal)).toBe( false, ); @@ -10867,7 +10984,11 @@ describe("session/close", () => { availableModels: [], }, modelInfos: [], - settingsManager: { dispose: vi.fn() } as any, + settingsManager: { + dispose: vi.fn(), + getApiKeyHelper: () => undefined, + getCwd: () => "/test", + } as any, accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -10963,7 +11084,11 @@ describe("session/delete", () => { modes: { currentModeId: "default", availableModes: [] }, models: { currentModelId: "default", availableModels: [] }, modelInfos: [], - settingsManager: { dispose: vi.fn() } as any, + settingsManager: { + dispose: vi.fn(), + getApiKeyHelper: () => undefined, + getCwd: () => "/test", + } as any, accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -11085,7 +11210,11 @@ describe("getOrCreateSession param change detection", () => { modes: { currentModeId: "default", availableModes: [] }, models: { currentModelId: "default", availableModels: [] }, modelInfos: [], - settingsManager: { dispose: vi.fn() } as any, + settingsManager: { + dispose: vi.fn(), + getApiKeyHelper: () => undefined, + getCwd: () => "/test", + } as any, accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -15349,7 +15478,11 @@ describe("post-error recovery", () => { modes: { currentModeId: "default", availableModes: [] }, models: { currentModelId: "default", availableModels: [] }, modelInfos: [], - settingsManager: { dispose: vi.fn() } as any, + settingsManager: { + dispose: vi.fn(), + getApiKeyHelper: () => undefined, + getCwd: () => "/test", + } as any, accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -15720,6 +15853,8 @@ describe("post-error recovery", () => { }, { settingsManager: { + getApiKeyHelper: () => undefined, + getCwd: () => "/test", dispose: vi.fn(() => { throw new Error("dispose boom"); }), @@ -22378,7 +22513,11 @@ describe("session/cancel wedge recovery (issue #680)", () => { modes: { currentModeId: "default", availableModes: [] }, models: { currentModelId: "default", availableModels: [] }, modelInfos: [], - settingsManager: { dispose: vi.fn() } as any, + settingsManager: { + dispose: vi.fn(), + getApiKeyHelper: () => undefined, + getCwd: () => "/test", + } as any, accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -24281,7 +24420,11 @@ describe("agent selection config option", () => { modes: { currentModeId: "default", availableModes: [] }, models: { currentModelId: "default", availableModels: [] }, modelInfos: [], - settingsManager: { dispose: vi.fn() } as any, + settingsManager: { + dispose: vi.fn(), + getApiKeyHelper: () => undefined, + getCwd: () => "/test", + } as any, accumulatedUsage: { inputTokens: 0, outputTokens: 0, diff --git a/src/tests/auth-status.test.ts b/src/tests/auth-status.test.ts index c13ff69f..2265a217 100644 --- a/src/tests/auth-status.test.ts +++ b/src/tests/auth-status.test.ts @@ -6,6 +6,9 @@ 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"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; const mockQuery = vi.hoisted(() => vi.fn()); @@ -142,6 +145,52 @@ function scriptedTurnQuery(args: any, account: Record) { } describe("auth status mappers", () => { + it.each([ + "central proxy start", + "/opt/tools/central proxy start --arbitrary-flag", + "CeNtRaL ProXy StArT --return-key", + "central.exe proxy\tstart", + ])("names the active Central helper %j without exposing its command", (helper) => { + const expected = { kind: "gateway", label: "JetBrains Air Gateway" }; + expect(fromCliStatus(CLI_KEY_HELPER_AND_SUBSCRIPTION, helper)).toEqual(expected); + expect( + fromAccountInfo( + { apiProvider: "firstParty", apiKeySource: "apiKeyHelper", subscriptionType: "max" }, + helper, + ), + ).toEqual(expected); + }); + + it.each([ + undefined, + "", + "decentralized proxy start", + "centralized proxy start", + "central proxy stop", + "another proxy start", + "central proxy starter", + ])("keeps a non-Central helper %j human readable", (helper) => { + expect(fromCliStatus(CLI_API_KEY, helper)).toEqual({ + kind: "api_key", + label: "Anthropic API key", + detail: "API key helper", + }); + }); + + it("does not use an inactive helper or override an external backend", () => { + const helper = "central proxy start"; + expect(fromCliStatus(CLI_BEDROCK, helper)).toEqual({ kind: "external", label: "AWS Bedrock" }); + expect(fromCliStatus(CLI_LOGGED_OUT, helper)).toEqual({ kind: "none", label: "Not logged in" }); + expect(fromAccountInfo({ apiProvider: "firstParty", apiKeySource: "env" }, helper)).toEqual({ + kind: "api_key", + label: "Anthropic API key", + detail: "env", + }); + expect(fromAccountInfo({ apiKeySource: "futureInternalIdentifier" }, helper)).toEqual({ + kind: "api_key", + label: "Anthropic API key", + }); + }); it("maps a first-party subscription account", () => { expect( fromAccountInfo({ @@ -188,7 +237,7 @@ describe("auth status mappers", () => { expect(fromCliStatus(CLI_KEY_HELPER_AND_SUBSCRIPTION)).toEqual({ kind: "api_key", label: "Anthropic API key", - detail: "apiKeyHelper", + detail: "API key helper", }); expect( fromAccountInfo({ @@ -201,7 +250,7 @@ describe("auth status mappers", () => { ).toEqual({ kind: "api_key", label: "Anthropic API key", - detail: "apiKeyHelper", + detail: "API key helper", }); }); @@ -244,7 +293,7 @@ describe("auth status mappers", () => { expect(fromCliStatus(CLI_API_KEY)).toEqual({ kind: "api_key", label: "Anthropic API key", - detail: "apiKeyHelper", + detail: "API key helper", }); expect(fromCliStatus(CLI_LOGGED_OUT)).toEqual({ kind: "none", @@ -298,7 +347,7 @@ describe("auth status mappers", () => { // 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: "API key helper" }, { kind: "api_key", label: "Anthropic API key", detail: "env" }, ), ).toBe(false); @@ -317,6 +366,8 @@ describe("auth status over ACP", () => { * Each probe captures the output configured when it was spawned. */ let deferStatus: boolean; let pendingStatus: Array<() => void>; + let configDir: string; + let originalConfigDir: string | undefined; /** Let every already-scheduled promise chain run to completion. */ function settle() { @@ -343,7 +394,10 @@ describe("auth status over ACP", () => { } } - beforeEach(() => { + beforeEach(async () => { + originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + configDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-status-test-")); + process.env.CLAUDE_CONFIG_DIR = configDir; // Skip native-binary resolution; the exec itself is mocked. process.env.CLAUDE_CODE_EXECUTABLE = "claude"; statusStdout = CLI_SUBSCRIPTION; @@ -382,7 +436,16 @@ describe("auth status over ACP", () => { } as unknown as AcpClient); }); - afterEach(() => { + afterEach(async () => { + for (const sessionId of Object.keys(agent.sessions)) { + await agent.closeSession({ sessionId }); + } + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + } + await fs.rm(configDir, { recursive: true, force: true }); delete process.env.CLAUDE_CODE_EXECUTABLE; vi.resetAllMocks(); }); @@ -425,6 +488,94 @@ describe("auth status over ACP", () => { return response; } + it("pushes Central from the effective pre-session settings and suppresses duplicates", async () => { + await fs.writeFile( + path.join(configDir, "settings.json"), + JSON.stringify({ + apiKeyHelper: "central proxy start --return-key", + env: { ANTHROPIC_BASE_URL: "https://example.invalid/private-route" }, + }), + ); + statusStdout = CLI_API_KEY; + await initialize(); + await vi.waitFor(() => + expect(lastPush()).toEqual({ kind: "gateway", label: "JetBrains Air Gateway" }), + ); + const sessionId = await scriptedSession({ + apiProvider: "firstParty", + apiKeySource: "apiKeyHelper", + }); + await promptOnce(sessionId); + expect(updates()).toHaveLength(1); + expect(JSON.stringify(updates())).not.toContain("private-route"); + expect(JSON.stringify(updates())).not.toContain("return-key"); + }); + + it("discards a late probe when programmatic settings replace the active helper", async () => { + await fs.writeFile( + path.join(configDir, "settings.json"), + JSON.stringify({ apiKeyHelper: "ordinary-helper" }), + ); + statusStdout = CLI_API_KEY; + deferStatus = true; + await initialize(); + mockQuery.mockImplementation((args: any) => + scriptedTurnQuery(args, { apiKeySource: "apiKeyHelper" }), + ); + const { sessionId } = await agent.newSession({ + cwd: process.cwd(), + mcpServers: [], + _meta: { + claudeCode: { + options: { + settings: { apiKeyHelper: "central proxy start" }, + env: { ANTHROPIC_BASE_URL: "https://example.invalid/override" }, + }, + }, + }, + }); + await flushStatus(); + expect(lastPush()).toEqual({ kind: "gateway", label: "JetBrains Air Gateway" }); + expect(updates()).toHaveLength(1); + expect(statusCalls().at(-1)?.[2]).toMatchObject({ + cwd: process.cwd(), + env: { ANTHROPIC_BASE_URL: "https://example.invalid/override" }, + }); + expect(statusCalls().at(-1)?.[1]).toEqual([ + "auth", + "status", + "--json", + "--settings", + JSON.stringify({ apiKeyHelper: "central proxy start" }), + ]); + await promptOnce(sessionId); + await flushStatus(); + expect(updates()).toHaveLength(1); + }); + + it("re-probes when a session switches to a Central helper after the initialize probe settled", async () => { + await fs.writeFile( + path.join(configDir, "settings.json"), + JSON.stringify({ apiKeyHelper: "ordinary-helper" }), + ); + statusStdout = CLI_API_KEY; + await initialize(); + expect(lastPush()?.kind).toBe("api_key"); + const probesBefore = statusCalls().length; + mockQuery.mockImplementation((args: any) => + scriptedTurnQuery(args, { apiProvider: "firstParty", apiKeySource: "apiKeyHelper" }), + ); + await agent.newSession({ + cwd: process.cwd(), + mcpServers: [], + _meta: { claudeCode: { options: { settings: { apiKeyHelper: "central proxy start" } } } }, + }); + await settle(); + // No prompt yet: the session switch itself must reclassify the helper. + expect(statusCalls()).toHaveLength(probesBefore + 1); + expect(lastPush()).toEqual({ kind: "gateway", label: "JetBrains Air Gateway" }); + }); + it("pushes the logged-out CLI verdict even though the probe exits non-zero", async () => { statusStdout = CLI_LOGGED_OUT; statusFails = true; @@ -580,7 +731,7 @@ describe("auth status over ACP", () => { authStatus: { kind: "api_key", label: "Anthropic API key", - detail: "apiKeyHelper", + detail: "API key helper", }, }, { @@ -615,7 +766,7 @@ describe("auth status over ACP", () => { expect(agent.currentAuthStatus).toEqual({ kind: "api_key", label: "Anthropic API key", - detail: "apiKeyHelper", + detail: "API key helper", }); }); @@ -647,7 +798,7 @@ describe("auth status over ACP", () => { expect(agent.currentAuthStatus).toEqual({ kind: "api_key", label: "Anthropic API key", - detail: "apiKeyHelper", + detail: "API key helper", }); }); diff --git a/src/tests/authorization.test.ts b/src/tests/authorization.test.ts index d74ea986..6be7cb31 100644 --- a/src/tests/authorization.test.ts +++ b/src/tests/authorization.test.ts @@ -1,4 +1,7 @@ import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest"; import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk"; import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js"; @@ -45,7 +48,13 @@ vi.mock("node:child_process", async () => { }); describe("authorization", () => { - beforeEach(() => { + let configDir: string; + let originalConfigDir: string | undefined; + + beforeEach(async () => { + originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + configDir = await mkdtemp(path.join(os.tmpdir(), "authorization-test-")); + process.env.CLAUDE_CONFIG_DIR = configDir; vi.useFakeTimers(); // Skip native-binary resolution; the exec itself is stubbed. process.env.CLAUDE_CODE_EXECUTABLE = "claude"; @@ -74,7 +83,7 @@ describe("authorization", () => { ); }); - afterEach(() => { + afterEach(async () => { //await all pending events like vi.runAllTimers(); vi.useRealTimers(); @@ -82,6 +91,9 @@ describe("authorization", () => { delete process.env.CLAUDE_CODE_EXECUTABLE; vi.unstubAllGlobals(); vi.resetAllMocks(); + if (originalConfigDir === undefined) delete realProcess.env.CLAUDE_CONFIG_DIR; + else realProcess.env.CLAUDE_CONFIG_DIR = originalConfigDir; + await rm(configDir, { recursive: true, force: true }); }); /** Third element: the `extNotification` spy, so a test can read the @@ -1124,7 +1136,7 @@ describe("authorization", () => { // 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" }, + { kind: "api_key", label: "Anthropic API key", detail: "API key helper" }, ]); }); diff --git a/src/tests/session-doubles.ts b/src/tests/session-doubles.ts index db1dd5d8..0e8ab289 100644 --- a/src/tests/session-doubles.ts +++ b/src/tests/session-doubles.ts @@ -64,7 +64,12 @@ export function mockSessionState( modes: { currentModeId: "default", availableModes: [] }, models: { currentModelId: "default", availableModels: [] }, modelInfos: [], - settingsManager: { dispose: vi.fn(), getSettings: () => ({}) }, + settingsManager: { + dispose: vi.fn(), + getSettings: () => ({}), + getApiKeyHelper: () => undefined, + getCwd: () => process.cwd(), + }, accumulatedUsage: { inputTokens: 0, outputTokens: 0, diff --git a/src/tests/settings-api-key-helper.test.ts b/src/tests/settings-api-key-helper.test.ts new file mode 100644 index 00000000..667d033d --- /dev/null +++ b/src/tests/settings-api-key-helper.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; + +const resolved = vi.hoisted(() => ({ value: undefined as unknown })); + +vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => ({ + ...(await importOriginal()), + resolveSettings: async () => resolved.value, +})); + +const { SettingsManager } = await import("../settings.js"); + +async function managerResolving(value: unknown) { + resolved.value = value; + const manager = new SettingsManager(process.cwd()); + await manager.initialize(); + return manager; +} + +describe("SettingsManager.getApiKeyHelper", () => { + it("prefers a managed helper over the --settings override", async () => { + const manager = await managerResolving({ + effective: { apiKeyHelper: "managed-helper" }, + provenance: { apiKeyHelper: { source: "managed" } }, + sources: [], + }); + expect(manager.getApiKeyHelper({ apiKeyHelper: "override-helper" })).toBe("managed-helper"); + manager.dispose(); + }); + + it("prefers the --settings override over a user helper", async () => { + const manager = await managerResolving({ + effective: { apiKeyHelper: "user-helper" }, + provenance: { apiKeyHelper: { source: "userSettings" } }, + sources: [], + }); + expect(manager.getApiKeyHelper({ apiKeyHelper: "override-helper" })).toBe("override-helper"); + expect(manager.getApiKeyHelper()).toBe("user-helper"); + manager.dispose(); + }); +});