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! (
2026-09-14 09:13:10 +00:00
" 請在我的電腦上擝作:{instruction} \n 打開 {viewer} ,坚完後在這裡回覆「好了〝。 "
2026-09-14 09:08:35 +00:00
) ) ;
ctx . runtime . park_question ( & json! ( {
" kind " : " box_help " ,
" question " : format ! ( " {instruction} \n 打開我的電腦:{viewer} " ) ,
2026-09-14 09:13:10 +00:00
" options " : [ " 我已完戝,請檢查畫面後繼續 " , " 坜止這份工作 " ] ,
2026-09-14 09:08:35 +00:00
" 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 ,
2026-09-14 09:13:10 +00:00
& [ " 杛一種方法處睆目剝的阻礙 " , " 先完戝丝块阻礙影響的部分 " ] ,
2026-09-13 16:38:32 +00:00
) ? ;
2026-09-14 09:13:10 +00:00
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 " ] ) ) ,
2026-09-14 09:13:10 +00:00
def ( " web_fetch " , " Fast anonymous HTTP GET of a public URL; HTML is reduced to readable text with link footnotes. No browser cookies, local profile, localhost access or JavaScript execution. Results are cached briefly, so do not refetch the same URL. If the site blocks plain HTTP the result is a model-rendered summary marked content_kind=model_rendered_web_content. " , json! ( { " url " :{ " type " :" string " } , " max_bytes " :{ " type " :" integer " } } ) , json! ( [ " url " ] ) ) ,
2026-09-14 09:08:35 +00:00
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 ( ) ;
2026-09-14 09:13:10 +00:00
let mut options = vec! [ " 我已完戝登入,請檢查頝面後繼續 " . to_string ( ) ] ;
2026-09-13 16:38:32 +00:00
options . extend ( recovery_options (
args ,
2026-09-14 09:13:10 +00:00
& [ " 登入仝有啝題,先坚丝需覝登入的部分 " ] ,
2026-09-13 16:38:32 +00:00
) ? ) ;
2026-09-14 09:13:10 +00:00
options . push ( " 坜止這份工作 " . into ( ) ) ;
2026-09-13 16:38:32 +00:00
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 ,
2026-09-14 09:13:10 +00:00
& [ " 登入仝有啝題,先坚丝需覝登入的部分 " ]
2026-09-13 16:38:32 +00:00
) ? ) ;
question [ " question " ] = json! ( format! (
2026-09-14 09:13:10 +00:00
" {} \n 請在 {} 擝作;完戝後回覆,將繼續使用坌一份登入狀態。 " ,
2026-09-14 09:08:35 +00:00
args [ " reason " ] . as_str ( ) . unwrap ( ) ,
2026-09-14 09:13:10 +00:00
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 ( ) ,
2026-09-14 09:13:10 +00:00
Some ( " abort " | " cancel " | " no " | " 坜止這份工作 " )
2026-09-13 16:38:32 +00:00
) {
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 " ,
2026-09-14 09:13:10 +00:00
" prompt " : " 蝉稿內容 " ,
2026-09-13 08:37:51 +00:00
" 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
}
}