You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user