From c274a83f8bb0f9b5520a3f1d475060bebbe3b728 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 17 Jun 2026 17:20:53 -0700 Subject: [PATCH] feat(exec-server): add Noise rendezvous environment (#28774) ## Why Codex can run a remote exec server through the Noise relay, but the normal environment-manager path could not establish an environment-registry-backed harness connection. Signed rendezvous URLs and harness authorizations are short-lived, so reconnects must fetch a fresh bundle instead of retaining stale connection credentials. A stalled registry request must also fail within the regular remote connection deadline, without exposing these credentials in debug logs. Issue: N/A (internal environment-service integration). ## What Changed - Add environment-manager configuration for a registry-backed Noise rendezvous environment. - Request a fresh bundle from `/cloud/environment/{environment_id}/connect` for every physical harness connection, using the existing 10-second remote connection timeout. - Share the Environment Registry register, connect, and validate wire payloads through `codex-exec-server` and `codex-core-api`. - Redact the signed rendezvous URL and harness authorization from the public connect response's `Debug` output. - Add focused coverage for registry bundle retrieval, stalled requests, and credential redaction. --- codex-rs/core-api/src/lib.rs | 2 + codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/environment.rs | 108 +++++++ .../exec-server/src/environment_registry.rs | 34 +++ .../src/environment_registry_tests.rs | 22 ++ codex-rs/exec-server/src/lib.rs | 2 + codex-rs/exec-server/src/remote.rs | 264 ++++++++++++++++++ 7 files changed, 433 insertions(+) create mode 100644 codex-rs/exec-server/src/environment_registry_tests.rs diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 5a64a2b85..de3a7de90 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -48,6 +48,8 @@ pub use codex_core::resolve_installation_id; pub use codex_core::skills::SkillsService; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::EnvironmentRegistryConnectRequest; +pub use codex_exec_server::EnvironmentRegistryConnectResponse; pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationRequest; pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationResponse; pub use codex_exec_server::EnvironmentRegistryRegistrationRequest; diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 426567976..31fc4ea0a 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -28,6 +28,7 @@ codex-utils-path-uri = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } +http = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] } prost = "0.14.3" serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 0c703ecef..6159fb2dd 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -25,11 +25,19 @@ use crate::local_process::LocalProcess; use crate::process::ExecBackend; use crate::protocol::EnvironmentInfo; use crate::protocol::ShellInfo; +use crate::remote::NoiseRendezvousEnvironmentConfig; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; use codex_shell_command::shell_detect::DetectedShell; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; +pub const CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL"; +pub const CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID"; +pub const CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"; +pub const CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID"; /// Owns the execution/filesystem environments available to the Codex runtime. /// @@ -98,6 +106,9 @@ impl EnvironmentManager { codex_home: impl AsRef, local_runtime_paths: Option, ) -> Result { + if let Some(config) = noise_environment_config_from_env()? { + return Self::from_noise_environment_config(config, local_runtime_paths); + } let provider = environment_provider_from_codex_home(codex_home.as_ref())?; Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } @@ -107,6 +118,9 @@ impl EnvironmentManager { pub async fn from_env( local_runtime_paths: Option, ) -> Result { + if let Some(config) = noise_environment_config_from_env()? { + return Self::from_noise_environment_config(config, local_runtime_paths); + } let provider = DefaultEnvironmentProvider::from_env(); Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } @@ -122,6 +136,23 @@ impl EnvironmentManager { } } + fn from_noise_environment_config( + config: NoiseRendezvousEnvironmentConfig, + local_runtime_paths: Option, + ) -> Result { + let manager = Self { + default_environment: Some(REMOTE_ENVIRONMENT_ID.to_string()), + environments: RwLock::new(HashMap::new()), + local_environment: None, + local_runtime_paths, + }; + manager.upsert_noise_environment( + REMOTE_ENVIRONMENT_ID.to_string(), + config.connect_provider(), + )?; + Ok(manager) + } + /// Builds a test-only manager that keeps the provider default while also /// allowing tests to select the local environment explicitly. pub async fn create_for_tests_with_local( @@ -317,6 +348,53 @@ impl EnvironmentManager { } } +fn noise_environment_config_from_env() +-> Result, ExecServerError> { + noise_environment_config_from_values( + optional_environment_value(CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR), + ) +} + +fn noise_environment_config_from_values( + registry_url: Option, + environment_id: Option, + auth_token: Option, + chatgpt_account_id: Option, +) -> Result, ExecServerError> { + let (registry_url, environment_id, auth_token) = + match (registry_url, environment_id, auth_token) { + (None, None, None) => return Ok(None), + (Some(registry_url), Some(environment_id), Some(auth_token)) => { + (registry_url, environment_id, auth_token) + } + _ => { + return Err(ExecServerError::EnvironmentRegistryConfig(format!( + "Noise environment requires {CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR}, \ +{CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR}, and \ +{CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR}" + ))); + } + }; + + let config = NoiseRendezvousEnvironmentConfig::new( + registry_url, + environment_id, + auth_token, + chatgpt_account_id, + )?; + Ok(Some(config)) +} + +fn optional_environment_value(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + /// Concrete execution/filesystem environment selected for a session. /// /// This bundles the selected backend metadata together with the local runtime @@ -528,6 +606,7 @@ mod tests { use super::EnvironmentManager; use super::LOCAL_ENVIRONMENT_ID; use super::REMOTE_ENVIRONMENT_ID; + use super::noise_environment_config_from_values; use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::environment_provider::EnvironmentDefault; @@ -547,6 +626,35 @@ mod tests { assert!(manager.try_local_environment().is_none()); } + #[test] + fn noise_environment_config_selects_remote_as_default() { + let config = noise_environment_config_from_values( + Some("http://registry.example/api".to_string()), + Some("environment-requested".to_string()), + Some("registry-token".to_string()), + Some("workspace-123".to_string()), + ) + .expect("parse noise environment configuration") + .expect("noise environment configuration"); + + let manager = EnvironmentManager::from_noise_environment_config( + config, /*local_runtime_paths*/ None, + ) + .expect("build environment manager"); + + assert_eq!( + manager.default_environment_id(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert!( + manager + .default_environment() + .expect("remote environment") + .is_remote() + ); + assert_local_environment_unavailable(&manager); + } + #[tokio::test] async fn create_local_environment_does_not_connect() { let environment = Environment::create(/*exec_server_url*/ None, test_runtime_paths()) diff --git a/codex-rs/exec-server/src/environment_registry.rs b/codex-rs/exec-server/src/environment_registry.rs index 09ac5415f..bc0755fd6 100644 --- a/codex-rs/exec-server/src/environment_registry.rs +++ b/codex-rs/exec-server/src/environment_registry.rs @@ -19,6 +19,36 @@ pub struct EnvironmentRegistryRegistrationResponse { pub executor_registration_id: String, } +/// Request body for connecting a harness key with the environment registry. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryConnectRequest { + pub harness_public_key: NoiseChannelPublicKey, +} + +/// Environment registry response returned after connecting a harness key. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryConnectResponse { + pub environment_id: String, + pub url: String, + pub security_profile: String, + pub executor_registration_id: String, + pub executor_public_key: NoiseChannelPublicKey, + pub harness_key_authorization: String, +} + +impl std::fmt::Debug for EnvironmentRegistryConnectResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EnvironmentRegistryConnectResponse") + .field("environment_id", &self.environment_id) + .field("url", &"") + .field("security_profile", &self.security_profile) + .field("executor_registration_id", &self.executor_registration_id) + .field("executor_public_key", &self.executor_public_key) + .field("harness_key_authorization", &"") + .finish() + } +} + /// Request body for authorizing a harness key with the environment registry. #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] pub struct EnvironmentRegistryHarnessKeyValidationRequest { @@ -32,3 +62,7 @@ pub struct EnvironmentRegistryHarnessKeyValidationRequest { pub struct EnvironmentRegistryHarnessKeyValidationResponse { pub valid: bool, } + +#[cfg(test)] +#[path = "environment_registry_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/environment_registry_tests.rs b/codex-rs/exec-server/src/environment_registry_tests.rs new file mode 100644 index 000000000..8c32e0fb4 --- /dev/null +++ b/codex-rs/exec-server/src/environment_registry_tests.rs @@ -0,0 +1,22 @@ +use crate::EnvironmentRegistryConnectResponse; +use crate::NoiseChannelIdentity; + +#[test] +fn connect_response_debug_redacts_authorizations() { + let response = EnvironmentRegistryConnectResponse { + environment_id: "environment-1".to_string(), + url: "wss://rendezvous.test?sig=secret-url-authorization".to_string(), + security_profile: "noise_hybrid_ik_v1".to_string(), + executor_registration_id: "registration-1".to_string(), + executor_public_key: NoiseChannelIdentity::generate() + .expect("identity") + .public_key(), + harness_key_authorization: "secret-harness-authorization".to_string(), + }; + + let debug = format!("{response:?}"); + + assert!(debug.contains("")); + assert!(!debug.contains("secret-url-authorization")); + assert!(!debug.contains("secret-harness-authorization")); +} diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index df71eda95..c96c44344 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -57,6 +57,8 @@ pub use environment::REMOTE_ENVIRONMENT_ID; pub use environment_provider::DefaultEnvironmentProvider; pub use environment_provider::EnvironmentProvider; pub use environment_provider::EnvironmentProviderFuture; +pub use environment_registry::EnvironmentRegistryConnectRequest; +pub use environment_registry::EnvironmentRegistryConnectResponse; pub use environment_registry::EnvironmentRegistryHarnessKeyValidationRequest; pub use environment_registry::EnvironmentRegistryHarnessKeyValidationResponse; pub use environment_registry::EnvironmentRegistryRegistrationRequest; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index ace1578da..e2111318e 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -1,6 +1,12 @@ +use std::sync::Arc; use std::time::Duration; +use codex_api::AuthProvider; use codex_api::SharedAuthProvider; +use futures::FutureExt; +use http::HeaderMap; +use http::HeaderName; +use http::HeaderValue; use reqwest::StatusCode; use serde::Deserialize; use tokio::time::sleep; @@ -11,6 +17,8 @@ use tracing::warn; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use crate::EnvironmentRegistryConnectRequest; +use crate::EnvironmentRegistryConnectResponse; use crate::EnvironmentRegistryHarnessKeyValidationRequest; use crate::EnvironmentRegistryHarnessKeyValidationResponse; use crate::EnvironmentRegistryRegistrationRequest; @@ -19,6 +27,9 @@ use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::NoiseChannelIdentity; use crate::NoiseChannelPublicKey; +use crate::NoiseRendezvousConnectBundle; +use crate::NoiseRendezvousConnectProvider; +use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::noise_relay::noise_relay_websocket_config; use crate::relay::HarnessKeyValidator; use crate::relay::run_multiplexed_environment; @@ -32,6 +43,7 @@ struct EnvironmentRegistryClient { base_url: String, auth_provider: SharedAuthProvider, http: reqwest::Client, + connect_timeout: Duration, } impl std::fmt::Debug for EnvironmentRegistryClient { @@ -52,6 +64,7 @@ impl EnvironmentRegistryClient { http: reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build()?, + connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, }) } @@ -102,6 +115,53 @@ impl EnvironmentRegistryClient { Ok(response) } + /// Authorize one Noise harness key and obtain the full rendezvous bundle. + async fn connect_environment( + &self, + environment_id: &str, + harness_public_key: NoiseChannelPublicKey, + ) -> Result { + let response = self + .http + .post(endpoint_url( + &self.base_url, + &format!("/cloud/environment/{environment_id}/connect"), + )) + .headers(self.auth_provider.to_auth_headers()) + .json(&EnvironmentRegistryConnectRequest { harness_public_key }) + .timeout(self.connect_timeout) + .send() + .await?; + let response: EnvironmentRegistryConnectResponse = + self.parse_json_response(response).await?; + if response.environment_id != environment_id { + return Err(ExecServerError::Protocol( + "environment registry returned a different environment id".to_string(), + )); + } + if response.security_profile != NOISE_RELAY_SECURITY_PROFILE { + return Err(ExecServerError::Protocol(format!( + "environment registry returned unsupported security profile `{}`", + response.security_profile + ))); + } + if response.url.trim().is_empty() + || response.executor_registration_id.trim().is_empty() + || response.harness_key_authorization.trim().is_empty() + { + return Err(ExecServerError::Protocol( + "environment registry returned incomplete Noise connection data".to_string(), + )); + } + Ok(NoiseRendezvousConnectBundle { + websocket_url: response.url, + environment_id: response.environment_id, + executor_registration_id: response.executor_registration_id, + executor_public_key: response.executor_public_key, + harness_key_authorization: response.harness_key_authorization, + }) + } + async fn parse_json_response( &self, response: reqwest::Response, @@ -182,6 +242,130 @@ impl HarnessKeyValidator for RegistryHarnessKeyValidator { } } +/// Noise connection configuration for a Codex harness. +/// +/// The provider holds the authenticated registry client so every reconnect +/// receives fresh URL and harness-key authorization material. +#[derive(Clone)] +pub(crate) struct NoiseRendezvousEnvironmentConfig { + provider: Arc, +} + +impl std::fmt::Debug for NoiseRendezvousEnvironmentConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NoiseRendezvousEnvironmentConfig") + .field("provider", &"") + .finish() + } +} + +impl NoiseRendezvousEnvironmentConfig { + pub(crate) fn new( + base_url: String, + environment_id: String, + bearer_token: String, + chatgpt_account_id: Option, + ) -> Result { + let environment_id = normalize_environment_id(environment_id)?; + let auth_provider = static_bearer_auth_provider(bearer_token, chatgpt_account_id)?; + let client = EnvironmentRegistryClient::new(base_url, auth_provider)?; + Ok(Self { + provider: Arc::new(EnvironmentRegistryNoiseConnectProvider { + client, + environment_id, + }), + }) + } + + pub(crate) fn connect_provider(&self) -> Arc { + Arc::clone(&self.provider) + } +} + +#[derive(Clone, Debug)] +struct EnvironmentRegistryNoiseConnectProvider { + client: EnvironmentRegistryClient, + environment_id: String, +} + +impl NoiseRendezvousConnectProvider for EnvironmentRegistryNoiseConnectProvider { + fn connect_bundle( + &self, + harness_public_key: NoiseChannelPublicKey, + ) -> futures::future::BoxFuture<'_, Result> { + async move { + self.client + .connect_environment(&self.environment_id, harness_public_key) + .await + } + .boxed() + } +} + +#[derive(Clone)] +struct StaticBearerAuthProvider { + authorization: HeaderValue, + chatgpt_account_id: Option, +} + +impl std::fmt::Debug for StaticBearerAuthProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticBearerAuthProvider") + .field("authorization", &"") + .field( + "chatgpt_account_id", + &self.chatgpt_account_id.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl AuthProvider for StaticBearerAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + headers.insert(http::header::AUTHORIZATION, self.authorization.clone()); + if let Some(chatgpt_account_id) = &self.chatgpt_account_id { + headers.insert( + HeaderName::from_static("chatgpt-account-id"), + chatgpt_account_id.clone(), + ); + } + } +} + +fn static_bearer_auth_provider( + bearer_token: String, + chatgpt_account_id: Option, +) -> Result { + let bearer_token = bearer_token.trim(); + if bearer_token.is_empty() { + return Err(ExecServerError::EnvironmentRegistryConfig( + "environment registry bearer token is required".to_string(), + )); + } + let authorization = + HeaderValue::try_from(format!("Bearer {bearer_token}")).map_err(|error| { + ExecServerError::EnvironmentRegistryConfig(format!( + "environment registry bearer token is not a valid HTTP header: {error}" + )) + })?; + let chatgpt_account_id = chatgpt_account_id + .as_deref() + .map(str::trim) + .filter(|account_id| !account_id.is_empty()) + .map(|account_id| { + HeaderValue::try_from(account_id).map_err(|error| { + ExecServerError::EnvironmentRegistryConfig(format!( + "ChatGPT account id is not a valid HTTP header: {error}" + )) + }) + }) + .transpose()?; + Ok(Arc::new(StaticBearerAuthProvider { + authorization, + chatgpt_account_id, + })) +} + /// Configuration for registering an exec-server for remote use. #[derive(Clone)] pub struct RemoteEnvironmentConfig { @@ -459,6 +643,86 @@ mod tests { ); } + #[tokio::test] + async fn noise_connect_provider_requests_and_validates_a_full_bundle() { + let server = MockServer::start().await; + let harness_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + let executor_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/connect")) + .and(header("authorization", "Bearer registry-token")) + .and(header("chatgpt-account-id", "workspace-123")) + .and(body_partial_json(serde_json::json!({ + "harness_public_key": harness_public_key.clone(), + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "environment_id": "environment-requested", + "url": "wss://rendezvous.test/cloud-agent/default/ws/environment/environment-requested?role=harness&sig=abc", + "security_profile": NOISE_RELAY_SECURITY_PROFILE, + "executor_registration_id": "registration-1", + "executor_public_key": executor_public_key.clone(), + "harness_key_authorization": "authorization-1", + }))) + .mount(&server) + .await; + let config = NoiseRendezvousEnvironmentConfig::new( + server.uri(), + "environment-requested".to_string(), + "registry-token".to_string(), + Some("workspace-123".to_string()), + ) + .expect("noise configuration"); + + let bundle = config + .connect_provider() + .connect_bundle(harness_public_key) + .await + .expect("Noise connect bundle"); + + assert_eq!( + bundle.websocket_url, + "wss://rendezvous.test/cloud-agent/default/ws/environment/environment-requested?role=harness&sig=abc" + ); + assert_eq!(bundle.environment_id, "environment-requested"); + assert_eq!(bundle.executor_registration_id, "registration-1"); + assert_eq!(bundle.executor_public_key, executor_public_key); + assert_eq!(bundle.harness_key_authorization, "authorization-1"); + } + + #[tokio::test] + async fn connect_environment_times_out_when_registry_stalls() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/connect")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1))) + .mount(&server) + .await; + let mut client = + EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) + .expect("client"); + client.connect_timeout = Duration::from_millis(50); + let harness_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + + let error = match client + .connect_environment("environment-requested", harness_public_key) + .await + { + Ok(_) => panic!("stalled connect response should time out"), + Err(error) => error, + }; + + assert!(matches!( + error, + ExecServerError::EnvironmentRegistryRequest(error) if error.is_timeout() + )); + } + #[tokio::test] async fn register_environment_does_not_follow_redirects_with_auth_headers() { let server = MockServer::start().await;