diff --git a/README.md b/README.md index 3145bfdf..fe401b19 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/readme-dev.md b/readme-dev.md index 587a81c9..fc9384c3 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -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? diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 4fea0c46..ff98b77b 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -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"; @@ -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(); @@ -139,6 +132,30 @@ export class CodexAcpClient { return false; } + private async authenticateWithApiKey(apiKey: string): Promise { + 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 { const modelProvider = await this.getCurrentModelProvider(); diff --git a/src/CodexAuthMethod.ts b/src/CodexAuthMethod.ts index d0fa327b..94c1833a 100644 --- a/src/CodexAuthMethod.ts +++ b/src/CodexAuthMethod.ts @@ -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", @@ -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", @@ -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); diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 2edececc..0f6788c2 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -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, @@ -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 () => { @@ -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(); diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts index 5b44be07..35bb5f3a 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts @@ -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 { @@ -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; } diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index da4cc231..e7050101 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -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"])); + }); });