From 64ba58bd589097e5936d072ec431b48b71ae3a0f Mon Sep 17 00:00:00 2001 From: andriypolanski Date: Wed, 29 Jul 2026 22:58:35 -0700 Subject: [PATCH 1/2] fix(cli): reject non-success HTTP responses --- crates/genie-ctl/src/main.rs | 60 +++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/crates/genie-ctl/src/main.rs b/crates/genie-ctl/src/main.rs index 171a46f0..2f4fe426 100644 --- a/crates/genie-ctl/src/main.rs +++ b/crates/genie-ctl/src/main.rs @@ -34,6 +34,7 @@ use anyhow::{Context, Result}; use genie_common::config::{Config, ServiceProbeTarget, parse_service_probe_target}; +use genie_common::http::{HttpResponseLimits, read_response}; use genie_common::probe::{ProbeTimeouts, probe_service_target}; use genie_core::skills::{ SkillLoader, SkillManifestAudit, find_manifest_sidecar, manifest_sidecar_candidates, @@ -54,6 +55,7 @@ mod bfcl_import; const GOVERNOR_SOCK: &str = "/run/geniepod/governor.sock"; const BFCL_LLM_DEFAULT_MAX_TOKENS: u32 = 160; +const CLI_MAX_HTTP_RESPONSE_BYTES: usize = 8 * 1024 * 1024; fn load_core_addr() -> Result { Ok(Config::load()?.core_http_addr()) @@ -2885,23 +2887,19 @@ async fn http_post_inner( async fn read_http_body(reader: tokio::net::tcp::OwnedReadHalf) -> Result { let mut buf_reader = BufReader::new(reader); - let mut body = String::new(); - let mut in_body = false; - - loop { - let mut line = String::new(); - let n = buf_reader.read_line(&mut line).await?; - if n == 0 { - break; - } - if in_body { - body.push_str(&line); - } else if line.trim().is_empty() { - in_body = true; + let limits = HttpResponseLimits::with_body_cap(CLI_MAX_HTTP_RESPONSE_BYTES); + let response = read_response(&mut buf_reader, &limits) + .await + .map_err(|error| anyhow::anyhow!("invalid HTTP response: {error}"))?; + if !(200..300).contains(&response.status) { + let detail = response.body.trim().replace('\n', " "); + if detail.is_empty() { + anyhow::bail!("HTTP {}", response.status); } + anyhow::bail!("HTTP {}: {}", response.status, detail); } - Ok(body.trim().to_string()) + Ok(response.body.trim().to_string()) } async fn governor_cmd(json: &str) -> Option { @@ -2942,6 +2940,40 @@ mod tests { toml::from_str("").expect("default config should parse from empty TOML") } + async fn serve_one_http_response(response: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + addr.to_string() + } + + #[tokio::test] + async fn http_get_returns_success_body() { + let addr = serve_one_http_response( + "HTTP/1.1 200 OK\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}", + ) + .await; + + assert_eq!(http_get(&addr, "/test").await.unwrap(), r#"{"ok":true}"#); + } + + #[tokio::test] + async fn http_get_rejects_error_status_with_response_detail() { + let addr = serve_one_http_response( + "HTTP/1.1 403 Forbidden\r\nContent-Length: 18\r\nConnection: close\r\n\r\n{\"error\":\"denied\"}", + ) + .await; + + let error = http_get(&addr, "/protected").await.unwrap_err().to_string(); + assert!(error.contains("HTTP 403"), "got: {error}"); + assert!(error.contains("denied"), "got: {error}"); + } + #[test] fn format_uptime_hm_floors_each_component() { assert_eq!(format_uptime_hm(3000), "0h 50m"); From 518a96f42d18f0a80fcd590181af0cc07d5427f3 Mon Sep 17 00:00:00 2001 From: andriypolanski Date: Wed, 29 Jul 2026 23:11:57 -0700 Subject: [PATCH 2/2] run cargo fmt --all -- --check --- crates/genie-ctl/src/main.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/genie-ctl/src/main.rs b/crates/genie-ctl/src/main.rs index 2f4fe426..5fea0fd8 100644 --- a/crates/genie-ctl/src/main.rs +++ b/crates/genie-ctl/src/main.rs @@ -2941,9 +2941,7 @@ mod tests { } async fn serve_one_http_response(response: &'static str) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap();