Skip to content

Commit cc193c4

Browse files
committed
Support for device code auth
1 parent 3196231 commit cc193c4

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
@@ -41,7 +41,7 @@ CODEX_PATH=/path/to/codex npx -y @agentclientprotocol/codex-acp
4141

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

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

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

6060
## 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
@@ -41,6 +41,7 @@ import type {
4141
} from "./app-server/v2";
4242
import packageJson from "../package.json";
4343
import type {AuthenticationStatusResponse} from "./AcpExtensions";
44+
import type { AcpClientConnection } from "./ACPSessionConnection";
4445
import {createCodexCollaborationMode} from "./CollaborationModeConfig";
4546
import type {ModeKind} from "./app-server/ModeKind";
4647

@@ -99,7 +100,11 @@ export class CodexAcpClient {
99100
});
100101
}
101102

102-
async authenticate(authRequest: acp.AuthenticateRequest): Promise<Boolean> {
103+
async authenticate(
104+
connection: AcpClientConnection,
105+
authRequest: acp.AuthenticateRequest,
106+
env: NodeJS.ProcessEnv = process.env,
107+
): Promise<Boolean> {
103108
if (!isCodexAuthRequest(authRequest)) {
104109
throw RequestError.invalidRequest();
105110
}
@@ -114,13 +119,11 @@ export class CodexAcpClient {
114119
if (accountResponse.account?.type === "chatgpt") {
115120
return true;
116121
}
117-
const loginCompletedPromise = this.awaitNextLoginCompleted();
118-
const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"});
119-
if (loginResponse.type == "chatgpt") {
120-
await open(loginResponse.authUrl);
122+
if (env["NO_BROWSER"]) {
123+
return await this.authenticateWithChatGptDeviceCode(connection);
124+
} else {
125+
return await this.authenticateWithChatGptBrowser();
121126
}
122-
const result = await loginCompletedPromise;
123-
return result.success;
124127
}
125128
case "gateway":
126129
if (!authRequest._meta) throw RequestError.invalidRequest();
@@ -139,6 +142,58 @@ export class CodexAcpClient {
139142
}
140143
}
141144

145+
private async authenticateWithChatGptBrowser(): Promise<Boolean> {
146+
const loginCompletedPromise = this.awaitNextLoginCompleted();
147+
const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"});
148+
if (loginResponse.type == "chatgpt") {
149+
await open(loginResponse.authUrl);
150+
}
151+
const result = await loginCompletedPromise;
152+
return result.success;
153+
}
154+
155+
private async authenticateWithChatGptDeviceCode(connection: AcpClientConnection): Promise<Boolean> {
156+
const loginCompletedPromise = this.awaitNextLoginCompleted();
157+
const loginResponse = await this.codexClient.accountLogin({type: "chatgptDeviceCode"});
158+
if (loginResponse.type == "chatgptDeviceCode") {
159+
const url = loginResponse.verificationUrl;
160+
const userCode = loginResponse.userCode;
161+
162+
await this.requestDeviceCodeAuth(connection, {
163+
elicitationId: `chatgpt-login-${crypto.randomUUID()}`,
164+
url,
165+
message: `Follow these steps to sign in with ChatGPT using device code authorization:\n\
166+
\n1. Open this link in your browser and sign in to your account\n ${url}\n\
167+
\n2. Enter this one-time code (expires in 15 minutes)\n ${userCode}\n\
168+
\nDevice codes are a common phishing target. Never share this code.\n`,
169+
});
170+
}
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+
142197
private async authenticateWithApiKey(apiKey: string): Promise<Boolean> {
143198
const loginCompletedPromise = this.awaitNextLoginCompleted();
144199
await this.codexClient.accountLogin({

src/CodexAcpServer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,7 @@ export class CodexAcpServer {
655655
_params: acp.AuthenticateRequest,
656656
): Promise<acp.AuthenticateResponse> {
657657
logger.log("Authenticate request received");
658-
const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(_params));
658+
const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(this.connection, _params));
659659
if (!isAuthenticated) {
660660
logger.log("Authenticate request failed");
661661
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
@@ -203,6 +203,67 @@ describe('ACP server test', { timeout: 40_000 }, () => {
203203
expect(accountLoginSpy).not.toHaveBeenCalled();
204204
});
205205

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

28572918
await codexAcpAgent.authenticate({methodId: "api-key"});
28582919

2859-
expect(authenticateSpy).toHaveBeenCalledWith({methodId: "api-key"});
2920+
expect(authenticateSpy).toHaveBeenCalledWith(expect.anything(), {methodId: "api-key"});
28602921
expect(getAccountSpy).toHaveBeenCalledTimes(4);
28612922
expect(codexAcpAgent.getSessionState(session1.sessionId)).toMatchObject({
28622923
account: { type: "apiKey" },
@@ -2912,7 +2973,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
29122973
};
29132974
await codexAcpAgent.authenticate(gatewayAuthRequest);
29142975

2915-
expect(authenticateSpy).toHaveBeenCalledWith(gatewayAuthRequest);
2976+
expect(authenticateSpy).toHaveBeenCalledWith(expect.anything(), gatewayAuthRequest);
29162977
expect(getAccountSpy).toHaveBeenCalledTimes(1);
29172978
expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({
29182979
account: { type: "apiKey" },

src/__tests__/CodexACPAgent/initialize.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,27 @@ describe('CodexACPAgent - initialize', () => {
119119
]));
120120
});
121121

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

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

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
@@ -13,7 +13,12 @@ import os from "node:os";
1313
import {AgentMode} from "../AgentMode";
1414
import {DEFAULT_COLLABORATION_MODE} from "../CollaborationModeConfig";
1515
import {expect, vi} from "vitest";
16-
import type {Model, ReasoningEffortOption} from "../app-server/v2";
16+
import type {
17+
AccountLoginCompletedNotification,
18+
LoginAccountResponse,
19+
Model,
20+
ReasoningEffortOption
21+
} from "../app-server/v2";
1722

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

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

251258
/**
@@ -258,6 +265,7 @@ export interface CodexMockTestFixture extends TestFixture {
258265
export function createCodexMockTestFixture(): CodexMockTestFixture {
259266
let unhandledNotificationHandler: ((notification: any) => void) | null = null;
260267
const requestHandlers = new Map<string, (params: unknown) => Promise<unknown>>();
268+
const loginCompletedHandlers = new Set<(notification: AccountLoginCompletedNotification) => void>();
261269

262270
// State for controlling permission responses
263271
const permissionState: { response: RequestPermissionResponse } = {
@@ -272,7 +280,15 @@ export function createCodexMockTestFixture(): CodexMockTestFixture {
272280
onUnhandledNotification: (handler: (notification: any) => void) => {
273281
unhandledNotificationHandler = handler;
274282
},
275-
onNotification: () => {},
283+
onNotification: (method: string, handler: (notification: AccountLoginCompletedNotification) => void) => {
284+
if (method === "account/login/completed") {
285+
loginCompletedHandlers.add(handler);
286+
return {
287+
dispose: () => loginCompletedHandlers.delete(handler),
288+
};
289+
}
290+
return { dispose: () => {} };
291+
},
276292
onRequest: (type: { method: string }, handler: (params: unknown) => Promise<unknown>) => {
277293
requestHandlers.set(type.method, handler);
278294
},
@@ -293,6 +309,7 @@ export function createCodexMockTestFixture(): CodexMockTestFixture {
293309
return { mock: "Mocked return" };
294310
});
295311
returnValues.set('requestPermission', () => permissionState.response);
312+
returnValues.set('createElicitation', () => elicitationState.response);
296313

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

0 commit comments

Comments
 (0)