2026-06-04 11:02:17 +08:00
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe , expect , test } from "bun:test"
import { Effect , Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AppProcess } from "@opencode-ai/core/process"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash"
2026-06-05 11:12:17 +08:00
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
2026-06-04 11:02:17 +08:00
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
2026-06-07 08:49:12 +08:00
import { toolIdentity , executeTool , settleTool , toolDefinitions } from "./lib/tool"
2026-06-04 11:02:17 +08:00
const sessionID = SessionV2 . ID . make ( "ses_bash_tool_test" )
const assertions : PermissionV2.AssertInput [ ] = [ ]
const runs : Array < {
readonly command : string
readonly cwd? : string
readonly shell? : string | boolean
readonly options? : AppProcess.RunOptions
} > = [ ]
let denyAction : string | undefined
let result : AppProcess.RunResult = {
command : "mock" ,
exitCode : 0 ,
stdout : Buffer.from ( "hello\n" ) ,
stderr : Buffer.alloc ( 0 ) ,
stdoutTruncated : false ,
stderrTruncated : false ,
}
let runFailure : AppProcess.AppProcessError | undefined
2026-06-06 11:08:23 +08:00
let afterPermission = ( _input : PermissionV2.AssertInput ) : Effect . Effect < void > = > Effect . void
2026-06-04 11:02:17 +08:00
const permission = Layer . succeed (
PermissionV2 . Service ,
PermissionV2 . Service . of ( {
assert : ( input ) = >
Effect . sync ( ( ) = > assertions . push ( input ) ) . pipe (
2026-06-06 11:08:23 +08:00
Effect . andThen ( Effect . suspend ( ( ) = > afterPermission ( input ) ) ) ,
2026-06-04 11:02:17 +08:00
Effect . andThen (
input . action === denyAction ? Effect . fail ( new PermissionV2 . DeniedError ( { rules : [ ] } ) ) : Effect . void ,
) ,
) ,
ask : ( ) = > Effect . die ( "unused" ) ,
reply : ( ) = > Effect . die ( "unused" ) ,
get : ( ) = > Effect . die ( "unused" ) ,
forSession : ( ) = > Effect . die ( "unused" ) ,
list : ( ) = > Effect . die ( "unused" ) ,
} ) ,
)
const appProcess = Layer . succeed (
AppProcess . Service ,
AppProcess . Service . of ( {
run : ( command : ChildProcess.Command , options? : AppProcess.RunOptions ) = >
Effect . suspend ( ( ) = > {
if ( command . _tag !== "StandardCommand" ) throw new Error ( "expected standard command" )
runs . push ( { command : command.command , cwd : command.options.cwd , shell : command.options.shell , options } )
return runFailure ? Effect . fail ( runFailure ) : Effect . succeed ( result )
} ) ,
} as unknown as AppProcess . Interface ) ,
)
const config = Layer . succeed (
Config . Service ,
Config . Service . of ( {
entries : ( ) = > Effect . succeed ( [ ] ) ,
} ) ,
)
const reset = ( ) = > {
assertions . length = 0
runs . length = 0
denyAction = undefined
runFailure = undefined
2026-06-06 11:08:23 +08:00
afterPermission = ( ) = > Effect . void
2026-06-04 11:02:17 +08:00
result = {
command : "mock" ,
exitCode : 0 ,
stdout : Buffer.from ( "hello\n" ) ,
stderr : Buffer.alloc ( 0 ) ,
stdoutTruncated : false ,
stderrTruncated : false ,
}
}
const withTool = < A , E , R > (
directory : string ,
body : ( registry : ToolRegistry.Interface ) = > Effect . Effect < A , E , R > ,
processLayer : Layer.Layer < AppProcess.Service > = appProcess ,
) = > {
const filesystem = FSUtil . defaultLayer
const activeLocation = Layer . succeed (
Location . Service ,
Location . Service . of ( location ( { directory : AbsolutePath.make ( directory ) } ) ) ,
)
const mutation = LocationMutation . layer . pipe ( Layer . provide ( filesystem ) , Layer . provide ( activeLocation ) )
2026-06-05 11:12:17 +08:00
const registry = ToolRegistry . defaultLayer . pipe ( Layer . provide ( permission ) )
2026-06-04 11:02:17 +08:00
const bash = BashTool . layer . pipe (
Layer . provide ( registry ) ,
Layer . provide ( permission ) ,
Layer . provide ( mutation ) ,
2026-06-06 11:08:23 +08:00
Layer . provide ( filesystem ) ,
2026-06-04 11:02:17 +08:00
Layer . provide ( processLayer ) ,
Layer . provide ( config ) ,
)
return Effect . gen ( function * ( ) {
return yield * body ( yield * ToolRegistry . Service )
} ) . pipe ( Effect . provide ( Layer . mergeAll ( registry , bash ) ) )
}
2026-06-07 09:55:34 +08:00
const call = ( input : typeof BashTool . Input . Type , id = "call-bash" ) = > ( {
2026-06-04 11:02:17 +08:00
sessionID ,
2026-06-07 08:49:12 +08:00
. . . toolIdentity ,
2026-06-04 11:02:17 +08:00
call : { type : "tool-call" as const , id , name : "bash" , input } ,
} )
const it = testEffect ( Layer . empty )
describe ( "BashTool" , ( ) = > {
it . live ( "registers and returns structured successful output from the active Location" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
return withTool ( tmp . path , ( registry ) = >
Effect . gen ( function * ( ) {
2026-06-07 08:49:12 +08:00
const definitions = yield * toolDefinitions ( registry )
2026-06-04 11:02:17 +08:00
expect ( definitions . map ( ( tool ) = > tool . name ) ) . toEqual ( [ "bash" ] )
expect ( definitions [ 0 ] ? . inputSchema ) . not . toHaveProperty ( "properties.background" )
2026-06-23 10:18:06 +08:00
expect ( definitions [ 0 ] ? . inputSchema ) . not . toHaveProperty ( "properties.description" )
2026-06-07 08:49:12 +08:00
expect ( yield * toolDefinitions ( registry , [ { action : "bash" , resource : "*" , effect : "deny" } ] ) ) . toEqual ( [ ] )
2026-06-23 10:18:06 +08:00
expect ( yield * settleTool ( registry , call ( { command : "pwd" } ) ) ) . toEqual ( {
2026-06-04 11:02:17 +08:00
result : { type : "text" , value : "hello\n\n\nCommand exited with code 0." } ,
output : {
structured : {
command : "pwd" ,
cwd : realpathSync ( tmp . path ) ,
exitCode : 0 ,
output : "hello\n" ,
truncated : false ,
} ,
content : [ { type : "text" , text : "hello\n\n\nCommand exited with code 0." } ] ,
} ,
} )
expect ( runs ) . toMatchObject ( [ { command : "pwd" , cwd : realpathSync ( tmp . path ) } ] )
expect ( runs [ 0 ] ? . options ) . toMatchObject ( {
maxOutputBytes : BashTool.MAX_CAPTURE_BYTES ,
maxErrorBytes : BashTool.MAX_CAPTURE_BYTES ,
} )
2026-06-07 08:49:12 +08:00
expect ( assertions ) . toMatchObject ( [ { sessionID , action : "bash" , resources : [ "pwd" ] , save : [ "pwd" ] } ] )
2026-06-04 11:02:17 +08:00
} ) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "resolves a relative workdir from the active Location" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
return Effect . promise ( ( ) = > fs . mkdir ( path . join ( tmp . path , "src" ) ) ) . pipe (
2026-06-07 08:49:12 +08:00
Effect . andThen (
withTool ( tmp . path , ( registry ) = > executeTool ( registry , call ( { command : "pwd" , workdir : "src" } ) ) ) ,
) ,
2026-06-04 11:02:17 +08:00
Effect . andThen (
Effect . sync ( ( ) = > expect ( runs ) . toMatchObject ( [ { cwd : realpathSync ( path . join ( tmp . path , "src" ) ) } ] ) ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
2026-06-06 11:08:23 +08:00
it . live ( "rejects a workdir that stops being a directory during approval" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
const workdir = path . join ( tmp . path , "src" )
afterPermission = ( input ) = >
input . action === "bash"
? Effect . promise ( async ( ) = > {
await fs . rm ( workdir , { recursive : true } )
await fs . writeFile ( workdir , "not a directory" )
} ) . pipe ( Effect . orDie )
: Effect . void
return Effect . promise ( ( ) = > fs . mkdir ( workdir ) ) . pipe (
2026-06-07 08:49:12 +08:00
Effect . andThen (
withTool ( tmp . path , ( registry ) = > executeTool ( registry , call ( { command : "pwd" , workdir : "src" } ) ) ) ,
) ,
2026-06-06 11:08:23 +08:00
Effect . andThen (
Effect . sync ( ( ) = > {
expect ( runs ) . toEqual ( [ ] )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "bash" ] )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
2026-06-04 11:02:17 +08:00
if ( process . platform !== "win32" ) {
it . live ( "executes a real shell command through AppProcess" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
return withTool (
tmp . path ,
2026-06-07 08:49:12 +08:00
( registry ) = > settleTool ( registry , call ( { command : "printf core-bash" } ) ) ,
2026-06-04 11:02:17 +08:00
AppProcess . defaultLayer ,
) . pipe (
Effect . andThen ( ( settled ) = >
Effect . sync ( ( ) = > {
expect ( settled . result ) . toEqual ( { type : "text" , value : "core-bash\n\nCommand exited with code 0." } )
expect ( settled . output ? . structured ) . toMatchObject ( {
command : "printf core-bash" ,
cwd : realpathSync ( tmp . path ) ,
exitCode : 0 ,
output : "core-bash" ,
} )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
}
it . live ( "approves an explicit external workdir before bash execution" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > Promise . all ( [ tmpdir ( ) , tmpdir ( ) ] ) ) ,
( [ active , outside ] ) = > {
reset ( )
return withTool ( active . path , ( registry ) = >
2026-06-07 08:49:12 +08:00
executeTool ( registry , call ( { command : "pwd" , workdir : outside.path } ) ) ,
2026-06-04 11:02:17 +08:00
) . pipe (
Effect . andThen (
Effect . sync ( ( ) = > {
expect ( assertions . map ( ( item ) = > item . action ) ) . toEqual ( [ "external_directory" , "bash" ] )
expect ( assertions [ 0 ] ) . toMatchObject ( {
resources : [ path . join ( realpathSync ( outside . path ) , "*" ) . replaceAll ( "\\" , "/" ) ] ,
} )
expect ( runs ) . toHaveLength ( 1 )
} ) ,
) ,
)
} ,
( [ active , outside ] ) = >
Effect . promise ( ( ) = >
Promise . all ( [ active [ Symbol . asyncDispose ] ( ) , outside [ Symbol . asyncDispose ] ( ) ] ) . then ( ( ) = > undefined ) ,
) ,
) ,
)
it . live ( "does not execute after external-directory or bash denial" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > Promise . all ( [ tmpdir ( ) , tmpdir ( ) ] ) ) ,
( [ active , outside ] ) = >
Effect . gen ( function * ( ) {
reset ( )
denyAction = "external_directory"
2026-06-07 08:49:12 +08:00
yield * withTool ( active . path , ( registry ) = >
executeTool ( registry , call ( { command : "pwd" , workdir : outside.path } ) ) ,
)
2026-06-04 11:02:17 +08:00
expect ( assertions . map ( ( item ) = > item . action ) ) . toEqual ( [ "external_directory" ] )
expect ( runs ) . toEqual ( [ ] )
reset ( )
denyAction = "bash"
2026-06-07 08:49:12 +08:00
yield * withTool ( active . path , ( registry ) = > executeTool ( registry , call ( { command : "pwd" } ) ) )
2026-06-04 11:02:17 +08:00
expect ( assertions . map ( ( item ) = > item . action ) ) . toEqual ( [ "bash" ] )
expect ( runs ) . toEqual ( [ ] )
} ) ,
( [ active , outside ] ) = >
Effect . promise ( ( ) = >
Promise . all ( [ active [ Symbol . asyncDispose ] ( ) , outside [ Symbol . asyncDispose ] ( ) ] ) . then ( ( ) = > undefined ) ,
) ,
) ,
)
it . live ( "reports external command arguments as advisory warnings without enforcing approval" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > Promise . all ( [ tmpdir ( ) , tmpdir ( ) ] ) ) ,
( [ active , outside ] ) = > {
reset ( )
denyAction = "external_directory"
const target = path . join ( outside . path , "secret.txt" )
2026-06-07 08:49:12 +08:00
return withTool ( active . path , ( registry ) = > settleTool ( registry , call ( { command : ` cat ${ target } ` } ) ) ) . pipe (
2026-06-04 11:02:17 +08:00
Effect . andThen ( ( settled ) = >
Effect . sync ( ( ) = > {
expect ( assertions . map ( ( item ) = > item . action ) ) . toEqual ( [ "bash" ] )
expect ( runs ) . toHaveLength ( 1 )
expect ( settled . output ? . structured ) . toMatchObject ( {
warnings : [
` Command argument references external directory ${ path . join ( realpathSync ( outside . path ) , "*" ) . replaceAll ( "\\" , "/" ) } . Bash runs with host-user filesystem, process, and network authority; this scan is advisory only. ` ,
] ,
} )
expect ( settled . result ) . toMatchObject ( { type : "text" , value : expect.stringContaining ( "Warnings:" ) } )
} ) ,
) ,
)
} ,
( [ active , outside ] ) = >
Effect . promise ( ( ) = >
Promise . all ( [ active [ Symbol . asyncDispose ] ( ) , outside [ Symbol . asyncDispose ] ( ) ] ) . then ( ( ) = > undefined ) ,
) ,
) ,
)
2026-06-07 08:49:12 +08:00
it . live ( "keeps non-zero exits useful" , ( ) = >
2026-06-04 11:02:17 +08:00
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
result = { . . . result , exitCode : 7 , stdout : Buffer.from ( "HEAD full output TAIL" ) }
2026-06-07 08:49:12 +08:00
return withTool ( tmp . path , ( registry ) = > settleTool ( registry , call ( { command : "false" } , "call-overflow" ) ) ) . pipe (
2026-06-04 11:02:17 +08:00
Effect . andThen ( ( settled ) = >
Effect . sync ( ( ) = > {
expect ( settled . result ) . toMatchObject ( {
type : "text" ,
value : expect.stringContaining ( "Command exited with code 7" ) ,
} )
expect ( settled . output ? . structured ) . toMatchObject ( {
command : "false" ,
cwd : realpathSync ( tmp . path ) ,
exitCode : 7 ,
2026-06-07 08:49:12 +08:00
output : "HEAD full output TAIL" ,
truncated : false ,
2026-06-04 11:02:17 +08:00
} )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "surfaces bounded process-capture truncation" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
result = { . . . result , stdoutTruncated : true }
2026-06-07 08:49:12 +08:00
return withTool ( tmp . path , ( registry ) = > settleTool ( registry , call ( { command : "verbose" } ) ) ) . pipe (
2026-06-04 11:02:17 +08:00
Effect . andThen ( ( settled ) = >
Effect . sync ( ( ) = > {
expect ( settled . output ? . structured ) . toMatchObject ( { truncated : true , stdoutTruncated : true } )
expect ( settled . result ) . toMatchObject ( {
type : "text" ,
value : expect.stringContaining ( "stdout capture truncated" ) ,
} )
expect ( settled . output ? . structured ) . not . toHaveProperty ( "resource" )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "returns a useful timeout settlement" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
runFailure = new AppProcess . AppProcessError ( { command : "sleep" , cause : new Error ( "Timed out" ) } )
2026-06-07 08:49:12 +08:00
return withTool ( tmp . path , ( registry ) = > settleTool ( registry , call ( { command : "sleep 60" , timeout : 10 } ) ) ) . pipe (
2026-06-04 11:02:17 +08:00
Effect . andThen ( ( settled ) = >
Effect . sync ( ( ) = > {
expect ( settled . result ) . toMatchObject ( {
type : "text" ,
value : expect.stringContaining ( "Command timed out" ) ,
} )
expect ( settled . output ? . structured ) . toMatchObject ( {
command : "sleep 60" ,
timedOut : true ,
truncated : false ,
} )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
} )
test ( "keeps locked deferred parity TODOs visible" , async ( ) = > {
const source = await fs . readFile ( new URL ( "../src/tool/bash.ts" , import . meta . url ) , "utf8" )
for ( const todo of [
"Port tree-sitter bash / PowerShell parser-based approval reduction." ,
"Port BashArity reusable command-prefix approvals." ,
"Replace token-based command-argument external-directory advisories with parser-based detection." ,
"Restore PowerShell and cmd-specific invocation/path handling on Windows." ,
"Add plugin shell.env environment augmentation once V2 plugin hooks exist." ,
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired." ,
"Persist background job status and define restart recovery before exposing remote observation." ,
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it." ,
"Revisit binary output handling if stdout/stderr decoding is text-only." ,
"Stream full shell output into managed storage while retaining only a bounded in-memory preview." ,
] ) {
expect ( source ) . toContain ( ` TODO: ${ todo } ` )
}
} )