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>
159 lines
5.7 KiB
Go
159 lines
5.7 KiB
Go
package server
|
||
|
||
import (
|
||
"agent-system-backend/internal/store"
|
||
"agent-system-backend/pkg/extension"
|
||
core "agent-system-core"
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
)
|
||
|
||
// SetProjectLifecycle is wired only for a registered in-process provider. The
|
||
// optional host factory constructs a capability scoped to this request's actor.
|
||
func (s *Server) SetProjectLifecycle(p extension.ProjectLifecycle, host func(string) extension.Host) {
|
||
s.projectLifecycle = &p
|
||
s.projectHost = host
|
||
}
|
||
func (s *Server) lifecycleHost(owner string) extension.Host {
|
||
if s.projectHost == nil {
|
||
return nil
|
||
}
|
||
return s.projectHost(owner)
|
||
}
|
||
|
||
// scratchProjectWork 让临时会话的属主在没有 RDM provider 时也能工作。scratch 项目
|
||
// 从不进入 RDM:provider 只会报 Work=false,属主会被自己的临时会话挡住(U2 核心)。
|
||
// 所以门禁先走存储侧归属:属主放行且不再问 provider,非属主一律 NotFound(与
|
||
// 项目工作同一错误面)。handled=false 表示这不是临时会话,调用点照旧。
|
||
func (s *Server) scratchProjectWork(owner, projectID string) (handled bool, err error) {
|
||
if s.st == nil {
|
||
return false, nil
|
||
}
|
||
return s.st.ScratchProjectDecision(projectID, owner)
|
||
}
|
||
|
||
func (s *Server) projectWork(ctx context.Context, owner, projectID string) error {
|
||
if handled, err := s.scratchProjectWork(owner, projectID); err != nil {
|
||
return err
|
||
} else if handled {
|
||
return nil
|
||
}
|
||
if s.projectLifecycle == nil {
|
||
return nil
|
||
}
|
||
if s.projectLifecycle.Work == nil {
|
||
return store.ErrNotFound
|
||
}
|
||
ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !ok {
|
||
return store.ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// projectRead is the read-side member decision (U3). Live work stays the
|
||
// authority for every member who is still active. A member the provider reports
|
||
// as no longer live (RDM state=removed) is not decided by a second membership
|
||
// probe: the decision goes back to the one resource authorization query surface
|
||
// the domain already answers, and only a shared-domain read grant opens the
|
||
// read path. Everything else stays denied and every write keeps projectWork.
|
||
//
|
||
// With no resource authorizer registered (EXT-8b V2 absent) this is exactly
|
||
// projectWork, so un-extended hosts keep their current denial unchanged.
|
||
func (s *Server) projectRead(ctx context.Context, owner, projectID string) error {
|
||
if handled, err := s.scratchProjectWork(owner, projectID); err != nil {
|
||
return err
|
||
} else if handled {
|
||
// 临时会话只对属主可读:共享域回落永远不参与(它不是共享资源)。
|
||
return nil
|
||
}
|
||
if s.projectLifecycle == nil {
|
||
return nil
|
||
}
|
||
if s.projectLifecycle.Work == nil {
|
||
return store.ErrNotFound
|
||
}
|
||
ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ok {
|
||
return nil
|
||
}
|
||
if projectID != "" && s.ProjectAuthzV2 != nil &&
|
||
s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) {
|
||
return nil
|
||
}
|
||
return store.ErrNotFound
|
||
}
|
||
|
||
// createPrivateConversation is shared by the API and /new. Only live work
|
||
// authorization grants member creation; the resulting conversation stays owned.
|
||
// A scratch project already holds its one private conversation, so this shared
|
||
// path refuses a second one instead of handing out a normal session under it.
|
||
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
|
||
if err := s.rejectScratchProjectConversation(projectID, owner); err != nil {
|
||
return core.Conversation{}, err
|
||
}
|
||
if s.projectLifecycle != nil {
|
||
if err := s.projectWork(ctx, owner, projectID); err != nil {
|
||
return core.Conversation{}, err
|
||
}
|
||
return s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, false)
|
||
}
|
||
c, err := s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, true)
|
||
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) {
|
||
return s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, false)
|
||
}
|
||
return c, err
|
||
}
|
||
|
||
// rejectScratchProjectConversation keeps the scratch 1:1 rule on the shared
|
||
// conversation-create path (POST /api/projects/{id}/conversations and /new).
|
||
// The lookup is the internal trusted one, and it is ownership-aware: a non-owner
|
||
// is not told anything about the session (projectWork answers NotFound), while
|
||
// the owner still gets the 1:1 refusal. Nothing here is returned to the caller
|
||
// for an unreadable project, which the store path decides as before.
|
||
func (s *Server) rejectScratchProjectConversation(projectID, owner string) error {
|
||
if s.st == nil {
|
||
return nil
|
||
}
|
||
handled, err := s.st.ScratchProjectDecision(projectID, owner)
|
||
if !handled || err != nil {
|
||
return nil
|
||
}
|
||
return store.ErrScratchSessionSingle
|
||
}
|
||
|
||
func writeProjectErr(w http.ResponseWriter, err error) {
|
||
var coded interface{ StatusCode() int }
|
||
if errors.As(err, &coded) {
|
||
switch code := coded.StatusCode(); code {
|
||
case 400, 403, 404, 409, 503:
|
||
http.Error(w, err.Error(), code)
|
||
return
|
||
}
|
||
}
|
||
writeStoreErr(w, err)
|
||
}
|
||
func writeProjectJSON(w http.ResponseWriter, p any, err error) {
|
||
if err != nil {
|
||
writeProjectErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, p)
|
||
}
|
||
|
||
// publicProjectMetadata also masks provider-returned metadata, since providers
|
||
// may obtain raw owner-management rows before producing a shared public view.
|
||
func (s *Server) publicProjectMetadata(p core.Project) core.Project {
|
||
if s.projectLifecycle != nil {
|
||
p.Settings = s.set.MaskedProjectMetadata(p.Settings)
|
||
}
|
||
return p
|
||
}
|