Skip to content

Commit faac66f

Browse files
committed
Device code auth support
1 parent 156cb0d commit faac66f

2 files changed

Lines changed: 96 additions & 23 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ This tool implements an ACP adapter around the Codex CLI, supporting:
2020
- Custom Prompts
2121
- Client MCP servers
2222
- Auth Methods:
23-
- ChatGPT subscription (requires paid subscription and doesn't work in remote projects)
23+
- ChatGPT subscription
24+
- Launches a browser if available
25+
- Uses device code auth if NO_BROWSER is set
2426
- CODEX_API_KEY
2527
- OPENAI_API_KEY
2628

src/codex_agent.rs

Lines changed: 93 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
use acp::schema::{
2-
AgentAuthCapabilities, AgentCapabilities, AuthEnvVar, AuthMethod, AuthMethodAgent,
3-
AuthMethodEnvVar, AuthMethodId, AuthenticateRequest, AuthenticateResponse, CancelNotification,
4-
ClientCapabilities, CloseSessionRequest, CloseSessionResponse, Implementation,
5-
InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse,
6-
LoadSessionRequest, LoadSessionResponse, LogoutCapabilities, LogoutRequest, LogoutResponse,
7-
McpCapabilities, McpServer, McpServerHttp, McpServerStdio, NewSessionRequest,
8-
NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse, ProtocolVersion,
2+
AgentAuthCapabilities, AgentCapabilities, AgentRequest, AuthEnvVar, AuthMethod,
3+
AuthMethodAgent, AuthMethodEnvVar, AuthMethodId, AuthenticateRequest, AuthenticateResponse,
4+
CancelNotification, ClientCapabilities, CloseSessionRequest, CloseSessionResponse,
5+
CreateElicitationRequest, CreateElicitationResponse, ElicitationAction,
6+
ElicitationRequestScope, ElicitationUrlMode, Implementation, InitializeRequest,
7+
InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest,
8+
LoadSessionResponse, LogoutCapabilities, LogoutRequest, LogoutResponse, McpCapabilities,
9+
McpServer, McpServerHttp, McpServerStdio, NewSessionRequest, NewSessionResponse,
10+
PromptCapabilities, PromptRequest, PromptResponse, ProtocolVersion, RequestId,
911
SessionCapabilities, SessionCloseCapabilities, SessionId, SessionInfo, SessionListCapabilities,
1012
SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest,
1113
SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse,
1214
};
13-
use acp::{Agent, Client, ConnectTo, ConnectionTo, Error};
15+
use acp::{Agent, Client, ConnectTo, ConnectionTo, Error, Responder};
1416
use agent_client_protocol as acp;
1517
use codex_config::{McpServerConfig, McpServerTransportConfig};
1618
use codex_core::{
@@ -32,7 +34,7 @@ use std::{
3234
path::{Path, PathBuf},
3335
sync::{Arc, Mutex},
3436
};
35-
use tracing::{debug, info};
37+
use tracing::{debug, error, info};
3638
use unicode_segmentation::UnicodeSegmentation;
3739

3840
use crate::thread::Thread;
@@ -130,11 +132,16 @@ impl CodexAgent {
130132
{
131133
let agent = agent.clone();
132134
async move |request: AuthenticateRequest,
133-
responder,
135+
responder: Responder<AuthenticateResponse>,
134136
cx: ConnectionTo<Client>| {
135137
let agent = agent.clone();
138+
let auth_cx = cx.clone();
139+
let request_id = request_id_from_json(responder.id())
140+
.expect("request id must be a string or number");
136141
cx.spawn(async move {
137-
responder.respond_with_result(agent.authenticate(request).await)
142+
responder.respond_with_result(
143+
agent.authenticate(request, auth_cx, request_id).await,
144+
)
138145
})?;
139146
Ok(())
140147
}
@@ -234,7 +241,7 @@ impl CodexAgent {
234241
let agent = agent.clone();
235242
cx.spawn(async move {
236243
if let Err(e) = agent.cancel(notification).await {
237-
tracing::error!("Error handling cancel: {:?}", e);
244+
error!("Error handling cancel: {:?}", e);
238245
}
239246
Ok(())
240247
})?;
@@ -433,6 +440,11 @@ impl CodexAgent {
433440
debug!("Received initialize request with protocol version {protocol_version:?}",);
434441
let protocol_version = ProtocolVersion::V1;
435442

443+
let client_supports_url_elicitation = client_capabilities
444+
.elicitation
445+
.as_ref()
446+
.is_some_and(|elicitation| elicitation.url.is_some());
447+
436448
*self.client_capabilities.lock().unwrap() = client_capabilities;
437449

438450
let mut agent_capabilities = AgentCapabilities::new()
@@ -450,8 +462,8 @@ impl CodexAgent {
450462
CodexAuthMethod::CodexApiKey.into(),
451463
CodexAuthMethod::OpenAiApiKey.into(),
452464
];
453-
// Until codex device code auth works, we can't use this in remote ssh projects
454-
if std::env::var("NO_BROWSER").is_ok() {
465+
466+
if std::env::var("NO_BROWSER").is_ok() && !client_supports_url_elicitation {
455467
auth_methods.remove(0);
456468
}
457469

@@ -464,6 +476,8 @@ impl CodexAgent {
464476
async fn authenticate(
465477
&self,
466478
request: AuthenticateRequest,
479+
cx: ConnectionTo<Client>,
480+
request_id: RequestId,
467481
) -> Result<AuthenticateResponse, Error> {
468482
let auth_method = CodexAuthMethod::try_from(request.method_id)?;
469483

@@ -483,21 +497,25 @@ impl CodexAgent {
483497

484498
match auth_method {
485499
CodexAuthMethod::ChatGpt => {
486-
// Perform browser/device login via codex-rs, then report success/failure to the client.
487500
let opts = codex_login::ServerOptions::new(
488501
self.config.codex_home.to_path_buf(),
489502
codex_login::auth::CLIENT_ID.to_string(),
490503
None,
491504
self.config.cli_auth_credentials_store_mode,
492505
);
493506

494-
let server =
495-
codex_login::run_login_server(opts).map_err(Error::into_internal_error)?;
496-
497-
server
498-
.block_until_done()
499-
.await
500-
.map_err(Error::into_internal_error)?;
507+
if std::env::var("NO_BROWSER").is_ok() {
508+
Self::device_code_auth(opts, cx, request_id).await?;
509+
} else {
510+
// Perform browser/device login via codex-rs, then report success/failure to the client.
511+
let server =
512+
codex_login::run_login_server(opts).map_err(Error::into_internal_error)?;
513+
514+
server
515+
.block_until_done()
516+
.await
517+
.map_err(Error::into_internal_error)?;
518+
}
501519
}
502520
CodexAuthMethod::CodexApiKey => {
503521
let api_key = read_codex_api_key_from_env().ok_or_else(|| {
@@ -528,6 +546,51 @@ impl CodexAgent {
528546
Ok(AuthenticateResponse::new())
529547
}
530548

549+
async fn device_code_auth(
550+
opts: codex_login::ServerOptions,
551+
cx: ConnectionTo<Client>,
552+
request_id: RequestId,
553+
) -> Result<(), Error> {
554+
let device_code = codex_login::request_device_code(&opts)
555+
.await
556+
.map_err(Error::into_internal_error)?;
557+
558+
let url = device_code.verification_url.clone();
559+
let user_code = device_code.user_code.clone();
560+
let message = format!(
561+
"Follow these steps to sign in with ChatGPT using device code authorization:\n\
562+
\n1. Open this link in your browser and sign in to your account\n {url}\n\
563+
\n2. Enter this one-time code (expires in 15 minutes)\n {user_code}\n\
564+
\nDevice codes are a common phishing target. Never share this code.\n"
565+
);
566+
567+
let elicitation_id = format!("chatgpt-login-{}", uuid::Uuid::new_v4());
568+
let response = cx
569+
.send_request(AgentRequest::CreateElicitationRequest(
570+
CreateElicitationRequest::new(
571+
ElicitationUrlMode::new(
572+
ElicitationRequestScope::new(request_id),
573+
elicitation_id,
574+
url,
575+
),
576+
message,
577+
),
578+
))
579+
.block_task()
580+
.await?;
581+
582+
let response: CreateElicitationResponse =
583+
serde_json::from_value(response).map_err(Error::into_internal_error)?;
584+
if !matches!(response.action, ElicitationAction::Accept(_)) {
585+
return Err(Error::auth_required());
586+
}
587+
588+
codex_login::complete_device_code_login(opts, device_code)
589+
.await
590+
.map_err(Error::into_internal_error)?;
591+
Ok(())
592+
}
593+
531594
async fn logout(&self, _request: LogoutRequest) -> Result<LogoutResponse, Error> {
532595
self.auth_manager
533596
.logout()
@@ -873,6 +936,14 @@ impl TryFrom<AuthMethodId> for CodexAuthMethod {
873936
}
874937
}
875938

939+
fn request_id_from_json(id: serde_json::Value) -> Option<RequestId> {
940+
match id {
941+
serde_json::Value::String(id) => Some(id.into()),
942+
serde_json::Value::Number(id) => id.as_u64().map(Into::into),
943+
_ => None,
944+
}
945+
}
946+
876947
fn truncate_graphemes(text: &str, max_graphemes: usize) -> String {
877948
let mut graphemes = text.grapheme_indices(true);
878949

0 commit comments

Comments
 (0)