Reload remote executor credentials safely across token rotation (#58251)

## 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
This commit is contained in:
tgrue-openai
2026-10-30 05:30:20 +00:00
committed by copyberry
parent c15d0ce323
commit 582a8812e0
25 changed files with 2178 additions and 103 deletions
@@ -16,6 +16,62 @@ 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()?;
@@ -58,12 +114,12 @@ async fn noise_executor_refreshes_on_401_and_reloads_rotated_credentials() -> Re
.mount(&registry)
.await;
}
// A later registry rejection reloads the rotated token from storage, without
// another OAuth refresh or unconditional reloads on ordinary requests.
// 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(1)
.expect(0)
.mount(&registry)
.await;
let mut child = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)