mirror of
https://github.com/openai/codex.git
synced 2026-05-28 15:00:16 +00:00
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:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2310,6 +2310,7 @@ dependencies = [
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"which 8.0.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user