agent-exec transport 47f1c6dec77e33ca7e6408150b0d3dd0/5116ee9fd8bf

This commit is contained in:
agent-exec-transport
2026-10-20 05:00:06 +00:00
parent 5543a44859
commit d3380de4a0
3 changed files with 284 additions and 1 deletions
+30
View File
@@ -38,6 +38,36 @@ func (s *Server) projectWork(ctx context.Context, owner, projectID string) error
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 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.
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
+250
View File
@@ -3,6 +3,7 @@ package server
import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
@@ -506,3 +507,252 @@ func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) {
t.Fatal("revoked upload created an artifact")
}
}
// ---- U3:被移除成员(removed)的会话面——读放行、写关闭(与 RDM U3a 配对) ----
//
// RDM 侧 U3a(成员状态机 active/removed/absent)经两个受信扩展面回答 Backend:
// ProjectLifecycle.Work(活着的工作权限)与 extension.ResourceAuthzQuery
// (共享域读)。被移除成员失去全部工作与写权限,只保留共享域读的只读路径。
// 被测面是真实 HTTP 服务,供应侧换成可注入的成员状态 fixture。
const (
memberActive = "active"
memberRemoved = "removed"
memberAbsent = "absent"
removedMemberHistoryProbe = "REMOVED_MEMBER_HISTORY_PROBE"
removedMemberWriteProbe = "REMOVED_MEMBER_WRITE_PROBE"
)
// memberStateFixture 是可注入的 RDM 供应侧:同一成员状态机回答上面两个面。
type memberStateFixture struct {
owner string
member string
projectID string
memberState string
readAllowed bool
queries []extension.ResourceAuthzQuery
}
func (f *memberStateFixture) stateOf(actor string) string {
switch {
case actor == f.owner:
return memberActive
case actor == f.member:
if f.memberState == "" {
return memberActive
}
return f.memberState
}
return memberAbsent
}
// work 是活工作探针:只有 active 成员可工作,removed 与 absent 一律 false。
func (f *memberStateFixture) work(_ context.Context, actor string, id core.ID) (bool, error) {
return id == core.ID(f.projectID) && f.stateOf(actor) == memberActive, nil
}
// authorize 是唯一资源授权查询面:读动作跟随成员状态(removed 只保留共享域读),
// 写与副作用动作对任何成员状态都拒绝(与 RDM 侧 IsMember 只认 active 一致)。
func (f *memberStateFixture) authorize(q extension.ResourceAuthzQuery) (bool, error) {
f.queries = append(f.queries, q)
if q.ResourceType != "project" || q.ResourceID != core.ID(f.projectID) || q.ProjectID != core.ID(f.projectID) {
return false, nil
}
switch q.Action {
case "read", "list", "stream":
default:
return false, nil
}
if state := f.stateOf(q.Actor); state != memberActive {
return state == memberRemoved && f.readAllowed, nil
}
return true, nil
}
// removedMemberWorld 是上面 fixture 装好的真实服务:alice 是项目属主,bob 是
// 被移除的成员,mallory 从未入项目。bob 的会话属主就是 bob 自己。
type removedMemberWorld struct {
s *Server
st *store.Store
fixture *memberStateFixture
keys map[string]string
project core.Project
mine core.Conversation
other core.Conversation
}
func newRemovedMemberWorld(t *testing.T, state string) *removedMemberWorld {
t.Helper()
s, st := newAuthTestServer(t)
keys := map[string]string{}
for _, owner := range []string{"alice", "bob", "mallory"} {
key, err := createLegacyAPIKeyFixture(t, st, owner, "test")
if err != nil {
t.Fatal(err)
}
keys[owner] = key
}
project, err := st.CreateProject("alice", "Shared")
if err != nil {
t.Fatal(err)
}
mine, err := st.CreateConversationInProject("bob", project.ID, "Bob session", "")
if err != nil {
t.Fatal(err)
}
other, err := st.CreateConversation("alice", project.ID, "Alice private", "")
if err != nil {
t.Fatal(err)
}
if err := st.AppendMessage(&core.Message{ConversationID: mine.ID, Role: core.RoleUser,
Parts: []core.Part{{Type: core.PartText, Text: removedMemberHistoryProbe}}}); err != nil {
t.Fatal(err)
}
fixture := &memberStateFixture{owner: "alice", member: "bob", projectID: project.ID, memberState: state, readAllowed: true}
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: fixture.work}, nil)
s.SetResourceAuthorizerV2(fixture.authorize)
s.DisableUploadIndexing = true
return &removedMemberWorld{s: s, st: st, fixture: fixture, keys: keys, project: project, mine: mine, other: other}
}
func (w *removedMemberWorld) listPath() string {
return "/api/projects/" + string(w.project.ID) + "/conversations"
}
// requireDenied 固化既有 fail-closed 语义:被移除成员与非成员在写门上一律
// 403/404(不泄露存在性),两者都算拒绝——放行成 200 才是回归放宽。
func requireDenied(t *testing.T, rec *httptest.ResponseRecorder, label string) {
t.Helper()
if rec.Code != http.StatusForbidden && rec.Code != http.StatusNotFound {
t.Fatalf("%s: gate opened (%d) %s", label, rec.Code, rec.Body.String())
}
}
// TestRemovedMemberReadsOwnSessionHistory 覆盖 (a):removed+共享读放行时,
// 被移除成员读本人会话列表、会话详情与消息历史成功,且不新增任何可见性。
func TestRemovedMemberReadsOwnSessionHistory(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
bob := w.keys["bob"]
rec := lifecycleRequest(t, w.s, bob, http.MethodGet, w.listPath(), "")
var cs []core.Conversation
_ = json.Unmarshal(rec.Body.Bytes(), &cs)
if rec.Code != 200 || len(cs) != 1 || cs[0].ID != w.mine.ID {
t.Fatalf("removed member own list=%d %s", rec.Code, rec.Body.String())
}
// 放行必须来自同一资源授权查询面(不是第二套 membership 判定)。
granted := false
for _, q := range w.fixture.queries {
if q.Actor == "bob" && q.Action == "read" && q.ResourceType == "project" &&
q.ResourceID == core.ID(w.project.ID) && q.ProjectID == core.ID(w.project.ID) && q.AuthorityEpoch >= 1 {
granted = true
}
}
if !granted {
t.Fatalf("read decision did not consume the resource authorization query: %+v", w.fixture.queries)
}
// 会话详情与消息历史(只读)。
rec = lifecycleRequest(t, w.s, bob, http.MethodGet, "/api/conversations/"+string(w.mine.ID), "")
var detail struct {
Conversation core.Conversation `json:"conversation"`
Messages []core.Message `json:"messages"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &detail)
if rec.Code != 200 || string(detail.Conversation.ID) != string(w.mine.ID) || len(detail.Messages) != 1 ||
!bytes.Contains(rec.Body.Bytes(), []byte(removedMemberHistoryProbe)) {
t.Fatalf("removed member own detail=%d %s", rec.Code, rec.Body.String())
}
rec = lifecycleRequest(t, w.s, bob, http.MethodGet, "/api/conversations/"+string(w.mine.ID)+"/export", "")
if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte(removedMemberHistoryProbe)) {
t.Fatalf("removed member own export=%d %s", rec.Code, rec.Body.String())
}
// 不新增可见性:他人会话继续不可见。
for _, q := range []struct{ label, key, path string }{
{"other member session", bob, "/api/conversations/" + string(w.other.ID)},
{"nonmember list", w.keys["mallory"], w.listPath()},
{"nonmember removed member session", w.keys["mallory"], "/api/conversations/" + string(w.mine.ID)},
{"nonmember owner session", w.keys["mallory"], "/api/conversations/" + string(w.other.ID)},
{"owner reading another member session", w.keys["alice"], "/api/conversations/" + string(w.mine.ID)},
} {
if rec := lifecycleRequest(t, w.s, q.key, http.MethodGet, q.path, ""); rec.Code != http.StatusNotFound {
t.Fatalf("%s=%d %s", q.label, rec.Code, rec.Body.String())
}
}
}
// TestRemovedMemberWriteGatesStayClosed 覆盖 (b):写与副作用对 removed 一律拒绝。
func TestRemovedMemberWriteGatesStayClosed(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
bob := w.keys["bob"]
w.s.Bus = cmdbus.New(w.st, nil, func() time.Time { return time.Now() })
w.s.WireBuiltinCommands()
card := core.HITLCard{ID: "hitl-removed-member", ConversationID: w.mine.ID, RunID: "run-removed-member", Kind: "confirm"}
if err := w.st.SaveHITLCard("bob", &card); err != nil {
t.Fatal(err)
}
for _, q := range []struct{ label, method, path, body string }{
{"create conversation", http.MethodPost, w.listPath(), `{"title":"Denied"}`},
{"send message", http.MethodPost, "/api/conversations/" + string(w.mine.ID) + "/messages", `{"text":"` + removedMemberWriteProbe + `"}`},
{"retry", http.MethodPost, "/api/conversations/" + string(w.mine.ID) + "/regenerate", `{}`},
{"resume command", http.MethodPost, "/api/commands/conversation.create", `{"conversationId":"` + string(w.mine.ID) + `"}`},
{"hitl resolve", http.MethodPost, "/api/hitl/hitl-removed-member/resolve", `{"choiceId":"yes"}`},
} {
requireDenied(t, lifecycleRequest(t, w.s, bob, q.method, q.path, q.body), "removed member "+q.label)
}
requireDenied(t, lifecycleUpload(t, w.s, bob, string(w.project.ID), "removed-member.txt", removedMemberWriteProbe), "removed member upload")
// 拒绝必须没有留下任何副作用。
if cs, err := w.st.ListConversations("bob", string(w.project.ID)); err != nil || len(cs) != 1 {
t.Fatalf("denied create wrote a conversation: count=%d error=%v", len(cs), err)
}
if msgs, err := w.st.ListMessages(string(w.mine.ID)); err != nil || len(msgs) != 1 {
t.Fatalf("denied send wrote messages: count=%d error=%v", len(msgs), err)
}
if artifacts, err := w.st.ListProjectArtifactsAll(string(w.project.ID)); err != nil || len(artifacts) != 0 {
t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err)
}
if stored, err := w.st.GetHITLCard("bob", "hitl-removed-member"); err != nil || stored.Resolved {
t.Fatalf("denied hitl resolve changed the card: %+v error=%v", stored, err)
}
}
// TestRemovedMemberWithoutSharedReadGrantStaysDenied 覆盖共享读被收回时读门同样关闭:
// 放行只能来自资源授权查询面的回答,不能因为「成员曾入项目」而无条件放行。
func TestRemovedMemberWithoutSharedReadGrantStaysDenied(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
w.fixture.readAllowed = false
requireDenied(t, lifecycleRequest(t, w.s, w.keys["bob"], http.MethodGet, w.listPath(), ""), "removed member without shared read")
// 本人会话详情仍按会话所有权放行(既有语义,U3 未改动这一条)。
if rec := lifecycleRequest(t, w.s, w.keys["bob"], http.MethodGet, "/api/conversations/"+string(w.mine.ID), ""); rec.Code != 200 {
t.Fatalf("owned session detail=%d %s", rec.Code, rec.Body.String())
}
requireDenied(t, lifecycleRequest(t, w.s, w.keys["bob"], http.MethodPost, w.listPath(), `{}`), "removed member create without shared read")
}
// TestRestoredMemberRegainsSessionReadWrite 覆盖 (d):供应侧再报 active 后读写复原。
func TestRestoredMemberRegainsSessionReadWrite(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
bob := w.keys["bob"]
requireDenied(t, lifecycleRequest(t, w.s, bob, http.MethodPost, w.listPath(), `{}`), "removed member create before restore")
w.fixture.memberState = memberActive
if rec := lifecycleRequest(t, w.s, bob, http.MethodGet, w.listPath(), ""); rec.Code != 200 {
t.Fatalf("restored member list=%d %s", rec.Code, rec.Body.String())
}
rec := lifecycleRequest(t, w.s, bob, http.MethodPost, w.listPath(), `{"title":"Restored"}`)
if rec.Code != 200 {
t.Fatalf("restored member create=%d %s", rec.Code, rec.Body.String())
}
var created core.Conversation
_ = json.Unmarshal(rec.Body.Bytes(), &created)
if string(created.ID) == "" || string(created.Owner) != "bob" || string(created.ProjectID) != string(w.project.ID) {
t.Fatalf("restored create shape=%+v", created)
}
if rec := lifecycleUpload(t, w.s, bob, string(w.project.ID), "restored.txt", "RESTORED"); rec.Code != 200 {
t.Fatalf("restored member upload=%d %s", rec.Code, rec.Body.String())
}
}
+4 -1
View File
@@ -870,7 +870,10 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
if s.projectLifecycle != nil {
if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil {
// Read path (U3): a removed member keeps read access to their own
// sessions when the domain grants a shared-domain read. The store query
// below is still owner-scoped, so no other member's session appears.
if err := s.projectRead(r.Context(), owner, r.PathValue("id")); err != nil {
writeProjectErr(w, err)
return
}