- FIND-SEC-01: GetUserContext requires session ownership (handler 403 + SQL/memory owner filter); in-memory HTTP + isolated-PG regressions - FIND-ARCH-01: archtest fails closed on untracked table writes (+noise exemption) and registers 50 existing writer tables in table_owners - FIND-SEC-02: Authorizer without pool denies by default; explicit NewInMemoryAuthorizer seam opt-in via ALLOW_IN_MEMORY at composition - FIND-SEC-03: swboot commands run argv-exec (no sh -c) with exact basename allowlist match; prefix-spoof and no-shell-eval tests - FIND-FLOW-01: confirm rejects validated_only plans; ExecuteClaimed hard-refuses validated_only steps (new error codes + HTTP mapping) - FIND-QUAL-01: intentflow.ValidateFlagConsistency fail-fast at Assemble
212 lines
7.1 KiB
Go
212 lines
7.1 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"chat-agent-system/internal/app/intentflow"
|
||
"chat-agent-system/internal/authz"
|
||
)
|
||
|
||
type intentPlanDecisionBody struct {
|
||
ExpectedRevision int64 `json:"expected_revision"`
|
||
DecisionNonce string `json:"decision_nonce"`
|
||
DecisionDigest string `json:"decision_digest"`
|
||
Kind string `json:"kind"`
|
||
StepIDs []string `json:"step_ids"`
|
||
ExcludeUnselected bool `json:"exclude_unselected"`
|
||
UserID int64 `json:"user_id"`
|
||
ProjectID int64 `json:"project_id"`
|
||
SessionID string `json:"session_id"`
|
||
}
|
||
|
||
func (s *Server) handleGetActiveIntentPlan(w http.ResponseWriter, r *http.Request) {
|
||
plans, prin, ok := s.planRequestScope(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
view, err := plans.GetActivePlan(r.Context(), prin)
|
||
if err != nil {
|
||
writeIntentPlanError(w, r, plans, prin, "", err)
|
||
return
|
||
}
|
||
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
|
||
}
|
||
|
||
func (s *Server) handleGetIntentPlan(w http.ResponseWriter, r *http.Request) {
|
||
plans, prin, ok := s.planRequestScope(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
planID := r.PathValue("plan_id")
|
||
view, err := plans.GetPlan(r.Context(), prin, planID)
|
||
if err != nil {
|
||
writeIntentPlanError(w, r, plans, prin, planID, err)
|
||
return
|
||
}
|
||
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
|
||
}
|
||
|
||
func (s *Server) handleIntentPlanDecision(w http.ResponseWriter, r *http.Request) {
|
||
plans, prin, ok := s.planRequestScope(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
var body intentPlanDecisionBody
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
|
||
"error_code": "PLAN_DEPENDENCY_INVALID",
|
||
"message": "无效的决策请求",
|
||
})
|
||
return
|
||
}
|
||
kind := intentflow.DecisionKind(strings.TrimSpace(body.Kind))
|
||
planID := r.PathValue("plan_id")
|
||
view, err := plans.DecidePlan(r.Context(), intentflow.DecisionCommand{
|
||
Principal: prin,
|
||
PlanID: planID,
|
||
ExpectedRevision: body.ExpectedRevision,
|
||
DecisionNonce: body.DecisionNonce,
|
||
DecisionDigest: body.DecisionDigest,
|
||
Kind: kind,
|
||
StepIDs: body.StepIDs,
|
||
ExcludeUnselected: body.ExcludeUnselected,
|
||
})
|
||
if err != nil {
|
||
writeIntentPlanError(w, r, plans, prin, planID, err)
|
||
return
|
||
}
|
||
// After confirm, advance eligible steps via the existing plan ledger dispatcher
|
||
// (ClaimStep / ExecuteClaimed / RecoverReceipt). No second decision queue.
|
||
if shouldDispatchAfterDecision(kind, view) {
|
||
if advanced, advErr := plans.DispatchEligible(r.Context(), "http-confirm", planID); advErr == nil && advanced != nil {
|
||
view = advanced
|
||
}
|
||
}
|
||
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
|
||
}
|
||
|
||
func shouldDispatchAfterDecision(kind intentflow.DecisionKind, view *intentflow.PlanView) bool {
|
||
if view == nil {
|
||
return false
|
||
}
|
||
switch kind {
|
||
case intentflow.DecisionConfirm, intentflow.DecisionConfirmRetry, intentflow.DecisionContinue:
|
||
return view.Status == intentflow.PlanExecuting ||
|
||
view.Status == intentflow.PlanPartiallyCompleted ||
|
||
view.Status == intentflow.PlanCompleted
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (s *Server) planRequestScope(w http.ResponseWriter, r *http.Request) (intentflow.Service, intentflow.Principal, bool) {
|
||
if s == nil || s.intentPlans() == nil {
|
||
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{
|
||
"error_code": "PLAN_NOT_FOUND_OR_HIDDEN",
|
||
"message": "计划服务不可用",
|
||
})
|
||
return nil, intentflow.Principal{}, false
|
||
}
|
||
user, ok := s.requireAuthenticatedUser(w, r)
|
||
if !ok {
|
||
return nil, intentflow.Principal{}, false
|
||
}
|
||
projectID, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("project_id")), 10, 64)
|
||
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
|
||
if err != nil || projectID <= 0 || sessionID == "" {
|
||
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
|
||
"error_code": "PLAN_DEPENDENCY_INVALID",
|
||
"message": "缺少 project_id 或 session_id",
|
||
})
|
||
return nil, intentflow.Principal{}, false
|
||
}
|
||
if err := s.planProjectAccess(r.Context(), projectID, user.ID); err != nil {
|
||
if errors.Is(err, authz.ErrUnauthenticated) {
|
||
writeJSONResponse(w, http.StatusUnauthorized, map[string]string{
|
||
"error_code": "ERR_UNAUTHENTICATED",
|
||
"message": "请先登录",
|
||
})
|
||
return nil, intentflow.Principal{}, false
|
||
}
|
||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||
"error_code": "ERR_FORBIDDEN",
|
||
"message": "无权访问该项目",
|
||
})
|
||
return nil, intentflow.Principal{}, false
|
||
}
|
||
ownerID, ownerErr := s.planSessionOwner(r.Context(), sessionID)
|
||
if ownerErr != nil || ownerID <= 0 || ownerID != user.ID {
|
||
writeJSONResponse(w, http.StatusNotFound, map[string]string{
|
||
"error_code": "PLAN_NOT_FOUND_OR_HIDDEN",
|
||
"message": "计划不存在或不可见",
|
||
})
|
||
return nil, intentflow.Principal{}, false
|
||
}
|
||
env := "dev"
|
||
if s.cfg != nil && strings.TrimSpace(s.cfg.Env) != "" {
|
||
env = s.cfg.Env
|
||
}
|
||
return s.intentPlans(), intentflow.Principal{
|
||
UserID: user.ID,
|
||
Environment: env,
|
||
ProjectID: projectID,
|
||
SessionID: sessionID,
|
||
}, true
|
||
}
|
||
|
||
func (s *Server) planProjectAccess(ctx context.Context, projectID, userID int64) error {
|
||
if s.planAccessFn != nil {
|
||
return s.planAccessFn(ctx, projectID, userID)
|
||
}
|
||
if s.authorizer() == nil {
|
||
return authz.ErrForbidden
|
||
}
|
||
return s.authorizer().RequireProjectAccess(ctx, projectID, userID)
|
||
}
|
||
|
||
func (s *Server) planSessionOwner(ctx context.Context, sessionID string) (int64, error) {
|
||
if s.planSessionOwnerFn != nil {
|
||
return s.planSessionOwnerFn(ctx, sessionID)
|
||
}
|
||
if s.ctxMgr() == nil {
|
||
return 0, intentflow.ErrPlanNotFoundOrHidden
|
||
}
|
||
return s.ctxMgr().GetSessionOwnerID(ctx, sessionID)
|
||
}
|
||
|
||
func writeIntentPlanError(w http.ResponseWriter, r *http.Request, plans intentflow.Service, prin intentflow.Principal, planID string, err error) {
|
||
code := intentflow.ErrorCode(err)
|
||
status := http.StatusInternalServerError
|
||
switch {
|
||
case errors.Is(err, intentflow.ErrUnauthenticated):
|
||
status = http.StatusUnauthorized
|
||
case errors.Is(err, intentflow.ErrPlanNotFoundOrHidden):
|
||
status = http.StatusNotFound
|
||
case errors.Is(err, intentflow.ErrPlanActionForbidden):
|
||
status = http.StatusForbidden
|
||
case errors.Is(err, intentflow.ErrPlanModeNotConfirmable),
|
||
errors.Is(err, intentflow.ErrPlanModeNotExecutable):
|
||
// validated_only(shadow/dry-run)计划的硬边界:该动作对这类计划永远不可用。
|
||
status = http.StatusForbidden
|
||
case errors.Is(err, intentflow.ErrPlanStaleRevision),
|
||
errors.Is(err, intentflow.ErrPlanExpired),
|
||
errors.Is(err, intentflow.ErrPlanDecisionConflict),
|
||
errors.Is(err, intentflow.ErrPlanActionVersionConflict):
|
||
status = http.StatusConflict
|
||
case errors.Is(err, intentflow.ErrPlanDependencyInvalid):
|
||
status = http.StatusBadRequest
|
||
}
|
||
body := map[string]any{"error_code": code, "message": code}
|
||
if plans != nil && (errors.Is(err, intentflow.ErrPlanStaleRevision) || errors.Is(err, intentflow.ErrPlanDecisionConflict)) && planID != "" {
|
||
if latest, getErr := plans.GetPlan(r.Context(), prin, planID); getErr == nil && latest != nil {
|
||
body["plan"] = latest
|
||
}
|
||
}
|
||
writeJSONResponse(w, status, body)
|
||
}
|