mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
## Why ChatGPT credentials can rotate while a remote executor is idle. Registry requests need to follow stored rotations without continuing after logout or switching the executor's identity. Concurrent refreshes must also avoid overwriting newer credentials. ## What changed - Reload and validate ChatGPT credentials before registry requests, pinning the credential mode, account, user, and workspace policy. Reuse cached metadata for unchanged personal access tokens. - Retry registration when temporary credential resolution failures prevent a request from being sent. Bound credential resolution and block reuse of a token generation whose refresh outcome is unknown. - Restrict supported authentication modes before executor startup loads credentials or cloud configuration. - Serialize persistent credential writes across cooperating processes and compare stored credentials before committing refreshed tokens. - Replace `auth.json` atomically so overlapping readers see a complete document, preserving symlink targets and protecting file permissions, including Windows access controls. ## Testing Add coverage for credential rotation, logout, identity changes, cancelled refresh callers, temporary metadata failures, registration retries, concurrent credential writes, and atomic file replacement. GitOrigin-RevId: 6b210944bf8eba2f45bc27113f901d2c3cb06b48
179 lines
6.3 KiB
Rust
179 lines
6.3 KiB
Rust
//! Verify authentication updates across the running CLI executor process.
|
|
|
|
use std::process::Stdio;
|
|
|
|
use anyhow::Result;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::write_chatgpt_auth;
|
|
use codex_login::AuthCredentialsStoreMode;
|
|
use tempfile::TempDir;
|
|
use tokio::net::TcpListener;
|
|
use tokio_tungstenite::accept_async;
|
|
use tokio_tungstenite::accept_hdr_async;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::header;
|
|
use wiremock::matchers::path;
|
|
|
|
#[tokio::test]
|
|
async fn executor_startup_rejects_agent_identity_before_cloud_config() -> Result<()> {
|
|
let home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
Mock::given(wiremock::matchers::any())
|
|
.respond_with(ResponseTemplate::new(503))
|
|
.expect(0)
|
|
.mount(&server)
|
|
.await;
|
|
let manager = codex_login::test_support::auth_manager_with_agent_identity().await?;
|
|
let Some(codex_login::CodexAuth::AgentIdentity(agent)) = manager.auth_cached() else {
|
|
anyhow::bail!("expected Agent Identity fixture");
|
|
};
|
|
let mut record = agent.record().clone();
|
|
record.task_id = None;
|
|
std::fs::write(
|
|
home.path().join("auth.json"),
|
|
serde_json::to_vec(&serde_json::json!({
|
|
"auth_mode": codex_protocol::auth::AuthMode::AgentIdentity,
|
|
"agent_identity": record,
|
|
}))?,
|
|
)?;
|
|
std::fs::write(
|
|
home.path().join("config.toml"),
|
|
"cli_auth_credentials_store = 'file'\n",
|
|
)?;
|
|
let mut command = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
|
command
|
|
.current_dir(home.path())
|
|
.env("CODEX_HOME", home.path())
|
|
.env_remove("CODEX_API_KEY")
|
|
.env_remove("OPENAI_API_KEY")
|
|
.env_remove("CODEX_ACCESS_TOKEN")
|
|
.env("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", server.uri())
|
|
.env("NO_PROXY", "127.0.0.1,localhost")
|
|
.env("no_proxy", "127.0.0.1,localhost")
|
|
.args([
|
|
"exec-server",
|
|
"--remote",
|
|
&server.uri(),
|
|
"--environment-id",
|
|
"env",
|
|
])
|
|
.kill_on_drop(true);
|
|
let output =
|
|
tokio::time::timeout(std::time::Duration::from_secs(20), command.output()).await??;
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("requires ChatGPT authentication or API key authentication"),
|
|
"{stderr}"
|
|
);
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn noise_executor_refreshes_on_401_and_reloads_rotated_credentials() -> Result<()> {
|
|
let home = TempDir::new()?;
|
|
let registry = MockServer::start().await;
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
std::fs::write(
|
|
home.path().join("config.toml"),
|
|
"cli_auth_credentials_store = 'file'\n",
|
|
)?;
|
|
write_chatgpt_auth(
|
|
home.path(),
|
|
ChatGptAuthFixture::new("old-token")
|
|
.account_id("account")
|
|
.chatgpt_user_id("user"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
Mock::given(path("/refresh"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "refreshed-token", "refresh_token": "new-refresh-token",
|
|
})))
|
|
.expect(1)
|
|
.mount(®istry)
|
|
.await;
|
|
Mock::given(path("/cloud/environment/env/register"))
|
|
.and(header("authorization", "Bearer old-token"))
|
|
.respond_with(ResponseTemplate::new(401))
|
|
.expect(1)
|
|
.mount(®istry)
|
|
.await;
|
|
for token in ["refreshed-token", "rotated-token"] {
|
|
Mock::given(path("/cloud/environment/env/register"))
|
|
.and(header("authorization", format!("Bearer {token}")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"environment_id": "env", "url": format!("ws://{}", listener.local_addr()?),
|
|
"executor_registration_id": "registration", "security_profile": "noise_hybrid_ik_v1",
|
|
})))
|
|
.with_priority(1)
|
|
.up_to_n_times(1)
|
|
.expect(1)
|
|
.mount(®istry)
|
|
.await;
|
|
}
|
|
// The later registration sees the stored rotation before sending a stale
|
|
// request. The initial 401 above still exercises bounded auth recovery.
|
|
Mock::given(path("/cloud/environment/env/register"))
|
|
.and(header("authorization", "Bearer refreshed-token"))
|
|
.respond_with(ResponseTemplate::new(401))
|
|
.expect(0)
|
|
.mount(®istry)
|
|
.await;
|
|
let mut child = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
|
.current_dir(home.path())
|
|
.env("CODEX_HOME", home.path())
|
|
.env_remove("CODEX_API_KEY")
|
|
.env_remove("OPENAI_API_KEY")
|
|
.env_remove("CODEX_ACCESS_TOKEN")
|
|
.env(
|
|
"CODEX_REFRESH_TOKEN_URL_OVERRIDE",
|
|
format!("{}/refresh", registry.uri()),
|
|
)
|
|
.env("NO_PROXY", "127.0.0.1,localhost")
|
|
.env("no_proxy", "127.0.0.1,localhost")
|
|
.args([
|
|
"exec-server",
|
|
"--remote",
|
|
®istry.uri(),
|
|
"--environment-id",
|
|
"env",
|
|
"--exit-on-stdin-close",
|
|
])
|
|
.stdin(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.kill_on_drop(true)
|
|
.spawn()?;
|
|
let stdin = child.stdin.take();
|
|
let (socket, _) = listener.accept().await?;
|
|
let mut websocket = accept_async(socket).await?;
|
|
// Update storage while the executor is alive, then force a new registration.
|
|
write_chatgpt_auth(
|
|
home.path(),
|
|
ChatGptAuthFixture::new("rotated-token")
|
|
.account_id("account")
|
|
.chatgpt_user_id("user"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
websocket.close(None).await?;
|
|
drop(websocket);
|
|
let (socket, _) = listener.accept().await?;
|
|
let rejected = accept_hdr_async(socket, |_: &_, _: _| {
|
|
Err(http::Response::builder().status(401).body(None).unwrap())
|
|
})
|
|
.await;
|
|
assert!(rejected.is_err());
|
|
let (socket, _) = listener.accept().await?;
|
|
let _websocket = accept_async(socket).await?;
|
|
drop(stdin);
|
|
let output = child.wait_with_output().await?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(())
|
|
}
|