Skip to content

Commit aa2d821

Browse files
committed
Support for device code auth
1 parent 5506fba commit aa2d821

8 files changed

Lines changed: 190 additions & 18 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ CODEX_PATH=/path/to/codex npx -y @agentclientprotocol/codex-acp
4040

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

43-
- ChatGPT login. Set `NO_BROWSER=1` to hide this method in remote or browserless environments.
43+
- ChatGPT login. Set `NO_BROWSER=1` to use device code auth for remote or browserless environments.
4444
- API key via `CODEX_API_KEY` or `OPENAI_API_KEY`.
4545
- A custom OpenAI-compatible gateway, when the client opts in to the gateway auth capability.
4646

@@ -53,7 +53,7 @@ The adapter advertises ACP auth methods during initialization. Clients can authe
5353
- `MODEL_PROVIDER` - model provider to pass to Codex for new sessions.
5454
- `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication.
5555
- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, or `agent-full-access`.
56-
- `NO_BROWSER` - hide browser-based ChatGPT auth when set.
56+
- `NO_BROWSER` - use device code auth instead of browser-based ChatGPT auth when set.
5757
- `APP_SERVER_LOGS` - directory for adapter logs.
5858

5959
## Development

readme-dev.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Set `CODEX_PATH` to run a different Codex binary; versions other than the one sp
1010
- `MODEL_PROVIDER` - model provider to pass to Codex for new sessions.
1111
- `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication.
1212
- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, or `agent-full-access`.
13-
- `NO_BROWSER` - hide browser-based ChatGPT auth when set.
13+
- `NO_BROWSER` - use device code auth instead of browser-based ChatGPT auth when set.
1414
- `APP_SERVER_LOGS` - directory for adapter logs.
1515

1616
### Quick start

src/CodexAcpClient.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import type {
4040
} from "./app-server/v2";
4141
import packageJson from "../package.json";
4242
import type {AuthenticationStatusResponse} from "./AcpExtensions";
43+
import type { AcpClientConnection } from "./ACPSessionConnection";
4344

4445
/**
4546
* API for accessing the Codex App Server using ACP requests.
@@ -78,7 +79,11 @@ export class CodexAcpClient {
7879
});
7980
}
8081

81-
async authenticate(authRequest: acp.AuthenticateRequest): Promise<Boolean> {
82+
async authenticate(
83+
connection: AcpClientConnection,
84+
authRequest: acp.AuthenticateRequest,
85+
env: NodeJS.ProcessEnv = process.env,
86+
): Promise<Boolean> {
8287
if (!isCodexAuthRequest(authRequest)) {
8388
throw RequestError.invalidRequest();
8489
}
@@ -94,14 +99,11 @@ export class CodexAcpClient {
9499
this.gatewayConfig = null;
95100
return true;
96101
}
97-
const loginCompletedPromise = this.awaitNextLoginCompleted();
98-
const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"});
99-
if (loginResponse.type == "chatgpt") {
100-
await open(loginResponse.authUrl);
102+
if (env["NO_BROWSER"]) {
103+
return await this.authenticateWithChatGptDeviceCode(connection);
104+
} else {
105+
return await this.authenticateWithChatGptBrowser();
101106
}
102-
this.gatewayConfig = null;
103-
const result = await loginCompletedPromise;
104-
return result.success;
105107
}
106108
case "gateway":
107109
if (!authRequest._meta) throw RequestError.invalidRequest();
@@ -138,6 +140,60 @@ export class CodexAcpClient {
138140
return false;
139141
}
140142

143+
private async authenticateWithChatGptBrowser(): Promise<Boolean> {
144+
const loginCompletedPromise = this.awaitNextLoginCompleted();
145+
const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"});
146+
if (loginResponse.type == "chatgpt") {
147+
await open(loginResponse.authUrl);
148+
}
149+
this.gatewayConfig = null;
150+
const result = await loginCompletedPromise;
151+
return result.success;
152+
}
153+
154+
private async authenticateWithChatGptDeviceCode(connection: AcpClientConnection): Promise<Boolean> {
155+
const loginCompletedPromise = this.awaitNextLoginCompleted();
156+
const loginResponse = await this.codexClient.accountLogin({type: "chatgptDeviceCode"});
157+
if (loginResponse.type == "chatgptDeviceCode") {
158+
const url = loginResponse.verificationUrl;
159+
const userCode = loginResponse.userCode;
160+
161+
await this.requestDeviceCodeAuth(connection, {
162+
elicitationId: `chatgpt-login-${crypto.randomUUID()}`,
163+
url,
164+
message: `Follow these steps to sign in with ChatGPT using device code authorization:\n\
165+
\n1. Open this link in your browser and sign in to your account\n ${url}\n\
166+
\n2. Enter this one-time code (expires in 15 minutes)\n ${userCode}\n\
167+
\nDevice codes are a common phishing target. Never share this code.\n`,
168+
});
169+
}
170+
this.gatewayConfig = null;
171+
const result = await loginCompletedPromise;
172+
return result.success;
173+
}
174+
175+
private async requestDeviceCodeAuth(
176+
client: AcpClientConnection,
177+
request: { elicitationId: string; url: string; message: string },
178+
): Promise<void> {
179+
const response = await client.request(
180+
acp.methods.client.elicitation.create,
181+
{
182+
mode: "url",
183+
// This should be the authenticate requestId, but this is not exposed by the ACP SDK.
184+
// The elicitation still works, but won't be tied to the authenticate request.
185+
requestId: null,
186+
elicitationId: request.elicitationId,
187+
url: request.url,
188+
message: request.message,
189+
},
190+
);
191+
192+
if (response.action !== "accept") {
193+
throw RequestError.authRequired();
194+
}
195+
}
196+
141197
private async authenticateWithApiKey(apiKey: string): Promise<Boolean> {
142198
const loginCompletedPromise = this.awaitNextLoginCompleted();
143199
await this.codexClient.accountLogin({

src/CodexAcpServer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ export class CodexAcpServer {
604604
_params: acp.AuthenticateRequest,
605605
): Promise<acp.AuthenticateResponse> {
606606
logger.log("Authenticate request received");
607-
const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(_params));
607+
const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(this.connection, _params));
608608
if (!isAuthenticated) {
609609
logger.log("Authenticate request failed");
610610
throw RequestError.invalidParams();

src/CodexAuthMethod.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ export interface GatewayAuthRequest extends AuthenticateRequest {
5959

6060
export function getCodexAuthMethods(clientCapabilities?: ClientCapabilities | null, env: NodeJS.ProcessEnv = process.env): AuthMethod[] {
6161
const authMethods: AuthMethod[] = [ApiKeyAuthMethod];
62-
if (!env["NO_BROWSER"]) {
62+
// ChatGPT login requires a browser or URL elicitation support for device code auth
63+
if (!env["NO_BROWSER"] || clientCapabilities?.elicitation?.url) {
6364
authMethods.push(ChatGptAuthMethod);
6465
}
6566
const supportsGatewayAuth = clientCapabilities?.auth?._meta?.["gateway"] === true;

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,67 @@ describe('ACP server test', { timeout: 40_000 }, () => {
202202
expect(accountLoginSpy).not.toHaveBeenCalled();
203203
});
204204

205+
206+
it('should request ACP URL elicitation for device code auth', async () => {
207+
const deviceCodeFixture = createCodexMockTestFixture();
208+
vi.spyOn(deviceCodeFixture.getCodexAppServerClient(), "accountRead").mockResolvedValue({
209+
account: null,
210+
requiresOpenaiAuth: true,
211+
});
212+
deviceCodeFixture.setElicitationResponse({action: "accept"});
213+
deviceCodeFixture.setAccountLoginResponse({
214+
type: "chatgptDeviceCode",
215+
loginId: "login-id",
216+
verificationUrl: "https://openai.example/device",
217+
userCode: "ABCD-EFGH",
218+
});
219+
vi.stubEnv("NO_BROWSER", "1");
220+
221+
const authPromise = deviceCodeFixture.getCodexAcpAgent().authenticate({methodId: "chat-gpt"});
222+
223+
await vi.waitFor(() => {
224+
expect(deviceCodeFixture.getCodexAppServerClient().accountLogin)
225+
.toHaveBeenCalledWith({type: "chatgptDeviceCode"});
226+
});
227+
228+
await vi.waitFor(() => {
229+
const elicitationEvent = deviceCodeFixture.getAcpConnectionEvents([])
230+
.find(event => event.method === "elicitationCreate");
231+
expect(elicitationEvent?.args[0]).toEqual(expect.objectContaining({
232+
mode: "url",
233+
requestId: null,
234+
elicitationId: expect.stringMatching(/^chatgpt-login-/),
235+
url: "https://openai.example/device",
236+
message: expect.stringContaining("ABCD-EFGH"),
237+
}));
238+
});
239+
240+
deviceCodeFixture.sendAccountLoginCompleted({loginId: "login-id", success: true, error: null});
241+
242+
await expect(authPromise).resolves.toEqual({});
243+
});
244+
245+
it('should fail device code auth when ACP URL elicitation is not accepted', async () => {
246+
const deviceCodeFixture = createCodexMockTestFixture();
247+
vi.spyOn(deviceCodeFixture.getCodexAppServerClient(), "accountRead").mockResolvedValue({
248+
account: null,
249+
requiresOpenaiAuth: true,
250+
});
251+
deviceCodeFixture.setElicitationResponse({action: "cancel"});
252+
deviceCodeFixture.setAccountLoginResponse({
253+
type: "chatgptDeviceCode",
254+
loginId: "login-id",
255+
verificationUrl: "https://openai.example/device",
256+
userCode: "ABCD-EFGH",
257+
});
258+
vi.stubEnv("NO_BROWSER", "1");
259+
260+
await expect(deviceCodeFixture.getCodexAcpAgent().authenticate({methodId: "chat-gpt"}))
261+
.rejects.toThrow("Authentication required");
262+
expect(deviceCodeFixture.getCodexAppServerClient().accountLogin)
263+
.toHaveBeenCalledWith({type: "chatgptDeviceCode"});
264+
});
265+
205266
it('should authenticate with a gateway', async () => {
206267
const gatewayFixture = createTestFixture();
207268
const codexAcpAgent = gatewayFixture.getCodexAcpAgent();
@@ -2697,7 +2758,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
26972758

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

2700-
expect(authenticateSpy).toHaveBeenCalledWith({methodId: "api-key"});
2761+
expect(authenticateSpy).toHaveBeenCalledWith(expect.anything(), {methodId: "api-key"});
27012762
expect(getAccountSpy).toHaveBeenCalledTimes(4);
27022763
expect(codexAcpAgent.getSessionState(session1.sessionId)).toMatchObject({
27032764
account: { type: "apiKey" },
@@ -2752,7 +2813,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
27522813
};
27532814
await codexAcpAgent.authenticate(gatewayAuthRequest);
27542815

2755-
expect(authenticateSpy).toHaveBeenCalledWith(gatewayAuthRequest);
2816+
expect(authenticateSpy).toHaveBeenCalledWith(expect.anything(), gatewayAuthRequest);
27562817
expect(getAccountSpy).toHaveBeenCalledTimes(1);
27572818
expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({
27582819
account: { type: "apiKey" },

src/__tests__/CodexACPAgent/initialize.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,27 @@ describe('CodexACPAgent - initialize', () => {
102102
]));
103103
});
104104

105-
it('should not advertise ChatGPT auth when browser auth is disabled', () => {
105+
it('should not advertise ChatGPT auth when browser auth is disabled and URL elicitation is unsupported', () => {
106106
const methodIds = getCodexAuthMethods(undefined, {NO_BROWSER: "1"} as NodeJS.ProcessEnv)
107107
.map((method) => method.id);
108108

109109
expect(methodIds).not.toContain("chat-gpt");
110110
expect(methodIds).toEqual(expect.arrayContaining(["api-key"]));
111111
});
112+
113+
it('should advertise ChatGPT auth when browser auth is disabled and URL elicitation is supported', () => {
114+
const methodIds = getCodexAuthMethods(
115+
{elicitation: {url: {}}},
116+
{NO_BROWSER: "1"} as NodeJS.ProcessEnv,
117+
).map((method) => method.id);
118+
119+
expect(methodIds).toEqual(expect.arrayContaining(["chat-gpt", "api-key"]));
120+
});
121+
122+
it('should advertise ChatGPT auth when browser auth is enabled without URL elicitation support', () => {
123+
const methodIds = getCodexAuthMethods(undefined, {} as NodeJS.ProcessEnv)
124+
.map((method) => method.id);
125+
126+
expect(methodIds).toEqual(expect.arrayContaining(["chat-gpt", "api-key"]));
127+
});
112128
});

src/__tests__/acp-test-utils.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import fs from "node:fs";
1212
import os from "node:os";
1313
import {AgentMode} from "../AgentMode";
1414
import {expect, vi} from "vitest";
15-
import type {Model, ReasoningEffortOption} from "../app-server/v2";
15+
import type {
16+
AccountLoginCompletedNotification,
17+
LoginAccountResponse,
18+
Model,
19+
ReasoningEffortOption
20+
} from "../app-server/v2";
1621

1722
export type MethodCallEvent = { method: string; args: any[] };
1823

@@ -42,6 +47,9 @@ function normalizeAcpConnectionEvent(event: MethodCallEvent): MethodCallEvent {
4247
if (event.method === "request" && event.args[0] === acp.methods.client.session.requestPermission) {
4348
return {method: "requestPermission", args: [event.args[1]]};
4449
}
50+
if (event.method === "request" && event.args[0] === acp.methods.client.elicitation.create) {
51+
return {method: "elicitationCreate", args: [event.args[1]]};
52+
}
4553
if (event.method === "notify" && event.args[0] === acp.methods.client.session.update) {
4654
return {method: "sessionUpdate", args: [event.args[1]]};
4755
}
@@ -238,6 +246,9 @@ export interface CodexMockTestFixture extends TestFixture {
238246
sendServerNotification(notification: ServerNotification | Record<string, unknown>): void,
239247
sendServerRequest<T>(method: string, params: unknown): Promise<T>,
240248
setPermissionResponse(response: RequestPermissionResponse): void,
249+
setElicitationResponse(response: acp.CreateElicitationResponse): void,
250+
setAccountLoginResponse(response: LoginAccountResponse): void,
251+
sendAccountLoginCompleted(notification: AccountLoginCompletedNotification): void,
241252
}
242253

243254
/**
@@ -250,18 +261,30 @@ export interface CodexMockTestFixture extends TestFixture {
250261
export function createCodexMockTestFixture(): CodexMockTestFixture {
251262
let unhandledNotificationHandler: ((notification: any) => void) | null = null;
252263
const requestHandlers = new Map<string, (params: unknown) => Promise<unknown>>();
264+
const loginCompletedHandlers = new Set<(notification: AccountLoginCompletedNotification) => void>();
253265

254266
// State for controlling permission responses
255267
const permissionState: { response: RequestPermissionResponse } = {
256268
response: { outcome: { outcome: 'cancelled' } }
257269
};
270+
const elicitationState: { response: acp.CreateElicitationResponse } = {
271+
response: { action: 'cancel' }
272+
};
258273

259274
const mockCodexConnection = {
260275
sendRequest: () => Promise.resolve(undefined),
261276
onUnhandledNotification: (handler: (notification: any) => void) => {
262277
unhandledNotificationHandler = handler;
263278
},
264-
onNotification: () => {},
279+
onNotification: (method: string, handler: (notification: AccountLoginCompletedNotification) => void) => {
280+
if (method === "account/login/completed") {
281+
loginCompletedHandlers.add(handler);
282+
return {
283+
dispose: () => loginCompletedHandlers.delete(handler),
284+
};
285+
}
286+
return { dispose: () => {} };
287+
},
265288
onRequest: (type: { method: string }, handler: (params: unknown) => Promise<unknown>) => {
266289
requestHandlers.set(type.method, handler);
267290
},
@@ -276,9 +299,13 @@ export function createCodexMockTestFixture(): CodexMockTestFixture {
276299
if (args[0] === acp.methods.client.session.requestPermission) {
277300
return permissionState.response;
278301
}
302+
if (args[0] === acp.methods.client.elicitation.create) {
303+
return elicitationState.response;
304+
}
279305
return { mock: "Mocked return" };
280306
});
281307
returnValues.set('requestPermission', () => permissionState.response);
308+
returnValues.set('elicitationCreate', () => elicitationState.response);
282309

283310
const acpConnection = createSmartMock<AcpClientConnection>((event) => {
284311
const normalizedEvent = normalizeAcpConnectionEvent(event);
@@ -313,6 +340,17 @@ export function createCodexMockTestFixture(): CodexMockTestFixture {
313340
setPermissionResponse(response: RequestPermissionResponse): void {
314341
permissionState.response = response;
315342
},
343+
setElicitationResponse(response: acp.CreateElicitationResponse): void {
344+
elicitationState.response = response;
345+
},
346+
setAccountLoginResponse(response: LoginAccountResponse): void {
347+
vi.spyOn(baseFixture.getCodexAppServerClient(), "accountLogin").mockResolvedValue(response);
348+
},
349+
sendAccountLoginCompleted(notification: AccountLoginCompletedNotification): void {
350+
for (const handler of loginCompletedHandlers) {
351+
handler(notification);
352+
}
353+
},
316354
};
317355
}
318356

0 commit comments

Comments
 (0)