Files
Agent-System-Backend/internal/server/scratch_session.go
T
weichenandCursor 613c068c79 Isolate scratch sessions from project-only capabilities
Why: A scratch session is a private general-agent chat; it must work for its
owner without the RDM provider and must not share documents or context with any
other conversation.
Evidence: agent-exec run fb04c4a9 (pi, success); go test ./internal/store ./internal/server ./internal/runmgr ./internal/app pass.
Impact: Owner work/read on scratch bypasses the domain provider; non-owners and
shared adapters get NotFound. Scratch runs skip project context, knowledge
retrieval, owner/project context entries, user/project memory, and cross-session
reads (both directions). Knowledge add/search 400, rdm.* and formal project
commands 403, settings writes 400, uploads skip indexing, and deleting the sole
conversation removes the scratch project.
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 02:53:31 +00:00

114 lines
4.5 KiB
Go

package server
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// 临时会话入口(U1):POST /api/scratch-sessions。
//
// 能力是内生的——任何已启用账号都能建,不需要 project.create,也不在权限目录
// /replaceRolePermissions 里出现为可配置项。项目与会话由 store 在一个事务里
// 写出(scratch 标记 + cross_private 会话),不经过 RDM/projectLifecycle。
// scratchSessionView 是创建结果:调用方同时拿到 scratch 项目与它唯一的会话。
type scratchSessionView struct {
Project core.Project `json:"project"`
Conversation core.Conversation `json:"conversation"`
}
func (s *Server) createScratchSession(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Title string `json:"title"`
ProfileID string `json:"profileId"`
Settings map[string]any `json:"settings"`
}
// 请求体可省略:空体即默认名临时会话。非空体必须是单一 JSON 对象。
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, "invalid scratch session request", http.StatusBadRequest)
return
}
// 标记归服务端所有:客户端不能带 settings 上来(哪怕是别的键),
// 否则“scratch 标记”会变成一个客户端可写的项目字段。
if req.Settings != nil {
http.Error(w, "scratch host marker is server-owned", http.StatusBadRequest)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = store.ScratchDefaultProjectName
}
title := strings.TrimSpace(req.Title)
if title == "" {
title = "新会话"
}
// 与其它 create 路径一致:认证关闭(嵌入式档)走默认租户,认证开启时由
// store 的账号有效性边界拒绝停用账号——scratch 不是绕过账号状态的旁路。
project, conversation, err := s.st.CreateScratchSessionAccountChecked(
s.owner(r), name, title, strings.TrimSpace(req.ProfileID), s.Auth == nil || !s.Auth.Enabled)
if err != nil {
writeStoreErr(w, err)
return
}
// 状态码随同类创建入口(POST /api/projects、/api/projects/{id}/conversations):
// 200 配 writeJSON,而不是 201。
writeJSON(w, http.StatusOK, scratchSessionView{Project: project, Conversation: conversation})
}
// ---- U2:隔离门禁 ----
//
// 临时会话是“只属于自己的通用代理会话”:没有知识库、没有项目上下文、不与任何
// 会话共享材料。这些门禁只认存储里的 host 标记(store.IsScratchProject),调用点
// 不自己比对标记字符串。
const (
// scratchNoKnowledge 知识库是项目域能力,临时会话一律没有。
scratchNoKnowledge = "scratch sessions have no knowledge base"
// scratchNoSettings 临时会话只有会话,没有可写的项目设置。
scratchNoSettings = "scratch sessions have no project settings"
// scratchNoCommand 项目域命令(RDM 及其它需要基座项目的命令)在临时会话不可用。
scratchNoCommand = "not available in scratch sessions"
)
// scratchProjectOf reports whether projectID is the caller's own scratch project.
// Other owners' projects (and unknown ids) fall through to the normal decision
// path, so the gate never leaks the existence of someone else's scratch session.
func (s *Server) scratchProjectOf(projectID, owner string) bool {
if s.st == nil {
return false
}
handled, err := s.st.ScratchProjectDecision(projectID, owner)
return handled && err == nil
}
// refuseScratchProjectCapability 门禁拒绝:只对调用者自己的临时会话生效。
func (s *Server) refuseScratchProjectCapability(w http.ResponseWriter, projectID, owner, message string) bool {
if !s.scratchProjectOf(projectID, owner) {
return false
}
http.Error(w, message, http.StatusBadRequest)
return true
}
// refuseScratchCommandHost 命令门禁(403):临时会话里不得分发需要基座项目的命令。
// rdm.* 全族与正式项目命令(cmdbus.RequiresProjectWorkDecision)都不行;内置的
// 会话类命令照旧可用。
func (s *Server) refuseScratchCommandHost(w http.ResponseWriter, hostProjectID, owner, commandID string) bool {
if !s.scratchProjectOf(hostProjectID, owner) {
return false
}
if !strings.HasPrefix(commandID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(commandID) {
return false
}
http.Error(w, scratchNoCommand, http.StatusForbidden)
return true
}