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 }