//! Built-in tools: shell, files, completion, Playwright browser (P3), human handoff (P4), //! confirm-before-post (P6). use anyhow::{anyhow, Context, Result}; use serde_json::{json, Value}; use std::future::Future; use std::path::{Component, Path, PathBuf}; use std::pin::Pin; use std::sync::{Arc, Mutex}; use crate::browser::{self, LastUrlSlot}; use crate::confirm; use crate::box_runtime::BoxHub; use crate::mcp::McpHub; use crate::model::ChatMessage; use crate::subagents::Subagents; pub type SharedCompleter = Arc< dyn Fn( Vec, Option, ) -> Pin> + Send>> + Send + Sync, >; pub const MAX_READ_BYTES: usize = 256 * 1024; pub const SHELL_TIMEOUT_SECS: u64 = 30; /// Runtime context for tool execution. #[derive(Clone)] pub struct ToolContext { /// Default working directory for relative paths / shell. pub cwd: PathBuf, pub(crate) team: Option>, /// Optional workspace root; paths outside it are rejected when set. pub workspace_root: Option, /// Last navigated browser URL (shared across clones). pub last_browser_url: LastUrlSlot, pub runtime: std::sync::Arc, pub(crate) jobs: std::sync::Arc, pub(crate) browser: std::sync::Arc, pub(crate) model: Arc>>, pub(crate) subagents: Arc, pub(crate) mcp: Arc, pub(crate) box_hub: Arc, pub(crate) subagent_depth: u32, /// When true, this context is a `computerUse` subagent and may call `computer`. pub(crate) computer_use: bool, /// When true (`run` / tests), wait inside the loop for background work. /// REPL sets this false so the prompt returns; completion revives later. pub hold_background: bool, } impl std::fmt::Debug for ToolContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ToolContext") .field("cwd", &self.cwd) .field("subagent_depth", &self.subagent_depth) .field("computer_use", &self.computer_use) .finish() } } impl ToolContext { pub fn new(cwd: impl Into) -> Self { let cwd = cwd.into(); Self { cwd: cwd.clone(), team: None, workspace_root: Some(cwd.clone()), last_browser_url: std::sync::Arc::new(std::sync::Mutex::new(None)), runtime: std::sync::Arc::new(crate::runtime::Runtime::default()), jobs: Default::default(), browser: Default::default(), model: Arc::new(Mutex::new(None)), subagents: Arc::new(Subagents::default()), mcp: McpHub::load(&cwd).unwrap_or_else(|_| McpHub::empty()), box_hub: BoxHub::new(), subagent_depth: 0, computer_use: false, hold_background: true, } } pub async fn has_live_background(&self) -> bool { self.jobs.active().await || self.subagents.has_running() } pub fn has_parked_background(&self) -> bool { self.jobs.has_unreaped_exit() || self.subagents.has_completed() } pub async fn wait_background_progress(&self) { loop { if !self.has_live_background().await { return; } tokio::select! { _ = self.jobs.notify.notified() => return, _ = self.subagents.notified() => return, _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => { if !self.has_live_background().await { return; } } } } } pub fn with_runtime(mut self, runtime: std::sync::Arc) -> Self { self.runtime = runtime; self } pub async fn shutdown(&self) { self.jobs.cancel().await; self.subagents.cancel_all().await; self.browser.close().await; if let Some(team) = &self.team { team.held_browser.lock().await.take(); } } pub fn with_workspace(mut self, root: Option) -> Self { self.workspace_root = root; self } pub fn last_browser_url_value(&self) -> Option { self.last_browser_url.lock().ok().and_then(|g| g.clone()) } } /// OpenAI-compatible tool definitions for chat completions. pub fn tool_definitions() -> Value { assemble_tool_definitions(false) } /// Tool list for this agent. A `computerUse` child gets `computer`; the parent does not. pub fn tool_definitions_for(ctx: &ToolContext) -> Value { assemble_tool_definitions(ctx.computer_use) } fn assemble_tool_definitions(computer_use: bool) -> Value { let mut defs = json!([ { "type": "function", "function": { "name": "external_shell", "description": "Run a shell command. Captures stdout, stderr, and exit code. Timeout 30s. cwd defaults to the session working directory.", "parameters": { "type": "object", "properties": { "command": { "type": "string", "description": "Shell command to run" }, "cwd": { "type": "string", "description": "Optional working directory" } }, "required": ["command"] } } }, { "type": "function", "function": { "name": "external_list_dir", "description": "List entries in a directory (names only, sorted).", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory path (relative to cwd or absolute)" } }, "required": ["path"] } } }, { "type": "function", "function": { "name": "external_read_file", "description": "Read text in line segments (max 256KB per response). offset is zero-based; use next_offset when truncated.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "File path" }, "offset": {"type":"integer","minimum":0}, "limit": {"type":"integer","minimum":1} }, "required": ["path"] } } }, { "type": "function", "function": { "name": "external_write_file", "description": "Write text to a file, creating parent directories as needed.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "File path" }, "content": { "type": "string", "description": "File contents" } }, "required": ["path", "content"] } } }, { "type": "function", "function": { "name": "report_done", "description": "Signal that the task is complete. Stops the agent loop with a done verdict. Call once when finished; include a short summary in message.", "parameters": { "type": "object", "properties": { "message": { "type": "string", "description": "Final summary for the user" } }, "required": ["message"] } } }, { "type": "function", "function": { "name": "report_blocked", "description": "Explain a blocker and offer 2–3 concrete alternative routes in options. With interactive input, wait for a choice and continue the SAME task; the runtime appends a stop option. Without input, return a blocked verdict. Prefer useful alternatives over repeated failed attempts.", "parameters": { "type": "object", "properties": { "reason": { "type": "string", "description": "What failed, what was tried, and what remains possible" }, "options": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 3, "description": "Concrete next directions, excluding stop; e.g. manual login in current browser or draft content without login" } }, "required": ["reason"] } } } ]); // Confirm-before-post (P6) + optional Playwright DOM tools. if let Some(arr) = defs.as_array_mut() { arr.push(confirm::confirm_tool_definition()); arr.extend(browser::browser_tool_definitions()); arr.extend(extra_tool_definitions()); if computer_use { arr.push(crate::computer::tool_definition()); } for tool in arr.iter_mut() { let name = tool["function"]["name"].as_str().unwrap_or("").to_owned(); if name.starts_with("external_") { let description = tool["function"]["description"].as_str().unwrap_or(""); tool["function"]["description"] = json!(format!("USER'S LOCAL COMPUTER, not the box. Use only for explicitly requested local files/environment; ordinary work belongs in shell/read on the box. {description}")); } else { match name.as_str() { "box_shell" => tool["function"]["name"] = json!("shell"), "box_read" => tool["function"]["name"] = json!("read"), "box_await" => tool["function"]["name"] = json!("await_shell"), _ => {} } } } arr.sort_by_key(|tool| tool["function"]["name"].as_str().unwrap_or("").starts_with("external_")); } defs } /// Tools that must be called alone (human wait / hard stop). `send_message` may mix. pub fn is_completion_tool(name: &str) -> bool { matches!( name, "report_done" | "report_blocked" | "request_user_input" | "request_user_confirm" | "browser_handoff" | "request_box_help" ) } /// User-visible delivery. Does not end the turn; a later no-tool response does. pub fn is_delivery_tool(name: &str) -> bool { matches!(name, "send_message" | "report_progress" | "report_done") } pub(crate) fn is_parallel_safe(name: &str) -> bool { matches!( name, "external_list_dir" | "external_read_file" | "external_search_files" | "external_grep" | "external_glob" | "web_fetch" | "web_search" | "send_message" | "report_progress" | "update_plan" | "check_subagent" | "get_mcp_tools" | "get_mcp_server_status" | "box_read" | "read" | "screenshot" ) } /// Resolve a user-supplied path against cwd and optionally enforce workspace_root. pub fn resolve_path(ctx: &ToolContext, path: &str) -> Result { let raw = Path::new(path); let joined = if raw.is_absolute() { raw.to_path_buf() } else { ctx.cwd.join(raw) }; // Normalize without requiring the path to exist (for write_file parents). let resolved = normalize_path(&joined); if let Some(root) = &ctx.workspace_root { let root_norm = normalize_path(root); if !resolved.starts_with(&root_norm) { return Err(anyhow!( "path {:?} is outside workspace root {:?}", resolved, root_norm )); } } // Resolve existing ancestors too: lexical checks alone allow escaping via symlinks. if let Some(root) = &ctx.workspace_root { let real_root = std::fs::canonicalize(root).unwrap_or_else(|_| normalize_path(root)); let mut ancestor = resolved.as_path(); while !ancestor.exists() { if std::fs::symlink_metadata(ancestor).is_ok() { return Err(anyhow!("dangling symlink in path")); } ancestor = ancestor .parent() .ok_or_else(|| anyhow!("no existing path ancestor"))?; } if !std::fs::canonicalize(ancestor)?.starts_with(real_root) { return Err(anyhow!("symlink target outside workspace")); } } Ok(resolved) } fn normalize_path(path: &Path) -> PathBuf { let mut out = PathBuf::new(); for comp in path.components() { match comp { Component::Prefix(p) => out.push(p.as_os_str()), Component::RootDir => out.push(Component::RootDir.as_os_str()), Component::CurDir => {} Component::ParentDir => { out.pop(); } Component::Normal(c) => out.push(c), } } out } pub async fn execute_tool(ctx: &ToolContext, name: &str, arguments_json: &str) -> String { match execute_tool_guarded(ctx, name, arguments_json).await { Ok(v) => v.to_string(), Err(e) => json!({ "error": format!("{e:#}") }).to_string(), } } async fn execute_tool_guarded(ctx: &ToolContext, name: &str, arguments: &str) -> Result { // Plain names always select the box. Historical box_* names remain aliases. let name = match name { "shell" => "box_shell", "read" => "box_read", "await_shell" => "box_await", _ => name, }; if let Some(team) = &ctx.team { let args: Value = serde_json::from_str(arguments)?; // `send_message` is overloaded: with `task_id` it steers another task, otherwise it is // the worker's user-facing voice and must reach the runtime like in single-agent mode. let task_scoped = name != "send_message" || args.get("task_id").is_some() || team.task.is_none(); if crate::team::worker::is_team_tool(name) && task_scoped { if name == "wait_task" { if ctx.jobs.active().await { return Err(anyhow!( "finish or terminate the active command before waiting for another task" )); } team.held_workspace.lock().await.take(); } return team.tool(name, &args).await; } if team.task.is_none() { return Err(anyhow!( "foreground chat must delegate tool work to a background task" )); } if name == "report_done" && team.unfinished() { return Err(anyhow!( "child tasks are still active; wait for their results" )); } if !matches!( name, "report_done" | "report_blocked" | "report_progress" | "send_message" | "web_fetch" | "web_search" | "spawn_subagent" | "check_subagent" | "message_subagent" | "stop_subagent" | "get_mcp_tools" | "call_mcp_tool" | "get_mcp_server_status" | "add_mcp_server" | "remove_mcp_server" | "box_shell" | "box_read" | "box_await" | "copy_to_box" | "copy_from_box" | "screenshot" | "computer" | "request_box_help" | "update_plan" | "request_user_input" | "request_user_confirm" | "browser_handoff" ) { let service = team.service()?; let mut held = team.held_workspace.lock().await; if held.is_none() { *held = Some(service.workspace(&ctx.cwd)?.lock_owned().await); } let result = execute_tool_inner(ctx, name, arguments).await; if !ctx.jobs.active().await { held.take(); } return result; } if matches!( name, "request_user_input" | "request_user_confirm" | "browser_handoff" | "request_box_help" ) && !ctx.jobs.active().await { team.held_workspace.lock().await.take(); } // A live command must be finished before handing control to a human. if matches!( name, "request_user_input" | "request_user_confirm" | "browser_handoff" | "request_box_help" ) && ctx.jobs.active().await { return Err(anyhow!( "finish or terminate the active command before requesting human input" )); } } execute_tool_inner(ctx, name, arguments).await } async fn execute_tool_inner(ctx: &ToolContext, name: &str, arguments_json: &str) -> Result { let args: Value = serde_json::from_str(arguments_json) .with_context(|| format!("invalid tool arguments JSON for {name}"))?; match name { "external_shell" => tool_shell(ctx, &args).await, "external_list_dir" => tool_list_dir(ctx, &args).await, "external_read_file" => tool_read_file(ctx, &args).await, "external_search_files" => search_files(ctx, &args).await, "external_edit_file" => edit_file(ctx, &args).await, "external_exec_command" => { let cwd = resolve_path(ctx, args["cwd"].as_str().unwrap_or("."))?; ctx.jobs.exec(&cwd, &args).await } "external_write_stdin" => ctx.jobs.write(&args).await, "send_message" => tool_send_message(ctx, &args).await, "external_grep" => tool_grep(ctx, &args).await, "external_glob" => tool_glob(ctx, &args).await, "web_fetch" => crate::web::fetch(&args).await, "web_search" => crate::web::search(&args).await, "external_await_command" => ctx.jobs.write(&args).await, "spawn_subagent" => { let goal = required_text(&args, "goal")?; let kind = crate::subagents::parse_kind( args["kind"] .as_str() .or_else(|| args["subagent_type"].as_str()), )?; ctx.subagents.spawn(ctx, goal, args["title"].as_str(), kind) } "check_subagent" => tool_check_subagent(ctx, &args), "message_subagent" => { let id = required_text(&args, "subagent_id")?; let message = required_text(&args, "message")?; Ok(json!({"status": ctx.subagents.message(id, message)?})) } "stop_subagent" => { let id = required_text(&args, "subagent_id")?; Ok(json!({"status": ctx.subagents.stop(id).await?})) } "get_mcp_tools" => { ctx.mcp .get_tools( args["server"].as_str().or(args["server_id"].as_str()), args["tool_name"].as_str().or(args["toolName"].as_str()), args["pattern"].as_str(), ) .await } "call_mcp_tool" => { let server = required_text(&args, "server") .or_else(|_| required_text(&args, "server_id"))?; let tool = required_text(&args, "tool_name") .or_else(|_| required_text(&args, "toolName"))?; let arguments = args.get("arguments").cloned().unwrap_or(json!({})); ctx.mcp.call_tool(server, tool, arguments).await } "get_mcp_server_status" => { ctx.mcp .status(args["server"].as_str().or(args["server_id"].as_str())) .await } "add_mcp_server" => { let name = required_text(&args, "name")?; ctx.mcp .add( name, args["url"].as_str(), crate::mcp::parse_headers(&args["headers"]), args["command"].as_str(), crate::mcp::parse_args_list(&args["args"]), crate::mcp::parse_env(&args["env"]), ) .await } "remove_mcp_server" => { let name = required_text(&args, "name") .or_else(|_| required_text(&args, "server")) .or_else(|_| required_text(&args, "server_id"))?; ctx.mcp.remove(name).await } "box_shell" => { let cmd = required_text(&args, "cmd").or_else(|_| required_text(&args, "command"))?; let block = args["block_until_ms"] .as_u64() .or(args["yield_time_ms"].as_u64()) .unwrap_or(30_000); ctx.box_hub.shell(cmd, block).await } "box_read" => { ctx.box_hub .read( required_text(&args, "path")?, args["offset"].as_i64(), args["limit"].as_i64(), ) .await } "box_await" => { ctx.box_hub .await_job(required_text(&args, "session_id")?) .await } "copy_to_box" => { let host = resolve_path(ctx, required_text(&args, "computer_path")?)?; ctx.box_hub .copy_to_box(&host, args["box_path"].as_str()) .await } "copy_from_box" => { let dest = args["computer_path"] .as_str() .map(|p| resolve_path(ctx, p)) .transpose()? .unwrap_or_else(|| { ctx.cwd.join( Path::new(args["box_path"].as_str().unwrap_or("file")) .file_name() .unwrap_or_default(), ) }); ctx.box_hub .copy_from_box(required_text(&args, "box_path")?, &dest) .await } "screenshot" => { let dest = ctx.cwd.join(".grokboy-output").join(format!( "box-shot-{}.png", uuid::Uuid::new_v4() )); ctx.box_hub.screenshot(&dest).await } "computer" => { if !ctx.computer_use { return Err(anyhow!( "computer is only available to a computerUse subagent. Delegate with spawn_subagent kind=computerUse. The parent may only take a read-only screenshot." )); } let dest = ctx.cwd.join(".grokboy-output").join(format!( "box-shot-{}.png", uuid::Uuid::new_v4() )); let actions = crate::computer::parse_computer_call(&args)?; ctx.box_hub.computer(&actions, &dest).await } "request_box_help" => { let instruction = required_text(&args, "instruction")?; let ready = ctx.box_hub.ensure_ready().await?; let viewer = ready["viewer_url"] .as_str() .unwrap_or("") .to_string(); ctx.runtime.deliver(&format!( "請在我的電腦上操作:{instruction}\n打開 {viewer} ,做完後在這裡回覆「好了」。" )); ctx.runtime.park_question(&json!({ "kind": "box_help", "question": format!("{instruction}\n打開我的電腦:{viewer}"), "options": ["我已完成,請檢查畫面後繼續", "停止這份工作"], "viewer_url": viewer, "reason": args["reason"], })) } "report_progress" => { let message = required_text(&args, "message")?; ctx.runtime.deliver(message); ctx.runtime.emit(crate::AgentEvent::Progress { message: message.into(), }); Ok(json!({"emitted":true,"sent":true})) } "update_plan" => ctx.runtime.update_plan(&args), "request_user_input" => { required_text(&args, "question")?; let mut question = args.clone(); if question.get("kind").is_none() { question["kind"] = json!("input"); } ctx.runtime.park_question(&question) } "external_write_file" => tool_write_file(ctx, &args).await, "report_done" => { let message = required_text(&args, "message")?; ctx.runtime.deliver(message); tool_report_done(&args) } "report_blocked" => recovery_choice(ctx, &args).await, "request_user_confirm" if ctx.runtime.input.is_some() => human_confirm(ctx, &args).await, "request_user_confirm" => confirm::execute_request_user_confirm_async(&args).await, name if name.starts_with("browser_") => browser_tool(ctx, name, &args).await, other => Err(anyhow!("unknown tool: {other}")), } } fn tool_report_done(args: &Value) -> Result { let message = args .get("message") .and_then(|v| v.as_str()) .filter(|s| !s.trim().is_empty()) .ok_or_else(|| anyhow!("report_done: missing 'message'"))?; Ok(json!({ "status": "done", "message": message, })) } async fn recovery_choice(ctx: &ToolContext, args: &Value) -> Result { let blocked = tool_report_blocked(args)?; if ctx.jobs.active().await { return Err(anyhow!( "finish or terminate the active command before asking for a recovery choice" )); } if let Some(team) = &ctx.team { team.held_workspace.lock().await.take(); } let mut options = recovery_options( args, &["換一種方法處理目前的阻礙", "先完成不受阻礙影響的部分"], )?; options.push("停止這份工作".into()); let question = json!({"kind":"recovery","question":format!("{}\n接下來你想怎麼做?",blocked["reason"].as_str().unwrap_or("目前遇到阻礙")),"options":options}); let mut parked = ctx.runtime.park_question(&question)?; parked["status"] = json!("blocked"); parked["reason"] = blocked["reason"].clone(); Ok(parked) } fn recovery_options(args: &Value, defaults: &[&str]) -> Result> { match args.get("options") { None => Ok(defaults.iter().map(|s| s.to_string()).collect()), Some(value) => { let values = value .as_array() .filter(|a| !a.is_empty() && a.len() <= 3) .ok_or_else(|| anyhow!("options must contain 1–3 next directions"))?; values .iter() .map(|v| { v.as_str() .filter(|s| !s.trim().is_empty() && s.len() <= 1000) .map(str::to_string) .ok_or_else(|| { anyhow!("each option must be nonempty text, at most 1000 bytes") }) }) .collect() } } } fn tool_report_blocked(args: &Value) -> Result { let reason = args .get("reason") .and_then(|v| v.as_str()) .filter(|s| !s.trim().is_empty()) .ok_or_else(|| anyhow!("report_blocked: missing 'reason'"))?; Ok(json!({ "status": "blocked", "reason": reason, })) } async fn tool_shell(ctx: &ToolContext, args: &Value) -> Result { let command = args .get("command") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("shell: missing 'command'"))?; let cwd = if let Some(c) = args.get("cwd").and_then(|v| v.as_str()) { resolve_path(ctx, c)? } else { ctx.cwd.clone() }; if !cwd.is_dir() { return Err(anyhow!("shell cwd is not a directory: {}", cwd.display())); } let mut result = ctx .jobs .exec( &cwd, &json!({"cmd":command,"timeout_ms":SHELL_TIMEOUT_SECS*1000,"yield_time_ms":10000}), ) .await?; let mut stdout = result["stdout"].as_str().unwrap_or("").to_string(); let mut stderr = result["stderr"].as_str().unwrap_or("").to_string(); while result["running"] == true { result = ctx .jobs .write(&json!({"session_id":result["session_id"],"yield_time_ms":10000})) .await?; if stdout.len() < 64 * 1024 { stdout.push_str(result["stdout"].as_str().unwrap_or("")); } if stderr.len() < 32 * 1024 { stderr.push_str(result["stderr"].as_str().unwrap_or("")); } } result["stdout"] = json!(truncate_output(&stdout, 64 * 1024)); result["stderr"] = json!(truncate_output(&stderr, 32 * 1024)); result["command"] = json!(command); result["cwd"] = json!(cwd); if result["timed_out"] == true { result["error"] = json!("shell command timed out after 30s"); } Ok(result) } async fn tool_list_dir(ctx: &ToolContext, args: &Value) -> Result { let path = args .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("list_dir: missing 'path'"))?; let dir = resolve_path(ctx, path)?; let mut rd = tokio::fs::read_dir(&dir) .await .with_context(|| format!("list_dir: {}", dir.display()))?; let mut entries = Vec::new(); while let Some(ent) = rd.next_entry().await? { let name = ent.file_name().to_string_lossy().to_string(); let file_type = ent.file_type().await?; let kind = if file_type.is_dir() { "dir" } else if file_type.is_symlink() { "symlink" } else { "file" }; entries.push(json!({ "name": name, "kind": kind })); } entries.sort_by(|a, b| { a["name"] .as_str() .unwrap_or("") .cmp(b["name"].as_str().unwrap_or("")) }); Ok(json!({ "path": dir.display().to_string(), "entries": entries, })) } async fn tool_read_file(ctx: &ToolContext, args: &Value) -> Result { let path = args .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("read_file: missing 'path'"))?; let file = resolve_path(ctx, path)?; use tokio::io::{AsyncBufReadExt, BufReader}; let offset = args["offset"].as_u64().unwrap_or(0); let limit = args["limit"].as_u64().unwrap_or(2000).clamp(1, 10000); let mut reader = BufReader::new(tokio::fs::File::open(&file).await?); let mut content = String::new(); let mut line = Vec::new(); let mut number = 0; let mut more = false; loop { line.clear(); // Bound a single line as well as the whole response. use tokio::io::AsyncReadExt; let n = (&mut reader) .take((MAX_READ_BYTES + 1) as u64) .read_until(b'\n', &mut line) .await?; if n == 0 { break; } if line.len() > MAX_READ_BYTES { return Err(anyhow!( "line exceeds 256KB; use a command to inspect the file" )); } if number >= offset { if number - offset >= limit || content.len() + line.len() > MAX_READ_BYTES { more = true; break; } content.push_str(&String::from_utf8_lossy(&line)); } number += 1; } Ok( json!({"path":file,"bytes":content.len(),"content":content,"offset":offset,"next_offset":number,"truncated":more}), ) } async fn tool_write_file(ctx: &ToolContext, args: &Value) -> Result { let path = args .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("write_file: missing 'path'"))?; let content = args .get("content") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("write_file: missing 'content'"))?; let file = resolve_path(ctx, path)?; if let Some(parent) = file.parent() { tokio::fs::create_dir_all(parent) .await .with_context(|| format!("write_file create_dir_all: {}", parent.display()))?; } tokio::fs::write(&file, content.as_bytes()) .await .with_context(|| format!("write_file: {}", file.display()))?; Ok(json!({ "path": file.display().to_string(), "bytes_written": content.len(), })) } fn truncate_output(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); } let mut end = max; while end > 0 && !s.is_char_boundary(end) { end -= 1; } format!( "{}…\n[truncated {} bytes]", &s[..end], s.len().saturating_sub(end) ) } fn required_text<'a>(args: &'a Value, key: &str) -> Result<&'a str> { args[key] .as_str() .filter(|s| !s.trim().is_empty()) .ok_or_else(|| anyhow!("missing nonempty '{key}'")) } fn def(name: &str, description: &str, properties: Value, required: Value) -> Value { json!({"type":"function","function":{"name":name,"description":description,"parameters":{"type":"object","properties":properties,"required":required}}}) } fn extra_tool_definitions() -> Vec { vec![ def("send_message","Your only voice. The user never sees plain assistant text. Use {\"type\":\"text\",\"content\":\"...\"} for replies, progress, and results. Use {\"type\":\"widget\",\"widget\":{\"prompt\":\"...\",\"options\":[{\"label\":\"...\",\"value\":\"...\"}]}} to ask a decision; that ends the turn. After delivering a result, respond with NO tool calls to end the turn.",json!({"type":{"type":"string","enum":["text","widget"]},"content":{"type":"string"},"widget":{"type":"object","properties":{"prompt":{"type":"string"},"options":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string"},"value":{"type":"string"}},"required":["label"]}}},"required":["prompt","options"]}}),json!(["type"])), def("report_progress","Alias of send_message text. Continues the turn.",json!({"message":{"type":"string"}}),json!(["message"])), def("update_plan","Maintain a short task checklist. At most one in_progress. After marking a step completed, send_message the finding to the user in the same beat. Explain changes to step text/order.",json!({"explanation":{"type":"string"},"plan":{"type":"array","items":{"type":"object","properties":{"step":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","completed"]}},"required":["step","status"]}}}),json!(["plan"])), def("request_user_input","Ask only for missing information you cannot discover with tools. Ends the turn; the user's next message is the answer. Prefer send_message widget for decisions.",json!({"question":{"type":"string"},"options":{"type":"array","items":{"type":"string"}},"timeout_secs":{"type":"integer"}}),json!(["question"])), def("external_exec_command","Run a command. Waits up to block_until_ms (default 30000) then backgrounds; use external_await_command/external_write_stdin to observe. Set block_until_ms to 0 to background immediately. Default kill timeout 10 minutes. No PTY.",json!({"cmd":{"type":"string"},"cwd":{"type":"string"},"block_until_ms":{"type":"integer"},"yield_time_ms":{"type":"integer"},"timeout_ms":{"type":"integer"},"max_output_bytes":{"type":"integer"}}),json!(["cmd"])), def("external_write_stdin","Read incremental command output, send input, close stdin or terminate a command. Controlled waiting is not a no-progress loop.",json!({"session_id":{"type":"string"},"chars":{"type":"string"},"close_stdin":{"type":"boolean"},"terminate":{"type":"boolean"},"block_until_ms":{"type":"integer"},"yield_time_ms":{"type":"integer"},"max_output_bytes":{"type":"integer"}}),json!(["session_id"])), def("external_await_command","Wait for more output from a background command without holding a tight poll loop. Same arguments as external_write_stdin.",json!({"session_id":{"type":"string"},"block_until_ms":{"type":"integer"},"yield_time_ms":{"type":"integer"},"max_output_bytes":{"type":"integer"},"terminate":{"type":"boolean"}}),json!(["session_id"])), def("external_grep","Search workspace file contents with a regular expression. Skips symlinks, .git, node_modules, target and output folders.",json!({"pattern":{"type":"string"},"path":{"type":"string"},"glob":{"type":"string"},"limit":{"type":"integer"}}),json!(["pattern"])), def("external_glob","Find files by name glob (e.g. **/*.rs) under a path.",json!({"pattern":{"type":"string"},"path":{"type":"string"},"limit":{"type":"integer"}}),json!(["pattern"])), def("web_search","Search public web via the configured remote service. Does not open a browser or use browser logins.",json!({"searchTerm":{"type":"string"},"explanation":{"type":"string"}}),json!(["searchTerm"])), def("web_fetch","Fetch a public URL via the configured remote web service. No browser cookies, local profile, localhost access or JavaScript browser session.",json!({"url":{"type":"string"},"max_bytes":{"type":"integer"}}),json!(["url"])), def("spawn_subagent","Start a background subagent for a self-contained chunk of work. Returns immediately with subagent_id. Do not wait or poll; keep working or end the turn — you are revived automatically when it finishes. kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot/click/move/drag/type/key/scroll/wait; only one computerUse may run at a time because they share the screen. Prefer browser_* for ordinary web; use computerUse for GUI apps, file dialogs, drag, or sites that defeat page-level automation.",json!({"goal":{"type":"string"},"title":{"type":"string"},"kind":{"type":"string","enum":["general","computerUse"],"description":"general (default) or computerUse"},"subagent_type":{"type":"string","description":"Alias of kind (Grok Bot Task subagent_type)"}}),json!(["goal"])), def("check_subagent","Inspect a running background subagent (status, elapsed time, recent tools). Omit subagent_id to list all. Not for polling completion.",json!({"subagent_id":{"type":"string"}}),json!([])), def("message_subagent","Inject an instruction into a running subagent without aborting it. It keeps its context.",json!({"subagent_id":{"type":"string"},"message":{"type":"string"}}),json!(["subagent_id","message"])), def("stop_subagent","Abort a running background subagent.",json!({"subagent_id":{"type":"string"}}),json!(["subagent_id"])), def("get_mcp_tools","Grok Bot GetMcpTools: list installed MCP servers and their tool schemas. Pass server to inspect one connector; tool_name for a single schema; pattern to search. Always read the schema before CallMcpTool.",json!({"server":{"type":"string"},"tool_name":{"type":"string"},"pattern":{"type":"string"}}),json!([])), def("call_mcp_tool","Grok Bot CallMcpTool: invoke a live MCP tool. server is the identifier from GetMcpTools, tool_name is the tool, arguments is the JSON object from its inputSchema. Prefer a connector over the browser for that service.",json!({"server":{"type":"string"},"tool_name":{"type":"string"},"arguments":{"type":"object"}}),json!(["server","tool_name"])), def("get_mcp_server_status","List configured MCP servers (connected/configured/error). Pass server for one identifier.",json!({"server":{"type":"string"}}),json!([])), def("add_mcp_server","Install an MCP server. Remote: name + url (https) + optional headers. Local stdio: name + command + args + optional env. Ask the user for secrets; do not put credentials in the URL.",json!({"name":{"type":"string"},"url":{"type":"string"},"headers":{"type":"object"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object"}}),json!(["name"])), def("remove_mcp_server","Remove an installed MCP server by name/identifier.",json!({"name":{"type":"string"}}),json!(["name"])), def("box_shell","Run a command on MY computer (the box Linux VM), not the user's. cwd is /workspace. Default block_until_ms 30000; 0 backgrounds. Prefer this for installs, experiments, and anything that should not touch the user's machine.",json!({"cmd":{"type":"string"},"block_until_ms":{"type":"integer"}}),json!(["cmd"])), def("box_read","Read a UTF-8 file on MY computer. Paths are under /workspace or /home/box.",json!({"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}}),json!(["path"])), def("box_await","Wait for a background box_shell session on MY computer.",json!({"session_id":{"type":"string"}}),json!(["session_id"])), def("copy_to_box","Copy a file from the USER's computer onto MY computer (verbatim). computer_path is an External/read_file path; default box dest is /workspace/uploads/.",json!({"computer_path":{"type":"string"},"box_path":{"type":"string"}}),json!(["computer_path"])), def("copy_from_box","Copy a file from MY computer onto the USER's computer. box_path under /workspace or /home/box.",json!({"box_path":{"type":"string"},"computer_path":{"type":"string"}}),json!(["box_path"])), def("screenshot","Read-only capture of MY computer's screen. I cannot click, type, or scroll; this is to see the desktop or check on a running computerUse subagent. Returns a PNG path on the user's machine plus the viewer URL. Delegate clicks to spawn_subagent kind=computerUse.",json!({}),json!([])), def("request_box_help","Hand MY computer to the user for a login, 2FA, captcha, or payment. Ends the turn. Do not first ask whether to hand it over. instruction is one line. Next user message is the hand-back.",json!({"instruction":{"type":"string"},"reason":{"type":"string","enum":["auth","captcha","payment","other"]}}),json!(["instruction"])), def("external_search_files","Search workspace file names or literal text (not regex). Skips symlinks, .git, node_modules, target and output folders. Bounded results include line numbers.",json!({"path":{"type":"string"},"query":{"type":"string"},"mode":{"type":"string","enum":["content","name"]},"limit":{"type":"integer"}}),json!(["query"])), def("external_edit_file","Replace a unique exact old_text match in a UTF-8 file. Fails if missing or ambiguous; read again before retrying.",json!({"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"}}),json!(["path","old_text","new_text"])), ] } fn tool_check_subagent(ctx: &ToolContext, args: &Value) -> Result { if let Some(id) = args["subagent_id"].as_str().filter(|s| !s.trim().is_empty()) { return match ctx.subagents.get_running(id) { Some(info) => Ok(json!({ "subagent_id": info.id, "title": info.title, "goal": info.goal, "kind": info.kind, "elapsed_ms": info.elapsed_ms, "tool_call_count": info.tool_call_count, "recent": info.recent, "status": "running", })), None => Ok(json!({ "error": format!("no running subagent {id}"), "running": ctx.subagents.list_running().iter().map(|i| &i.id).collect::>(), })), }; } let running = ctx.subagents.list_running(); Ok(json!({"running": running.iter().map(|i| json!({"subagent_id":i.id,"title":i.title,"kind":i.kind,"elapsed_ms":i.elapsed_ms,"tool_call_count":i.tool_call_count})).collect::>()})) } async fn tool_send_message(ctx: &ToolContext, args: &Value) -> Result { let kind = args["type"].as_str().unwrap_or("text"); if kind == "widget" { let widget = args .get("widget") .ok_or_else(|| anyhow!("widget is required when type is widget"))?; let prompt = widget["prompt"] .as_str() .filter(|s| !s.trim().is_empty()) .ok_or_else(|| anyhow!("widget.prompt is required"))?; let options = widget["options"] .as_array() .filter(|a| !a.is_empty() && a.len() <= 6) .ok_or_else(|| anyhow!("widget.options must contain 1–6 choices"))?; let labels: Vec = options .iter() .map(|o| { o["value"] .as_str() .or(o["label"].as_str()) .unwrap_or("") .to_string() }) .filter(|s| !s.trim().is_empty()) .collect(); if labels.is_empty() { return Err(anyhow!("widget options need nonempty label or value")); } ctx.runtime.deliver(prompt); let question = json!({"kind":"widget","question":prompt,"options":labels}); let parked = ctx.runtime.park_question(&question)?; return Ok(json!({"sent":true,"type":"widget","yield_turn":true,"question":parked["question"]})); } let content = args["content"] .as_str() .or(args["message"].as_str()) .filter(|s| !s.trim().is_empty()) .ok_or_else(|| anyhow!("content is required for text send_message"))?; ctx.runtime.deliver(content); Ok(json!({"sent":true,"type":"text"})) } fn glob_to_regex(pattern: &str) -> Result { let mut re = String::from("(?s)^"); let chars: Vec = pattern.chars().collect(); let mut i = 0; while i < chars.len() { match chars[i] { '*' if chars.get(i + 1) == Some(&'*') => { if chars.get(i + 2) == Some(&'/') { re.push_str("(?:.*/)?"); i += 3; } else { re.push_str(".*"); i += 2; } } '*' => { re.push_str("[^/]*"); i += 1; } '?' => { re.push_str("[^/]"); i += 1; } c if ".+()[]{}|^$\\".contains(c) => { re.push('\\'); re.push(c); i += 1; } c => { re.push(c); i += 1; } } } re.push('$'); regex::Regex::new(&re).map_err(|e| anyhow!("invalid glob: {e}")) } fn skipped_dir(name: &str) -> bool { matches!( name, ".git" | "node_modules" | "target" | ".grokboy-output" ) } async fn walk_files(root: PathBuf, limit_scan: usize) -> Result> { tokio::task::spawn_blocking(move || -> Result> { let mut stack = vec![root]; let mut files = vec![]; let mut scanned = 0; while let Some(path) = stack.pop() { scanned += 1; if scanned > limit_scan { break; } let meta = match std::fs::symlink_metadata(&path) { Ok(m) => m, Err(_) => continue, }; if meta.file_type().is_symlink() { continue; } if meta.is_dir() { let mut children = std::fs::read_dir(&path)? .filter_map(|e| e.ok()) .filter(|e| !e.file_name().to_str().is_some_and(skipped_dir)) .map(|e| e.path()) .collect::>(); children.sort(); stack.extend(children.into_iter().rev()); } else if meta.is_file() { files.push(path); } } Ok(files) }) .await? } async fn tool_glob(ctx: &ToolContext, args: &Value) -> Result { let pattern = required_text(args, "pattern")?; let re = glob_to_regex(pattern)?; let root = resolve_path(ctx, args["path"].as_str().unwrap_or("."))?; let limit = args["limit"].as_u64().unwrap_or(200).clamp(1, 1000) as usize; let files = walk_files(root.clone(), 10_000).await?; let root_norm = root.to_string_lossy().trim_end_matches('/').to_string(); let mut matches = vec![]; for path in files { let rel = path .to_string_lossy() .strip_prefix(&root_norm) .unwrap_or(path.to_string_lossy().as_ref()) .trim_start_matches('/') .to_string(); if re.is_match(&rel) || re.is_match(path.file_name().and_then(|n| n.to_str()).unwrap_or("")) { matches.push(json!({"path": path})); if matches.len() >= limit { break; } } } Ok(json!({"matches":matches,"truncated":matches.len()>=limit,"pattern":pattern})) } async fn tool_grep(ctx: &ToolContext, args: &Value) -> Result { let pattern = required_text(args, "pattern")?.to_string(); let re = regex::Regex::new(&pattern).map_err(|e| anyhow!("invalid regex: {e}"))?; let root = resolve_path(ctx, args["path"].as_str().unwrap_or("."))?; let limit = args["limit"].as_u64().unwrap_or(50).clamp(1, 200) as usize; let name_glob = args["glob"].as_str().map(glob_to_regex).transpose()?; let files = walk_files(root, 10_000).await?; let mut matches = vec![]; let mut truncated = false; for path in files { if let Some(g) = &name_glob { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if !g.is_match(name) { continue; } } let meta = match std::fs::metadata(&path) { Ok(m) => m, Err(_) => continue, }; if meta.len() > 2 * 1024 * 1024 { continue; } let Ok(text) = std::fs::read_to_string(&path) else { continue; }; for (i, line) in text.lines().enumerate() { if re.is_match(line) { matches.push(json!({"path":path,"line":i+1,"text":line.chars().take(500).collect::()})); if matches.len() >= limit { truncated = true; break; } } } if truncated { break; } } Ok(json!({"matches":matches,"truncated":truncated,"pattern":pattern})) } async fn search_files(ctx: &ToolContext, args: &Value) -> Result { let query = required_text(args, "query")?.to_string(); let root = resolve_path(ctx, args["path"].as_str().unwrap_or("."))?; let limit = args["limit"].as_u64().unwrap_or(50).clamp(1, 200) as usize; let names = args["mode"].as_str() == Some("name"); tokio::task::spawn_blocking(move || -> Result { let mut stack=vec![root]; let mut results=vec![]; let mut scanned=0; let mut truncated=false; while let Some(path)=stack.pop() { scanned+=1; if scanned>10000 || results.len()>=limit { truncated=true; break; } let meta=std::fs::symlink_metadata(&path)?; if meta.file_type().is_symlink() { continue; } if meta.is_dir() { let mut children=std::fs::read_dir(path)?.filter_map(|e|e.ok()).filter(|e|!matches!(e.file_name().to_str(),Some(".git"|"node_modules"|"target"|".grokboy-output"))).map(|e|e.path()).collect::>(); children.sort(); stack.extend(children.into_iter().rev()); } else if meta.is_file() { if names { if path.file_name().unwrap_or_default().to_string_lossy().contains(&query) { results.push(json!({"path":path})); } } else if meta.len()<=2*1024*1024 { if let Ok(text)=std::fs::read_to_string(&path) { for (i,line) in text.lines().enumerate() { if line.contains(&query) { results.push(json!({"path":path,"line":i+1,"text":line.chars().take(500).collect::()})); if results.len()>=limit { truncated=true; break; } } } } } } } Ok(json!({"matches":results,"truncated":truncated,"scanned":scanned})) }).await? } async fn edit_file(ctx: &ToolContext, args: &Value) -> Result { let path = std::fs::canonicalize(resolve_path(ctx, required_text(args, "path")?)?)?; let old = required_text(args, "old_text")?; let new = args["new_text"] .as_str() .ok_or_else(|| anyhow!("missing new_text"))?; if tokio::fs::metadata(&path).await?.len() > 8 * 1024 * 1024 { return Err(anyhow!("edit_file supports files up to 8MB")); } let content = tokio::fs::read_to_string(&path).await?; let first = content.find(old); let ambiguous = first.is_some_and(|i| { content[i + content[i..].chars().next().unwrap().len_utf8()..].contains(old) }); if first.is_none() || ambiguous { return Err(anyhow!("old_text must match exactly once; no file changed")); } let updated = content.replacen(old, new, 1); let temp = path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4())); // No await between temp creation and rename: cancellation cannot leave a half-written target. std::fs::write(&temp, &updated)?; std::fs::set_permissions(&temp, std::fs::metadata(&path)?.permissions())?; std::fs::rename(&temp, &path)?; Ok(json!({"path":path,"replacements":1,"bytes_written":updated.len()})) } async fn human_confirm(ctx: &ToolContext, args: &Value) -> Result { required_text(args, "reason")?; if let Some(auto) = confirm::confirm_auto_from_env() { return Ok( json!({"approved":matches!(auto,confirm::ConfirmWait::Approved),"status":if matches!(auto,confirm::ConfirmWait::Approved){"approved"}else{"denied"}}), ); } let mut prompt = args.clone(); prompt["kind"] = json!("confirm"); prompt["question"] = json!(format!( "{}\n輸入 yes/y 或 Enter 核准;no/abort 拒絕。", args["reason"].as_str().unwrap() )); prompt["options"] = json!(["yes", "no"]); ctx.runtime.park_question(&prompt) } async fn browser_tool(ctx: &ToolContext, name: &str, args: &Value) -> Result { if name == "browser_release" { ctx.browser.close().await; if let Some(team) = &ctx.team { team.held_browser.lock().await.take(); } return Ok(json!({"released":true,"login_state":"persistent profile retained"})); } if let Some(team) = &ctx.team { let mut held = team.held_browser.lock().await; if held.is_none() { let service = team.service()?; let task = service.store.task( team.task .as_deref() .ok_or_else(|| anyhow!("delegate browser work"))?, )?; let guard = match service.browser_lock(if crate::browser_client::local_browser_enabled() { &task.owner_id } else { "box-shared-browser" }).try_lock_owned() { Ok(guard) => guard, Err(_) => { return Ok( json!({"error":"browser_busy","instruction":"Another task owns this owner's browser, possibly during human handoff. Do independent work or ask it to browser_release. Do not retry repeatedly or interpret this as logged out."}), ) } }; let profile = service .browser_profile(&task.owner_id, &crate::team::data_dir().join("profiles"))?; *ctx.runtime.browser_profile.lock().unwrap() = Some(profile); *held = Some(guard); } } let op = name.strip_prefix("browser_").unwrap_or(name); let mut req = args.clone(); req["op"] = json!(if op == "dom" { "snapshot" } else { op }); if op == "upload" && crate::browser_client::local_browser_enabled() { req["path"] = json!(std::fs::canonicalize(resolve_path( ctx, required_text(args, "path")? )?)?); } if op == "download" && crate::browser_client::local_browser_enabled() { let path = resolve_path(ctx, required_text(args, "path")?)?; if path.exists() { return Err(anyhow!("download target already exists")); } if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } req["path"] = json!(std::fs::canonicalize(path.parent().unwrap())?.join(path.file_name().unwrap())); } if crate::browser_client::local_browser_enabled() && !ctx.browser.is_started().await && !matches!(op, "navigate" | "type") { if let Some(url) = ctx.last_browser_url_value() { let restored = ctx .browser .request( &ctx.cwd, ctx.runtime.profile_dir(), json!({"op":"navigate","url":url}), ) .await?; if restored["ok"] != true { return Ok(browser::response_to_tool_json(restored)); } } } let mut handoff_answer = None; if op == "handoff" { required_text(args, "reason")?; let prep = ctx .browser .request( &ctx.cwd, ctx.runtime.profile_dir(), json!({"op":"handoff_prepare"}), ) .await?; if prep["ok"] != true { return Ok(browser::response_to_tool_json(prep)); } browser::update_last_url(&ctx.last_browser_url, &prep); *ctx.runtime.browser_url.lock().unwrap() = ctx.last_browser_url_value(); let mut options = vec!["我已完成登入,請檢查頁面後繼續".to_string()]; options.extend(recovery_options( args, &["登入仍有問題,先做不需要登入的部分"], )?); options.push("停止這份工作".into()); let auto = std::env::var("GROKBOY_HANDOFF_AUTO").ok(); let answer: Result = match auto.as_deref() { Some("1" | "true" | "resume" | "continue" | "yes") => Ok(json!({"answer":""})), Some("abort" | "0" | "false" | "no") => Ok(json!({"answer":"abort"})), _ => { let mut question = args.clone(); question["kind"] = json!("handoff"); question["handoff_options"] = json!(recovery_options( args, &["登入仍有問題,先做不需要登入的部分"] )?); question["question"] = json!(format!( "{}\n請在 {} 操作;完成後回覆,將繼續使用同一份登入狀態。", args["reason"].as_str().unwrap(), if crate::browser_client::local_browser_enabled() { "本地工具瀏覽器".to_string() } else { BoxHub::viewer_url() } )); question["options"] = json!(options.clone()); return ctx.runtime.park_question(&question); } }; let answer = answer?; if matches!( answer["answer"].as_str(), Some("abort" | "cancel" | "no" | "停止這份工作") ) { return Ok(json!({"blocked":true,"handoff":"aborted","user_stopped":true})); } let selection = answer["answer"].as_str().unwrap_or(""); if options.iter().skip(1).any(|option| option == selection) { return Ok( json!({"handoff":"deferred","status":"replan","answer":selection,"url":ctx.last_browser_url_value(),"instruction":"Keep this task and browser session. Follow the chosen alternative and update the plan; login has NOT been verified."}), ); } handoff_answer = Some(selection.to_owned()); req = json!({"op":"snapshot"}); } let mut result = ctx .browser .request(&ctx.cwd, ctx.runtime.profile_dir(), req) .await?; result["surface"] = json!(if crate::browser_client::local_browser_enabled() { "local_browser" } else { "box_browser" }); if !crate::browser_client::local_browser_enabled() { result["profile"] = json!("/home/box/chrome-profile"); result["viewer_url"] = json!(BoxHub::viewer_url()); } browser::update_last_url(&ctx.last_browser_url, &result); *ctx.runtime.browser_url.lock().unwrap() = ctx.last_browser_url_value(); if op == "handoff" && result["ok"] == true { result["handoff"] = json!("resumed"); result["answer"] = json!(handoff_answer); result["login_verified"] = json!(false); result["instruction"] = json!("Human replied; follow their actual answer (including requests to stop or change direction). Inspect this fresh snapshot to determine whether login actually succeeded; if still blocked, offer alternatives instead of repeating the same attempts."); } Ok(browser::response_to_tool_json(result)) } #[cfg(test)] mod tests { use super::*; fn temp_ctx() -> (ToolContext, PathBuf) { let stamp = uuid::Uuid::new_v4(); let dir = std::env::temp_dir().join(format!("grokboy-tools-{stamp}")); std::fs::create_dir_all(&dir).unwrap(); (ToolContext::new(dir.clone()), dir) } #[tokio::test] #[ignore = "requires the local Docker box; writes only a unique disposable fixture"] async fn default_shell_and_read_use_box_external_tools_use_local() { let (ctx, dir) = temp_ctx(); let name = format!("surface-fixture-{}.txt", uuid::Uuid::new_v4()); let remote = format!("/workspace/{name}"); let result: Value = serde_json::from_str(&execute_tool(&ctx, "shell", &json!({ "cmd":format!("sleep 2; printf box > {remote}"), "block_until_ms":1 }).to_string()).await).unwrap(); assert_eq!(result["running"], true, "{result}"); assert_eq!(result["surface"], "box"); assert!(!dir.join(&name).exists()); let completed: Value = serde_json::from_str(&execute_tool(&ctx, "await_shell", &json!({"session_id":result["session_id"]}).to_string()).await).unwrap(); assert_eq!(completed["exit_code"], 0, "{completed}"); let read: Value = serde_json::from_str(&execute_tool(&ctx, "read", &json!({"path":remote}).to_string()).await).unwrap(); assert_eq!(read["content"], "box"); let local: Value = serde_json::from_str(&execute_tool(&ctx, "external_write_file", &json!({"path":name,"content":"local"}).to_string()).await).unwrap(); assert!(local.get("error").is_none(), "{local}"); assert_eq!(std::fs::read_to_string(dir.join(&name)).unwrap(), "local"); let ambiguous: Value = serde_json::from_str(&execute_tool(&ctx, "write_file", &json!({"path":"unexpected.txt","content":"bad"}).to_string()).await).unwrap(); assert!(ambiguous.get("error").is_some()); assert!(!dir.join("unexpected.txt").exists()); // A closed desktop browser must not block shell/read, and can be reopened. ctx.box_hub.ensure_browser_ready().await.unwrap(); execute_tool(&ctx, "shell", &json!({"cmd":format!("rm -- {remote}")}).to_string()).await; std::fs::remove_dir_all(dir).unwrap(); } #[tokio::test] async fn file_segments_search_and_unique_edits() { let (ctx, dir) = temp_ctx(); std::fs::write(dir.join("notes.txt"), "alpha\nbeta\ngamma\n").unwrap(); let read = execute_tool( &ctx, "external_read_file", &json!({"path":"notes.txt","offset":1,"limit":1}).to_string(), ) .await; let read: Value = serde_json::from_str(&read).unwrap(); assert_eq!(read["content"], "beta\n", "{read}"); assert_eq!(read["next_offset"], 2); assert_eq!(read["truncated"], true); std::fs::create_dir_all(dir.join("node_modules")).unwrap(); std::fs::write(dir.join("node_modules/noise.txt"), "beta").unwrap(); let found: Value = serde_json::from_str(&execute_tool(&ctx, "external_search_files", r#"{"query":"beta"}"#).await) .unwrap(); assert_eq!(found["matches"].as_array().unwrap().len(), 1); assert_eq!(found["matches"][0]["line"], 2); let edited: Value = serde_json::from_str( &execute_tool( &ctx, "external_edit_file", r#"{"path":"notes.txt","old_text":"beta","new_text":"BETA"}"#, ) .await, ) .unwrap(); assert_eq!(edited["replacements"], 1); assert_eq!( std::fs::read_to_string(dir.join("notes.txt")).unwrap(), "alpha\nBETA\ngamma\n" ); std::fs::write(dir.join("notes.txt"), "aaa").unwrap(); let denied: Value = serde_json::from_str( &execute_tool( &ctx, "external_edit_file", r#"{"path":"notes.txt","old_text":"aa","new_text":"x"}"#, ) .await, ) .unwrap(); assert!(denied.get("error").is_some()); assert_eq!( std::fs::read_to_string(dir.join("notes.txt")).unwrap(), "aaa" ); std::fs::remove_dir_all(dir).unwrap(); } #[cfg(unix)] #[tokio::test] async fn file_tools_reject_symlink_escape() { let (ctx, dir) = temp_ctx(); let outside = std::env::temp_dir().join(format!("grokboy-outside-{}", uuid::Uuid::new_v4())); std::fs::write(&outside, "private").unwrap(); std::os::unix::fs::symlink(&outside, dir.join("link")).unwrap(); for name in ["external_read_file", "external_write_file", "external_edit_file"] { let out: Value = serde_json::from_str( &execute_tool( &ctx, name, &json!({"path":"link","content":"bad","old_text":"private","new_text":"bad"}) .to_string(), ) .await, ) .unwrap(); assert!(out.get("error").is_some(), "{out}"); } assert_eq!(std::fs::read_to_string(&outside).unwrap(), "private"); std::fs::remove_file(outside).unwrap(); std::fs::remove_dir_all(dir).unwrap(); } #[tokio::test] async fn write_read_list_shell() { let (ctx, dir) = temp_ctx(); let w = execute_tool( &ctx, "external_write_file", &json!({"path": "hello.txt", "content": "你好 GrokBoy"}).to_string(), ) .await; let w: Value = serde_json::from_str(&w).unwrap(); assert!(w.get("error").is_none(), "{w}"); assert_eq!(w["bytes_written"], "你好 GrokBoy".len()); let r = execute_tool(&ctx, "external_read_file", &json!({"path": "hello.txt"}).to_string()).await; let r: Value = serde_json::from_str(&r).unwrap(); assert_eq!(r["content"], "你好 GrokBoy"); let l = execute_tool(&ctx, "external_list_dir", &json!({"path": "."}).to_string()).await; let l: Value = serde_json::from_str(&l).unwrap(); let names: Vec<&str> = l["entries"] .as_array() .unwrap() .iter() .map(|e| e["name"].as_str().unwrap()) .collect(); assert!(names.contains(&"hello.txt")); let s = execute_tool( &ctx, "external_shell", &json!({"command": "echo hi && ls hello.txt"}).to_string(), ) .await; let s: Value = serde_json::from_str(&s).unwrap(); assert_eq!(s["exit_code"], 0); assert!(s["stdout"].as_str().unwrap().contains("hi")); let _ = std::fs::remove_dir_all(&dir); } #[tokio::test] async fn rejects_path_traversal() { let (ctx, dir) = temp_ctx(); let out = execute_tool( &ctx, "external_read_file", &json!({"path": "../outside.txt"}).to_string(), ) .await; let v: Value = serde_json::from_str(&out).unwrap(); assert!(v.get("error").is_some(), "{v}"); let _ = std::fs::remove_dir_all(&dir); } #[tokio::test] async fn report_done_and_blocked() { let (ctx, dir) = temp_ctx(); let done = execute_tool( &ctx, "report_done", &json!({"message": "all good"}).to_string(), ) .await; let done: Value = serde_json::from_str(&done).unwrap(); assert_eq!(done["status"], "done"); assert_eq!(done["message"], "all good"); let blocked = execute_tool( &ctx, "report_blocked", &json!({"reason": "no access"}).to_string(), ) .await; let blocked: Value = serde_json::from_str(&blocked).unwrap(); assert_eq!(blocked["status"], "blocked"); assert_eq!(blocked["reason"], "no access"); assert!(is_completion_tool("report_done")); assert!(is_completion_tool("report_blocked")); assert!(!is_completion_tool("external_shell")); let _ = std::fs::remove_dir_all(&dir); } #[test] fn tool_defs_include_core_and_browser() { let defs = tool_definitions(); let arr = defs.as_array().unwrap(); assert_eq!(arr.len(), 51); let names: Vec<&str> = arr .iter() .map(|t| t["function"]["name"].as_str().unwrap()) .collect(); assert!(names.contains(&"external_shell")); assert!(names.contains(&"external_list_dir")); assert!(names.contains(&"external_read_file")); assert!(names.contains(&"external_write_file")); assert!(names.contains(&"send_message")); assert!(names.contains(&"external_grep")); assert!(names.contains(&"external_glob")); assert!(names.contains(&"web_fetch")); assert!(names.contains(&"web_search")); assert!(names.contains(&"external_await_command")); assert!(names.contains(&"spawn_subagent")); assert!(names.contains(&"check_subagent")); assert!(names.contains(&"message_subagent")); assert!(names.contains(&"stop_subagent")); assert!(names.contains(&"get_mcp_tools")); assert!(names.contains(&"call_mcp_tool")); assert!(names.contains(&"add_mcp_server")); assert!(names.contains(&"shell")); assert!(names.contains(&"read")); assert!(names.contains(&"await_shell")); assert!(!names.contains(&"exec_command")); assert!(!names.contains(&"read_file")); assert!(names.contains(&"copy_to_box")); assert!(names.contains(&"screenshot")); assert!(!names.contains(&"computer")); assert!(names.contains(&"request_box_help")); assert!(is_completion_tool("request_box_help")); assert!(names.contains(&"report_done")); assert!(names.contains(&"report_blocked")); assert!(names.contains(&"request_user_confirm")); assert!(names.contains(&"browser_navigate")); assert!(names.contains(&"browser_snapshot")); assert!(names.contains(&"browser_click")); assert!(names.contains(&"browser_type")); assert!(names.contains(&"browser_eval")); assert!(names.contains(&"browser_handoff")); let mut child = ToolContext::new(std::env::temp_dir()); child.computer_use = true; let child_defs = tool_definitions_for(&child); let child_names: Vec<&str> = child_defs .as_array() .unwrap() .iter() .map(|t| t["function"]["name"].as_str().unwrap()) .collect(); assert!(child_names.contains(&"computer")); assert_eq!(child_names.len(), arr.len() + 1); } #[tokio::test] async fn parent_cannot_call_computer() { let (ctx, dir) = temp_ctx(); let out = execute_tool( &ctx, "computer", &json!({"action":"click","x":10,"y":10}).to_string(), ) .await; let v: Value = serde_json::from_str(&out).unwrap(); let err = v["error"].as_str().unwrap_or(&out); assert!(err.contains("computerUse"), "{err}"); assert!(err.contains("screenshot"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } #[tokio::test] async fn computer_use_child_rejects_click_outside_desktop_without_docker_if_unflagged() { // Bounds are enforced once the box reports geometry. Structural errors fail closed // before Docker: a half-specified click never reaches xdotool. let (mut ctx, dir) = temp_ctx(); ctx.computer_use = true; let out = execute_tool(&ctx, "computer", &json!({"action":"click","x":10}).to_string()).await; let v: Value = serde_json::from_str(&out).unwrap(); let err = v["error"].as_str().unwrap_or(&out); assert!(err.contains("both x and y"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } #[tokio::test] #[ignore = "requires the local Docker box; drives the shared desktop"] async fn computer_use_click_and_screenshot_on_box() { let (mut ctx, dir) = temp_ctx(); ctx.computer_use = true; let out = execute_tool( &ctx, "computer", &json!({"action":"click","x":640,"y":360,"description":"desktop center"}).to_string(), ) .await; let v: Value = serde_json::from_str(&out).unwrap(); assert!(v.get("error").is_none(), "{v}"); let path = std::path::PathBuf::from(v["path"].as_str().unwrap()); assert!(path.is_file(), "{v}"); assert!(std::fs::metadata(&path).unwrap().len() > 100); assert_eq!(v["screen"]["width"], 1280); let _ = std::fs::remove_dir_all(&dir); } #[tokio::test] async fn grep_glob_and_send_message() { let dir = std::env::temp_dir().join(format!("grokboy-grep-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("a.rs"), "fn hello() {}\nfn world() {}\n").unwrap(); std::fs::write(dir.join("b.txt"), "hello text\n").unwrap(); let ctx = ToolContext::new(&dir); let globbed: Value = serde_json::from_str( &execute_tool(&ctx, "external_glob", &json!({"pattern":"*.rs"}).to_string()).await, ) .unwrap(); assert_eq!(globbed["matches"].as_array().unwrap().len(), 1); let grepped: Value = serde_json::from_str( &execute_tool( &ctx, "external_grep", &json!({"pattern":"fn \\w+","glob":"*.rs"}).to_string(), ) .await, ) .unwrap(); assert_eq!(grepped["matches"].as_array().unwrap().len(), 2); let sent: Value = serde_json::from_str( &execute_tool( &ctx, "send_message", &json!({"type":"text","content":"hi there"}).to_string(), ) .await, ) .unwrap(); assert_eq!(sent["sent"], true); assert_eq!(ctx.runtime.last_delivered().as_deref(), Some("hi there")); let _ = std::fs::remove_dir_all(dir); } #[tokio::test] async fn browser_handoff_auto_resume_protocol() { let _env_lock = crate::test_env::lock_async().await; let surface = std::env::var("GROKBOY_BROWSER_SURFACE").ok(); std::env::set_var("GROKBOY_BROWSER_SURFACE", "local"); // Offline: with GROKBOY_HANDOFF_AUTO=1, missing Chromium still fail-closes // OR (if Chromium present) resumes and returns snapshot/error JSON — never hangs. let prev = std::env::var("GROKBOY_HANDOFF_AUTO").ok(); unsafe { std::env::set_var("GROKBOY_HANDOFF_AUTO", "1") }; let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let root = root.canonicalize().unwrap_or(root); let ctx = ToolContext::new(root); let out = execute_tool( &ctx, "browser_handoff", &json!({"reason": "unit test OTP wall", "timeout_secs": 5}).to_string(), ) .await; let v: Value = serde_json::from_str(&out).unwrap(); // Either resumed snapshot, or fail-closed blocked/error (no Chromium) — must not panic. let okish = v.get("handoff").and_then(|h| h.as_str()) == Some("resumed") || v.get("error").is_some() || v.get("blocked") == Some(&json!(true)); assert!(okish, "unexpected handoff result: {v}"); // missing reason fails closed let bad = execute_tool(&ctx, "browser_handoff", &json!({}).to_string()).await; let bad: Value = serde_json::from_str(&bad).unwrap(); assert!(bad.get("error").is_some(), "{bad}"); if let Some(value) = surface { std::env::set_var("GROKBOY_BROWSER_SURFACE", value); } else { std::env::remove_var("GROKBOY_BROWSER_SURFACE"); } match prev { Some(v) => unsafe { std::env::set_var("GROKBOY_HANDOFF_AUTO", v) }, None => unsafe { std::env::remove_var("GROKBOY_HANDOFF_AUTO") }, } } #[tokio::test] async fn request_user_confirm_auto_approve_and_deny() { let _env_lock = crate::test_env::lock_async().await; let prev_c = std::env::var("GROKBOY_CONFIRM_AUTO").ok(); let prev_h = std::env::var("GROKBOY_HANDOFF_AUTO").ok(); unsafe { std::env::remove_var("GROKBOY_HANDOFF_AUTO"); std::env::set_var("GROKBOY_CONFIRM_AUTO", "1"); } let (ctx, dir) = temp_ctx(); let out = execute_tool( &ctx, "request_user_confirm", &json!({ "reason": "publish example post", "prompt": "草稿內容", "timeout_secs": 2 }) .to_string(), ) .await; let v: Value = serde_json::from_str(&out).unwrap(); assert_eq!(v["status"], "approved"); assert_eq!(v["approved"], true); unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", "abort") }; let out2 = execute_tool( &ctx, "request_user_confirm", &json!({"reason": "publish example post", "timeout_secs": 2}).to_string(), ) .await; let v2: Value = serde_json::from_str(&out2).unwrap(); assert_eq!(v2["status"], "denied"); assert_eq!(v2["approved"], false); let bad = execute_tool(&ctx, "request_user_confirm", &json!({}).to_string()).await; let bad: Value = serde_json::from_str(&bad).unwrap(); assert!(bad.get("error").is_some(), "{bad}"); match prev_c { Some(v) => unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", v) }, None => unsafe { std::env::remove_var("GROKBOY_CONFIRM_AUTO") }, } match prev_h { Some(v) => unsafe { std::env::set_var("GROKBOY_HANDOFF_AUTO", v) }, None => unsafe { std::env::remove_var("GROKBOY_HANDOFF_AUTO") }, } let _ = std::fs::remove_dir_all(&dir); } #[tokio::test] async fn browser_tool_fails_closed_without_chromium_ok_with_helper() { let _env_lock = crate::test_env::lock_async().await; let surface = std::env::var("GROKBOY_BROWSER_SURFACE").ok(); std::env::set_var("GROKBOY_BROWSER_SURFACE", "local"); // Missing required args should fail closed via helper protocol (no Chromium needed). let (ctx, dir) = temp_ctx(); // Point cwd at repo root so helper is found when running under cargo test. let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let root = root.canonicalize().unwrap_or(root); let ctx = ToolContext { cwd: root.clone(), ..ctx }; let out = execute_tool(&ctx, "browser_type", &json!({"selector": "#x"}).to_string()).await; let v: Value = serde_json::from_str(&out).unwrap(); assert!(v.get("error").is_some(), "{v}"); if let Some(value) = surface { std::env::set_var("GROKBOY_BROWSER_SURFACE", value); } else { std::env::remove_var("GROKBOY_BROWSER_SURFACE"); } let _ = std::fs::remove_dir_all(&dir); } }