From 9752e0e0bdb28aa2d21169078a6c4ae3ea9615d2 Mon Sep 17 00:00:00 2001 From: Colin Young Date: Wed, 11 Nov 2026 07:01:46 +0000 Subject: [PATCH] Add a Git credential helper for existing Codex logins (#60746) ## What changed Add the hidden `codex git-credential` command to supply an existing file-backed ChatGPT login as an ephemeral Bearer credential through Git's `authtype` protocol. Restrict credentials to HTTPS requests under `backend-api/codex/sites/git/` on `chatgpt.com` or `chatgpt-staging.com`, matching the configured origin. Check managed workspace policy and reject expired or changed logins before returning credentials. Ignore repository configuration when selecting authentication settings. The helper does not refresh tokens or modify the saved login; `store` and `erase` are no-ops. Return `quit=true` when authentication is unavailable or fails so Git does not fall back to prompting. ## Testing Add coverage for capability advertisement before login, reading only the first credential record, and declining personal access tokens without an authentication API lookup. GitOrigin-RevId: 2f96af1eeb7d05e3d893367137103a7adbc02742 --- codex-rs/cli/src/git_credential.rs | 193 +++++++++++++++++++++++++++++ codex-rs/cli/src/lib.rs | 3 + codex-rs/cli/src/main.rs | 18 +++ codex-rs/cli/tests/login.rs | 46 +++++++ 4 files changed, 260 insertions(+) create mode 100644 codex-rs/cli/src/git_credential.rs diff --git a/codex-rs/cli/src/git_credential.rs b/codex-rs/cli/src/git_credential.rs new file mode 100644 index 0000000000..8f37b3fdd3 --- /dev/null +++ b/codex-rs/cli/src/git_credential.rs @@ -0,0 +1,193 @@ +//! Read the existing Codex login for Git; the host retains ownership of refresh. + +use anyhow::Result; +use anyhow::ensure; +use chrono::Utc; +use clap::Args; +use clap::ValueEnum; +use codex_app_server::in_process::EmbeddedNetworkPolicy; +use codex_cloud_config::managed_config_bundle_loader_for_auth; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_login::AuthManager; +use codex_login::is_workload_identity_selected; +use codex_login::token_data::parse_jwt_expiration; +use codex_protocol::auth::AuthMode; +use codex_utils_cli::CliConfigOverrides; +use std::io::BufRead; +use std::io::Read; +use std::io::Write; +use url::Url; + +#[derive(Debug, Args)] +pub struct GitCredentialArgs { + #[command(flatten)] + pub config_overrides: CliConfigOverrides, + #[arg(value_enum)] + operation: Operation, +} + +#[derive(Clone, Debug, ValueEnum)] +enum Operation { + Get, + Store, + Erase, + Capability, +} + +pub async fn run_git_credential(args: GitCredentialArgs) -> Result<()> { + if matches!(args.operation, Operation::Capability) { + println!("version 0\ncapability authtype"); + return Ok(()); + } + let input = read_request()?; + if !matches!(args.operation, Operation::Get) { + // Git approval/rejection never stores another copy or changes the Codex login. + return Ok(()); + } + let fields = || input.lines().filter_map(|line| line.split_once('=')); + let field = |name| fields().find_map(|(key, value)| (key == name).then_some(value)); + if !fields().any(|pair| pair == ("capability[]", "authtype")) + || field("protocol") != Some("https") + { + return Ok(()); + } + let (Some(host), Some(path)) = (field("host"), field("path")) else { + return Ok(()); + }; + if !path.starts_with("backend-api/codex/sites/git/") + || path + .chars() + .any(|ch| ch.is_control() || matches!(ch, '\\' | '?' | '#')) + || path.split('/').any(|part| matches!(part, "." | "..")) + { + return Ok(()); + } + let Ok(destination) = Url::parse(&format!("https://{host}/{path}")) else { + return Ok(()); + }; + if !matches!( + destination.host_str(), + Some("chatgpt.com" | "chatgpt-staging.com") + ) || destination.port_or_known_default() != Some(443) + || !destination + .path() + .starts_with("/backend-api/codex/sites/git/") + || !destination.username().is_empty() + || destination.password().is_some() + { + return Ok(()); + } + + let Some(token) = git_token_for_origin(args.config_overrides, &destination).await? else { + println!("quit=true\n"); + return Ok(()); + }; + writeln!( + std::io::stdout().lock(), + "capability[]=authtype\nauthtype=Bearer\ncredential={token}\nephemeral=1\n" + )?; + Ok(()) +} + +async fn git_token_for_origin( + overrides: CliConfigOverrides, + destination: &Url, +) -> Result> { + // Repository configuration must not select the credential source or its API origin. + let builder = ConfigBuilder::default() + .cli_overrides(overrides.parse_overrides().map_err(config_unavailable)?) + .without_project_context(); + let config = builder.clone().build().await.map_err(config_unavailable)?; + let matches_origin_and_store = |config: &Config| { + config.cli_auth_credentials_store_mode == AuthCredentialsStoreMode::File + && Url::parse(&config.chatgpt_base_url) + .is_ok_and(|base| base.origin() == destination.origin()) + }; + // Git reads a file-backed user login. Other identities can require a network exchange. + ensure!( + matches_origin_and_store(&config) && !is_workload_identity_selected(), + "Codex Git authentication requires a file-backed ChatGPT login for this origin" + ); + let manager = AuthManager::shared_from_config_with_auth_modes( + &config, + /*enable_codex_api_key_env*/ false, + vec![AuthMode::Chatgpt], + ) + .await + .map_err(|_| anyhow::anyhow!("Codex authentication is unavailable"))?; + // Git runs inside the execution sandbox, which cannot persist a rotated login. + let Some(auth) = manager + .auth_cached() + .filter(|auth| !auth.is_fedramp_account()) + else { + return Ok(None); + }; + let token = auth + .get_token() + .map_err(|_| anyhow::anyhow!("Codex authentication is unavailable"))?; + let expiry = parse_jwt_expiration(&token).ok().flatten(); + let unexpired = || expiry.is_some_and(|expiry| expiry > Utc::now()); + if !unexpired() || !token.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) { + return Ok(None); + } + let requirements = config.config_layer_stack.requirements_toml(); + // Config-server OAuth uses the OS keyring, which Git must never open. + ensure!( + requirements + .config_server + .as_ref() + .is_none_or(|server| server.oauth.is_none()), + "Git cannot verify Codex workspace policy using a protected login" + ); + let login_changes = manager.auth_change_receiver(); + let network = EmbeddedNetworkPolicy::load(&Default::default()).await; + let bundle = managed_config_bundle_loader_for_auth( + network.bind_bootstrap_auth(config.auth_config()), + &auth, + requirements, + ) + .await + .map_err(config_unavailable)?; + let effective = builder + .cloud_config_bundle(bundle) + .build() + .await + .map_err(config_unavailable)?; + effective + .config_layer_stack + .ensure_config_server_applied() + .map_err(config_unavailable)?; + ensure!( + matches_origin_and_store(&effective) && effective.auth_config().allows_auth(&auth), + "Codex workspace policy does not permit this Git login" + ); + // The host can sign out or rotate the saved login while Git waits for policy. + manager + .reload_with_cached_auth() + .await + .map_err(config_unavailable)?; + ensure!( + !login_changes.has_changed()? && unexpired(), + "Codex login changed or expired while checking Git access" + ); + Ok(Some(token)) +} + +fn config_unavailable(_: E) -> anyhow::Error { + anyhow::anyhow!("Codex authentication configuration is unavailable") +} + +fn read_request() -> Result { + let mut stdin = std::io::stdin().lock().take(16 * 1024 + 1); + let mut input = String::new(); + loop { + let start = input.len(); + stdin.read_line(&mut input)?; + ensure!(stdin.limit() > 0, "Git credential request is too large"); + if matches!(&input[start..], "" | "\n" | "\r\n") { + return Ok(input); + } + } +} diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index cc91f5b82d..dbfd0bc0e7 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -1,6 +1,7 @@ pub mod cloud_config; pub(crate) mod debug_sandbox; mod exit_status; +mod git_credential; pub(crate) mod login; use clap::Args; @@ -13,6 +14,8 @@ use std::path::PathBuf; pub use debug_sandbox::run_command_under_landlock; pub use debug_sandbox::run_command_under_seatbelt; pub use debug_sandbox::run_command_under_windows_sandbox; +pub use git_credential::GitCredentialArgs; +pub use git_credential::run_git_credential; pub use login::read_access_token_from_stdin; pub use login::read_api_key_from_stdin; pub use login::run_login_status; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index a8a5ed7e7b..5108253a5e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -161,6 +161,10 @@ enum Subcommand { /// Manage login. Login(LoginCommand), + /// Supply the existing Codex login to Git's credential-helper protocol. + #[clap(hide = true)] + GitCredential(codex_cli::GitCredentialArgs), + /// Remove stored authentication credentials. Logout(LogoutCommand), @@ -1680,6 +1684,19 @@ async fn dispatch_cli( arg0_paths, ))); } + Some(Subcommand::GitCredential(mut args)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "git-credential", + )?; + prepend_config_flags(&mut args.config_overrides, root_config_overrides.clone()); + // Git falls back to prompting after helper errors; quitting must be explicit. + if let Err(error) = codex_cli::run_git_credential(args).await { + eprintln!("{error}"); + println!("quit=true\n"); + } + } Some(Subcommand::Login(mut login_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -2417,6 +2434,7 @@ fn unsupported_subcommand_name_for_strict_config( #[cfg(any(target_os = "macos", target_os = "windows"))] Some(Subcommand::App(_)) => Some("app"), Some(Subcommand::Login(_)) => Some("login"), + Some(Subcommand::GitCredential(_)) => Some("git-credential"), Some(Subcommand::Logout(_)) => Some("logout"), Some(Subcommand::Completion(_)) => Some("completion"), Some(Subcommand::Update) => Some("update"), diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs index 88a1b98d44..db6761b50b 100644 --- a/codex-rs/cli/tests/login.rs +++ b/codex-rs/cli/tests/login.rs @@ -46,6 +46,52 @@ fn read_auth_json(codex_home: &Path) -> Result { Ok(serde_json::from_str(&auth_json)?) } +const SITES_GIT_REQUEST: &str = "capability[]=authtype\nprotocol=https\nhost=chatgpt.com\npath=backend-api/codex/sites/git/fixture.git\n\n"; + +#[test] +fn git_credential_advertises_authtype_before_login() -> Result<()> { + let codex_home = TempDir::new()?; + codex_command(codex_home.path())? + .args(["git-credential", "capability"]) + .assert() + .success() + .stdout(contains("capability authtype\n")); + Ok(()) +} + +#[test] +fn git_credential_reads_only_the_first_record() -> Result<()> { + let home = TempDir::new()?; + codex_command(home.path())? + .args(["git-credential", "get"]) + .write_stdin(format!( + "protocol=https\nhost=chatgpt.com\n\n{SITES_GIT_REQUEST}" + )) + .assert() + .success() + .stdout(""); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn git_credential_declines_personal_access_tokens_without_a_lookup() -> Result<()> { + let home = TempDir::new()?; + write_file_auth_config(home.path())?; + let auth_api = MockServer::start().await; + codex_command(home.path())? + .env_remove(OPENAI_FEDERATION_RULE_ID_ENV_VAR) + .env_remove(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR) + .env(CODEX_ACCESS_TOKEN_ENV_VAR, "at-fixture") + .env("CODEX_AUTHAPI_BASE_URL", auth_api.uri()) + .args(["git-credential", "get"]) + .write_stdin(SITES_GIT_REQUEST) + .assert() + .success() + .stdout("quit=true\n\n"); + assert!(auth_api.received_requests().await.unwrap().is_empty()); + Ok(()) +} + #[test] fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> { let codex_home = TempDir::new()?;