Files
agent-exec-transportandCursor 954e744d6d
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m14s
Require the caller's conversation when creating a surface
POST /api/surfaces with an empty surfaceId now requires a conversationId
that belongs to the caller: empty returns 400, missing or another owner's
conversation returns 404. Revisions keep the previous behavior. Without
this check a direct API caller could create a project-less surface and
skip the project lifecycle gate on actions.

Why: an empty conversationId produced surfaces with no project ownership.
Evidence: agent-exec unit backend-surface-submit-conversation-required (pi, attempt 1 after authorized rerun, terminal=success); go test ./internal/server/... ./internal/surfacesvc/... .
Impact: HTTP create-surface validation only.
Refs: Dev task fix/svc-superadmin-envfile-surface-conv.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 15:02:15 +00:00

362 lines
12 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 (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
core "agent-system-core"
)
func (s *Server) createBackgroundTask(w http.ResponseWriter, r *http.Request) {
var body struct {
Kind string `json:"kind"`
Title string `json:"title"`
ConversationID string `json:"conversationId"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Kind == "" {
http.Error(w, "kind required", http.StatusBadRequest)
return
}
task, err := s.st.CreateBackgroundTaskAccountChecked(s.owner(r), body.Kind, body.Title, body.ConversationID, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, task)
}
func (s *Server) listBackgroundTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := s.st.ListBackgroundTasks(s.owner(r))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(tasks))
}
func (s *Server) getBackgroundTask(w http.ResponseWriter, r *http.Request) {
task, err := s.st.GetBackgroundTask(s.owner(r), r.PathValue("id"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) updateBackgroundTask(w http.ResponseWriter, r *http.Request) {
var body struct {
Status string `json:"status"`
Progress int `json:"progress"`
Error string `json:"error"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Status == "" {
http.Error(w, "status required", http.StatusBadRequest)
return
}
if err := s.st.UpdateBackgroundTaskAccountChecked(s.owner(r), r.PathValue("id"), body.Status, body.Progress, body.Error, s.Auth == nil || !s.Auth.Enabled); err != nil {
writeStoreErr(w, err)
return
}
s.getBackgroundTask(w, r)
}
func (s *Server) putMemory(w http.ResponseWriter, r *http.Request) {
var e core.MemoryEntry
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || strings.TrimSpace(e.Content) == "" {
http.Error(w, "content required", http.StatusBadRequest)
return
}
if e.Scope == "" {
e.Scope = "user"
}
if e.Type == "" {
e.Type = "fact"
}
if e.Source == "" {
e.Source = "manual"
}
saved, err := s.st.PutMemoryAccountChecked(s.owner(r), e, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, saved)
}
func (s *Server) listMemory(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
list, err := s.st.ListMemory(s.owner(r), q.Get("scope"), q.Get("projectId"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(list))
}
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
evs, err := s.st.ListAuditEvents(s.owner(r), limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(evs))
}
func (s *Server) postAudit(w http.ResponseWriter, r *http.Request) {
var body struct {
Action string `json:"action"`
Target string `json:"target"`
Evidence string `json:"evidence"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Action == "" {
http.Error(w, "action required", http.StatusBadRequest)
return
}
if err := s.st.AppendAuditEventAccountChecked(s.owner(r), store.AuditEvent{
Owner: s.owner(r), Actor: s.owner(r), Action: body.Action, Target: body.Target,
Evidence: body.Evidence, TraceID: r.Header.Get("X-Trace-Id"),
}, s.Auth == nil || !s.Auth.Enabled); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) setPluginEnabled(w http.ResponseWriter, r *http.Request) {
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := s.st.SetPluginEnabledAccountChecked(s.owner(r), r.PathValue("id"), body.Enabled, s.Auth == nil || !s.Auth.Enabled); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) llmBudget(w http.ResponseWriter, r *http.Request) {
budget := int(s.set.Effective(s.owner(r), "").GetNumber("llm.dailyTokenBudget", 0))
status, err := s.st.AccountTokens(s.owner(r), 0, budget)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{"total": status.Total, "budget": budget, "over": status.Over})
}
func (s *Server) exportConversation(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
id := r.PathValue("id")
conv, err := s.st.GetConversation(owner, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
msgs, err := s.st.ListMessages(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.URL.Query().Get("format") == "md" {
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
fmt.Fprintf(w, "# %s\n\n", conv.Title)
for _, m := range msgs {
fmt.Fprintf(w, "## %s\n\n%s\n\n", m.Role, messageText(m))
}
return
}
writeJSON(w, http.StatusOK, map[string]any{"conversation": conv, "messages": msgs})
}
func messageText(m core.Message) string {
var b strings.Builder
for _, p := range m.Parts {
if p.Type == core.PartText {
b.WriteString(p.Text)
}
}
return b.String()
}
// ---- Generative UI Surface(提案 U2;EXT-17)。直接 API 与插件命令共用
// surfacesvc.Submit 单一咽喉:禁用/epoch/校验/CAS 守卫不因入口不同而绕过。----
func (s *Server) submitSurface(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
var body struct {
PluginID string `json:"pluginId"`
ConversationID string `json:"conversationId"`
SurfaceID string `json:"surfaceId"`
BaseRevision int `json:"baseRevision"`
ObservedEpoch int64 `json:"observedEpoch"`
ContextVersion string `json:"contextVersion"`
SemanticText string `json:"semanticText"`
Spec json.RawMessage `json:"spec"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.PluginID == "" || len(body.Spec) == 0 {
http.Error(w, "pluginId and spec required", http.StatusBadRequest)
return
}
// 新建页面必须落在调用者自己的会话上:会话表反查项目归属是唯一来源,
// 空或越权会话会让页面绕过项目生命周期检查。缺失与他人会话同回 404。
if body.SurfaceID == "" {
if body.ConversationID == "" {
http.Error(w, "conversationId required", http.StatusBadRequest)
return
}
if _, err := s.st.GetConversation(s.owner(r), body.ConversationID); err != nil {
http.Error(w, "conversation not found", http.StatusNotFound)
return
}
}
snap, err := s.Surfaces.SubmitAccountChecked(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{
SurfaceID: core.ID(body.SurfaceID), ConversationID: core.ID(body.ConversationID),
BaseRevision: body.BaseRevision, ObservedEpoch: body.ObservedEpoch,
ContextVersion: body.ContextVersion, SemanticText: body.SemanticText, Spec: body.Spec,
}, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusCreated, snap)
}
func (s *Server) getSurface(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, snap)
}
func (s *Server) listSurfaces(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
metas, err := s.Surfaces.List(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(metas))
}
func (s *Server) setSurfaceActivation(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "enabled required", http.StatusBadRequest)
return
}
row, err := s.Surfaces.SetActivationAccountChecked(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
if s.projectLifecycle != nil {
snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
if snap.Identity.ProjectID != "" {
if err := s.projectWork(r.Context(), s.owner(r), snap.Identity.ProjectID); err != nil {
writeProjectErr(w, err)
return
}
}
}
// 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道
// (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。
var body struct {
ActionID string `json:"actionId"`
Revision int `json:"revision"`
RequestID string `json:"requestId"`
Data map[string]any `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ActionID == "" || body.RequestID == "" {
http.Error(w, "actionId and requestId required", http.StatusBadRequest)
return
}
res, err := s.Surfaces.SubmitActionAccountChecked(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{
SurfaceID: core.ID(r.PathValue("id")), ActionID: body.ActionID,
Revision: body.Revision, RequestID: body.RequestID, Data: body.Data,
}, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// writeSurfaceError 把服务层确定错误映射到状态码:禁用/epoch=409 语义(准入
// 截止),规格违规=400 带字段清单,冲突=409 带当前版本,NotFound=404。
func (s *Server) writeSurfaceError(w http.ResponseWriter, err error) {
var invalid *surfacesvc.ErrInvalidSpec
var conflict *store.ErrRevisionConflict
switch {
case errors.Is(err, surfacesvc.ErrDisabled), errors.Is(err, surfacesvc.ErrStaleEpoch),
errors.Is(err, surfacesvc.ErrUnavailable):
http.Error(w, err.Error(), http.StatusForbidden)
case errors.Is(err, surfacesvc.ErrUnknownAction):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, store.ErrIdempotencyConflict):
http.Error(w, err.Error(), http.StatusConflict)
case errors.As(err, &invalid):
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid spec", "details": invalid.Errs})
case errors.As(err, &conflict):
writeJSON(w, http.StatusConflict, map[string]any{"error": "revision conflict", "current": conflict.Current})
case errors.Is(err, store.ErrNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// listProjectSurfaces 项目级跨会话页面聚合(U6/R8):owner 授权作用域。
func (s *Server) listProjectSurfaces(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
metas, err := s.Surfaces.ListByProject(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(metas))
}