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
125 changes: 92 additions & 33 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<acp.CreateElicitationRequest, {mode: "url"}>;

/**
* 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<CreateUrlElicitationRequest, "mode" | "requestId">;

export interface UrlElicitationRequester {
elicitUrl(request: UrlElicitationRequest): Promise<acp.CreateElicitationResponse>;
}

/**
* 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.
Expand Down Expand Up @@ -106,47 +128,28 @@ export class CodexAcpClient {
return this.configPath;
}

async authenticate(authRequest: acp.AuthenticateRequest): Promise<Boolean> {
async authenticate(
authRequest: acp.AuthenticateRequest,
urlElicitationRequester?: UrlElicitationRequester,
): Promise<Boolean> {
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<Boolean> {
private async authenticateWithApiKey(authRequest: ApiKeyAuthRequest): Promise<Boolean> {
const apiKey = authRequest._meta?.["api-key"]?.apiKey ?? this.readApiKeyFromEnv();
const loginCompletedPromise = this.awaitNextLoginCompleted();
await this.codexClient.accountLogin({
type: "apiKey",
Expand All @@ -156,6 +159,62 @@ export class CodexAcpClient {
return result.success;
}

private async authenticateWithChatGpt(): Promise<Boolean> {
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<Boolean> {
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();
Expand Down
56 changes: 33 additions & 23 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -671,9 +666,11 @@ export class CodexAcpServer {

async authenticate(
_params: acp.AuthenticateRequest,
requestId?: acp.JsonRpcId,
): Promise<acp.AuthenticateResponse> {
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();
Expand All @@ -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<void> {
logger.log("Logout request received");
await this.runWithProcessCheck(() => this.codexAcpClient.logout());
Expand Down
6 changes: 6 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type {
ServerNotification
} from "./app-server";
import type {
CancelLoginAccountParams,
CancelLoginAccountResponse,
ConfigReadParams,
ConfigReadResponse,
GetAccountParams,
Expand Down Expand Up @@ -580,6 +582,10 @@ export class CodexAppServerClient {
return await this.sendRequest({ method: "account/login/start", params: params });
}

async accountLoginCancel(params: CancelLoginAccountParams): Promise<CancelLoginAccountResponse> {
return await this.sendRequest({ method: "account/login/cancel", params: params });
}

async accountLogout(): Promise<LogoutAccountResponse> {
return await this.sendRequest({ method: "account/logout", params: undefined });
}
Expand Down
20 changes: 17 additions & 3 deletions src/CodexAuthMethod.ts
Original file line number Diff line number Diff line change
@@ -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"?: {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -62,15 +73,18 @@ 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);
}
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";
}
Loading