Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 64 additions & 8 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -77,7 +78,11 @@ export class CodexAcpClient {
});
}

async authenticate(authRequest: acp.AuthenticateRequest): Promise<Boolean> {
async authenticate(
connection: AcpClientConnection,
authRequest: acp.AuthenticateRequest,
env: NodeJS.ProcessEnv = process.env,
): Promise<Boolean> {
if (!isCodexAuthRequest(authRequest)) {
throw RequestError.invalidRequest();
}
Expand All @@ -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();
Expand Down Expand Up @@ -137,6 +139,60 @@ export class CodexAcpClient {
return false;
}

private async authenticateWithChatGptBrowser(): Promise<Boolean> {
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<Boolean> {
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<void> {
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,

@anvilpete anvilpete Jun 26, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I don't think we can fill in the requestId here without support to expose it from the ACP SDK.

I think this is fine for now, but could we consider adding requestId to the AgentHandlerContext?

elicitationId: request.elicitationId,
url: request.url,
message: request.message,
},
);

if (response.action !== "accept") {
throw RequestError.authRequired();
}
}

private async authenticateWithApiKey(apiKey: string): Promise<Boolean> {
const loginCompletedPromise = this.awaitNextLoginCompleted();
await this.codexClient.accountLogin({
Expand Down
2 changes: 1 addition & 1 deletion src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ export class CodexAcpServer {
_params: acp.AuthenticateRequest,
): Promise<acp.AuthenticateResponse> {
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();
Expand Down
3 changes: 2 additions & 1 deletion src/CodexAuthMethod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 63 additions & 2 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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" },
Expand Down
18 changes: 17 additions & 1 deletion src/__tests__/CodexACPAgent/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]));
});
});
42 changes: 40 additions & 2 deletions src/__tests__/acp-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] };

Expand Down Expand Up @@ -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]]};
}
Expand Down Expand Up @@ -238,6 +246,9 @@ export interface CodexMockTestFixture extends TestFixture {
sendServerNotification(notification: ServerNotification | Record<string, unknown>): void,
sendServerRequest<T>(method: string, params: unknown): Promise<T>,
setPermissionResponse(response: RequestPermissionResponse): void,
setElicitationResponse(response: acp.CreateElicitationResponse): void,
setAccountLoginResponse(response: LoginAccountResponse): void,
sendAccountLoginCompleted(notification: AccountLoginCompletedNotification): void,
}

/**
Expand All @@ -250,18 +261,30 @@ export interface CodexMockTestFixture extends TestFixture {
export function createCodexMockTestFixture(): CodexMockTestFixture {
let unhandledNotificationHandler: ((notification: any) => void) | null = null;
const requestHandlers = new Map<string, (params: unknown) => Promise<unknown>>();
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<unknown>) => {
requestHandlers.set(type.method, handler);
},
Expand All @@ -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<AcpClientConnection>((event) => {
const normalizedEvent = normalizeAcpConnectionEvent(event);
Expand Down Expand Up @@ -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);
}
},
};
}

Expand Down