Files
weichen f3f23a3018 feat(errcode): 用稳定错误码记录 HTTP 与异步失败
失败响应此前不写日志,request_id 无法对照原因。注册表不覆盖处理器已经给出的 HTTP 状态。
2026-09-28 02:27:16 +00:00

304 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"chat-agent-system/internal/swboot"
)
// SWBOOT-10 controlled execution surface (fail-closed):
// POST /api/v1/projects/{id}/swboot/executions — creator-only patch execution
// GET /api/v1/projects/{id}/swboot/executions — execution evidence list
// POST /api/v1/projects/{id}/swboot/bindings — creator repo binding upsert
//
// The executor works on isolated git worktrees, never merges/pushes, and only
// runs admin-registered test commands (repo_path_allowlist.test_commands).
type swbootRuntime struct {
mu sync.Mutex
allowlist *swboot.PGRepoAllowlist
executor *swboot.ControlledPatchExecutor
}
func (s *Server) swbootExec() (*swboot.ControlledPatchExecutor, error) {
if s == nil || s.rt == nil || s.rt.Pool == nil {
return nil, fmt.Errorf("swboot: database required")
}
s.swbootRT.mu.Lock()
defer s.swbootRT.mu.Unlock()
if s.swbootRT.executor != nil {
return s.swbootRT.executor, nil
}
allow := swboot.NewPGRepoAllowlist(s.rt.Pool, 30*time.Second)
exec := &swboot.ControlledPatchExecutor{
Allowlist: allow,
Pool: s.rt.Pool,
CommandAllow: func(cmd string) bool {
// The repo path is not known here; commands are validated per
// execution by the handler against the bound repo's prefixes.
return false
},
}
s.swbootRT.allowlist = allow
s.swbootRT.executor = exec
return exec, nil
}
type swbootExecutionRequest struct {
RepoPath string `json:"repo_path"`
BaseRef string `json:"base_ref"`
Patches []string `json:"patches"`
Commands []string `json:"commands,omitempty"`
}
func (s *Server) handleSwbootExecutions(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
return
}
if !s.requireProjectCreator(w, r, projectID, user.ID) {
return
}
switch r.Method {
case http.MethodGet:
rows, err := s.listSwbootExecutions(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list executions failed")
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"executions": rows})
case http.MethodPost:
exec, err := s.swbootExec()
if err != nil {
writeDomainError(w, r, http.StatusServiceUnavailable, err)
return
}
var req swbootExecutionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body")
return
}
repoPath := strings.TrimSpace(req.RepoPath)
if repoPath == "" {
repoPath = s.boundRepoPath(r.Context(), projectID)
}
if repoPath == "" {
writeJSONResponse(w, http.StatusConflict, map[string]string{
"error_code": "ERR_REPO_BINDING_MISSING",
"message": "项目未绑定仓库或未提供 repo_path;controlled_patch 需先绑定 allowlist 内仓库",
})
return
}
// Commands must be registered by the admin for the covering allowlist
// prefix (longest cover wins; empty registration = no commands).
allowedPrefixes := s.allowlistFor(repoPath)
for _, cmd := range req.Commands {
if !commandAllowed(cmd, allowedPrefixes) {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_COMMAND_NOT_ALLOWLISTED",
"message": "命令不在管理员 test_commands 白名单内:" + firstSegment(cmd),
})
return
}
}
res, err := exec.Execute(r.Context(), swboot.ControlledPatchRequest{
ProjectID: projectID,
RequestedBy: user.ID,
RepoPath: repoPath,
BaseRef: req.BaseRef,
Patches: req.Patches,
Commands: req.Commands,
})
if err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
"error_code": "ERR_EXECUTION_FAILED",
"message": "受控执行失败(证据已记录)",
"execution": res,
})
return
}
writeJSONResponse(w, http.StatusCreated, res)
}
}
type swbootBindingRequest struct {
RepoPath string `json:"repo_path"`
Scope string `json:"permission_scope,omitempty"`
}
func (s *Server) handleSwbootBinding(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
return
}
if !s.requireProjectCreator(w, r, projectID, user.ID) {
return
}
if r.Method != http.MethodPost {
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
exec, err := s.swbootExec()
if err != nil {
writeDomainError(w, r, http.StatusServiceUnavailable, err)
return
}
var req swbootBindingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.RepoPath) == "" {
writeSafeError(w, r, http.StatusBadRequest, "repo_path required")
return
}
scope := strings.TrimSpace(req.Scope)
if scope == "" {
scope = "controlled_patch"
}
repoPath, err := filepath.Abs(strings.TrimSpace(req.RepoPath))
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid repo_path")
return
}
if !exec.Allowlist.IsAllowlisted(repoPath) {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_REPO_PATH_NOT_ALLOWLISTED",
"message": "仓库路径不在管理员 repo_path_allowlist 内",
})
return
}
if err := s.pwSvc().Store().SaveProjectSourceBinding(r.Context(), projectID, repoPath, scope, user.ID); err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "save binding failed")
return
}
writeJSONResponse(w, http.StatusCreated, map[string]any{"success": true, "repo_path": repoPath, "permission_scope": scope})
}
func (s *Server) requireProjectCreator(w http.ResponseWriter, r *http.Request, projectID, userID int64) bool {
creator, err := s.pwSvc().IsProjectCreator(r.Context(), projectID, userID)
if err != nil || !creator {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "仅项目创建者可管理受控工程执行",
})
return false
}
if err := s.authorizer().RequireProjectAccess(r.Context(), projectID, userID); err != nil {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权访问该项目",
})
return false
}
return true
}
func (s *Server) allowlistFor(repoPath string) []string {
s.swbootRT.mu.Lock()
allow := s.swbootRT.allowlist
s.swbootRT.mu.Unlock()
if allow == nil {
return nil
}
return allow.AllowedCommands(repoPath)
}
func (s *Server) boundRepoPath(ctx context.Context, projectID int64) string {
if s == nil || s.rt == nil || s.rt.Pool == nil {
return ""
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var repoPath string
err := s.rt.Pool.QueryRow(cctx, `
SELECT source_ref FROM project_source_bindings
WHERE project_id=$1 AND permission_scope='controlled_patch'
ORDER BY created_at DESC LIMIT 1
`, projectID).Scan(&repoPath)
if err != nil {
return ""
}
return repoPath
}
func (s *Server) listSwbootExecutions(ctx context.Context, projectID int64) ([]map[string]any, error) {
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
rows, err := s.rt.Pool.Query(cctx, `
SELECT id, status, repo_path, base_ref, branch, worktree_path, patches_applied,
files_json, commands_json, error, started_at, finished_at
FROM swboot_controlled_executions
WHERE project_id=$1
ORDER BY started_at DESC
LIMIT 50
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]map[string]any, 0, 4)
for rows.Next() {
var (
id int64
status, repo, base, branch, wt, errTxt string
patches int
filesJSON, cmdsJSON []byte
started, finished time.Time
)
if err := rows.Scan(&id, &status, &repo, &base, &branch, &wt, &patches,
&filesJSON, &cmdsJSON, &errTxt, &started, &finished); err != nil {
return nil, err
}
out = append(out, map[string]any{
"id": id, "status": status, "repo_path": repo, "base_ref": base,
"branch": branch, "worktree_path": wt, "patches_applied": patches,
"files": json.RawMessage(filesJSON), "commands": json.RawMessage(cmdsJSON),
"error": errTxt, "started_at": started, "finished_at": finished,
})
}
return out, rows.Err()
}
func commandAllowed(cmd string, prefixes []string) bool {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return false
}
seg := firstSegment(cmd)
for _, p := range prefixes {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if seg == p || strings.HasPrefix(seg, p) {
return true
}
}
return false
}
func firstSegment(cmd string) string {
fields := strings.Fields(strings.TrimSpace(cmd))
if len(fields) == 0 {
return ""
}
return filepath.Base(fields[0])
}