Add ChatGPT device-code login to app server (#15525)

## Problem

App-server clients could only initiate ChatGPT login through the browser
callback flow, even though the shared login crate already supports
device-code auth. That left VS Code, Codex App, and other app-server
clients without a first-class way to use the existing device-code
backend when browser redirects are brittle or when the client UX wants
to own the login ceremony.

## Mental model

This change adds a second ChatGPT login start path to app-server:
clients can now call `account/login/start` with `type:
"chatgptDeviceCode"`. App-server immediately returns a `loginId` plus
the device-code UX payload (`verificationUrl` and `userCode`), then
completes the login asynchronously in the background using the existing
`codex_login` polling flow. Successful device-code login still resolves
to ordinary `chatgpt` auth, and completion continues to flow through the
existing `account/login/completed` and `account/updated` notifications.

## Non-goals

This does not introduce a new auth mode, a new account shape, or a
device-code eligibility discovery API. It also does not add automatic
fallback to browser login in core; clients remain responsible for
choosing when to request device code and whether to retry with a
different UX if the backend/admin policy rejects it.

## Tradeoffs

We intentionally keep `login_chatgpt_common` as a local validation
helper instead of turning it into a capability probe. Device-code
eligibility is checked by actually calling `request_device_code`, which
means policy-disabled cases surface as an immediate request error rather
than an async completion event. We also keep the active-login state
machine minimal: browser and device-code logins share the same public
cancel contract, but device-code cancellation is implemented with a
local cancel token rather than a larger cross-crate refactor.

## Architecture

The protocol grows a new `chatgptDeviceCode` request/response variant in
app-server v2. On the server side, the new handler reuses the existing
ChatGPT login precondition checks, calls `request_device_code`, returns
the device-code payload, and then spawns a background task that waits on
either cancellation or `complete_device_code_login`. On success, it
reuses the existing auth reload and cloud-requirements refresh path
before emitting `account/login/completed` success and `account/updated`.
On failure or cancellation, it emits only `account/login/completed`
failure. The existing `account/login/cancel { loginId }` contract
remains unchanged and now works for both browser and device-code
attempts.


## Tests

Added protocol serialization coverage for the new request/response
variant, plus app-server tests for device-code success, failure, cancel,
and start-time rejection behavior. Existing browser ChatGPT login
coverage remains in place to show that the callback-based flow is
unchanged.
This commit is contained in:
daniel-oai
2026-03-27 00:27:15 -07:00
committed by GitHub
parent dd30c8eedd
commit 47a9e2e084
14 changed files with 802 additions and 36 deletions

View File

@@ -225,7 +225,11 @@ enum CliCommand {
abort_on: Option<usize>,
},
/// Trigger the ChatGPT login flow and wait for completion.
TestLogin,
TestLogin {
/// Use the device-code login flow instead of the browser callback flow.
#[arg(long, default_value_t = false)]
device_code: bool,
},
/// Fetch the current account rate limits from the Codex app-server.
GetAccountRateLimits,
/// List the available models from the Codex app-server.
@@ -372,10 +376,10 @@ pub async fn run() -> Result<()> {
)
.await
}
CliCommand::TestLogin => {
CliCommand::TestLogin { device_code } => {
ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
test_login(&endpoint, &config_overrides).await
test_login(&endpoint, &config_overrides, device_code).await
}
CliCommand::GetAccountRateLimits => {
ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?;
@@ -1028,17 +1032,38 @@ async fn send_follow_up_v2(
.await
}
async fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
async fn test_login(
endpoint: &Endpoint,
config_overrides: &[String],
device_code: bool,
) -> Result<()> {
with_client("test-login", endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let login_response = client.login_account_chatgpt()?;
println!("< account/login/start response: {login_response:?}");
let LoginAccountResponse::Chatgpt { login_id, auth_url } = login_response else {
bail!("expected chatgpt login response");
let login_response = if device_code {
client.login_account_chatgpt_device_code()?
} else {
client.login_account_chatgpt()?
};
println!("< account/login/start response: {login_response:?}");
let login_id = match login_response {
LoginAccountResponse::Chatgpt { login_id, auth_url } => {
println!("Open the following URL in your browser to continue:\n{auth_url}");
login_id
}
LoginAccountResponse::ChatgptDeviceCode {
login_id,
verification_url,
user_code,
} => {
println!(
"Open the following URL and enter the code to continue:\n{verification_url}\n\nCode: {user_code}"
);
login_id
}
_ => bail!("expected chatgpt login response"),
};
println!("Open the following URL in your browser to continue:\n{auth_url}");
let completion = client.wait_for_account_login_completion(&login_id)?;
println!("< account/login/completed notification: {completion:?}");
@@ -1590,6 +1615,16 @@ impl CodexClient {
self.send_request(request, request_id, "account/login/start")
}
fn login_account_chatgpt_device_code(&mut self) -> Result<LoginAccountResponse> {
let request_id = self.request_id();
let request = ClientRequest::LoginAccount {
request_id: request_id.clone(),
params: codex_app_server_protocol::LoginAccountParams::ChatgptDeviceCode,
};
self.send_request(request, request_id, "account/login/start")
}
fn get_account_rate_limits(&mut self) -> Result<GetAccountRateLimitsResponse> {
let request_id = self.request_id();
let request = ClientRequest::GetAccountRateLimits {