2026-02-18 01:34:35 +00:00
import path from "node:path" ;
2026-02-13 17:49:29 +00:00
import type { AgentToolResult } from "@mariozechner/pi-agent-core" ;
import { Type } from "@sinclair/typebox" ;
import type { ExecAsk , ExecHost , ExecSecurity } from "../infra/exec-approvals.js" ;
2026-02-18 01:29:02 +00:00
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js" ;
2026-02-21 11:43:53 +01:00
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js" ;
2026-03-02 10:14:38 -06:00
import { findPathKey , mergePathPrepend } from "../infra/path-prepend.js" ;
2026-02-18 01:29:02 +00:00
import { enqueueSystemEvent } from "../infra/system-events.js" ;
2026-03-03 14:47:40 +03:00
import { scopedHeartbeatWakeOptions } from "../routing/session-key.js" ;
2026-02-18 01:34:35 +00:00
import type { ProcessSession } from "./bash-process-registry.js" ;
2026-02-19 14:21:07 +01:00
import type { ExecToolDetails } from "./bash-tools.exec-types.js" ;
2026-02-18 01:34:35 +00:00
import type { BashSandboxConfig } from "./bash-tools.shared.js" ;
2026-03-02 10:14:38 -06:00
export { applyPathPrepend , findPathKey , normalizePathPrepend } from "../infra/path-prepend.js" ;
2026-02-18 01:29:02 +00:00
import { logWarn } from "../logger.js" ;
2026-02-18 01:34:35 +00:00
import type { ManagedRun } from "../process/supervisor/index.js" ;
2026-02-16 09:32:05 +08:00
import { getProcessSupervisor } from "../process/supervisor/index.js" ;
2026-02-13 17:49:29 +00:00
import {
addSession ,
appendOutput ,
createSessionSlug ,
markExited ,
tail ,
} from "./bash-process-registry.js" ;
import {
buildDockerExecArgs ,
chunkString ,
clampWithDefault ,
readEnvInt ,
} from "./bash-tools.shared.js" ;
import { buildCursorPositionResponse , stripDsrRequests } from "./pty-dsr.js" ;
import { getShellConfig , sanitizeBinaryOutput } from "./shell-utils.js" ;
2026-02-24 12:09:42 -07:00
// Sanitize inherited host env before merge so dangerous variables from process.env
// are not propagated into non-sandboxed executions.
export function sanitizeHostBaseEnv ( env : Record < string , string > ) : Record < string , string > {
const sanitized : Record < string , string > = { } ;
for ( const [ key , value ] of Object . entries ( env ) ) {
const upperKey = key . toUpperCase ( ) ;
if ( upperKey === "PATH" ) {
sanitized [ key ] = value ;
continue ;
}
if ( isDangerousHostEnvVarName ( upperKey ) ) {
continue ;
}
sanitized [ key ] = value ;
}
return sanitized ;
}
2026-02-13 17:49:29 +00:00
// Centralized sanitization helper.
// Throws an error if dangerous variables or PATH modifications are detected on the host.
export function validateHostEnv ( env : Record < string , string > ) : void {
for ( const key of Object . keys ( env ) ) {
const upperKey = key . toUpperCase ( ) ;
// 1. Block known dangerous variables (Fail Closed)
2026-02-21 11:43:53 +01:00
if ( isDangerousHostEnvVarName ( upperKey ) ) {
2026-02-13 17:49:29 +00:00
throw new Error (
` Security Violation: Environment variable ' ${ key } ' is forbidden during host execution. ` ,
) ;
}
// 2. Strictly block PATH modification on host
// Allowing custom PATH on the gateway/node can lead to binary hijacking.
if ( upperKey === "PATH" ) {
throw new Error (
"Security Violation: Custom 'PATH' variable is forbidden during host execution." ,
) ;
}
}
}
export const DEFAULT_MAX_OUTPUT = clampWithDefault (
readEnvInt ( "PI_BASH_MAX_OUTPUT_CHARS" ) ,
200 _000 ,
1 _000 ,
200 _000 ,
) ;
export const DEFAULT_PENDING_MAX_OUTPUT = clampWithDefault (
readEnvInt ( "OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS" ) ,
2026-02-14 18:32:45 -05:00
30 _000 ,
2026-02-13 17:49:29 +00:00
1 _000 ,
200 _000 ,
) ;
export const DEFAULT_PATH =
process . env . PATH ? ? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ;
export const DEFAULT_NOTIFY_TAIL_CHARS = 400 ;
2026-02-14 18:32:45 -05:00
const DEFAULT_NOTIFY_SNIPPET_CHARS = 180 ;
2026-02-13 17:49:29 +00:00
export const DEFAULT_APPROVAL_TIMEOUT_MS = 120 _000 ;
export const DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS = 130 _000 ;
const DEFAULT_APPROVAL_RUNNING_NOTICE_MS = 10 _000 ;
const APPROVAL_SLUG_LENGTH = 8 ;
export const execSchema = Type . Object ( {
command : Type.String ( { description : "Shell command to execute" } ) ,
workdir : Type.Optional ( Type . String ( { description : "Working directory (defaults to cwd)" } ) ) ,
env : Type.Optional ( Type . Record ( Type . String ( ) , Type . String ( ) ) ) ,
yieldMs : Type.Optional (
Type . Number ( {
description : "Milliseconds to wait before backgrounding (default 10000)" ,
} ) ,
) ,
background : Type.Optional ( Type . Boolean ( { description : "Run in background immediately" } ) ) ,
timeout : Type.Optional (
Type . Number ( {
description : "Timeout in seconds (optional, kills process on expiry)" ,
} ) ,
) ,
pty : Type.Optional (
Type . Boolean ( {
description :
2026-02-16 21:47:18 -05:00
"Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)" ,
2026-02-13 17:49:29 +00:00
} ) ,
) ,
elevated : Type.Optional (
Type . Boolean ( {
description : "Run on the host with elevated permissions (if allowed)" ,
} ) ,
) ,
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." ,
} ) ,
) ,
} ) ;
export type ExecProcessOutcome = {
status : "completed" | "failed" ;
exitCode : number | null ;
exitSignal : NodeJS.Signals | number | null ;
durationMs : number ;
aggregated : string ;
timedOut : boolean ;
reason? : string ;
} ;
export type ExecProcessHandle = {
session : ProcessSession ;
startedAt : number ;
pid? : number ;
promise : Promise < ExecProcessOutcome > ;
kill : ( ) = > void ;
} ;
export function normalizeExecHost ( value? : string | null ) : ExecHost | null {
const normalized = value ? . trim ( ) . toLowerCase ( ) ;
if ( normalized === "sandbox" || normalized === "gateway" || normalized === "node" ) {
return normalized ;
}
return null ;
}
export function normalizeExecSecurity ( value? : string | null ) : ExecSecurity | null {
const normalized = value ? . trim ( ) . toLowerCase ( ) ;
if ( normalized === "deny" || normalized === "allowlist" || normalized === "full" ) {
return normalized ;
}
return null ;
}
export 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 ;
}
export function renderExecHostLabel ( host : ExecHost ) {
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node" ;
}
export function normalizeNotifyOutput ( value : string ) {
return value . replace ( /\s+/g , " " ) . trim ( ) ;
}
2026-02-14 18:32:45 -05:00
function compactNotifyOutput ( value : string , maxChars = DEFAULT_NOTIFY_SNIPPET_CHARS ) {
const normalized = normalizeNotifyOutput ( value ) ;
if ( ! normalized ) {
return "" ;
}
if ( normalized . length <= maxChars ) {
return normalized ;
}
const safe = Math . max ( 1 , maxChars - 1 ) ;
return ` ${ normalized . slice ( 0 , safe ) } … ` ;
}
2026-02-13 17:49:29 +00:00
export 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 ;
}
2026-03-02 10:14:38 -06:00
const pathKey = findPathKey ( env ) ;
const merged = mergePathPrepend ( env [ pathKey ] , entries ) ;
2026-02-13 17:49:29 +00:00
if ( merged ) {
2026-03-02 10:14:38 -06:00
env [ pathKey ] = merged ;
2026-02-13 17:49:29 +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 } ` ;
2026-02-14 18:32:45 -05:00
const output = compactNotifyOutput (
2026-02-13 17:49:29 +00:00
tail ( session . tail || session . aggregated || "" , DEFAULT_NOTIFY_TAIL_CHARS ) ,
) ;
2026-02-14 18:32:45 -05:00
if ( status === "completed" && ! output && session . notifyOnExitEmptySuccess !== true ) {
return ;
}
2026-02-13 17:49:29 +00:00
const summary = output
? ` Exec ${ status } ( ${ session . id . slice ( 0 , 8 ) } , ${ exitLabel } ) :: ${ output } `
: ` Exec ${ status } ( ${ session . id . slice ( 0 , 8 ) } , ${ exitLabel } ) ` ;
enqueueSystemEvent ( summary , { sessionKey } ) ;
2026-03-03 14:47:40 +03:00
requestHeartbeatNow (
scopedHeartbeatWakeOptions ( sessionKey , { reason : ` exec: ${ session . id } :exit ` } ) ,
) ;
2026-02-13 17:49:29 +00:00
}
export function createApprovalSlug ( id : string ) {
return id . slice ( 0 , APPROVAL_SLUG_LENGTH ) ;
}
export 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 ) ;
}
export function emitExecSystemEvent (
text : string ,
opts : { sessionKey? : string ; contextKey? : string } ,
) {
const sessionKey = opts . sessionKey ? . trim ( ) ;
if ( ! sessionKey ) {
return ;
}
enqueueSystemEvent ( text , { sessionKey , contextKey : opts.contextKey } ) ;
2026-03-03 14:47:40 +03:00
requestHeartbeatNow ( scopedHeartbeatWakeOptions ( sessionKey , { reason : "exec-event" } ) ) ;
2026-02-13 17:49:29 +00:00
}
export async function runExecProcess ( opts : {
command : string ;
2026-02-14 19:42:52 +01:00
// Execute this instead of `command` (which is kept for display/session/logging).
// Used to sanitize safeBins execution while preserving the original user input.
execCommand? : string ;
2026-02-13 17:49:29 +00:00
workdir : string ;
env : Record < string , string > ;
sandbox? : BashSandboxConfig ;
containerWorkdir? : string | null ;
usePty : boolean ;
warnings : string [ ] ;
maxOutput : number ;
pendingMaxOutput : number ;
notifyOnExit : boolean ;
2026-02-14 18:32:45 -05:00
notifyOnExitEmptySuccess? : boolean ;
2026-02-13 17:49:29 +00:00
scopeKey? : string ;
sessionKey? : string ;
2026-02-22 23:02:17 +01:00
timeoutSec : number | null ;
2026-02-13 17:49:29 +00:00
onUpdate ? : ( partialResult : AgentToolResult < ExecToolDetails > ) = > void ;
} ) : Promise < ExecProcessHandle > {
const startedAt = Date . now ( ) ;
const sessionId = createSessionSlug ( ) ;
2026-02-14 19:42:52 +01:00
const execCommand = opts . execCommand ? ? opts . command ;
2026-02-16 09:32:05 +08:00
const supervisor = getProcessSupervisor ( ) ;
2026-03-01 20:31:06 -08:00
const shellRuntimeEnv : Record < string , string > = {
. . . opts . env ,
OPENCLAW_SHELL : "exec" ,
} ;
2026-02-13 17:49:29 +00:00
2026-02-16 09:32:05 +08:00
const session : ProcessSession = {
2026-02-13 17:49:29 +00:00
id : sessionId ,
command : opts.command ,
scopeKey : opts.scopeKey ,
sessionKey : opts.sessionKey ,
notifyOnExit : opts.notifyOnExit ,
2026-02-14 18:32:45 -05:00
notifyOnExitEmptySuccess : opts.notifyOnExitEmptySuccess === true ,
2026-02-13 17:49:29 +00:00
exitNotified : false ,
2026-02-16 09:32:05 +08:00
child : undefined ,
stdin : undefined ,
pid : undefined ,
2026-02-13 17:49:29 +00:00
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 ,
} ;
2026-02-16 09:32:05 +08:00
addSession ( session ) ;
2026-02-13 17:49:29 +00:00
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 ( ) ;
}
} ;
2026-02-16 09:32:05 +08:00
const timeoutMs =
typeof opts . timeoutSec === "number" && opts . timeoutSec > 0
? Math . floor ( opts . timeoutSec * 1000 )
: undefined ;
const spawnSpec :
| {
mode : "child" ;
argv : string [ ] ;
env : NodeJS.ProcessEnv ;
stdinMode : "pipe-open" | "pipe-closed" ;
}
| {
mode : "pty" ;
ptyCommand : string ;
childFallbackArgv : string [ ] ;
env : NodeJS.ProcessEnv ;
stdinMode : "pipe-open" ;
} = ( ( ) = > {
if ( opts . sandbox ) {
return {
mode : "child" as const ,
argv : [
"docker" ,
. . . buildDockerExecArgs ( {
containerName : opts.sandbox.containerName ,
command : execCommand ,
workdir : opts.containerWorkdir ? ? opts . sandbox . containerWorkdir ,
2026-03-01 20:31:06 -08:00
env : shellRuntimeEnv ,
2026-02-16 09:32:05 +08:00
tty : opts.usePty ,
} ) ,
] ,
env : process.env ,
stdinMode : opts.usePty ? ( "pipe-open" as const ) : ( "pipe-closed" as const ) ,
} ;
}
const { shell , args : shellArgs } = getShellConfig ( ) ;
const childArgv = [ shell , . . . shellArgs , execCommand ] ;
if ( opts . usePty ) {
return {
mode : "pty" as const ,
ptyCommand : execCommand ,
childFallbackArgv : childArgv ,
2026-03-01 20:31:06 -08:00
env : shellRuntimeEnv ,
2026-02-16 09:32:05 +08:00
stdinMode : "pipe-open" as const ,
} ;
}
return {
mode : "child" as const ,
argv : childArgv ,
2026-03-01 20:31:06 -08:00
env : shellRuntimeEnv ,
2026-02-16 09:32:05 +08:00
stdinMode : "pipe-closed" as const ,
} ;
} ) ( ) ;
let managedRun : ManagedRun | null = null ;
let usingPty = spawnSpec . mode === "pty" ;
const cursorResponse = buildCursorPositionResponse ( ) ;
const onSupervisorStdout = ( chunk : string ) = > {
if ( usingPty ) {
const { cleaned , requests } = stripDsrRequests ( chunk ) ;
if ( requests > 0 && managedRun ? . stdin ) {
2026-02-13 17:49:29 +00:00
for ( let i = 0 ; i < requests ; i += 1 ) {
2026-02-16 09:32:05 +08:00
managedRun . stdin . write ( cursorResponse ) ;
2026-02-13 17:49:29 +00:00
}
}
handleStdout ( cleaned ) ;
2026-02-16 09:32:05 +08:00
return ;
}
handleStdout ( chunk ) ;
} ;
2026-02-13 17:49:29 +00:00
2026-02-16 09:32:05 +08:00
try {
const spawnBase = {
runId : sessionId ,
sessionId : opts.sessionKey?.trim ( ) || sessionId ,
backendId : opts.sandbox ? "exec-sandbox" : "exec-host" ,
scopeKey : opts.scopeKey ,
cwd : opts.workdir ,
env : spawnSpec.env ,
timeoutMs ,
captureOutput : false ,
onStdout : onSupervisorStdout ,
onStderr : handleStderr ,
} ;
managedRun =
spawnSpec . mode === "pty"
? await supervisor . spawn ( {
. . . spawnBase ,
mode : "pty" ,
ptyCommand : spawnSpec.ptyCommand ,
} )
: await supervisor . spawn ( {
. . . spawnBase ,
mode : "child" ,
argv : spawnSpec.argv ,
stdinMode : spawnSpec.stdinMode ,
} ) ;
} catch ( err ) {
if ( spawnSpec . mode === "pty" ) {
const warning = ` Warning: PTY spawn failed ( ${ String ( err ) } ); retrying without PTY for \` ${ opts . command } \` . ` ;
logWarn (
` exec: PTY spawn failed ( ${ String ( err ) } ); retrying without PTY for " ${ opts . command } ". ` ,
) ;
opts . warnings . push ( warning ) ;
usingPty = false ;
try {
managedRun = await supervisor . spawn ( {
runId : sessionId ,
sessionId : opts.sessionKey?.trim ( ) || sessionId ,
backendId : "exec-host" ,
scopeKey : opts.scopeKey ,
mode : "child" ,
argv : spawnSpec.childFallbackArgv ,
cwd : opts.workdir ,
env : spawnSpec.env ,
stdinMode : "pipe-open" ,
timeoutMs ,
captureOutput : false ,
onStdout : handleStdout ,
onStderr : handleStderr ,
} ) ;
} catch ( retryErr ) {
markExited ( session , null , null , "failed" ) ;
maybeNotifyOnExit ( session , "failed" ) ;
throw retryErr ;
2026-02-13 17:49:29 +00:00
}
2026-02-16 09:32:05 +08:00
} else {
markExited ( session , null , null , "failed" ) ;
maybeNotifyOnExit ( session , "failed" ) ;
throw err ;
}
}
session . stdin = managedRun . stdin ;
session . pid = managedRun . pid ;
const promise = managedRun
. wait ( )
. then ( ( exit ) : ExecProcessOutcome = > {
2026-02-13 17:49:29 +00:00
const durationMs = Date . now ( ) - startedAt ;
2026-02-16 23:39:02 +05:30
const isNormalExit = exit . reason === "exit" ;
2026-02-24 11:58:52 +08:00
const exitCode = exit . exitCode ? ? 0 ;
// Shell exit codes 126 (not executable) and 127 (command not found) are
// unrecoverable infrastructure failures that should surface as real errors
// rather than silently completing — e.g. `python: command not found`.
const isShellFailure = exitCode === 126 || exitCode === 127 ;
const status : "completed" | "failed" =
isNormalExit && ! isShellFailure ? "completed" : "failed" ;
2026-02-16 23:39:02 +05:30
2026-02-16 09:32:05 +08:00
markExited ( session , exit . exitCode , exit . exitSignal , status ) ;
2026-02-13 17:49:29 +00:00
maybeNotifyOnExit ( session , status ) ;
if ( ! session . child && session . stdin ) {
session . stdin . destroyed = true ;
}
const aggregated = session . aggregated . trim ( ) ;
2026-02-16 09:32:05 +08:00
if ( status === "completed" ) {
2026-02-16 23:39:02 +05:30
const exitMsg = exitCode !== 0 ? ` \ n \ n(Command exited with code ${ exitCode } ) ` : "" ;
2026-02-16 09:32:05 +08:00
return {
status : "completed" ,
2026-02-16 23:39:02 +05:30
exitCode ,
2026-02-16 09:32:05 +08:00
exitSignal : exit.exitSignal ,
2026-02-13 17:49:29 +00:00
durationMs ,
2026-02-16 23:39:02 +05:30
aggregated : aggregated + exitMsg ,
2026-02-16 09:32:05 +08:00
timedOut : false ,
} ;
2026-02-13 17:49:29 +00:00
}
2026-02-24 11:58:52 +08:00
const reason = isShellFailure
? exitCode === 127
? "Command not found"
: "Command not executable (permission denied)"
: exit . reason === "overall-timeout"
2026-02-22 23:02:17 +01:00
? typeof opts . timeoutSec === "number" && opts . timeoutSec > 0
2026-03-03 09:50:49 +08:00
? ` Command timed out after ${ opts . timeoutSec } seconds. If this command is expected to take longer, re-run with a higher timeout (e.g., exec timeout=300). `
: "Command timed out. If this command is expected to take longer, re-run with a higher timeout (e.g., exec timeout=300)."
2026-02-16 09:32:05 +08:00
: exit . reason === "no-output-timeout"
? "Command timed out waiting for output"
: exit . exitSignal != null
? ` Command aborted by signal ${ exit . exitSignal } `
2026-02-16 23:39:02 +05:30
: "Command aborted before exit code was captured" ;
2026-02-16 09:32:05 +08:00
return {
status : "failed" ,
exitCode : exit.exitCode ,
exitSignal : exit.exitSignal ,
2026-02-13 17:49:29 +00:00
durationMs ,
aggregated ,
2026-02-16 09:32:05 +08:00
timedOut : exit.timedOut ,
reason : aggregated ? ` ${ aggregated } \ n \ n ${ reason } ` : reason ,
} ;
} )
. catch ( ( err ) : ExecProcessOutcome = > {
markExited ( session , null , null , "failed" ) ;
maybeNotifyOnExit ( session , "failed" ) ;
const aggregated = session . aggregated . trim ( ) ;
const message = aggregated ? ` ${ aggregated } \ n \ n ${ String ( err ) } ` : String ( err ) ;
return {
status : "failed" ,
exitCode : null ,
exitSignal : null ,
durationMs : Date.now ( ) - startedAt ,
aggregated ,
2026-02-13 17:49:29 +00:00
timedOut : false ,
2026-02-16 09:32:05 +08:00
reason : message ,
} ;
} ) ;
2026-02-13 17:49:29 +00:00
return {
session ,
startedAt ,
pid : session.pid ? ? undefined ,
promise ,
2026-02-16 09:32:05 +08:00
kill : ( ) = > {
managedRun ? . cancel ( "manual-cancel" ) ;
} ,
2026-02-13 17:49:29 +00:00
} ;
}