diff --git a/codex-rs/app-server-transport/src/transport/remote_control/pairing.rs b/codex-rs/app-server-transport/src/transport/remote_control/pairing.rs index 1b8acd2ad3..215e8d9ac0 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/pairing.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/pairing.rs @@ -16,6 +16,8 @@ const REMOTE_CONTROL_PAIRING_TIMEOUT: std::time::Duration = std::time::Duration: pub(super) struct RemoteControlPairingClient { pairing_url: String, remote_control_token: String, + server_id: String, + environment_id: String, expires_at: OffsetDateTime, } @@ -23,11 +25,15 @@ impl RemoteControlPairingClient { pub(super) fn new( remote_control_target: &RemoteControlTarget, remote_control_token: String, + server_id: String, + environment_id: String, expires_at: OffsetDateTime, ) -> Self { Self { pairing_url: remote_control_target.pair_url.clone(), remote_control_token, + server_id, + environment_id, expires_at, } } @@ -89,7 +95,15 @@ impl RemoteControlPairingClient { environment_id, expires_at, } = pairing; - let _ = server_id; + if server_id != self.server_id || environment_id != self.environment_id { + return Err(io::Error::new( + ErrorKind::InvalidData, + format!( + "remote control pairing returned mismatched enrollment: expected server_id={}, environment_id={}; got server_id={}, environment_id={}", + self.server_id, self.environment_id, server_id, environment_id + ), + )); + } let expires_at = OffsetDateTime::parse(&expires_at, &Rfc3339) .map_err(|err| { io::Error::new( diff --git a/codex-rs/app-server-transport/src/transport/remote_control/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/pairing_tests.rs index 77dd7c2a2f..662ab702af 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/pairing_tests.rs @@ -102,6 +102,8 @@ async fn start_remote_control_pairing_uses_server_token_and_maps_response() { pair_url, }, "remote-control-token".to_string(), + "server-id".to_string(), + "environment-id".to_string(), OffsetDateTime::from_unix_timestamp(33_336_362_096).expect("future timestamp should parse"), ); @@ -168,6 +170,8 @@ async fn start_remote_control_pairing_preserves_backend_error_context() { pair_url, }, "remote-control-token".to_string(), + "server-id".to_string(), + "environment-id".to_string(), OffsetDateTime::from_unix_timestamp(33_336_362_096).expect("future timestamp should parse"), ); @@ -184,3 +188,100 @@ async fn start_remote_control_pairing_preserves_backend_error_context() { ) ); } + +#[tokio::test] +async fn start_remote_control_pairing_rejects_expired_server_token() { + let client = RemoteControlPairingClient::new( + &RemoteControlTarget { + websocket_url: "ws://unused".to_string(), + enroll_url: "http://unused".to_string(), + refresh_url: "http://unused".to_string(), + pair_url: "http://unused".to_string(), + }, + "remote-control-token".to_string(), + "server-id".to_string(), + "environment-id".to_string(), + OffsetDateTime::from_unix_timestamp(0).expect("expired timestamp should parse"), + ); + + let err = client + .start(StartRemoteControlPairingRequest { manual_code: false }) + .await + .expect_err("expired server token should fail pairing"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "remote control pairing is unavailable because the server token expired" + ); +} + +#[tokio::test] +async fn start_remote_control_pairing_rejects_mismatched_enrollment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let pair_url = format!( + "http://{}/backend-api/wham/remote/control/server/pair", + listener.local_addr().expect("listener should have addr") + ); + let server_task = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("request should arrive"); + let mut reader = BufReader::new(stream); + + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .expect("request line should read"); + if line == "\r\n" { + break; + } + } + + let response_body = json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": null, + "server_id": "other-server-id", + "environment_id": "other-environment-id", + "expires_at": "3026-05-22T12:34:56Z", + }) + .to_string(); + reader + .get_mut() + .write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{response_body}", + response_body.len() + ) + .as_bytes(), + ) + .await + .expect("response should write"); + }); + let client = RemoteControlPairingClient::new( + &RemoteControlTarget { + websocket_url: "ws://unused".to_string(), + enroll_url: "http://unused".to_string(), + refresh_url: "http://unused".to_string(), + pair_url, + }, + "remote-control-token".to_string(), + "server-id".to_string(), + "environment-id".to_string(), + OffsetDateTime::from_unix_timestamp(33_336_362_096).expect("future timestamp should parse"), + ); + + let err = client + .start(StartRemoteControlPairingRequest { manual_code: false }) + .await + .expect_err("mismatched enrollment should fail pairing"); + server_task.await.expect("server task should finish"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert_eq!( + err.to_string(), + "remote control pairing returned mismatched enrollment: expected server_id=server-id, environment_id=environment-id; got server_id=other-server-id, environment_id=other-environment-id" + ); +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index fa4901e08a..6c187e9c96 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1504,9 +1504,14 @@ fn set_pairing_client( })?; *pairing_client .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some( - RemoteControlPairingClient::new(remote_control_target, remote_control_token, expires_at), - ); + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(RemoteControlPairingClient::new( + remote_control_target, + remote_control_token, + enrollment.server_id.clone(), + enrollment.environment_id.clone(), + expires_at, + )); Ok(()) }