mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
## What changed - Share the harness runtime identity with analytics delivery and turn-cost queries, and resolve request authentication together with its account-bound transport policy. - Add runtime authentication to ChatGPT request helpers and `codex cloud`, including environment discovery. Resolve environment credentials for each request and honor CLI configuration overrides when loading cloud authentication. - Preserve custom-backend authentication, validate runtime destinations, and keep cloud requests from following redirects. - Forward only organization and project selectors from model-provider headers to API-key analytics requests. ## Testing Add regression coverage for runtime request signing, credential refresh, account and resource selection, redirect credential handling, custom destinations, and cloud credential-store overrides. GitOrigin-RevId: 75f33961ee6482881e9ff9817e99d977f31b65b8
278 lines
10 KiB
Rust
278 lines
10 KiB
Rust
//! Cover credential refresh between Cloud startup and environment discovery.
|
|
|
|
#![cfg(test)]
|
|
|
|
use base64::Engine;
|
|
use chrono::Utc;
|
|
use codex_utils_cli::CliConfigOverrides;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::header;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
fn access_token(expires_at: i64) -> String {
|
|
let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
|
|
json!({
|
|
"exp": expires_at,
|
|
"iss": "https://auth.openai.com/",
|
|
"sub": "user-a",
|
|
"session_id": "session-a",
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_account_id": "account-a",
|
|
"chatgpt_user_id": "user-a",
|
|
"chatgpt_plan_type": "plus",
|
|
},
|
|
})
|
|
.to_string(),
|
|
);
|
|
format!("header.{claims}.signature")
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn environment_discovery_refreshes_after_cloud_startup() {
|
|
const SERVER: &str = "CODEX_CLOUD_REFRESH_TEST_SERVER";
|
|
if let Ok(server) = std::env::var(SERVER) {
|
|
let overrides = CliConfigOverrides {
|
|
raw_overrides: vec![
|
|
"cli_auth_credentials_store=\"file\"".into(),
|
|
"features.runtime_agent_instances=false".into(),
|
|
"features.use_agent_identity=false".into(),
|
|
],
|
|
};
|
|
let context = super::init_backend("cloud-refresh-test", &overrides)
|
|
.await
|
|
.expect("Cloud startup");
|
|
for _ in 0..2 {
|
|
let environments = crate::env_detect::list_environments(
|
|
&context.environment_http,
|
|
&server,
|
|
&crate::util::build_chatgpt_headers(),
|
|
)
|
|
.await
|
|
.expect("environment discovery after token refresh");
|
|
assert_eq!(environments.len(), 1);
|
|
assert_eq!(environments[0].id, "env-a");
|
|
}
|
|
return;
|
|
}
|
|
|
|
let server = MockServer::start().await;
|
|
let home = tempfile::tempdir().expect("isolated Cloud credentials");
|
|
let expired = access_token(Utc::now().timestamp() - 60);
|
|
// The startup token is near expiry, so discovery must refresh it through AuthManager.
|
|
let startup = access_token(Utc::now().timestamp() + 240);
|
|
let current = access_token(Utc::now().timestamp() + 3_600);
|
|
std::fs::write(
|
|
home.path().join("auth.json"),
|
|
json!({
|
|
"tokens": {
|
|
"id_token": expired, "access_token": expired,
|
|
"refresh_token": "refresh-initial", "account_id": "account-a",
|
|
},
|
|
"last_refresh": Utc::now(),
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write initial credentials");
|
|
let refreshed = current.clone();
|
|
Mock::given(method("POST"))
|
|
.and(path("/token"))
|
|
.respond_with(move |request: &wiremock::Request| {
|
|
let body: serde_json::Value = request.body_json().unwrap();
|
|
let token = match body["refresh_token"].as_str().unwrap() {
|
|
"refresh-initial" => &startup,
|
|
"refresh-startup" => &refreshed,
|
|
other => panic!("unexpected test refresh token: {other}"),
|
|
};
|
|
ResponseTemplate::new(200).set_body_json(json!({
|
|
"id_token": token, "access_token": token,
|
|
"refresh_token": "refresh-startup",
|
|
}))
|
|
})
|
|
.expect(2)
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(path("/api/codex/environments"))
|
|
.respond_with(ResponseTemplate::new(401))
|
|
.with_priority(10)
|
|
.expect(0)
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/api/codex/environments"))
|
|
.and(header("authorization", format!("Bearer {current}")))
|
|
.and(header("chatgpt-account-id", "account-a"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
|
{"id": "env-a", "label": "Environment A"},
|
|
])))
|
|
.expect(2)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
|
command
|
|
.args([
|
|
"--exact",
|
|
"backend_auth_tests::environment_discovery_refreshes_after_cloud_startup",
|
|
"--nocapture",
|
|
])
|
|
.current_dir(home.path())
|
|
.env(SERVER, server.uri())
|
|
.env("CODEX_HOME", home.path())
|
|
.env(
|
|
"CODEX_REFRESH_TOKEN_URL_OVERRIDE",
|
|
format!("{}/token", server.uri()),
|
|
)
|
|
.env(
|
|
"CODEX_CLOUD_TASKS_BASE_URL",
|
|
"https://chatgpt.com/backend-api",
|
|
)
|
|
.env_remove("CODEX_CLOUD_TASKS_MODE")
|
|
.env_remove("CODEX_ACCESS_TOKEN")
|
|
.env_remove("CODEX_API_KEY")
|
|
.env_remove("OPENAI_API_KEY");
|
|
let output = tokio::task::spawn_blocking(move || command.output())
|
|
.await
|
|
.expect("Cloud test child task")
|
|
.expect("Cloud test child process");
|
|
assert!(
|
|
output.status.success(),
|
|
"Cloud startup regression failed:\n{}\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr),
|
|
);
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn cloud_list_preserves_existing_agent_identity_during_runtime_rollout() -> anyhow::Result<()>
|
|
{
|
|
use core_test_support::runtime_auth::PROBE_SERVER;
|
|
use core_test_support::runtime_auth::run_harness_through_proxy;
|
|
|
|
const TEST: &str =
|
|
"backend_auth_tests::cloud_list_preserves_existing_agent_identity_during_runtime_rollout";
|
|
const MODE: &str = "CODEX_CLOUD_PROGRAMMATIC_TEST_MODE";
|
|
if let Ok(mode) = std::env::var(MODE) {
|
|
let enabled = mode != "off";
|
|
let mode = if enabled {
|
|
mode.as_str()
|
|
} else {
|
|
"dual_header"
|
|
};
|
|
let overrides = CliConfigOverrides {
|
|
raw_overrides: vec![
|
|
"cli_auth_credentials_store=\"file\"".into(),
|
|
format!("features.runtime_agent_instances={{ enabled={enabled}, mode={mode:?} }}"),
|
|
"features.use_agent_identity=false".into(),
|
|
],
|
|
};
|
|
let context = super::init_backend("cloud-programmatic-test", &overrides).await?;
|
|
let page = codex_cloud_tasks_client::CloudBackend::list_tasks(
|
|
&*context.backend,
|
|
/*env*/ None,
|
|
/*limit*/ Some(10),
|
|
/*cursor*/ None,
|
|
)
|
|
.await?;
|
|
assert!(page.tasks.is_empty());
|
|
let environments = crate::env_detect::list_environments(
|
|
&context.environment_http,
|
|
"https://chatgpt.com",
|
|
&crate::util::build_chatgpt_headers(),
|
|
)
|
|
.await?;
|
|
assert_eq!(environments.len(), 1);
|
|
assert_eq!(environments[0].id, "env-a");
|
|
return Ok(());
|
|
}
|
|
if std::env::var(PROBE_SERVER).is_ok() {
|
|
let home = tempfile::tempdir()?;
|
|
let key = codex_agent_identity::generate_agent_key_material()?;
|
|
std::fs::write(
|
|
home.path().join("auth.json"),
|
|
json!({
|
|
"auth_mode": "agentIdentity",
|
|
"agent_identity": {
|
|
"agent_runtime_id": "existing-agent",
|
|
"agent_private_key": key.private_key_pkcs8_base64,
|
|
"account_id": "account-a",
|
|
"chatgpt_user_id": "user-a",
|
|
"plan_type": "plus",
|
|
"chatgpt_account_is_fedramp": false,
|
|
"task_id": "existing-task",
|
|
},
|
|
})
|
|
.to_string(),
|
|
)?;
|
|
// Keep each credential/configuration case in its own process, retaining the proxy
|
|
// from the outer fixture without mutating environment variables in a running test.
|
|
for mode in ["off", "dual_header", "assertion_only"] {
|
|
let mut command = std::process::Command::new(std::env::current_exe()?);
|
|
command
|
|
.args(["--exact", TEST, "--nocapture"])
|
|
.current_dir(home.path())
|
|
.env(MODE, mode)
|
|
.env("CODEX_HOME", home.path())
|
|
.env(
|
|
"CODEX_CLOUD_TASKS_BASE_URL",
|
|
"https://chatgpt.com/backend-api",
|
|
)
|
|
.env_remove("CODEX_CLOUD_TASKS_MODE")
|
|
.env_remove("CODEX_ACCESS_TOKEN")
|
|
.env_remove("CODEX_API_KEY")
|
|
.env_remove("OPENAI_API_KEY");
|
|
let output = tokio::task::spawn_blocking(move || command.output()).await??;
|
|
assert!(
|
|
output.status.success(),
|
|
"{mode}: {}\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
|
|
}
|
|
return Ok(());
|
|
}
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/wham/tasks/list"))
|
|
.and(header("chatgpt-account-id", "account-a"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(json!({"items": [], "cursor": null})),
|
|
)
|
|
.expect(3)
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/api/codex/environments"))
|
|
.and(header("chatgpt-account-id", "account-a"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(json!([{ "id": "env-a", "label": "Environment A" }])),
|
|
)
|
|
.expect(3)
|
|
.mount(&server)
|
|
.await;
|
|
run_harness_through_proxy(&server, TEST, /*realtime_target*/ None).await?;
|
|
let requests = server.received_requests().await.unwrap();
|
|
assert_eq!(requests.len(), 6);
|
|
for request in requests {
|
|
let assertion = request.headers["authorization"]
|
|
.to_str()?
|
|
.strip_prefix("AgentAssertion ")
|
|
.unwrap();
|
|
let envelope: serde_json::Value = serde_json::from_slice(
|
|
&base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(assertion)?,
|
|
)?;
|
|
assert_eq!(envelope["agent_runtime_id"], "existing-agent");
|
|
assert_eq!(envelope["task_id"], "existing-task");
|
|
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
|
|
}
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|