| #![cfg(unix)] |
|
|
| mod common; |
|
|
| use std::collections::BTreeMap; |
| use std::io::ErrorKind; |
| use std::time::Duration; |
|
|
| use codex_exec_server::HttpHeader; |
| use codex_exec_server::HttpRedirectPolicy; |
| use codex_exec_server::HttpRequestBodyDeltaNotification; |
| use codex_exec_server::HttpRequestParams; |
| use codex_exec_server::HttpRequestResponse; |
| use codex_exec_server::InitializeParams; |
| use codex_exec_server_protocol::JSONRPCError; |
| use codex_exec_server_protocol::JSONRPCMessage; |
| use codex_exec_server_protocol::JSONRPCNotification; |
| use codex_exec_server_protocol::JSONRPCResponse; |
| use codex_exec_server_protocol::RequestId; |
| use common::SYSTEM_PROXY_REQUEST_URL_ENV; |
| use common::SYSTEM_PROXY_URL_ENV; |
| use common::exec_server::ExecServerHarness; |
| use common::exec_server::exec_server; |
| use common::exec_server::exec_server_with_env; |
| use pretty_assertions::assert_eq; |
| use serde::de::DeserializeOwned; |
| use serde_json::Value; |
| use tokio::io::AsyncBufReadExt; |
| use tokio::io::AsyncReadExt; |
| use tokio::io::AsyncWriteExt; |
| use tokio::io::BufReader; |
| use tokio::net::TcpListener; |
| use tokio::net::TcpStream; |
| use tokio::sync::oneshot; |
| use tokio::time::timeout; |
|
|
| |
| #[derive(Debug)] |
| struct CapturedHttpRequest { |
| stream: TcpStream, |
| request_line: String, |
| headers: BTreeMap<String, String>, |
| body: Vec<u8>, |
| } |
|
|
| |
| |
| |
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn exec_server_http_request_buffers_response_body() -> anyhow::Result<()> { |
| |
| let mut server = exec_server_with_env( |
| [ |
| ("NODE_REPL_AUTH_TOKEN", "executor-token"), |
| ("LINEAR_API_KEY", "linear-token"), |
| ], |
| &[], |
| ) |
| .await?; |
| initialize_exec_server(&mut server).await?; |
|
|
| |
| let listener = TcpListener::bind("127.0.0.1:0").await?; |
| let url = format!("http://{}/mcp?case=buffered", listener.local_addr()?); |
| let http_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "POST".to_string(), |
| url, |
| headers: vec![ |
| HttpHeader { |
| name: "x-codex-test".to_string(), |
| value: "buffered".to_string(), |
| value_env_var: None, |
| }, |
| HttpHeader { |
| name: "authorization".to_string(), |
| value: "Bearer ".to_string(), |
| value_env_var: Some("NODE_REPL_AUTH_TOKEN".to_string()), |
| }, |
| HttpHeader { |
| name: "x-linear-authorization".to_string(), |
| value: "Bearer ".to_string(), |
| value_env_var: Some("LINEAR_API_KEY".to_string()), |
| }, |
| ], |
| body: Some(b"request-body".to_vec().into()), |
| timeout_ms: Some(5_000), |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: "buffered-request".to_string(), |
| stream_response: false, |
| })?, |
| ) |
| .await?; |
|
|
| |
| |
| let captured = accept_http_request(&listener).await?; |
| assert_eq!( |
| ( |
| captured.request_line.as_str(), |
| captured.headers.get("x-codex-test").map(String::as_str), |
| captured.headers.get("authorization").map(String::as_str), |
| captured |
| .headers |
| .get("x-linear-authorization") |
| .map(String::as_str), |
| captured.body.as_slice(), |
| ), |
| ( |
| "POST /mcp?case=buffered HTTP/1.1", |
| Some("buffered"), |
| Some("Bearer executor-token"), |
| Some("Bearer linear-token"), |
| b"request-body".as_slice(), |
| ) |
| ); |
| respond_with_status_and_headers( |
| captured.stream, |
| "201 Created", |
| &[("x-mcp-test", "buffered")], |
| b"response-body", |
| ) |
| .await?; |
|
|
| |
| |
| let response: HttpRequestResponse = wait_for_response(&mut server, http_request_id).await?; |
| assert_eq!( |
| ( |
| response.status, |
| response_header(&response.headers, "x-mcp-test"), |
| response.body.into_inner(), |
| ), |
| (201, Some("buffered".to_string()), b"response-body".to_vec(),) |
| ); |
|
|
| server.shutdown().await?; |
| Ok(()) |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn exec_server_http_request_rejects_protected_environment_headers() -> anyhow::Result<()> { |
| let mut server = exec_server_with_env( |
| [( |
| "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN", |
| "executor-internal-token", |
| )], |
| &[], |
| ) |
| .await?; |
| initialize_exec_server(&mut server).await?; |
|
|
| let listener = TcpListener::bind("127.0.0.1:0").await?; |
| for (index, env_var) in [ |
| "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN", |
| "codex_exec_server_noise_auth_token", |
| "OPENAI_API_KEY", |
| "CODEX_ACCESS_TOKEN", |
| "CODEX_CONNECTORS_TOKEN", |
| "AWS_SECRET_ACCESS_KEY", |
| "AZURE_FEDERATED_TOKEN_FILE", |
| "OPENAI_IDENTITY_TOKEN_FILE", |
| ] |
| .into_iter() |
| .enumerate() |
| { |
| let request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url: format!("http://{}/mcp", listener.local_addr()?), |
| headers: vec![HttpHeader { |
| name: "authorization".to_string(), |
| value: "Bearer ".to_string(), |
| value_env_var: Some(env_var.to_string()), |
| }], |
| body: None, |
| timeout_ms: Some(5_000), |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: format!("protected-header-request-{index}"), |
| stream_response: false, |
| })?, |
| ) |
| .await?; |
| let error = wait_for_error_response(&mut server, request_id).await?; |
| assert_eq!(error.code, -32602); |
| assert_eq!( |
| error.message, |
| format!( |
| "http/request header authorization cannot use executor environment variable {env_var}" |
| ) |
| ); |
| } |
| assert!( |
| timeout(Duration::from_millis(50), listener.accept()) |
| .await |
| .is_err() |
| ); |
|
|
| server.shutdown().await?; |
| Ok(()) |
| } |
|
|
| |
| |
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn exec_server_http_request_omits_url_fragment() -> anyhow::Result<()> { |
| let mut server = exec_server().await?; |
| initialize_exec_server(&mut server).await?; |
|
|
| let listener = TcpListener::bind("127.0.0.1:0").await?; |
| let url = format!( |
| "http://{}/mcp?case=fragment#client-section", |
| listener.local_addr()? |
| ); |
| let http_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url, |
| headers: Vec::new(), |
| body: None, |
| timeout_ms: Some(5_000), |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: "fragment-request".to_string(), |
| stream_response: false, |
| })?, |
| ) |
| .await?; |
|
|
| let captured = accept_http_request(&listener).await?; |
| assert_eq!(captured.request_line, "GET /mcp?case=fragment HTTP/1.1"); |
| respond_with_status_and_headers(captured.stream, "200 OK", &[], b"fragment-response").await?; |
|
|
| let response: HttpRequestResponse = wait_for_response(&mut server, http_request_id).await?; |
| assert_eq!( |
| (response.status, response.body.into_inner()), |
| (200, b"fragment-response".to_vec()) |
| ); |
|
|
| server.shutdown().await?; |
| Ok(()) |
| } |
|
|
| |
| |
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn exec_server_http_request_uses_configured_system_proxy() -> anyhow::Result<()> { |
| let proxy_listener = TcpListener::bind("127.0.0.1:0").await?; |
| let proxy_url = format!("http://{}", proxy_listener.local_addr()?); |
| let request_url = "http://exec-server-system-proxy.invalid/delegated?route=system"; |
| let mut server = exec_server_with_env( |
| [ |
| (SYSTEM_PROXY_REQUEST_URL_ENV, request_url), |
| (SYSTEM_PROXY_URL_ENV, proxy_url.as_str()), |
| ("HTTP_PROXY", ""), |
| ("http_proxy", ""), |
| ("HTTPS_PROXY", ""), |
| ("https_proxy", ""), |
| ("ALL_PROXY", ""), |
| ("all_proxy", ""), |
| ("NO_PROXY", ""), |
| ("no_proxy", ""), |
| ], |
| &[], |
| ) |
| .await?; |
| initialize_exec_server(&mut server).await?; |
|
|
| let http_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url: request_url.to_string(), |
| headers: Vec::new(), |
| body: None, |
| timeout_ms: Some(5_000), |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: "system-proxy-request".to_string(), |
| stream_response: false, |
| })?, |
| ) |
| .await?; |
|
|
| let captured = accept_http_request(&proxy_listener).await?; |
| assert_eq!( |
| captured.request_line, |
| "GET http://exec-server-system-proxy.invalid/delegated?route=system HTTP/1.1" |
| ); |
| respond_with_status_and_headers(captured.stream, "200 OK", &[], b"proxied-response").await?; |
|
|
| let response: HttpRequestResponse = wait_for_response(&mut server, http_request_id).await?; |
| assert_eq!( |
| (response.status, response.body.into_inner()), |
| (200, b"proxied-response".to_vec()) |
| ); |
|
|
| server.shutdown().await?; |
| Ok(()) |
| } |
|
|
| |
| |
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn exec_server_http_request_normalizes_unicode_hostname() -> anyhow::Result<()> { |
| let proxy_listener = TcpListener::bind("127.0.0.1:0").await?; |
| let proxy_url = format!("http://{}", proxy_listener.local_addr()?); |
| let request_url = "http://münich.invalid/mcp?route=unicode"; |
| let normalized_url = "http://xn--mnich-kva.invalid/mcp?route=unicode"; |
| let mut server = exec_server_with_env( |
| [ |
| (SYSTEM_PROXY_REQUEST_URL_ENV, normalized_url), |
| (SYSTEM_PROXY_URL_ENV, proxy_url.as_str()), |
| ("HTTP_PROXY", ""), |
| ("http_proxy", ""), |
| ("HTTPS_PROXY", ""), |
| ("https_proxy", ""), |
| ("ALL_PROXY", ""), |
| ("all_proxy", ""), |
| ("NO_PROXY", ""), |
| ("no_proxy", ""), |
| ], |
| &[], |
| ) |
| .await?; |
| initialize_exec_server(&mut server).await?; |
|
|
| let http_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url: request_url.to_string(), |
| headers: Vec::new(), |
| body: None, |
| timeout_ms: Some(5_000), |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: "unicode-hostname-request".to_string(), |
| stream_response: false, |
| })?, |
| ) |
| .await?; |
|
|
| let captured = accept_http_request(&proxy_listener).await?; |
| assert_eq!( |
| ( |
| captured.request_line.as_str(), |
| captured.headers.get("host").map(String::as_str), |
| ), |
| ( |
| "GET http://xn--mnich-kva.invalid/mcp?route=unicode HTTP/1.1", |
| Some("xn--mnich-kva.invalid"), |
| ) |
| ); |
| respond_with_status_and_headers(captured.stream, "200 OK", &[], b"unicode-response").await?; |
|
|
| let response: HttpRequestResponse = wait_for_response(&mut server, http_request_id).await?; |
| assert_eq!( |
| (response.status, response.body.into_inner()), |
| (200, b"unicode-response".to_vec()) |
| ); |
|
|
| server.shutdown().await?; |
| Ok(()) |
| } |
|
|
| |
| |
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn exec_server_http_request_can_stop_at_redirects() -> anyhow::Result<()> { |
| let mut server = exec_server().await?; |
| initialize_exec_server(&mut server).await?; |
|
|
| let listener = TcpListener::bind("127.0.0.1:0").await?; |
| let base_url = format!("http://{}", listener.local_addr()?); |
| let http_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url: format!("{base_url}/redirect"), |
| headers: Vec::new(), |
| body: None, |
| timeout_ms: Some(5_000), |
| redirect_policy: HttpRedirectPolicy::Stop, |
| request_id: "redirect-request".to_string(), |
| stream_response: false, |
| })?, |
| ) |
| .await?; |
|
|
| let captured = accept_http_request(&listener).await?; |
| assert_eq!(captured.request_line, "GET /redirect HTTP/1.1"); |
| respond_with_status_and_headers( |
| captured.stream, |
| "302 Found", |
| &[("location", &format!("{base_url}/final"))], |
| b"redirect", |
| ) |
| .await?; |
|
|
| let response: HttpRequestResponse = wait_for_response(&mut server, http_request_id).await?; |
| assert_eq!( |
| ( |
| response.status, |
| response_header(&response.headers, "location"), |
| response.body.into_inner(), |
| ), |
| (302, Some(format!("{base_url}/final")), b"redirect".to_vec(),) |
| ); |
| assert!( |
| timeout(Duration::from_millis(100), listener.accept()) |
| .await |
| .is_err(), |
| "redirect target should not be requested" |
| ); |
|
|
| server.shutdown().await?; |
| Ok(()) |
| } |
|
|
| |
| |
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn exec_server_http_request_can_follow_redirects() -> anyhow::Result<()> { |
| let mut server = exec_server().await?; |
| initialize_exec_server(&mut server).await?; |
|
|
| let listener = TcpListener::bind("127.0.0.1:0").await?; |
| let base_url = format!("http://{}", listener.local_addr()?); |
| let http_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url: format!("{base_url}/redirect"), |
| headers: Vec::new(), |
| body: None, |
| timeout_ms: Some(5_000), |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: "follow-redirect-request".to_string(), |
| stream_response: false, |
| })?, |
| ) |
| .await?; |
|
|
| let redirect_request = accept_http_request(&listener).await?; |
| assert_eq!(redirect_request.request_line, "GET /redirect HTTP/1.1"); |
| respond_with_status_and_headers( |
| redirect_request.stream, |
| "302 Found", |
| &[("location", &format!("{base_url}/final"))], |
| b"redirect", |
| ) |
| .await?; |
|
|
| let final_request = accept_http_request(&listener).await?; |
| assert_eq!(final_request.request_line, "GET /final HTTP/1.1"); |
| respond_with_status_and_headers( |
| final_request.stream, |
| "200 OK", |
| &[("x-mcp-test", "redirected")], |
| b"final-response-body", |
| ) |
| .await?; |
|
|
| let response: HttpRequestResponse = wait_for_response(&mut server, http_request_id).await?; |
| assert_eq!( |
| ( |
| response.status, |
| response_header(&response.headers, "x-mcp-test"), |
| response.body.into_inner(), |
| ), |
| ( |
| 200, |
| Some("redirected".to_string()), |
| b"final-response-body".to_vec(), |
| ) |
| ); |
|
|
| server.shutdown().await?; |
| Ok(()) |
| } |
|
|
| |
| |
| |
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn exec_server_http_request_streams_response_body_notifications() -> anyhow::Result<()> { |
| |
| let mut server = exec_server().await?; |
| initialize_exec_server(&mut server).await?; |
|
|
| |
| let listener = TcpListener::bind("127.0.0.1:0").await?; |
| let url = format!("http://{}/mcp?case=streaming", listener.local_addr()?); |
| let http_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url, |
| headers: vec![HttpHeader { |
| name: "accept".to_string(), |
| value: "text/event-stream".to_string(), |
| value_env_var: None, |
| }], |
| body: None, |
| timeout_ms: Some(5_000), |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: "stream-1".to_string(), |
| stream_response: true, |
| })?, |
| ) |
| .await?; |
|
|
| |
| |
| let captured = accept_http_request(&listener).await?; |
| assert_eq!( |
| ( |
| captured.request_line.as_str(), |
| captured.headers.get("accept").map(String::as_str), |
| captured.body, |
| ), |
| ( |
| "GET /mcp?case=streaming HTTP/1.1", |
| Some("text/event-stream"), |
| Vec::new(), |
| ) |
| ); |
| respond_with_chunked_body( |
| captured.stream, |
| &[("x-mcp-test", "streaming")], |
| &[b"hello ".as_slice(), b"world".as_slice()], |
| ) |
| .await?; |
|
|
| |
| |
| |
| let first_event = server.next_event().await?; |
| let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = first_event else { |
| anyhow::bail!("expected http/request response before body deltas, got {first_event:?}"); |
| }; |
| assert_eq!(id, http_request_id); |
| let response: HttpRequestResponse = serde_json::from_value(result)?; |
| assert_eq!( |
| ( |
| response.status, |
| response_header(&response.headers, "x-mcp-test"), |
| response.body.into_inner(), |
| ), |
| (200, Some("streaming".to_string()), Vec::new()) |
| ); |
|
|
| |
| |
| let deltas = collect_response_body_deltas(&mut server, "stream-1").await?; |
| let seqs = deltas.iter().map(|delta| delta.seq).collect::<Vec<_>>(); |
| let body = deltas |
| .iter() |
| .flat_map(|delta| delta.delta.clone().into_inner()) |
| .collect::<Vec<_>>(); |
| let terminal = deltas.last().map(|delta| (delta.done, delta.error.clone())); |
| let expected_seqs = (1..=deltas.len() as u64).collect::<Vec<_>>(); |
| assert_eq!( |
| (seqs, body, terminal), |
| (expected_seqs, b"hello world".to_vec(), Some((true, None))) |
| ); |
|
|
| server.shutdown().await?; |
| Ok(()) |
| } |
|
|
| |
| |
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn exec_server_http_request_rejects_duplicate_stream_request_ids() -> anyhow::Result<()> { |
| let mut server = exec_server().await?; |
| initialize_exec_server(&mut server).await?; |
|
|
| let listener = TcpListener::bind("127.0.0.1:0").await?; |
| let url = format!( |
| "http://{}/mcp?case=duplicate-stream-id", |
| listener.local_addr()? |
| ); |
| let first_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url: url.clone(), |
| headers: Vec::new(), |
| body: None, |
| timeout_ms: None, |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: "stream-dup".to_string(), |
| stream_response: true, |
| })?, |
| ) |
| .await?; |
|
|
| let captured = accept_http_request(&listener).await?; |
| let (finish_tx, finish_rx) = oneshot::channel(); |
| let response_task = tokio::spawn(async move { |
| respond_with_chunked_body_until_finish(captured.stream, &[], &[b"hello"], finish_rx).await |
| }); |
|
|
| let _: HttpRequestResponse = wait_for_response(&mut server, first_request_id).await?; |
|
|
| let duplicate_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url, |
| headers: Vec::new(), |
| body: None, |
| timeout_ms: None, |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: "stream-dup".to_string(), |
| stream_response: true, |
| })?, |
| ) |
| .await?; |
|
|
| let duplicate_response = server |
| .wait_for_event(|event| { |
| matches!( |
| event, |
| JSONRPCMessage::Error(JSONRPCError { id, .. }) if id == &duplicate_request_id |
| ) |
| }) |
| .await?; |
| let JSONRPCMessage::Error(JSONRPCError { error, .. }) = duplicate_response else { |
| anyhow::bail!("expected duplicate requestId error response"); |
| }; |
| assert_eq!(error.code, -32602); |
| assert_eq!( |
| error.message, |
| "http/request streamResponse requestId `stream-dup` is already active" |
| ); |
|
|
| finish_tx |
| .send(()) |
| .expect("response task should still be waiting"); |
| response_task.await??; |
| let _ = collect_response_body_deltas(&mut server, "stream-dup").await?; |
|
|
| server.shutdown().await?; |
| Ok(()) |
| } |
|
|
| |
| |
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn exec_server_http_request_honors_optional_timeout() -> anyhow::Result<()> { |
| let mut server = exec_server().await?; |
| initialize_exec_server(&mut server).await?; |
|
|
| let listener = TcpListener::bind("127.0.0.1:0").await?; |
| let delayed_url = format!( |
| "http://{}/mcp?case=optional-timeout", |
| listener.local_addr()? |
| ); |
| let no_timeout_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url: delayed_url.clone(), |
| headers: Vec::new(), |
| body: None, |
| timeout_ms: None, |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: "buffered-request".to_string(), |
| stream_response: false, |
| })?, |
| ) |
| .await?; |
|
|
| let captured = accept_http_request(&listener).await?; |
| let delayed_response = tokio::spawn(async move { |
| tokio::time::sleep(Duration::from_millis(100)).await; |
| respond_with_status_and_headers(captured.stream, "200 OK", &[], b"slow-success").await |
| }); |
| let response: HttpRequestResponse = |
| wait_for_response(&mut server, no_timeout_request_id).await?; |
| assert_eq!(response.body.into_inner(), b"slow-success".to_vec()); |
| delayed_response.await??; |
|
|
| let timeout_request_id = server |
| .send_request( |
| "http/request", |
| serde_json::to_value(HttpRequestParams { |
| method: "GET".to_string(), |
| url: delayed_url, |
| headers: Vec::new(), |
| body: None, |
| timeout_ms: Some(10), |
| redirect_policy: HttpRedirectPolicy::Follow, |
| request_id: "buffered-request".to_string(), |
| stream_response: false, |
| })?, |
| ) |
| .await?; |
|
|
| let captured = accept_http_request(&listener).await?; |
| let delayed_timeout_response = tokio::spawn(async move { |
| tokio::time::sleep(Duration::from_millis(100)).await; |
| respond_with_status_and_headers(captured.stream, "200 OK", &[], b"too-late").await |
| }); |
| let error = wait_for_error_response(&mut server, timeout_request_id).await?; |
| assert_eq!(error.code, -32603); |
| assert!( |
| error.message.starts_with("http/request failed: "), |
| "unexpected timeout error: {}", |
| error.message |
| ); |
| match delayed_timeout_response.await? { |
| Ok(()) => {} |
| Err(err) if is_expected_peer_disconnect(&err) => {} |
| Err(err) => return Err(err), |
| } |
|
|
| server.shutdown().await?; |
| Ok(()) |
| } |
|
|
| |
| async fn initialize_exec_server(server: &mut ExecServerHarness) -> anyhow::Result<()> { |
| let initialize_id = server |
| .send_request( |
| "initialize", |
| serde_json::to_value(InitializeParams { |
| client_name: "exec-server-http-test".to_string(), |
| resume_session_id: None, |
| })?, |
| ) |
| .await?; |
| let _: Value = wait_for_response(server, initialize_id).await?; |
| server |
| .send_notification("initialized", serde_json::json!({})) |
| .await?; |
| Ok(()) |
| } |
|
|
| |
| async fn wait_for_response<T>( |
| server: &mut ExecServerHarness, |
| request_id: RequestId, |
| ) -> anyhow::Result<T> |
| where |
| T: DeserializeOwned, |
| { |
| let response = server |
| .wait_for_event(|event| { |
| matches!( |
| event, |
| JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &request_id |
| ) |
| }) |
| .await?; |
| let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { |
| anyhow::bail!("expected JSON-RPC response for {request_id:?}"); |
| }; |
| Ok(serde_json::from_value(result)?) |
| } |
|
|
| |
| async fn wait_for_error_response( |
| server: &mut ExecServerHarness, |
| request_id: RequestId, |
| ) -> anyhow::Result<codex_exec_server_protocol::JSONRPCErrorError> { |
| let response = server |
| .wait_for_event(|event| { |
| matches!( |
| event, |
| JSONRPCMessage::Error(JSONRPCError { id, .. }) if id == &request_id |
| ) |
| }) |
| .await?; |
| let JSONRPCMessage::Error(JSONRPCError { error, .. }) = response else { |
| anyhow::bail!("expected JSON-RPC error for {request_id:?}"); |
| }; |
| Ok(error) |
| } |
|
|
| |
| async fn accept_http_request(listener: &TcpListener) -> anyhow::Result<CapturedHttpRequest> { |
| let (stream, _) = timeout(Duration::from_secs(5), listener.accept()).await??; |
| let mut reader = BufReader::new(stream); |
|
|
| let mut request_line = String::new(); |
| reader.read_line(&mut request_line).await?; |
| let request_line = request_line.trim_end_matches("\r\n").to_string(); |
|
|
| let mut headers = BTreeMap::new(); |
| loop { |
| let mut line = String::new(); |
| reader.read_line(&mut line).await?; |
| if line == "\r\n" { |
| break; |
| } |
| let line = line.trim_end_matches("\r\n"); |
| let (name, value) = line |
| .split_once(':') |
| .ok_or_else(|| anyhow::anyhow!("HTTP header should contain colon: {line}"))?; |
| headers.insert(name.to_ascii_lowercase(), value.trim().to_string()); |
| } |
|
|
| let content_length = headers |
| .get("content-length") |
| .and_then(|value| value.parse::<usize>().ok()) |
| .unwrap_or(0); |
| let mut body = vec![0; content_length]; |
| reader.read_exact(&mut body).await?; |
|
|
| Ok(CapturedHttpRequest { |
| stream: reader.into_inner(), |
| request_line, |
| headers, |
| body, |
| }) |
| } |
|
|
| |
| async fn respond_with_status_and_headers( |
| mut stream: TcpStream, |
| status: &str, |
| headers: &[(&str, &str)], |
| body: &[u8], |
| ) -> anyhow::Result<()> { |
| let extra_headers = headers |
| .iter() |
| .map(|(name, value)| format!("{name}: {value}\r\n")) |
| .collect::<String>(); |
| let response = format!( |
| "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n{extra_headers}\r\n", |
| body.len(), |
| ); |
| stream.write_all(response.as_bytes()).await?; |
| stream.write_all(body).await?; |
| stream.flush().await?; |
| Ok(()) |
| } |
|
|
| fn is_expected_peer_disconnect(err: &anyhow::Error) -> bool { |
| err.chain().any(|cause| { |
| cause |
| .downcast_ref::<std::io::Error>() |
| .is_some_and(|io_err| { |
| matches!( |
| io_err.kind(), |
| ErrorKind::BrokenPipe | ErrorKind::ConnectionReset | ErrorKind::UnexpectedEof |
| ) |
| }) |
| }) |
| } |
|
|
| |
| async fn respond_with_chunked_body( |
| mut stream: TcpStream, |
| headers: &[(&str, &str)], |
| chunks: &[&[u8]], |
| ) -> anyhow::Result<()> { |
| let extra_headers = headers |
| .iter() |
| .map(|(name, value)| format!("{name}: {value}\r\n")) |
| .collect::<String>(); |
| let response = format!( |
| "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ntransfer-encoding: chunked\r\nconnection: close\r\n{extra_headers}\r\n", |
| ); |
| stream.write_all(response.as_bytes()).await?; |
| for chunk in chunks { |
| stream |
| .write_all(format!("{:x}\r\n", chunk.len()).as_bytes()) |
| .await?; |
| stream.write_all(chunk).await?; |
| stream.write_all(b"\r\n").await?; |
| stream.flush().await?; |
| } |
| stream.write_all(b"0\r\n\r\n").await?; |
| stream.flush().await?; |
| Ok(()) |
| } |
|
|
| |
| async fn respond_with_chunked_body_until_finish( |
| mut stream: TcpStream, |
| headers: &[(&str, &str)], |
| chunks: &[&[u8]], |
| finish_rx: oneshot::Receiver<()>, |
| ) -> anyhow::Result<()> { |
| let extra_headers = headers |
| .iter() |
| .map(|(name, value)| format!("{name}: {value}\r\n")) |
| .collect::<String>(); |
| let response = format!( |
| "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ntransfer-encoding: chunked\r\nconnection: close\r\n{extra_headers}\r\n", |
| ); |
| stream.write_all(response.as_bytes()).await?; |
| for chunk in chunks { |
| stream |
| .write_all(format!("{:x}\r\n", chunk.len()).as_bytes()) |
| .await?; |
| stream.write_all(chunk).await?; |
| stream.write_all(b"\r\n").await?; |
| stream.flush().await?; |
| } |
| finish_rx.await?; |
| stream.write_all(b"0\r\n\r\n").await?; |
| stream.flush().await?; |
| Ok(()) |
| } |
|
|
| |
| async fn collect_response_body_deltas( |
| server: &mut ExecServerHarness, |
| request_id: &str, |
| ) -> anyhow::Result<Vec<HttpRequestBodyDeltaNotification>> { |
| let mut deltas = Vec::new(); |
| loop { |
| let event = server.next_event().await?; |
| let JSONRPCMessage::Notification(JSONRPCNotification { method, params }) = event else { |
| anyhow::bail!("expected http/request body delta notification, got {event:?}"); |
| }; |
| assert_eq!(method, "http/request/bodyDelta"); |
| let delta: HttpRequestBodyDeltaNotification = |
| serde_json::from_value(params.unwrap_or(Value::Null))?; |
| assert_eq!(delta.request_id, request_id); |
|
|
| let done = delta.done; |
| deltas.push(delta); |
| if done { |
| return Ok(deltas); |
| } |
| } |
| } |
|
|
| |
| fn response_header(headers: &[HttpHeader], name: &str) -> Option<String> { |
| headers |
| .iter() |
| .find(|header| header.name.eq_ignore_ascii_case(name)) |
| .map(|header| header.value.clone()) |
| } |
|
|