From 1176913a6fa248864d454e637301c6e3557941dd Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Tue, 3 Nov 2026 23:04:59 +0000 Subject: [PATCH] Add stdin bearer authentication for exec-server registration (#59254) ## Why Allow remote exec-server registration to use credentials independently of Codex login while retaining the usual local configuration and plugins. ## What changed - Add `--registration-bearer-stdin` to read one newline-terminated bearer and use it for remote registration. - Validate the bearer, require HTTPS or HTTP loopback for the registry, and disable process dumpability on Linux before reading the credential. - Document the option and its distinction from rotating credentials via `--executor-credential-stdin`. ## Testing Add integration coverage for registration with the supplied bearer across desktop sign-in states, rejection of non-loopback HTTP registries, and restricted process environment access on Linux. Update CLI help snapshots. GitOrigin-RevId: da99492270c5dc6bb5e0cac41947e9c999a72b75 --- ..._server_help_documents_remote_options.snap | 3 + codex-rs/exec-server-cli/src/command.rs | 18 ++- ...alone_help_documents_executor_options.snap | 3 + codex-rs/exec-server-cli/tests/standalone.rs | 104 ++++++++++++++++++ codex-rs/exec-server/Cargo.toml | 2 +- codex-rs/exec-server/README.md | 6 + codex-rs/exec-server/src/lib.rs | 1 + codex-rs/exec-server/src/remote.rs | 32 ++++++ 8 files changed, 167 insertions(+), 2 deletions(-) diff --git a/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap b/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap index 706c032121..00d35d245f 100644 --- a/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap +++ b/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap @@ -99,6 +99,9 @@ Options: Use Agent Identity auth from CODEX_ACCESS_TOKEN for remote registration + --registration-bearer-stdin + Read one registration bearer from stdin + --executor-credential-stdin Read rotating executor credentials from stdin; exit when the pipe closes diff --git a/codex-rs/exec-server-cli/src/command.rs b/codex-rs/exec-server-cli/src/command.rs index 9f96d48815..dd4848f026 100644 --- a/codex-rs/exec-server-cli/src/command.rs +++ b/codex-rs/exec-server-cli/src/command.rs @@ -106,6 +106,15 @@ pub struct ExecServerCommand { )] use_agent_identity_auth: bool, + /// Read one registration bearer from stdin. + #[arg( + long, + requires = "exec_server_remote", + conflicts_with_all = ["use_agent_identity_auth", "executor_credential_stdin", "aws_sigv4"], + global = true + )] + registration_bearer_stdin: bool, + /// Read rotating executor credentials from stdin; exit when the pipe closes. #[arg( long = "executor-credential-stdin", @@ -194,6 +203,10 @@ impl ExecServerCommand { let environment_id = self.environment_id.take().ok_or_else(|| { anyhow::anyhow!("--environment-id is required when --remote is set") })?; + let registration_auth = self + .registration_bearer_stdin + .then(codex_exec_server::registration_auth_from_stdin) + .transpose()?; let config = load_exec_server_config(root_config_overrides, strict_config, enable_cloud_config) .await?; @@ -228,6 +241,8 @@ impl ExecServerCommand { config.http_client_factory(), ) .await? + } else if let Some(provider) = registration_auth { + provider } else { load_exec_server_remote_auth_provider( &config, @@ -247,7 +262,7 @@ impl ExecServerCommand { auth_provider, config.http_client_factory(), )?; - if self.executor_credential_stdin { + if self.executor_credential_stdin || self.registration_bearer_stdin { remote_config = remote_config.with_executor_credential_auth()?; } if let Some(name) = self.name { @@ -342,6 +357,7 @@ impl ExecServerCommand { self.remote.is_some() && self.command.is_none() && !self.executor_credential_stdin + && !self.registration_bearer_stdin && !self.aws_sigv4 && !self.use_agent_identity_auth } diff --git a/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap b/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap index ca16be8532..723533f981 100644 --- a/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap +++ b/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap @@ -93,6 +93,9 @@ Options: Use Agent Identity auth from CODEX_ACCESS_TOKEN for remote registration + --registration-bearer-stdin + Read one registration bearer from stdin + --executor-credential-stdin Read rotating executor credentials from stdin; exit when the pipe closes diff --git a/codex-rs/exec-server-cli/tests/standalone.rs b/codex-rs/exec-server-cli/tests/standalone.rs index 22ed228264..bbda498b9f 100644 --- a/codex-rs/exec-server-cli/tests/standalone.rs +++ b/codex-rs/exec-server-cli/tests/standalone.rs @@ -21,6 +21,110 @@ use std::process::Stdio; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test] +async fn registration_bearer_rejects_plaintext_remote_registry() -> anyhow::Result<()> { + let binary = cargo_bin("codex-exec-server")?; + let home = tempfile::tempdir()?; + let mut child = tokio::process::Command::from(background_command(&binary)) + .args([ + "--remote", + "http://registry.example.invalid", + "--environment-id", + "test", + "--registration-bearer-stdin", + ]) + .env("CODEX_HOME", home.path()) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + child + .stdin + .take() + .context("registration stdin")? + .write_all(b"registration-placeholder\n") + .await?; + let output = child.wait_with_output().await?; + assert!(!output.status.success()); + assert!( + String::from_utf8(output.stderr)?.contains("requires https for non-loopback endpoints") + ); + Ok(()) +} + +#[tokio::test] +async fn registration_uses_its_bearer_before_and_after_desktop_sign_in() -> anyhow::Result<()> { + let binary = cargo_bin("codex-exec-server")?; + let home = tempfile::tempdir()?; + for (desktop_token, account_id) in [ + ("access_token_initial", None), + ("signed-in-token", Some("desktop-account")), + ] { + std::fs::write( + home.path().join("auth.json"), + serde_json::to_vec(&json!({ + "auth_mode": "chatgpt", + "tokens": { + "id_token": "eyJhbGciOiJub25lIn0.e30.signature", + "access_token": desktop_token, + "refresh_token": "refresh_token", + "account_id": account_id + } + }))?, + )?; + let registry = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/cloud/environment/test/register")) + .and(header("Authorization", "Bearer registration-placeholder")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "environment_id": "test", + "url": format!("{}/relay", registry.uri().replacen("http://", "ws://", 1)), + "security_profile": "noise_hybrid_ik_v1", + "executor_registration_id": "registration-1" + }))) + .mount(®istry) + .await; + // Connecting to the returned relay proves the executor accepted registration. + let relay = Mock::given(method("GET")) + .and(path("/relay")) + .respond_with(ResponseTemplate::new(403)) + .expect(1) + .mount_as_scoped(®istry) + .await; + let mut server = tokio::process::Command::from(background_command(&binary)) + .args(["--remote", ®istry.uri(), "--environment-id", "test"]) + .arg("--registration-bearer-stdin") + .stdin(Stdio::piped()) + .env("CODEX_HOME", home.path()) + .kill_on_drop(true) + .spawn()?; + server + .stdin + .take() + .context("registration stdin")? + .write_all(b"registration-placeholder\n") + .await?; + tokio::time::timeout( + std::time::Duration::from_secs(15), + relay.wait_until_satisfied(), + ) + .await?; + #[cfg(target_os = "linux")] + { + let pid = server.id().context("executor PID")?; + let error = std::fs::File::open(format!("/proc/{pid}/environ")).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + } + } + Ok(()) +} #[tokio::test] async fn standalone_initializes_reports_version_and_runs_helpers() -> anyhow::Result<()> { diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index aee56d1d75..886262a124 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -80,7 +80,7 @@ codex-linux-sandbox = { workspace = true } [target.'cfg(unix)'.dependencies] codex-uds = { workspace = true } libc = { workspace = true } -rustix = { workspace = true, features = ["fs"] } +rustix = { workspace = true, features = ["fs", "process"] } tempfile = { workspace = true } [target.'cfg(windows)'.dependencies] diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 46a465a79d..7d95b845cf 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -63,6 +63,12 @@ Tokens require `wss://` or a loopback destination, are redacted in diagnostics, Remote mode registers the local exec-server with the environment registry, then reconnects to the service-provided rendezvous websocket as the environment. +To supply registration credentials independently of Codex login, pass +`--registration-bearer-stdin` and write one newline-terminated bearer to the +executor's stdin. Exec-server reads it once and requires an HTTPS registry URL +or an HTTP loopback address. For rotating credentials, use +`--executor-credential-stdin`. Local configuration +and plugins still come from the usual Codex home. Remote communication uses the Noise relay contract; the registry and harness must support it. Forward mode uses the same registration and Noise relay, but opens an independent diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index f1fdbddde3..138570fcdb 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -239,6 +239,7 @@ pub use protocol::WriteStatus; pub use regular_file::read_sensitive_file_to_string; pub use remote::RemoteEnvironmentConfig; pub use remote::RemoteEnvironmentTransport; +pub use remote::registration_auth_from_stdin; pub use remote::run_remote_environment; pub use remote::run_remote_environment_forward_until_shutdown; pub use remote::run_remote_environment_until_shutdown; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index cf88006ff0..6c250b9946 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -567,6 +567,38 @@ impl AuthProvider for StaticBearerAuthProvider { } } +/// Read a single newline-terminated registration bearer from a parent-owned pipe. +pub fn registration_auth_from_stdin() -> std::io::Result { + use std::io::BufRead; + use std::io::Read; + + // Deny same-user access to the pipe and credential memory before reading. + #[cfg(target_os = "linux")] + rustix::process::set_dumpable_behavior(rustix::process::DumpableBehavior::NotDumpable)?; + + let mut token = String::new(); + std::io::stdin() + .lock() + .take(16 * 1024) + .read_line(&mut token)?; + if token.pop() != Some('\n') { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "expected a newline-terminated registration bearer of at most 16 KiB", + )); + } + if token.ends_with('\r') { + token.pop(); + } + if token.is_empty() || !token.bytes().all(|byte| byte.is_ascii_graphic()) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid registration bearer", + )); + } + static_bearer_auth_provider(token, /*chatgpt_account_id*/ None).map_err(std::io::Error::other) +} + fn static_bearer_auth_provider( bearer_token: String, chatgpt_account_id: Option,