From 4fdeb6f1fa7f2b0d3d4b6a39dd0f09271c7df9be Mon Sep 17 00:00:00 2001 From: "sergey.shkredov" Date: Tue, 6 Oct 2026 18:13:46 +0200 Subject: [PATCH] fix: display selected wire provider as JetBrains Air Gateway Co-authored-by: Junie Co-Authored-By: Claude Opus 5.5 --- docs/air-extensions.md | 12 +++ src/AuthStatusMeta.ts | 17 ++++ src/CodexAcpServer.ts | 5 +- .../CodexACPAgent/auth-status.test.ts | 81 ++++++++++++++++++- 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 79f4a593..42c79778 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -207,6 +207,18 @@ The adapter keeps them where they are. | `quota` | `PromptResponse._meta.quota` | Token usage and rate limits of the turn. | | `authStatus` | `initialize` response `agentCapabilities._meta.authStatus` | The agent pushes `_auth/status_update`. The object carries no payload. | +## Auth source display + +The agent-owned provider ID comes from `getAgentConfiguredModelProvider()`: launch options take priority over the effective app-server `configRead`, and client-owned `providers/set` routing is ignored for identity reporting. + +An active `model_provider` ID equal to `wire` after trimming whitespace and ignoring case is displayed as: + +```json +{ "authStatus": { "kind": "gateway", "label": "JetBrains Air Gateway" } } +``` + +The configured friendly `name`, URL, host and route are not checked. An unused provider or another ID whose friendly name is `wire` does not match. This is a weak display heuristic, not proof of configuration provenance or access rights. This payload omits `detail` and `account` and contains no keys, headers or private routes. Both the initial push and subsequent status publication use the same classification and existing duplicate suppression. Other providers and gateway authentication names retain their previous presentation. + ## Zed conventions The adapter keeps these Zed conventions for every client: diff --git a/src/AuthStatusMeta.ts b/src/AuthStatusMeta.ts index 88ca881b..85987e50 100644 --- a/src/AuthStatusMeta.ts +++ b/src/AuthStatusMeta.ts @@ -78,6 +78,10 @@ export function authStatusCapability(): AuthStatusCapability { const NOT_LOGGED_IN_LABEL = "Not logged in"; const DEFAULT_GATEWAY_LABEL = "Custom model gateway"; +/** Agent-owned `model_provider` ID that the JetBrains Air Gateway setup writes. */ +const AIR_GATEWAY_PROVIDER_ID = "wire"; +/** Same label the Junie agent reports for this gateway, so AIR shows one name. */ +const AIR_GATEWAY_LABEL = "JetBrains Air Gateway"; const PLAN_DISPLAY_NAMES: Record = { free: "Free", @@ -126,6 +130,19 @@ export function gatewayStatus(providerName?: string | null): AuthStatus { }; } +/** + * Status for the agent-owned provider ID. Matching the bare `wire` ID is + * deliberate: it is a display-only heuristic, not proof of where the config + * came from, and the provider name, URL and route are not checked + * (see `docs/air-extensions.md`, "Auth source display"). + */ +export function agentConfiguredProviderStatus(providerId: string | null): AuthStatus { + if (providerId?.trim().toLowerCase() === AIR_GATEWAY_PROVIDER_ID) { + return {kind: "gateway", label: AIR_GATEWAY_LABEL}; + } + return gatewayStatus(providerId); +} + export function unauthenticatedStatus(): AuthStatus { return { kind: "none", diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 5590c633..c0fa3a5e 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -134,6 +134,7 @@ import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter"; import {nameFromAgentPath} from "./subagents/CodexAgentPath"; import { accountFromUpdated, + agentConfiguredProviderStatus, fromAccount, fromAccountUpdated, gatewayStatus, @@ -1485,7 +1486,7 @@ export class CodexAcpServer { } const modelProvider = await this.runWithProcessCheck(() => this.codexAcpClient.getAgentConfiguredModelProvider()); if (!this.authProviderUsesOpenAiAccount(modelProvider)) { - return gatewayStatus(modelProvider); + return agentConfiguredProviderStatus(modelProvider); } const accountResponse = await this.runWithProcessCheck(() => this.codexAcpClient.getAccount()); this.lastReadAccount = accountResponse.account; @@ -1518,7 +1519,7 @@ export class CodexAcpServer { await this.publishAuthStatusRead(); return; } - await this.setAuthStatus(gatewayStatus(authProvider)); + await this.setAuthStatus(agentConfiguredProviderStatus(authProvider)); } /** diff --git a/src/__tests__/CodexACPAgent/auth-status.test.ts b/src/__tests__/CodexACPAgent/auth-status.test.ts index 9325ca26..1d883d76 100644 --- a/src/__tests__/CodexACPAgent/auth-status.test.ts +++ b/src/__tests__/CodexACPAgent/auth-status.test.ts @@ -17,7 +17,7 @@ import { import {ModelId} from "../../ModelId"; import {PROTOCOL_VERSION} from "@agentclientprotocol/sdk"; import type {AcpClientConnection} from "../../ACPSessionConnection"; -import type {Account, AccountUpdatedNotification} from "../../app-server/v2"; +import type {Account, AccountUpdatedNotification, Config, ConfigReadResponse} from "../../app-server/v2"; const CHAT_GPT_PLUS: AuthStatus = { kind: "account", @@ -95,6 +95,85 @@ function gatewayAuthRequest(providerName: string) { } describe("authStatus extension", () => { + describe("Central provider display", () => { + const centralStatus: AuthStatus = {kind: "gateway", label: "JetBrains Air Gateway"}; + + function providerConfig(provider: string, name = "Any display name", baseUrl?: string): ConfigReadResponse { + return { + config: { + model_provider: provider, + model_providers: { + [provider.trim()]: {name, ...(baseUrl ? {base_url: baseUrl} : {})}, + unused: {name: "wire", base_url: "https://example.invalid/unused"}, + }, + } as unknown as Config, + origins: {}, + layers: null, + }; + } + + it.each([ + ["wire", "Custom name", undefined], + ["WIRE", "wire", "https://example.invalid/other-backend"], + [" WiRe ", "JetBrains Central CLI proxy", "http://127.0.0.1:12345/wire/redacted/codex/openai/v1"], + ])("uses active ID %j independently of name and URL", async (provider, name, baseUrl) => { + const fixture = createCodexMockTestFixture(); + const configRead = vi.spyOn(fixture.getCodexAppServerClient(), "configRead") + .mockResolvedValue(providerConfig(provider!, name!, baseUrl)); + const accountRead = mockAccount(fixture, {type: "chatgpt", email: "unused@example.com", planType: "plus"}); + expect(await initializeAndAwaitFirstPush(fixture)).toEqual(centralStatus); + expect(configRead).toHaveBeenCalledWith({includeLayers: false}); + expect(accountRead).not.toHaveBeenCalled(); + expect(JSON.stringify(authStatusUpdates(fixture))).not.toContain("redacted"); + }); + + it("retains ordinary provider display even when its friendly name is wire", async () => { + const fixture = createCodexMockTestFixture(); + vi.spyOn(fixture.getCodexAppServerClient(), "configRead").mockResolvedValue(providerConfig("another", "wire")); + expect(await initializeAndAwaitFirstPush(fixture)).toEqual({kind: "gateway", label: "Custom model gateway", detail: "another"}); + }); + + it("preserves launch ID priority over a Central entry in config", async () => { + const fixture = createCodexMockTestFixture(); + const configRead = vi.spyOn(fixture.getCodexAppServerClient(), "configRead").mockResolvedValue(providerConfig("wire")); + vi.spyOn(fixture.getCodexAcpClient(), "getModelProvider").mockReturnValue("openai"); + mockAccount(fixture, {type: "chatgpt", email: "user@example.com", planType: "plus"}); + expect(await initializeAndAwaitFirstPush(fixture)).toEqual(CHAT_GPT_PLUS); + expect(configRead).not.toHaveBeenCalled(); + }); + + it("uses the same display for session creation and suppresses account-event duplicates", async () => { + const fixture = createCodexMockTestFixture(); + const client = fixture.getCodexAcpClient(); + const modelProvider = vi.spyOn(client, "getModelProvider").mockReturnValue("wire"); + expect(await initializeAndAwaitFirstPush(fixture)).toEqual(centralStatus); + await createPromptableSession(fixture); + fixture.getCodexAcpAgent().handleAccountUpdated({authMode: "chatgpt", planType: "pro"}); + await drainScheduledWork(); + expect(authStatusUpdates(fixture)).toEqual([centralStatus]); + modelProvider.mockReturnValue("another"); + await createPromptableSession(fixture); + expect(authStatusUpdates(fixture).at(-1)).toEqual({kind: "gateway", label: "Custom model gateway", detail: "another"}); + }); + + it("keeps client-owned routing separate from the configured wire identity", async () => { + const fixture = createCodexMockTestFixture(); + vi.spyOn(fixture.getCodexAppServerClient(), "configRead").mockResolvedValue(providerConfig("wire")); + const agent = fixture.getCodexAcpAgent(); + await agent.setProvider({providerId: "openai", apiType: "openai", baseUrl: "https://example.invalid/client", headers: {Authorization: "dummy-secret"}}); + expect(await initializeAndAwaitFirstPush(fixture)).toEqual(centralStatus); + await createPromptableSession(fixture); + expect(authStatusUpdates(fixture)).toEqual([centralStatus]); + expect(JSON.stringify(authStatusUpdates(fixture))).not.toContain("dummy-secret"); + }); + + it("does not mistake a gateway authentication friendly name for the provider ID", async () => { + const fixture = createCodexMockTestFixture(); + await fixture.getCodexAcpAgent().authenticate(gatewayAuthRequest("wire")); + expect(authStatusUpdates(fixture)).toEqual([{kind: "gateway", label: "Custom model gateway", detail: "wire"}]); + }); + }); + describe("capability marker", () => { it("is advertised in the initialize response, without a payload", async () => { const fixture = createCodexMockTestFixture();