From 85af2be1ac54e0220423435589ed96a8b5fb6de6 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 26 Jun 2026 13:48:37 +0100 Subject: [PATCH] Support for device code auth --- README.md | 4 +- readme-dev.md | 2 +- src/CodexAcpClient.ts | 72 ++++++++++++++++--- src/CodexAcpServer.ts | 2 +- src/CodexAuthMethod.ts | 3 +- .../CodexACPAgent/CodexAcpClient.test.ts | 65 ++++++++++++++++- .../CodexACPAgent/initialize.test.ts | 18 ++++- src/__tests__/acp-test-utils.ts | 42 ++++++++++- 8 files changed, 190 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index fe401b19..b4ea48d5 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ CODEX_PATH=/path/to/codex npx -y @agentclientprotocol/codex-acp The adapter advertises ACP auth methods during initialization. Clients can authenticate with: -- ChatGPT login. Set `NO_BROWSER=1` to hide this method in remote or browserless environments. +- ChatGPT login. Set `NO_BROWSER=1` to use device code auth for remote or browserless environments. - API key via `CODEX_API_KEY` or `OPENAI_API_KEY`. - A custom OpenAI-compatible gateway, when the client opts in to the gateway auth capability. @@ -53,7 +53,7 @@ The adapter advertises ACP auth methods during initialization. Clients can authe - `MODEL_PROVIDER` - model provider to pass to Codex for new sessions. - `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication. - `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, or `agent-full-access`. -- `NO_BROWSER` - hide browser-based ChatGPT auth when set. +- `NO_BROWSER` - use device code auth instead of browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. ## Development diff --git a/readme-dev.md b/readme-dev.md index bc147807..27305e48 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -10,7 +10,7 @@ Set `CODEX_PATH` to run a different Codex binary; versions other than the one sp - `MODEL_PROVIDER` - model provider to pass to Codex for new sessions. - `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication. - `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, or `agent-full-access`. -- `NO_BROWSER` - hide browser-based ChatGPT auth when set. +- `NO_BROWSER` - use device code auth instead of browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. ### Quick start diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 6f43c0c8..600239bf 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -39,6 +39,7 @@ import type { } from "./app-server/v2"; import packageJson from "../package.json"; import type {AuthenticationStatusResponse} from "./AcpExtensions"; +import type { AcpClientConnection } from "./ACPSessionConnection"; /** * API for accessing the Codex App Server using ACP requests. @@ -77,7 +78,11 @@ export class CodexAcpClient { }); } - async authenticate(authRequest: acp.AuthenticateRequest): Promise { + async authenticate( + connection: AcpClientConnection, + authRequest: acp.AuthenticateRequest, + env: NodeJS.ProcessEnv = process.env, + ): Promise { if (!isCodexAuthRequest(authRequest)) { throw RequestError.invalidRequest(); } @@ -93,14 +98,11 @@ export class CodexAcpClient { this.gatewayConfig = null; return true; } - const loginCompletedPromise = this.awaitNextLoginCompleted(); - const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"}); - if (loginResponse.type == "chatgpt") { - await open(loginResponse.authUrl); + if (env["NO_BROWSER"]) { + return await this.authenticateWithChatGptDeviceCode(connection); + } else { + return await this.authenticateWithChatGptBrowser(); } - this.gatewayConfig = null; - const result = await loginCompletedPromise; - return result.success; } case "gateway": if (!authRequest._meta) throw RequestError.invalidRequest(); @@ -137,6 +139,60 @@ export class CodexAcpClient { return false; } + private async authenticateWithChatGptBrowser(): Promise { + const loginCompletedPromise = this.awaitNextLoginCompleted(); + const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"}); + if (loginResponse.type == "chatgpt") { + await open(loginResponse.authUrl); + } + this.gatewayConfig = null; + const result = await loginCompletedPromise; + return result.success; + } + + private async authenticateWithChatGptDeviceCode(connection: AcpClientConnection): Promise { + const loginCompletedPromise = this.awaitNextLoginCompleted(); + const loginResponse = await this.codexClient.accountLogin({type: "chatgptDeviceCode"}); + if (loginResponse.type == "chatgptDeviceCode") { + const url = loginResponse.verificationUrl; + const userCode = loginResponse.userCode; + + await this.requestDeviceCodeAuth(connection, { + elicitationId: `chatgpt-login-${crypto.randomUUID()}`, + url, + message: `Follow these steps to sign in with ChatGPT using device code authorization:\n\ + \n1. Open this link in your browser and sign in to your account\n ${url}\n\ + \n2. Enter this one-time code (expires in 15 minutes)\n ${userCode}\n\ + \nDevice codes are a common phishing target. Never share this code.\n`, + }); + } + this.gatewayConfig = null; + const result = await loginCompletedPromise; + return result.success; + } + + private async requestDeviceCodeAuth( + client: AcpClientConnection, + request: { elicitationId: string; url: string; message: string }, + ): Promise { + const response = await client.request( + acp.methods.client.elicitation.create, + { + mode: "url", + // This should be the authenticate requestId, but this is not exposed by the ACP SDK. + // The elicitation still works, but won't be tied to the authenticate request. + requestId: null, + elicitationId: request.elicitationId, + url: request.url, + message: request.message, + }, + ); + + if (response.action !== "accept") { + throw RequestError.authRequired(); + } + } + private async authenticateWithApiKey(apiKey: string): Promise { const loginCompletedPromise = this.awaitNextLoginCompleted(); await this.codexClient.accountLogin({ diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index f5d628a0..64d7da1f 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -596,7 +596,7 @@ export class CodexAcpServer { _params: acp.AuthenticateRequest, ): Promise { logger.log("Authenticate request received"); - const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(_params)); + const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(this.connection, _params)); if (!isAuthenticated) { logger.log("Authenticate request failed"); throw RequestError.invalidParams(); diff --git a/src/CodexAuthMethod.ts b/src/CodexAuthMethod.ts index 94c1833a..7f2c5cdf 100644 --- a/src/CodexAuthMethod.ts +++ b/src/CodexAuthMethod.ts @@ -59,7 +59,8 @@ export interface GatewayAuthRequest extends AuthenticateRequest { export function getCodexAuthMethods(clientCapabilities?: ClientCapabilities | null, env: NodeJS.ProcessEnv = process.env): AuthMethod[] { const authMethods: AuthMethod[] = [ApiKeyAuthMethod]; - if (!env["NO_BROWSER"]) { + // ChatGPT login requires a browser or URL elicitation support for device code auth + if (!env["NO_BROWSER"] || clientCapabilities?.elicitation?.url) { authMethods.push(ChatGptAuthMethod); } const supportsGatewayAuth = clientCapabilities?.auth?._meta?.["gateway"] === true; diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index dd68685c..dd5ffd35 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -202,6 +202,67 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(accountLoginSpy).not.toHaveBeenCalled(); }); + + it('should request ACP URL elicitation for device code auth', async () => { + const deviceCodeFixture = createCodexMockTestFixture(); + vi.spyOn(deviceCodeFixture.getCodexAppServerClient(), "accountRead").mockResolvedValue({ + account: null, + requiresOpenaiAuth: true, + }); + deviceCodeFixture.setElicitationResponse({action: "accept"}); + deviceCodeFixture.setAccountLoginResponse({ + type: "chatgptDeviceCode", + loginId: "login-id", + verificationUrl: "https://openai.example/device", + userCode: "ABCD-EFGH", + }); + vi.stubEnv("NO_BROWSER", "1"); + + const authPromise = deviceCodeFixture.getCodexAcpAgent().authenticate({methodId: "chat-gpt"}); + + await vi.waitFor(() => { + expect(deviceCodeFixture.getCodexAppServerClient().accountLogin) + .toHaveBeenCalledWith({type: "chatgptDeviceCode"}); + }); + + await vi.waitFor(() => { + const elicitationEvent = deviceCodeFixture.getAcpConnectionEvents([]) + .find(event => event.method === "elicitationCreate"); + expect(elicitationEvent?.args[0]).toEqual(expect.objectContaining({ + mode: "url", + requestId: null, + elicitationId: expect.stringMatching(/^chatgpt-login-/), + url: "https://openai.example/device", + message: expect.stringContaining("ABCD-EFGH"), + })); + }); + + deviceCodeFixture.sendAccountLoginCompleted({loginId: "login-id", success: true, error: null}); + + await expect(authPromise).resolves.toEqual({}); + }); + + it('should fail device code auth when ACP URL elicitation is not accepted', async () => { + const deviceCodeFixture = createCodexMockTestFixture(); + vi.spyOn(deviceCodeFixture.getCodexAppServerClient(), "accountRead").mockResolvedValue({ + account: null, + requiresOpenaiAuth: true, + }); + deviceCodeFixture.setElicitationResponse({action: "cancel"}); + deviceCodeFixture.setAccountLoginResponse({ + type: "chatgptDeviceCode", + loginId: "login-id", + verificationUrl: "https://openai.example/device", + userCode: "ABCD-EFGH", + }); + vi.stubEnv("NO_BROWSER", "1"); + + await expect(deviceCodeFixture.getCodexAcpAgent().authenticate({methodId: "chat-gpt"})) + .rejects.toThrow("Authentication required"); + expect(deviceCodeFixture.getCodexAppServerClient().accountLogin) + .toHaveBeenCalledWith({type: "chatgptDeviceCode"}); + }); + it('should authenticate with a gateway', async () => { const gatewayFixture = createTestFixture(); const codexAcpAgent = gatewayFixture.getCodexAcpAgent(); @@ -1598,7 +1659,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { await codexAcpAgent.authenticate({methodId: "api-key"}); - expect(authenticateSpy).toHaveBeenCalledWith({methodId: "api-key"}); + expect(authenticateSpy).toHaveBeenCalledWith(expect.anything(), {methodId: "api-key"}); expect(getAccountSpy).toHaveBeenCalledTimes(4); expect(codexAcpAgent.getSessionState(session1.sessionId)).toMatchObject({ account: { type: "apiKey" }, @@ -1653,7 +1714,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }; await codexAcpAgent.authenticate(gatewayAuthRequest); - expect(authenticateSpy).toHaveBeenCalledWith(gatewayAuthRequest); + expect(authenticateSpy).toHaveBeenCalledWith(expect.anything(), gatewayAuthRequest); 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 e7050101..06ac4242 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -102,11 +102,27 @@ describe('CodexACPAgent - initialize', () => { ])); }); - it('should not advertise ChatGPT auth when browser auth is disabled', () => { + it('should not advertise ChatGPT auth when browser auth is disabled and URL elicitation is unsupported', () => { const methodIds = getCodexAuthMethods(undefined, {NO_BROWSER: "1"} as NodeJS.ProcessEnv) .map((method) => method.id); expect(methodIds).not.toContain("chat-gpt"); expect(methodIds).toEqual(expect.arrayContaining(["api-key"])); }); + + it('should advertise ChatGPT auth when browser auth is disabled and URL elicitation is supported', () => { + const methodIds = getCodexAuthMethods( + {elicitation: {url: {}}}, + {NO_BROWSER: "1"} as NodeJS.ProcessEnv, + ).map((method) => method.id); + + expect(methodIds).toEqual(expect.arrayContaining(["chat-gpt", "api-key"])); + }); + + it('should advertise ChatGPT auth when browser auth is enabled without URL elicitation support', () => { + const methodIds = getCodexAuthMethods(undefined, {} as NodeJS.ProcessEnv) + .map((method) => method.id); + + expect(methodIds).toEqual(expect.arrayContaining(["chat-gpt", "api-key"])); + }); }); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 2f50d49d..8518a007 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -12,7 +12,12 @@ import fs from "node:fs"; import os from "node:os"; import {AgentMode} from "../AgentMode"; import {expect, vi} from "vitest"; -import type {Model, ReasoningEffortOption} from "../app-server/v2"; +import type { + AccountLoginCompletedNotification, + LoginAccountResponse, + Model, + ReasoningEffortOption +} from "../app-server/v2"; export type MethodCallEvent = { method: string; args: any[] }; @@ -42,6 +47,9 @@ function normalizeAcpConnectionEvent(event: MethodCallEvent): MethodCallEvent { if (event.method === "request" && event.args[0] === acp.methods.client.session.requestPermission) { return {method: "requestPermission", args: [event.args[1]]}; } + if (event.method === "request" && event.args[0] === acp.methods.client.elicitation.create) { + return {method: "elicitationCreate", args: [event.args[1]]}; + } if (event.method === "notify" && event.args[0] === acp.methods.client.session.update) { return {method: "sessionUpdate", args: [event.args[1]]}; } @@ -238,6 +246,9 @@ export interface CodexMockTestFixture extends TestFixture { sendServerNotification(notification: ServerNotification | Record): void, sendServerRequest(method: string, params: unknown): Promise, setPermissionResponse(response: RequestPermissionResponse): void, + setElicitationResponse(response: acp.CreateElicitationResponse): void, + setAccountLoginResponse(response: LoginAccountResponse): void, + sendAccountLoginCompleted(notification: AccountLoginCompletedNotification): void, } /** @@ -250,18 +261,30 @@ export interface CodexMockTestFixture extends TestFixture { export function createCodexMockTestFixture(): CodexMockTestFixture { let unhandledNotificationHandler: ((notification: any) => void) | null = null; const requestHandlers = new Map Promise>(); + const loginCompletedHandlers = new Set<(notification: AccountLoginCompletedNotification) => void>(); // State for controlling permission responses const permissionState: { response: RequestPermissionResponse } = { response: { outcome: { outcome: 'cancelled' } } }; + const elicitationState: { response: acp.CreateElicitationResponse } = { + response: { action: 'cancel' } + }; const mockCodexConnection = { sendRequest: () => Promise.resolve(undefined), onUnhandledNotification: (handler: (notification: any) => void) => { unhandledNotificationHandler = handler; }, - onNotification: () => {}, + onNotification: (method: string, handler: (notification: AccountLoginCompletedNotification) => void) => { + if (method === "account/login/completed") { + loginCompletedHandlers.add(handler); + return { + dispose: () => loginCompletedHandlers.delete(handler), + }; + } + return { dispose: () => {} }; + }, onRequest: (type: { method: string }, handler: (params: unknown) => Promise) => { requestHandlers.set(type.method, handler); }, @@ -276,9 +299,13 @@ export function createCodexMockTestFixture(): CodexMockTestFixture { if (args[0] === acp.methods.client.session.requestPermission) { return permissionState.response; } + if (args[0] === acp.methods.client.elicitation.create) { + return elicitationState.response; + } return { mock: "Mocked return" }; }); returnValues.set('requestPermission', () => permissionState.response); + returnValues.set('elicitationCreate', () => elicitationState.response); const acpConnection = createSmartMock((event) => { const normalizedEvent = normalizeAcpConnectionEvent(event); @@ -313,6 +340,17 @@ export function createCodexMockTestFixture(): CodexMockTestFixture { setPermissionResponse(response: RequestPermissionResponse): void { permissionState.response = response; }, + setElicitationResponse(response: acp.CreateElicitationResponse): void { + elicitationState.response = response; + }, + setAccountLoginResponse(response: LoginAccountResponse): void { + vi.spyOn(baseFixture.getCodexAppServerClient(), "accountLogin").mockResolvedValue(response); + }, + sendAccountLoginCompleted(notification: AccountLoginCompletedNotification): void { + for (const handler of loginCompletedHandlers) { + handler(notification); + } + }, }; }