use std::collections::HashMap; use std::time::Duration; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; use futures::SinkExt; use futures::StreamExt; use http::HeaderValue; use pretty_assertions::assert_eq; use tokio::net::TcpListener; use tokio::net::TcpStream; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::handshake::server::Request; use tokio_tungstenite::tungstenite::handshake::server::Response; use super::RemoteEnvironmentOptions; use crate::EnvironmentManager; use crate::InitializeParams; use crate::InitializeResponse; use crate::protocol::INITIALIZE_METHOD; use crate::protocol::INITIALIZED_METHOD; use crate::protocol::JSONRPCMessage; use crate::protocol::JSONRPCResponse; #[test] fn remote_environment_options_redact_header_values() { let options = RemoteEnvironmentOptions { exec_server_url: "wss://relay.example/environment".to_string(), connect_timeout: Some(Duration::from_secs(5)), http_headers: HashMap::from([( "authorization".to_string(), "Bearer secret-customer-token".to_string(), )]), }; let debug = format!("{options:?}"); assert!(debug.contains("")); assert!(!debug.contains("secret-customer-token")); } #[test] fn trusted_headers_require_tls_for_non_loopback_destinations() { let manager = EnvironmentManager::without_environments(HttpClientFactory::new( OutboundProxyPolicy::ReqwestDefault, )); let error = manager .upsert_environment_with_options( "customer-environment".to_string(), RemoteEnvironmentOptions { exec_server_url: "ws://relay.example/environment".to_string(), connect_timeout: None, http_headers: HashMap::from([( "x-session-id".to_string(), "customer-session".to_string(), )]), }, ) .expect_err("trusted headers must not be sent over an insecure remote connection"); assert_eq!( error.to_string(), "exec-server protocol error: exec-server WebSocket headers require wss:// or a loopback destination" ); assert!(manager.get_environment("customer-environment").is_none()); } #[test] fn duplicate_case_insensitive_websocket_headers_fail_before_registration() { let manager = EnvironmentManager::without_environments(HttpClientFactory::new( OutboundProxyPolicy::ReqwestDefault, )); let error = manager .upsert_environment_with_options( "customer-environment".to_string(), RemoteEnvironmentOptions { exec_server_url: "ws://127.0.0.1:8765".to_string(), connect_timeout: None, http_headers: HashMap::from([ ("X-Session-Id".to_string(), "first-session".to_string()), ("x-session-id".to_string(), "second-session".to_string()), ]), }, ) .expect_err("duplicate header names must fail regardless of case"); assert_eq!( error.to_string(), "exec-server protocol error: duplicate exec-server WebSocket header `x-session-id`" ); assert!(manager.get_environment("customer-environment").is_none()); } #[test] fn invalid_websocket_header_names_fail_before_registration() { let manager = EnvironmentManager::without_environments(HttpClientFactory::new( OutboundProxyPolicy::ReqwestDefault, )); let error = manager .upsert_environment_with_options( "customer-environment".to_string(), RemoteEnvironmentOptions { exec_server_url: "ws://127.0.0.1:8765".to_string(), connect_timeout: None, http_headers: HashMap::from([("bad header".to_string(), "value".to_string())]), }, ) .expect_err("invalid header name should fail"); assert_eq!( error.to_string(), "exec-server protocol error: invalid exec-server WebSocket header name `bad header`" ); assert!(manager.get_environment("customer-environment").is_none()); } #[test] fn invalid_websocket_header_values_fail_before_registration() { let manager = EnvironmentManager::without_environments(HttpClientFactory::new( OutboundProxyPolicy::ReqwestDefault, )); let error = manager .upsert_environment_with_options( "customer-environment".to_string(), RemoteEnvironmentOptions { exec_server_url: "ws://127.0.0.1:8765".to_string(), connect_timeout: None, http_headers: HashMap::from([( "x-session-id".to_string(), "customer\nspoofed".to_string(), )]), }, ) .expect_err("invalid header value should fail"); assert_eq!( error.to_string(), "exec-server protocol error: invalid value for exec-server WebSocket header `x-session-id`" ); assert!(manager.get_environment("customer-environment").is_none()); } #[test] fn websocket_controlled_headers_fail_before_registration() { let manager = EnvironmentManager::without_environments(HttpClientFactory::new( OutboundProxyPolicy::ReqwestDefault, )); for header in ["host", "connection", "upgrade", "sec-websocket-key"] { let error = manager .upsert_environment_with_options( "customer-environment".to_string(), RemoteEnvironmentOptions { exec_server_url: "ws://127.0.0.1:8765".to_string(), connect_timeout: None, http_headers: HashMap::from([(header.to_string(), "overridden".to_string())]), }, ) .expect_err("connection-controlled header should fail"); assert_eq!( error.to_string(), format!( "exec-server protocol error: exec-server WebSocket header `{header}` is controlled by the connection" ) ); assert!(manager.get_environment("customer-environment").is_none()); } } #[tokio::test] async fn trusted_headers_are_sent_on_initial_websocket_and_session_reconnect() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("listener should bind"); let websocket_url = format!( "ws://{}", listener.local_addr().expect("listener should have address") ); let (resumed_tx, resumed_rx) = oneshot::channel(); let (finish_tx, finish_rx) = oneshot::channel(); let server = tokio::spawn(async move { let mut first = accept_routing_headers_websocket(&listener).await; complete_websocket_initialize(&mut first, /*expected_resume_session_id*/ None).await; first .close(None) .await .expect("first websocket should close"); let mut resumed = accept_routing_headers_websocket(&listener).await; complete_websocket_initialize(&mut resumed, Some("session-1")).await; resumed_tx.send(()).expect("resume should signal"); finish_rx.await.expect("test should finish"); }); let manager = EnvironmentManager::without_environments(HttpClientFactory::new( OutboundProxyPolicy::ReqwestDefault, )); manager .upsert_environment_with_options( "customer-environment".to_string(), RemoteEnvironmentOptions { exec_server_url: websocket_url, connect_timeout: Some(Duration::from_secs(1)), http_headers: HashMap::from([ ( "x-account-id".to_string(), "customer-account-456".to_string(), ), ( "x-session-id".to_string(), "customer-session-123".to_string(), ), ( "x-environment-id".to_string(), "customer-environment-789".to_string(), ), ]), }, ) .expect("environment with routing headers should register"); let environment = manager .get_environment("customer-environment") .expect("environment with routing headers should exist"); environment .wait_until_ready() .await .expect("environment with routing headers should initialize"); timeout(Duration::from_secs(3), resumed_rx) .await .expect("routing-header session resume should not time out") .expect("routing-header session resume should signal"); finish_tx.send(()).expect("test should finish"); server.await.expect("server task should finish"); } async fn accept_routing_headers_websocket(listener: &TcpListener) -> WebSocketStream { let (stream, _) = listener.accept().await.expect("listener should accept"); accept_hdr_async(stream, |request: &Request, response: Response| { assert_eq!( request.headers().get("x-account-id"), Some(&HeaderValue::from_static("customer-account-456")) ); assert_eq!( request.headers().get("x-session-id"), Some(&HeaderValue::from_static("customer-session-123")) ); assert_eq!( request.headers().get("x-environment-id"), Some(&HeaderValue::from_static("customer-environment-789")) ); Ok(response) }) .await .expect("routing-header websocket handshake should succeed") } async fn complete_websocket_initialize( websocket: &mut WebSocketStream, expected_resume_session_id: Option<&str>, ) { let message = websocket .next() .await .expect("initialize request should arrive") .expect("initialize websocket message should succeed"); let Message::Text(encoded) = message else { panic!("expected initialize text message"); }; let JSONRPCMessage::Request(request) = serde_json::from_str::(&encoded).expect("initialize request should parse") else { panic!("expected initialize request"); }; assert_eq!(request.method, INITIALIZE_METHOD); let params: InitializeParams = serde_json::from_value( request .params .expect("initialize request should contain parameters"), ) .expect("initialize parameters should parse"); assert_eq!( params.resume_session_id.as_deref(), expected_resume_session_id ); let response = JSONRPCMessage::Response(JSONRPCResponse { id: request.id, result: serde_json::to_value(InitializeResponse { session_id: "session-1".to_string(), environment_info: None, }) .expect("initialize response should serialize"), }); websocket .send(Message::Text( serde_json::to_string(&response) .expect("initialize response should encode") .into(), )) .await .expect("initialize response should send"); let message = websocket .next() .await .expect("initialized notification should arrive") .expect("initialized websocket message should succeed"); let Message::Text(encoded) = message else { panic!("expected initialized text message"); }; let JSONRPCMessage::Notification(notification) = serde_json::from_str::(&encoded) .expect("initialized notification should parse") else { panic!("expected initialized notification"); }; assert_eq!(notification.method, INITIALIZED_METHOD); }