2026-01-18 04:27:33 +00:00
import crypto from "node:crypto" ;
2026-01-17 04:57:04 +00:00
import { spawn , type ChildProcessWithoutNullStreams } from "node:child_process" ;
2026-01-14 05:39:59 +00:00
import type { AgentTool , AgentToolResult } from "@mariozechner/pi-agent-core" ;
import { Type } from "@sinclair/typebox" ;
2026-01-18 04:27:33 +00:00
import {
type ExecAsk ,
type ExecHost ,
type ExecSecurity ,
addAllowlistEntry ,
matchAllowlist ,
maxAsk ,
minSecurity ,
recordAllowlistUse ,
requestExecApprovalViaSocket ,
resolveCommandResolution ,
resolveExecApprovals ,
} from "../infra/exec-approvals.js" ;
2026-01-17 05:43:27 +00:00
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js" ;
import { enqueueSystemEvent } from "../infra/system-events.js" ;
2026-01-14 05:39:59 +00:00
import { logInfo } from "../logger.js" ;
2026-01-17 04:57:04 +00:00
import {
2026-01-17 05:43:27 +00:00
type ProcessSession ,
2026-01-17 04:57:04 +00:00
type SessionStdin ,
addSession ,
appendOutput ,
2026-01-17 06:23:21 +00:00
createSessionSlug ,
2026-01-17 04:57:04 +00:00
markBackgrounded ,
markExited ,
2026-01-17 05:43:27 +00:00
tail ,
2026-01-17 04:57:04 +00:00
} from "./bash-process-registry.js" ;
2026-01-14 05:39:59 +00:00
import type { BashSandboxConfig } from "./bash-tools.shared.js" ;
import {
buildDockerExecArgs ,
buildSandboxEnv ,
chunkString ,
clampNumber ,
coerceEnv ,
killSession ,
readEnvInt ,
resolveSandboxWorkdir ,
resolveWorkdir ,
truncateMiddle ,
} from "./bash-tools.shared.js" ;
2026-01-18 04:27:33 +00:00
import { callGatewayTool } from "./tools/gateway.js" ;
import { listNodes , resolveNodeIdFromList } from "./tools/nodes-utils.js" ;
2026-01-14 05:39:59 +00:00
import { getShellConfig , sanitizeBinaryOutput } from "./shell-utils.js" ;
2026-01-17 07:05:15 +00:00
import { buildCursorPositionResponse , stripDsrRequests } from "./pty-dsr.js" ;
2026-01-14 05:39:59 +00:00
const DEFAULT_MAX_OUTPUT = clampNumber (
readEnvInt ( "PI_BASH_MAX_OUTPUT_CHARS" ) ,
30 _000 ,
1 _000 ,
150 _000 ,
) ;
2026-01-17 08:18:27 +00:00
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber (
readEnvInt ( "CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS" ) ,
30 _000 ,
1 _000 ,
150 _000 ,
) ;
2026-01-14 05:39:59 +00:00
const DEFAULT_PATH =
2026-01-14 14:31:43 +00:00
process . env . PATH ? ? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ;
2026-01-17 05:43:27 +00:00
const DEFAULT_NOTIFY_TAIL_CHARS = 400 ;
2026-01-14 05:39:59 +00:00
2026-01-17 04:57:04 +00:00
type PtyExitEvent = { exitCode : number ; signal? : number } ;
type PtyListener < T > = ( event : T ) = > void ;
type PtyHandle = {
pid : number ;
write : ( data : string | Buffer ) = > void ;
onData : ( listener : PtyListener < string > ) = > void ;
onExit : ( listener : PtyListener < PtyExitEvent > ) = > void ;
} ;
type PtySpawn = (
file : string ,
args : string [ ] | string ,
options : {
name? : string ;
cols? : number ;
rows? : number ;
cwd? : string ;
env? : Record < string , string > ;
} ,
) = > PtyHandle ;
2026-01-14 05:39:59 +00:00
export type ExecToolDefaults = {
2026-01-18 04:27:33 +00:00
host? : ExecHost ;
security? : ExecSecurity ;
ask? : ExecAsk ;
node? : string ;
agentId? : string ;
2026-01-14 05:39:59 +00:00
backgroundMs? : number ;
timeoutSec? : number ;
sandbox? : BashSandboxConfig ;
elevated? : ExecElevatedDefaults ;
allowBackground? : boolean ;
scopeKey? : string ;
2026-01-17 05:43:27 +00:00
sessionKey? : string ;
2026-01-17 17:55:04 +00:00
messageProvider? : string ;
2026-01-17 05:43:27 +00:00
notifyOnExit? : boolean ;
2026-01-14 05:39:59 +00:00
cwd? : string ;
} ;
export type { BashSandboxConfig } from "./bash-tools.shared.js" ;
export type ExecElevatedDefaults = {
enabled : boolean ;
allowed : boolean ;
defaultLevel : "on" | "off" ;
} ;
const execSchema = Type . Object ( {
command : Type.String ( { description : "Shell command to execute" } ) ,
2026-01-14 14:31:43 +00:00
workdir : Type.Optional ( Type . String ( { description : "Working directory (defaults to cwd)" } ) ) ,
2026-01-14 05:39:59 +00:00
env : Type.Optional ( Type . Record ( Type . String ( ) , Type . String ( ) ) ) ,
yieldMs : Type.Optional (
Type . Number ( {
description : "Milliseconds to wait before backgrounding (default 10000)" ,
} ) ,
) ,
2026-01-14 14:31:43 +00:00
background : Type.Optional ( Type . Boolean ( { description : "Run in background immediately" } ) ) ,
2026-01-14 05:39:59 +00:00
timeout : Type.Optional (
Type . Number ( {
description : "Timeout in seconds (optional, kills process on expiry)" ,
} ) ,
) ,
2026-01-17 04:57:04 +00:00
pty : Type.Optional (
Type . Boolean ( {
2026-01-17 05:48:34 +00:00
description :
"Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)" ,
2026-01-17 04:57:04 +00:00
} ) ,
) ,
2026-01-14 05:39:59 +00:00
elevated : Type.Optional (
Type . Boolean ( {
description : "Run on the host with elevated permissions (if allowed)" ,
} ) ,
) ,
2026-01-18 04:27:33 +00:00
host : Type.Optional (
Type . String ( {
description : "Exec host (sandbox|gateway|node)." ,
} ) ,
) ,
security : Type.Optional (
Type . String ( {
description : "Exec security mode (deny|allowlist|full)." ,
} ) ,
) ,
ask : Type.Optional (
Type . String ( {
description : "Exec ask mode (off|on-miss|always)." ,
} ) ,
) ,
node : Type.Optional (
Type . String ( {
description : "Node id/name for host=node." ,
} ) ,
) ,
2026-01-14 05:39:59 +00:00
} ) ;
export type ExecToolDetails =
| {
status : "running" ;
sessionId : string ;
pid? : number ;
startedAt : number ;
cwd? : string ;
tail? : string ;
}
| {
status : "completed" | "failed" ;
exitCode : number | null ;
durationMs : number ;
aggregated : string ;
cwd? : string ;
} ;
2026-01-18 04:27:33 +00:00
function normalizeExecHost ( value? : string | null ) : ExecHost | null {
const normalized = value ? . trim ( ) . toLowerCase ( ) ;
if ( normalized === "sandbox" || normalized === "gateway" || normalized === "node" ) {
return normalized ;
}
return null ;
}
function normalizeExecSecurity ( value? : string | null ) : ExecSecurity | null {
const normalized = value ? . trim ( ) . toLowerCase ( ) ;
if ( normalized === "deny" || normalized === "allowlist" || normalized === "full" ) {
return normalized ;
}
return null ;
}
function normalizeExecAsk ( value? : string | null ) : ExecAsk | null {
const normalized = value ? . trim ( ) . toLowerCase ( ) ;
if ( normalized === "off" || normalized === "on-miss" || normalized === "always" ) {
return normalized as ExecAsk ;
}
return null ;
}
function renderExecHostLabel ( host : ExecHost ) {
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node" ;
}
2026-01-17 05:43:27 +00:00
function normalizeNotifyOutput ( value : string ) {
return value . replace ( /\s+/g , " " ) . trim ( ) ;
}
function maybeNotifyOnExit ( session : ProcessSession , status : "completed" | "failed" ) {
if ( ! session . backgrounded || ! session . notifyOnExit || session . exitNotified ) return ;
const sessionKey = session . sessionKey ? . trim ( ) ;
if ( ! sessionKey ) return ;
session . exitNotified = true ;
const exitLabel = session . exitSignal
? ` signal ${ session . exitSignal } `
: ` code ${ session . exitCode ? ? 0 } ` ;
const output = normalizeNotifyOutput (
tail ( session . tail || session . aggregated || "" , DEFAULT_NOTIFY_TAIL_CHARS ) ,
) ;
const summary = output
? ` Exec ${ status } ( ${ session . id . slice ( 0 , 8 ) } , ${ exitLabel } ) :: ${ output } `
: ` Exec ${ status } ( ${ session . id . slice ( 0 , 8 ) } , ${ exitLabel } ) ` ;
enqueueSystemEvent ( summary , { sessionKey } ) ;
requestHeartbeatNow ( { reason : ` exec: ${ session . id } :exit ` } ) ;
}
2026-01-14 05:39:59 +00:00
export function createExecTool (
defaults? : ExecToolDefaults ,
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
) : AgentTool < any , ExecToolDetails > {
const defaultBackgroundMs = clampNumber (
defaults ? . backgroundMs ? ? readEnvInt ( "PI_BASH_YIELD_MS" ) ,
10 _000 ,
10 ,
120 _000 ,
) ;
const allowBackground = defaults ? . allowBackground ? ? true ;
const defaultTimeoutSec =
typeof defaults ? . timeoutSec === "number" && defaults . timeoutSec > 0
? defaults . timeoutSec
: 1800 ;
2026-01-17 05:43:27 +00:00
const notifyOnExit = defaults ? . notifyOnExit !== false ;
const notifySessionKey = defaults ? . sessionKey ? . trim ( ) || undefined ;
2026-01-14 05:39:59 +00:00
return {
name : "exec" ,
label : "exec" ,
description :
2026-01-17 04:57:04 +00:00
"Execute shell commands with background continuation. Use yieldMs/background to continue later via process tool. Use pty=true for TTY-required commands (terminal UIs, coding agents)." ,
2026-01-14 05:39:59 +00:00
parameters : execSchema ,
execute : async ( _toolCallId , args , signal , onUpdate ) = > {
const params = args as {
command : string ;
workdir? : string ;
env? : Record < string , string > ;
yieldMs? : number ;
background? : boolean ;
timeout? : number ;
2026-01-17 04:57:04 +00:00
pty? : boolean ;
2026-01-14 05:39:59 +00:00
elevated? : boolean ;
2026-01-18 04:27:33 +00:00
host? : string ;
security? : string ;
ask? : string ;
node? : string ;
2026-01-14 05:39:59 +00:00
} ;
if ( ! params . command ) {
throw new Error ( "Provide a command to start." ) ;
}
const maxOutput = DEFAULT_MAX_OUTPUT ;
2026-01-17 08:18:27 +00:00
const pendingMaxOutput = DEFAULT_PENDING_MAX_OUTPUT ;
2026-01-14 05:39:59 +00:00
const startedAt = Date . now ( ) ;
2026-01-17 06:23:21 +00:00
const sessionId = createSessionSlug ( ) ;
2026-01-14 05:39:59 +00:00
const warnings : string [ ] = [ ] ;
const backgroundRequested = params . background === true ;
const yieldRequested = typeof params . yieldMs === "number" ;
if ( ! allowBackground && ( backgroundRequested || yieldRequested ) ) {
2026-01-14 14:31:43 +00:00
warnings . push ( "Warning: background execution is disabled; running synchronously." ) ;
2026-01-14 05:39:59 +00:00
}
const yieldWindow = allowBackground
? backgroundRequested
? 0
2026-01-14 14:31:43 +00:00
: clampNumber ( params . yieldMs ? ? defaultBackgroundMs , defaultBackgroundMs , 10 , 120 _000 )
2026-01-14 05:39:59 +00:00
: null ;
const elevatedDefaults = defaults ? . elevated ;
const elevatedDefaultOn =
elevatedDefaults ? . defaultLevel === "on" &&
elevatedDefaults . enabled &&
elevatedDefaults . allowed ;
const elevatedRequested =
2026-01-14 14:31:43 +00:00
typeof params . elevated === "boolean" ? params.elevated : elevatedDefaultOn ;
2026-01-14 05:39:59 +00:00
if ( elevatedRequested ) {
if ( ! elevatedDefaults ? . enabled || ! elevatedDefaults . allowed ) {
const runtime = defaults ? . sandbox ? "sandboxed" : "direct" ;
const gates : string [ ] = [ ] ;
2026-01-17 17:55:04 +00:00
const contextParts : string [ ] = [ ] ;
const provider = defaults ? . messageProvider ? . trim ( ) ;
const sessionKey = defaults ? . sessionKey ? . trim ( ) ;
if ( provider ) contextParts . push ( ` provider= ${ provider } ` ) ;
if ( sessionKey ) contextParts . push ( ` session= ${ sessionKey } ` ) ;
2026-01-14 05:39:59 +00:00
if ( ! elevatedDefaults ? . enabled ) {
2026-01-14 14:31:43 +00:00
gates . push ( "enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)" ) ;
2026-01-14 05:39:59 +00:00
} else {
gates . push (
"allowFrom (tools.elevated.allowFrom.<provider> / agents.list[].tools.elevated.allowFrom.<provider>)" ,
) ;
}
throw new Error (
[
` elevated is not available right now (runtime= ${ runtime } ). ` ,
` Failing gates: ${ gates . join ( ", " ) } ` ,
2026-01-17 17:55:04 +00:00
contextParts . length > 0 ? ` Context: ${ contextParts . join ( " " ) } ` : undefined ,
2026-01-14 05:39:59 +00:00
"Fix-it keys:" ,
"- tools.elevated.enabled" ,
"- tools.elevated.allowFrom.<provider>" ,
"- agents.list[].tools.elevated.enabled" ,
"- agents.list[].tools.elevated.allowFrom.<provider>" ,
2026-01-17 17:55:04 +00:00
]
. filter ( Boolean )
. join ( "\n" ) ,
2026-01-14 05:39:59 +00:00
) ;
}
logInfo (
` exec: elevated command ( ${ sessionId . slice ( 0 , 8 ) } ) ${ truncateMiddle (
params . command ,
120 ,
) } ` ,
) ;
}
2026-01-18 04:27:33 +00:00
const configuredHost = defaults ? . host ? ? "sandbox" ;
const requestedHost = normalizeExecHost ( params . host ) ? ? null ;
let host : ExecHost = requestedHost ? ? configuredHost ;
if ( ! elevatedRequested && requestedHost && requestedHost !== configuredHost ) {
throw new Error (
` exec host not allowed (requested ${ renderExecHostLabel ( requestedHost ) } ; ` +
` configure tools.exec.host= ${ renderExecHostLabel ( configuredHost ) } to allow). ` ,
) ;
}
if ( elevatedRequested ) {
host = "gateway" ;
}
2026-01-14 05:39:59 +00:00
2026-01-18 04:27:33 +00:00
const configuredSecurity = defaults ? . security ? ? "deny" ;
const requestedSecurity = normalizeExecSecurity ( params . security ) ;
2026-01-18 04:37:15 +00:00
let security = minSecurity ( configuredSecurity , requestedSecurity ? ? configuredSecurity ) ;
2026-01-18 04:27:33 +00:00
if ( elevatedRequested ) {
security = "full" ;
}
const configuredAsk = defaults ? . ask ? ? "on-miss" ;
const requestedAsk = normalizeExecAsk ( params . ask ) ;
let ask = maxAsk ( configuredAsk , requestedAsk ? ? configuredAsk ) ;
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined ;
2026-01-14 14:31:43 +00:00
const rawWorkdir = params . workdir ? . trim ( ) || defaults ? . cwd || process . cwd ( ) ;
2026-01-14 05:39:59 +00:00
let workdir = rawWorkdir ;
let containerWorkdir = sandbox ? . containerWorkdir ;
if ( sandbox ) {
const resolved = await resolveSandboxWorkdir ( {
workdir : rawWorkdir ,
sandbox ,
warnings ,
} ) ;
workdir = resolved . hostWorkdir ;
containerWorkdir = resolved . containerWorkdir ;
} else {
workdir = resolveWorkdir ( rawWorkdir , warnings ) ;
}
const { shell , args : shellArgs } = getShellConfig ( ) ;
const baseEnv = coerceEnv ( process . env ) ;
const mergedEnv = params . env ? { . . . baseEnv , . . . params . env } : baseEnv ;
const env = sandbox
? buildSandboxEnv ( {
defaultPath : DEFAULT_PATH ,
paramsEnv : params.env ,
sandboxEnv : sandbox.env ,
containerWorkdir : containerWorkdir ? ? sandbox . containerWorkdir ,
} )
: mergedEnv ;
2026-01-18 04:27:33 +00:00
if ( host === "node" ) {
if ( security === "deny" ) {
throw new Error ( "exec denied: host=node security=deny" ) ;
}
const boundNode = defaults ? . node ? . trim ( ) ;
const requestedNode = params . node ? . trim ( ) ;
if ( boundNode && requestedNode && boundNode !== requestedNode ) {
throw new Error ( ` exec node not allowed (bound to ${ boundNode } ) ` ) ;
}
const nodeQuery = boundNode || requestedNode ;
const nodes = await listNodes ( { } ) ;
if ( nodes . length === 0 ) {
throw new Error (
"exec host=node requires a paired node (none available). This requires the macOS companion app." ,
) ;
}
let nodeId : string ;
try {
nodeId = resolveNodeIdFromList ( nodes , nodeQuery , ! nodeQuery ) ;
} catch ( err ) {
if ( ! nodeQuery && String ( err ) . includes ( "node required" ) ) {
throw new Error (
"exec host=node requires a node id when multiple nodes are available (set tools.exec.node or exec.node)." ,
) ;
}
throw err ;
}
const nodeInfo = nodes . find ( ( entry ) = > entry . nodeId === nodeId ) ;
const supportsSystemRun = Array . isArray ( nodeInfo ? . commands )
? nodeInfo ? . commands ? . includes ( "system.run" )
: false ;
if ( ! supportsSystemRun ) {
throw new Error ( "exec host=node requires a node that supports system.run." ) ;
}
const argv = [ "/bin/sh" , "-lc" , params . command ] ;
const invokeParams : Record < string , unknown > = {
nodeId ,
command : "system.run" ,
params : {
command : argv ,
cwd : workdir ,
env : params.env ,
timeoutMs : typeof params . timeout === "number" ? params . timeout * 1000 : undefined ,
agentId : defaults?.agentId ,
sessionKey : defaults?.sessionKey ,
} ,
idempotencyKey : crypto.randomUUID ( ) ,
} ;
const raw = ( await callGatewayTool ( "node.invoke" , { } , invokeParams ) ) as {
payload ? : {
exitCode? : number ;
timedOut? : boolean ;
success? : boolean ;
stdout? : string ;
stderr? : string ;
error? : string | null ;
} ;
} ;
const payload = raw ? . payload ? ? { } ;
return {
content : [
{
type : "text" ,
text : payload.stdout || payload . stderr || payload . error || "" ,
} ,
] ,
details : {
status : payload.success ? "completed" : "failed" ,
exitCode : payload.exitCode ? ? null ,
durationMs : Date.now ( ) - startedAt ,
aggregated : [ payload . stdout , payload . stderr , payload . error ] . filter ( Boolean ) . join ( "\n" ) ,
cwd : workdir ,
} satisfies ExecToolDetails ,
} ;
}
if ( host === "gateway" ) {
const approvals = resolveExecApprovals ( defaults ? . agentId ) ;
const hostSecurity = minSecurity ( security , approvals . agent . security ) ;
const hostAsk = maxAsk ( ask , approvals . agent . ask ) ;
const askFallback = approvals . agent . askFallback ;
if ( hostSecurity === "deny" ) {
throw new Error ( "exec denied: host=gateway security=deny" ) ;
}
const resolution = resolveCommandResolution ( params . command , workdir , env ) ;
const allowlistMatch =
hostSecurity === "allowlist" ? matchAllowlist ( approvals . allowlist , resolution ) : null ;
const requiresAsk =
hostAsk === "always" ||
( hostAsk === "on-miss" && hostSecurity === "allowlist" && ! allowlistMatch ) ;
if ( requiresAsk ) {
const decision =
( await requestExecApprovalViaSocket ( {
socketPath : approvals.socketPath ,
token : approvals.token ,
request : {
command : params.command ,
cwd : workdir ,
host : "gateway" ,
security : hostSecurity ,
ask : hostAsk ,
agentId : defaults?.agentId ,
resolvedPath : resolution?.resolvedPath ? ? null ,
} ,
} ) ) ? ? null ;
if ( decision === "deny" ) {
throw new Error ( "exec denied: user denied" ) ;
}
if ( ! decision ) {
if ( askFallback === "deny" ) {
throw new Error (
"exec denied: approval required (companion app approval UI not available)" ,
) ;
}
if ( askFallback === "allowlist" ) {
if ( ! allowlistMatch ) {
throw new Error (
"exec denied: approval required (companion app approval UI not available)" ,
) ;
}
}
}
if ( decision === "allow-always" && hostSecurity === "allowlist" ) {
const pattern =
resolution ? . resolvedPath ? ?
resolution ? . rawExecutable ? ?
params . command . split ( /\s+/ ) . shift ( ) ? ?
"" ;
if ( pattern ) {
addAllowlistEntry ( approvals . file , defaults ? . agentId , pattern ) ;
}
}
}
if ( allowlistMatch ) {
recordAllowlistUse (
approvals . file ,
defaults ? . agentId ,
allowlistMatch ,
params . command ,
resolution ? . resolvedPath ,
) ;
}
}
2026-01-17 04:57:04 +00:00
const usePty = params . pty === true && ! sandbox ;
let child : ChildProcessWithoutNullStreams | null = null ;
let pty : PtyHandle | null = null ;
let stdin : SessionStdin | undefined ;
if ( sandbox ) {
child = spawn (
2026-01-17 05:48:34 +00:00
"docker" ,
buildDockerExecArgs ( {
containerName : sandbox.containerName ,
command : params.command ,
workdir : containerWorkdir ? ? sandbox . containerWorkdir ,
env ,
tty : params.pty === true ,
} ) ,
{
cwd : workdir ,
env : process.env ,
detached : process.platform !== "win32" ,
stdio : [ "pipe" , "pipe" , "pipe" ] ,
windowsHide : true ,
} ,
) as ChildProcessWithoutNullStreams ;
2026-01-17 04:57:04 +00:00
stdin = child . stdin ;
} else if ( usePty ) {
const ptyModule = ( await import ( "@lydell/node-pty" ) ) as unknown as {
spawn? : PtySpawn ;
default ? : { spawn? : PtySpawn } ;
} ;
const spawnPty = ptyModule . spawn ? ? ptyModule . default ? . spawn ;
if ( ! spawnPty ) {
throw new Error ( "PTY support is unavailable (node-pty spawn not found)." ) ;
}
pty = spawnPty ( shell , [ . . . shellArgs , params . command ] , {
cwd : workdir ,
env ,
name : process.env.TERM ? ? "xterm-256color" ,
cols : 120 ,
rows : 30 ,
} ) ;
stdin = {
destroyed : false ,
write : ( data , cb ) = > {
try {
pty ? . write ( data ) ;
cb ? . ( null ) ;
} catch ( err ) {
cb ? . ( err as Error ) ;
}
} ,
end : ( ) = > {
try {
const eof = process . platform === "win32" ? "\x1a" : "\x04" ;
pty ? . write ( eof ) ;
} catch {
// ignore EOF errors
}
} ,
} ;
} else {
child = spawn ( shell , [ . . . shellArgs , params . command ] , {
2026-01-17 05:48:34 +00:00
cwd : workdir ,
env ,
detached : process.platform !== "win32" ,
stdio : [ "pipe" , "pipe" , "pipe" ] ,
windowsHide : true ,
2026-01-17 04:57:04 +00:00
} ) as ChildProcessWithoutNullStreams ;
stdin = child . stdin ;
}
2026-01-14 05:39:59 +00:00
const session = {
id : sessionId ,
command : params.command ,
scopeKey : defaults?.scopeKey ,
2026-01-17 05:43:27 +00:00
sessionKey : notifySessionKey ,
notifyOnExit ,
exitNotified : false ,
2026-01-17 04:57:04 +00:00
child : child ? ? undefined ,
stdin ,
pid : child?.pid ? ? pty ? . pid ,
2026-01-14 05:39:59 +00:00
startedAt ,
cwd : workdir ,
maxOutputChars : maxOutput ,
2026-01-17 08:18:27 +00:00
pendingMaxOutputChars : pendingMaxOutput ,
2026-01-14 05:39:59 +00:00
totalOutputChars : 0 ,
pendingStdout : [ ] ,
pendingStderr : [ ] ,
2026-01-17 08:18:27 +00:00
pendingStdoutChars : 0 ,
pendingStderrChars : 0 ,
2026-01-14 05:39:59 +00:00
aggregated : "" ,
tail : "" ,
exited : false ,
exitCode : undefined as number | null | undefined ,
exitSignal : undefined as NodeJS . Signals | number | null | undefined ,
truncated : false ,
backgrounded : false ,
} ;
addSession ( session ) ;
let settled = false ;
let yielded = false ;
let yieldTimer : NodeJS.Timeout | null = null ;
let timeoutTimer : NodeJS.Timeout | null = null ;
2026-01-17 03:52:37 +00:00
let timeoutFinalizeTimer : NodeJS.Timeout | null = null ;
2026-01-14 05:39:59 +00:00
let timedOut = false ;
2026-01-17 03:52:37 +00:00
const timeoutFinalizeMs = 1000 ;
let rejectFn : ( ( err : Error ) = > void ) | null = null ;
2026-01-14 05:39:59 +00:00
const settle = ( fn : ( ) = > void ) = > {
if ( settled ) return ;
settled = true ;
fn ( ) ;
} ;
2026-01-17 03:52:37 +00:00
const effectiveTimeout =
typeof params . timeout === "number" ? params.timeout : defaultTimeoutSec ;
const finalizeTimeout = ( ) = > {
if ( session . exited ) return ;
markExited ( session , null , "SIGKILL" , "failed" ) ;
2026-01-17 05:43:27 +00:00
maybeNotifyOnExit ( session , "failed" ) ;
2026-01-17 03:52:37 +00:00
if ( settled || ! rejectFn ) return ;
const aggregated = session . aggregated . trim ( ) ;
const reason = ` Command timed out after ${ effectiveTimeout } seconds ` ;
const message = aggregated ? ` ${ aggregated } \ n \ n ${ reason } ` : reason ;
settle ( ( ) = > rejectFn ? . ( new Error ( message ) ) ) ;
} ;
2026-01-16 10:43:08 +00:00
// Tool-call abort should not kill backgrounded sessions; timeouts still must.
const onAbortSignal = ( ) = > {
2026-01-16 15:31:39 +05:30
if ( yielded || session . backgrounded ) return ;
2026-01-14 05:39:59 +00:00
killSession ( session ) ;
} ;
2026-01-16 10:43:08 +00:00
// Timeouts always terminate, even for backgrounded sessions.
const onTimeout = ( ) = > {
timedOut = true ;
killSession ( session ) ;
2026-01-17 03:52:37 +00:00
if ( ! timeoutFinalizeTimer ) {
timeoutFinalizeTimer = setTimeout ( ( ) = > {
finalizeTimeout ( ) ;
} , timeoutFinalizeMs ) ;
}
2026-01-16 10:43:08 +00:00
} ;
if ( signal ? . aborted ) onAbortSignal ( ) ;
2026-01-14 05:39:59 +00:00
else if ( signal ) {
2026-01-16 10:43:08 +00:00
signal . addEventListener ( "abort" , onAbortSignal , { once : true } ) ;
2026-01-14 05:39:59 +00:00
}
if ( effectiveTimeout > 0 ) {
timeoutTimer = setTimeout ( ( ) = > {
2026-01-16 10:43:08 +00:00
onTimeout ( ) ;
2026-01-14 05:39:59 +00:00
} , effectiveTimeout * 1000 ) ;
}
const emitUpdate = ( ) = > {
if ( ! onUpdate ) return ;
const tailText = session . tail || session . aggregated ;
const warningText = warnings . length ? ` ${ warnings . join ( "\n" ) } \ n \ n ` : "" ;
onUpdate ( {
content : [ { type : "text" , text : warningText + ( tailText || "" ) } ] ,
details : {
status : "running" ,
sessionId ,
pid : session.pid ? ? undefined ,
startedAt ,
cwd : session.cwd ,
tail : session.tail ,
} ,
} ) ;
} ;
2026-01-17 04:57:04 +00:00
const handleStdout = ( data : string ) = > {
2026-01-14 05:39:59 +00:00
const str = sanitizeBinaryOutput ( data . toString ( ) ) ;
for ( const chunk of chunkString ( str ) ) {
appendOutput ( session , "stdout" , chunk ) ;
emitUpdate ( ) ;
}
2026-01-17 04:57:04 +00:00
} ;
2026-01-14 05:39:59 +00:00
2026-01-17 04:57:04 +00:00
const handleStderr = ( data : string ) = > {
2026-01-14 05:39:59 +00:00
const str = sanitizeBinaryOutput ( data . toString ( ) ) ;
for ( const chunk of chunkString ( str ) ) {
appendOutput ( session , "stderr" , chunk ) ;
emitUpdate ( ) ;
}
2026-01-17 04:57:04 +00:00
} ;
if ( pty ) {
2026-01-17 07:05:15 +00:00
const cursorResponse = buildCursorPositionResponse ( ) ;
pty . onData ( ( data ) = > {
const raw = data . toString ( ) ;
const { cleaned , requests } = stripDsrRequests ( raw ) ;
if ( requests > 0 ) {
for ( let i = 0 ; i < requests ; i += 1 ) {
pty . write ( cursorResponse ) ;
}
}
handleStdout ( cleaned ) ;
} ) ;
2026-01-17 04:57:04 +00:00
} else if ( child ) {
child . stdout . on ( "data" , handleStdout ) ;
child . stderr . on ( "data" , handleStderr ) ;
}
2026-01-14 05:39:59 +00:00
2026-01-14 14:31:43 +00:00
return new Promise < AgentToolResult < ExecToolDetails > > ( ( resolve , reject ) = > {
2026-01-17 03:52:37 +00:00
rejectFn = reject ;
2026-01-14 14:31:43 +00:00
const resolveRunning = ( ) = > {
settle ( ( ) = >
resolve ( {
content : [
{
type : "text" ,
text :
` ${ warnings . length ? ` ${ warnings . join ( "\n" ) } \ n \ n ` : "" } ` +
` Command still running (session ${ sessionId } , pid ${ session . pid ? ? "n/a" } ). ` +
"Use process (list/poll/log/write/kill/clear/remove) for follow-up." ,
2026-01-14 05:39:59 +00:00
} ,
2026-01-14 14:31:43 +00:00
] ,
details : {
status : "running" ,
sessionId ,
pid : session.pid ? ? undefined ,
startedAt ,
cwd : session.cwd ,
tail : session.tail ,
} ,
} ) ,
) ;
} ;
const onYieldNow = ( ) = > {
if ( yieldTimer ) clearTimeout ( yieldTimer ) ;
if ( settled ) return ;
yielded = true ;
markBackgrounded ( session ) ;
resolveRunning ( ) ;
} ;
if ( allowBackground && yieldWindow !== null ) {
if ( yieldWindow === 0 ) {
onYieldNow ( ) ;
} else {
yieldTimer = setTimeout ( ( ) = > {
if ( settled ) return ;
yielded = true ;
markBackgrounded ( session ) ;
resolveRunning ( ) ;
} , yieldWindow ) ;
2026-01-14 05:39:59 +00:00
}
2026-01-14 14:31:43 +00:00
}
2026-01-14 05:39:59 +00:00
2026-01-14 14:31:43 +00:00
const handleExit = ( code : number | null , exitSignal : NodeJS.Signals | number | null ) = > {
if ( yieldTimer ) clearTimeout ( yieldTimer ) ;
if ( timeoutTimer ) clearTimeout ( timeoutTimer ) ;
2026-01-17 03:52:37 +00:00
if ( timeoutFinalizeTimer ) clearTimeout ( timeoutFinalizeTimer ) ;
2026-01-14 14:31:43 +00:00
const durationMs = Date . now ( ) - startedAt ;
const wasSignal = exitSignal != null ;
const isSuccess = code === 0 && ! wasSignal && ! signal ? . aborted && ! timedOut ;
const status : "completed" | "failed" = isSuccess ? "completed" : "failed" ;
markExited ( session , code , exitSignal , status ) ;
2026-01-17 05:43:27 +00:00
maybeNotifyOnExit ( session , status ) ;
2026-01-17 04:57:04 +00:00
if ( ! session . child && session . stdin ) {
session . stdin . destroyed = true ;
}
2026-01-14 14:31:43 +00:00
if ( yielded || session . backgrounded ) return ;
const aggregated = session . aggregated . trim ( ) ;
if ( ! isSuccess ) {
const reason = timedOut
? ` Command timed out after ${ effectiveTimeout } seconds `
: wasSignal && exitSignal
? ` Command aborted by signal ${ exitSignal } `
: code === null
? "Command aborted before exit code was captured"
: ` Command exited with code ${ code } ` ;
const message = aggregated ? ` ${ aggregated } \ n \ n ${ reason } ` : reason ;
settle ( ( ) = > reject ( new Error ( message ) ) ) ;
return ;
}
settle ( ( ) = >
resolve ( {
content : [
{
type : "text" ,
text :
` ${ warnings . length ? ` ${ warnings . join ( "\n" ) } \ n \ n ` : "" } ` +
( aggregated || "(no output)" ) ,
2026-01-14 05:39:59 +00:00
} ,
2026-01-14 14:31:43 +00:00
] ,
details : {
status : "completed" ,
exitCode : code ? ? 0 ,
durationMs ,
aggregated ,
cwd : session.cwd ,
} ,
} ) ,
) ;
} ;
2026-01-14 05:39:59 +00:00
2026-01-14 14:31:43 +00:00
// `exit` can fire before stdio fully flushes (notably on Windows).
// `close` waits for streams to close, so aggregated output is complete.
2026-01-17 04:57:04 +00:00
if ( pty ) {
pty . onExit ( ( event ) = > {
const rawSignal = event . signal ? ? null ;
const normalizedSignal = rawSignal === 0 ? null : rawSignal ;
handleExit ( event . exitCode ? ? null , normalizedSignal ) ;
} ) ;
} else if ( child ) {
child . once ( "close" , ( code , exitSignal ) = > {
handleExit ( code , exitSignal ) ;
} ) ;
2026-01-14 05:39:59 +00:00
2026-01-17 04:57:04 +00:00
child . once ( "error" , ( err ) = > {
if ( yieldTimer ) clearTimeout ( yieldTimer ) ;
if ( timeoutTimer ) clearTimeout ( timeoutTimer ) ;
if ( timeoutFinalizeTimer ) clearTimeout ( timeoutFinalizeTimer ) ;
markExited ( session , null , null , "failed" ) ;
2026-01-17 05:43:27 +00:00
maybeNotifyOnExit ( session , "failed" ) ;
2026-01-17 04:57:04 +00:00
settle ( ( ) = > reject ( err ) ) ;
} ) ;
}
2026-01-14 14:31:43 +00:00
} ) ;
2026-01-14 05:39:59 +00:00
} ,
} ;
}
export const execTool = createExecTool ( ) ;