package builtin // Bash 工具 -- 在 shell 中执行命令. // // 这是 Agent 最核心的能力之一:通过 os/exec 执行任意 shell 命令, // 让 Agent 可以编译代码,运行测试,安装依赖,查看系统状态等. // // 对应原项目中同名模块的功能. // 原项目使用 child_process.spawn 执行命令,Go 版本使用 os/exec. // // 安全特性: // - 支持命令超时(默认 120 秒),防止命令无限挂起 // - 支持工作目录配置(cwd),限制命令执行范围 // - stdout 和 stderr 分离,stderr 带 [stderr] 前缀 // - 流式读取输出,通过 progress 回调实时报告 // - 二进制输出检测(null bytes,无效 UTF-8,magic bytes) // - 优雅终止:超时后先 SIGTERM 给进程组,等 5 秒,再 SIGKILL,再等 2 秒 // - 超过 5 秒的命令在结果末尾报告耗时 // - 进程组管理:子进程在独立进程组中运行 // - 进程隔离:不继承多余 fd,过滤敏感环境变量 // - 支持后台运行模式(run_in_background) // - stdout/stderr 独立截断,保留头尾 // - CJK 字符宽度计算 // - 命令分类(search/read/list/write/silent/general) // - ConcurrencySafe: false,一次只允许一个 bash 命令执行 import ( "bufio" "bytes" "context" "encoding/json" "fmt" "os" "strconv" "strings" "sync" "syscall" "time" "unicode" "unicode/utf8" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" "git.flytoex.net/yuanwei/flyto-agent/pkg/permission" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // 输出截断限制常量 const ( MaxStdoutBytes = 200 * 1024 // 200KB stdout MaxStderrBytes = 56 * 1024 // 56KB stderr MaxTotalBytes = 256 * 1024 // 256KB 总计 ) // 截断时头尾比例 const ( truncateHeadRatio = 0.8 // 保留前 80% truncateTailRatio = 0.2 // 保留后 20% ) // magicBytesSignatures 常见二进制格式的 magic bytes 签名. var magicBytesSignatures = []struct { name string magic []byte }{ {"ELF", []byte{0x7F, 0x45, 0x4C, 0x46}}, // ELF 可执行文件 {"PNG", []byte{0x89, 0x50, 0x4E, 0x47}}, // PNG 图片 {"JPEG", []byte{0xFF, 0xD8, 0xFF}}, // JPEG 图片 {"PDF", []byte{0x25, 0x50, 0x44, 0x46}}, // PDF 文档 {"GIF", []byte{0x47, 0x49, 0x46, 0x38}}, // GIF 图片 {"GZIP", []byte{0x1F, 0x8B}}, // GZIP 压缩 {"ZIP", []byte{0x50, 0x4B, 0x03, 0x04}}, // ZIP 压缩 {"TAR", []byte{0x75, 0x73, 0x74, 0x61, 0x72}}, // TAR 归档 (ustar) {"WASM", []byte{0x00, 0x61, 0x73, 0x6D}}, // WebAssembly {"MACHO", []byte{0xFE, 0xED, 0xFA, 0xCE}}, // Mach-O 32 位 {"MACHO64", []byte{0xFE, 0xED, 0xFA, 0xCF}}, // Mach-O 64 位 {"BMP", []byte{0x42, 0x4D}}, // BMP 图片 {"WEBP", []byte{0x52, 0x49, 0x46, 0x46}}, // WEBP/RIFF } // BashTool 是 bash 命令执行工具. type BashTool struct { cwd string defaultTimeout time.Duration bgStore *BackgroundTaskStore // 后台任务存储 taskStore *TaskStore // 主任务存储(可选,用于集成) isMainAgent bool // 是否为主 agent(true 时启用 15s 自动后台化) // executor 是子进程启动抽象 (M1 commit 7c+7d). 本地模式传 // execenv.DefaultExecutor{}, 云端 SaaS 传 platform sandbox.Backend. // 方案 β 严格 DI: 构造时必填, nil panic. executor execenv.Executor // 升华改进(ELEVATED): 实例级 socket 路径注入,替代 os.Setenv 全局污染-- // 多 Engine 实例(SaaS 多租户)时每个 BashTool 持有自己的 sock 路径, // Spec.Env 只对当前子进程生效,不影响同进程其他 Engine 实例的子进程. // 替代方案(原方案):os.Setenv("FLYTO_SESSION_SOCK", ...)--修改全局进程环境, // 第二个 Engine 初始化时会覆盖第一个,导致第一个 Engine 的 Bash 子进程连错 socket. sessionSockPath string // FLYTO_SESSION_SOCK 的实例级值(空字符串 = 不注入) planSockPath string // FLYTO_PLAN_SOCK 的实例级值(空字符串 = 不注入) } // NewBashTool 创建一个 Bash 工具实例. executor 必填 (方案 β 严格 DI), // 本地模式传 execenv.DefaultExecutor{}, nil panic. func NewBashTool(cwd string, executor execenv.Executor) *BashTool { if executor == nil { panic("builtin.NewBashTool: executor must not be nil (方案 β 严格 DI, 本地模式传 execenv.DefaultExecutor{})") } return &BashTool{ cwd: cwd, defaultTimeout: bashDefaultTimeout, executor: executor, } } // NewBashToolWithStores 创建一个带后台任务支持的 Bash 工具实例. // executor 必填, 语义同 NewBashTool. func NewBashToolWithStores(cwd string, bgStore *BackgroundTaskStore, taskStore *TaskStore, executor execenv.Executor) *BashTool { if executor == nil { panic("builtin.NewBashToolWithStores: executor must not be nil") } return &BashTool{ cwd: cwd, defaultTimeout: bashDefaultTimeout, bgStore: bgStore, taskStore: taskStore, executor: executor, } } // NewBashToolMainAgent 创建主 agent 专用的 Bash 工具实例(启用 15s 自动后台化). // // 升华改进(ELEVATED): 与 NewBashTool/NewBashToolWithStores 的区别在于 isMainAgent=true-- // 主 agent 的对话需要保持响应性,长阻塞命令自动转后台; // subagent 专注单一任务,不需要自动后台化(用 NewBashToolWithStores). // 替代方案:统一在 Config 里控制(Config.AutoBackgroundMs)- 否决:过度暴露实现细节给消费层. // // executor 必填, 语义同 NewBashTool. func NewBashToolMainAgent(cwd string, bgStore *BackgroundTaskStore, taskStore *TaskStore, executor execenv.Executor) *BashTool { if executor == nil { panic("builtin.NewBashToolMainAgent: executor must not be nil") } return &BashTool{ cwd: cwd, defaultTimeout: bashDefaultTimeout, bgStore: bgStore, taskStore: taskStore, isMainAgent: true, executor: executor, } } // SetSessionSockPath 设置 FLYTO_SESSION_SOCK 的实例级值. // 由 Engine.New() 在 UDS Inbox 初始化完成后调用,避免使用 os.Setenv 污染全局进程环境. // 空字符串表示不注入该环境变量. func (t *BashTool) SetSessionSockPath(path string) { t.sessionSockPath = path } // SetPlanSockPath 设置 FLYTO_PLAN_SOCK 的实例级值. // 由 Engine.New() 在 PlanCommandServer 初始化完成后调用,避免使用 os.Setenv 污染全局进程环境. // 空字符串表示不注入该环境变量. func (t *BashTool) SetPlanSockPath(path string) { t.planSockPath = path } // bashInput 是 Bash 工具的输入参数. type bashInput struct { Command string `json:"command"` Timeout int `json:"timeout,omitempty"` // 超时(毫秒),默认 120000 RunInBackground bool `json:"run_in_background,omitempty"` // 是否后台运行 Description string `json:"description,omitempty"` // 命令描述 } // bashDefaultTimeout 是 BashTool 的默认命令超时(2 分钟). // 覆盖大多数构建,测试,脚本场景;用户可在调用时通过 timeout 参数覆盖. const bashDefaultTimeout = 120 * time.Second // bashMaxTimeout 是用户可设置的最大超时上限(10 分钟). // 防止用户设置无限超时导致 agent 永久阻塞. const bashMaxTimeout = 600 * time.Second // AssistantBlockingBudgetMs 是主 agent 前台命令的自动后台化阈值(15 秒). // // 升华改进(ELEVATED): 早期实现 BashTool.tsx 中同名常量(ASSISTANT_BLOCKING_BUDGET_MS = 15_000), // 仅在 feature('KAIROS') && getKairosActive() 双重门控下生效(Ant 内部 feature flag). // 我们将其作为标准功能开放,通过 BashTool.isMainAgent 字段控制-- // 主 agent 流中阻塞 > 15s 的命令自动转后台,保持对话响应; // subagent 不自动转后台(subagent 通常专注于单一任务,没有"响应性"需求). const AssistantBlockingBudgetMs = 15_000 // BashResult 是 Bash 工具的执行结果. type BashResult struct { Output string `json:"output"` ExitCode int `json:"exit_code"` Duration time.Duration `json:"duration,omitempty"` CommandClass CommandClass `json:"command_class"` // 命令分类 IsTruncated bool `json:"is_truncated"` // 是否被截断 AssistantAutoBackgrounded bool `json:"assistant_auto_backgrounded,omitempty"` // 自动后台化标志 BackgroundTaskID string `json:"background_task_id,omitempty"` // 后台任务 ID } // Name 返回工具名称. func (t *BashTool) Name() string { return "Bash" } // Description 返回工具描述. func (t *BashTool) Description(ctx context.Context) string { return "Executes a given bash command and returns its output. " + "The working directory persists between commands. " + "Supports timeout configuration (default 120 seconds). " + "Stdout and stderr are separated, with stderr lines prefixed by [stderr]. " + "Binary output is detected and replaced with a placeholder message. " + "Supports run_in_background for long-running commands." } // InputSchema 返回工具的 JSON Schema 输入定义. func (t *BashTool) InputSchema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "command": { "type": "string", "description": "The bash command to execute" }, "timeout": { "type": "integer", "description": "Timeout in milliseconds (default 120000, max 600000)" }, "run_in_background": { "type": "boolean", "description": "Run the command in the background and return a task_id immediately" }, "description": { "type": "string", "description": "A description of what this command does" } }, "required": ["command"] }`) } // Metadata 返回工具元数据. func (t *BashTool) Metadata() tools.Metadata { return tools.Metadata{ ConcurrencySafe: false, ReadOnly: false, Destructive: true, SearchHint: "shell command execute terminal", PermissionClass: permission.PermClassBash, AuditOperation: "execute", } } // isBinaryContent 检测内容是否为二进制数据. // 检查 null bytes,无效 UTF-8 和常见二进制格式的 magic bytes. func isBinaryContent(data []byte) bool { if len(data) == 0 { return false } checkLen := len(data) if checkLen > 512 { checkLen = 512 } sample := data[:checkLen] // 检查常见二进制格式的 magic bytes if hasMagicBytes(sample) { return true } // 检查 null 字节 if bytes.ContainsRune(sample, 0) { return true } // 检查是否为有效的 UTF-8 if !utf8.Valid(sample) { return true } return false } // hasMagicBytes 检查数据是否包含已知二进制格式的 magic bytes. func hasMagicBytes(data []byte) bool { for _, sig := range magicBytesSignatures { if len(data) >= len(sig.magic) && bytes.Equal(data[:len(sig.magic)], sig.magic) { return true } } return false } // streamLine 表示一行流式输出. type streamLine struct { text string isStderr bool } // Execute 执行 bash 命令. // 使用管道流式读取 stdout 和 stderr,通过 progress 回调实时报告输出. // 超时后先发 SIGTERM 给进程组优雅终止,等 5 秒后再 SIGKILL 强制终止. func (t *BashTool) Execute(ctx context.Context, input json.RawMessage, progress tools.ProgressFunc) (*tools.Result, error) { var params bashInput if err := json.Unmarshal(input, ¶ms); err != nil { return nil, fmt.Errorf("bash: invalid input: %w", err) } if params.Command == "" { return &tools.Result{ Output: "error: command is required", IsError: true, }, nil } // 后台运行模式 if params.RunInBackground { result, err := t.executeBackground(ctx, params) if err != nil { return nil, err } return &tools.Result{ Output: result.Output, IsError: result.ExitCode != 0, Data: result, }, nil } // 前台执行 result := t.executeForeground(ctx, params, progress) return &tools.Result{ Output: result.Output, IsError: result.ExitCode != 0, Data: result, }, nil } // executeForeground 在前台执行命令(原有逻辑增强版). func (t *BashTool) executeForeground(ctx context.Context, params bashInput, progress tools.ProgressFunc) *BashResult { // cwd resolution: SubAgent-injected override wins (worktree isolation); // empty means "use construction-time cwd". See pkg/tools/workdir.go. // // cwd 解析: SubAgent 通过 ctx 注入的覆盖优先 (worktree 隔离); 为空则 // 用构造期 cwd. 见 pkg/tools/workdir.go. cwd := tools.WorkdirFromContext(ctx) if cwd == "" { cwd = t.cwd } // 命令分类 cmdClass := ClassifyShellCommand(params.Command) // 计算超时 timeout := t.defaultTimeout if params.Timeout > 0 { timeout = time.Duration(params.Timeout) * time.Millisecond // 最大不超过 10 分钟 if timeout > bashMaxTimeout { timeout = bashMaxTimeout } } // 创建一个可取消的上下文用于超时控制(不用 CommandContext 的 default cancel, // 手动管理信号时序以执行 SIGTERM→SIGKILL 的 graceful kill 路径) execCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() // 子进程 env: 全量继承 os.Environ() + 实例级 FLYTO_*_SOCK + per-request secrets. // // 设计决策 (L513 审计 2026-04-15): 不做 env 黑名单过滤, 对齐 Claude Code 默认 // 模式 / Cursor / Aider — 用户对自己机器负责. 秘密管理走 engine.SetSecret() + // SecretStore.Redact(), shell env 中的秘密**不受**引擎保护. 消费层若需更强 // 隔离 (platform/ 层云端沙盒), 在 engine.New 之前处理. 详见 pkg/plugin/doc.go // 类 B 条目. 原方案: filterSensitiveEnv() 8 条黑名单, 否决 — 枚举不全 + 错误 // 过滤 GH_TOKEN 断 gh CLI + 职责越界 (引擎干了消费层的事). // // 实例级 FLYTO_*_SOCK 注入: 替代 os.Setenv 全局污染, 每个 BashTool 只改自己 // 的 Spec.Env, 多 Engine 实例 (SaaS 多租户) 不会互相覆盖 sock 路径. // // per-request secret env vars(通过 context 注入,避免修改共享 BashTool 实例的字段). // 精妙之处(CLEVER): context 注入而非字段注入 -- // BashTool 是引擎级别的单例,多个并发请求共用同一实例. // 若将 per-request secrets 写入字段,并发请求会互相覆盖(data race). // context 天然是 per-request 的,且 Execute 签名已携带 ctx,零侵入性. extraEnv := map[string]string{} if t.sessionSockPath != "" { extraEnv["FLYTO_SESSION_SOCK"] = t.sessionSockPath } if t.planSockPath != "" { extraEnv["FLYTO_PLAN_SOCK"] = t.planSockPath } for _, kv := range SecretEnvsFromCtx(ctx) { // SecretEnvsFromCtx 返回 "KEY=VAL" 字符串切片, 切分回 map 注入 FullInheritMap. if k, v, ok := strings.Cut(kv, "="); ok { extraEnv[k] = v } } // 用 bash -c 执行命令, Class=ClassBash, IsolateProcessGroup=true 让 // DefaultExecutor 一并设 Setpgid + cmd.Cancel(pgid kill) + WaitDelay // (见 pkg/execenv/default_executor.go). Env=FullInheritMap(extras) // 对齐原 L513 决策 (类 B 全继承). StdoutPipe/StderrPipe 不在 Spec 里设, // 改调 proc 方法获取 — bash 场景需要流式管道, Writer 模式拿不到. proc := t.executor.Command(execCtx, execenv.Spec{ Class: execenv.ClassBash, Path: "bash", Args: []string{"-c", params.Command}, Env: execenv.FullInheritMap(extraEnv), WorkDir: cwd, IsolateProcessGroup: true, }) // 创建管道 stdoutPipe, err := proc.StdoutPipe() if err != nil { return &BashResult{ Output: fmt.Sprintf("error: failed to create stdout pipe: %v", err), ExitCode: -1, CommandClass: cmdClass, } } stderrPipe, err := proc.StderrPipe() if err != nil { return &BashResult{ Output: fmt.Sprintf("error: failed to create stderr pipe: %v", err), ExitCode: -1, CommandClass: cmdClass, } } // 记录开始时间 startTime := time.Now() // 启动命令 if err := proc.Start(); err != nil { return &BashResult{ Output: fmt.Sprintf("error starting command: %v", err), ExitCode: -1, CommandClass: cmdClass, } } // 收集输出行的通道 lineCh := make(chan streamLine, 256) var wg sync.WaitGroup wg.Add(2) // 流式读取 stdout go func() { defer wg.Done() scanner := bufio.NewScanner(stdoutPipe) scanner.Buffer(make([]byte, 0, 256*1024), 256*1024) for scanner.Scan() { lineCh <- streamLine{text: scanner.Text(), isStderr: false} } }() // 流式读取 stderr go func() { defer wg.Done() scanner := bufio.NewScanner(stderrPipe) scanner.Buffer(make([]byte, 0, 256*1024), 256*1024) for scanner.Scan() { lineCh <- streamLine{text: scanner.Text(), isStderr: true} } }() // 等待管道读取完毕后关闭通道 go func() { wg.Wait() close(lineCh) }() // 收集输出,同时监控超时和上下文取消 var stdoutLines []string var stderrLines []string binaryDetected := false timedOut := false // 进度报告行计数器 lineCount := 0 // 自动后台化定时器:主 agent 模式下,命令阻塞超过 15s 自动转后台. // // 精妙之处(CLEVER): 只在满足全部条件时才启用定时器,避免不必要的 goroutine 和 timer 开销: // - isMainAgent:只有主 agent 才需要保持"响应性",subagent 专注任务不需要 // - bgStore != nil:后台任务基础设施必须就绪,否则无法移交 // - !params.RunInBackground:已经是后台模式则不需要再自动后台化 // 替代方案:<全局 Config.AutoBackgroundMs> - 否决:暴露内部机制给消费层, // 且 subagent 也会受影响,破坏 subagent 的单任务语义. var autoBackgroundTimer <-chan time.Time if t.isMainAgent && t.bgStore != nil && !params.RunInBackground { timer := time.NewTimer(AssistantBlockingBudgetMs * time.Millisecond) defer timer.Stop() autoBackgroundTimer = timer.C } // 持续读取输出直到通道关闭或超时 collectLoop: for { select { case line, ok := <-lineCh: if !ok { // 通道关闭,所有输出已读取 break collectLoop } lineCount++ // 检测二进制输出 if !binaryDetected && isBinaryContent([]byte(line.text)) { binaryDetected = true } if line.isStderr { stderrLines = append(stderrLines, line.text) } else { stdoutLines = append(stdoutLines, line.text) } // 通过 progress 回调实时报告输出行 if progress != nil { prefix := "" if line.isStderr { prefix = "[stderr] " } detail := prefix + line.text // 截断过长的行以避免回调内容过大 if len(detail) > 500 { detail = detail[:500] + "..." } progress(0, detail) } case <-autoBackgroundTimer: // 升华改进(ELEVATED): 命令超过 AssistantBlockingBudgetMs(15s)仍在运行-- // 自动移交给后台任务,立即返回,让主 agent 保持对话响应性. // // 移交机制(精妙之处 CLEVER): // 1. 创建 BackgroundBashTask,引用已在运行的 cmd 和 pid // 2. 启动 goroutine 继续从 lineCh 收集剩余输出(管道读取 goroutine 仍在运行) // 3. 等待 cmd.Wait() 收割进程,更新任务状态 // 4. 主 goroutine 立即返回 AssistantAutoBackgrounded=true 结果 // // 替代方案:<重新启动新进程> - 否决:已运行 15s 的命令有状态(文件已创建, // 网络连接已建立),重启会导致重复副作用.移交而非重启是正确的. taskID := t.bgStore.NextID() bgOutput := &BashOutput{} // 把已收集的输出复制到 bgOutput(前 15s 的输出不丢失) for _, line := range stdoutLines { bgOutput.WriteStdout(line) } for _, line := range stderrLines { bgOutput.WriteStderr(line) } // proc.ID() 返回 opaque 字符串, 本地 DefaultExecutor 填 pid 数字串, // 云端 backend 可能返回 microVM_id 等非数字. Atoi 失败回落到 0, // Pid 字段仅用于日志展示, 不参与 kill 路径 (kill 走 task.cancel()). pid, _ := strconv.Atoi(proc.ID()) bgTask := &BackgroundBashTask{ ID: taskID, Command: params.Command, Pid: pid, Status: "running", StartTime: startTime, Output: bgOutput, cancel: cancel, // 移交 cancel 给后台任务管理 } if err := t.bgStore.Add(bgTask); err != nil { // 精妙之处(CLEVER): 后台任务数已满时降级为继续前台等待-- // cancel 是进程的生命周期控制,不释放(进程仍在运行). // break 退出 select,for 循环继续收集输出,等待进程结束. // 对用户的影响:本次命令不会显示 "moved to background", // 会继续阻塞直到进程结束或超时.比 kill 进程更安全. break } // 后台 goroutine:继续收集输出 + 等待进程结束 go func() { // 继续从 lineCh 收集剩余输出(管道读取 goroutine 仍在运行) for line := range lineCh { if line.isStderr { bgOutput.WriteStderr(line.text) } else { bgOutput.WriteStdout(line.text) } } // 收割进程,更新任务状态 cmdErr := proc.Wait() bgTask.EndTime = time.Now() if cmdErr != nil { if ec, ok := cmdErr.(interface{ ExitCode() int }); ok { bgTask.ExitCode = ec.ExitCode() } else { bgTask.ExitCode = -1 } bgTask.Status = "failed" } else { bgTask.ExitCode = 0 bgTask.Status = "completed" } }() elapsed := time.Since(startTime) return &BashResult{ Output: fmt.Sprintf( "Command exceeded the %ds assistant-mode blocking budget and was moved to the background.\n"+ "task_id: %s\npid: %d\n"+ "It is still running — use task tools to check status and retrieve output.\n"+ "In assistant mode, delegate long-running work to a subagent or use run_in_background.", AssistantBlockingBudgetMs/1000, taskID, pid, ), ExitCode: 0, Duration: elapsed, CommandClass: cmdClass, AssistantAutoBackgrounded: true, BackgroundTaskID: taskID, } case <-execCtx.Done(): // 超时或上下文取消 - 优雅终止进程 timedOut = execCtx.Err() == context.DeadlineExceeded gracefulKill(proc) // 排空剩余输出 for line := range lineCh { if line.isStderr { stderrLines = append(stderrLines, line.text) } else { stdoutLines = append(stdoutLines, line.text) } } break collectLoop } } // 等待命令结束 cmdErr := proc.Wait() // 计算耗时 elapsed := time.Since(startTime) // 提取退出码 (duck-type, 不假设底层 *exec.ExitError, 云端 backend 可能返回 // 不同 error 类型但都实现 ExitCode() int 方法. 见 commit 10/11 precedent) exitCode := 0 if cmdErr != nil { if ec, ok := cmdErr.(interface{ ExitCode() int }); ok { exitCode = ec.ExitCode() } else { exitCode = -1 } } // 如果检测到二进制输出,替换为提示信息 if binaryDetected { totalBytes := totalLineBytes(stdoutLines) + totalLineBytes(stderrLines) return &BashResult{ Output: fmt.Sprintf("Binary output (%d bytes). Pipe to file if needed.", totalBytes), ExitCode: exitCode, Duration: elapsed, CommandClass: cmdClass, } } // 分离截断 stdout 和 stderr stdoutStr, stdoutTruncated := truncateStream(stdoutLines, MaxStdoutBytes, "stdout") stderrStr, stderrTruncated := truncateStream(stderrLines, MaxStderrBytes, "stderr") isTruncated := stdoutTruncated || stderrTruncated // 组合输出 var output strings.Builder if stdoutStr != "" { output.WriteString(stdoutStr) } if stderrStr != "" { // stderr 行加前缀 stderrLineList := strings.Split(strings.TrimRight(stderrStr, "\n"), "\n") for _, line := range stderrLineList { output.WriteString("[stderr] ") output.WriteString(line) output.WriteByte('\n') } } // 确保总输出不超过 MaxTotalBytes outStr := output.String() if len(outStr) > MaxTotalBytes { outStr = outStr[:MaxTotalBytes] + "\n... [total output truncated, exceeded 256KB]" isTruncated = true } outStr = strings.TrimRight(outStr, "\n") // 超过 5 秒的命令报告耗时 if elapsed >= 5*time.Second { outStr += fmt.Sprintf("\n\n[command completed in %.1fs]", elapsed.Seconds()) } // 处理超时 if timedOut { return &BashResult{ Output: fmt.Sprintf("error: command timed out after %v\n%s", timeout, outStr), ExitCode: -1, Duration: elapsed, CommandClass: cmdClass, IsTruncated: isTruncated, } } // 处理命令错误 if cmdErr != nil { if outStr == "" { outStr = cmdErr.Error() } return &BashResult{ Output: outStr, ExitCode: exitCode, Duration: elapsed, CommandClass: cmdClass, IsTruncated: isTruncated, } } if outStr == "" { outStr = "(no output)" } return &BashResult{ Output: outStr, ExitCode: 0, Duration: elapsed, CommandClass: cmdClass, IsTruncated: isTruncated, } } // truncateStream 对单个输出流进行截断,保留头尾. // 返回截断后的字符串和是否发生了截断. func truncateStream(lines []string, maxBytes int, streamName string) (string, bool) { if len(lines) == 0 { return "", false } // 计算总字节数(含换行符) totalBytes := 0 for _, line := range lines { totalBytes += len(line) + 1 // +1 for \n } // 未超限,直接拼接返回 if totalBytes <= maxBytes { var b strings.Builder for _, line := range lines { b.WriteString(line) b.WriteByte('\n') } return b.String(), false } // 需要截断:保留前 80% 和后 20% headLimit := int(float64(maxBytes) * truncateHeadRatio) tailLimit := int(float64(maxBytes) * truncateTailRatio) truncatedBytes := totalBytes - maxBytes // 收集头部 var head strings.Builder headBytes := 0 for _, line := range lines { lineSize := len(line) + 1 if headBytes+lineSize > headLimit { break } head.WriteString(line) head.WriteByte('\n') headBytes += lineSize } // 收集尾部(从后往前) var tailLines []string tailBytes := 0 for i := len(lines) - 1; i >= 0; i-- { lineSize := len(lines[i]) + 1 if tailBytes+lineSize > tailLimit { break } tailLines = append(tailLines, lines[i]) tailBytes += lineSize } // 反转尾部行 for i, j := 0, len(tailLines)-1; i < j; i, j = i+1, j-1 { tailLines[i], tailLines[j] = tailLines[j], tailLines[i] } // 组装结果 var result strings.Builder result.WriteString(head.String()) result.WriteString(fmt.Sprintf("... (truncated %d bytes of %s) ...\n", truncatedBytes, streamName)) for _, line := range tailLines { result.WriteString(line) result.WriteByte('\n') } return result.String(), true } // gracefulKill 优雅终止进程:先 SIGTERM 给进程组,等 5 秒,再 SIGKILL 给进程组,再等 2 秒. // 注意:此函数不调用 proc.Wait(),调用方需自行等待进程退出. // // M1 commit 7c+7d: 从 func(cmd *exec.Cmd) 迁移到 func(proc execenv.Process), // SIGTERM/SIGKILL 走 Process.SignalGroup (commit 7a 扩容的 pgid-level 信号), // 存活探测走 Process.Signal(syscall.Signal(0)) — 后者仍是单进程语义但足够 // 代表 bash 子进程的存活状态. 时序常量 (5s / 2s / 100ms tick) 逐字节保留 // 原 pid 版实现. 云端 backend 的 Process.SignalGroup 会把信号映射到 microVM // 内整个 pgid, 行为等价. func gracefulKill(proc execenv.Process) { // 第一步:发送 SIGTERM 到进程组 if err := proc.SignalGroup(syscall.SIGTERM); err != nil { fmt.Fprintf(os.Stderr, "warn: SIGTERM %s: %v\n", proc.ID(), err) } // 第二步:等待最多 5 秒,检查进程是否已退出 if waitForExit(proc, 5*time.Second) { return } // 第三步:5 秒后仍未退出,发送 SIGKILL 到进程组 if err := proc.SignalGroup(syscall.SIGKILL); err != nil { fmt.Fprintf(os.Stderr, "warn: SIGKILL %s: %v\n", proc.ID(), err) } // 第四步:再等最多 2 秒收集最终输出 waitForExit(proc, 2*time.Second) } // waitForExit 等待进程退出,最多等 maxWait 时间. 返回 true 表示进程已退出. // // 探测路径: proc.Signal(syscall.Signal(0)) 对单进程发送 0 号信号, POSIX // 语义是"不发信号但检查目标可达性", 进程退出后返回 error (os.ErrProcessDone // 或 os: process already finished). 对齐原 `syscall.Kill(pid, 0)` 行为, // 云端 backend 的 Signal(0) 映射到 microVM 内的同等探测. func waitForExit(proc execenv.Process, maxWait time.Duration) bool { deadline := time.After(maxWait) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-deadline: return false case <-ticker.C: // 发送信号 0 检测进程是否存活 if err := proc.Signal(syscall.Signal(0)); err != nil { return true } } } } // totalLineBytes 计算所有行的总字节数. func totalLineBytes(lines []string) int { total := 0 for _, l := range lines { total += len(l) } return total } // displayWidth 计算字符串的显示宽度,CJK 字符占 2 列. func displayWidth(s string) int { width := 0 for _, r := range s { if isCJK(r) { width += 2 } else { width += 1 } } return width } // isCJK 判断一个 rune 是否是 CJK 字符(占两个字符宽度). // 覆盖 CJK 统一汉字,CJK 兼容汉字,全角标点等. func isCJK(r rune) bool { return unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hangul, r) || unicode.Is(unicode.Katakana, r) || unicode.Is(unicode.Hiragana, r) || isFullWidth(r) } // isFullWidth 判断是否为全角字符. func isFullWidth(r rune) bool { // 全角 ASCII 和标点 if r >= 0xFF01 && r <= 0xFF60 { return true } // 全角括号等 if r >= 0xFFE0 && r <= 0xFFE6 { return true } return false }