diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts new file mode 100644 index 00000000..13780134 --- /dev/null +++ b/src/AcpExtensions.ts @@ -0,0 +1,11 @@ +export type ExtMethodRequest = AuthenticationStatusRequest | AuthenticationLogoutRequest + +export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { + return request.method === "authentication/status" || request.method === "authentication/logout"; +} + +export type AuthenticationStatusRequest = { method: "authentication/status", params: {} } +export type AuthenticationStatusResponse = { type: "api-key" } | { type: "chat-gpt", email: string } | { type: "gateway", name: string } | { type: "unauthenticated" } + +export type AuthenticationLogoutRequest = { method: "authentication/logout", params: {} } +export type AuthenticationLogoutResponse = {} \ No newline at end of file diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index ed572dfd..58081af3 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -25,6 +25,7 @@ import type { UserInput, } from "./app-server/v2"; import packageJson from "../package.json"; +import type {AuthenticationLogoutResponse, AuthenticationStatusResponse} from "./AcpExtensions"; /** * API for accessing the Codex App Server using ACP requests. @@ -113,9 +114,47 @@ export class CodexAcpClient { return result.success; } - async logout(): Promise { + + async getAuthenticationStatus(): Promise { + const modelProvider = await this.getCurrentModelProvider(); + if (modelProvider) { + return { + type: "gateway", + name: modelProvider, + }; + } + const account = (await this.getAccount()).account; + if (account === null) { + return { + type: "unauthenticated", + }; + } + switch (account.type) { + case "apiKey": + return { + type: "api-key", + }; + case "chatgpt": + return { + type: "chat-gpt", + email: account.email, + }; + } + } + + async getCurrentModelProvider(): Promise { + const sessionModelProvider = this.getModelProvider(); + if (sessionModelProvider !== null) { + return sessionModelProvider; + } + const settingsModelProvider = await this.codexClient.configRead({includeLayers: false}); + return settingsModelProvider.config.model_provider; + } + + async logout(): Promise { await this.codexClient.accountLogout(); await this.codexClient.awaitAccountUpdated(); + return {}; } async authRequired(): Promise { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 85fa9582..0c5a6f70 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -18,6 +18,7 @@ import type {TokenCount} from "./TokenCount"; import {CodexCommands} from "./CodexCommands"; import type {QuotaMeta} from "./QuotaMeta"; import {logger} from "./Logger"; +import {isExtMethodRequest} from "./AcpExtensions"; export interface SessionState { sessionId: string, @@ -86,6 +87,19 @@ export class CodexAcpServer implements acp.Agent { }; } + async extMethod(method: string, params: Record): Promise> { + const methodRequest = { method: method, params: params }; + if (!isExtMethodRequest(methodRequest)) { + return {}; + } + switch (methodRequest.method) { + case "authentication/status": + return await this.runWithProcessCheck(() => this.codexAcpClient.getAuthenticationStatus()); + case "authentication/logout": + return await this.runWithProcessCheck(() => this.codexAcpClient.logout()); + } + } + async checkAuthorization(){ const authNeeded = await this.runWithProcessCheck(() => this.codexAcpClient.authRequired()); logger.log("Auth requirement checked", {authRequired: authNeeded}); diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 11468a85..f8353b4b 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -26,7 +26,7 @@ import type { SkillsListParams, SkillsListResponse, ListMcpServerStatusParams, - ListMcpServerStatusResponse, + ListMcpServerStatusResponse, ConfigReadParams, ConfigReadResponse, } from "./app-server/v2"; export interface ApprovalHandler { @@ -124,6 +124,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "account/logout", params: undefined }); } + async configRead(params: ConfigReadParams): Promise { + return await this.sendRequest({ method: "config/read", params: params }); + } + async awaitLoginCompleted(): Promise { return await new Promise((resolve) => { this.connection.onNotification("account/login/completed", (event: AccountLoginCompletedNotification) => { diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 3a4bbaab..7b1d91c3 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -54,6 +54,11 @@ describe('ACP server test', { timeout: 40_000 }, () => { await codexAcpAgent.initialize({protocolVersion: 1}); await fixture.getCodexAcpClient().logout(); + + + const unauthenticatedResponse = await fixture.getCodexAcpAgent().extMethod("authentication/status", {}); + expect(unauthenticatedResponse).toEqual({type: "unauthenticated"}); + fixture.clearCodexConnectionDump(); const authRequest: CodexAuthRequest = { methodId: "api-key", _meta: { "api-key": { apiKey: "TOKEN" }}} @@ -63,6 +68,13 @@ describe('ACP server test', { timeout: 40_000 }, () => { const transportDump = fixture.getCodexConnectionDump([...ignoredFields, "upgrade"]); await expect(transportDump).toMatchFileSnapshot("data/auth-with-key.json"); + + const authenticatedResponse = await fixture.getCodexAcpAgent().extMethod("authentication/status", {}); + expect(authenticatedResponse).toEqual({type: "api-key"}); + + await fixture.getCodexAcpAgent().extMethod("authentication/logout", {}); + const logoutResponse = await fixture.getCodexAcpAgent().extMethod("authentication/status", {}); + expect(logoutResponse).toEqual({type: "unauthenticated"}); }); it('should authenticate with a gateway', async () => { @@ -86,6 +98,9 @@ describe('ACP server test', { timeout: 40_000 }, () => { await codexAcpAgent.authenticate(authRequest); expect(await fixture.getCodexAcpClient().authRequired()).toBe(false); + const authenticatedResponse = await fixture.getCodexAcpAgent().extMethod("authentication/status", {}); + expect(authenticatedResponse).toEqual({type: "gateway", name: "custom-gateway"}); + const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); expect(newSessionResponse.sessionId).toBeDefined() }) @@ -358,7 +373,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const sessionState: SessionState = createTestSessionState(); - const logoutSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "logout").mockResolvedValue(undefined); + const logoutSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "logout").mockResolvedValue({}); // @ts-expect-error - exercising private helper const handled = await codexAcpAgent.availableCommands.handleCommand({ name: "logout", input: null }, sessionState);