Files
zero/internal/tools/exec_command.go
T
Anandan 518d2e53ba fix(windows): use PowerShell for command execution (#804)
* fix(windows): prefer PowerShell for command execution

Detect PowerShell 7 or Windows PowerShell before falling back to cmd.exe, and share the selected host shell across agent tools and the interactive shell escape.

Configure UTF-8 output, preserve restricted-sandbox MSYS safeguards, and add Windows coverage for pipelines, workspace writes, and interactive stdin sessions.

* test(windows): align shell coverage with PowerShell

Make Windows command fixtures and guidance assertions follow the selected host shell, preserve cmd fallback coverage, and use a genuine MSYS executable when PowerShell aliases are available.

Remove the unsupported non-TTY PowerShell stdin assertion; Windows interactive stdin requires a separate PTY transport.

* fix(sandbox): classify Windows executable suffixes

Normalize .exe, .cmd, .bat, and .com command names before safety and network lookup so PowerShell invocations such as curl.cmd receive the same network permission handling as curl.

Add analyzer and engine-level regressions for the Windows PowerShell command shape observed in CI.

* fix(sandbox): normalize absolute Windows command paths

Extract drive-letter and UNC basenames before removing shell escape characters so fully qualified Windows executables continue matching canonical safety and network classifications.

Cover both drive and UNC curl paths with analyzer regressions.

* fix(sandbox): normalize relative Windows paths

Recognize drive-relative, explicit relative, and suffixed backslash executable paths before generic shell escape removal so Windows commands continue to match canonical sandbox risk entries.

* fix(windows): harden PowerShell execution

* fix(sandbox): classify PowerShell removal aliases
2026-07-27 13:21:47 +05:30

677 lines
27 KiB
Go

package tools
import (
"context"
"errors"
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/execution"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
const (
ExecCommandToolName = "exec_command"
WriteStdinToolName = "write_stdin"
defaultExecYieldTimeMS = 10000
defaultPollYieldTimeMS = 5000
maxExecYieldTimeMS = 30000
maxPollYieldTimeMS = 300000
defaultMaxOutputTokens = 10000
maxExecOutputTokenRequest = 200000
// maxExecOutputBufferBytes caps the undrained output an unpolled session can
// accumulate. Without a cap, a long-lived background session nobody polls
// again (e.g. a dev server left running after its initiating run was
// cancelled) grows this buffer forever as long as the process keeps writing
// output, with no ceiling — this previously ran a session's memory into the
// tens of gigabytes over several hours and got the whole zero process
// OOM-killed by the OS.
maxExecOutputBufferBytes = 2 * 1024 * 1024
execOutputBufferTruncatedMessage = "[zero] output buffer truncated: undrained output exceeded 2MiB, oldest output dropped"
)
type execSessionManager = execution.ProcessManager
func newExecSessionManager() *execSessionManager {
return execution.NewProcessManager(execution.ProcessManagerOptions{})
}
var defaultExecSessionManager = newExecSessionManager()
type ExecSessionSnapshot = execution.ProcessSnapshot
type ExecSessionController interface {
ExecSessions() []ExecSessionSnapshot
StopExecSession(id int) bool
StopAllExecSessions() []int
}
type execCommandTool struct {
baseTool
workspaceRoot string
scope PathScope
manager *execSessionManager
}
func (execCommandTool) outputCategory(args map[string]any) outputCategory {
command, _ := execCommandArg(args)
return shellOutputCategory(command)
}
func execCommandArg(args map[string]any) (string, error) {
return aliasedStringArg(args, []string{"cmd", "command", "script", "shell"}, "", true, false)
}
func NewExecCommandTool(workspaceRoot string, manager *execSessionManager) Tool {
return NewScopedExecCommandTool(workspaceRoot, nil, manager)
}
func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *execSessionManager) Tool {
if manager == nil {
manager = defaultExecSessionManager
}
description := "Runs a command in a PTY, returning output or a session ID for ongoing interaction."
if runtimeGOOS() == "windows" {
description += "\n\n" + shellGuidanceForGOOS(runtimeGOOS())
}
return execCommandTool{
baseTool: baseTool{
name: ExecCommandToolName,
description: description,
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"cmd": {Type: "string", Description: "Shell command to execute."},
"workdir": {Type: "string", Description: "Working directory for the command. Defaults to the turn cwd.", Default: "."},
"cwd": {Type: "string", Description: "Alias for workdir. Prefer workdir.", Default: "."},
"yield_time_ms": {Type: "integer", Description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.", Default: defaultExecYieldTimeMS, Minimum: intPtr(1), Maximum: intPtr(maxExecYieldTimeMS)},
"max_output_tokens": {Type: "integer", Description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.", Default: defaultMaxOutputTokens, Minimum: intPtr(1), Maximum: intPtr(maxExecOutputTokenRequest)},
"sandbox_permissions": {Type: "string", Enum: []string{string(SandboxPermissionsUseDefault), string(SandboxPermissionsWithAdditionalPermissions), string(SandboxPermissionsRequireEscalated)}, Description: "Per-command sandbox override. Defaults to `use_default`; use `with_additional_permissions` with `additional_permissions` for sandboxed file/network access, or `require_escalated` only when the command must run outside the sandbox, such as host/global process, socket, service, or desktop state hidden by sandbox namespaces.", Default: string(SandboxPermissionsUseDefault)},
"additional_permissions": {
Type: "object",
Description: "Sandboxed filesystem or network access for this command; only with `sandbox_permissions: \"with_additional_permissions\"`.",
Properties: additionalPermissionsProperties(),
},
"justification": {Type: "string", Description: "User-facing approval question for `require_escalated`; omit otherwise."},
"prefix_rule": {Type: "array", Items: &PropertySchema{Type: "string"}, Description: "Reusable approval prefix for this command, only with `sandbox_permissions: \"require_escalated\"`; keep it narrow, for example [\"git\", \"pull\"]."},
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes.", Default: false},
},
Required: []string{"cmd"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
Reason: "Shell commands can read, write, or execute programs.",
AdvertiseInAuto: true,
},
// PTY/shell session — never concurrent.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
manager: manager,
}
}
func (tool execCommandTool) Run(ctx context.Context, args map[string]any) Result {
return tool.run(ctx, args, nil, true)
}
func (tool execCommandTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
return tool.run(ctx, args, engine, true)
}
func (tool execCommandTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
return tool.run(ctx, args, options.Sandbox, false)
}
func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
return tool.manager.List()
}
func (tool execCommandTool) StopExecSession(id int) bool {
return tool.manager.Stop(id)
}
func (tool execCommandTool) StopAllExecSessions() []int {
return tool.manager.StopAll()
}
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
commandText, err := execCommandArg(args)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
workdir, err := aliasedStringArg(args, []string{"workdir", "cwd", "dir", "directory"}, ".", false, true)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultExecYieldTimeMS, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
maxOutputTokens, err := intArg(args, "max_output_tokens", defaultMaxOutputTokens, 1, maxExecOutputTokenRequest)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
ttyRequested, err := boolArg(args, "tty", false)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
sandboxPermissions, err := sandboxPermissionsArg(args)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
// Resolve the command engine before the MSYS preflight check so an
// approved require_escalated call (commandEngine == nil, truly
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtimeGOOS())); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
return interactiveBlockResult(interactive)
}
absoluteCwd, relativeCwd, err := resolveScopedPath(tool.workspaceRoot, tool.scope, workdir)
if err != nil {
return errorResult("Error running exec_command: " + err.Error())
}
commandCtx, cancel := context.WithCancel(context.Background())
command, plan, err := buildBashCommand(commandCtx, commandText, absoluteCwd, commandEngine)
if err != nil {
cancel()
return errorResult("Error starting exec_command: " + err.Error())
}
executionRequest := execExecutionRequest(command, plan, absoluteCwd, ttyRequested)
if err := executionRequest.Validate(); err != nil {
plan.Cleanup()
cancel()
return errorResult("Error starting exec_command: prepare execution request: " + err.Error())
}
meta := map[string]string{}
addSandboxMeta(meta, plan)
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
processResult, err := tool.manager.Start(ctx, execution.ProcessStart{
Prepared: execution.PreparedCommand{
Command: command,
Enforcement: executionEnforcement(plan),
Report: plan.ExecutionReport,
Cleanup: func() {
plan.Cleanup()
cancel()
},
},
Request: executionRequest, CommandText: commandText, RelativeCwd: relativeCwd,
TTY: ttyRequested, Metadata: meta,
AfterWait: func() []byte {
blocks := monitor.Stop()
if len(blocks) == 0 {
return nil
}
return []byte(appendSandboxBlocks("", blocks))
},
}, time.Duration(yieldTimeMS)*time.Millisecond)
if err != nil {
_ = monitor.Stop()
return errorResult("Error starting exec_command: " + err.Error())
}
return execToolResultWithBudget(execToolResultInput{
commandText: processResult.CommandText, output: processResult.Output,
outputBufferTruncated: processResult.OutputTruncated, sessionID: processResult.ProcessID,
exitCode: processResult.ExitCode, exited: processResult.Exited, relativeCwd: processResult.RelativeCwd,
tty: processResult.TTY, request: processResult.Request, enforcement: processResult.Enforcement,
report: processResult.Report, reportErr: processResult.ReportErr, changes: processResult.Changes,
sandboxMeta: processResult.Metadata,
maxOutputTokens: maxOutputTokens,
}, directBudget)
}
func executionEnforcement(plan zeroSandbox.CommandPlan) execution.Enforcement {
return execution.Enforcement{
Backend: string(plan.TargetBackend),
Level: string(plan.EnforcementLevel),
Degraded: plan.EnforcementLevel == zeroSandbox.EnforcementDegraded,
DowngradeReason: plan.DowngradeReason,
}
}
type writeStdinTool struct {
baseTool
manager *execSessionManager
}
// UnknownExecSessionError is the result returned when write_stdin targets a
// session_id with no live exec session. The stable recovery guidance leads and
// the numeric id trails on purpose: the agent's repeated-failure guard keys on
// a normalized, truncated prefix of the error string, so keeping the id out of
// that prefix makes a model probing ids 1, 2, 3, … produce ONE signature that
// finally trips the halt instead of a fresh signature per id — while also
// telling the model how to actually recover. See TestUnknownExecSessionErrorSignatureIsIDInvariant
// in internal/agent, which pins this invariant against the real guard.
func UnknownExecSessionError(sessionID int) string {
return fmt.Sprintf("Error: write_stdin needs a session_id returned by a still-running exec_command; do not guess or probe session ids. Start a process with exec_command, or use write_file/edit_file/apply_patch for file changes. (no live session %d)", sessionID)
}
func (writeStdinTool) outputCategory(map[string]any) outputCategory {
return outputCategoryProcess
}
func NewWriteStdinTool(manager *execSessionManager) Tool {
if manager == nil {
manager = defaultExecSessionManager
}
return writeStdinTool{
baseTool: baseTool{
name: WriteStdinToolName,
description: "Writes characters to an existing unified exec session and returns recent output.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"session_id": {Type: "integer", Description: "Identifier of a running unified exec session, as returned by exec_command. Never guess or probe ids.", Minimum: intPtr(1)},
"chars": {Type: "string", Description: "Bytes to write to stdin. Defaults to empty, which polls without writing.", Default: ""},
"yield_time_ms": {Type: "integer", Description: "Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.", Default: defaultPollYieldTimeMS, Minimum: intPtr(1), Maximum: intPtr(maxPollYieldTimeMS)},
"max_output_tokens": {Type: "integer", Description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.", Default: defaultMaxOutputTokens, Minimum: intPtr(1), Maximum: intPtr(maxExecOutputTokenRequest)},
},
Required: []string{"session_id"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
Reason: "Sending stdin can drive an existing shell process beyond the original command; empty polling and Ctrl-C interrupts are allowed automatically.",
AdvertiseInAuto: true,
},
// Writes to a retained process stdin — process interaction.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
},
manager: manager,
}
}
func (tool writeStdinTool) PermissionForArgs(args map[string]any) Permission {
raw, ok := args["chars"]
if !ok || raw == nil {
return PermissionAllow
}
chars, ok := raw.(string)
if !ok {
return PermissionPrompt
}
if chars == "" || chars == "\x03" {
return PermissionAllow
}
return PermissionPrompt
}
func (tool writeStdinTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]any, _ RunOptions) Result {
// A missing, non-integer, or < 1 session_id all mean the same thing: the model
// has no live session to write to. Route them to the SAME recovery guidance the
// no-live-session case uses (UnknownExecSessionError) instead of a terse
// "session_id must be at least 1". The terse error gives no way forward and, by
// naming the minimum, nudges the model to try 1, 2, 3... — the exact id-probing
// #749 works to suppress. The recovery message instead tells it to start a
// session or edit files directly, and because that message is id-invariant, the
// missing/zero/non-integer entry points and id-probing collapse to ONE
// repeated-failure signature, so any mix of them accumulates toward the halt
// rather than resetting the streak on each class of mistake.
value, present := args["session_id"]
sessionID, sessionErr := intArg(args, "session_id", 0, 1, 0)
if !present || value == nil || sessionErr != nil {
return errorResult(UnknownExecSessionError(sessionID))
}
chars, err := stringArgWithEmpty(args, "chars", "", false, true)
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
defaultYieldTimeMS := defaultPollYieldTimeMS
if chars != "" {
defaultYieldTimeMS = 250
}
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultYieldTimeMS, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
maxOutputTokens, err := intArg(args, "max_output_tokens", defaultMaxOutputTokens, 1, maxExecOutputTokenRequest)
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
snapshot, ok := tool.manager.Snapshot(sessionID)
if !ok {
return errorResult(UnknownExecSessionError(sessionID))
}
interrupted := chars != "" && shouldInterruptExecSession(chars, snapshot.TTY)
processResult, err := tool.manager.Continue(ctx, execution.ProcessContinue{
ProcessID: sessionID, Input: []byte(chars), Interrupt: interrupted,
Wait: time.Duration(yieldTimeMS) * time.Millisecond,
})
if errors.Is(err, execution.ErrProcessNotFound) {
return errorResult(UnknownExecSessionError(sessionID))
}
if errors.Is(err, execution.ErrProcessStdinDisabled) {
return errorResult(fmt.Sprintf("Error: exec session_id %d does not accept stdin. Use empty chars to poll, or send chars \"\\u0003\" to interrupt/stop it.", sessionID))
}
if err != nil {
return errorResult("Error writing to exec session: " + err.Error())
}
return execToolResult(execToolResultInput{
commandText: processResult.CommandText, output: processResult.Output,
outputBufferTruncated: processResult.OutputTruncated, sessionID: processResult.ProcessID,
exitCode: processResult.ExitCode, exited: processResult.Exited, relativeCwd: processResult.RelativeCwd,
tty: processResult.TTY, interrupted: processResult.Interrupted, request: processResult.Request,
enforcement: processResult.Enforcement, report: processResult.Report, reportErr: processResult.ReportErr,
changes: processResult.Changes, sandboxMeta: processResult.Metadata,
maxOutputTokens: maxOutputTokens,
})
}
func shouldInterruptExecSession(chars string, tty bool) bool {
if strings.Contains(chars, "\x03") {
return true
}
normalized := strings.ToLower(strings.TrimSpace(chars))
normalizedNoSpace := strings.ReplaceAll(normalized, " ", "")
switch normalizedNoSpace {
case `\u0003`, `\\u0003`, `\x03`, `\\x03`, "^c", "ctrl-c", "control-c", "sigint", "interrupt":
return true
}
if tty {
return false
}
switch normalized {
case "q", "quit", "exit", "stop", "kill", "terminate":
return true
}
return false
}
type execToolResultInput struct {
commandText string
output string
// outputBufferTruncated is true when the session's undrained output buffer
// had to drop bytes to stay within maxExecOutputBufferBytes since it was
// last collected — data that is gone for good, unlike the head/tail
// truncation below (which the caller can recover by polling again or
// raising max_output_tokens).
outputBufferTruncated bool
sessionID int
exitCode int
exited bool
relativeCwd string
tty bool
interrupted bool
request execution.Request
enforcement execution.Enforcement
sandboxMeta map[string]string
report execution.AdapterReport
reportErr error
changes []execution.Change
maxOutputTokens int
}
func execToolResult(input execToolResultInput) Result {
return execToolResultWithBudget(input, true)
}
func execToolResultWithBudget(input execToolResultInput, directBudget bool) Result {
output := input.output
truncated := false
if directBudget {
output, truncated = truncateExecOutput(input.output, input.maxOutputTokens)
}
meta := map[string]string{
"cwd": input.relativeCwd,
"tty": strconv.FormatBool(input.tty),
}
for key, value := range input.sandboxMeta {
meta[key] = value
}
outcome := execExecutionOutcome(input)
if input.exited {
meta["exit_code"] = strconv.Itoa(input.exitCode)
if input.interrupted {
meta["interrupted"] = "true"
}
if input.report.Denial != nil {
markStructuredSandboxDenial(meta, *input.report.Denial)
}
} else {
meta["session_id"] = strconv.Itoa(input.sessionID)
}
if input.outputBufferTruncated {
meta["output_buffer_truncated"] = "true"
}
status := StatusOK
if input.exited && input.exitCode != 0 && !input.interrupted {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
meta["shell_issue"] = issue.Kind
body = appendShellIssueHint(body, *issue)
}
}
if input.outputBufferTruncated {
// Appended after truncateExecOutput's own head/tail slicing, not
// embedded in the text that goes through it — a marker inside that
// text can land in the discarded middle or get chopped at a small
// max_output_tokens budget. Appending here guarantees it survives.
body += "\n" + execOutputBufferTruncatedMessage
}
return Result{
Status: status,
Output: body,
Truncated: truncated || input.outputBufferTruncated,
Meta: meta,
ExecutionRequest: &input.request,
ExecutionOutcome: &outcome,
ChangedFiles: executionChangedFiles(input.changes),
ChangeSummaries: executionChangeSummaries(input.changes),
Display: Display{
Summary: execDisplaySummary(input.commandText, input.sessionID, input.exited, input.exitCode),
Kind: "shell",
},
}
}
func execExecutionRequest(command *exec.Cmd, plan zeroSandbox.CommandPlan, cwd string, tty bool) execution.Request {
mode := execution.ModeCaptured
if tty {
mode = execution.ModeInteractive
}
workspaceRoot := strings.TrimSpace(plan.WorkspaceRoot)
if workspaceRoot == "" {
workspaceRoot = cwd
}
workspaceRoots := []string{workspaceRoot}
capabilities := []execution.Capability{{Kind: execution.CapabilityProcessSpawn}}
if plan.PermissionProfile.FileSystem.Kind == zeroSandbox.FileSystemUnrestricted {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"})
}
for _, root := range plan.PermissionProfile.FileSystem.ReadRoots {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityRead, Scope: root})
}
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityWorkspaceWrite, Scope: root.Root})
}
if plan.PermissionProfile.Network.Mode == zeroSandbox.NetworkAllow || plan.Policy.Network == zeroSandbox.NetworkAllow {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityExternalNetwork})
} else if plan.TargetBackend == zeroSandbox.BackendLinuxBwrap {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityIsolatedLoopback, Scope: "sandbox"})
}
args := append([]string(nil), command.Args...)
name := command.Path
if len(args) > 0 {
name = args[0]
args = args[1:]
}
return execution.Request{
Origin: execution.OriginInteractiveCommand,
Mode: mode,
Command: execution.Command{Name: name, Args: args},
WorkingDirectory: cwd,
WorkspaceRoots: workspaceRoots,
Capabilities: capabilities,
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
}
}
func execExecutionOutcome(input execToolResultInput) execution.Outcome {
enforcement := input.enforcement
if !input.exited {
return execution.Outcome{
State: execution.StateRetained,
Kind: execution.OutcomeRunning,
ProcessID: strconv.Itoa(input.sessionID),
Enforcement: enforcement,
}
}
exit := &execution.Exit{Code: input.exitCode}
if input.reportErr != nil {
return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeSandboxSetupFailure, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
if input.report.Denial != nil {
denial := *input.report.Denial
return execution.Outcome{State: execution.StateDenied, Kind: execution.OutcomeEnforcementDenied, Exit: exit, Denial: &denial, Enforcement: enforcement, Changes: input.changes}
}
if input.interrupted {
return execution.Outcome{State: execution.StateCancelled, Kind: execution.OutcomeCancelled, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
if input.exitCode == 0 {
return execution.Outcome{State: execution.StateCompleted, Kind: execution.OutcomeSuccess, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeApplicationFailure, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
func executionChangedFiles(changes []execution.Change) []string {
if len(changes) == 0 {
return nil
}
paths := make([]string, 0, len(changes))
for _, change := range changes {
if !change.Aggregated && strings.TrimSpace(change.Path) != "" {
paths = append(paths, change.Path)
}
}
return paths
}
func executionChangeSummaries(changes []execution.Change) []execution.Change {
var summaries []execution.Change
for _, change := range changes {
if change.Aggregated && strings.TrimSpace(change.Path) != "" {
summaries = append(summaries, change)
}
}
return summaries
}
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool) string {
output = strings.TrimRight(output, "\r\n")
parts := []string{}
if output != "" {
parts = append(parts, "output:\n"+output)
}
if exited {
if output == "" {
if interrupted {
parts = append(parts, "Command interrupted.")
} else {
parts = append(parts, "Command completed with no output.")
}
}
if interrupted {
parts = append(parts, "interrupted: true")
}
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
} else {
if output == "" {
parts = append(parts, "Command is still running.")
}
parts = append(parts, fmt.Sprintf("session_id: %d", sessionID))
parts = append(parts, fmt.Sprintf("Use write_stdin with session_id %d and empty chars to poll; send chars \"\\u0003\" to interrupt/stop it.", sessionID))
}
return strings.Join(parts, "\n")
}
func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
return truncateExecOutputSpill(output, maxOutputTokens, "exec_command")
}
// truncateExecOutputSpill keeps a head/tail window of the output within the
// token budget and, on truncation, spills the full output to disk so the model
// can grep/read the elided middle instead of re-running the command with a
// bigger budget. The spill is best-effort: when it fails the notice simply
// omits the file hint.
func truncateExecOutputSpill(output string, maxOutputTokens int, toolName string) (string, bool) {
if maxOutputTokens <= 0 {
maxOutputTokens = defaultMaxOutputTokens
}
maxBytes := maxOutputTokens * 4
if len(output) <= maxBytes {
return output, false
}
notice := "\n[zero] output truncated\n"
if spillPath := spillTruncatedOutput(toolName, output); spillPath != "" {
notice = "\n[zero] output truncated — full output saved to " + spillPath + " (grep or read_file it instead of re-running)\n"
}
head := maxBytes / 2
tail := maxBytes - head
return utf8Prefix(output, head) + notice + utf8Suffix(output, tail), true
}
func utf8Prefix(value string, maxBytes int) string {
if maxBytes <= 0 {
return ""
}
if len(value) <= maxBytes {
return value
}
for maxBytes > 0 && !utf8.RuneStart(value[maxBytes]) {
maxBytes--
}
return value[:maxBytes]
}
func utf8Suffix(value string, maxBytes int) string {
if maxBytes <= 0 {
return ""
}
if len(value) <= maxBytes {
return value
}
start := len(value) - maxBytes
for start < len(value) && !utf8.RuneStart(value[start]) {
start++
}
return value[start:]
}
func execDisplaySummary(commandText string, sessionID int, exited bool, exitCode int) string {
commandText = strings.TrimSpace(commandText)
if commandText == "" {
commandText = "command"
}
if exited {
return fmt.Sprintf("%s exited with code %d", commandText, exitCode)
}
return fmt.Sprintf("%s still running as session %d", commandText, sessionID)
}
func runtimeGOOS() string {
return runtime.GOOS
}