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 }