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-13 07:42:59 +00:00
use std ::path ::{ Component , Path , PathBuf } ;
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-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.
#[ derive(Debug, Clone) ]
pub struct ToolContext {
/// Default working directory for relative paths / shell.
pub cwd : PathBuf ,
2026-09-13 16:38:32 +00:00
pub ( crate ) team : Option < std ::sync ::Arc < crate ::team ::TeamContext > > ,
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-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-13 07:42:59 +00:00
workspace_root : Some ( cwd ) ,
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 ( ) ,
}
}
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 ;
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-13 07:57:57 +00:00
let mut defs = json! ( [
2026-09-13 07:42:59 +00:00
{
" type " : " function " ,
" function " : {
" name " : " shell " ,
" description " : " Run a shell command. Captures stdout, stderr, and exit code. Timeout 30s. cwd defaults to the session working directory. " ,
" parameters " : {
" type " : " object " ,
" properties " : {
" command " : { " type " : " string " , " description " : " Shell command to run " } ,
" cwd " : { " type " : " string " , " description " : " Optional working directory " }
} ,
" required " : [ " command " ]
}
}
} ,
{
" type " : " function " ,
" function " : {
" name " : " list_dir " ,
" description " : " List entries in a directory (names only, sorted). " ,
" parameters " : {
" type " : " object " ,
" properties " : {
" path " : { " type " : " string " , " description " : " Directory path (relative to cwd or absolute) " }
} ,
" required " : [ " path " ]
}
}
} ,
{
" type " : " function " ,
" function " : {
" name " : " 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 " : {
" name " : " write_file " ,
" description " : " Write text to a file, creating parent directories as needed. " ,
" parameters " : {
" type " : " object " ,
" properties " : {
" path " : { " type " : " string " , " description " : " File path " } ,
" content " : { " type " : " string " , " description " : " File contents " }
} ,
" required " : [ " path " , " content " ]
}
}
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-13 07:57:57 +00:00
}
defs
2026-09-13 07:42:59 +00:00
}
2026-09-13 07:51:13 +00:00
/// True if the tool name ends the ReAct loop (completion contract).
pub fn is_completion_tool ( name : & str ) -> bool {
matches! ( name , " report_done " | " report_blocked " )
}
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 > {
if let Some ( team ) = & ctx . team {
let args : Value = serde_json ::from_str ( arguments ) ? ;
if crate ::team ::worker ::is_team_tool ( name ) {
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 "
| " update_plan "
| " request_user_input "
| " request_user_confirm "
| " browser_handoff "
) {
let service = team . service ( ) ? ;
let mut held = team . held_workspace . lock ( ) . await ;
if held . is_none ( ) {
* held = Some ( service . workspace ( & ctx . cwd ) ? . lock_owned ( ) . await ) ;
}
let result = execute_tool_inner ( ctx , name , arguments ) . await ;
if ! ctx . jobs . active ( ) . await {
held . take ( ) ;
}
return result ;
}
if matches! (
name ,
" request_user_input " | " request_user_confirm " | " browser_handoff "
) & & ! ctx . jobs . active ( ) . await
{
team . held_workspace . lock ( ) . await . take ( ) ;
}
// A live command must be finished before handing control to a human.
if matches! (
name ,
" request_user_input " | " request_user_confirm " | " browser_handoff "
) & & 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 {
" shell " = > tool_shell ( ctx , & args ) . await ,
" list_dir " = > tool_list_dir ( ctx , & args ) . await ,
" read_file " = > tool_read_file ( ctx , & args ) . await ,
2026-09-13 16:38:32 +00:00
" search_files " = > search_files ( ctx , & args ) . await ,
" edit_file " = > edit_file ( ctx , & args ) . await ,
" exec_command " = > {
let cwd = resolve_path ( ctx , args [ " cwd " ] . as_str ( ) . unwrap_or ( " . " ) ) ? ;
ctx . jobs . exec ( & cwd , & args ) . await
}
" write_stdin " = > ctx . jobs . write ( & args ) . await ,
" report_progress " = > {
let message = required_text ( & args , " message " ) ? ;
ctx . runtime . emit ( crate ::AgentEvent ::Progress {
message : message . into ( ) ,
} ) ;
Ok ( json! ( { " emitted " :true } ) )
}
" update_plan " = > ctx . runtime . update_plan ( & args ) ,
" request_user_input " = > {
required_text ( & args , " question " ) ? ;
ctx . runtime . question ( & args ) . await
}
2026-09-13 07:42:59 +00:00
" write_file " = > tool_write_file ( ctx , & args ) . await ,
2026-09-13 16:38:32 +00:00
" report_done " = > {
if ctx . runtime . unfinished ( ) | | ctx . jobs . active ( ) . await {
return Err ( anyhow! ( " cannot finish: unfinished plan or active command; verify the outcome and finish the steps first " ) ) ;
}
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 . runtime . input . as_ref ( ) . is_some_and ( | i | i . can_ask ( ) ) {
return Ok ( blocked ) ;
}
if ctx . jobs . active ( ) . await {
return Err ( anyhow! (
" finish or terminate the active command before asking for a recovery choice "
) ) ;
}
if let Some ( team ) = & ctx . team {
team . held_workspace . lock ( ) . await . take ( ) ;
}
let mut options = recovery_options (
args ,
& [ " 換一種方法處理目前的阻礙 " , " 先完成不受阻礙影響的部分 " ] ,
) ? ;
options . push ( " 停止這份工作 " . into ( ) ) ;
let question = json! ( { " kind " :" recovery " , " question " :format ! ( " {} \n 接下來你想怎麼做? " , blocked [ " reason " ] . as_str ( ) . unwrap_or ( " 目前遇到阻礙 " ) ) , " options " :options } ) ;
let answer = ctx . runtime . question ( & question ) . await ? ;
if answer [ " answer " ] = = " 停止這份工作 "
| | matches! ( answer [ " answer " ] . as_str ( ) , Some ( " abort " | " cancel " | " stop " ) )
{
return Ok ( blocked ) ;
}
Ok (
json! ( { " status " :" replan " , " reason " :blocked [ " reason " ] , " answer " :answer [ " answer " ] , " instruction " :" Continue this same task and browser session along the chosen route. Revise the plan; do not repeat the failed approach unchanged or claim the original blocker was resolved. " } ) ,
)
}
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! [
def ( " report_progress " , " Send a concise user-visible progress message and CONTINUE working. Do not report internal reasoning. Prefer text alongside an action tool when possible. " , json! ( { " message " :{ " type " :" string " } } ) , json! ( [ " message " ] ) ) ,
def ( " update_plan " , " Maintain a short task checklist; proceed without asking approval. At most one in_progress. 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. Wait for an answer, then continue. Not for approval of a plan. " , json! ( { " question " :{ " type " :" string " } , " options " :{ " type " :" array " , " items " :{ " type " :" string " } } , " timeout_secs " :{ " type " :" integer " } } ) , json! ( [ " question " ] ) ) ,
def ( " exec_command " , " Start one foreground command with piped stdin/stdout. Returns session_id and running; use write_stdin to observe. Default timeout 10 minutes. Full output saved to files. No PTY. " , json! ( { " cmd " :{ " type " :" string " } , " cwd " :{ " type " :" string " } , " yield_time_ms " :{ " type " :" integer " } , " timeout_ms " :{ " type " :" integer " } , " max_output_bytes " :{ " type " :" integer " } } ) , json! ( [ " cmd " ] ) ) ,
def ( " 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 " } , " yield_time_ms " :{ " type " :" integer " } , " max_output_bytes " :{ " type " :" integer " } } ) , json! ( [ " session_id " ] ) ) ,
def ( " 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 ( " 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 " ] ) ) ,
]
}
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 ( )
) ) ;
let answer = ctx . runtime . question ( & prompt ) . await ;
let approved = answer
. as_ref ( )
. ok ( )
. and_then ( | v | v [ " answer " ] . as_str ( ) )
. is_some_and ( | s | matches! ( s . to_lowercase ( ) . as_str ( ) , " " | " yes " | " y " ) ) ;
Ok (
json! ( { " approved " :approved , " status " :if approved { " approved " } else { " denied " } , " reason " :answer . err ( ) . map ( | e | e . to_string ( ) ) , " prompt " :args [ " prompt " ] } ) ,
)
}
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 " ) ) ? ,
) ? ;
let guard = match service . browser_lock ( & task . owner_id ) . try_lock_owned ( ) {
Ok ( guard ) = > guard ,
Err ( _ ) = > {
return Ok (
json! ( { " error " :" browser_busy " , " instruction " :" Another task owns this owner's browser, possibly during human handoff. Do independent work or ask it to browser_release. Do not retry repeatedly or interpret this as logged out. " } ) ,
)
}
} ;
let profile = service
. browser_profile ( & task . owner_id , & crate ::team ::data_dir ( ) . join ( " profiles " ) ) ? ;
* ctx . runtime . browser_profile . lock ( ) . unwrap ( ) = Some ( profile ) ;
* held = Some ( guard ) ;
}
}
let op = name . strip_prefix ( " browser_ " ) . unwrap_or ( name ) ;
let mut req = args . clone ( ) ;
req [ " op " ] = json! ( if op = = " dom " { " snapshot " } else { op } ) ;
if op = = " upload " {
req [ " path " ] = json! ( std ::fs ::canonicalize ( resolve_path (
ctx ,
required_text ( args , " path " ) ?
) ? ) ? ) ;
}
if op = = " download " {
let path = resolve_path ( ctx , required_text ( args , " path " ) ? ) ? ;
if path . exists ( ) {
return Err ( anyhow! ( " download target already exists " ) ) ;
}
if let Some ( parent ) = path . parent ( ) {
tokio ::fs ::create_dir_all ( parent ) . await ? ;
}
req [ " path " ] =
json! ( std ::fs ::canonicalize ( path . parent ( ) . unwrap ( ) ) ? . join ( path . file_name ( ) . unwrap ( ) ) ) ;
}
if ! ctx . browser . is_started ( ) . await & & ! matches! ( op , " navigate " | " type " ) {
if let Some ( url ) = ctx . last_browser_url_value ( ) {
let restored = ctx
. browser
. request (
& ctx . cwd ,
ctx . runtime . profile_dir ( ) ,
json! ( { " op " :" navigate " , " url " :url } ) ,
)
. await ? ;
if restored [ " ok " ] ! = true {
return Ok ( browser ::response_to_tool_json ( restored ) ) ;
}
}
}
let mut handoff_answer = None ;
if op = = " handoff " {
required_text ( args , " reason " ) ? ;
let prep = ctx
. browser
. request (
& ctx . cwd ,
ctx . runtime . profile_dir ( ) ,
json! ( { " op " :" handoff_prepare " } ) ,
)
. await ? ;
if prep [ " ok " ] ! = true {
return Ok ( browser ::response_to_tool_json ( prep ) ) ;
}
browser ::update_last_url ( & ctx . last_browser_url , & prep ) ;
* ctx . runtime . browser_url . lock ( ) . unwrap ( ) = ctx . last_browser_url_value ( ) ;
let mut options = vec! [ " 我已完成登入,請檢查頁面後繼續 " . to_string ( ) ] ;
options . extend ( recovery_options (
args ,
& [ " 登入仍有問題,先做不需要登入的部分 " ] ,
) ? ) ;
options . push ( " 停止這份工作 " . into ( ) ) ;
let auto = std ::env ::var ( " GROKBOY_HANDOFF_AUTO " ) . ok ( ) ;
let answer = match auto . as_deref ( ) {
Some ( " 1 " | " true " | " resume " | " continue " | " yes " ) = > Ok ( json! ( { " answer " :" " } ) ) ,
Some ( " abort " | " 0 " | " false " | " no " ) = > Ok ( json! ( { " answer " :" abort " } ) ) ,
_ = > {
let mut question = args . clone ( ) ;
question [ " kind " ] = json! ( " handoff " ) ;
question [ " handoff_options " ] = json! ( recovery_options (
args ,
& [ " 登入仍有問題,先做不需要登入的部分 " ]
) ? ) ;
question [ " question " ] = json! ( format! (
" {} \n 請在目前顯示的瀏覽器視窗操作,這是本任務接下來會使用的登入狀態。完成後回覆「登入了」或選第一項;也可以改走其他路線。 " ,
args [ " reason " ] . as_str ( ) . unwrap ( )
) ) ;
question [ " options " ] = json! ( options . clone ( ) ) ;
ctx . runtime . question ( & question ) . await
}
} ;
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 ? ;
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-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 ,
" read_file " ,
& json! ( { " path " :" notes.txt " , " offset " :1 , " limit " :1 } ) . to_string ( ) ,
)
. await ;
let read : Value = serde_json ::from_str ( & read ) . unwrap ( ) ;
assert_eq! ( read [ " content " ] , " beta \n " , " {read} " ) ;
assert_eq! ( read [ " next_offset " ] , 2 ) ;
assert_eq! ( read [ " truncated " ] , true ) ;
std ::fs ::create_dir_all ( dir . join ( " node_modules " ) ) . unwrap ( ) ;
std ::fs ::write ( dir . join ( " node_modules/noise.txt " ) , " beta " ) . unwrap ( ) ;
let found : Value =
serde_json ::from_str ( & execute_tool ( & ctx , " search_files " , r # "{"query":"beta"}"# ) . await )
. unwrap ( ) ;
assert_eq! ( found [ " matches " ] . as_array ( ) . unwrap ( ) . len ( ) , 1 ) ;
assert_eq! ( found [ " matches " ] [ 0 ] [ " line " ] , 2 ) ;
let edited : Value = serde_json ::from_str (
& execute_tool (
& ctx ,
" edit_file " ,
r # "{"path":"notes.txt","old_text":"beta","new_text":"BETA"}"# ,
)
. await ,
)
. unwrap ( ) ;
assert_eq! ( edited [ " replacements " ] , 1 ) ;
assert_eq! (
std ::fs ::read_to_string ( dir . join ( " notes.txt " ) ) . unwrap ( ) ,
" alpha \n BETA \n gamma \n "
) ;
std ::fs ::write ( dir . join ( " notes.txt " ) , " aaa " ) . unwrap ( ) ;
let denied : Value = serde_json ::from_str (
& execute_tool (
& ctx ,
" edit_file " ,
r # "{"path":"notes.txt","old_text":"aa","new_text":"x"}"# ,
)
. await ,
)
. unwrap ( ) ;
assert! ( denied . get ( " error " ) . is_some ( ) ) ;
assert_eq! (
std ::fs ::read_to_string ( dir . join ( " notes.txt " ) ) . unwrap ( ) ,
" aaa "
) ;
std ::fs ::remove_dir_all ( dir ) . unwrap ( ) ;
}
#[ cfg(unix) ]
#[ tokio::test ]
async fn file_tools_reject_symlink_escape ( ) {
let ( ctx , dir ) = temp_ctx ( ) ;
let outside =
std ::env ::temp_dir ( ) . join ( format! ( " grokboy-outside- {} " , uuid ::Uuid ::new_v4 ( ) ) ) ;
std ::fs ::write ( & outside , " private " ) . unwrap ( ) ;
std ::os ::unix ::fs ::symlink ( & outside , dir . join ( " link " ) ) . unwrap ( ) ;
for name in [ " read_file " , " write_file " , " edit_file " ] {
let out : Value = serde_json ::from_str (
& execute_tool (
& ctx ,
name ,
& json! ( { " path " :" link " , " content " :" bad " , " old_text " :" private " , " new_text " :" bad " } )
. to_string ( ) ,
)
. await ,
)
. unwrap ( ) ;
assert! ( out . get ( " error " ) . is_some ( ) , " {out} " ) ;
}
assert_eq! ( std ::fs ::read_to_string ( & outside ) . unwrap ( ) , " private " ) ;
std ::fs ::remove_file ( outside ) . unwrap ( ) ;
std ::fs ::remove_dir_all ( dir ) . unwrap ( ) ;
}
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 ,
" write_file " ,
& json! ( { " path " : " hello.txt " , " content " : " 你好 GrokBoy " } ) . to_string ( ) ,
)
. await ;
let w : Value = serde_json ::from_str ( & w ) . unwrap ( ) ;
assert! ( w . get ( " error " ) . is_none ( ) , " {w} " ) ;
assert_eq! ( w [ " bytes_written " ] , " 你好 GrokBoy " . len ( ) ) ;
2026-09-13 16:38:32 +00:00
let r = execute_tool ( & ctx , " 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 " ) ;
let l = execute_tool ( & ctx , " list_dir " , & json! ( { " path " : " . " } ) . to_string ( ) ) . await ;
let l : Value = serde_json ::from_str ( & l ) . unwrap ( ) ;
let names : Vec < & str > = l [ " entries " ]
. as_array ( )
. unwrap ( )
. iter ( )
. map ( | e | e [ " name " ] . as_str ( ) . unwrap ( ) )
. collect ( ) ;
assert! ( names . contains ( & " hello.txt " ) ) ;
let s = execute_tool (
& ctx ,
" shell " ,
& json! ( { " command " : " echo hi && ls hello.txt " } ) . to_string ( ) ,
)
. await ;
let s : Value = serde_json ::from_str ( & s ) . unwrap ( ) ;
assert_eq! ( s [ " exit_code " ] , 0 ) ;
assert! ( s [ " stdout " ] . as_str ( ) . unwrap ( ) . contains ( " hi " ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
#[ tokio::test ]
async fn rejects_path_traversal ( ) {
let ( ctx , dir ) = temp_ctx ( ) ;
let out = execute_tool (
& ctx ,
" read_file " ,
& json! ( { " path " : " ../outside.txt " } ) . to_string ( ) ,
)
. await ;
let v : Value = serde_json ::from_str ( & out ) . unwrap ( ) ;
assert! ( v . get ( " error " ) . is_some ( ) , " {v} " ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
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 " ) ) ;
assert! ( ! is_completion_tool ( " shell " ) ) ;
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-13 16:38:32 +00:00
assert_eq! ( arr . len ( ) , 29 ) ;
2026-09-13 07:42:59 +00:00
let names : Vec < & str > = arr
. iter ( )
. map ( | t | t [ " function " ] [ " name " ] . as_str ( ) . unwrap ( ) )
. collect ( ) ;
assert! ( names . contains ( & " shell " ) ) ;
assert! ( names . contains ( & " list_dir " ) ) ;
assert! ( names . contains ( & " read_file " ) ) ;
assert! ( names . contains ( & " write_file " ) ) ;
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 " ) ) ;
}
#[ 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-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} " ) ;
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 ( ) {
// 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 ( ) ,
workspace_root : ctx . workspace_root ,
last_browser_url : ctx . last_browser_url ,
2026-09-13 16:38:32 +00:00
runtime : ctx . runtime ,
jobs : ctx . jobs ,
browser : ctx . browser ,
team : None ,
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} " ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
2026-09-13 07:42:59 +00:00
}
}