Files
Agent-System-Backend/internal/tools/exec_backend.go
T
weichen 4df81ef1da
ci / vet-and-test (pull_request) Successful in 43s
Enable parallel tool execution, fix delegate truncation, sandbox deps layer
Why: Real runs were fully serial: the direct path never set the kernel's
parallel tool mode, delegate_task handed the parent only the child's
160-char summary (the full text was dropped), MiniMax streamed its
reasoning inside content <think> blocks, and the EXT-16 sandbox hid
~/.local so python-docx/openpyxl/pptx could not even import. All four
were measured defects (F1/F2/F3/F5) in the delegation/routing proposal.

Why: Serial-only mutual exclusion is not enough for order-sensitive
tools - the card-stream test failed with out-of-order appends - so the
ordering moved into the kernel (SerialTools partition, Agent-Native
#44); process events pair by CallID (#42) and tool results write back
through the injected call context (#43).

Evidence: go test ./... green, including new regression anchors:
TestDelegateToolReturnsFullText, TestToolEventsPairByCallID,
TestToolExecModeAndSerialSet, TestOpenLLMClientReasoningSplitAuto,
TestSandboxArgvDepsLayer, TestExecEnvJoinsPythonPath.

Impact: Direct runs now execute independent same-turn calls
(searches, reads, delegate_task, review_findings) concurrently by
default (runtime.parallelTools=false restores sequential); card flow,
plans, workflows, exec primitives and file edits keep model-declared
order. delegate_task returns the capped full child text. MiniMax-*
models get reasoning_split=true so thinking streams via
reasoning_content. --exec-deps-dir/AGENT_SYSTEM_EXEC_DEPS_DIR mounts a
read-only dependency layer into the sandbox and merges PYTHONPATH.
Bumps deps/agent-native to c348441.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E1,
F1-F5); Agent-Native #42/#43/#44; Dev task feature/agent-e1-parallel-fixes.
2026-10-10 09:02:10 +00:00

201 lines
6.5 KiB
Go
Raw 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.
// EXT-16 执行后端:把「启动白名单解释器进程」抽象为 ExecBackend 接口。
//
// direct = 现状(exec.CommandContext 直启,逐字节等价);sandbox = bwrap 隔离档
// (模型生成代码按不可信对待):只读系统根、tmpfs 遮蔽 /home 与 /tmp、仅会话
// 工作目录与技能目录挂载可写、--unshare-net 断网、ulimit -u 限进程数防 fork
// 炸弹、超时杀 bwrap 宿主侧进程组(pid 命名空间随之销毁,不可逃逸存活)。
// 启动级 DisableExecTools 是最终兜底,优先级高于后端选择(execGate 拒绝在前)。
package tools
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync/atomic"
"syscall"
"time"
)
// execSandboxMaxProcs sandbox 档内 ulimit -u(fork 炸弹防线;bash 自身进程也计入)。
const execSandboxMaxProcs = 256
// ExecSpec 一次解释器执行的启动规格(后端无关)。
type ExecSpec struct {
Bin string // 白名单解释器绝对路径
Args []string // 落盘脚本路径为唯一参数
Dir string // 会话沙箱工作目录(sandbox 档唯一可写挂载)
Env []string // 最小环境(execEnv 口径)
SkillsDir string // 技能根(sandbox 档只读挂载;空 = 不挂)
// DepsDir 依赖层(sandbox 档只读挂载;空 = 不挂):python-docx 等装在
// ~/.local 下会被 --tmpfs /home 遮蔽,只读挂回其原路径并并入 PYTHONPATH,
// 交付物生成链路(F3)在隔离档下才可用。
DepsDir string
Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用)
}
// ExecProcess 后端启动的执行体句柄:Wait 等待退出;Kill 杀整个执行体
// (direct = 进程组 SIGKILL;sandbox = bwrap 宿主侧进程组,pid 命名空间销毁)。
type ExecProcess interface {
Wait() error
Kill() error
}
// ExecBackend 执行后端接口(EXT-16):启动 spec 描述的解释器,
// stdout+stderr 合并写入 out。
type ExecBackend interface {
Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error)
}
// ---- 后端选择(组合根装配期设置)----
var execBackend = atomic.Value{} // string:"" / "direct" = 现状;"sandbox" = 隔离档
// SetExecBackend 按名选择执行后端;空串与 "direct" 等价(现状直启)。
// 未知名字报错(fail-closed,不静默回落)。硬关闭 DisableExecTools 优先于此。
func SetExecBackend(name string) error {
switch name {
case "", "direct", "sandbox":
execBackend.Store(name)
return nil
default:
return fmt.Errorf("unknown exec backend %q (direct|sandbox)", name)
}
}
// ExecBackendName 报告当前后端名(空 = direct)。
func ExecBackendName() string {
v, _ := execBackend.Load().(string)
return v
}
func currentExecBackend() ExecBackend {
if ExecBackendName() == "sandbox" {
return sandboxBackend{}
}
return directBackend{}
}
// ---- direct 后端:现状行为 ----
type directBackend struct{}
func (directBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error) {
cmd := exec.CommandContext(ctx, spec.Bin, spec.Args...)
cmd.Dir = spec.Dir
cmd.Env = spec.Env
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = 2 * time.Second
cmd.Cancel = func() error {
if cmd.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
cmd.Stdout = out
cmd.Stderr = out
if err := cmd.Start(); err != nil {
return nil, err
}
return cmdProcess{cmd: cmd}, nil
}
type cmdProcess struct {
cmd *exec.Cmd
}
func (p cmdProcess) Wait() error { return p.cmd.Wait() }
func (p cmdProcess) Kill() error {
if p.cmd.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
// ---- sandbox 后端:bwrap 隔离档 ----
type sandboxBackend struct{}
func (sandboxBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error) {
bin, err := exec.LookPath("bwrap")
if err != nil {
return nil, fmt.Errorf("sandbox exec backend requires bwrap: %w", err)
}
argv := sandboxArgv(spec)
cmd := exec.CommandContext(ctx, bin, argv...)
cmd.Env = []string{"PATH=" + os.Getenv("PATH")}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = 2 * time.Second
cmd.Cancel = func() error {
if cmd.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
cmd.Stdout = out
cmd.Stderr = out
if err := cmd.Start(); err != nil {
return nil, err
}
return cmdProcess{cmd: cmd}, nil
}
// sandboxArgv 组装 bwrap 参数(单测锚点;挂载顺序敏感——/home 与 /tmp 的
// tmpfs 遮蔽必须后于 --ro-bind /,会话目录 bind 必须后于其遮蔽)。
func sandboxArgv(spec ExecSpec) []string {
argv := []string{
"--unshare-all", // pid/net/ipc/uts/user 命名空间全隔离(断网在此)
"--die-with-parent",
"--ro-bind", "/", "/", // 只读系统根(解释器与依赖库可用)
"--tmpfs", "/home", // 遮蔽真实家目录(~/.secrets 等不可见不可读)
"--tmpfs", "/tmp", // 可写临时区(pip/tempfile),不含宿主内容
"--proc", "/proc",
"--dev", "/dev",
"--bind", spec.Dir, spec.Dir, // 会话沙箱:唯一可写挂载
}
if spec.SkillsDir != "" {
argv = append(argv, "--ro-bind", spec.SkillsDir, spec.SkillsDir)
}
if spec.DepsDir != "" {
// 必须后于 --tmpfs /home:先遮蔽再只读挂回依赖层(挂载顺序敏感)。
argv = append(argv, "--ro-bind", spec.DepsDir, spec.DepsDir)
}
argv = append(argv, "--chdir", spec.Dir, "--clearenv")
for _, kv := range spec.Env {
if i := strings.IndexByte(kv, '='); i > 0 {
argv = append(argv, "--setenv", kv[:i], kv[i+1:])
}
}
// ulimit 包一层 bash(dash 不支持 -u):进程数限额防 fork 炸弹
// (命名空间内 RLIMIT_NPROC 生效)。
wrapped := append([]string{spec.Bin}, spec.Args...)
argv = append(argv, "/bin/bash", "-c",
fmt.Sprintf("ulimit -u %d; exec %s", execSandboxMaxProcs, shellQuote(wrapped)))
return argv
}
// shellQuote 逐元素单引号包裹(sh 安全拼接,无引号内插值)。
func shellQuote(parts []string) string {
out := make([]string, len(parts))
for i, p := range parts {
out[i] = "'" + strings.ReplaceAll(p, "'", `'\''`) + "'"
}
return strings.Join(out, " ")
}