Files
zero/internal/tools/bash.go
PierrunoYTandClaude Opus 5.5 13e2596b2e chore: move repository references to Twigpine/zero (#1105)
Update the Go module path, imports, install scripts, docs, workflows,
packaging and action defaults from Gitlawb/zero to Twigpine/zero. The npm
scope (already @twigpine/zero on main, with its @gitlawb/zero migration
notes), LICENSE, action author, the OpenGateway provider and historical
CHANGELOG links are unchanged.

The X-AIMLAPI-Integration-Repo header keeps Gitlawb/zero: it is what
AIMLAPI sees for attribution, so changing it needs a maintainer's yes.
docs/INSTALL.md keeps `go install github.com/Gitlawb/zero/...`, because
every tagged release so far declares that module path. The Homebrew
formula comment names the Twigpine/homebrew-tap tap.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 07:13:21 +08:00

613 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tools
import (
"context"
"errors"
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"github.com/Twigpine/zero/internal/execution"
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/secrets"
)
const defaultBashTimeoutMS = 120000
const maxBashTimeoutMS = 600000
type bashTool struct {
baseTool
workspaceRoot string
scope PathScope
}
func (bashTool) outputCategory(args map[string]any) outputCategory {
command, _ := bashCommandArg(args)
return shellOutputCategory(command)
}
func bashCommandArg(args map[string]any) (string, error) {
return aliasedStringArg(args, []string{"command", "cmd", "script", "shell"}, "", true, false)
}
func NewBashTool(workspaceRoot string) Tool {
return NewScopedBashTool(workspaceRoot, nil)
}
func NewScopedBashTool(workspaceRoot string, scope PathScope) Tool {
shellGuidance := shellGuidanceForGOOS(runtime.GOOS)
return bashTool{
baseTool: baseTool{
name: "bash",
description: "Execute a shell command inside the workspace (or an explicitly granted extra directory) after permission is granted. " + shellGuidance,
deferred: true,
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"command": {Type: "string", Description: "Shell command to execute using the host shell. " + shellGuidance},
"cwd": {Type: "string", Description: "Directory to run the command in. Relative paths stay in the workspace; use an absolute path to run in a granted extra directory. Defaults to workspace root. Prefer cwd over cd when changing directories.", Default: "."},
"timeout_ms": {Type: "integer", Description: "Command timeout in milliseconds.", Default: defaultBashTimeoutMS, Minimum: intPtr(1), Maximum: intPtr(maxBashTimeoutMS)},
"sandbox_permissions": {Type: "string", Enum: []string{string(SandboxPermissionsUseDefault), string(SandboxPermissionsWithAdditionalPermissions), string(SandboxPermissionsRequireEscalated)}, Description: sandboxPermissionsDescription, Default: string(SandboxPermissionsUseDefault)},
"additional_permissions": {
Type: "object",
Description: additionalPermissionsDescription,
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\"]."},
},
Required: []string{"command"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
// One-shot shell can rewrite the workspace (and more). Serialized
// as WorkspaceWrite so external-mutator audits include bash; not
// session-bound Interactive (that is reserved for PTY/terminal tools).
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
}
}
func (tool bashTool) Run(ctx context.Context, args map[string]any) Result {
return tool.run(ctx, args, nil, true)
}
func (tool bashTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
return tool.run(ctx, args, engine, true)
}
func (tool bashTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
return tool.run(ctx, args, options.Sandbox, false)
}
func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
commandText, err := bashCommandArg(args)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
cwd, err := stringArg(args, "cwd", ".", false)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
timeoutMS, err := intArg(args, "timeout_ms", defaultBashTimeoutMS, 1, maxBashTimeoutMS)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
sandboxPermissions, err := sandboxPermissionsArg(args)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + 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(runtime.GOOS)); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
// Pre-execution safety: refuse interactive commands (editors, pagers, REPLs,
// remote shells, etc.) that would hang the non-interactive agent until the
// timeout fires. This runs before the command is built or launched.
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtime.GOOS); interactive.Interactive {
return interactiveBlockResult(interactive)
}
absoluteCwd, relativeCwd, err := resolveScopedPath(tool.workspaceRoot, tool.scope, cwd)
if err != nil {
return errorResult("Error running bash: " + err.Error())
}
commandCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMS)*time.Millisecond)
defer cancel()
meta := map[string]string{
"cwd": relativeCwd,
"timeout_ms": strconv.Itoa(timeoutMS),
}
if commandEngine == nil && sandboxPermissions == SandboxPermissionsRequireEscalated {
meta["sandbox_permissions"] = string(SandboxPermissionsRequireEscalated)
}
command, plan, err := buildBashCommand(commandCtx, commandText, absoluteCwd, commandEngine)
if err != nil {
meta["exit_code"] = "-1"
return Result{
Status: StatusError,
Output: "Error preparing sandboxed bash command: " + err.Error(),
Meta: meta,
}
}
defer plan.Cleanup()
addSandboxMeta(meta, plan)
executionRequest := execExecutionRequest(command, plan, absoluteCwd, false)
changeObserver := execution.NewChangeObserver(plan.WorkspaceRoot)
// Bound the capture so a command with runaway output (`cat huge.log`, `yes`)
// can't grow Zero's memory before truncation: only the head+tail each stream
// will ever surface to the model are retained, the middle is discarded as it
// streams, and the true size is counted for the truncation marker.
stdout := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
stderr := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
command.Stdout = stdout
command.Stderr = stderr
// Kill the shell as a process group on timeout and bound the post-kill I/O
// wait, so a backgrounded child cannot outlive the command or hang Run().
hardenProcessLifetime(command)
// Capture sandbox denials when the plan opted in (macOS + Policy.MonitorDenials).
// A no-op when MonitorTag is empty, so the default path is unchanged.
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
err = command.Run()
exitCode := commandExitCode(err)
adapterReport, reportErr := plan.ExecutionReport()
meta["exit_code"] = strconv.Itoa(exitCode)
stdoutText := stdout.retained()
stderrRetained := stderr.retained()
stderrText := appendSandboxBlocks(stderrRetained, monitor.Stop())
// Sandbox blocks are extra model-visible stderr bytes appended after capture;
// count them toward the true total so the budget/marker stay accurate.
stderrTotal := stderr.total + (len(stderrText) - len(stderrRetained))
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
result := Result{
Status: StatusError,
Output: fmt.Sprintf("Error: Command timed out after %dms.", timeoutMS),
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), true)
}
if err != nil {
if exitCode < 0 {
result := Result{
Status: StatusError,
Output: "Error executing command: " + err.Error(),
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
if adapterReport.Denial != nil {
markStructuredSandboxDenial(meta, *adapterReport.Denial)
}
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
result := Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
if adapterReport.Denial != nil {
markStructuredSandboxDenial(meta, *adapterReport.Denial)
}
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
result := Result{
Status: StatusOK,
Output: formatBashOutput(outText, errText, exitCode),
Truncated: truncated,
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
func withBashExecution(result Result, request execution.Request, plan zeroSandbox.CommandPlan, exitCode int, report execution.AdapterReport, reportErr error, changes []execution.Change, timedOut bool) Result {
input := execToolResultInput{
exited: true,
exitCode: exitCode,
enforcement: executionEnforcement(plan),
request: request,
report: report,
reportErr: reportErr,
changes: changes,
}
outcome := execExecutionOutcome(input)
if timedOut && report.Denial == nil && reportErr == nil {
outcome.State = execution.StateFailed
outcome.Kind = execution.OutcomeTimedOut
}
result.ExecutionRequest = &request
result.ExecutionOutcome = &outcome
result.ChangedFiles = executionChangedFiles(changes)
result.ChangeSummaries = executionChangeSummaries(changes)
return result
}
func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) *zeroSandbox.Engine {
if sandboxPermissions == SandboxPermissionsRequireEscalated && (engine == nil || engine.UnsandboxedExecutionAllowed()) {
return nil
}
return engine
}
// msysGuardBypassed reports whether a windows_msys_sandbox preflight issue no
// longer applies because sandbox_permissions: require_escalated actually
// resolved to unsandboxed, host-level execution (commandEngine is nil only
// then, per commandEngineForSandboxPermissions). MSYS coreutils fail because
// of the sandbox's restricted token/handles, so running outside it removes
// the failure mode this guard exists for. Any other issue kind (e.g. a real
// cmd.exe syntax problem) still blocks regardless of escalation, since
// running outside the sandbox does not fix a syntax error.
func msysGuardBypassed(issue *shellIssue, commandEngine *zeroSandbox.Engine) bool {
return issue.Kind == windowsMsysSandboxKind && commandEngine == nil
}
// appendSandboxBlocks appends a <sandbox_blocks> block listing the denials
// the sandbox log monitor captured, so the model can see what was blocked. With no
// blocks the stderr is returned unchanged.
func appendSandboxBlocks(stderr string, blocks []string) string {
if len(blocks) == 0 {
return stderr
}
var builder strings.Builder
builder.WriteString("<sandbox_blocks>\n")
for _, block := range blocks {
builder.WriteString(block)
builder.WriteString("\n")
}
builder.WriteString("</sandbox_blocks>")
if strings.TrimSpace(stderr) == "" {
return builder.String()
}
return stderr + "\n" + builder.String()
}
func shellIssueBlockResult(issue shellIssue) Result {
return Result{
Status: StatusError,
Output: appendShellIssueHint("", issue),
Meta: map[string]string{
"exit_code": "-1",
"shell_issue": issue.Kind,
},
Display: Display{
Summary: issue.Message,
Kind: "shell",
},
}
}
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
// commandText. PowerShell is preferred on Windows, with cmd.exe retained as
// the fallback. Only cmd.exe needs the raw command-line override.
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
hostShell := detectShellRuntime(runtime.GOOS)
spec := zeroSandbox.CommandSpec{
Name: hostShell.Executable,
Args: hostShell.arguments(commandText),
Dir: absoluteCwd,
}
if engine != nil {
command, plan, err := engine.CommandContext(ctx, spec)
if err == nil {
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
}
return command, plan, err
}
directPolicy := zeroSandbox.DefaultPolicy()
directPolicy.Mode = zeroSandbox.ModeDisabled
directPolicy.Network = zeroSandbox.NetworkAllow
plan := zeroSandbox.CommandPlan{
Backend: zeroSandbox.Backend{
Name: zeroSandbox.BackendUnavailable,
Message: "sandbox engine not provided",
},
TargetBackend: zeroSandbox.BackendNone,
WorkspaceRoot: absoluteCwd,
Policy: directPolicy,
PermissionProfile: zeroSandbox.PermissionProfileFromPolicy(absoluteCwd, directPolicy, nil),
Wrapped: false,
EnforcementLevel: zeroSandbox.EnforcementDisabled,
Name: spec.Name,
Args: spec.Args,
Dir: spec.Dir,
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
return command, plan, nil
}
func addSandboxMeta(meta map[string]string, plan zeroSandbox.CommandPlan) {
if plan.Backend.Name == "" {
return
}
meta["sandbox_backend"] = string(plan.Backend.Name)
if plan.TargetBackend != "" {
meta["sandbox_target_backend"] = string(plan.TargetBackend)
}
meta["sandbox_wrapped"] = strconv.FormatBool(plan.Wrapped)
if plan.EnforcementLevel != "" {
meta["sandbox_enforcement_level"] = string(plan.EnforcementLevel)
}
if plan.DowngradeReason != "" {
meta["sandbox_downgrade_reason"] = plan.DowngradeReason
}
meta["sandbox_requires_platform"] = strconv.FormatBool(plan.RequiresPlatformSandbox)
if plan.Backend.Message != "" {
meta["sandbox_message"] = plan.Backend.Message
}
if plan.SandboxDir != "" {
meta["sandbox_cwd"] = plan.SandboxDir
}
}
// interactiveBlockResult builds the structured tool Result returned when a
// command is refused before execution because it would hang the agent. The
// block is surfaced both in Output (clearly delimited) and in Meta/Display
// so downstream consumers and the TUI can render it consistently.
func interactiveBlockResult(detection zeroSandbox.InteractiveCommandResult) Result {
message := fmt.Sprintf(
"Error: Blocked interactive command %q before execution: %s. This would hang the non-interactive agent.\nSuggestion: %s",
detection.Command, detection.Reason, detection.Suggestion,
)
return Result{
Status: StatusError,
Output: message,
Meta: map[string]string{
"exit_code": "-1",
"safety_block": "interactive_command",
"safety_cmd": detection.Command,
},
Display: Display{
Summary: "Blocked interactive command: " + detection.Command,
Kind: "shell",
},
}
}
func commandExitCode(err error) int {
if err == nil {
return 0
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode()
}
return -1
}
func formatBashOutput(stdout string, stderr string, exitCode int) string {
parts := []string{}
stdout = strings.TrimRight(stdout, "\r\n")
stderr = strings.TrimRight(stderr, "\r\n")
// Redact high-confidence secrets a command may have printed, so they are not
// echoed back into the model context (additive to the configured-key scrub
// done at the registry boundary).
stdout, outFindings := secrets.Redact(stdout)
stderr, errFindings := secrets.Redact(stderr)
if stdout != "" {
parts = append(parts, "stdout:\n"+stdout)
}
if stderr != "" {
parts = append(parts, "stderr:\n"+stderr)
}
if exitCode != 0 {
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
}
if n := len(outFindings) + len(errFindings); n > 0 {
parts = append(parts, fmt.Sprintf("[zero] redacted %d likely secret(s) from this output before showing it.", n))
}
if len(parts) == 0 {
return "Command completed with no output."
}
return strings.Join(parts, "\n")
}
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model (32 KiB
// ≈ 8k tokens per stream). bash is the one tool that can emit unbounded output
// (`cat large.log`, `find /`, verbose test runs); a chatty build log at the old
// 96 KiB budget injected ~24k tokens per call and was then re-billed on every
// following turn. Head+tail truncation keeps both the start and the end of an
// oversized stream, since build/test failures usually surface at the tail, and
// the full captured text is spilled to disk so nothing is lost — the model
// greps/reads the spill instead of re-running the command.
const bashOutputBudgetBytes = 32 * 1024
// bashCaptureBudgetBytes bounds the head and tail retained IN MEMORY per
// stream during capture. Deliberately larger than the emit budget: the extra
// retained bytes never reach the transcript, but they make the spill file —
// the model's recovery path — cover 6× more of the output.
const bashCaptureBudgetBytes = 96 * 1024
// prepareBashOutput keeps direct callers on the established positional budget.
// Registry calls retain the existing bounded capture but leave final semantic
// reduction and spill creation to the post-redaction registry boundary.
func prepareBashOutput(out string, outTotal int, errStr string, errTotal int, meta map[string]string, directBudget bool) (string, string, bool) {
if directBudget {
return budgetBashCapture(out, outTotal, errStr, errTotal, meta)
}
outText := sectionWithCaptureGap(out, outTotal)
errText := sectionWithCaptureGap(errStr, errTotal)
truncated := outTotal > len(out) || errTotal > len(errStr)
if meta != nil {
meta["raw_bytes"] = strconv.Itoa(outTotal + errTotal)
meta["emitted_bytes"] = strconv.Itoa(len(outText) + len(errText))
meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(len(outText) + len(errText)))
if truncated {
meta["truncated"] = "true"
meta["truncation_reason"] = "capture_budget"
}
}
return outText, errText, truncated
}
// budgetBashCapture is budgetBashOutput for the streaming-capture path: outTotal
// and errTotal are the true byte counts (from boundedBuffer.total), which may
// exceed the retained strings when the middle was dropped during capture. Meta's
// raw_bytes therefore reflects everything the command produced, not just what was
// kept in memory.
func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, meta map[string]string) (string, string, bool) {
outText, outRaw, outTrunc := truncateHeadTailWithTotal(out, outTotal, bashOutputBudgetBytes)
errText, errRaw, errTrunc := truncateHeadTailWithTotal(errStr, errTotal, bashOutputBudgetBytes)
truncated := outTrunc || errTrunc
if truncated {
if spillPath := spillBashStreams(out, outTotal, errStr, errTotal); spillPath != "" {
hint := "\n[zero] captured output saved to " + spillPath + " (grep or read_file it instead of re-running)"
if errTrunc {
errText += hint
} else {
outText += hint
}
if meta != nil {
meta["spill_path"] = spillPath
}
}
}
if meta != nil {
emitted := len(outText) + len(errText)
meta["raw_bytes"] = strconv.Itoa(outRaw + errRaw)
meta["emitted_bytes"] = strconv.Itoa(emitted)
meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(emitted))
if truncated {
meta["truncated"] = "true"
}
}
return outText, errText, truncated
}
// spillBashStreams writes the retained (capture-bounded) stdout and stderr to
// the spill directory as one sectioned file and returns its path, or "" when
// spilling fails. The spill holds up to bashCaptureBudgetBytes of head and
// tail per stream — everything zero kept in memory, which is more than the
// emit budget shows the model but not necessarily the whole output. When the
// capture itself dropped the middle of a stream (total exceeds the retained
// bytes), a gap marker is inserted at the head/tail junction so the spilled
// log never reads as contiguous when it is not.
func spillBashStreams(stdout string, stdoutTotal int, stderr string, stderrTotal int) string {
stdout = sectionWithCaptureGap(stdout, stdoutTotal)
stderr = sectionWithCaptureGap(stderr, stderrTotal)
var combined strings.Builder
combined.Grow(len(stdout) + len(stderr) + 64)
combined.WriteString("### stdout\n")
combined.WriteString(stdout)
if !strings.HasSuffix(stdout, "\n") {
combined.WriteString("\n")
}
combined.WriteString("### stderr\n")
combined.WriteString(stderr)
return spillTruncatedOutput("bash", combined.String())
}
// sectionWithCaptureGap marks the point where boundedBuffer dropped the middle
// of a stream. When total exceeds the retained bytes, the retained text is the
// frozen head (bashCaptureBudgetBytes, always full once overflow happened)
// followed immediately by the rolling tail — the junction sits at the head cap,
// snapped back to a rune boundary.
func sectionWithCaptureGap(text string, total int) string {
if total <= len(text) || len(text) <= bashCaptureBudgetBytes {
return text
}
head := utf8Prefix(text, bashCaptureBudgetBytes)
marker := fmt.Sprintf("\n[zero] capture gap: %d bytes omitted from the middle of this stream\n", total-len(text))
return head + marker + text[len(head):]
}
// boundedBuffer is an io.Writer that retains at most headCap bytes from the start
// and tailCap bytes from the end of a stream while counting the total written, so
// a command emitting unbounded output (`cat huge.log`, `yes`) cannot grow Zero's
// memory: the middle is discarded as it arrives instead of buffered whole and then
// truncated. total records the full size for the truncation marker even though the
// middle is never held. Not safe for concurrent writes; exec drives Stdout and
// Stderr from separate goroutines, so each stream gets its own buffer.
type boundedBuffer struct {
head []byte
headCap int
tail []byte
tailCap int
total int
}
func newBoundedBuffer(headCap, tailCap int) *boundedBuffer {
return &boundedBuffer{headCap: headCap, tailCap: tailCap}
}
func (b *boundedBuffer) Write(p []byte) (int, error) {
n := len(p)
b.total += n
// Fill the head until it reaches headCap; the head is written once and frozen.
if len(b.head) < b.headCap {
take := b.headCap - len(b.head)
if take > len(p) {
take = len(p)
}
b.head = append(b.head, p[:take]...)
p = p[take:]
}
// Anything past the head feeds a rolling tail that keeps only the last tailCap
// bytes. Compact once it grows past 2×tailCap so the append stays amortized O(1)
// while memory never exceeds ~2×tailCap.
if len(p) > 0 && b.tailCap > 0 {
b.tail = append(b.tail, p...)
if len(b.tail) > 2*b.tailCap {
b.tail = append(b.tail[:0], b.tail[len(b.tail)-b.tailCap:]...)
}
}
return n, nil
}
// retained returns the kept head+tail bytes (marker-less) as a string. The junction
// between head and tail lands in the middle, which the display budget trims away;
// callers that need the true size read total separately.
func (b *boundedBuffer) retained() string {
if len(b.tail) > b.tailCap {
// Not yet compacted since the last overflow; expose only the last tailCap.
return string(b.head) + string(b.tail[len(b.tail)-b.tailCap:])
}
return string(b.head) + string(b.tail)
}
// truncateHeadTailWithTotal head+tail-truncates value to maxBytes, using total —
// the full original byte count — for the "N bytes omitted" marker and the raw
// count. total may exceed len(value) when the middle was already discarded during
// bounded capture (boundedBuffer): value then holds only the retained head+tail,
// and this trims it to the display budget while still reporting the true total.
func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int, bool) {
if maxBytes <= 0 || total <= maxBytes {
return value, total, false
}
marker := fmt.Sprintf("\n[zero] output truncated: %d bytes omitted from the middle — redirect to a file and read_file a range for the full text\n", total-maxBytes)
budget := maxBytes - len(marker)
if budget < 0 {
budget = 0
}
head := budget / 2
tail := budget - head
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
}
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
output := formatBashOutput(stdout, stderr, exitCode)
if issue := detectShellOutputIssueForRuntime(stdout+"\n"+stderr, detectShellRuntime(runtime.GOOS)); issue != nil {
meta["shell_issue"] = issue.Kind
output = appendShellIssueHint(output, *issue)
}
return output
}