2025-10-26 19:50:41 +00:00
import z from "zod"
2025-10-12 04:24:48 +00:00
import { spawn } from "child_process"
2025-05-31 18:41:00 +00:00
import { Tool } from "./tool"
2025-06-04 17:12:13 +00:00
import DESCRIPTION from "./bash.txt"
2025-08-01 00:40:05 +00:00
import { Log } from "../util/log"
2025-09-01 21:15:49 +00:00
import { Instance } from "../project/instance"
2025-10-31 19:07:36 +00:00
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import { Agent } from "@/agent/agent"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { Wildcard } from "@/util/wildcard"
import { Permission } from "@/permission"
2025-11-18 06:46:49 +00:00
import { fileURLToPath } from "url"
2025-11-20 16:45:13 +00:00
import { Flag } from "@/flag/flag.ts"
2025-11-19 18:31:24 +00:00
import path from "path"
2025-11-21 22:29:47 +00:00
import { iife } from "@/util/iife"
2025-05-19 23:29:38 +00:00
2025-11-20 16:45:13 +00:00
const DEFAULT_MAX_OUTPUT_LENGTH = 30 _000
const MAX_OUTPUT_LENGTH = ( ( ) = > {
const parsed = Number ( Flag . OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH )
return Number . isInteger ( parsed ) && parsed > 0 ? parsed : DEFAULT_MAX_OUTPUT_LENGTH
} ) ( )
2025-05-31 18:41:00 +00:00
const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
2025-10-16 19:39:36 +00:00
const SIGKILL_TIMEOUT_MS = 200
2025-05-19 23:29:38 +00:00
2025-10-31 19:07:36 +00:00
export const log = Log . create ( { service : "bash-tool" } )
2025-08-01 01:41:48 +00:00
2025-11-18 06:46:49 +00:00
const resolveWasm = ( asset : string ) = > {
if ( asset . startsWith ( "file://" ) ) return fileURLToPath ( asset )
2025-11-18 20:06:45 +00:00
if ( asset . startsWith ( "/" ) || /^[a-z]:/i . test ( asset ) ) return asset
2025-11-18 06:46:49 +00:00
const url = new URL ( asset , import . meta . url )
return fileURLToPath ( url )
}
2025-10-07 03:24:07 +00:00
const parser = lazy ( async ( ) = > {
2025-10-31 19:07:36 +00:00
const { Parser } = await import ( "web-tree-sitter" )
const { default : treeWasm } = await import ( "web-tree-sitter/tree-sitter.wasm" as string , {
with : { type : "wasm" } ,
} )
2025-11-18 06:46:49 +00:00
const treePath = resolveWasm ( treeWasm )
2025-10-31 19:07:36 +00:00
await Parser . init ( {
locateFile() {
2025-11-18 06:46:49 +00:00
return treePath
2025-10-31 19:07:36 +00:00
} ,
} )
const { default : bashWasm } = await import ( "tree-sitter-bash/tree-sitter-bash.wasm" as string , {
with : { type : "wasm" } ,
} )
2025-11-18 06:46:49 +00:00
const bashPath = resolveWasm ( bashWasm )
const bashLanguage = await Language . load ( bashPath )
2025-10-31 19:07:36 +00:00
const p = new Parser ( )
p . setLanguage ( bashLanguage )
return p
2025-07-31 21:19:56 +00:00
} )
2025-07-31 00:57:52 +00:00
2025-11-21 22:29:47 +00:00
// TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool . define ( "bash" , async ( ) = > {
const shell = iife ( ( ) = > {
const s = process . env . SHELL
if ( s ) {
if ( ! new Set ( [ "/bin/fish" , "/bin/nu" , "/usr/bin/fish" , "/usr/bin/nu" ] ) . has ( s ) ) {
return s
}
2025-10-28 22:32:39 +00:00
}
2025-11-21 22:29:47 +00:00
if ( process . platform === "darwin" ) {
return "/bin/zsh"
2025-10-31 19:07:36 +00:00
}
2025-11-21 22:29:47 +00:00
2025-11-23 18:29:15 +00:00
if ( process . platform === "win32" ) {
// Let Bun / Node pick COMSPEC (usually cmd.exe)
// or explicitly:
return process . env . COMSPEC || true
}
2025-11-21 22:29:47 +00:00
const bash = Bun . which ( "bash" )
if ( bash ) {
return bash
}
return true
} )
log . info ( "bash tool using shell" , { shell } )
return {
description : DESCRIPTION ,
parameters : z.object ( {
command : z.string ( ) . describe ( "The command to execute" ) ,
timeout : z.number ( ) . describe ( "Optional timeout in milliseconds" ) . optional ( ) ,
description : z
. string ( )
. describe (
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'" ,
) ,
} ) ,
async execute ( params , ctx ) {
if ( params . timeout !== undefined && params . timeout < 0 ) {
throw new Error ( ` Invalid timeout value: ${ params . timeout } . Timeout must be a positive number. ` )
}
const timeout = Math . min ( params . timeout ? ? DEFAULT_TIMEOUT , MAX_TIMEOUT )
const tree = await parser ( ) . then ( ( p ) = > p . parse ( params . command ) )
if ( ! tree ) {
throw new Error ( "Failed to parse command" )
2025-07-31 00:57:52 +00:00
}
2025-11-21 22:29:47 +00:00
const agent = await Agent . get ( ctx . agent )
const permissions = agent . permission . bash
2025-07-31 00:57:52 +00:00
2025-11-21 22:29:47 +00:00
const askPatterns = new Set < string > ( )
for ( const node of tree . rootNode . descendantsOfType ( "command" ) ) {
if ( ! node ) continue
const command = [ ]
for ( let i = 0 ; i < node . childCount ; i ++ ) {
const child = node . child ( i )
if ( ! child ) continue
if (
child . type !== "command_name" &&
child . type !== "word" &&
child . type !== "string" &&
child . type !== "raw_string" &&
child . type !== "concatenation"
) {
continue
}
command . push ( child . text )
}
// not an exhaustive list, but covers most common cases
if ( [ "cd" , "rm" , "cp" , "mv" , "mkdir" , "touch" , "chmod" , "chown" ] . includes ( command [ 0 ] ) ) {
for ( const arg of command . slice ( 1 ) ) {
if ( arg . startsWith ( "-" ) || ( command [ 0 ] === "chmod" && arg . startsWith ( "+" ) ) ) continue
const resolved = await $ ` realpath ${ arg } `
. quiet ( )
. nothrow ( )
. text ( )
. then ( ( x ) = > x . trim ( ) )
log . info ( "resolved path" , { arg , resolved } )
if ( resolved ) {
// Git Bash on Windows returns Unix-style paths like /c/Users/...
const normalized =
process . platform === "win32" && resolved . match ( /^\/[a-z]\// )
? resolved . replace ( /^\/([a-z])\// , ( _ , drive ) = > ` ${ drive . toUpperCase ( ) } : \\ ` ) . replace ( /\//g , "\\" )
: resolved
if ( ! Filesystem . contains ( Instance . directory , normalized ) ) {
const parentDir = path . dirname ( normalized )
if ( agent . permission . external_directory === "ask" ) {
await Permission . ask ( {
type : "external_directory" ,
pattern : [ parentDir , path . join ( parentDir , "*" ) ] ,
sessionID : ctx.sessionID ,
messageID : ctx.messageID ,
callID : ctx.callID ,
title : ` This command references paths outside of ${ Instance . directory } ` ,
metadata : {
command : params.command ,
} ,
} )
} else if ( agent . permission . external_directory === "deny" ) {
throw new Permission . RejectedError (
ctx . sessionID ,
"external_directory" ,
ctx . callID ,
{
command : params.command ,
} ,
` This command references paths outside of ${ Instance . directory } so it is not allowed to be executed. ` ,
)
}
2025-11-19 18:31:24 +00:00
}
2025-11-17 07:06:44 +00:00
}
2025-07-31 00:57:52 +00:00
}
}
2025-11-21 22:29:47 +00:00
// always allow cd if it passes above check
if ( command [ 0 ] !== "cd" ) {
const action = Wildcard . allStructured ( { head : command [ 0 ] , tail : command.slice ( 1 ) } , permissions )
if ( action === "deny" ) {
throw new Error (
` The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${ JSON . stringify ( permissions ) } ` ,
)
}
if ( action === "ask" ) {
const pattern = ( ( ) = > {
if ( command . length === 0 ) return
const head = command [ 0 ]
// Find first non-flag argument as subcommand
const sub = command . slice ( 1 ) . find ( ( arg ) = > ! arg . startsWith ( "-" ) )
return sub ? ` ${ head } ${ sub } * ` : ` ${ head } * `
} ) ( )
if ( pattern ) {
askPatterns . add ( pattern )
}
2025-09-14 14:01:57 +00:00
}
}
2025-07-31 00:57:52 +00:00
}
2025-11-21 22:29:47 +00:00
if ( askPatterns . size > 0 ) {
const patterns = Array . from ( askPatterns )
await Permission . ask ( {
type : "bash" ,
pattern : patterns ,
sessionID : ctx.sessionID ,
messageID : ctx.messageID ,
callID : ctx.callID ,
title : params.command ,
metadata : {
command : params.command ,
patterns ,
} ,
} )
}
const proc = spawn ( params . command , {
shell ,
cwd : Instance.directory ,
env : {
. . . process . env ,
2025-07-31 00:57:52 +00:00
} ,
2025-11-21 22:29:47 +00:00
stdio : [ "ignore" , "pipe" , "pipe" ] ,
detached : process.platform !== "win32" ,
2025-07-31 00:57:52 +00:00
} )
2025-07-31 14:34:43 +00:00
2025-11-21 22:29:47 +00:00
let output = ""
// Initialize metadata with empty output
2025-08-11 05:23:00 +00:00
ctx . metadata ( {
metadata : {
2025-11-21 22:29:47 +00:00
output : "" ,
2025-08-11 05:23:00 +00:00
description : params.description ,
} ,
} )
2025-11-21 22:29:47 +00:00
const append = ( chunk : Buffer ) = > {
2025-12-05 19:56:56 +00:00
if ( output . length <= MAX_OUTPUT_LENGTH ) {
output += chunk . toString ( )
ctx . metadata ( {
metadata : {
output ,
description : params.description ,
} ,
} )
}
2025-11-21 22:29:47 +00:00
}
2025-10-16 19:39:36 +00:00
2025-11-21 22:29:47 +00:00
proc . stdout ? . on ( "data" , append )
proc . stderr ? . on ( "data" , append )
2025-08-03 19:34:37 +00:00
2025-11-21 22:29:47 +00:00
let timedOut = false
let aborted = false
let exited = false
2025-10-16 19:39:36 +00:00
2025-11-21 22:29:47 +00:00
const killTree = async ( ) = > {
const pid = proc . pid
if ( ! pid || exited ) {
return
}
2025-10-16 19:39:36 +00:00
2025-11-21 22:29:47 +00:00
if ( process . platform === "win32" ) {
await new Promise < void > ( ( resolve ) = > {
const killer = spawn ( "taskkill" , [ "/pid" , String ( pid ) , "/f" , "/t" ] , { stdio : "ignore" } )
killer . once ( "exit" , resolve )
killer . once ( "error" , resolve )
} )
return
2025-10-16 19:39:36 +00:00
}
2025-11-21 22:29:47 +00:00
try {
process . kill ( - pid , "SIGTERM" )
await Bun . sleep ( SIGKILL_TIMEOUT_MS )
if ( ! exited ) {
process . kill ( - pid , "SIGKILL" )
}
} catch ( _e ) {
proc . kill ( "SIGTERM" )
await Bun . sleep ( SIGKILL_TIMEOUT_MS )
if ( ! exited ) {
proc . kill ( "SIGKILL" )
}
2025-10-16 19:39:36 +00:00
}
}
2025-11-21 22:29:47 +00:00
if ( ctx . abort . aborted ) {
aborted = true
await killTree ( )
}
2025-10-16 19:39:36 +00:00
2025-11-21 22:29:47 +00:00
const abortHandler = ( ) = > {
aborted = true
void killTree ( )
}
2025-10-16 19:39:36 +00:00
2025-11-21 22:29:47 +00:00
ctx . abort . addEventListener ( "abort" , abortHandler , { once : true } )
2025-10-16 19:39:36 +00:00
2025-11-21 22:29:47 +00:00
const timeoutTimer = setTimeout ( ( ) = > {
timedOut = true
void killTree ( )
2025-12-05 19:56:56 +00:00
} , timeout + 100 )
2025-10-16 19:39:36 +00:00
2025-11-21 22:29:47 +00:00
await new Promise < void > ( ( resolve , reject ) = > {
const cleanup = ( ) = > {
clearTimeout ( timeoutTimer )
ctx . abort . removeEventListener ( "abort" , abortHandler )
}
2025-10-16 19:39:36 +00:00
2025-11-21 22:29:47 +00:00
proc . once ( "exit" , ( ) = > {
exited = true
cleanup ( )
resolve ( )
} )
2025-08-03 19:34:37 +00:00
2025-11-21 22:29:47 +00:00
proc . once ( "error" , ( error ) = > {
exited = true
cleanup ( )
reject ( error )
} )
2025-10-16 19:39:36 +00:00
} )
2025-06-03 17:08:47 +00:00
2025-12-04 17:33:00 +00:00
let resultMetadata : String [ ] = [ "<bash_metadata>" ]
2025-11-21 22:29:47 +00:00
if ( output . length > MAX_OUTPUT_LENGTH ) {
output = output . slice ( 0 , MAX_OUTPUT_LENGTH )
2025-12-05 19:56:56 +00:00
resultMetadata . push ( ` bash tool truncated output as it exceeded ${ MAX_OUTPUT_LENGTH } char limit ` )
2025-11-21 22:29:47 +00:00
}
2025-08-12 18:51:13 +00:00
2025-11-21 22:29:47 +00:00
if ( timedOut ) {
2025-12-05 19:56:56 +00:00
resultMetadata . push ( ` bash tool terminated commmand after exceeding timeout ${ timeout } ms ` )
2025-11-21 22:29:47 +00:00
}
2025-10-06 04:55:01 +00:00
2025-11-21 22:29:47 +00:00
if ( aborted ) {
2025-12-05 19:56:56 +00:00
resultMetadata . push ( "User aborted the command" )
2025-12-04 17:33:00 +00:00
}
if ( resultMetadata . length > 1 ) {
resultMetadata . push ( "</bash_metadata>" )
output += "\n\n" + resultMetadata . join ( "\n" )
2025-11-21 22:29:47 +00:00
}
2025-10-16 19:39:36 +00:00
2025-11-21 22:29:47 +00:00
return {
title : params.description ,
metadata : {
output ,
exit : proc.exitCode ,
description : params.description ,
} ,
2025-08-11 05:23:00 +00:00
output ,
2025-11-21 22:29:47 +00:00
}
} ,
}
2025-05-31 18:41:00 +00:00
} )