diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 0c7af207..0a2baa46 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -1,4 +1,11 @@ -import {CODEX_API_KEY_ENV_VAR, GatewayAuthMethod, isCodexAuthRequest, OPENAI_API_KEY_ENV_VAR} from "./CodexAuthMethod"; +import { + type ApiKeyAuthRequest, + CODEX_API_KEY_ENV_VAR, + GatewayAuthMethod, + type GatewayAuthRequest, + isCodexAuthRequest, + OPENAI_API_KEY_ENV_VAR, +} from "./CodexAuthMethod"; import type {EmbeddedResourceResource} from "@agentclientprotocol/sdk"; import * as acp from "@agentclientprotocol/sdk"; import {type McpServer, RequestError} from "@agentclientprotocol/sdk"; @@ -52,6 +59,21 @@ import type {ModeKind} from "./app-server/ModeKind"; */ export const CUSTOM_GATEWAY_PROVIDER_ID = "custom-gateway"; +/** + * The url-mode variant of the ACP `elicitation/create` request params. + */ +export type CreateUrlElicitationRequest = Extract; + +/** + * The fields of a URL elicitation this layer can fill in; the ACP server layer + * supplies the rest (`mode`, `requestId`) when sending `elicitation/create`. + */ +export type UrlElicitationRequest = Omit; + +export interface UrlElicitationRequester { + elicitUrl(request: UrlElicitationRequest): Promise; +} + /** * ACP `LlmProtocol` values Codex can route through the custom gateway, mapped to * the Codex `wire_api`. Codex only supports the OpenAI Responses wire API here. @@ -106,47 +128,28 @@ export class CodexAcpClient { return this.configPath; } - async authenticate(authRequest: acp.AuthenticateRequest): Promise { + async authenticate( + authRequest: acp.AuthenticateRequest, + urlElicitationRequester?: UrlElicitationRequester, + ): Promise { if (!isCodexAuthRequest(authRequest)) { throw RequestError.invalidRequest(); } this.gatewayConfig = null; switch (authRequest.methodId) { - case "api-key": { - const apiKey = authRequest._meta?.["api-key"]?.apiKey ?? this.readApiKeyFromEnv(); - return await this.authenticateWithApiKey(apiKey); - } - case "chat-gpt": { - const accountResponse = await this.codexClient.accountRead({refreshToken: true}); - if (accountResponse.account?.type === "chatgpt") { - return true; - } - const loginCompletedPromise = this.awaitNextLoginCompleted(); - const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"}); - if (loginResponse.type == "chatgpt") { - await open(loginResponse.authUrl); - } - const result = await loginCompletedPromise; - return result.success; - } + case "api-key": + return await this.authenticateWithApiKey(authRequest); + case "chat-gpt": + return await this.authenticateWithChatGpt(); + case "chat-gpt-device-code": + return await this.authenticateWithChatGptDeviceCode(urlElicitationRequester); case "gateway": - if (!authRequest._meta) throw RequestError.invalidRequest(); - - const gatewaySettings = authRequest._meta["gateway"]; - if (!gatewaySettings) throw RequestError.invalidRequest(); - - this.applyGatewayConfig({ - baseUrl: gatewaySettings.baseUrl, - apiType: GatewayAuthMethod._meta.gateway.protocol, - headers: gatewaySettings.headers, - providerName: gatewaySettings.providerName, - }); - - return true; + return this.authenticateWithGateway(authRequest); } } - private async authenticateWithApiKey(apiKey: string): Promise { + private async authenticateWithApiKey(authRequest: ApiKeyAuthRequest): Promise { + const apiKey = authRequest._meta?.["api-key"]?.apiKey ?? this.readApiKeyFromEnv(); const loginCompletedPromise = this.awaitNextLoginCompleted(); await this.codexClient.accountLogin({ type: "apiKey", @@ -156,6 +159,62 @@ export class CodexAcpClient { return result.success; } + private async authenticateWithChatGpt(): Promise { + const accountResponse = await this.codexClient.accountRead({refreshToken: true}); + if (accountResponse.account?.type === "chatgpt") { + return true; + } + const loginCompletedPromise = this.awaitNextLoginCompleted(); + const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"}); + if (loginResponse.type == "chatgpt") { + await open(loginResponse.authUrl); + } + const result = await loginCompletedPromise; + return result.success; + } + + private async authenticateWithChatGptDeviceCode(urlElicitationRequester?: UrlElicitationRequester): Promise { + const accountResponse = await this.codexClient.accountRead({refreshToken: true}); + if (accountResponse.account?.type === "chatgpt") { + return true; + } + if (!urlElicitationRequester) { + throw RequestError.invalidRequest(undefined, "Device code authentication requires URL elicitation support"); + } + const loginCompletedPromise = this.awaitNextLoginCompleted(); + const loginResponse = await this.codexClient.accountLogin({type: "chatgptDeviceCode"}); + if (loginResponse.type !== "chatgptDeviceCode") { + return false; + } + const elicitationResponse = await urlElicitationRequester.elicitUrl({ + url: loginResponse.verificationUrl, + message: `Sign in to ChatGPT and enter this code: ${loginResponse.userCode}`, + elicitationId: loginResponse.loginId, + }); + if (!acp.CreateElicitationResponse.isAccept(elicitationResponse)) { + await this.codexClient.accountLoginCancel({loginId: loginResponse.loginId}); + return false; + } + const result = await loginCompletedPromise; + return result.success; + } + + private authenticateWithGateway(authRequest: GatewayAuthRequest): boolean { + if (!authRequest._meta) throw RequestError.invalidRequest(); + + const gatewaySettings = authRequest._meta["gateway"]; + if (!gatewaySettings) throw RequestError.invalidRequest(); + + this.applyGatewayConfig({ + baseUrl: gatewaySettings.baseUrl, + apiType: GatewayAuthMethod._meta.gateway.protocol, + headers: gatewaySettings.headers, + providerName: gatewaySettings.providerName, + }); + + return true; + } + private readApiKeyFromEnv(): string { for (const envVar of [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR]) { const value = process.env[envVar]?.trim(); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 928f2b16..f91c552a 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -4,18 +4,17 @@ import {CodexEventHandler} from "./CodexEventHandler"; import {CodexApprovalHandler} from "./CodexApprovalHandler"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; import {type CodexAuthRequest, getCodexAuthMethods, isCodexAuthRequest} from "./CodexAuthMethod"; -import {CodexAcpClient, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient"; +import {clientSupportsUrlElicitation} from "./ElicitationCapabilities"; +import { + CodexAcpClient, + type SessionMetadata, + type SessionMetadataWithThread, + type UrlElicitationRequester +} from "./CodexAcpClient"; import type {McpStartupResult} from "./CodexAppServerClient"; -import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; +import {type AcpClientConnection, ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; import type {InputModality, ReasoningEffort} from "./app-server"; -import type { - Account, - Model, - ReasoningEffortOption, - Thread, - ThreadItem, - UserInput -} from "./app-server/v2"; +import type {Account, Model, ReasoningEffortOption, Thread, ThreadItem, UserInput} from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; import {ModelId} from "./ModelId"; import {AgentMode, MODE_CONFIG_ID} from "./AgentMode"; @@ -41,24 +40,24 @@ import {logger} from "./Logger"; import {sanitizeMcpServerName} from "./McpServerName"; import {createResponseItemHistoryFallbackUpdates} from "./ResponseItemHistoryFallback"; import { + GOAL_CONTROL_METHOD, + isExtMethodRequest, + LEGACY_SET_SESSION_MODEL_METHOD, type LegacyLoadSessionResponse, type LegacyNewSessionResponse, type LegacyResumeSessionResponse, type LegacySessionModelState, type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, - type SessionSteerRequest, - type SessionSteeringResponse, - GOAL_CONTROL_METHOD, - isExtMethodRequest, - LEGACY_SET_SESSION_MODEL_METHOD, SESSION_STEERING_METHOD, + type SessionSteeringResponse, + type SessionSteerRequest, } from "./AcpExtensions"; import { createCollabAgentToolCallUpdate, - createCompletedContextCompactionUpdate, createCommandExecutionCompleteUpdate, createCommandExecutionUpdate, + createCompletedContextCompactionUpdate, createDynamicToolCallUpdate, createFileChangeUpdate, createImageGenerationUpdate, @@ -80,16 +79,12 @@ import packageJson from "../package.json"; import {isJetBrains2026_1Client} from "./JBUtils"; import {resolveTerminalOutputMode, type TerminalOutputMode} from "./TerminalOutputMode"; import { - createCodexMessagePhaseMeta, createAgentTextMessageChunk, createAgentTextThoughtChunk, + createCodexMessagePhaseMeta, createUserMessageChunk, } from "./ContentChunks"; -import { - sameThreadGoalSnapshot, - type ThreadGoalSnapshot, - toThreadGoalSnapshot, -} from "./ThreadGoalSnapshot"; +import {sameThreadGoalSnapshot, type ThreadGoalSnapshot, toThreadGoalSnapshot,} from "./ThreadGoalSnapshot"; export interface SessionState { sessionId: string, @@ -671,9 +666,11 @@ export class CodexAcpServer { async authenticate( _params: acp.AuthenticateRequest, + requestId?: acp.JsonRpcId, ): Promise { logger.log("Authenticate request received"); - const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(_params)); + const elicitationRequester = this.createUrlElicitationRequester(requestId); + const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(_params, elicitationRequester)); if (!isAuthenticated) { logger.log("Authenticate request failed"); throw RequestError.invalidParams(); @@ -683,6 +680,19 @@ export class CodexAcpServer { return { }; } + private createUrlElicitationRequester(requestId?: acp.JsonRpcId): UrlElicitationRequester | undefined { + if (requestId == null || !clientSupportsUrlElicitation(this.clientCapabilities)) { + return undefined; + } + return { + elicitUrl: (request) => this.connection.request(acp.methods.client.elicitation.create, { + mode: "url", + requestId, + ...request, + }), + }; + } + async logout(_params: acp.LogoutRequest): Promise { logger.log("Logout request received"); await this.runWithProcessCheck(() => this.codexAcpClient.logout()); diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index eb26c83f..50a95e42 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -6,6 +6,8 @@ import type { ServerNotification } from "./app-server"; import type { + CancelLoginAccountParams, + CancelLoginAccountResponse, ConfigReadParams, ConfigReadResponse, GetAccountParams, @@ -580,6 +582,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "account/login/start", params: params }); } + async accountLoginCancel(params: CancelLoginAccountParams): Promise { + return await this.sendRequest({ method: "account/login/cancel", params: params }); + } + async accountLogout(): Promise { return await this.sendRequest({ method: "account/logout", params: undefined }); } diff --git a/src/CodexAuthMethod.ts b/src/CodexAuthMethod.ts index 9f550583..c1976a00 100644 --- a/src/CodexAuthMethod.ts +++ b/src/CodexAuthMethod.ts @@ -1,10 +1,11 @@ import type {AuthenticateRequest, AuthMethod, ClientCapabilities} from "@agentclientprotocol/sdk"; +import {clientSupportsUrlElicitation} from "./ElicitationCapabilities"; export const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY"; export const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY"; -interface ApiKeyAuthRequest extends AuthenticateRequest { +export interface ApiKeyAuthRequest extends AuthenticateRequest { methodId: "api-key"; _meta?: { "api-key"?: { @@ -34,6 +35,16 @@ export interface ChatGPTAuthRequest extends AuthenticateRequest { methodId: "chat-gpt"; } +const ChatGptDeviceCodeAuthMethod: AuthMethod = { + id: "chat-gpt-device-code", + name: "ChatGPT (device code)", + description: "Sign in to ChatGPT by opening a verification page and entering a one-time code" +} + +export interface ChatGPTDeviceCodeAuthRequest extends AuthenticateRequest { + methodId: "chat-gpt-device-code"; +} + export const GatewayAuthMethod = { id: "gateway", name: "Custom model gateway", @@ -62,6 +73,9 @@ export function getCodexAuthMethods(clientCapabilities?: ClientCapabilities | nu if (!env["NO_BROWSER"]) { authMethods.push(ChatGptAuthMethod); } + if (clientSupportsUrlElicitation(clientCapabilities)) { + authMethods.push(ChatGptDeviceCodeAuthMethod); + } const supportsGatewayAuth = clientCapabilities?.auth?._meta?.["gateway"] === true; if (supportsGatewayAuth) { authMethods.push(GatewayAuthMethod); @@ -69,8 +83,8 @@ export function getCodexAuthMethods(clientCapabilities?: ClientCapabilities | nu return authMethods; } -export type CodexAuthRequest = ApiKeyAuthRequest | ChatGPTAuthRequest | GatewayAuthRequest; +export type CodexAuthRequest = ApiKeyAuthRequest | ChatGPTAuthRequest | ChatGPTDeviceCodeAuthRequest | GatewayAuthRequest; export function isCodexAuthRequest(request: AuthenticateRequest): request is CodexAuthRequest { - return request.methodId === "api-key" || request.methodId === "chat-gpt" || request.methodId === "gateway"; + return request.methodId === "api-key" || request.methodId === "chat-gpt" || request.methodId === "chat-gpt-device-code" || request.methodId === "gateway"; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index d58bd594..d90c09a3 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -203,6 +203,84 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(accountLoginSpy).not.toHaveBeenCalled(); }); + function createDeviceCodeFixture() { + const deviceFixture = createCodexMockTestFixture(); + const codexAppServerClient = deviceFixture.getCodexAppServerClient(); + vi.spyOn(codexAppServerClient, "accountRead").mockResolvedValue({ + account: null, + requiresOpenaiAuth: true, + }); + vi.spyOn(codexAppServerClient, "accountLogin").mockResolvedValue({ + type: "chatgptDeviceCode", + loginId: "login-1", + verificationUrl: "https://example.com/device", + userCode: "ABCD-1234", + }); + let loginCompleted: ((event: unknown) => void) | undefined; + vi.spyOn(codexAppServerClient.connection, "onNotification").mockImplementation(((method: unknown, handler: (event: unknown) => void) => { + if (method === "account/login/completed") { + loginCompleted = handler; + } + return { dispose: () => {} }; + }) as never); + return { + deviceFixture, + codexAppServerClient, + completeLogin: (success: boolean) => loginCompleted?.({ loginId: "login-1", success, error: null }), + loginCompletedSubscribed: () => loginCompleted !== undefined, + }; + } + + it('should authenticate with ChatGPT device code via URL elicitation', async () => { + const { deviceFixture, completeLogin, loginCompletedSubscribed } = createDeviceCodeFixture(); + const codexAcpAgent = deviceFixture.getCodexAcpAgent(); + await codexAcpAgent.initialize({ + protocolVersion: 1, + clientCapabilities: { elicitation: { url: {} } }, + }); + deviceFixture.setElicitationResponse({ action: "accept" }); + + const authPromise = codexAcpAgent.authenticate({ methodId: "chat-gpt-device-code" }, 42); + await vi.waitFor(() => expect(loginCompletedSubscribed()).toBe(true)); + completeLogin(true); + await expect(authPromise).resolves.toEqual({}); + + const elicitationRequest = deviceFixture.getAcpConnectionEvents([]) + .find(event => event.method === "createElicitation"); + expect(elicitationRequest?.args[0]).toEqual({ + mode: "url", + requestId: 42, + elicitationId: "login-1", + url: "https://example.com/device", + message: expect.stringContaining("ABCD-1234"), + }); + }); + + it('should cancel ChatGPT device code login when URL elicitation is declined', async () => { + const { deviceFixture, codexAppServerClient } = createDeviceCodeFixture(); + const codexAcpAgent = deviceFixture.getCodexAcpAgent(); + await codexAcpAgent.initialize({ + protocolVersion: 1, + clientCapabilities: { elicitation: { url: {} } }, + }); + const cancelSpy = vi.spyOn(codexAppServerClient, "accountLoginCancel") + .mockResolvedValue({ status: "canceled" }); + deviceFixture.setElicitationResponse({ action: "decline" }); + + await expect(codexAcpAgent.authenticate({ methodId: "chat-gpt-device-code" }, 42)) + .rejects.toThrow(); + expect(cancelSpy).toHaveBeenCalledWith({ loginId: "login-1" }); + }); + + it('should reject ChatGPT device code auth when the client lacks URL elicitation', async () => { + const { deviceFixture } = createDeviceCodeFixture(); + const codexAcpAgent = deviceFixture.getCodexAcpAgent(); + await codexAcpAgent.initialize({ protocolVersion: 1 }); + + await expect(codexAcpAgent.authenticate({ methodId: "chat-gpt-device-code" }, 42)) + .rejects.toThrow("Device code authentication requires URL elicitation support"); + }); + it('should authenticate with a gateway', async () => { const gatewayFixture = createTestFixture(); const codexAcpAgent = gatewayFixture.getCodexAcpAgent(); @@ -2856,7 +2934,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { await codexAcpAgent.authenticate({methodId: "api-key"}); - expect(authenticateSpy).toHaveBeenCalledWith({methodId: "api-key"}); + expect(authenticateSpy).toHaveBeenCalledWith({methodId: "api-key"}, undefined); expect(getAccountSpy).toHaveBeenCalledTimes(4); expect(codexAcpAgent.getSessionState(session1.sessionId)).toMatchObject({ account: { type: "apiKey" }, @@ -2912,7 +2990,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }; await codexAcpAgent.authenticate(gatewayAuthRequest); - expect(authenticateSpy).toHaveBeenCalledWith(gatewayAuthRequest); + expect(authenticateSpy).toHaveBeenCalledWith(gatewayAuthRequest, undefined); expect(getAccountSpy).toHaveBeenCalledTimes(1); expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({ account: { type: "apiKey" }, diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 9d6dc2b8..723f0c3f 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -124,6 +124,16 @@ describe('CodexACPAgent - initialize', () => { ])); }); + it('should advertise ChatGPT device code auth only when the client supports URL elicitation', () => { + const withUrlElicitation = getCodexAuthMethods({elicitation: {url: {}}}) + .map((method) => method.id); + expect(withUrlElicitation).toContain("chat-gpt-device-code"); + + const withoutUrlElicitation = getCodexAuthMethods({elicitation: {form: {}}}) + .map((method) => method.id); + expect(withoutUrlElicitation).not.toContain("chat-gpt-device-code"); + }); + it('should not advertise ChatGPT auth when browser auth is disabled', () => { const methodIds = getCodexAuthMethods(undefined, {NO_BROWSER: "1"} as NodeJS.ProcessEnv) .map((method) => method.id); diff --git a/src/index.ts b/src/index.ts index 014801ff..79efcf05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -135,7 +135,7 @@ function startAcpServer() { .onRequest(acp.methods.agent.session.close, (ctx) => getAgent().closeSession(ctx.params)) .onRequest(acp.methods.agent.session.setMode, (ctx) => getAgent().setSessionMode(ctx.params)) .onRequest(acp.methods.agent.session.setConfigOption, (ctx) => getAgent().setSessionConfigOption(ctx.params)) - .onRequest(acp.methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params)) + .onRequest(acp.methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params, ctx.requestId)) .onRequest(acp.methods.agent.logout, (ctx) => getAgent().logout(ctx.params)) .onRequest(acp.methods.agent.providers.list, (ctx) => getAgent().listProviders(ctx.params)) .onRequest(acp.methods.agent.providers.set, (ctx) => getAgent().setProvider(ctx.params))