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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"vitest": "^4.0.10"
},
"dependencies": {
"@agentclientprotocol/sdk": "^1.0.0",
"@agentclientprotocol/sdk": "^1.1.0",
"@openai/codex": "^0.142.4",
"diff": "^8.0.3",
"open": "^11.0.0",
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 @@ -40,6 +40,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 @@ -78,7 +79,12 @@ export class CodexAcpClient {
});
}

async authenticate(authRequest: acp.AuthenticateRequest): Promise<Boolean> {
async authenticate(
connection: AcpClientConnection,
authRequest: acp.AuthenticateRequest,
requestId: acp.JsonRpcId,
env: NodeJS.ProcessEnv = process.env,
): Promise<Boolean> {
if (!isCodexAuthRequest(authRequest)) {
throw RequestError.invalidRequest();
}
Expand All @@ -94,14 +100,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, requestId);
} 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 @@ -138,6 +141,59 @@ 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, requestId: acp.JsonRpcId): 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, {
requestId,
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: { requestId: acp.JsonRpcId, elicitationId: string; url: string; message: string },
): Promise<void> {
const response = await client.request(
acp.methods.client.elicitation.create,
{
mode: "url",
requestId: request.requestId,
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
47 changes: 31 additions & 16 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,15 @@ export class CodexAcpServer {
}
}

async checkAuthorization(){
async checkAuthorization(requestId: acp.JsonRpcId){
const authNeeded = await this.runWithProcessCheck(() => this.codexAcpClient.authRequired());
logger.log("Auth requirement checked", {authRequired: authNeeded});
if (authNeeded) {
if (this.defaultAuthRequest) {
logger.log("Authenticating with default auth request...", {
authRequest: this.defaultAuthRequest
});
await this.authenticate(this.defaultAuthRequest)
await this.authenticate(this.defaultAuthRequest, requestId);
logger.log("Authentication completed");
} else {
logger.log("Authentication required but no default auth request provided, return to IDE");
Expand All @@ -250,9 +250,12 @@ export class CodexAcpServer {
}
}

async getOrCreateSession(request: acp.NewSessionRequest | acp.ResumeSessionRequest): Promise<[SessionId, LegacySessionModelState, SessionModeState]> {
async getOrCreateSession(
request: acp.NewSessionRequest | acp.ResumeSessionRequest,
requestId: acp.JsonRpcId,
): Promise<[SessionId, LegacySessionModelState, SessionModeState]> {
try {
return await this.tryCreateSession(request);
return await this.tryCreateSession(request, requestId);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
await this.handleError(error);
Expand Down Expand Up @@ -331,11 +334,14 @@ export class CodexAcpServer {
return generation;
}

async tryCreateSession(request: acp.NewSessionRequest | acp.ResumeSessionRequest): Promise<[SessionId, LegacySessionModelState, SessionModeState]> {
async tryCreateSession(
request: acp.NewSessionRequest | acp.ResumeSessionRequest,
requestId: acp.JsonRpcId,
): Promise<[SessionId, LegacySessionModelState, SessionModeState]> {
const requestedSessionGeneration = "sessionId" in request
? this.beginSessionOpen(request.sessionId)
: null;
await this.checkAuthorization();
await this.checkAuthorization(requestId);
const requestedMcpServers = request.mcpServers ?? [];
const mcpServerStartupVersion = requestedMcpServers.length > 0
? this.codexAcpClient.getMcpServerStartupVersion()
Expand Down Expand Up @@ -453,14 +459,17 @@ export class CodexAcpServer {
return null;
}

async loadSession(params: acp.LoadSessionRequest): Promise<LegacyLoadSessionResponse> {
async loadSession(
params: acp.LoadSessionRequest,
requestId: acp.JsonRpcId,
): Promise<LegacyLoadSessionResponse> {
logger.log("Loading session...", {sessionId: params.sessionId});
const {
sessionId,
modelState,
modeState,
thread,
} = await this.getOrCreateSessionWithHistory(params);
} = await this.getOrCreateSessionWithHistory(params, requestId);

await this.streamThreadHistory(sessionId, thread);

Expand All @@ -476,9 +485,12 @@ export class CodexAcpServer {
};
}

async resumeSession(params: acp.ResumeSessionRequest): Promise<LegacyResumeSessionResponse> {
async resumeSession(
params: acp.ResumeSessionRequest,
requestId: acp.JsonRpcId,
): Promise<LegacyResumeSessionResponse> {
logger.log("Resuming session...", {sessionId: params.sessionId});
const [sessionId, modelState, modeState] = await this.getOrCreateSession(params);
const [sessionId, modelState, modeState] = await this.getOrCreateSession(params, requestId);

logger.log("Session resumed", {
sessionId: sessionId,
Expand All @@ -492,9 +504,9 @@ export class CodexAcpServer {
};
}

async listSessions(params: acp.ListSessionsRequest): Promise<acp.ListSessionsResponse> {
async listSessions(params: acp.ListSessionsRequest, requestId: acp.JsonRpcId): Promise<acp.ListSessionsResponse> {
logger.log("Listing sessions...", {cwd: params.cwd, cursor: params.cursor});
await this.checkAuthorization();
await this.checkAuthorization(requestId);
const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSessions(params));
return {
...response,
Expand Down Expand Up @@ -582,9 +594,10 @@ export class CodexAcpServer {

async newSession(
params: acp.NewSessionRequest,
requestId: acp.JsonRpcId,
): Promise<LegacyNewSessionResponse> {
logger.log("Starting new session...");
const [sessionId, modelState, modeState] = await this.getOrCreateSession(params);
const [sessionId, modelState, modeState] = await this.getOrCreateSession(params, requestId);

logger.log("New session created", {
sessionId: sessionId,
Expand All @@ -602,9 +615,10 @@ 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 isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(this.connection, _params, requestId));
if (!isAuthenticated) {
logger.log("Authenticate request failed");
throw RequestError.invalidParams();
Expand Down Expand Up @@ -838,15 +852,16 @@ export class CodexAcpServer {
}

private async getOrCreateSessionWithHistory(
request: acp.LoadSessionRequest
request: acp.LoadSessionRequest,
requestId: acp.JsonRpcId,
): Promise<{
sessionId: SessionId;
modelState: LegacySessionModelState;
modeState: SessionModeState;
thread: Thread;
}> {
const requestedSessionGeneration = this.beginSessionOpen(request.sessionId);
await this.checkAuthorization();
await this.checkAuthorization(requestId);
const requestedMcpServers = request.mcpServers ?? [];
const mcpServerStartupVersion = requestedMcpServers.length > 0
? this.codexAcpClient.getMcpServerStartupVersion()
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
Loading