You've already forked agentic-coding-workflow
247 lines
7.8 KiB
Go
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,
|
|
})
|
|
}
|