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
This commit is contained in:
Colin Young
2026-11-11 07:12:23 +00:00
committed by copyberry
parent 9018f3a3b0
commit 9752e0e0bd
4 changed files with 260 additions and 0 deletions
+193
View File
@@ -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<Option<String>> {
// 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>(_: E) -> anyhow::Error {
anyhow::anyhow!("Codex authentication configuration is unavailable")
}
fn read_request() -> Result<String> {
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);
}
}
}
+3
View File
@@ -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;
+18
View File
@@ -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"),
+46
View File
@@ -46,6 +46,52 @@ fn read_auth_json(codex_home: &Path) -> Result<Value> {
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()?;