From c57dee98b7e70f306d2981f9075dde1d1b9a90e7 Mon Sep 17 00:00:00 2001 From: Steve Coffey Date: Wed, 27 May 2026 14:17:38 -0700 Subject: [PATCH] Allow API-key auth for remote exec-server registration (#24666) ## Overview Allow remote `codex exec-server` registration to use existing API-key auth while restricting where those credentials can be sent. - Accept `CodexAuth::ApiKey` for the normal `--remote` registration path. - Restrict API-key remote registration to HTTPS `openai.com` and `openai.org` hosts and subdomains, with explicit HTTP loopback support for local development. - Disable registry registration redirects so credentials cannot be forwarded to an unvalidated destination. - Retain `--use-agent-identity-auth` as the explicit Agent Identity path. - Document remote registration using `CODEX_API_KEY`. ## Big picture Callers can now provide an API key directly to `exec-server` registration without first establishing ChatGPT login state: ```sh CODEX_API_KEY="$OPENAI_API_KEY" \ codex exec-server \ --remote "https://.openai.org/api" \ --environment-id "$ENVIRONMENT_ID" ``` ## Validation - `cargo fmt --all` (`just fmt` is not installed on this host) - `cargo test -p codex-cli -p codex-exec-server` --- codex-rs/Cargo.lock | 1 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 109 +++++++++++++++++++++++++++-- codex-rs/exec-server/README.md | 10 +++ codex-rs/exec-server/src/remote.rs | 39 ++++++++++- 5 files changed, 155 insertions(+), 5 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 33e81b0f44..f29ad2aa0a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2310,6 +2310,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "unicode-segmentation", + "url", "which 8.0.0", "windows-sys 0.52.0", ] diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 1a1b852667..6a4d78fbc7 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -83,6 +83,7 @@ tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } unicode-segmentation = { workspace = true } +url = { workspace = true } which = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 856273354c..e0812e3fdc 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1486,7 +1486,8 @@ async fn run_exec_server_command( .ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?; let config = load_exec_server_config(root_config_overrides, strict_config).await?; let auth_provider = - load_exec_server_remote_auth_provider(&config, cmd.use_agent_identity_auth).await?; + load_exec_server_remote_auth_provider(&config, &base_url, cmd.use_agent_identity_auth) + .await?; let mut remote_config = codex_exec_server::RemoteEnvironmentConfig::new( base_url, environment_id, @@ -1516,6 +1517,7 @@ async fn run_exec_server_command( async fn load_exec_server_remote_auth_provider( config: &codex_core::config::Config, + base_url: &str, use_agent_identity_auth: bool, ) -> anyhow::Result { if use_agent_identity_auth { @@ -1530,19 +1532,61 @@ async fn load_exec_server_remote_auth_provider( let auth = load_exec_server_remote_auth( config, - "remote exec-server registration requires ChatGPT authentication; run `codex login` first", + "remote exec-server registration requires ChatGPT authentication or API key authentication; run `codex login` or set CODEX_API_KEY", ) .await?; - if !auth.is_chatgpt_auth() { + if !is_supported_exec_server_remote_auth(&auth) { anyhow::bail!( - "remote exec-server registration requires ChatGPT authentication; API key and Agent Identity auth are not supported" + "remote exec-server registration requires ChatGPT authentication or API key authentication; Agent Identity auth requires --use-agent-identity-auth" ); } + if auth.is_api_key_auth() { + validate_api_key_remote_host(base_url)?; + } + Ok(codex_model_provider::auth_provider_from_auth(&auth)) } +fn is_supported_exec_server_remote_auth(auth: &CodexAuth) -> bool { + auth.is_chatgpt_auth() || auth.is_api_key_auth() +} + +fn validate_api_key_remote_host(base_url: &str) -> anyhow::Result<()> { + let url = url::Url::parse(base_url) + .map_err(|err| anyhow::anyhow!("invalid remote exec-server registration URL: {err}"))?; + let host = url.host().ok_or_else(|| { + anyhow::anyhow!("remote exec-server registration URL must include a host") + })?; + + let is_loopback = match &host { + url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"), + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + }; + let is_openai_host = match &host { + url::Host::Domain(host) => ["openai.com", "openai.org"].into_iter().any(|domain| { + host.eq_ignore_ascii_case(domain) + || host.to_ascii_lowercase().ends_with(&format!(".{domain}")) + }), + _ => false, + }; + let is_allowed = match url.scheme() { + "https" => is_loopback || is_openai_host, + "http" => is_loopback, + _ => false, + }; + + if !is_allowed { + anyhow::bail!( + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + + Ok(()) +} + async fn load_exec_server_config( root_config_overrides: &CliConfigOverrides, strict_config: bool, @@ -2168,6 +2212,63 @@ mod tests { use codex_tui::TokenUsage; use pretty_assertions::assert_eq; + #[test] + fn exec_server_remote_auth_accepts_api_key_auth() { + let auth = CodexAuth::from_api_key("sk-test"); + + assert!(is_supported_exec_server_remote_auth(&auth)); + } + + #[test] + fn exec_server_remote_api_key_auth_accepts_https_openai_domains() { + for base_url in [ + "https://openai.com/api", + "https://service.openai.com/api", + "https://openai.org/api", + "https://service.openai.org/api", + ] { + assert!(validate_api_key_remote_host(base_url).is_ok()); + } + } + + #[test] + fn exec_server_remote_api_key_auth_accepts_http_loopback() { + for base_url in [ + "http://localhost:8098/api", + "http://127.0.0.1:8098/api", + "http://[::1]:8098/api", + ] { + assert!(validate_api_key_remote_host(base_url).is_ok()); + } + } + + #[test] + fn exec_server_remote_api_key_auth_rejects_http_openai_domain() { + for base_url in [ + "http://service.openai.com/api", + "http://service.openai.org/api", + ] { + let error = validate_api_key_remote_host(base_url) + .expect_err("reject plaintext OpenAI destination"); + + assert_eq!( + error.to_string(), + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + } + + #[test] + fn exec_server_remote_api_key_auth_rejects_suffix_spoof() { + let error = validate_api_key_remote_host("https://service.openai.org.evil.example/api") + .expect_err("reject suffix spoof"); + + assert_eq!( + error.to_string(), + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + fn finalize_resume_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let MultitoolCli { diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 468ac205f7..8fa1a9eb75 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -32,6 +32,16 @@ Agent Identity JWT in `CODEX_ACCESS_TOKEN` can opt into that auth path with `--use-agent-identity-auth`; Codex then registers an Agent task and sends the derived AgentAssertion headers on the registry request. +Alternatively, API users can instead use `CODEX_API_KEY`; +Codex sends it as a bearer token on the registration request. For example: + +```sh +CODEX_API_KEY="$OPENAI_API_KEY" \ +codex exec-server \ + --remote ... \ + --environment-id "$ENVIRONMENT_ID" +``` + Wire framing: - local websocket: one JSON-RPC message per websocket frame diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index de09d94e7c..ae7242377f 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -38,7 +38,9 @@ impl EnvironmentRegistryClient { Ok(Self { base_url, auth_provider, - http: reqwest::Client::new(), + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?, }) } @@ -312,6 +314,41 @@ mod tests { ); } + #[tokio::test] + async fn register_environment_does_not_follow_redirects_with_auth_headers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/register")) + .and(header("authorization", "Bearer registry-token")) + .respond_with( + ResponseTemplate::new(302) + .insert_header("location", format!("{}/redirect-target", server.uri())), + ) + .mount(&server) + .await; + Mock::given(path("/redirect-target")) + .and(header("authorization", "Bearer registry-token")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let client = EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) + .expect("client"); + + let error = client + .register_environment("environment-requested") + .await + .expect_err("redirect response should not be followed"); + + assert!(matches!( + error, + ExecServerError::EnvironmentRegistryHttp { + status: StatusCode::FOUND, + .. + } + )); + } + #[test] fn debug_output_redacts_auth_provider() { let config = RemoteEnvironmentConfig::new(