304 lines
8.9 KiB
Go
304 lines
8.9 KiB
Go
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])
|
||
}
|