diff --git a/src/autofix.rs b/src/autofix.rs index ce900ec..090ea93 100644 --- a/src/autofix.rs +++ b/src/autofix.rs @@ -25,10 +25,12 @@ pub struct AutoFixConfig { pub trigger: AutoFixTrigger, pub lint_command: Option, pub test_command: Option, - /// Reserved for a future multi-turn retry loop. The current hook runs - /// tests exactly once per turn and does not retry — setting this to - /// anything other than the default has no runtime effect today, and - /// `Config::load` emits a warning when the user overrides it. + /// Maximum number of lint/test → feedback → re-prompt rounds within a + /// single user turn. Honored by the TUI agentic loop: on + /// `AutoFixAction::Retry`, the failure output is appended as a + /// synthetic user message and the model is re-prompted; on + /// `AutoFixAction::GiveUp` the working tree is left as-is. + /// Clamped to `1..=10` by `Config::load`. pub max_retries: u32, /// Maximum wall-clock seconds to let the test command run before killing /// it and returning `CommandResult::Timeout`. `0` means no timeout. diff --git a/src/browser/actions.rs b/src/browser/actions.rs index 28ddf0e..fd35b30 100644 --- a/src/browser/actions.rs +++ b/src/browser/actions.rs @@ -7,14 +7,42 @@ //! `&mut BrowserSession` — those are fast, no long awaits. use super::cdp::CdpClient; use super::BrowserSession; -use anyhow::Result; +use anyhow::{Result, bail}; use serde_json::json; +/// URL schemes the browser is allowed to navigate to. Anything else +/// (`javascript:`, `data:`, `file:`, `ftp:`, …) is rejected up front so a +/// model can't pivot the session into local-file disclosure or in-page +/// script execution. +const ALLOWED_SCHEMES: &[&str] = &["http", "https", "about"]; + +/// Reject URLs that aren't plain HTTP(S) or `about:blank` style. Returns the +/// validated URL on success. +fn validate_navigation_url(url: &str) -> Result<()> { + let trimmed = url.trim(); + if trimmed.is_empty() { + bail!("navigation URL is empty"); + } + let lower = trimmed.to_ascii_lowercase(); + let scheme = match lower.split_once(':') { + Some((s, _)) if !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') => s.to_string(), + // No scheme — treat as relative; reject so callers always pass absolute URLs. + _ => bail!("navigation URL '{url}' is missing an http(s):// scheme"), + }; + if !ALLOWED_SCHEMES.contains(&scheme.as_str()) { + bail!( + "navigation URL scheme '{scheme}:' is not allowed; only http/https/about are permitted" + ); + } + Ok(()) +} + /// Navigate to a URL. Returns (title, status). Does NOT mutate session state — /// the caller is responsible for updating `current_url` / `current_title` /// after this returns, so the session lock can be released while we wait on /// the page load event (bounded by `timeout_ms`). pub async fn navigate(client: &CdpClient, url: &str, timeout_ms: u64) -> Result<(String, u16)> { + validate_navigation_url(url)?; // Subscribe BEFORE navigating so we don't miss Page.loadEventFired on fast loads. let mut events = client.subscribe(); @@ -182,29 +210,6 @@ pub async fn press_key(client: &CdpClient, key: &str) -> Result { Ok(format!("Pressed key: {key}")) } -/// Handle a dialog (alert/confirm/prompt). -/// Not yet exposed as a tool — wired up when we add a `browser_dialog` -/// tool and subscribe to `Page.javascriptDialogOpening`. Safe to drop -/// if that feature is abandoned. -#[allow(dead_code)] -pub async fn handle_dialog(client: &CdpClient, accept: bool, text: Option<&str>) -> Result { - let mut params = json!({"accept": accept}); - if let Some(t) = text { - params["promptText"] = json!(t); - } - client.send("Page.handleJavaScriptDialog", params).await?; - Ok(format!("Dialog {}", if accept { "accepted" } else { "dismissed" })) -} - -/// Get console messages (placeholder — requires a Runtime.consoleAPICalled listener). -/// Not yet exposed as a tool; kept so the signature is stable once we -/// wire a persistent console-event subscriber to BrowserSession. -#[allow(dead_code)] -pub async fn get_console_messages(_client: &CdpClient) -> Result> { - // Event-based listener not yet wired; return empty for now. - Ok(Vec::new()) -} - /// Get text content of an element by @ref. pub async fn get_text(session: &mut BrowserSession, element_ref: &str) -> Result { let node_id = session.resolve_ref(element_ref)?; @@ -250,3 +255,42 @@ pub async fn wait_for( tokio::time::sleep(std::time::Duration::from_millis(200)).await; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn navigation_accepts_http_and_https() { + assert!(validate_navigation_url("http://example.com").is_ok()); + assert!(validate_navigation_url("https://example.com/path?q=1#x").is_ok()); + assert!(validate_navigation_url("HTTPS://EXAMPLE.COM").is_ok()); + assert!(validate_navigation_url("about:blank").is_ok()); + } + + #[test] + fn navigation_rejects_dangerous_schemes() { + for url in [ + "javascript:alert(1)", + "JavaScript:void(0)", + "data:text/html,", + "file:///etc/passwd", + "ftp://example.com", + "chrome://settings", + "view-source:http://example.com", + ] { + assert!( + validate_navigation_url(url).is_err(), + "should have rejected {url}" + ); + } + } + + #[test] + fn navigation_rejects_relative_or_empty() { + assert!(validate_navigation_url("").is_err()); + assert!(validate_navigation_url(" ").is_err()); + assert!(validate_navigation_url("/foo/bar").is_err()); + assert!(validate_navigation_url("example.com").is_err()); + } +} diff --git a/src/browser/cdp.rs b/src/browser/cdp.rs index c4f3673..db0fdf0 100644 --- a/src/browser/cdp.rs +++ b/src/browser/cdp.rs @@ -153,15 +153,6 @@ impl CdpClient { self.event_tx.subscribe() } - /// Returns true if the WebSocket connection is still alive. - /// Not yet consulted by any tool — `send()` already surfaces reconnect - /// failures on the next call, so live health checks aren't needed today. - /// Kept for future /browser status display. - #[allow(dead_code)] - pub fn is_alive(&self) -> bool { - self.alive.load(Ordering::Relaxed) - } - /// Background reader loop: dispatches responses to pending waiters, events to broadcast. /// On exit, marks alive=false and drains all pending senders so callers get errors fast. async fn reader_loop( diff --git a/src/browser/extraction.rs b/src/browser/extraction.rs deleted file mode 100644 index 254a1f3..0000000 --- a/src/browser/extraction.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Schema-driven structured data extraction from pages. -//! -//! Given a URL and a JSON schema, the flow is: navigate → snapshot → ask the -//! LLM to extract data conforming to the schema → validate. The actual -//! LLM round-trip requires the API client (in `run.rs`), so this module -//! owns the prompt-building and validation helpers; wiring lives elsewhere. - -#![allow(dead_code)] // wired in a follow-up task; keep the shape stable - -use anyhow::Result; -use serde_json::Value; - -/// Extraction request. -#[derive(Debug)] -pub struct ExtractionRequest { - pub url: String, - pub schema: Value, - pub instructions: Option, -} - -/// Build the extraction prompt for the LLM. -pub fn build_extraction_prompt( - snapshot: &str, - schema: &Value, - instructions: Option<&str>, -) -> String { - let mut prompt = format!( - "Extract structured data from this page according to the JSON schema below.\n\n\ - ## Page Content (Accessibility Snapshot)\n\n{snapshot}\n\n\ - ## Required Output Schema\n\n```json\n{}\n```\n\n\ - Return ONLY valid JSON matching the schema. No markdown, no explanation.", - serde_json::to_string_pretty(schema).unwrap_or_default() - ); - - if let Some(inst) = instructions { - prompt.push_str(&format!("\n\n## Additional Instructions\n\n{inst}")); - } - - prompt -} - -/// Validate extracted JSON against the schema (basic type checking). -/// -/// Only root-level checks: root `type`, and `required` field presence for -/// objects. No recursion into nested schemas, no format validation, no enum -/// checks. Good enough for the initial use case; swap in `jsonschema` crate -/// if we need full validation later. -pub fn validate_extraction(data: &Value, schema: &Value) -> Result<()> { - let schema_type = schema["type"].as_str().unwrap_or("object"); - - match schema_type { - "object" => { - if !data.is_object() { - anyhow::bail!("Expected object, got {}", value_type_name(data)); - } - // Check required fields - if let Some(required) = schema["required"].as_array() { - for req in required { - if let Some(field) = req.as_str() - && data.get(field).is_none() { - anyhow::bail!("Missing required field: {field}"); - } - } - } - } - "array" => { - if !data.is_array() { - anyhow::bail!("Expected array, got {}", value_type_name(data)); - } - } - _ => {} - } - Ok(()) -} - -fn value_type_name(v: &Value) -> &'static str { - match v { - Value::Null => "null", - Value::Bool(_) => "boolean", - Value::Number(_) => "number", - Value::String(_) => "string", - Value::Array(_) => "array", - Value::Object(_) => "object", - } -} diff --git a/src/browser/mod.rs b/src/browser/mod.rs index 093661e..54bfe8f 100644 --- a/src/browser/mod.rs +++ b/src/browser/mod.rs @@ -4,7 +4,6 @@ pub mod browse_loop; pub mod approval_gate; pub mod cdp; pub mod element; -pub mod extraction; pub mod loop_detector; pub mod middleware; pub mod snapshot; @@ -12,10 +11,16 @@ pub mod yolo_ack; use anyhow::{Result, bail}; use cdp::CdpClient; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; +use std::sync::Arc; use tempfile::TempDir; use tokio::process::Child; +use tokio::sync::Mutex as AsyncMutex; +use tokio::task::JoinHandle; + +/// Bounded ring buffer cap for captured console messages. +const CONSOLE_BUF_CAP: usize = 500; /// Active browser session — owns the CDP connection, Chrome child process, and page state. #[derive(Default)] @@ -34,6 +39,11 @@ pub struct BrowserSession { pub current_url: String, /// Current page title pub current_title: String, + /// Captured console messages (Runtime.consoleAPICalled + Runtime.exceptionThrown). + /// Bounded at `CONSOLE_BUF_CAP`; oldest dropped on overflow. + console_buf: Arc>>, + /// Background task that drains CDP events into `console_buf`. Aborted on close. + console_task: Option>, } @@ -110,6 +120,7 @@ impl BrowserSession { } }; + self.console_task = Some(spawn_console_listener(&client, self.console_buf.clone())); self.client = Some(client); self.child = Some(child); self._user_data = Some(user_data); @@ -118,12 +129,20 @@ impl BrowserSession { /// Connect to an existing CDP endpoint. pub async fn connect(&mut self, endpoint: &str) -> Result<()> { - self.client = Some(CdpClient::connect(endpoint).await?); + let client = CdpClient::connect(endpoint).await?; + self.console_task = Some(spawn_console_listener(&client, self.console_buf.clone())); + self.client = Some(client); Ok(()) } /// Close the browser session — kills Chrome and frees the user-data-dir. pub async fn close(&mut self) { + // Stop the console listener before tearing down the CDP client so it + // doesn't observe a half-dead connection. + if let Some(task) = self.console_task.take() { + task.abort(); + } + self.console_buf.lock().await.clear(); // Drop the CDP client first to close the WebSocket. self.client = None; // Kill the Chrome process if we launched it (connect() doesn't set child). @@ -139,6 +158,15 @@ impl BrowserSession { self.current_title.clear(); } + /// Drain and return all console messages captured since the last call. + /// Includes both `Runtime.consoleAPICalled` events (formatted as + /// `[level] text`) and `Runtime.exceptionThrown` events (formatted as + /// `[exception] text`). The buffer is cleared on each call. + pub async fn take_console_messages(&self) -> Vec { + let mut buf = self.console_buf.lock().await; + buf.drain(..).collect() + } + /// Update ref map (called after each snapshot). Names are optional — pass /// an empty map to preserve the old behavior. #[allow(dead_code)] @@ -214,6 +242,80 @@ pub fn find_chrome() -> Option { None } +/// Spawn a background task that subscribes to CDP events and pushes +/// console + exception messages into `buf`. Bounded at `CONSOLE_BUF_CAP`; +/// oldest entries are dropped on overflow. The returned handle is aborted +/// on session close. +fn spawn_console_listener( + client: &CdpClient, + buf: Arc>>, +) -> JoinHandle<()> { + let mut rx = client.subscribe(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + let formatted = match event.method.as_str() { + "Runtime.consoleAPICalled" => format_console_event(&event.params), + "Runtime.exceptionThrown" => format_exception_event(&event.params), + _ => continue, + }; + if let Some(line) = formatted { + let mut guard = buf.lock().await; + if guard.len() >= CONSOLE_BUF_CAP { + guard.pop_front(); + } + guard.push_back(line); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + // High event rate — drop the lag and keep listening. + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }) +} + +fn format_console_event(params: &serde_json::Value) -> Option { + let level = params.get("type").and_then(|v| v.as_str()).unwrap_or("log"); + let args = params.get("args").and_then(|v| v.as_array())?; + let text: Vec = args + .iter() + .map(|a| { + a.get("value") + .map(stringify_arg) + .or_else(|| a.get("description").and_then(|v| v.as_str()).map(String::from)) + .unwrap_or_else(|| "".into()) + }) + .collect(); + Some(format!("[{level}] {}", text.join(" "))) +} + +fn format_exception_event(params: &serde_json::Value) -> Option { + let details = params.get("exceptionDetails")?; + let text = details.get("text").and_then(|v| v.as_str()).unwrap_or(""); + let exception_text = details + .get("exception") + .and_then(|e| e.get("description").and_then(|d| d.as_str())) + .or_else(|| details.get("exception").and_then(|e| e.get("value").and_then(|v| v.as_str()))) + .unwrap_or(""); + let combined = if exception_text.is_empty() { + text.to_string() + } else { + format!("{text} {exception_text}").trim().to_string() + }; + Some(format!("[exception] {combined}")) +} + +fn stringify_arg(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + /// Find a free TCP port for Chrome's debugging port. async fn find_free_port() -> Result { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") @@ -258,3 +360,110 @@ async fn poll_cdp_endpoint(port: u16) -> Result { } bail!("Chrome did not expose a page target within 6 seconds on port {port}") } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn console_event_formats_string_args() { + let params = json!({ + "type": "log", + "args": [ + {"type": "string", "value": "hello"}, + {"type": "number", "value": 42}, + ] + }); + assert_eq!( + format_console_event(¶ms).as_deref(), + Some("[log] hello 42") + ); + } + + #[test] + fn console_event_falls_back_to_description_for_objects() { + let params = json!({ + "type": "error", + "args": [ + {"type": "object", "description": "Error: oops"}, + ] + }); + assert_eq!( + format_console_event(¶ms).as_deref(), + Some("[error] Error: oops") + ); + } + + #[test] + fn console_event_defaults_level_to_log() { + let params = json!({ + "args": [{"type": "string", "value": "no-level"}] + }); + assert_eq!( + format_console_event(¶ms).as_deref(), + Some("[log] no-level") + ); + } + + #[test] + fn console_event_returns_none_without_args() { + let params = json!({"type": "log"}); + assert!(format_console_event(¶ms).is_none()); + } + + #[test] + fn exception_event_formats_text_and_description() { + let params = json!({ + "exceptionDetails": { + "text": "Uncaught", + "exception": {"description": "TypeError: x is undefined"} + } + }); + assert_eq!( + format_exception_event(¶ms).as_deref(), + Some("[exception] Uncaught TypeError: x is undefined") + ); + } + + #[test] + fn exception_event_handles_missing_exception() { + let params = json!({ + "exceptionDetails": {"text": "Uncaught"} + }); + assert_eq!( + format_exception_event(¶ms).as_deref(), + Some("[exception] Uncaught") + ); + } + + #[tokio::test] + async fn take_console_messages_drains_and_clears() { + let session = BrowserSession::default(); + { + let mut buf = session.console_buf.lock().await; + buf.push_back("[log] one".into()); + buf.push_back("[error] two".into()); + } + let drained = session.take_console_messages().await; + assert_eq!(drained, vec!["[log] one", "[error] two"]); + assert!(session.take_console_messages().await.is_empty()); + } + + #[tokio::test] + async fn console_buf_drops_oldest_on_overflow() { + let buf: Arc>> = Arc::new(AsyncMutex::new(VecDeque::new())); + // Simulate the listener push path with a small synthetic cap. + for i in 0..(CONSOLE_BUF_CAP + 5) { + let mut g = buf.lock().await; + if g.len() >= CONSOLE_BUF_CAP { + g.pop_front(); + } + g.push_back(format!("msg {i}")); + } + let g = buf.lock().await; + assert_eq!(g.len(), CONSOLE_BUF_CAP); + assert_eq!(g.front().unwrap(), &format!("msg {}", 5)); + assert_eq!(g.back().unwrap(), &format!("msg {}", CONSOLE_BUF_CAP + 4)); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f07529f..a384fb5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -444,11 +444,16 @@ pub fn dispatch(input: &str, ctx: &CommandContext) -> CommandAction { "export" => cmd_export(ctx), "mcp" => cmd_mcp(args, ctx), "login" | "logout" => CommandAction::Message( - "Auth management not needed — API key is read from ANTHROPIC_API_KEY.".into(), + "Auth is env-driven: set ANTHROPIC_API_KEY for Claude, or set the matching key \ + (GROQ_API_KEY, OPENROUTER_API_KEY, …) and switch with /model :. \ + No login state to manage." + .into(), ), "theme" => cmd_theme(args, ctx), "fast" => CommandAction::Message( - "Fast mode is not applicable in rustyclaw (streaming is always on).".into(), + "Streaming is always on. Use /model haiku for the lowest-latency tier, or /router \ + to auto-route easy turns to a cheap model." + .into(), ), "plan" => CommandAction::TogglePlanMode, "hooks" => cmd_hooks(ctx), diff --git a/src/main.rs b/src/main.rs index 0c88ecf..9891ba8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -503,6 +503,14 @@ async fn main() -> Result<()> { } } + // Respect NO_COLOR (https://no-color.org/) and dumb terminals so piped + // output / CI logs / `less` don't get ANSI escape codes. + if std::env::var_os("NO_COLOR").is_some() + || std::env::var("TERM").map(|t| t == "dumb").unwrap_or(false) + { + colored::control::set_override(false); + } + // Load .env files before anything else so API keys are available // to Config::load() and all downstream code. load_dotenv_auto(); diff --git a/src/rag/indexer.rs b/src/rag/indexer.rs index d2ac7d6..71cd14b 100644 --- a/src/rag/indexer.rs +++ b/src/rag/indexer.rs @@ -425,6 +425,14 @@ pub fn index_project(db: &RagDb, cwd: &Path, force: bool) -> Result if !entry.file_type().is_file() { continue; } + // Defense in depth: WalkDir's `follow_links(false)` skips traversal + // into symlinked dirs but still surfaces symlinked files; `read_to_string` + // would then follow them at I/O time. Reject symlinked entries so a + // crafted repo can't trick the indexer into pulling in `~/.ssh/id_rsa`. + if entry.path_is_symlink() { + files_skipped += 1; + continue; + } let path = entry.path(); let ext = match path.extension().and_then(|e| e.to_str()) { diff --git a/src/sandbox.rs b/src/sandbox.rs index ff9a011..348a0f3 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -82,7 +82,7 @@ pub fn strict_check(cmd: &str) -> Option { /// - Uses --unshare-pid for process isolation /// - Uses --die-with-parent so cleanup is automatic pub fn bwrap_wrap(command: &str, cwd: &std::path::Path, allow_network: bool) -> String { - let cwd_str = cwd.display(); + let cwd_quoted = shell_quote(&cwd.display().to_string()); let net_flag = if allow_network { "" } else { "--unshare-net " }; format!( @@ -105,7 +105,7 @@ pub fn bwrap_wrap(command: &str, cwd: &std::path::Path, allow_network: bool) -> --unshare-pid \ --die-with-parent \ -- /bin/sh -c {shell_quoted}", - cwd = cwd_str, + cwd = cwd_quoted, net_flag = net_flag, shell_quoted = shell_quote(command), ) @@ -114,10 +114,10 @@ pub fn bwrap_wrap(command: &str, cwd: &std::path::Path, allow_network: bool) -> // ── firejail wrapper ────────────────────────────────────────────────────────── pub fn firejail_wrap(command: &str, cwd: &std::path::Path) -> String { - let cwd_str = cwd.display(); + let cwd_quoted = shell_quote(&cwd.display().to_string()); format!( "firejail --quiet --private-tmp --noroot --chdir={cwd} -- /bin/sh -c {cmd}", - cwd = cwd_str, + cwd = cwd_quoted, cmd = shell_quote(command), ) } diff --git a/src/session/mod.rs b/src/session/mod.rs index e567977..c88920f 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -40,8 +40,8 @@ impl SessionMeta { async fn save(&self) -> Result<()> { let path = Self::path_for(&self.id); - fs::write(&path, serde_json::to_string(self)?).await?; - Ok(()) + let body = serde_json::to_string(self)?; + atomic_write(&path, body.as_bytes()).await } async fn load(id: &str) -> Result { @@ -137,8 +137,7 @@ impl Session { content.push_str(&serde_json::to_string(msg)?); content.push('\n'); } - fs::write(&self.path, content).await?; - Ok(()) + atomic_write(&self.path, content.as_bytes()).await } /// Rename the session. @@ -408,6 +407,46 @@ fn human_session_name() -> String { .unwrap_or_else(|| "New session".to_string()) } +/// Atomic file write: write to a sibling temp file, fsync, then rename over +/// the target. Survives mid-write crashes — the target is either the old +/// content or the new content, never a truncated splice. Falls back to a +/// direct write only if the temp-file path can't be constructed. +async fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("session path has no parent: {}", path.display()))?; + fs::create_dir_all(parent).await?; + + let file_name = path + .file_name() + .ok_or_else(|| anyhow::anyhow!("session path has no file name: {}", path.display()))?; + let tmp = parent.join(format!( + ".{}.tmp.{}", + file_name.to_string_lossy(), + Uuid::new_v4() + )); + + { + let mut f = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&tmp) + .await?; + f.write_all(bytes).await?; + f.sync_all().await?; + } + + // tokio::fs::rename is atomic on POSIX and on Windows when paths are on + // the same volume. Both paths are siblings under the sessions dir, so + // we always satisfy that constraint. + if let Err(e) = fs::rename(&tmp, path).await { + // Best-effort cleanup on rename failure. + let _ = fs::remove_file(&tmp).await; + return Err(e.into()); + } + Ok(()) +} + fn first_user_preview(messages: &[Message]) -> Option { for msg in messages { if matches!(msg.role, Role::User) { @@ -460,3 +499,40 @@ mod meta_serde_tests { assert!(out.contains("undo_position")); } } + +#[cfg(test)] +mod atomic_write_tests { + use super::atomic_write; + use tempfile::tempdir; + + #[tokio::test] + async fn writes_then_replaces() { + let dir = tempdir().unwrap(); + let target = dir.path().join("session.meta"); + atomic_write(&target, b"v1").await.unwrap(); + assert_eq!(tokio::fs::read_to_string(&target).await.unwrap(), "v1"); + atomic_write(&target, b"v2").await.unwrap(); + assert_eq!(tokio::fs::read_to_string(&target).await.unwrap(), "v2"); + } + + #[tokio::test] + async fn leaves_no_temp_files_behind() { + let dir = tempdir().unwrap(); + let target = dir.path().join("session.meta"); + atomic_write(&target, b"hello").await.unwrap(); + let mut entries = tokio::fs::read_dir(dir.path()).await.unwrap(); + let mut names = Vec::new(); + while let Some(e) = entries.next_entry().await.unwrap() { + names.push(e.file_name().to_string_lossy().to_string()); + } + assert_eq!(names, vec!["session.meta"]); + } + + #[tokio::test] + async fn creates_parent_dir_if_missing() { + let dir = tempdir().unwrap(); + let target = dir.path().join("nested/sub/session.meta"); + atomic_write(&target, b"x").await.unwrap(); + assert_eq!(tokio::fs::read_to_string(&target).await.unwrap(), "x"); + } +} diff --git a/src/tools/agent.rs b/src/tools/agent.rs index 8a3fd51..e6656a5 100644 --- a/src/tools/agent.rs +++ b/src/tools/agent.rs @@ -23,10 +23,6 @@ struct AgentInput { /// "rustyclaw-guide", "statusline-setup" #[serde(default)] subagent_type: Option, - /// Whether to run the agent in the background (not yet implemented). - #[serde(default)] - #[allow(dead_code)] // deserialized from LLM tool call, background agent WIP - run_in_background: Option, } #[async_trait] @@ -63,10 +59,6 @@ impl Tool for AgentTool { "type": "string", "description": "Specialized agent type. One of: general-purpose, Explore, Plan, verification, rustyclaw-guide, statusline-setup", "enum": ["general-purpose", "Explore", "Plan", "verification", "rustyclaw-guide", "statusline-setup"] - }, - "run_in_background": { - "type": "boolean", - "description": "Whether to run the agent in the background" } }, "required": ["prompt"] diff --git a/src/tools/browser_tools.rs b/src/tools/browser_tools.rs index 46c3eb5..424a867 100644 --- a/src/tools/browser_tools.rs +++ b/src/tools/browser_tools.rs @@ -449,6 +449,41 @@ impl Tool for BrowserWaitTool { } } +// ── browser_console ───────────────────────────────────────────────────────── + +pub struct BrowserConsoleTool { + pub session: SharedSession, +} + +#[async_trait] +impl Tool for BrowserConsoleTool { + fn name(&self) -> &str { + "browser_console" + } + fn description(&self) -> &str { + "Drain and return console messages and uncaught exceptions captured \ + since the last call. Each entry is prefixed with [level] or \ + [exception]. The buffer is cleared after each call." + } + fn input_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": {}, + "required": [] + }) + } + async fn execute(&self, _input: serde_json::Value, _ctx: &ToolContext) -> Result { + let session = self.session.lock().await; + let _ = session.client()?; + let messages = session.take_console_messages().await; + if messages.is_empty() { + Ok(ToolOutput::success("(no console messages)".to_string())) + } else { + Ok(ToolOutput::success(messages.join("\n"))) + } + } +} + // ── browse_done ────────────────────────────────────────────────────────────── /// Sentinel tool the model calls to signal the end of an autonomous `/browse` diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 00fab66..a230766 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -487,6 +487,9 @@ pub fn all_tools_with_state(config: &crate::config::Config) -> (Vec, Sh session: browser_session.clone(), default_timeout_ms: config.browser_timeout_ms, })); + tools.push(Arc::new(browser_tools::BrowserConsoleTool { + session: browser_session.clone(), + })); tools.push(Arc::new(browser_tools::BrowseDoneTool)); Some(browser_session) } else { diff --git a/src/tui/diff.rs b/src/tui/diff.rs index 2f4ffcd..2d34beb 100644 --- a/src/tui/diff.rs +++ b/src/tui/diff.rs @@ -1,13 +1,9 @@ //! Unified diff parsing for the read-only diff overlay (`/diff`). //! -//! Parser-only. Interactive hunk-level accept/reject is not shipped; if it's -//! ever wired, revive the `HunkState` + `DiffReviewState` machine from git -//! history (commit e23cc71) rather than re-deriving it. -//! -//! The struct fields below are populated by the parser but only the -//! summary counts (`additions` / `deletions`) are read today — the -//! overlay renders the raw diff string directly. Fields retained so the -//! hunk-walker UI can reconnect without a parser change. +//! The TUI overlay renders the raw diff text and only reads the summary +//! counts (`additions` / `deletions`) from `FileDiff`. Hunks and lines are +//! still produced by the parser so unit tests in `tests/diff_tests.rs` can +//! verify hunk-level correctness. #[allow(dead_code)] #[derive(Debug, Clone)]