2026-09-13 08:37:51 +00:00
//! Built-in tools: shell, files, completion, Playwright browser (P3), human handoff (P4),
//! confirm-before-post (P6).
2026-09-13 07:42:59 +00:00
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 ;
2026-09-13 07:42:59 +00:00
use std ::path ::{ Component , Path , PathBuf } ;
2026-09-14 09:08:35 +00:00
use std ::pin ::Pin ;
use std ::sync ::{ Arc , Mutex } ;
2026-09-13 07:42:59 +00:00
2026-09-13 07:57:57 +00:00
use crate ::browser ::{ self , LastUrlSlot } ;
2026-09-13 08:37:51 +00:00
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 ,
> ;
2026-09-13 07:57:57 +00:00
2026-09-13 07:42:59 +00:00
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) ]
2026-09-13 07:42:59 +00:00
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 > > ,
2026-09-13 07:42:59 +00:00
/// Optional workspace root; paths outside it are rejected when set.
pub workspace_root : Option < PathBuf > ,
2026-09-13 07:57:57 +00:00
/// 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 ( )
}
2026-09-13 07:42:59 +00:00
}
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 ( ) ) ,
2026-09-13 07:57:57 +00:00
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 ( ) ;
2026-09-13 07:42:59 +00:00
}
}
pub fn with_workspace ( mut self , root : Option < PathBuf > ) -> Self {
self . workspace_root = root ;
self
}
2026-09-13 07:57:57 +00:00
pub fn last_browser_url_value ( & self ) -> Option < String > {
self . last_browser_url . lock ( ) . ok ( ) . and_then ( | g | g . clone ( ) )
}
2026-09-13 07:42:59 +00:00
}
/// 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 {
2026-09-13 07:57:57 +00:00
let mut defs = json! ( [
2026-09-13 07:42:59 +00:00
{
" type " : " function " ,
" function " : {
2026-09-14 09:08:35 +00:00
" name " : " external_shell " ,
2026-09-13 07:42:59 +00:00
" 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 " ,
2026-09-13 07:42:59 +00:00
" 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. " ,
2026-09-13 07:42:59 +00:00
" 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 }
2026-09-13 07:42:59 +00:00
} ,
" required " : [ " path " ]
}
}
} ,
{
" type " : " function " ,
" function " : {
2026-09-14 09:08:35 +00:00
" name " : " external_write_file " ,
2026-09-13 07:42:59 +00:00
" 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 " ]
}
}
2026-09-13 07:51:13 +00:00
} ,
{
" 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 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. " ,
2026-09-13 07:51:13 +00:00
" 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 " }
2026-09-13 07:51:13 +00:00
} ,
" required " : [ " reason " ]
}
}
2026-09-13 07:42:59 +00:00
}
2026-09-13 07:57:57 +00:00
] ) ;
2026-09-13 08:37:51 +00:00
// Confirm-before-post (P6) + optional Playwright DOM tools.
2026-09-13 07:57:57 +00:00
if let Some ( arr ) = defs . as_array_mut ( ) {
2026-09-13 08:37:51 +00:00
arr . push ( confirm ::confirm_tool_definition ( ) ) ;
2026-09-13 07:57:57 +00:00
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_ " ) ) ;
2026-09-13 07:57:57 +00:00
}
defs
2026-09-13 07:42:59 +00:00
}
2026-09-14 09:08:35 +00:00
/// Tools that must be called alone (human wait / hard stop). `send_message` may mix.
2026-09-13 07:51:13 +00:00
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 "
)
2026-09-13 07:51:13 +00:00
}
2026-09-13 07:42:59 +00:00
/// 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 " ) ) ;
}
}
2026-09-13 07:42:59 +00:00
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 {
2026-09-13 07:42:59 +00:00
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
}
2026-09-13 07:42:59 +00:00
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 07:57:57 +00:00
}
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 ,
2026-09-13 07:42:59 +00:00
other = > Err ( anyhow! ( " unknown tool: {other} " ) ) ,
}
}
2026-09-13 07:51:13 +00:00
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 ( ) )
2026-09-13 07:51:13 +00:00
. 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 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 ( )
}
}
}
2026-09-13 07:51:13 +00:00
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 ( ) )
2026-09-13 07:51:13 +00:00
. ok_or_else ( | | anyhow! ( " report_blocked: missing 'reason' " ) ) ? ;
Ok ( json! ( {
" status " : " blocked " ,
" reason " : reason ,
} ) )
}
2026-09-13 07:42:59 +00:00
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 07:42:59 +00:00
}
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 )
2026-09-13 07:42:59 +00:00
}
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 07:42:59 +00:00
}
2026-09-13 16:38:32 +00:00
Ok (
json! ( { " path " :file , " bytes " :content . len ( ) , " content " :content , " offset " :offset , " next_offset " :number , " truncated " :more } ) ,
)
2026-09-13 07:42:59 +00:00
}
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 {
2026-09-13 09:22:54 +00:00
return s . to_string ( ) ;
}
let mut end = max ;
while end > 0 & & ! s . is_char_boundary ( end ) {
end - = 1 ;
2026-09-13 07:42:59 +00:00
}
2026-09-13 09:22:54 +00:00
format! (
" {}… \n [truncated {} bytes] " ,
& s [ .. end ] ,
s . len ( ) . saturating_sub ( end )
)
2026-09-13 07:42:59 +00:00
}
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 1– 6 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 ) )
}
2026-09-13 07:42:59 +00:00
#[ 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 ( ) ;
2026-09-13 07:42:59 +00:00
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 \n beta \n gamma \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 \n BETA \n gamma \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 ( ) ;
}
2026-09-13 07:42:59 +00:00
#[ 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 " ,
2026-09-13 07:42:59 +00:00
& 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 ;
2026-09-13 07:42:59 +00:00
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 ;
2026-09-13 07:42:59 +00:00
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 " ,
2026-09-13 07:42:59 +00:00
& 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 " ,
2026-09-13 07:42:59 +00:00
& 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 ) ;
}
2026-09-13 07:51:13 +00:00
#[ 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 " ) ) ;
2026-09-13 07:51:13 +00:00
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
2026-09-13 07:42:59 +00:00
#[ test ]
2026-09-13 07:57:57 +00:00
fn tool_defs_include_core_and_browser ( ) {
2026-09-13 07:42:59 +00:00
let defs = tool_definitions ( ) ;
let arr = defs . as_array ( ) . unwrap ( ) ;
2026-09-14 09:08:35 +00:00
assert_eq! ( arr . len ( ) , 51 ) ;
2026-09-13 07:42:59 +00:00
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 " ) ) ;
2026-09-13 07:42:59 +00:00
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 " ) ) ;
2026-09-13 07:51:13 +00:00
assert! ( names . contains ( & " report_done " ) ) ;
assert! ( names . contains ( & " report_blocked " ) ) ;
2026-09-13 08:37:51 +00:00
assert! ( names . contains ( & " request_user_confirm " ) ) ;
2026-09-13 07:57:57 +00:00
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 " ) ) ;
2026-09-13 08:15:27 +00:00
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() {} \n fn 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 ) ;
2026-09-13 08:15:27 +00:00
}
#[ 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 " ) ;
2026-09-13 08:15:27 +00:00
// 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 " ) ; }
2026-09-13 08:15:27 +00:00
match prev {
Some ( v ) = > unsafe { std ::env ::set_var ( " GROKBOY_HANDOFF_AUTO " , v ) } ,
None = > unsafe { std ::env ::remove_var ( " GROKBOY_HANDOFF_AUTO " ) } ,
}
2026-09-13 07:57:57 +00:00
}
2026-09-13 08:37:51 +00:00
#[ 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 ;
2026-09-13 08:37:51 +00:00
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 ) ;
}
2026-09-13 07:57:57 +00:00
#[ 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 " ) ;
2026-09-13 07:57:57 +00:00
// 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 07:57:57 +00:00
} ;
2026-09-13 16:38:32 +00:00
let out = execute_tool ( & ctx , " browser_type " , & json! ( { " selector " : " #x " } ) . to_string ( ) ) . await ;
2026-09-13 07:57:57 +00:00
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 " ) ; }
2026-09-13 07:57:57 +00:00
let _ = std ::fs ::remove_dir_all ( & dir ) ;
2026-09-13 07:42:59 +00:00
}
}