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-19 00:35:39 +00:00
import path from "node:path" ;
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 ,
2026-01-21 22:02:17 -08:00
type ExecApprovalsFile ,
2026-01-18 04:27:33 +00:00
addAllowlistEntry ,
2026-01-23 00:10:19 +00:00
evaluateShellAllowlist ,
2026-01-18 04:27:33 +00:00
maxAsk ,
minSecurity ,
2026-01-21 22:02:17 -08:00
requiresExecApproval ,
2026-01-21 21:44:28 +00:00
resolveSafeBins ,
2026-01-18 04:27:33 +00:00
recordAllowlistUse ,
resolveExecApprovals ,
2026-01-21 22:02:17 -08:00
resolveExecApprovalsFromFile ,
2026-01-18 04:27:33 +00:00
} from "../infra/exec-approvals.js" ;
2026-01-17 05:43:27 +00:00
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js" ;
2026-01-18 07:44:28 +00:00
import { buildNodeShellCommand } from "../infra/node-shell.js" ;
2026-01-20 14:03:59 +00:00
import {
getShellPathFromLoginShell ,
resolveShellEnvFallbackTimeoutMs ,
} from "../infra/shell-env.js" ;
2026-01-17 05:43:27 +00:00
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-21 18:55:32 -08:00
import { parseAgentSessionKey , resolveAgentIdFromSessionKey } from "../routing/session-key.js" ;
2026-01-14 05:39:59 +00:00
const DEFAULT_MAX_OUTPUT = clampNumber (
readEnvInt ( "PI_BASH_MAX_OUTPUT_CHARS" ) ,
2026-01-18 15:23:36 +00:00
200 _000 ,
2026-01-14 05:39:59 +00:00
1 _000 ,
2026-01-18 15:23:36 +00:00
200 _000 ,
2026-01-14 05:39:59 +00:00
) ;
2026-01-17 08:18:27 +00:00
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber (
readEnvInt ( "CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS" ) ,
2026-01-18 15:23:36 +00:00
200 _000 ,
2026-01-17 08:18:27 +00:00
1 _000 ,
2026-01-18 15:23:36 +00:00
200 _000 ,
2026-01-17 08:18:27 +00:00
) ;
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-22 00:49:02 +00:00
const DEFAULT_APPROVAL_TIMEOUT_MS = 120 _000 ;
const DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS = 130 _000 ;
const DEFAULT_APPROVAL_RUNNING_NOTICE_MS = 10 _000 ;
const APPROVAL_SLUG_LENGTH = 8 ;
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-22 00:49:02 +00:00
type ExecProcessOutcome = {
status : "completed" | "failed" ;
exitCode : number | null ;
exitSignal : NodeJS.Signals | number | null ;
durationMs : number ;
aggregated : string ;
timedOut : boolean ;
reason? : string ;
} ;
type ExecProcessHandle = {
session : ProcessSession ;
startedAt : number ;
pid? : number ;
promise : Promise < ExecProcessOutcome > ;
kill : ( ) = > void ;
} ;
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 ;
2026-01-19 00:35:39 +00:00
pathPrepend? : string [ ] ;
2026-01-21 21:44:28 +00:00
safeBins? : string [ ] ;
2026-01-18 04:27:33 +00:00
agentId? : string ;
2026-01-14 05:39:59 +00:00
backgroundMs? : number ;
timeoutSec? : number ;
2026-01-22 00:49:02 +00:00
approvalRunningNoticeMs? : number ;
2026-01-14 05:39:59 +00:00
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 ;
2026-01-22 05:32:13 +00:00
defaultLevel : "on" | "off" | "ask" | "full" ;
2026-01-14 05:39:59 +00:00
} ;
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-22 00:49:02 +00:00
}
| {
status : "approval-pending" ;
approvalId : string ;
approvalSlug : string ;
expiresAtMs : number ;
host : ExecHost ;
command : string ;
cwd? : string ;
nodeId? : string ;
2026-01-14 05:39:59 +00:00
} ;
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 ( ) ;
}
2026-01-19 00:35:39 +00:00
function normalizePathPrepend ( entries? : string [ ] ) {
if ( ! Array . isArray ( entries ) ) return [ ] ;
const seen = new Set < string > ( ) ;
const normalized : string [ ] = [ ] ;
for ( const entry of entries ) {
if ( typeof entry !== "string" ) continue ;
const trimmed = entry . trim ( ) ;
if ( ! trimmed || seen . has ( trimmed ) ) continue ;
seen . add ( trimmed ) ;
normalized . push ( trimmed ) ;
}
return normalized ;
}
function mergePathPrepend ( existing : string | undefined , prepend : string [ ] ) {
if ( prepend . length === 0 ) return existing ;
const partsExisting = ( existing ? ? "" )
. split ( path . delimiter )
. map ( ( part ) = > part . trim ( ) )
. filter ( Boolean ) ;
const merged : string [ ] = [ ] ;
const seen = new Set < string > ( ) ;
for ( const part of [ . . . prepend , . . . partsExisting ] ) {
if ( seen . has ( part ) ) continue ;
seen . add ( part ) ;
merged . push ( part ) ;
}
return merged . join ( path . delimiter ) ;
}
function applyPathPrepend (
env : Record < string , string > ,
prepend : string [ ] ,
options ? : { requireExisting? : boolean } ,
) {
if ( prepend . length === 0 ) return ;
if ( options ? . requireExisting && ! env . PATH ) return ;
const merged = mergePathPrepend ( env . PATH , prepend ) ;
if ( merged ) env . PATH = merged ;
}
2026-01-20 14:03:59 +00:00
function applyShellPath ( env : Record < string , string > , shellPath? : string | null ) {
if ( ! shellPath ) return ;
const entries = shellPath
. split ( path . delimiter )
. map ( ( part ) = > part . trim ( ) )
. filter ( Boolean ) ;
if ( entries . length === 0 ) return ;
const merged = mergePathPrepend ( env . PATH , entries ) ;
if ( merged ) env . PATH = merged ;
}
2026-01-17 05:43:27 +00:00
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-22 00:49:02 +00:00
function createApprovalSlug ( id : string ) {
return id . slice ( 0 , APPROVAL_SLUG_LENGTH ) ;
}
function resolveApprovalRunningNoticeMs ( value? : number ) {
if ( typeof value !== "number" || ! Number . isFinite ( value ) ) {
return DEFAULT_APPROVAL_RUNNING_NOTICE_MS ;
}
if ( value <= 0 ) return 0 ;
return Math . floor ( value ) ;
}
function emitExecSystemEvent ( text : string , opts : { sessionKey? : string ; contextKey? : string } ) {
const sessionKey = opts . sessionKey ? . trim ( ) ;
if ( ! sessionKey ) return ;
enqueueSystemEvent ( text , { sessionKey , contextKey : opts.contextKey } ) ;
requestHeartbeatNow ( { reason : "exec-event" } ) ;
}
async function runExecProcess ( opts : {
command : string ;
workdir : string ;
env : Record < string , string > ;
sandbox? : BashSandboxConfig ;
containerWorkdir? : string | null ;
usePty : boolean ;
warnings : string [ ] ;
maxOutput : number ;
pendingMaxOutput : number ;
notifyOnExit : boolean ;
scopeKey? : string ;
sessionKey? : string ;
timeoutSec : number ;
onUpdate ? : ( partialResult : AgentToolResult < ExecToolDetails > ) = > void ;
} ) : Promise < ExecProcessHandle > {
const startedAt = Date . now ( ) ;
const sessionId = createSessionSlug ( ) ;
let child : ChildProcessWithoutNullStreams | null = null ;
let pty : PtyHandle | null = null ;
let stdin : SessionStdin | undefined ;
if ( opts . sandbox ) {
child = spawn (
"docker" ,
buildDockerExecArgs ( {
containerName : opts.sandbox.containerName ,
command : opts.command ,
workdir : opts.containerWorkdir ? ? opts . sandbox . containerWorkdir ,
env : opts.env ,
tty : opts.usePty ,
} ) ,
{
cwd : opts.workdir ,
env : process.env ,
detached : process.platform !== "win32" ,
stdio : [ "pipe" , "pipe" , "pipe" ] ,
windowsHide : true ,
} ,
) as ChildProcessWithoutNullStreams ;
stdin = child . stdin ;
} else if ( opts . 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)." ) ;
}
const { shell , args : shellArgs } = getShellConfig ( ) ;
pty = spawnPty ( shell , [ . . . shellArgs , opts . command ] , {
cwd : opts.workdir ,
env : opts.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 {
const { shell , args : shellArgs } = getShellConfig ( ) ;
child = spawn ( shell , [ . . . shellArgs , opts . command ] , {
cwd : opts.workdir ,
env : opts.env ,
detached : process.platform !== "win32" ,
stdio : [ "pipe" , "pipe" , "pipe" ] ,
windowsHide : true ,
} ) as ChildProcessWithoutNullStreams ;
stdin = child . stdin ;
}
const session = {
id : sessionId ,
command : opts.command ,
scopeKey : opts.scopeKey ,
sessionKey : opts.sessionKey ,
notifyOnExit : opts.notifyOnExit ,
exitNotified : false ,
child : child ? ? undefined ,
stdin ,
pid : child?.pid ? ? pty ? . pid ,
startedAt ,
cwd : opts.workdir ,
maxOutputChars : opts.maxOutput ,
pendingMaxOutputChars : opts.pendingMaxOutput ,
totalOutputChars : 0 ,
pendingStdout : [ ] ,
pendingStderr : [ ] ,
pendingStdoutChars : 0 ,
pendingStderrChars : 0 ,
aggregated : "" ,
tail : "" ,
exited : false ,
exitCode : undefined as number | null | undefined ,
exitSignal : undefined as NodeJS . Signals | number | null | undefined ,
truncated : false ,
backgrounded : false ,
} satisfies ProcessSession ;
addSession ( session ) ;
let settled = false ;
let timeoutTimer : NodeJS.Timeout | null = null ;
let timeoutFinalizeTimer : NodeJS.Timeout | null = null ;
let timedOut = false ;
const timeoutFinalizeMs = 1000 ;
let resolveFn : ( ( outcome : ExecProcessOutcome ) = > void ) | null = null ;
const settle = ( outcome : ExecProcessOutcome ) = > {
if ( settled ) return ;
settled = true ;
resolveFn ? . ( outcome ) ;
} ;
const finalizeTimeout = ( ) = > {
if ( session . exited ) return ;
markExited ( session , null , "SIGKILL" , "failed" ) ;
maybeNotifyOnExit ( session , "failed" ) ;
const aggregated = session . aggregated . trim ( ) ;
const reason = ` Command timed out after ${ opts . timeoutSec } seconds ` ;
settle ( {
status : "failed" ,
exitCode : null ,
exitSignal : "SIGKILL" ,
durationMs : Date.now ( ) - startedAt ,
aggregated ,
timedOut : true ,
reason : aggregated ? ` ${ aggregated } \ n \ n ${ reason } ` : reason ,
} ) ;
} ;
const onTimeout = ( ) = > {
timedOut = true ;
killSession ( session ) ;
if ( ! timeoutFinalizeTimer ) {
timeoutFinalizeTimer = setTimeout ( ( ) = > {
finalizeTimeout ( ) ;
} , timeoutFinalizeMs ) ;
}
} ;
if ( opts . timeoutSec > 0 ) {
timeoutTimer = setTimeout ( ( ) = > {
onTimeout ( ) ;
} , opts . timeoutSec * 1000 ) ;
}
const emitUpdate = ( ) = > {
if ( ! opts . onUpdate ) return ;
const tailText = session . tail || session . aggregated ;
const warningText = opts . warnings . length ? ` ${ opts . warnings . join ( "\n" ) } \ n \ n ` : "" ;
opts . onUpdate ( {
content : [ { type : "text" , text : warningText + ( tailText || "" ) } ] ,
details : {
status : "running" ,
sessionId ,
pid : session.pid ? ? undefined ,
startedAt ,
cwd : session.cwd ,
tail : session.tail ,
} ,
} ) ;
} ;
const handleStdout = ( data : string ) = > {
const str = sanitizeBinaryOutput ( data . toString ( ) ) ;
for ( const chunk of chunkString ( str ) ) {
appendOutput ( session , "stdout" , chunk ) ;
emitUpdate ( ) ;
}
} ;
const handleStderr = ( data : string ) = > {
const str = sanitizeBinaryOutput ( data . toString ( ) ) ;
for ( const chunk of chunkString ( str ) ) {
appendOutput ( session , "stderr" , chunk ) ;
emitUpdate ( ) ;
}
} ;
if ( pty ) {
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 ) ;
} ) ;
} else if ( child ) {
child . stdout . on ( "data" , handleStdout ) ;
child . stderr . on ( "data" , handleStderr ) ;
}
const promise = new Promise < ExecProcessOutcome > ( ( resolve ) = > {
resolveFn = resolve ;
const handleExit = ( code : number | null , exitSignal : NodeJS.Signals | number | null ) = > {
if ( timeoutTimer ) clearTimeout ( timeoutTimer ) ;
if ( timeoutFinalizeTimer ) clearTimeout ( timeoutFinalizeTimer ) ;
const durationMs = Date . now ( ) - startedAt ;
const wasSignal = exitSignal != null ;
const isSuccess = code === 0 && ! wasSignal && ! timedOut ;
const status : "completed" | "failed" = isSuccess ? "completed" : "failed" ;
markExited ( session , code , exitSignal , status ) ;
maybeNotifyOnExit ( session , status ) ;
if ( ! session . child && session . stdin ) {
session . stdin . destroyed = true ;
}
if ( settled ) return ;
const aggregated = session . aggregated . trim ( ) ;
if ( ! isSuccess ) {
const reason = timedOut
? ` Command timed out after ${ opts . timeoutSec } 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 ( {
status : "failed" ,
exitCode : code ? ? null ,
exitSignal : exitSignal ? ? null ,
durationMs ,
aggregated ,
timedOut ,
reason : message ,
} ) ;
return ;
}
settle ( {
status : "completed" ,
exitCode : code ? ? 0 ,
exitSignal : exitSignal ? ? null ,
durationMs ,
aggregated ,
timedOut : false ,
} ) ;
} ;
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 ) ;
} ) ;
child . once ( "error" , ( err ) = > {
if ( timeoutTimer ) clearTimeout ( timeoutTimer ) ;
if ( timeoutFinalizeTimer ) clearTimeout ( timeoutFinalizeTimer ) ;
markExited ( session , null , null , "failed" ) ;
maybeNotifyOnExit ( session , "failed" ) ;
const aggregated = session . aggregated . trim ( ) ;
const message = aggregated ? ` ${ aggregated } \ n \ n ${ String ( err ) } ` : String ( err ) ;
settle ( {
status : "failed" ,
exitCode : null ,
exitSignal : null ,
durationMs : Date.now ( ) - startedAt ,
aggregated ,
timedOut ,
reason : message ,
} ) ;
} ) ;
}
} ) ;
return {
session ,
startedAt ,
pid : session.pid ? ? undefined ,
promise ,
kill : ( ) = > killSession ( session ) ,
} ;
}
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-19 00:35:39 +00:00
const defaultPathPrepend = normalizePathPrepend ( defaults ? . pathPrepend ) ;
2026-01-21 21:44:28 +00:00
const safeBins = resolveSafeBins ( defaults ? . safeBins ) ;
2026-01-17 05:43:27 +00:00
const notifyOnExit = defaults ? . notifyOnExit !== false ;
const notifySessionKey = defaults ? . sessionKey ? . trim ( ) || undefined ;
2026-01-22 00:49:02 +00:00
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs ( defaults ? . approvalRunningNoticeMs ) ;
2026-01-21 18:55:32 -08:00
// Derive agentId only when sessionKey is an agent session key.
const parsedAgentSession = parseAgentSessionKey ( defaults ? . sessionKey ) ;
const agentId =
defaults ? . agentId ? ?
( parsedAgentSession ? resolveAgentIdFromSessionKey ( defaults ? . sessionKey ) : 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 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 ;
2026-01-22 06:03:15 +00:00
const elevatedAllowed = Boolean ( elevatedDefaults ? . enabled && elevatedDefaults . allowed ) ;
2026-01-22 05:32:13 +00:00
const elevatedDefaultMode =
elevatedDefaults ? . defaultLevel === "full"
? "full"
: elevatedDefaults ? . defaultLevel === "ask"
? "ask"
: elevatedDefaults ? . defaultLevel === "on"
? "ask"
: "off" ;
2026-01-22 06:03:15 +00:00
const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off" ;
2026-01-22 05:32:13 +00:00
const elevatedMode =
typeof params . elevated === "boolean"
? params . elevated
? elevatedDefaultMode === "full"
? "full"
: "ask"
: "off"
2026-01-22 06:03:15 +00:00
: effectiveDefaultMode ;
2026-01-22 05:32:13 +00:00
const elevatedRequested = elevatedMode !== "off" ;
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
) ;
}
2026-01-22 06:47:37 +00:00
}
if ( elevatedRequested ) {
2026-01-22 00:49:02 +00:00
logInfo ( ` exec: elevated command ${ truncateMiddle ( params . command , 120 ) } ` ) ;
2026-01-14 05:39:59 +00:00
}
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-21 03:40:21 +00:00
const configuredSecurity = defaults ? . security ? ? ( host === "sandbox" ? "deny" : "allowlist" ) ;
2026-01-18 04:27:33 +00:00
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 ) ;
2026-01-22 05:32:13 +00:00
const bypassApprovals = elevatedRequested && elevatedMode === "full" ;
if ( bypassApprovals ) {
ask = "off" ;
}
2026-01-18 04:27:33 +00:00
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 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-20 14:03:59 +00:00
if ( ! sandbox && host === "gateway" && ! params . env ? . PATH ) {
const shellPath = getShellPathFromLoginShell ( {
env : process.env ,
timeoutMs : resolveShellEnvFallbackTimeoutMs ( process . env ) ,
} ) ;
applyShellPath ( env , shellPath ) ;
}
2026-01-19 00:35:39 +00:00
applyPathPrepend ( env , defaultPathPrepend ) ;
2026-01-18 04:27:33 +00:00
if ( host === "node" ) {
2026-01-21 03:40:21 +00:00
const approvals = resolveExecApprovals (
2026-01-21 18:55:32 -08:00
agentId ,
2026-01-21 03:40:21 +00:00
host === "node" ? { security : "allowlist" } : undefined ,
) ;
2026-01-19 04:50:07 +00:00
const hostSecurity = minSecurity ( security , approvals . agent . security ) ;
const hostAsk = maxAsk ( ask , approvals . agent . ask ) ;
const askFallback = approvals . agent . askFallback ;
if ( hostSecurity === "deny" ) {
2026-01-18 04:27:33 +00:00
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 (
2026-01-18 07:44:28 +00:00
"exec host=node requires a paired node (none available). This requires a companion app or node host." ,
2026-01-18 04:27:33 +00:00
) ;
}
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 ) {
2026-01-18 07:44:28 +00:00
throw new Error (
"exec host=node requires a node that supports system.run (companion app or node host)." ,
) ;
2026-01-18 04:27:33 +00:00
}
2026-01-18 07:44:28 +00:00
const argv = buildNodeShellCommand ( params . command , nodeInfo ? . platform ) ;
2026-01-19 00:35:39 +00:00
const nodeEnv = params . env ? { . . . params . env } : undefined ;
if ( nodeEnv ) {
applyPathPrepend ( nodeEnv , defaultPathPrepend , { requireExisting : true } ) ;
}
2026-01-23 00:10:19 +00:00
const baseAllowlistEval = evaluateShellAllowlist ( {
command : params.command ,
allowlist : [ ] ,
safeBins : new Set ( ) ,
cwd : workdir ,
env ,
} ) ;
let analysisOk = baseAllowlistEval . analysisOk ;
2026-01-21 22:02:17 -08:00
let allowlistSatisfied = false ;
2026-01-23 00:10:19 +00:00
if ( hostAsk === "on-miss" && hostSecurity === "allowlist" && analysisOk ) {
2026-01-21 22:02:17 -08:00
try {
const approvalsSnapshot = ( await callGatewayTool (
"exec.approvals.node.get" ,
{ timeoutMs : 10_000 } ,
{ nodeId } ,
) ) as { file? : unknown } | null ;
const approvalsFile =
approvalsSnapshot && typeof approvalsSnapshot === "object"
? approvalsSnapshot . file
: undefined ;
if ( approvalsFile && typeof approvalsFile === "object" ) {
const resolved = resolveExecApprovalsFromFile ( {
file : approvalsFile as ExecApprovalsFile ,
agentId ,
overrides : { security : "allowlist" } ,
} ) ;
// Allowlist-only precheck; safe bins are node-local and may diverge.
2026-01-23 00:10:19 +00:00
const allowlistEval = evaluateShellAllowlist ( {
command : params.command ,
2026-01-21 22:02:17 -08:00
allowlist : resolved.allowlist ,
safeBins : new Set ( ) ,
cwd : workdir ,
2026-01-23 00:10:19 +00:00
env ,
} ) ;
allowlistSatisfied = allowlistEval . allowlistSatisfied ;
analysisOk = allowlistEval . analysisOk ;
2026-01-21 22:02:17 -08:00
}
} catch {
// Fall back to requiring approval if node approvals cannot be fetched.
}
}
const requiresAsk = requiresExecApproval ( {
ask : hostAsk ,
security : hostSecurity ,
2026-01-23 00:10:19 +00:00
analysisOk ,
2026-01-21 22:02:17 -08:00
allowlistSatisfied ,
} ) ;
2026-01-22 00:49:02 +00:00
const commandText = params . command ;
const invokeTimeoutMs = Math . max (
10 _000 ,
( typeof params . timeout === "number" ? params.timeout : defaultTimeoutSec ) * 1000 + 5 _000 ,
) ;
const buildInvokeParams = (
approvedByAsk : boolean ,
approvalDecision : "allow-once" | "allow-always" | null ,
runId? : string ,
) = >
( {
nodeId ,
command : "system.run" ,
params : {
command : argv ,
rawCommand : params.command ,
2026-01-19 11:32:15 +00:00
cwd : workdir ,
2026-01-22 00:49:02 +00:00
env : nodeEnv ,
timeoutMs : typeof params . timeout === "number" ? params . timeout * 1000 : undefined ,
2026-01-21 18:55:32 -08:00
agentId ,
2026-01-22 00:49:02 +00:00
sessionKey : defaults?.sessionKey ,
approved : approvedByAsk ,
approvalDecision : approvalDecision ? ? undefined ,
runId : runId ? ? undefined ,
2026-01-19 08:54:13 +00:00
} ,
2026-01-22 00:49:02 +00:00
idempotencyKey : crypto.randomUUID ( ) ,
} ) satisfies Record < string , unknown > ;
if ( requiresAsk ) {
const approvalId = crypto . randomUUID ( ) ;
const approvalSlug = createApprovalSlug ( approvalId ) ;
const expiresAtMs = Date . now ( ) + DEFAULT_APPROVAL_TIMEOUT_MS ;
const contextKey = ` exec: ${ approvalId } ` ;
const noticeSeconds = Math . max ( 1 , Math . round ( approvalRunningNoticeMs / 1000 ) ) ;
const warningText = warnings . length ? ` ${ warnings . join ( "\n" ) } \ n \ n ` : "" ;
void ( async ( ) = > {
let decision : string | null = null ;
try {
const decisionResult = ( await callGatewayTool (
"exec.approval.request" ,
{ timeoutMs : DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS } ,
{
id : approvalId ,
command : commandText ,
cwd : workdir ,
host : "node" ,
security : hostSecurity ,
ask : hostAsk ,
2026-01-21 18:55:32 -08:00
agentId ,
2026-01-21 22:02:17 -08:00
resolvedPath : undefined ,
sessionKey : defaults?.sessionKey ,
2026-01-22 00:49:02 +00:00
timeoutMs : DEFAULT_APPROVAL_TIMEOUT_MS ,
} ,
) ) as { decision? : string } | null ;
decision =
decisionResult && typeof decisionResult === "object"
? ( decisionResult . decision ? ? null )
: null ;
} catch {
emitExecSystemEvent (
` Exec denied (node= ${ nodeId } id= ${ approvalId } , approval-request-failed): ${ commandText } ` ,
{ sessionKey : notifySessionKey , contextKey } ,
) ;
return ;
}
let approvedByAsk = false ;
let approvalDecision : "allow-once" | "allow-always" | null = null ;
let deniedReason : string | null = null ;
if ( decision === "deny" ) {
deniedReason = "user-denied" ;
} else if ( ! decision ) {
if ( askFallback === "full" ) {
approvedByAsk = true ;
approvalDecision = "allow-once" ;
} else if ( askFallback === "allowlist" ) {
// Defer allowlist enforcement to the node host.
} else {
deniedReason = "approval-timeout" ;
}
} else if ( decision === "allow-once" ) {
2026-01-19 04:50:07 +00:00
approvedByAsk = true ;
2026-01-20 12:03:18 +00:00
approvalDecision = "allow-once" ;
2026-01-22 00:49:02 +00:00
} else if ( decision === "allow-always" ) {
approvedByAsk = true ;
approvalDecision = "allow-always" ;
2026-01-19 04:50:07 +00:00
}
2026-01-22 00:49:02 +00:00
if ( deniedReason ) {
emitExecSystemEvent (
` Exec denied (node= ${ nodeId } id= ${ approvalId } , ${ deniedReason } ): ${ commandText } ` ,
{ sessionKey : notifySessionKey , contextKey } ,
) ;
return ;
}
let runningTimer : NodeJS.Timeout | null = null ;
if ( approvalRunningNoticeMs > 0 ) {
runningTimer = setTimeout ( ( ) = > {
emitExecSystemEvent (
` Exec running (node= ${ nodeId } id= ${ approvalId } , > ${ noticeSeconds } s): ${ commandText } ` ,
{ sessionKey : notifySessionKey , contextKey } ,
) ;
} , approvalRunningNoticeMs ) ;
}
try {
await callGatewayTool (
"node.invoke" ,
{ timeoutMs : invokeTimeoutMs } ,
buildInvokeParams ( approvedByAsk , approvalDecision , approvalId ) ,
) ;
} catch {
emitExecSystemEvent (
` Exec denied (node= ${ nodeId } id= ${ approvalId } , invoke-failed): ${ commandText } ` ,
{ sessionKey : notifySessionKey , contextKey } ,
) ;
} finally {
if ( runningTimer ) clearTimeout ( runningTimer ) ;
}
} ) ( ) ;
return {
content : [
{
type : "text" ,
text :
` ${ warningText } Approval required (id ${ approvalSlug } ). ` +
"Approve to run; updates will arrive after completion." ,
} ,
] ,
details : {
status : "approval-pending" ,
approvalId ,
approvalSlug ,
expiresAtMs ,
host : "node" ,
command : commandText ,
cwd : workdir ,
nodeId ,
} ,
} ;
2026-01-19 04:50:07 +00:00
}
2026-01-22 00:49:02 +00:00
const startedAt = Date . now ( ) ;
const raw = ( await callGatewayTool (
"node.invoke" ,
{ timeoutMs : invokeTimeoutMs } ,
buildInvokeParams ( false , null ) ,
) ) as {
2026-01-18 04:27:33 +00:00
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 ,
} ;
}
2026-01-22 05:32:13 +00:00
if ( host === "gateway" && ! bypassApprovals ) {
2026-01-21 18:55:32 -08:00
const approvals = resolveExecApprovals ( agentId , { security : "allowlist" } ) ;
2026-01-18 04:27:33 +00:00
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" ) ;
}
2026-01-23 00:10:19 +00:00
const allowlistEval = evaluateShellAllowlist ( {
command : params.command ,
2026-01-21 22:02:17 -08:00
allowlist : approvals.allowlist ,
safeBins ,
cwd : workdir ,
2026-01-23 00:10:19 +00:00
env ,
2026-01-21 22:02:17 -08:00
} ) ;
const allowlistMatches = allowlistEval . allowlistMatches ;
2026-01-23 00:10:19 +00:00
const analysisOk = allowlistEval . analysisOk ;
2026-01-21 22:02:17 -08:00
const allowlistSatisfied =
2026-01-23 00:10:19 +00:00
hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false ;
2026-01-21 22:02:17 -08:00
const requiresAsk = requiresExecApproval ( {
ask : hostAsk ,
security : hostSecurity ,
2026-01-23 00:10:19 +00:00
analysisOk ,
2026-01-21 22:02:17 -08:00
allowlistSatisfied ,
} ) ;
2026-01-18 04:27:33 +00:00
if ( requiresAsk ) {
2026-01-22 00:49:02 +00:00
const approvalId = crypto . randomUUID ( ) ;
const approvalSlug = createApprovalSlug ( approvalId ) ;
const expiresAtMs = Date . now ( ) + DEFAULT_APPROVAL_TIMEOUT_MS ;
const contextKey = ` exec: ${ approvalId } ` ;
2026-01-23 00:10:19 +00:00
const resolvedPath = allowlistEval . segments [ 0 ] ? . resolution ? . resolvedPath ;
2026-01-22 00:49:02 +00:00
const noticeSeconds = Math . max ( 1 , Math . round ( approvalRunningNoticeMs / 1000 ) ) ;
const commandText = params . command ;
const effectiveTimeout =
typeof params . timeout === "number" ? params.timeout : defaultTimeoutSec ;
const warningText = warnings . length ? ` ${ warnings . join ( "\n" ) } \ n \ n ` : "" ;
void ( async ( ) = > {
let decision : string | null = null ;
try {
const decisionResult = ( await callGatewayTool (
"exec.approval.request" ,
{ timeoutMs : DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS } ,
{
id : approvalId ,
command : commandText ,
cwd : workdir ,
host : "gateway" ,
security : hostSecurity ,
ask : hostAsk ,
2026-01-21 18:55:32 -08:00
agentId ,
2026-01-22 00:49:02 +00:00
resolvedPath ,
2026-01-21 22:02:17 -08:00
sessionKey : defaults?.sessionKey ,
2026-01-22 00:49:02 +00:00
timeoutMs : DEFAULT_APPROVAL_TIMEOUT_MS ,
} ,
) ) as { decision? : string } | null ;
decision =
decisionResult && typeof decisionResult === "object"
? ( decisionResult . decision ? ? null )
: null ;
} catch {
emitExecSystemEvent (
` Exec denied (gateway id= ${ approvalId } , approval-request-failed): ${ commandText } ` ,
{ sessionKey : notifySessionKey , contextKey } ,
) ;
return ;
}
let approvedByAsk = false ;
let deniedReason : string | null = null ;
if ( decision === "deny" ) {
deniedReason = "user-denied" ;
} else if ( ! decision ) {
if ( askFallback === "full" ) {
approvedByAsk = true ;
} else if ( askFallback === "allowlist" ) {
2026-01-23 00:10:19 +00:00
if ( ! analysisOk || ! allowlistSatisfied ) {
2026-01-22 00:49:02 +00:00
deniedReason = "approval-timeout (allowlist-miss)" ;
} else {
approvedByAsk = true ;
}
} else {
deniedReason = "approval-timeout" ;
2026-01-18 04:27:33 +00:00
}
2026-01-22 00:49:02 +00:00
} else if ( decision === "allow-once" ) {
2026-01-18 07:44:28 +00:00
approvedByAsk = true ;
2026-01-22 00:49:02 +00:00
} else if ( decision === "allow-always" ) {
approvedByAsk = true ;
if ( hostSecurity === "allowlist" ) {
2026-01-23 00:10:19 +00:00
for ( const segment of allowlistEval . segments ) {
2026-01-22 00:49:02 +00:00
const pattern = segment . resolution ? . resolvedPath ? ? "" ;
if ( pattern ) {
2026-01-21 18:55:32 -08:00
addAllowlistEntry ( approvals . file , agentId , pattern ) ;
2026-01-22 00:49:02 +00:00
}
2026-01-21 21:44:28 +00:00
}
2026-01-18 07:44:28 +00:00
}
2026-01-18 04:27:33 +00:00
}
2026-01-22 00:49:02 +00:00
if (
hostSecurity === "allowlist" &&
2026-01-23 00:10:19 +00:00
( ! analysisOk || ! allowlistSatisfied ) &&
2026-01-22 00:49:02 +00:00
! approvedByAsk
) {
deniedReason = deniedReason ? ? "allowlist-miss" ;
}
if ( deniedReason ) {
emitExecSystemEvent (
` Exec denied (gateway id= ${ approvalId } , ${ deniedReason } ): ${ commandText } ` ,
{ sessionKey : notifySessionKey , contextKey } ,
) ;
return ;
}
if ( allowlistMatches . length > 0 ) {
const seen = new Set < string > ( ) ;
for ( const match of allowlistMatches ) {
if ( seen . has ( match . pattern ) ) continue ;
seen . add ( match . pattern ) ;
recordAllowlistUse (
approvals . file ,
2026-01-21 18:55:32 -08:00
agentId ,
2026-01-22 00:49:02 +00:00
match ,
commandText ,
resolvedPath ? ? undefined ,
) ;
}
}
let run : ExecProcessHandle | null = null ;
try {
run = await runExecProcess ( {
command : commandText ,
workdir ,
env ,
sandbox : undefined ,
containerWorkdir : null ,
usePty : params.pty === true && ! sandbox ,
warnings ,
maxOutput ,
pendingMaxOutput ,
notifyOnExit : false ,
scopeKey : defaults?.scopeKey ,
sessionKey : notifySessionKey ,
timeoutSec : effectiveTimeout ,
} ) ;
} catch {
emitExecSystemEvent (
` Exec denied (gateway id= ${ approvalId } , spawn-failed): ${ commandText } ` ,
{ sessionKey : notifySessionKey , contextKey } ,
) ;
return ;
}
markBackgrounded ( run . session ) ;
let runningTimer : NodeJS.Timeout | null = null ;
if ( approvalRunningNoticeMs > 0 ) {
runningTimer = setTimeout ( ( ) = > {
emitExecSystemEvent (
` Exec running (gateway id= ${ approvalId } , session= ${ run ? . session . id } , > ${ noticeSeconds } s): ${ commandText } ` ,
{ sessionKey : notifySessionKey , contextKey } ,
) ;
} , approvalRunningNoticeMs ) ;
}
const outcome = await run . promise ;
if ( runningTimer ) clearTimeout ( runningTimer ) ;
const output = normalizeNotifyOutput (
tail ( outcome . aggregated || "" , DEFAULT_NOTIFY_TAIL_CHARS ) ,
) ;
const exitLabel = outcome . timedOut ? "timeout" : ` code ${ outcome . exitCode ? ? "?" } ` ;
const summary = output
? ` Exec finished (gateway id= ${ approvalId } , session= ${ run . session . id } , ${ exitLabel } ) \ n ${ output } `
: ` Exec finished (gateway id= ${ approvalId } , session= ${ run . session . id } , ${ exitLabel } ) ` ;
emitExecSystemEvent ( summary , { sessionKey : notifySessionKey , contextKey } ) ;
} ) ( ) ;
return {
content : [
{
type : "text" ,
text :
` ${ warningText } ` +
` Approval required (id ${ approvalSlug } ). ` +
"Approve to run; updates will arrive after completion." ,
} ,
] ,
details : {
status : "approval-pending" ,
approvalId ,
approvalSlug ,
expiresAtMs ,
host : "gateway" ,
command : params.command ,
cwd : workdir ,
} ,
} ;
2026-01-18 07:44:28 +00:00
}
2026-01-23 00:10:19 +00:00
if ( hostSecurity === "allowlist" && ( ! analysisOk || ! allowlistSatisfied ) ) {
2026-01-18 07:44:28 +00:00
throw new Error ( "exec denied: allowlist miss" ) ;
2026-01-18 04:27:33 +00:00
}
2026-01-21 21:44:28 +00:00
if ( allowlistMatches . length > 0 ) {
const seen = new Set < string > ( ) ;
for ( const match of allowlistMatches ) {
if ( seen . has ( match . pattern ) ) continue ;
seen . add ( match . pattern ) ;
recordAllowlistUse (
approvals . file ,
2026-01-21 18:55:32 -08:00
agentId ,
2026-01-21 21:44:28 +00:00
match ,
params . command ,
2026-01-23 00:10:19 +00:00
allowlistEval . segments [ 0 ] ? . resolution ? . resolvedPath ,
2026-01-21 21:44:28 +00:00
) ;
}
2026-01-18 04:27:33 +00:00
}
}
2026-01-22 00:49:02 +00:00
const effectiveTimeout =
typeof params . timeout === "number" ? params.timeout : defaultTimeoutSec ;
const warningText = warnings . length ? ` ${ warnings . join ( "\n" ) } \ n \ n ` : "" ;
2026-01-17 04:57:04 +00:00
const usePty = params . pty === true && ! sandbox ;
2026-01-22 00:49:02 +00:00
const run = await runExecProcess ( {
2026-01-14 05:39:59 +00:00
command : params.command ,
2026-01-22 00:49:02 +00:00
workdir ,
env ,
sandbox ,
containerWorkdir ,
usePty ,
warnings ,
maxOutput ,
pendingMaxOutput ,
notifyOnExit ,
2026-01-14 05:39:59 +00:00
scopeKey : defaults?.scopeKey ,
2026-01-17 05:43:27 +00:00
sessionKey : notifySessionKey ,
2026-01-22 00:49:02 +00:00
timeoutSec : effectiveTimeout ,
onUpdate ,
} ) ;
2026-01-14 05:39:59 +00:00
let yielded = false ;
let yieldTimer : NodeJS.Timeout | null = null ;
2026-01-17 03:52:37 +00:00
2026-01-16 10:43:08 +00:00
// Tool-call abort should not kill backgrounded sessions; timeouts still must.
const onAbortSignal = ( ) = > {
2026-01-22 00:49:02 +00:00
if ( yielded || run . session . backgrounded ) return ;
run . kill ( ) ;
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
}
2026-01-14 14:31:43 +00:00
return new Promise < AgentToolResult < ExecToolDetails > > ( ( resolve , reject ) = > {
2026-01-22 00:49:02 +00:00
const resolveRunning = ( ) = >
resolve ( {
content : [
{
type : "text" ,
text :
` ${ warningText } ` +
` Command still running (session ${ run . session . id } , pid ${
run . session . pid ? ? "n/a"
} ) . ` +
"Use process (list/poll/log/write/kill/clear/remove) for follow-up." ,
2026-01-14 14:31:43 +00:00
} ,
2026-01-22 00:49:02 +00:00
] ,
details : {
status : "running" ,
sessionId : run.session.id ,
pid : run.session.pid ? ? undefined ,
startedAt : run.startedAt ,
cwd : run.session.cwd ,
tail : run.session.tail ,
} ,
} ) ;
2026-01-14 14:31:43 +00:00
const onYieldNow = ( ) = > {
if ( yieldTimer ) clearTimeout ( yieldTimer ) ;
2026-01-22 00:49:02 +00:00
if ( yielded ) return ;
2026-01-14 14:31:43 +00:00
yielded = true ;
2026-01-22 00:49:02 +00:00
markBackgrounded ( run . session ) ;
2026-01-14 14:31:43 +00:00
resolveRunning ( ) ;
} ;
if ( allowBackground && yieldWindow !== null ) {
if ( yieldWindow === 0 ) {
onYieldNow ( ) ;
} else {
yieldTimer = setTimeout ( ( ) = > {
2026-01-22 00:49:02 +00:00
if ( yielded ) return ;
2026-01-14 14:31:43 +00:00
yielded = true ;
2026-01-22 00:49:02 +00:00
markBackgrounded ( run . session ) ;
2026-01-14 14:31:43 +00:00
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-22 00:49:02 +00:00
run . promise
. then ( ( outcome ) = > {
if ( yieldTimer ) clearTimeout ( yieldTimer ) ;
if ( yielded || run . session . backgrounded ) return ;
if ( outcome . status === "failed" ) {
reject ( new Error ( outcome . reason ? ? "Command failed." ) ) ;
return ;
}
2026-01-14 14:31:43 +00:00
resolve ( {
content : [
{
type : "text" ,
2026-01-22 00:49:02 +00:00
text : ` ${ warningText } ${ outcome . aggregated || "(no output)" } ` ,
2026-01-14 05:39:59 +00:00
} ,
2026-01-14 14:31:43 +00:00
] ,
details : {
status : "completed" ,
2026-01-22 00:49:02 +00:00
exitCode : outcome.exitCode ? ? 0 ,
durationMs : outcome.durationMs ,
aggregated : outcome.aggregated ,
cwd : run.session.cwd ,
2026-01-14 14:31:43 +00:00
} ,
2026-01-22 00:49:02 +00:00
} ) ;
} )
. catch ( ( err ) = > {
2026-01-17 04:57:04 +00:00
if ( yieldTimer ) clearTimeout ( yieldTimer ) ;
2026-01-22 00:49:02 +00:00
if ( yielded || run . session . backgrounded ) return ;
reject ( err as Error ) ;
2026-01-17 04:57:04 +00:00
} ) ;
2026-01-14 14:31:43 +00:00
} ) ;
2026-01-14 05:39:59 +00:00
} ,
} ;
}
export const execTool = createExecTool ( ) ;