mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## Why Allow remote exec servers to connect directly to AWS-hosted registries that authenticate registry requests and WebSocket handshakes with AWS SigV4. ## What changed - Add `--remote-transport direct` with SigV4 profile, region, and service options while keeping Noise as the default transport. - Register the `direct_jsonrpc_v1` transport and carry plain exec-server JSON-RPC messages over the authenticated WebSocket. - Reuse direct registrations across transient disconnects, refresh them after a `409 Conflict`, and require TLS for non-loopback endpoints. ## Testing - Cover CLI validation and SigV4 request signing. - Exercise direct registration, handshake retry behavior, JSON-RPC interoperability, and process recovery after reconnecting. GitOrigin-RevId: 0755df330ba3abe5db0a516fdaa49338d9bbe2d2
450 lines
17 KiB
Rust
450 lines
17 KiB
Rust
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use anyhow::Result;
|
|
use codex_api::AuthProvider;
|
|
use codex_http_client::HttpClientFactory;
|
|
use codex_http_client::OutboundProxyPolicy;
|
|
use http::HeaderMap;
|
|
use http::HeaderValue;
|
|
use pretty_assertions::assert_eq;
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::net::TcpListener;
|
|
use tokio::time::timeout;
|
|
use tokio_tungstenite::accept_hdr_async;
|
|
use tokio_tungstenite::tungstenite::handshake::server::Request as HandshakeRequest;
|
|
use tokio_tungstenite::tungstenite::handshake::server::Response as HandshakeResponse;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::header;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
use super::*;
|
|
use crate::ExecServerRuntimePaths;
|
|
use crate::RemoteEnvironmentTransport;
|
|
|
|
#[derive(Debug)]
|
|
struct StaticAuthProvider;
|
|
|
|
impl AuthProvider for StaticAuthProvider {
|
|
fn add_auth_headers(&self, headers: &mut HeaderMap) {
|
|
headers.insert(
|
|
http::header::AUTHORIZATION,
|
|
HeaderValue::from_static("AWS4-HMAC-SHA256 test-signature"),
|
|
);
|
|
}
|
|
|
|
fn apply_auth(
|
|
&self,
|
|
mut request: codex_http_client::Request,
|
|
) -> codex_api::AuthProviderFuture<'_> {
|
|
Box::pin(async move {
|
|
if request.method == http::Method::GET {
|
|
assert_eq!(request.headers.len(), 1);
|
|
assert!(request.headers.contains_key(http::header::HOST));
|
|
assert!(request.url.starts_with("http"));
|
|
}
|
|
self.add_auth_headers(&mut request.headers);
|
|
Ok(request)
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct QueryAuthProvider;
|
|
|
|
impl AuthProvider for QueryAuthProvider {
|
|
fn add_auth_headers(&self, _headers: &mut HeaderMap) {}
|
|
|
|
fn apply_auth(
|
|
&self,
|
|
mut request: codex_http_client::Request,
|
|
) -> codex_api::AuthProviderFuture<'_> {
|
|
Box::pin(async move {
|
|
let mut url = url::Url::parse(&request.url)
|
|
.map_err(|error| codex_api::AuthError::Build(error.to_string()))?;
|
|
url.query_pairs_mut().append_pair("auth", "signed-query");
|
|
request.url = url.into();
|
|
Ok(request)
|
|
})
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_websocket_signs_only_host_and_preserves_handshake_headers() -> Result<()> {
|
|
let mut request = "wss://executor.example.com/connect".into_client_request()?;
|
|
request
|
|
.headers_mut()
|
|
.insert("traceparent", HeaderValue::from_static("trace-context"));
|
|
|
|
authenticate_websocket_request(&mut request, &StaticAuthProvider).await?;
|
|
|
|
assert!(request.headers().contains_key("sec-websocket-key"));
|
|
assert_eq!(request.headers()["traceparent"], "trace-context");
|
|
assert_eq!(
|
|
request.headers()[http::header::AUTHORIZATION],
|
|
"AWS4-HMAC-SHA256 test-signature"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_websocket_uses_authenticated_url_query_parameters() -> Result<()> {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let url = format!("ws://{}/connect?existing=value", listener.local_addr()?);
|
|
let acceptor = tokio::spawn(async move {
|
|
let (socket, _) = listener.accept().await?;
|
|
let callback = |request: &HandshakeRequest, response: HandshakeResponse| {
|
|
assert_eq!(request.uri().path(), "/connect");
|
|
assert_eq!(
|
|
request.uri().query(),
|
|
Some("existing=value&auth=signed-query")
|
|
);
|
|
Ok(response)
|
|
};
|
|
accept_hdr_async(socket, callback).await.map(|_| ())
|
|
});
|
|
|
|
let connection = connect_direct(
|
|
&url,
|
|
&QueryAuthProvider,
|
|
&HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
)
|
|
.await?;
|
|
drop(connection);
|
|
acceptor.await??;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn direct_endpoints_require_tls_except_on_loopback() {
|
|
for (url, scheme, allowed) in [
|
|
("https://registry.example.com", "https", true),
|
|
("wss://executor.example.com/connect", "wss", true),
|
|
("http://127.0.0.1:8080", "https", true),
|
|
("ws://localhost:8080/connect", "wss", true),
|
|
("http://registry.example.com", "https", false),
|
|
("ws://executor.example.com/connect", "wss", false),
|
|
] {
|
|
assert_eq!(
|
|
require_tls_or_loopback(url, scheme).is_ok(),
|
|
allowed,
|
|
"{url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn direct_authentication_retries_only_transient_credential_failures() {
|
|
for (error, retryable) in [
|
|
(AuthError::Transient("temporary".to_string()), true),
|
|
(AuthError::Build("invalid".to_string()), false),
|
|
] {
|
|
assert_eq!(
|
|
is_retryable_recovery_error(&direct_auth_error(error)),
|
|
retryable
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_registration_validates_connection_data() -> Result<()> {
|
|
for (field, invalid_value) in [
|
|
("environment_id", "different-environment"),
|
|
("transport", "noise_hybrid_ik_v1"),
|
|
("registration_id", ""),
|
|
("url", ""),
|
|
("url", "ws://executor.example.com/connect"),
|
|
] {
|
|
let registry = MockServer::start().await;
|
|
let mut response = serde_json::json!({
|
|
"environment_id": "environment-requested",
|
|
"transport": DIRECT_TRANSPORT,
|
|
"registration_id": "registration-1",
|
|
"url": "wss://executor.example.com/connect",
|
|
});
|
|
response[field] = serde_json::json!(invalid_value);
|
|
Mock::given(method("POST"))
|
|
.and(path(
|
|
"/cloud/environment/environment-requested/direct/register",
|
|
))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(response))
|
|
.expect(1)
|
|
.mount(®istry)
|
|
.await;
|
|
let client = EnvironmentRegistryClient::new(registry.uri(), Arc::new(StaticAuthProvider))?;
|
|
let error = client
|
|
.register_direct_environment("environment-requested")
|
|
.await
|
|
.expect_err("invalid registration must be rejected");
|
|
if invalid_value.starts_with("ws://") {
|
|
assert!(matches!(
|
|
error,
|
|
ExecServerError::EnvironmentRegistryConfig(_)
|
|
));
|
|
} else {
|
|
assert!(matches!(error, ExecServerError::Protocol(_)));
|
|
}
|
|
registry.verify().await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn direct_registration_uses_proxy_policy_without_logging_secrets() -> Result<()> {
|
|
use tracing_subscriber::Layer;
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
|
|
let mut log_file = tempfile::tempfile()?;
|
|
let writer = log_file.try_clone()?;
|
|
let subscriber = tracing_subscriber::registry().with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_ansi(false)
|
|
.with_writer(move || writer.try_clone().expect("clone log file"))
|
|
.with_filter(
|
|
tracing_subscriber::filter::Targets::new()
|
|
.with_target("codex_http_client", tracing::Level::TRACE)
|
|
.with_target("codex_exec_server", tracing::Level::TRACE),
|
|
),
|
|
);
|
|
let _guard = tracing::subscriber::set_default(subscriber);
|
|
tracing::debug!(target: "codex_exec_server", "direct registry log capture sentinel");
|
|
|
|
let proxy = MockServer::start().await;
|
|
let registry_url = "http://direct-registry-proxy.invalid/registry-path-secret";
|
|
let request_url =
|
|
format!("{registry_url}/cloud/environment/environment-requested/direct/register");
|
|
codex_http_client::cache_system_proxy_route_for_test(&request_url, proxy.uri());
|
|
Mock::given(method("POST"))
|
|
.and(path(
|
|
"/registry-path-secret/cloud/environment/environment-requested/direct/register",
|
|
))
|
|
.and(header("authorization", "AWS4-HMAC-SHA256 test-signature"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("set-cookie", "session=registry-cookie-secret")
|
|
.insert_header(
|
|
"location",
|
|
"https://registry.example/?token=registry-location-secret",
|
|
)
|
|
.set_body_json(serde_json::json!({
|
|
"environment_id": "environment-requested",
|
|
"transport": DIRECT_TRANSPORT,
|
|
"registration_id": "registration-1",
|
|
"url": "wss://executor.example/connect?token=websocket-query-secret",
|
|
})),
|
|
)
|
|
.expect(1)
|
|
.mount(&proxy)
|
|
.await;
|
|
let client = EnvironmentRegistryClient::new_with_telemetry(
|
|
registry_url.to_string(),
|
|
Arc::new(StaticAuthProvider),
|
|
crate::ExecServerTelemetry::default(),
|
|
HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy),
|
|
)?;
|
|
let response = client
|
|
.register_direct_environment("environment-requested")
|
|
.await?;
|
|
assert_eq!(
|
|
response.url,
|
|
"wss://executor.example/connect?token=websocket-query-secret"
|
|
);
|
|
let requests = proxy
|
|
.received_requests()
|
|
.await
|
|
.expect("record proxy requests");
|
|
assert_eq!(requests.len(), 1);
|
|
assert_eq!(requests[0].url.as_str(), request_url);
|
|
assert_eq!(requests[0].body, br#"{"transport":"direct_jsonrpc_v1"}"#);
|
|
proxy.verify().await;
|
|
|
|
std::io::Seek::rewind(&mut log_file)?;
|
|
let mut logs = String::new();
|
|
std::io::Read::read_to_string(&mut log_file, &mut logs)?;
|
|
assert!(logs.contains("direct registry log capture sentinel"));
|
|
for secret in [
|
|
"test-signature",
|
|
"registry-path-secret",
|
|
"registry-cookie-secret",
|
|
"registry-location-secret",
|
|
"websocket-query-secret",
|
|
] {
|
|
assert!(!logs.contains(secret), "registry logs exposed {secret}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_registration_failure_stops_initial_and_conflict_attempts() -> Result<()> {
|
|
for successful_registrations in [0, 1] {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let websocket_url = format!("ws://{}/connect", listener.local_addr()?);
|
|
let registry = MockServer::start().await;
|
|
let registration_count = AtomicUsize::new(0);
|
|
Mock::given(method("POST"))
|
|
.and(path(
|
|
"/cloud/environment/environment-requested/direct/register",
|
|
))
|
|
.respond_with(move |_: &wiremock::Request| {
|
|
let attempt = registration_count.fetch_add(1, Ordering::Relaxed);
|
|
if attempt < successful_registrations {
|
|
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"environment_id": "environment-requested",
|
|
"transport": DIRECT_TRANSPORT,
|
|
"registration_id": "registration-1",
|
|
"url": websocket_url,
|
|
}))
|
|
} else {
|
|
ResponseTemplate::new(503)
|
|
}
|
|
})
|
|
.expect((successful_registrations + 1) as u64)
|
|
.mount(®istry)
|
|
.await;
|
|
let config = RemoteEnvironmentConfig::new_with_transport(
|
|
registry.uri(),
|
|
"environment-requested".to_string(),
|
|
RemoteEnvironmentTransport::Direct,
|
|
Arc::new(StaticAuthProvider),
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
)?;
|
|
let runtime_paths = ExecServerRuntimePaths::new(
|
|
std::env::current_exe()?,
|
|
/*codex_linux_sandbox_exe*/ None,
|
|
)?;
|
|
let task = tokio::spawn(crate::run_remote_environment(config, runtime_paths));
|
|
if successful_registrations == 1 {
|
|
let (mut socket, _) = timeout(Duration::from_secs(5), listener.accept()).await??;
|
|
let mut request = [0; 4096];
|
|
let _ = socket.read(&mut request).await?;
|
|
socket
|
|
.write_all(b"HTTP/1.1 409 Conflict\r\nContent-Length: 0\r\n\r\n")
|
|
.await?;
|
|
socket.shutdown().await?;
|
|
}
|
|
let error = timeout(Duration::from_secs(5), task)
|
|
.await??
|
|
.expect_err("registration failure should stop the runner");
|
|
assert!(matches!(
|
|
error,
|
|
ExecServerError::EnvironmentRegistryHttp {
|
|
status: StatusCode::SERVICE_UNAVAILABLE,
|
|
..
|
|
}
|
|
));
|
|
registry.verify().await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_websocket_reuses_registration_and_stops_on_permanent_errors() -> Result<()> {
|
|
for (status, should_retry) in [
|
|
(Some(StatusCode::BAD_REQUEST), false),
|
|
(Some(StatusCode::UNAUTHORIZED), false),
|
|
(Some(StatusCode::FORBIDDEN), false),
|
|
(Some(StatusCode::NOT_FOUND), false),
|
|
(Some(StatusCode::METHOD_NOT_ALLOWED), false),
|
|
(Some(StatusCode::GONE), false),
|
|
(Some(StatusCode::REQUEST_TIMEOUT), true),
|
|
(Some(StatusCode::CONFLICT), true),
|
|
(Some(StatusCode::TOO_MANY_REQUESTS), true),
|
|
(Some(StatusCode::INTERNAL_SERVER_ERROR), true),
|
|
(Some(StatusCode::SERVICE_UNAVAILABLE), true),
|
|
(None, true), // Close the TCP socket without sending an HTTP response.
|
|
] {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let websocket_url = format!("ws://{}", listener.local_addr()?);
|
|
let registry = MockServer::start().await;
|
|
let expected_calls = if status == Some(StatusCode::CONFLICT) {
|
|
2
|
|
} else {
|
|
1
|
|
};
|
|
let registration_count = AtomicUsize::new(0);
|
|
Mock::given(method("POST"))
|
|
.and(path(
|
|
"/cloud/environment/environment-requested/direct/register",
|
|
))
|
|
.and(header("authorization", "AWS4-HMAC-SHA256 test-signature"))
|
|
.respond_with(move |_: &wiremock::Request| {
|
|
let registration = registration_count.fetch_add(1, Ordering::Relaxed) + 1;
|
|
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"environment_id": "environment-requested",
|
|
"transport": DIRECT_TRANSPORT,
|
|
"registration_id": format!("registration-{registration}"),
|
|
"url": format!("{websocket_url}/registration-{registration}"),
|
|
}))
|
|
})
|
|
.expect(expected_calls)
|
|
.mount(®istry)
|
|
.await;
|
|
let config = RemoteEnvironmentConfig::new_with_transport(
|
|
registry.uri(),
|
|
"environment-requested".to_string(),
|
|
RemoteEnvironmentTransport::Direct,
|
|
Arc::new(StaticAuthProvider),
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
)?;
|
|
let runtime_paths = ExecServerRuntimePaths::new(
|
|
std::env::current_exe()?,
|
|
/*codex_linux_sandbox_exe*/ None,
|
|
)?;
|
|
let task = tokio::spawn(crate::run_remote_environment(config, runtime_paths));
|
|
|
|
let (mut socket, _) = timeout(Duration::from_secs(5), listener.accept()).await??;
|
|
let mut request = [0; 4096];
|
|
let _ = socket.read(&mut request).await?;
|
|
if let Some(status) = status {
|
|
let response = format!(
|
|
"HTTP/1.1 {} {}\r\nContent-Length: 0\r\n\r\n",
|
|
status.as_u16(),
|
|
status.canonical_reason().unwrap_or_default()
|
|
);
|
|
socket.write_all(response.as_bytes()).await?;
|
|
}
|
|
socket.shutdown().await?;
|
|
drop(socket);
|
|
|
|
if should_retry {
|
|
let expected_path = format!("/registration-{expected_calls}");
|
|
let check_registration = |request: &HandshakeRequest, response: HandshakeResponse| {
|
|
assert_eq!(request.uri().path(), expected_path);
|
|
Ok(response)
|
|
};
|
|
let (socket, _) = timeout(Duration::from_secs(5), listener.accept()).await??;
|
|
let websocket = accept_hdr_async(socket, &check_registration).await?;
|
|
let retry_delay =
|
|
registry_recovery_retry_delay("environment-requested", /*attempt*/ 0);
|
|
let retry_started = Instant::now();
|
|
drop(websocket);
|
|
|
|
let (socket, _) = timeout(Duration::from_secs(5), listener.accept()).await??;
|
|
assert!(retry_started.elapsed() >= retry_delay);
|
|
let _websocket = accept_hdr_async(socket, &check_registration).await?;
|
|
registry.verify().await;
|
|
task.abort();
|
|
let _ = task.await;
|
|
} else {
|
|
let error = timeout(Duration::from_secs(5), task)
|
|
.await??
|
|
.expect_err("permanent WebSocket client error should be terminal");
|
|
assert!(matches!(
|
|
error,
|
|
ExecServerError::WebSocketConnect {
|
|
source: tokio_tungstenite::tungstenite::Error::Http(response),
|
|
..
|
|
} if Some(response.status()) == status
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|