Files
Agent-System-Backend/internal/server/project_lifecycle.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

159 lines
5.7 KiB
Go
Raw 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 (
"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
}