Skip to content

Commit 45a0c85

Browse files
committed
Support for device code auth
1 parent 75893cd commit 45a0c85

9 files changed

Lines changed: 176 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: 62 additions & 7 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
* Well-known provider id for the client-configurable custom LLM gateway.
@@ -93,7 +94,11 @@ export class CodexAcpClient {
9394
});
9495
}
9596

96-
async authenticate(authRequest: acp.AuthenticateRequest): Promise<Boolean> {
97+
async authenticate(
98+
connection: AcpClientConnection,
99+
authRequest: acp.AuthenticateRequest,
100+
env: NodeJS.ProcessEnv = process.env,
101+
): Promise<Boolean> {
97102
if (!isCodexAuthRequest(authRequest)) {
98103
throw RequestError.invalidRequest();
99104
}
@@ -108,13 +113,11 @@ export class CodexAcpClient {
108113
if (accountResponse.account?.type === "chatgpt") {
109114
return true;
110115
}
111-
const loginCompletedPromise = this.awaitNextLoginCompleted();
112-
const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"});
113-
if (loginResponse.type == "chatgpt") {
114-
await open(loginResponse.authUrl);
116+
if (env["NO_BROWSER"]) {
117+
return await this.authenticateWithChatGptDeviceCode(connection);
118+
} else {
119+
return await this.authenticateWithChatGptBrowser();
115120
}
116-
const result = await loginCompletedPromise;
117-
return result.success;
118121
}
119122
case "gateway":
120123
if (!authRequest._meta) throw RequestError.invalidRequest();
@@ -133,6 +136,58 @@ export class CodexAcpClient {
133136
}
134137
}
135138

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

src/CodexAcpServer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ export class CodexAcpServer {
618618
_params: acp.AuthenticateRequest,
619619
): Promise<acp.AuthenticateResponse> {
620620
logger.log("Authenticate request received");
621-
const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(_params));
621+
const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(this.connection, _params));
622622
if (!isAuthenticated) {
623623
logger.log("Authenticate request failed");
624624
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 === "createElicitation");
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();
@@ -2712,7 +2773,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
27122773

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

2715-
expect(authenticateSpy).toHaveBeenCalledWith({methodId: "api-key"});
2776+
expect(authenticateSpy).toHaveBeenCalledWith(expect.anything(), {methodId: "api-key"});
27162777
expect(getAccountSpy).toHaveBeenCalledTimes(4);
27172778
expect(codexAcpAgent.getSessionState(session1.sessionId)).toMatchObject({
27182779
account: { type: "apiKey" },
@@ -2767,7 +2828,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
27672828
};
27682829
await codexAcpAgent.authenticate(gatewayAuthRequest);
27692830

2770-
expect(authenticateSpy).toHaveBeenCalledWith(gatewayAuthRequest);
2831+
expect(authenticateSpy).toHaveBeenCalledWith(expect.anything(), gatewayAuthRequest);
27712832
expect(getAccountSpy).toHaveBeenCalledTimes(1);
27722833
expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({
27732834
account: { type: "apiKey" },

src/__tests__/CodexACPAgent/initialize.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,27 @@ describe('CodexACPAgent - initialize', () => {
116116
]));
117117
});
118118

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

123123
expect(methodIds).not.toContain("chat-gpt");
124124
expect(methodIds).toEqual(expect.arrayContaining(["api-key"]));
125125
});
126+
127+
it('should advertise ChatGPT auth when browser auth is disabled and URL elicitation is supported', () => {
128+
const methodIds = getCodexAuthMethods(
129+
{elicitation: {url: {}}},
130+
{NO_BROWSER: "1"} as NodeJS.ProcessEnv,
131+
).map((method) => method.id);
132+
133+
expect(methodIds).toEqual(expect.arrayContaining(["chat-gpt", "api-key"]));
134+
});
135+
136+
it('should advertise ChatGPT auth when browser auth is enabled without URL elicitation support', () => {
137+
const methodIds = getCodexAuthMethods(undefined, {} as NodeJS.ProcessEnv)
138+
.map((method) => method.id);
139+
140+
expect(methodIds).toEqual(expect.arrayContaining(["chat-gpt", "api-key"]));
141+
});
126142
});

src/__tests__/CodexACPAgent/providers.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ describe("Configurable LLM providers (providers/*)", () => {
149149
const fixture = createCodexMockTestFixture();
150150
const codexAcpClient = fixture.getCodexAcpClient();
151151

152-
await codexAcpClient.authenticate({
152+
await fixture.getCodexAcpAgent().authenticate({
153153
methodId: "gateway",
154154
_meta: {
155155
gateway: {

src/__tests__/acp-test-utils.ts

Lines changed: 27 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

@@ -245,6 +250,8 @@ export interface CodexMockTestFixture extends TestFixture {
245250
sendServerRequest<T>(method: string, params: unknown): Promise<T>,
246251
setPermissionResponse(response: RequestPermissionResponse): void,
247252
setElicitationResponse(response: CreateElicitationResponse | Promise<CreateElicitationResponse>): void,
253+
setAccountLoginResponse(response: LoginAccountResponse): void,
254+
sendAccountLoginCompleted(notification: AccountLoginCompletedNotification): void,
248255
}
249256

250257
/**
@@ -257,6 +264,7 @@ export interface CodexMockTestFixture extends TestFixture {
257264
export function createCodexMockTestFixture(): CodexMockTestFixture {
258265
let unhandledNotificationHandler: ((notification: any) => void) | null = null;
259266
const requestHandlers = new Map<string, (params: unknown) => Promise<unknown>>();
267+
const loginCompletedHandlers = new Set<(notification: AccountLoginCompletedNotification) => void>();
260268

261269
// State for controlling permission responses
262270
const permissionState: { response: RequestPermissionResponse } = {
@@ -271,7 +279,15 @@ export function createCodexMockTestFixture(): CodexMockTestFixture {
271279
onUnhandledNotification: (handler: (notification: any) => void) => {
272280
unhandledNotificationHandler = handler;
273281
},
274-
onNotification: () => {},
282+
onNotification: (method: string, handler: (notification: AccountLoginCompletedNotification) => void) => {
283+
if (method === "account/login/completed") {
284+
loginCompletedHandlers.add(handler);
285+
return {
286+
dispose: () => loginCompletedHandlers.delete(handler),
287+
};
288+
}
289+
return { dispose: () => {} };
290+
},
275291
onRequest: (type: { method: string }, handler: (params: unknown) => Promise<unknown>) => {
276292
requestHandlers.set(type.method, handler);
277293
},
@@ -292,6 +308,7 @@ export function createCodexMockTestFixture(): CodexMockTestFixture {
292308
return { mock: "Mocked return" };
293309
});
294310
returnValues.set('requestPermission', () => permissionState.response);
311+
returnValues.set('createElicitation', () => elicitationState.response);
295312

296313
const acpConnection = createSmartMock<AcpClientConnection>((event) => {
297314
const normalizedEvent = normalizeAcpConnectionEvent(event);
@@ -329,6 +346,14 @@ export function createCodexMockTestFixture(): CodexMockTestFixture {
329346
setElicitationResponse(response: CreateElicitationResponse | Promise<CreateElicitationResponse>): void {
330347
elicitationState.response = response;
331348
},
349+
setAccountLoginResponse(response: LoginAccountResponse): void {
350+
vi.spyOn(baseFixture.getCodexAppServerClient(), "accountLogin").mockResolvedValue(response);
351+
},
352+
sendAccountLoginCompleted(notification: AccountLoginCompletedNotification): void {
353+
for (const handler of loginCompletedHandlers) {
354+
handler(notification);
355+
}
356+
},
332357
};
333358
}
334359

0 commit comments

Comments
 (0)