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, }) }