Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/air-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions src/AuthStatusMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
free: "Free",
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only place in the code that explains why a provider ID of wire is shown with JetBrains branding, and right now it explains nothing. Could we pull "wire" and the label into named constants next to DEFAULT_GATEWAY_LABEL, and add a short doc comment like the ones on the neighbouring functions? It should say this is a display-only heuristic on the agent-owned provider ID (see docs/air-extensions.md). Otherwise the next reader has to guess whether matching the bare ID is deliberate.

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",
Expand Down
5 changes: 3 additions & 2 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter";
import {nameFromAgentPath} from "./subagents/CodexAgentPath";
import {
accountFromUpdated,
agentConfiguredProviderStatus,
fromAccount,
fromAccountUpdated,
gatewayStatus,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1518,7 +1519,7 @@ export class CodexAcpServer {
await this.publishAuthStatusRead();
return;
}
await this.setAuthStatus(gatewayStatus(authProvider));
await this.setAuthStatus(agentConfiguredProviderStatus(authProvider));
}

/**
Expand Down
81 changes: 80 additions & 1 deletion src/__tests__/CodexACPAgent/auth-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
Expand Down
Loading