Files
2026-06-22 08:55:57 +08:00

247 lines
7.8 KiB
Go

package run
import (
"context"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
procgrp "github.com/yan1h/agent-coding-workflow/internal/infra/proc"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
// ExecConfig 是 ExecService 的运行参数(派生自 config.RunConfig)。
type ExecConfig struct {
EnvWhitelist []string
DefaultTimeout time.Duration
MaxTimeout time.Duration
MaxOutputBytes int
KillGrace time.Duration
AllowedCommands []string // 空 = 显式不限制命令;生产环境应保持非空白名单
}
// ExecService 提供 agent 自验证用的一次性 exec 原语:RunCommand / RunTests。
// 与 run profile(长驻进程)共享 workspace 鉴权(要求 WRITE,因为 exec 会修改工作树)、
// env 白名单(绝不泄漏 APP_MASTER_KEY / DB DSN / git 凭据)、命令白名单与 proc 树终止。
//
// 无状态、同步:每次调用执行一条命令直至退出/超时后返回,不持有运行态,
// 因此无需 ShutdownAll 注册——超时与 ctx 取消由 proc.RunOnce 内部处理。
type ExecService struct {
wsRepo workspace.Repository
wtSvc workspace.WorktreeService
pa workspace.ProjectAccess
rec audit.Recorder
enc *crypto.Encryptor
cfg ExecConfig
log *slog.Logger
}
// NewExecService 构造 ExecService。enc 当前未用于 exec(请求 env 覆盖是临时明文),
// 保留参数以与 run.Service 装配签名一致并便于将来落库加密。
func NewExecService(
wsRepo workspace.Repository,
wtSvc workspace.WorktreeService,
pa workspace.ProjectAccess,
rec audit.Recorder,
enc *crypto.Encryptor,
cfg ExecConfig,
log *slog.Logger,
) *ExecService {
if log == nil {
log = slog.Default()
}
if cfg.DefaultTimeout <= 0 {
cfg.DefaultTimeout = 60 * time.Second
}
if cfg.MaxTimeout <= 0 {
cfg.MaxTimeout = 600 * time.Second
}
if cfg.MaxOutputBytes <= 0 {
cfg.MaxOutputBytes = 1 << 20
}
return &ExecService{wsRepo: wsRepo, wtSvc: wtSvc, pa: pa, rec: rec, enc: enc, cfg: cfg, log: log}
}
// RunCommand 在指定 workspace(main_path 或某 worktree)执行一条命令并返回结构化结果。
func (s *ExecService) RunCommand(ctx context.Context, c workspace.Caller, req ExecCommandReq) (ExecCommandResp, error) {
spec, _, err := s.prepare(ctx, c, req.WorkspaceID, req.WorktreeID, req.Command, req.Args, req.Env, req.TimeoutSec)
if err != nil {
return ExecCommandResp{}, err
}
res, err := s.run(ctx, c, "exec.run_command", req.WorkspaceID, req.WorktreeID, req.Command, spec)
if err != nil {
return ExecCommandResp{}, err
}
return res, nil
}
// RunTests 执行测试命令并把 stdout 解析为结构化摘要。解析失败时填 ParseError 而非报错,
// 始终返回原始输出与退出码——agent 永远能拿到可用信息。
func (s *ExecService) RunTests(ctx context.Context, c workspace.Caller, req ExecTestsReq) (ExecTestsResp, error) {
spec, _, err := s.prepare(ctx, c, req.WorkspaceID, req.WorktreeID, req.Command, req.Args, req.Env, req.TimeoutSec)
if err != nil {
return ExecTestsResp{}, err
}
cmdRes, err := s.run(ctx, c, "exec.run_tests", req.WorkspaceID, req.WorktreeID, req.Command, spec)
if err != nil {
return ExecTestsResp{}, err
}
resp := ExecTestsResp{ExecCommandResp: cmdRes}
summary, perr := parseTestOutput(req.Format, cmdRes.Stdout, cmdRes.Stderr)
if perr != nil {
resp.ParseError = perr.Error()
}
resp.Summary = summary
return resp, nil
}
// prepare 做命令/超时校验、鉴权、cwd 与 env 解析,产出可直接交给 proc.RunOnce 的 spec。
func (s *ExecService) prepare(
ctx context.Context,
c workspace.Caller,
workspaceID, worktreeID, command string,
args []string,
envOverrides map[string]string,
timeoutSec int,
) (procgrp.ExecSpec, *workspace.Workspace, error) {
if strings.TrimSpace(command) == "" {
return procgrp.ExecSpec{}, nil, errs.New(errs.CodeRunCommandInvalid, "command must not be empty")
}
if err := s.checkAllowed(command); err != nil {
return procgrp.ExecSpec{}, nil, err
}
wsID, err := uuid.Parse(workspaceID)
if err != nil {
return procgrp.ExecSpec{}, nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace_id parse")
}
ws, err := s.wsRepo.GetWorkspaceByID(ctx, wsID)
if err != nil {
return procgrp.ExecSpec{}, nil, err
}
_, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID)
if err != nil {
return procgrp.ExecSpec{}, nil, err
}
if !canWrite {
return procgrp.ExecSpec{}, nil, errs.New(errs.CodeForbidden, "no write access to this workspace")
}
dir, err := s.resolveCwd(ctx, c, wsID, ws, worktreeID)
if err != nil {
return procgrp.ExecSpec{}, nil, err
}
spec := procgrp.ExecSpec{
Command: command,
Args: args,
Dir: dir,
Env: buildEnvFromWhitelist(s.cfg.EnvWhitelist, envOverrides),
Timeout: s.clampTimeout(timeoutSec),
KillGrace: s.cfg.KillGrace,
MaxOutputBytes: s.cfg.MaxOutputBytes,
}
return spec, ws, nil
}
// run 执行 spec 并写审计,把 proc 结果折成 ExecCommandResp。超时映射为 run.exec_timeout。
func (s *ExecService) run(
ctx context.Context,
c workspace.Caller,
action, workspaceID, worktreeID, command string,
spec procgrp.ExecSpec,
) (ExecCommandResp, error) {
res, err := procgrp.RunOnce(ctx, spec)
if err != nil {
return ExecCommandResp{}, errs.Wrap(err, errs.CodeRunExecFailed, "exec command")
}
resp := ExecCommandResp{
ExitCode: res.ExitCode,
Stdout: res.Stdout,
Stderr: res.Stderr,
DurationMs: res.Duration.Milliseconds(),
TimedOut: res.TimedOut,
Truncated: res.Truncated,
}
s.audit(ctx, c, action, workspaceID, map[string]any{
"workspace_id": workspaceID,
"worktree_id": worktreeID,
"command": command,
"exit_code": res.ExitCode,
"duration_ms": resp.DurationMs,
"timed_out": res.TimedOut,
})
return resp, nil
}
// checkAllowed 在 AllowedCommands 非空时要求命令 base name(去掉路径与 .exe 后缀)在白名单内。
func (s *ExecService) checkAllowed(command string) error {
if len(s.cfg.AllowedCommands) == 0 {
return nil
}
base := strings.ToLower(filepath.Base(command))
base = strings.TrimSuffix(base, ".exe")
for _, a := range s.cfg.AllowedCommands {
if strings.ToLower(strings.TrimSuffix(a, ".exe")) == base {
return nil
}
}
return errs.New(errs.CodeRunExecCommandNotAllowed, "command not in allowed list: "+base)
}
// clampTimeout 把请求超时夹到 [1s, MaxTimeout];0/负 → DefaultTimeout。
func (s *ExecService) clampTimeout(sec int) time.Duration {
if sec <= 0 {
return s.cfg.DefaultTimeout
}
d := time.Duration(sec) * time.Second
if d < time.Second {
d = time.Second
}
if d > s.cfg.MaxTimeout {
d = s.cfg.MaxTimeout
}
return d
}
// resolveCwd 解析执行目录:worktreeID 非空 → 校验归属并返回其 Path;否则 main_path。
func (s *ExecService) resolveCwd(ctx context.Context, c workspace.Caller, wsID uuid.UUID, ws *workspace.Workspace, worktreeID string) (string, error) {
if strings.TrimSpace(worktreeID) == "" {
return ws.MainPath, nil
}
wtID, err := uuid.Parse(worktreeID)
if err != nil {
return "", errs.Wrap(err, errs.CodeInvalidInput, "worktree_id parse")
}
wts, err := s.wtSvc.List(ctx, c, wsID)
if err != nil {
return "", err
}
for _, wt := range wts {
if wt.ID == wtID {
return wt.Path, nil
}
}
return "", errs.New(errs.CodeNotFound, "worktree not found in workspace")
}
func (s *ExecService) audit(ctx context.Context, c workspace.Caller, action, targetID string, meta map[string]any) {
if s.rec == nil {
return
}
uid := c.UserID
_ = s.rec.Record(ctx, audit.Entry{
UserID: &uid,
Action: action,
TargetType: "workspace",
TargetID: targetID,
Metadata: meta,
})
}