Why: The Frontend groups scratch sessions under a separate sidebar category and needs an explicit marker instead of inferring it from the project list. Evidence: agent-exec run 2d662c46 (pi, success); go test ./internal/store ./internal/server pass. Impact: listTasks, listConversations, conversationDetail and POST /api/scratch-sessions return scratch: true for scratch conversations; other conversations keep the same JSON (omitempty). Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md Co-authored-by: Cursor <cursoragent@cursor.com>
119 lines
4.7 KiB
Go
119 lines
4.7 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 项目与它唯一的会话。
|
|
// 会话走 server 本地的 conversationView:刚建出来的会话必然属于 scratch 项目,
|
|
// 标记直接置真,不必回查。
|
|
type scratchSessionView struct {
|
|
Project core.Project `json:"project"`
|
|
Conversation conversationView `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: conversationView{Conversation: conversation, Scratch: true},
|
|
})
|
|
}
|
|
|
|
// ---- 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
|
|
}
|