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://<host>.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`
This commit is contained in:
Steve Coffey
2026-05-27 14:17:38 -07:00
committed by GitHub
parent 26c9502121
commit c57dee98b7
5 changed files with 155 additions and 5 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2310,6 +2310,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"unicode-segmentation",
"url",
"which 8.0.0",
"windows-sys 0.52.0",
]

View File

@@ -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]

View File

@@ -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<codex_api::SharedAuthProvider> {
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 {

View File

@@ -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

View File

@@ -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(