From 6143a454225a977ad6b09ce1632eb3caf632212f Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Mon, 22 Jun 2026 08:28:37 +0800 Subject: [PATCH 1/2] feat(tui): add dev server readiness tool Add a loopback-only wait_for_dev_server tool for local webapp automation. It polls TCP readiness and optional same-port HTTP healthchecks with bounded timeout output. Verification: - cargo test -p codewhale-tui --bin codewhale-tui --locked dev_server_readiness - cargo test -p codewhale-tui --bin codewhale-tui --locked test_builder_with_web_tools_no_longer_includes_finance - cargo test -p codewhale-tui --bin codewhale-tui --locked tool_names_route_to_families_by_verb - cargo test -p codewhale-tui --bin codewhale-tui --locked risk_query_only_network_is_benign_but_fetch_is_destructive - cargo test -p codewhale-tui --bin codewhale-tui --locked non_yolo_mode_retains_default_defer_policy - cargo test -p codewhale-tui --bin codewhale-tui --locked agent_catalog_advertises_and_searches_core_action_tools - cargo fmt -- --check - git diff --check --- crates/tui/src/core/engine/tests.rs | 4 + crates/tui/src/core/engine/tool_catalog.rs | 1 + crates/tui/src/tools/dev_server_readiness.rs | 680 +++++++++++++++++++ crates/tui/src/tools/mod.rs | 1 + crates/tui/src/tools/registry.rs | 5 +- crates/tui/src/tui/approval.rs | 12 +- crates/tui/src/tui/history.rs | 1 + crates/tui/src/tui/widgets/tool_card.rs | 10 +- 8 files changed, 710 insertions(+), 4 deletions(-) create mode 100644 crates/tui/src/tools/dev_server_readiness.rs diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 9a39f30e6..e02c376fe 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -856,6 +856,10 @@ fn non_yolo_mode_retains_default_defer_policy() { assert!(!should_default_defer_tool("run_tests", &always_load)); assert!(!should_default_defer_tool("agent", &always_load)); assert!(!should_default_defer_tool("read_file", &always_load)); + assert!(!should_default_defer_tool( + "wait_for_dev_server", + &always_load + )); assert!(!should_default_defer_tool("web_search", &always_load)); assert!(!should_default_defer_tool("write_file", &always_load)); assert!(!should_default_defer_tool("task_shell_start", &always_load)); diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index b3e7d3714..1a7d841ca 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -61,6 +61,7 @@ pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[ "task_shell_start", "task_shell_wait", "update_plan", + "wait_for_dev_server", "web_search", "write_file", ]; diff --git a/crates/tui/src/tools/dev_server_readiness.rs b/crates/tui/src/tools/dev_server_readiness.rs new file mode 100644 index 000000000..e32a7e4ea --- /dev/null +++ b/crates/tui/src/tools/dev_server_readiness.rs @@ -0,0 +1,680 @@ +//! Local dev-server readiness tool. +//! +//! This intentionally covers only the narrow "is my localhost dev server ready +//! yet?" primitive. It is not process supervision and it rejects non-loopback +//! targets so agents do not turn it into a general network probe. + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, + optional_str, optional_u64, required_u64, +}; +use async_trait::async_trait; +use serde::Serialize; +use serde_json::{Value, json}; +use std::future::Future; +use std::net::IpAddr; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio::time::{Instant, sleep, timeout}; + +const DEFAULT_HOST: &str = "127.0.0.1"; +const DEFAULT_TIMEOUT_MS: u64 = 30_000; +const HARD_MAX_TIMEOUT_MS: u64 = 120_000; +const DEFAULT_POLL_INTERVAL_MS: u64 = 250; +const MIN_POLL_INTERVAL_MS: u64 = 10; +const MAX_POLL_INTERVAL_MS: u64 = 5_000; + +pub struct WaitForDevServerTool; + +#[derive(Debug, Clone)] +struct ReadinessRequest { + host: String, + port: u16, + url: Option, + timeout: Duration, + poll_interval: Duration, +} + +#[derive(Debug, Serialize)] +struct ReadinessOutput { + ready: bool, + phase: &'static str, + target: String, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + elapsed_ms: u64, + timed_out: bool, + #[serde(skip_serializing_if = "Option::is_none")] + last_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_status: Option, +} + +#[async_trait] +impl ToolSpec for WaitForDevServerTool { + fn name(&self) -> &'static str { + "wait_for_dev_server" + } + + fn description(&self) -> &'static str { + "Wait for a local dev server to become ready. Polls a loopback TCP port, optionally then an HTTP(S) health URL on the same port, with bounded timeout and structured success/failure output." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "Loopback host to poll (default 127.0.0.1). Allowed: localhost, 127.0.0.1, ::1, or another loopback IP." + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "TCP port to wait for." + }, + "url": { + "type": "string", + "description": "Optional HTTP/HTTPS loopback healthcheck URL on the same port. 2xx and 3xx statuses count as ready." + }, + "timeout_ms": { + "type": "integer", + "description": "Maximum time to wait in milliseconds (default 30000; hard max 120000)." + }, + "poll_interval_ms": { + "type": "integer", + "description": "Delay between probes in milliseconds (default 250; clamped to 10..5000)." + } + }, + "required": ["port"], + "additionalProperties": false + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly, ToolCapability::Network] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let request = parse_request(&input)?; + let output = wait_for_readiness(request, context).await?; + readiness_result(output) + } +} + +fn parse_request(input: &Value) -> Result { + let host = normalize_loopback_host(optional_str(input, "host").unwrap_or(DEFAULT_HOST))?; + let port = parse_port(input)?; + let url = parse_healthcheck_url(input, port)?; + let timeout = Duration::from_millis( + optional_u64(input, "timeout_ms", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS), + ); + let poll_interval = Duration::from_millis( + optional_u64(input, "poll_interval_ms", DEFAULT_POLL_INTERVAL_MS) + .clamp(MIN_POLL_INTERVAL_MS, MAX_POLL_INTERVAL_MS), + ); + + Ok(ReadinessRequest { + host, + port, + url, + timeout, + poll_interval, + }) +} + +fn parse_port(input: &Value) -> Result { + let raw = required_u64(input, "port")?; + if raw == 0 || raw > u16::MAX as u64 { + return Err(ToolError::invalid_input( + "`port` must be between 1 and 65535", + )); + } + Ok(raw as u16) +} + +fn normalize_loopback_host(host: &str) -> Result { + let trimmed = host.trim(); + if trimmed.is_empty() { + return Err(ToolError::invalid_input("`host` cannot be empty")); + } + let unbracketed = trimmed + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(trimmed); + let lowered = unbracketed.to_ascii_lowercase(); + if lowered == "localhost" { + return Ok(DEFAULT_HOST.to_string()); + } + let ip = lowered.parse::().map_err(|_| { + ToolError::invalid_input("`host` must be localhost or a loopback IP address") + })?; + if !ip.is_loopback() { + return Err(ToolError::invalid_input( + "`host` must be localhost or a loopback IP address", + )); + } + Ok(ip.to_string()) +} + +fn parse_healthcheck_url(input: &Value, port: u16) -> Result, ToolError> { + let Some(url) = optional_str(input, "url") + .map(str::trim) + .filter(|url| !url.is_empty()) + else { + return Ok(None); + }; + let mut parsed = reqwest::Url::parse(url) + .map_err(|err| ToolError::invalid_input(format!("invalid `url`: {err}")))?; + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return Err(ToolError::invalid_input( + "`url` must use http:// or https://", + )); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(ToolError::invalid_input( + "`url` must not include credentials", + )); + } + let host = parsed + .host_str() + .ok_or_else(|| ToolError::invalid_input("`url` must include a host"))?; + let normalized_host = normalize_loopback_host(host).map_err(|_| { + ToolError::invalid_input("`url` host must be localhost or a loopback IP address") + })?; + let url_port = parsed + .port_or_known_default() + .ok_or_else(|| ToolError::invalid_input("`url` must include or imply a port"))?; + if url_port != port { + return Err(ToolError::invalid_input( + "`url` port must match the `port` readiness target", + )); + } + parsed + .set_host(Some(&normalized_host)) + .map_err(|_| ToolError::invalid_input("`url` host must be a valid loopback target"))?; + Ok(Some(parsed)) +} + +async fn wait_for_readiness( + request: ReadinessRequest, + context: &ToolContext, +) -> Result { + let started = Instant::now(); + let deadline = started + request.timeout; + let target = target_label(&request.host, request.port); + + if let Some(timeout) = wait_for_tcp(&request, &target, started, deadline, context).await? { + return Ok(timeout); + } + + let Some(url) = request.url.clone() else { + return Ok(ReadinessOutput { + ready: true, + phase: "ready", + target, + url: None, + elapsed_ms: elapsed_ms(started), + timed_out: false, + last_error: None, + last_status: None, + }); + }; + + wait_for_http(&request, url, &target, started, deadline, context).await +} + +async fn wait_for_tcp( + request: &ReadinessRequest, + target: &str, + started: Instant, + deadline: Instant, + context: &ToolContext, +) -> Result, ToolError> { + let mut last_error = None; + + loop { + check_cancelled(context)?; + match run_until_deadline( + deadline, + request.poll_interval, + TcpStream::connect((request.host.as_str(), request.port)), + ) + .await + { + Ok(Ok(_stream)) => break, + Ok(Err(err)) => last_error = Some(err.to_string()), + Err(()) if last_error.is_none() => { + last_error = Some("connection attempt timed out".to_string()); + } + Err(()) => {} + } + + if Instant::now() >= deadline { + return Ok(Some(ReadinessOutput { + ready: false, + phase: "tcp", + target: target.to_string(), + url: request.url.as_ref().map(ToString::to_string), + elapsed_ms: elapsed_ms(started), + timed_out: true, + last_error, + last_status: None, + })); + } + + sleep_until_next_poll(deadline, request.poll_interval, context).await?; + } + + Ok(None) +} + +async fn wait_for_http( + request: &ReadinessRequest, + url: reqwest::Url, + target: &str, + started: Instant, + deadline: Instant, + context: &ToolContext, +) -> Result { + let client = crate::tls::reqwest_client_builder() + .timeout(request.timeout) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .map_err(|err| { + ToolError::execution_failed(format!("failed to build HTTP client: {err}")) + })?; + let mut last_status = None; + let mut last_error = None; + + loop { + check_cancelled(context)?; + match run_until_deadline( + deadline, + request.poll_interval, + client.get(url.clone()).send(), + ) + .await + { + Ok(Ok(response)) => { + let status = response.status(); + last_status = Some(status.as_u16()); + last_error = None; + if status.is_success() || status.is_redirection() { + return Ok(ReadinessOutput { + ready: true, + phase: "ready", + target: target.to_string(), + url: Some(url.to_string()), + elapsed_ms: elapsed_ms(started), + timed_out: false, + last_error: None, + last_status, + }); + } + } + Ok(Err(err)) => { + last_error = Some(if err.is_timeout() { + "healthcheck request timed out".to_string() + } else { + err.to_string() + }); + } + Err(()) if last_error.is_none() && last_status.is_none() => { + last_error = Some("healthcheck request timed out".to_string()); + } + Err(()) => {} + } + + if Instant::now() >= deadline { + return Ok(ReadinessOutput { + ready: false, + phase: "http", + target: target.to_string(), + url: Some(url.to_string()), + elapsed_ms: elapsed_ms(started), + timed_out: true, + last_error, + last_status, + }); + } + + sleep_until_next_poll(deadline, request.poll_interval, context).await?; + } +} + +async fn run_until_deadline( + deadline: Instant, + attempt_timeout: Duration, + future: F, +) -> Result +where + F: Future, +{ + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(()); + } + timeout(remaining.min(attempt_timeout), future) + .await + .map_err(|_| ()) +} + +async fn sleep_until_next_poll( + deadline: Instant, + poll_interval: Duration, + context: &ToolContext, +) -> Result<(), ToolError> { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(()); + } + let delay = remaining.min(poll_interval); + if let Some(token) = context.cancel_token.as_ref() { + tokio::select! { + () = token.cancelled() => Err(ToolError::execution_failed("wait_for_dev_server cancelled")), + () = sleep(delay) => Ok(()), + } + } else { + sleep(delay).await; + Ok(()) + } +} + +fn check_cancelled(context: &ToolContext) -> Result<(), ToolError> { + if context + .cancel_token + .as_ref() + .is_some_and(tokio_util::sync::CancellationToken::is_cancelled) + { + return Err(ToolError::execution_failed("wait_for_dev_server cancelled")); + } + Ok(()) +} + +fn target_label(host: &str, port: u16) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +fn elapsed_ms(started: Instant) -> u64 { + started.elapsed().as_millis().try_into().unwrap_or(u64::MAX) +} + +fn readiness_result(output: ReadinessOutput) -> Result { + let success = output.ready; + let metadata = json!({ + "ready": output.ready, + "phase": output.phase, + "target": output.target, + "url": output.url, + "elapsed_ms": output.elapsed_ms, + "timed_out": output.timed_out, + "last_error": output.last_error, + "last_status": output.last_status, + }); + let content = serde_json::to_string_pretty(&output).map_err(|err| { + ToolError::execution_failed(format!("failed to serialize readiness result: {err}")) + })?; + Ok(ToolResult { + content, + success, + metadata: Some(metadata), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::spec::{ToolContext, ToolResult, ToolSpec}; + use serde_json::{Value, json}; + use std::path::PathBuf; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::task::JoinHandle; + + fn ctx() -> ToolContext { + ToolContext::new(PathBuf::from(".")) + } + + async fn run_tool(input: Value) -> (ToolResult, Value) { + let tool = WaitForDevServerTool; + let result = tool.execute(input, &ctx()).await.expect("tool result"); + let payload = serde_json::from_str(&result.content).expect("json result"); + (result, payload) + } + + async fn bind_tcp_listener() -> (TcpListener, u16) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + (listener, port) + } + + fn spawn_http_server(status: &'static str) -> (u16, JoinHandle<()>) { + let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = std_listener.local_addr().unwrap().port(); + std_listener.set_nonblocking(true).unwrap(); + let listener = TcpListener::from_std(std_listener).unwrap(); + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _addr)) = listener.accept().await else { + continue; + }; + tokio::spawn(async move { + let mut buf = [0_u8; 512]; + let _ = stream.read(&mut buf).await; + let response = format!( + "HTTP/1.1 {status}\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok" + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + (port, handle) + } + + fn spawn_hanging_http_server() -> (u16, JoinHandle<()>) { + let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = std_listener.local_addr().unwrap().port(); + std_listener.set_nonblocking(true).unwrap(); + let listener = TcpListener::from_std(std_listener).unwrap(); + let handle = tokio::spawn(async move { + loop { + let Ok((stream, _addr)) = listener.accept().await else { + continue; + }; + tokio::spawn(async move { + let _stream = stream; + sleep(Duration::from_secs(60)).await; + }); + } + }); + (port, handle) + } + + #[tokio::test] + async fn waits_until_tcp_port_accepts_connections() { + let (listener, port) = bind_tcp_listener().await; + let accept = tokio::spawn(async move { + let _ = listener.accept().await; + }); + + let (result, payload) = run_tool(json!({ + "host": "127.0.0.1", + "port": port, + "timeout_ms": 1_000, + "poll_interval_ms": 10 + })) + .await; + + assert!(result.success); + assert_eq!(payload["ready"], true); + assert_eq!(payload["phase"], "ready"); + assert_eq!(payload["target"], format!("127.0.0.1:{port}")); + assert!(payload["elapsed_ms"].as_u64().is_some()); + let _ = accept.await; + } + + #[tokio::test] + async fn reports_timeout_for_refused_tcp_port() { + let (listener, port) = bind_tcp_listener().await; + drop(listener); + + let (result, payload) = run_tool(json!({ + "host": "127.0.0.1", + "port": port, + "timeout_ms": 80, + "poll_interval_ms": 10 + })) + .await; + + assert!(!result.success); + assert_eq!(payload["ready"], false); + assert_eq!(payload["phase"], "tcp"); + assert_eq!(payload["timed_out"], true); + assert_eq!(payload["target"], format!("127.0.0.1:{port}")); + assert!(payload["elapsed_ms"].as_u64().unwrap() <= 500); + assert!(payload["last_error"].as_str().unwrap().contains("refused")); + } + + #[tokio::test] + async fn waits_for_http_success_status_after_tcp_ready() { + let (port, server) = spawn_http_server("204 No Content"); + + let (result, payload) = run_tool(json!({ + "host": "127.0.0.1", + "port": port, + "url": format!("http://127.0.0.1:{port}/health"), + "timeout_ms": 1_000, + "poll_interval_ms": 10 + })) + .await; + + assert!(result.success); + assert_eq!(payload["ready"], true); + assert_eq!(payload["phase"], "ready"); + assert_eq!(payload["last_status"], 204); + server.abort(); + } + + #[tokio::test] + async fn reports_last_http_status_when_healthcheck_never_succeeds() { + let (port, server) = spawn_http_server("503 Service Unavailable"); + + let (result, payload) = run_tool(json!({ + "host": "127.0.0.1", + "port": port, + "url": format!("http://127.0.0.1:{port}/health"), + "timeout_ms": 120, + "poll_interval_ms": 10 + })) + .await; + + assert!(!result.success); + assert_eq!(payload["ready"], false); + assert_eq!(payload["phase"], "http"); + assert_eq!(payload["timed_out"], true); + assert_eq!(payload["last_status"], 503); + server.abort(); + } + + #[tokio::test] + async fn reports_http_timeout_when_healthcheck_hangs() { + let (port, server) = spawn_hanging_http_server(); + + let (result, payload) = run_tool(json!({ + "host": "127.0.0.1", + "port": port, + "url": format!("http://127.0.0.1:{port}/health"), + "timeout_ms": 80, + "poll_interval_ms": 10 + })) + .await; + + assert!(!result.success); + assert_eq!(payload["ready"], false); + assert_eq!(payload["phase"], "http"); + assert_eq!(payload["timed_out"], true); + assert!(payload["last_status"].is_null()); + assert_eq!( + payload["last_error"].as_str(), + Some("healthcheck request timed out") + ); + server.abort(); + } + + #[test] + fn canonicalizes_localhost_to_loopback_literals() { + let request = parse_request(&json!({ + "host": "localhost", + "port": 8080, + "url": "http://localhost:8080/health" + })) + .unwrap(); + + assert_eq!(request.host, "127.0.0.1"); + let url = request.url.unwrap(); + assert_eq!(url.host_str(), Some("127.0.0.1")); + assert_eq!(url.as_str(), "http://127.0.0.1:8080/health"); + } + + #[tokio::test] + async fn rejects_non_loopback_targets() { + let tool = WaitForDevServerTool; + + let err = tool + .execute( + json!({ + "host": "example.com", + "port": 80, + "timeout_ms": 10 + }), + &ctx(), + ) + .await + .unwrap_err(); + assert!(format!("{err}").contains("loopback")); + + let err = tool + .execute( + json!({ + "host": "127.0.0.1", + "port": 8080, + "url": "https://example.com/health", + "timeout_ms": 10 + }), + &ctx(), + ) + .await + .unwrap_err(); + assert!(format!("{err}").contains("loopback")); + } + + #[tokio::test] + async fn rejects_healthcheck_url_credentials() { + let tool = WaitForDevServerTool; + + let err = tool + .execute( + json!({ + "host": "127.0.0.1", + "port": 8080, + "url": "http://user:secret@127.0.0.1:8080/health", + "timeout_ms": 10 + }), + &ctx(), + ) + .await + .unwrap_err(); + assert!(format!("{err}").contains("credentials")); + } +} diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index a0f68102a..0406b5e41 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -13,6 +13,7 @@ pub mod approval_cache; pub mod arg_repair; pub mod automation; pub mod cargo_failure_summary; +pub mod dev_server_readiness; pub mod diagnostics; pub mod diff_format; pub mod dynamic; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index ef253a349..92ac3acc7 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -726,11 +726,13 @@ impl ToolRegistryBuilder { /// NOT gated behind the web-search feature. #[must_use] pub fn with_web_tools(self) -> Self { + use super::dev_server_readiness::WaitForDevServerTool; use super::fetch_url::FetchUrlTool; use super::web_run::WebRunTool; use super::web_search::WebSearchTool; self.with_tool(Arc::new(WebSearchTool)) .with_tool(Arc::new(FetchUrlTool)) + .with_tool(Arc::new(WaitForDevServerTool)) .with_tool(Arc::new(WebRunTool)) } @@ -1634,9 +1636,10 @@ mod tests { let registry = ToolRegistryBuilder::new().with_web_tools().build(ctx); // finance was moved to with_finance_tool() in v0.8.49; - // with_web_tools() now only registers web search / fetch / web.run + // with_web_tools() registers web search/fetch plus local dev-server readiness. assert!(registry.contains("web_search")); assert!(registry.contains("fetch_url")); + assert!(registry.contains("wait_for_dev_server")); assert!(registry.contains("web.run")); assert!(!registry.contains("finance")); } diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index f227e1369..601fe9361 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -271,7 +271,10 @@ fn build_persistent_ask_rules(tool_name: &str, params: &Value) -> Vec ToolCategory { if matches!(name, "write_file" | "edit_file" | "apply_patch") { ToolCategory::FileWrite - } else if matches!(name, "web_run" | "web_search" | "fetch_url") { + } else if matches!( + name, + "web_run" | "web_search" | "fetch_url" | "wait_for_dev_server" + ) { ToolCategory::Network } else if matches!( name, @@ -328,7 +331,7 @@ pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> // Query-only network is benign; opening a URL pulls arbitrary // remote content, so it stays destructive. ToolCategory::Network => match tool_name { - "web_search" | "web_run" => RiskLevel::Benign, + "web_search" | "web_run" | "wait_for_dev_server" => RiskLevel::Benign, _ => RiskLevel::Destructive, }, // Shell is always destructive. We probe command_safety for @@ -1399,6 +1402,11 @@ mod tests { classify_risk("fetch_url", cat, &json!({"url": "https://example.com"})), RiskLevel::Destructive ); + // wait_for_dev_server only permits loopback targets. + assert_eq!( + classify_risk("wait_for_dev_server", cat, &json!({"port": 5173})), + RiskLevel::Benign + ); } #[test] diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 558286c7c..4d1f418e6 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -973,6 +973,7 @@ fn classify_tool_name_activity(name: &str) -> ToolRunActivity { | "task_shell_wait" | "run_tests" | "run_verifiers" + | "wait_for_dev_server" | "task_gate_run" | "validate_data" => ToolRunActivity::Command, "edit_file" | "apply_patch" | "write_file" | "diff" => ToolRunActivity::Edit, diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index 163778a7e..60e5638c3 100644 --- a/crates/tui/src/tui/widgets/tool_card.rs +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -90,7 +90,11 @@ pub fn tool_family_for_name(name: &str) -> ToolFamily { "grep_files" | "file_search" | "web_search" | "fetch_url" => ToolFamily::Find, "agent" => ToolFamily::Delegate, "rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm" => ToolFamily::Rlm, - "run_tests" | "run_verifiers" | "task_gate_run" | "validate_data" => ToolFamily::Verify, + "run_tests" + | "run_verifiers" + | "task_gate_run" + | "validate_data" + | "wait_for_dev_server" => ToolFamily::Verify, _ => ToolFamily::Generic, } } @@ -277,6 +281,10 @@ mod tests { assert_eq!(tool_family_for_name("agent"), ToolFamily::Delegate); assert_eq!(tool_family_for_name("rlm_eval"), ToolFamily::Rlm); assert_eq!(tool_family_for_name("run_verifiers"), ToolFamily::Verify); + assert_eq!( + tool_family_for_name("wait_for_dev_server"), + ToolFamily::Verify + ); assert_eq!( tool_family_for_name("totally_new_tool"), ToolFamily::Generic From 0af2d9b2061f71b45c4c085343130bc3815ff8f2 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Mon, 22 Jun 2026 11:54:44 +0800 Subject: [PATCH 2/2] fix(tui): decouple readiness probe timeout --- crates/tui/src/tools/dev_server_readiness.rs | 57 ++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tools/dev_server_readiness.rs b/crates/tui/src/tools/dev_server_readiness.rs index e32a7e4ea..1ce0781bd 100644 --- a/crates/tui/src/tools/dev_server_readiness.rs +++ b/crates/tui/src/tools/dev_server_readiness.rs @@ -23,6 +23,8 @@ const HARD_MAX_TIMEOUT_MS: u64 = 120_000; const DEFAULT_POLL_INTERVAL_MS: u64 = 250; const MIN_POLL_INTERVAL_MS: u64 = 10; const MAX_POLL_INTERVAL_MS: u64 = 5_000; +const TCP_CONNECT_ATTEMPT_TIMEOUT_MS: u64 = 2_000; +const HTTP_HEALTHCHECK_ATTEMPT_TIMEOUT_MS: u64 = 10_000; pub struct WaitForDevServerTool; @@ -242,7 +244,7 @@ async fn wait_for_tcp( check_cancelled(context)?; match run_until_deadline( deadline, - request.poll_interval, + Duration::from_millis(TCP_CONNECT_ATTEMPT_TIMEOUT_MS), TcpStream::connect((request.host.as_str(), request.port)), ) .await @@ -297,7 +299,7 @@ async fn wait_for_http( check_cancelled(context)?; match run_until_deadline( deadline, - request.poll_interval, + Duration::from_millis(HTTP_HEALTHCHECK_ATTEMPT_TIMEOUT_MS), client.get(url.clone()).send(), ) .await @@ -501,6 +503,29 @@ mod tests { (port, handle) } + fn spawn_delayed_http_server(delay: Duration) -> (u16, JoinHandle<()>) { + let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = std_listener.local_addr().unwrap().port(); + std_listener.set_nonblocking(true).unwrap(); + let listener = TcpListener::from_std(std_listener).unwrap(); + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _addr)) = listener.accept().await else { + continue; + }; + tokio::spawn(async move { + let mut buf = [0_u8; 512]; + let _ = stream.read(&mut buf).await; + sleep(delay).await; + let response = + "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + (port, handle) + } + #[tokio::test] async fn waits_until_tcp_port_accepts_connections() { let (listener, port) = bind_tcp_listener().await; @@ -542,8 +567,12 @@ mod tests { assert_eq!(payload["phase"], "tcp"); assert_eq!(payload["timed_out"], true); assert_eq!(payload["target"], format!("127.0.0.1:{port}")); - assert!(payload["elapsed_ms"].as_u64().unwrap() <= 500); - assert!(payload["last_error"].as_str().unwrap().contains("refused")); + assert!(payload["elapsed_ms"].as_u64().is_some()); + assert!( + payload["last_error"] + .as_str() + .is_some_and(|message| !message.is_empty()) + ); } #[tokio::test] @@ -612,6 +641,26 @@ mod tests { server.abort(); } + #[tokio::test] + async fn slow_healthcheck_can_complete_across_short_poll_intervals() { + let (port, server) = spawn_delayed_http_server(Duration::from_millis(600)); + + let (result, payload) = run_tool(json!({ + "host": "127.0.0.1", + "port": port, + "url": format!("http://127.0.0.1:{port}/health"), + "timeout_ms": 2_000, + "poll_interval_ms": 50 + })) + .await; + + assert!(result.success); + assert_eq!(payload["ready"], true); + assert_eq!(payload["phase"], "ready"); + assert_eq!(payload["last_status"], 204); + server.abort(); + } + #[test] fn canonicalizes_localhost_to_loopback_literals() { let request = parse_request(&json!({