ci / vet-and-test (pull_request) Successful in 43s
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.
201 lines
6.5 KiB
Go
201 lines
6.5 KiB
Go
// 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, " ")
|
||
}
|