LazyBoy2/crates/grokboy-core/src/tools.rs

1811 lines
79 KiB
Rust
Raw Normal View History

//! Built-in tools: shell, files, completion, Playwright browser (P3), human handoff (P4),
//! confirm-before-post (P6).
2026-09-13 16:38:32 +00:00
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Value};
2026-09-14 09:08:35 +00:00
use std::future::Future;
use std::path::{Component, Path, PathBuf};
2026-09-14 09:08:35 +00:00
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use crate::browser::{self, LastUrlSlot};
use crate::confirm;
2026-09-14 09:08:35 +00:00
use crate::box_runtime::BoxHub;
use crate::mcp::McpHub;
use crate::model::ChatMessage;
use crate::subagents::Subagents;
pub type SharedCompleter = Arc<
dyn Fn(
Vec<ChatMessage>,
Option<Value>,
) -> Pin<Box<dyn Future<Output = Result<ChatMessage>> + Send>>
+ Send
+ Sync,
>;
pub const MAX_READ_BYTES: usize = 256 * 1024;
pub const SHELL_TIMEOUT_SECS: u64 = 30;
/// Runtime context for tool execution.
2026-09-14 09:08:35 +00:00
#[derive(Clone)]
pub struct ToolContext {
/// Default working directory for relative paths / shell.
pub cwd: PathBuf,
2026-09-13 16:38:32 +00:00
pub(crate) team: Option<std::sync::Arc<crate::team::TeamContext>>,
/// Optional workspace root; paths outside it are rejected when set.
pub workspace_root: Option<PathBuf>,
/// Last navigated browser URL (shared across clones).
pub last_browser_url: LastUrlSlot,
2026-09-13 16:38:32 +00:00
pub runtime: std::sync::Arc<crate::runtime::Runtime>,
pub(crate) jobs: std::sync::Arc<crate::jobs::Jobs>,
pub(crate) browser: std::sync::Arc<crate::browser_client::BrowserClient>,
2026-09-14 09:08:35 +00:00
pub(crate) model: Arc<Mutex<Option<SharedCompleter>>>,
pub(crate) subagents: Arc<Subagents>,
pub(crate) mcp: Arc<McpHub>,
pub(crate) box_hub: Arc<BoxHub>,
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<PathBuf>) -> Self {
let cwd = cwd.into();
Self {
cwd: cwd.clone(),
2026-09-13 16:38:32 +00:00
team: None,
2026-09-14 09:08:35 +00:00
workspace_root: Some(cwd.clone()),
last_browser_url: std::sync::Arc::new(std::sync::Mutex::new(None)),
2026-09-13 16:38:32 +00:00
runtime: std::sync::Arc::new(crate::runtime::Runtime::default()),
jobs: Default::default(),
browser: Default::default(),
2026-09-14 09:08:35 +00:00
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;
}
}
}
2026-09-13 16:38:32 +00:00
}
}
pub fn with_runtime(mut self, runtime: std::sync::Arc<crate::runtime::Runtime>) -> Self {
self.runtime = runtime;
self
}
pub async fn shutdown(&self) {
self.jobs.cancel().await;
2026-09-14 09:08:35 +00:00
self.subagents.cancel_all().await;
2026-09-13 16:38:32 +00:00
self.browser.close().await;
if let Some(team) = &self.team {
team.held_browser.lock().await.take();
}
}
pub fn with_workspace(mut self, root: Option<PathBuf>) -> Self {
self.workspace_root = root;
self
}
pub fn last_browser_url_value(&self) -> Option<String> {
self.last_browser_url.lock().ok().and_then(|g| g.clone())
}
}
/// OpenAI-compatible tool definitions for chat completions.
pub fn tool_definitions() -> Value {
2026-09-14 09:08:35 +00:00
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": {
2026-09-14 09:08:35 +00:00
"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": {
2026-09-14 09:08:35 +00:00
"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": {
2026-09-14 09:08:35 +00:00
"name": "external_read_file",
2026-09-13 16:38:32 +00:00
"description": "Read text in line segments (max 256KB per response). offset is zero-based; use next_offset when truncated.",
"parameters": {
"type": "object",
"properties": {
2026-09-13 16:38:32 +00:00
"path": { "type": "string", "description": "File path" },
"offset": {"type":"integer","minimum":0},
"limit": {"type":"integer","minimum":1}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
2026-09-14 09:08:35 +00:00
"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",
2026-09-13 16:38:32 +00:00
"description": "Explain a blocker and offer 23 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": {
2026-09-13 16:38:32 +00:00
"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());
2026-09-13 16:38:32 +00:00
arr.extend(extra_tool_definitions());
2026-09-14 09:08:35 +00:00
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
}
2026-09-14 09:08:35 +00:00
/// Tools that must be called alone (human wait / hard stop). `send_message` may mix.
pub fn is_completion_tool(name: &str) -> bool {
2026-09-14 09:08:35 +00:00
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<PathBuf> {
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
));
}
}
2026-09-13 16:38:32 +00:00
// 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 {
2026-09-13 16:38:32 +00:00
match execute_tool_guarded(ctx, name, arguments_json).await {
Ok(v) => v.to_string(),
Err(e) => json!({ "error": format!("{e:#}") }).to_string(),
}
}
2026-09-13 16:38:32 +00:00
async fn execute_tool_guarded(ctx: &ToolContext, name: &str, arguments: &str) -> Result<Value> {
2026-09-14 09:08:35 +00:00
// 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,
};
2026-09-13 16:38:32 +00:00
if let Some(team) = &ctx.team {
let args: Value = serde_json::from_str(arguments)?;
2026-09-14 09:08:35 +00:00
// `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 {
2026-09-13 16:38:32 +00:00
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"
2026-09-14 09:08:35 +00:00
| "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"
2026-09-13 16:38:32 +00:00
| "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,
2026-09-14 09:08:35 +00:00
"request_user_input" | "request_user_confirm" | "browser_handoff" | "request_box_help"
2026-09-13 16:38:32 +00:00
) && !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,
2026-09-14 09:08:35 +00:00
"request_user_input" | "request_user_confirm" | "browser_handoff" | "request_box_help"
2026-09-13 16:38:32 +00:00
) && 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<Value> {
let args: Value = serde_json::from_str(arguments_json)
.with_context(|| format!("invalid tool arguments JSON for {name}"))?;
match name {
2026-09-14 09:08:35 +00:00
"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" => {
2026-09-13 16:38:32 +00:00
let cwd = resolve_path(ctx, args["cwd"].as_str().unwrap_or("."))?;
ctx.jobs.exec(&cwd, &args).await
}
2026-09-14 09:08:35 +00:00
"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"],
}))
}
2026-09-13 16:38:32 +00:00
"report_progress" => {
let message = required_text(&args, "message")?;
2026-09-14 09:08:35 +00:00
ctx.runtime.deliver(message);
2026-09-13 16:38:32 +00:00
ctx.runtime.emit(crate::AgentEvent::Progress {
message: message.into(),
});
2026-09-14 09:08:35 +00:00
Ok(json!({"emitted":true,"sent":true}))
2026-09-13 16:38:32 +00:00
}
"update_plan" => ctx.runtime.update_plan(&args),
"request_user_input" => {
required_text(&args, "question")?;
2026-09-14 09:08:35 +00:00
let mut question = args.clone();
if question.get("kind").is_none() {
question["kind"] = json!("input");
}
ctx.runtime.park_question(&question)
2026-09-13 16:38:32 +00:00
}
2026-09-14 09:08:35 +00:00
"external_write_file" => tool_write_file(ctx, &args).await,
2026-09-13 16:38:32 +00:00
"report_done" => {
2026-09-14 09:08:35 +00:00
let message = required_text(&args, "message")?;
ctx.runtime.deliver(message);
2026-09-13 16:38:32 +00:00
tool_report_done(&args)
}
2026-09-13 16:38:32 +00:00
"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<Value> {
let message = args
.get("message")
.and_then(|v| v.as_str())
2026-09-13 16:38:32 +00:00
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| anyhow!("report_done: missing 'message'"))?;
Ok(json!({
"status": "done",
"message": message,
}))
}
2026-09-13 16:38:32 +00:00
async fn recovery_choice(ctx: &ToolContext, args: &Value) -> Result<Value> {
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});
2026-09-14 09:08:35 +00:00
let mut parked = ctx.runtime.park_question(&question)?;
parked["status"] = json!("blocked");
parked["reason"] = blocked["reason"].clone();
Ok(parked)
2026-09-13 16:38:32 +00:00
}
fn recovery_options(args: &Value, defaults: &[&str]) -> Result<Vec<String>> {
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 13 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<Value> {
let reason = args
.get("reason")
.and_then(|v| v.as_str())
2026-09-13 16:38:32 +00:00
.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<Value> {
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()));
}
2026-09-13 16:38:32 +00:00
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(""));
}
2026-09-13 16:38:32 +00:00
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<Value> {
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<Value> {
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)?;
2026-09-13 16:38:32 +00:00
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;
}
2026-09-13 16:38:32 +00:00
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<Value> {
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)
)
}
2026-09-13 16:38:32 +00:00
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<Value> {
vec![
2026-09-14 09:08:35 +00:00
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/<name>.",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"])),
2026-09-13 16:38:32 +00:00
]
}
2026-09-14 09:08:35 +00:00
fn tool_check_subagent(ctx: &ToolContext, args: &Value) -> Result<Value> {
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::<Vec<_>>(),
})),
};
}
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::<Vec<_>>()}))
}
async fn tool_send_message(ctx: &ToolContext, args: &Value) -> Result<Value> {
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 16 choices"))?;
let labels: Vec<String> = 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<regex::Regex> {
let mut re = String::from("(?s)^");
let chars: Vec<char> = 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<Vec<PathBuf>> {
tokio::task::spawn_blocking(move || -> Result<Vec<PathBuf>> {
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::<Vec<_>>();
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<Value> {
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<Value> {
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::<String>()}));
if matches.len() >= limit {
truncated = true;
break;
}
}
}
if truncated {
break;
}
}
Ok(json!({"matches":matches,"truncated":truncated,"pattern":pattern}))
}
2026-09-13 16:38:32 +00:00
async fn search_files(ctx: &ToolContext, args: &Value) -> Result<Value> {
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<Value> {
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::<Vec<_>>();
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::<String>()})); 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<Value> {
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<Value> {
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()
));
2026-09-14 09:08:35 +00:00
prompt["options"] = json!(["yes", "no"]);
ctx.runtime.park_question(&prompt)
2026-09-13 16:38:32 +00:00
}
async fn browser_tool(ctx: &ToolContext, name: &str, args: &Value) -> Result<Value> {
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"))?,
)?;
2026-09-14 09:08:35 +00:00
let guard = match service.browser_lock(if crate::browser_client::local_browser_enabled() { &task.owner_id } else { "box-shared-browser" }).try_lock_owned() {
2026-09-13 16:38:32 +00:00
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 });
2026-09-14 09:08:35 +00:00
if op == "upload" && crate::browser_client::local_browser_enabled() {
2026-09-13 16:38:32 +00:00
req["path"] = json!(std::fs::canonicalize(resolve_path(
ctx,
required_text(args, "path")?
)?)?);
}
2026-09-14 09:08:35 +00:00
if op == "download" && crate::browser_client::local_browser_enabled() {
2026-09-13 16:38:32 +00:00
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()));
}
2026-09-14 09:08:35 +00:00
if crate::browser_client::local_browser_enabled() && !ctx.browser.is_started().await && !matches!(op, "navigate" | "type") {
2026-09-13 16:38:32 +00:00
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();
2026-09-14 09:08:35 +00:00
let answer: Result<Value> = match auto.as_deref() {
2026-09-13 16:38:32 +00:00
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!(
2026-09-14 09:08:35 +00:00
"{}\n請在 {} 操作;完成後回覆,將繼續使用同一份登入狀態。",
args["reason"].as_str().unwrap(),
if crate::browser_client::local_browser_enabled() { "本地工具瀏覽器".to_string() } else { BoxHub::viewer_url() }
2026-09-13 16:38:32 +00:00
));
question["options"] = json!(options.clone());
2026-09-14 09:08:35 +00:00
return ctx.runtime.park_question(&question);
2026-09-13 16:38:32 +00:00
}
};
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?;
2026-09-14 09:08:35 +00:00
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());
}
2026-09-13 16:38:32 +00:00
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) {
2026-09-13 16:38:32 +00:00
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)
}
2026-09-14 09:08:35 +00:00
#[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();
}
2026-09-13 16:38:32 +00:00
#[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,
2026-09-14 09:08:35 +00:00
"external_read_file",
2026-09-13 16:38:32 +00:00
&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 =
2026-09-14 09:08:35 +00:00
serde_json::from_str(&execute_tool(&ctx, "external_search_files", r#"{"query":"beta"}"#).await)
2026-09-13 16:38:32 +00:00
.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,
2026-09-14 09:08:35 +00:00
"external_edit_file",
2026-09-13 16:38:32 +00:00
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,
2026-09-14 09:08:35 +00:00
"external_edit_file",
2026-09-13 16:38:32 +00:00
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();
2026-09-14 09:08:35 +00:00
for name in ["external_read_file", "external_write_file", "external_edit_file"] {
2026-09-13 16:38:32 +00:00
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,
2026-09-14 09:08:35 +00:00
"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());
2026-09-14 09:08:35 +00:00
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");
2026-09-14 09:08:35 +00:00
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,
2026-09-14 09:08:35 +00:00
"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,
2026-09-14 09:08:35 +00:00
"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"));
2026-09-14 09:08:35 +00:00
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();
2026-09-14 09:08:35 +00:00
assert_eq!(arr.len(), 51);
let names: Vec<&str> = arr
.iter()
.map(|t| t["function"]["name"].as_str().unwrap())
.collect();
2026-09-14 09:08:35 +00:00
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"));
2026-09-14 09:08:35 +00:00
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"));
2026-09-14 09:08:35 +00:00
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() {
2026-09-13 16:38:32 +00:00
let _env_lock = crate::test_env::lock_async().await;
2026-09-14 09:08:35 +00:00
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}");
2026-09-14 09:08:35 +00:00
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() {
2026-09-13 16:38:32 +00:00
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() {
2026-09-14 09:08:35 +00:00
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(),
2026-09-14 09:08:35 +00:00
..ctx
};
2026-09-13 16:38:32 +00:00
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}");
2026-09-14 09:08:35 +00:00
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);
}
}