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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,20 @@ CODEX_PATH=/path/to/codex npx -y @agentclientprotocol/codex-acp

The adapter advertises ACP auth methods during initialization. Clients can authenticate with:

- ChatGPT login.
- OpenAI API key.
- ChatGPT login. Set `NO_BROWSER=1` to hide this method in 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.

## Runtime options

- `CODEX_API_KEY` - API key used when the API-key auth method is selected. Takes precedence over `OPENAI_API_KEY`.
- `OPENAI_API_KEY` - fallback API key used when the API-key auth method is selected.
- `CODEX_PATH` - run a specific Codex executable instead of the bundled package dependency.
- `CODEX_CONFIG` - JSON object merged into the Codex session config.
- `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.
- `APP_SERVER_LOGS` - directory for adapter logs.

## Development
Expand Down
12 changes: 12 additions & 0 deletions readme-dev.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
This package uses the bundled `@openai/codex` dependency by default.
Set `CODEX_PATH` to run a different Codex binary; versions other than the one specified in `package.json` may not be compatible.

### Runtime environment

- `CODEX_API_KEY` - API key used when the API-key auth method is selected. Takes precedence over `OPENAI_API_KEY`.
- `OPENAI_API_KEY` - fallback API key used when the API-key auth method is selected.
- `CODEX_PATH` - run a specific Codex executable instead of the bundled package dependency.
- `CODEX_CONFIG` - JSON object merged into the Codex session config.
- `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.
- `APP_SERVER_LOGS` - directory for adapter logs.

### Quick start

#### Develop on Windows?
Expand Down
37 changes: 27 additions & 10 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {isCodexAuthRequest} from "./CodexAuthMethod";
import {CODEX_API_KEY_ENV_VAR, 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";
Expand Down Expand Up @@ -84,15 +84,8 @@ export class CodexAcpClient {

switch (authRequest.methodId) {
case "api-key": {
if (!authRequest._meta || !authRequest._meta["api-key"]) throw RequestError.invalidRequest();
const loginCompletedPromise = this.awaitNextLoginCompleted();
await this.codexClient.accountLogin({
type: "apiKey",
apiKey: authRequest._meta["api-key"].apiKey
});
this.gatewayConfig = null;
const result = await loginCompletedPromise;
return result.success;
const apiKey = authRequest._meta?.["api-key"]?.apiKey ?? this.readApiKeyFromEnv();
return await this.authenticateWithApiKey(apiKey);
}
case "chat-gpt": {
const loginCompletedPromise = this.awaitNextLoginCompleted();
Expand Down Expand Up @@ -139,6 +132,30 @@ export class CodexAcpClient {
return false;
}

private async authenticateWithApiKey(apiKey: string): Promise<Boolean> {
const loginCompletedPromise = this.awaitNextLoginCompleted();
await this.codexClient.accountLogin({
type: "apiKey",
apiKey,
});
this.gatewayConfig = null;
const result = await loginCompletedPromise;
return result.success;
}

private readApiKeyFromEnv(): string {
for (const envVar of [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR]) {
const value = process.env[envVar]?.trim();
if (value) {
return value;
}
}
throw RequestError.internalError(
{envVars: [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR]},
`${CODEX_API_KEY_ENV_VAR} or ${OPENAI_API_KEY_ENV_VAR} is not set`
);
}


async getAuthenticationStatus(): Promise<AuthenticationStatusResponse> {
const modelProvider = await this.getCurrentModelProvider();
Expand Down
28 changes: 17 additions & 11 deletions src/CodexAuthMethod.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@

import type {AuthenticateRequest, AuthMethod, ClientCapabilities} from "@agentclientprotocol/sdk";

export const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
export const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";

interface ApiKeyAuthRequest extends AuthenticateRequest {
methodId: "api-key";
_meta?: {
"api-key"?: {
apiKey: string;
}
} | null;
}

const ApiKeyAuthMethod: AuthMethod = {
id: "api-key",
name: "API Key",
Expand All @@ -12,15 +24,6 @@ const ApiKeyAuthMethod: AuthMethod = {
}
}

interface ApiKeyAuthRequest extends AuthenticateRequest {
methodId: "api-key";
_meta: {
"api-key": {
apiKey: string;
}
};
}

const ChatGptAuthMethod: AuthMethod = {
id: "chat-gpt",
name: "ChatGPT",
Expand Down Expand Up @@ -54,8 +57,11 @@ export interface GatewayAuthRequest extends AuthenticateRequest {
};
}

export function getCodexAuthMethods(clientCapabilities?: ClientCapabilities | null): AuthMethod[] {
const authMethods: AuthMethod[] = [ApiKeyAuthMethod, ChatGptAuthMethod];
export function getCodexAuthMethods(clientCapabilities?: ClientCapabilities | null, env: NodeJS.ProcessEnv = process.env): AuthMethod[] {
const authMethods: AuthMethod[] = [ApiKeyAuthMethod];
if (!env["NO_BROWSER"]) {
authMethods.push(ChatGptAuthMethod);
}
const supportsGatewayAuth = clientCapabilities?.auth?._meta?.["gateway"] === true;
if (supportsGatewayAuth) {
authMethods.push(GatewayAuthMethod);
Expand Down
76 changes: 74 additions & 2 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// noinspection ES6RedundantAwait

import {beforeEach, describe, expect, it, vi} from 'vitest';
import type {CodexAuthRequest} from "../../CodexAuthMethod";
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR, type CodexAuthRequest} from "../../CodexAuthMethod";
import type * as acp from "@agentclientprotocol/sdk";
import {
createCodexMockTestFixture,
Expand All @@ -25,6 +25,10 @@ describe('ACP server test', { timeout: 40_000 }, () => {
vi.clearAllMocks();
});

afterEach(() => {
vi.unstubAllEnvs();
});

const ignoredFields = ["thread", "cwd", "id", "createdAt", "path", "threadId", "userAgent", "sandbox", "conversationId", "origins", "supportedReasoningEfforts", "reasoningEffort", "model", "readOnlyAccess", "approvalsReviewer"];

it('should throw error without authentication', async () => {
Expand Down Expand Up @@ -114,6 +118,74 @@ describe('ACP server test', { timeout: 40_000 }, () => {
expect(logoutResponse).toEqual({type: "unauthenticated"});
});

it('should authenticate with CODEX_API_KEY from the environment', async () => {
const envFixture = createTestFixture();
const codexAcpAgent = envFixture.getCodexAcpAgent();
vi.stubEnv(CODEX_API_KEY_ENV_VAR, "CODEX_ENV_TOKEN");
vi.stubEnv(OPENAI_API_KEY_ENV_VAR, "OPENAI_ENV_TOKEN");

await codexAcpAgent.initialize({protocolVersion: 1});
await envFixture.getCodexAcpClient().logout();
envFixture.clearCodexConnectionDump();

await codexAcpAgent.authenticate({methodId: "api-key"});

const transportEvents = envFixture.getCodexConnectionEvents([]);
const loginRequest = transportEvents.find(event =>
event.eventType === "request" &&
"method" in event &&
event.method === "account/login/start"
);
expect(loginRequest).toEqual({
eventType: "request",
method: "account/login/start",
params: {
type: "apiKey",
apiKey: "CODEX_ENV_TOKEN",
}
});
await expect(codexAcpAgent.extMethod("authentication/status", {})).resolves.toEqual({type: "api-key"});
});

it('should fall back to OPENAI_API_KEY from the environment', async () => {
const envFixture = createTestFixture();
const codexAcpAgent = envFixture.getCodexAcpAgent();
vi.stubEnv(CODEX_API_KEY_ENV_VAR, "");
vi.stubEnv(OPENAI_API_KEY_ENV_VAR, "OPENAI_ENV_TOKEN");

await codexAcpAgent.initialize({protocolVersion: 1});
await envFixture.getCodexAcpClient().logout();
envFixture.clearCodexConnectionDump();

await codexAcpAgent.authenticate({methodId: "api-key"});

const transportEvents = envFixture.getCodexConnectionEvents([]);
const loginRequest = transportEvents.find(event =>
event.eventType === "request" &&
"method" in event &&
event.method === "account/login/start"
);
expect(loginRequest).toEqual({
eventType: "request",
method: "account/login/start",
params: {
type: "apiKey",
apiKey: "OPENAI_ENV_TOKEN",
}
});
await expect(codexAcpAgent.extMethod("authentication/status", {})).resolves.toEqual({type: "api-key"});
});

it('should report a clear error when the selected API key env var is missing', async () => {
const envFixture = createTestFixture();
const codexAcpAgent = envFixture.getCodexAcpAgent();
vi.stubEnv(CODEX_API_KEY_ENV_VAR, "");
vi.stubEnv(OPENAI_API_KEY_ENV_VAR, "");

await expect(codexAcpAgent.authenticate({methodId: "api-key"}))
.rejects.toThrow(`${CODEX_API_KEY_ENV_VAR} or ${OPENAI_API_KEY_ENV_VAR} is not set`);
});

it('should authenticate with a gateway', async () => {
const gatewayFixture = createTestFixture();
const codexAcpAgent = gatewayFixture.getCodexAcpAgent();
Expand Down
5 changes: 3 additions & 2 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as acp from "@agentclientprotocol/sdk";
import {describe, expect} from "vitest";
import {AgentMode} from "../../../AgentMode";
import {CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR} from "../../../CodexAuthMethod";
import {createSpawnedAgentFixture, type SpawnedAgentFixture,} from "./spawned-agent-fixture";

export {
Expand Down Expand Up @@ -140,9 +141,9 @@ async function createSpawnedFixture(
}

export function requireLiveApiKey(): string {
const apiKey = process.env["CODEX_API_KEY"] ?? process.env["OPENAI_API_KEY"];
const apiKey = process.env[CODEX_API_KEY_ENV_VAR] ?? process.env[OPENAI_API_KEY_ENV_VAR];
if (!apiKey) {
throw new Error("Live integration test requires CODEX_API_KEY or OPENAI_API_KEY.");
throw new Error(`Live integration test requires ${CODEX_API_KEY_ENV_VAR} or ${OPENAI_API_KEY_ENV_VAR}.`);
}
return apiKey;
}
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/CodexACPAgent/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,30 @@ describe('CodexACPAgent - initialize', () => {
})
]));
});

it('should advertise API key auth with the legacy metadata method', () => {
expect(getCodexAuthMethods()).toEqual(expect.arrayContaining([
expect.objectContaining({
id: "api-key",
_meta: {
"api-key": {
provider: "openai",
},
},
}),
]));
expect(getCodexAuthMethods()).not.toEqual(expect.arrayContaining([
expect.objectContaining({type: "env_var"}),
expect.objectContaining({id: "codex-api-key"}),
expect.objectContaining({id: "openai-api-key"}),
]));
});

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);

expect(methodIds).not.toContain("chat-gpt");
expect(methodIds).toEqual(expect.arrayContaining(["api-key"]));
});
});
Loading