Merge remote-tracking branch 'origin/main' into feature/fast-slow-o2-p1-eval-contract-failfast
governance / governance (pull_request) Successful in 20s
ci / vet-and-test (pull_request) Successful in 1m31s

This commit is contained in:
weichen
2026-10-20 12:04:26 +00:00
10 changed files with 303 additions and 9 deletions
+15 -7
View File
@@ -115,9 +115,14 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
}
scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"},
MatchFields: []string{"owner", "name", "staffNo"}, AllCandidates: true, ScopeVersion: "system.users.v1"}
case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeDocumentDuties:
case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeProjectRoster, extension.AccountDirectoryPurposeDocumentDuties:
// project.member-roster is an explicit enumeration of current project
// members: only the trusted RDM resolver may name them, every account
// status is allowed and an empty query lists the whole roster.
roster := q.Purpose == extension.AccountDirectoryPurposeProjectRoster
if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") ||
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || p.status != "enabled" {
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || (roster && q.DocumentID != "") ||
(!roster && p.status != "enabled") {
return p, store.ErrAccountForbidden
}
h.caps.directory.mu.RLock()
@@ -132,7 +137,7 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
if err != nil {
return p, store.ErrAccountForbidden
}
if q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && scope.AllCandidates {
if (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties || roster) && scope.AllCandidates {
return p, store.ErrAccountForbidden
}
default:
@@ -311,7 +316,10 @@ func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool
func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) {
query := strings.TrimSpace(q.Query)
p, err := h.directoryPolicy(ctx, q, query != "")
// project.member-roster enumerates the resolved members: an empty query
// lists every row inside the scope with normal limit/cursor paging.
enumerate := query == "" && q.Purpose == extension.AccountDirectoryPurposeProjectRoster
p, err := h.directoryPolicy(ctx, q, query != "" || enumerate)
if err != nil {
return extension.AccountDirectoryPage{}, err
}
@@ -326,13 +334,13 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
}
page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version}
if query == "" {
if query == "" && !enumerate {
if q.Cursor != "" {
return extension.AccountDirectoryPage{}, store.ErrConflict
}
return page, nil
}
if q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 {
if query != "" && q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 {
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
}
var after directoryCursor
@@ -344,7 +352,7 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou
}
matched := make([]store.DirectoryAccountRow, 0, q.Limit+1)
for _, row := range p.rows {
if !directoryMatches(row, p.match, q.Mode, query) {
if !enumerate && !directoryMatches(row, p.match, q.Mode, query) {
continue
}
if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) {
+96
View File
@@ -154,6 +154,102 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
}
}
// project.member-roster:可信 RDM 解析方点名的项目成员清单。空 query 枚举作用域内
// 全部行(含已停用账号),limit/游标翻页照旧;非 rdm 插件、带 documentId、
// AllCandidates 解析结果一律拒绝;member-candidate 的空 query 仍是空页。
func TestAccountDirectoryProjectRosterEnumeratesResolvedMembers(t *testing.T) {
st := openDirectoryTestStore(t)
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
alice, err := st.CreatePendingAccount("admin", "alice", store.AccountProfile{Name: "阿丽", StaffNo: "hidden-a"})
if err != nil {
t.Fatal(err)
}
bob, err := st.CreatePendingAccount("admin", "bob", store.AccountProfile{Name: "小博", StaffNo: "hidden-b"})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "局外人", StaffNo: "hidden-x"}); err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
t.Fatal(err)
}
var requests []extension.AccountDirectoryScopeRequest
resolver := func(_ context.Context, req extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
requests = append(requests, req)
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
CandidateOwners: []string{alice.Owner, bob.Owner}, ScopeVersion: "roster-1"}, nil
}
slot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
slot.pluginID, slot.resolver = "rdm", resolver
h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"}
roster := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
Purpose: extension.AccountDirectoryPurposeProjectRoster, ProjectID: "project-1",
Mode: extension.AccountDirectoryModeFuzzy, Status: "all"}
page, err := h.QueryAccountDirectory(context.Background(), roster)
if err != nil {
t.Fatal(err)
}
if len(page.Entries) != 2 || page.NextCursor != "" {
t.Fatalf("roster page=%+v", page)
}
if page.Entries[0] != (extension.AccountDirectoryEntry{UserID: alice.ID, Owner: "alice", Name: "阿丽", Valid: true}) ||
page.Entries[1] != (extension.AccountDirectoryEntry{UserID: bob.ID, Owner: "bob", Name: "小博", Valid: false}) {
t.Fatalf("roster entries=%+v", page.Entries)
}
if len(requests) != 1 || requests[0].Purpose != extension.AccountDirectoryPurposeProjectRoster ||
requests[0].ProjectID != "project-1" || requests[0].DocumentID != "" || requests[0].Actor != "admin" {
t.Fatalf("resolver requests=%+v", requests)
}
// 游标翻页:同一签名游标,limit 1 两页走完。
roster.Limit = 1
first, err := h.QueryAccountDirectory(context.Background(), roster)
if err != nil || len(first.Entries) != 1 || first.Entries[0].UserID != alice.ID || first.NextCursor == "" {
t.Fatalf("roster first page=%+v %v", first, err)
}
roster.Cursor = first.NextCursor
second, err := h.QueryAccountDirectory(context.Background(), roster)
if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" {
t.Fatalf("roster second page=%+v %v", second, err)
}
roster.Cursor, roster.Limit = "", 0
// 非检索目的的空 query 依旧空页:member-candidate 行为不变。
roster.Purpose, roster.Status = extension.AccountDirectoryPurposeProjectMembers, "enabled"
if page, err := h.QueryAccountDirectory(context.Background(), roster); err != nil || len(page.Entries) != 0 {
t.Fatalf("member candidate empty query=%+v %v", page, err)
}
roster.Purpose = extension.AccountDirectoryPurposeProjectRoster
// 非 rdm 插件、documentId 限定、全量作用域解析结果:均拒绝。
other := h
other.pluginID = "genui"
if _, err := other.QueryAccountDirectory(context.Background(), roster); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("non-rdm roster: %v", err)
}
withDocument := roster
withDocument.DocumentID = "doc-1"
if _, err := h.QueryAccountDirectory(context.Background(), withDocument); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("document roster: %v", err)
}
wideSlot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
wideSlot.pluginID, wideSlot.resolver = "rdm", func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
AllCandidates: true, ScopeVersion: "roster-wide"}, nil
}
wide := h
wide.caps.directory = wideSlot
if _, err := wide.QueryAccountDirectory(context.Background(), roster); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("all-candidate roster: %v", err)
}
}
func TestPGDirectoryCursorContinuesInAnotherProcess(t *testing.T) {
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
if baseDSN == "" {
+42
View File
@@ -565,6 +565,48 @@ func (h inprocHost) ListRoles(ctx context.Context) ([]extension.RoleAdminRecord,
}
}
// ListRoleOptions is the read-only role directory for non-admin dispatch
// actors: enabled roles only, projected as roleId+name, sorted by name then
// roleId. It never requires system.roles.view and never exposes permissions,
// bindings or counts; callers still authorize their own domain action.
func (h inprocHost) ListRoleOptions(ctx context.Context) ([]extension.RoleOption, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := h.requireEffective(); err != nil {
return nil, err
}
if _, err := h.rolePolicyActor(); err != nil {
return nil, err
}
result := []extension.RoleOption{}
cursor := ""
for {
roles, next, err := h.caps.st.ListRoles("", "fuzzy", "all", "", cursor, 100)
if err != nil {
return nil, err
}
for _, role := range roles {
if !role.Enabled {
continue
}
result = append(result, extension.RoleOption{RoleID: role.RoleID, Name: role.Name})
}
if next == "" {
sort.Slice(result, func(i, j int) bool {
if result[i].Name != result[j].Name {
return result[i].Name < result[j].Name
}
return result[i].RoleID < result[j].RoleID
})
return result, nil
}
cursor = next
}
}
var _ extension.RoleOptionsProvider = inprocHost{}
func (h inprocHost) CreateRole(ctx context.Context, name, description string) (extension.RoleAdminRecord, error) {
if err := ctx.Err(); err != nil {
return extension.RoleAdminRecord{}, err
+68
View File
@@ -130,3 +130,71 @@ func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T)
t.Fatalf("roles=%+v err=%v", roles, err)
}
}
// 非管理员分发主体(项目创建者)只读已启用职位:roleId+name,按 name→roleId 排序,
// 不含权限/绑定/计数;空 actor(scoped host)与已停用账号一律拒绝。
func TestRoleOptionsExposeEnabledRolesToNonAdminActor(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/role-options.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, err := st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
beta, err := st.CreateRole("admin", store.RoleCreate{Name: "Beta 职位", Description: "停用前"})
if err != nil {
t.Fatal(err)
}
alpha, err := st.CreateRole("admin", store.RoleCreate{Name: "Alpha 职位"})
if err != nil {
t.Fatal(err)
}
retired, err := st.CreateRole("admin", store.RoleCreate{Name: "Gamma 职位"})
if err != nil {
t.Fatal(err)
}
disabled := false
if _, err := st.PatchRole("admin", retired.RoleID, retired.RoleVersion, store.RolePatch{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: "alice"}
options, ok := any(h).(extension.RoleOptionsProvider)
if !ok {
t.Fatal("dispatch host missing RoleOptionsProvider")
}
// 管理面仍然拒绝非管理员:只读目录不放大权限。
if _, err := any(h).(extension.RolePolicyAdmin).ListRoles(context.Background()); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("non-admin ListRoles: %v", err)
}
got, err := options.ListRoleOptions(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0] != (extension.RoleOption{RoleID: alpha.RoleID, Name: "Alpha 职位"}) ||
got[1] != (extension.RoleOption{RoleID: beta.RoleID, Name: "Beta 职位"}) {
t.Fatalf("options=%+v", got)
}
scoped := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice"}
if _, err := any(scoped).(extension.RoleOptionsProvider).ListRoleOptions(context.Background()); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("empty actor options: %v", err)
}
if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); err != nil {
t.Fatal(err)
}
if _, err := options.ListRoleOptions(context.Background()); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled actor options: %v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := options.ListRoleOptions(cancelled); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled context: %v", err)
}
}
+51 -1
View File
@@ -115,16 +115,66 @@ func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
t.Fatalf("/new=%d %s", w.Code, w.Body.String())
}
ready = false
for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} {
for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} {
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
if w.Code != 404 {
t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String())
}
}
// 已被移出项目的会话属主:发送消息答 403 not-project-member(不再谎称找不到会话)。
if msgs, err := st.ListMessages(ac.ID); err != nil || len(msgs) != 0 {
t.Fatalf("revoked history unreadable: %v %v", msgs, err)
}
if w := lifecycleRequest(t, s, alice, "POST", "/api/conversations/"+ac.ID+"/messages", `{"text":"new work"}`); w.Code != 403 ||
!bytes.Contains(w.Body.Bytes(), []byte("not-project-member")) {
t.Fatalf("revoked send=%d %s", w.Code, w.Body.String())
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 {
t.Fatalf("owned revoked history=%d", w.Code)
}
}
// 仍是会话属主、但项目工作授权被拒(被移出项目)时,发送消息必须说清
// not-project-member 而不是 404,且不落库。
func TestProjectLifecycleRemovedMemberMessageIsForbidden(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Shared")
conv, _ := st.CreateConversation("alice", p.ID, "Alice private", "")
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
member := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return member && actor == "alice" && id == core.ID(p.ID), nil
}}, nil)
if w := lifecycleRequest(t, s, alice, "POST", "/api/conversations/"+conv.ID+"/messages", `{"text":"仍在项目内"}`); w.Code != 200 {
t.Fatalf("member send=%d %s", w.Code, w.Body.String())
}
if msgs, err := st.ListMessages(conv.ID); err != nil || len(msgs) == 0 {
t.Fatalf("member message missing: %v %v", msgs, err)
}
member = false
w := lifecycleRequest(t, s, alice, "POST", "/api/conversations/"+conv.ID+"/messages", `{"text":"已被移出项目"}`)
if w.Code != 403 || !bytes.Contains(w.Body.Bytes(), []byte("not-project-member")) {
t.Fatalf("removed member send=%d %s", w.Code, w.Body.String())
}
msgs, err := st.ListMessages(conv.ID)
if err != nil {
t.Fatal(err)
}
for _, msg := range msgs {
if msg.Role != core.RoleUser {
continue
}
for _, part := range msg.Parts {
if part.Type == core.PartText && part.Text == "已被移出项目" {
t.Fatalf("denied send stored a message: %+v", msg)
}
}
}
if w := lifecycleRequest(t, s, alice, "GET", "/api/conversations/"+conv.ID, ""); w.Code != 200 {
t.Fatalf("owned conversation unreadable after removal=%d %s", w.Code, w.Body.String())
}
}
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
+6
View File
@@ -1049,6 +1049,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
// 已拥有并可读该会话、却已被移出项目的成员:说明“不再是项目成员”
// 比“找不到会话”更可行动,且不泄露会话是否存在。
if conv.ProjectID != "" && errors.Is(err, store.ErrNotFound) {
http.Error(w, "conversation: not-project-member", http.StatusForbidden)
return
}
writeWorkFailure(w, extension.WorkOperationConversation, err)
return
}
+1
View File
@@ -7,6 +7,7 @@ const AccountDirectorySchemaVersion = 1
const (
AccountDirectoryPurposeSystemUsers = "system.users"
AccountDirectoryPurposeProjectMembers = "project.member-candidate"
AccountDirectoryPurposeProjectRoster = "project.member-roster"
AccountDirectoryPurposeDocumentDuties = "document.duty-candidate"
AccountDirectoryModeExact = "exact"
AccountDirectoryModeFuzzy = "fuzzy"
+5 -1
View File
@@ -71,7 +71,11 @@ import (
// (CapabilityExplain / MemberBasicCollaborate / DocumentQualify for the
// Host-bound actor) and categorized WorkFailure for create, member add and
// conversation work.
const SurfaceVersion = "2.27.0-experimental"
// 2.28.0-experimental: optional RoleOptionsProvider (read-only enabled role
// directory: RoleOption roleId+name, no permissions or bindings) and the
// AccountDirectory purpose project.member-roster, an explicit project member
// enumeration inside the trusted RDM scope.
const SurfaceVersion = "2.28.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
+15
View File
@@ -56,6 +56,21 @@ type RolePolicyProvider interface {
CheckRoleAction(context.Context, string, string) (RoleActionDecision, error)
}
// RoleOption is the public directory projection of one enabled role: a stable
// identifier and its display name only. It never carries permissions,
// bindings or counts, so a non-admin caller learns nothing about authority.
type RoleOption struct {
RoleID string `json:"roleId"`
Name string `json:"name"`
}
// RoleOptionsProvider is optional on a trusted dispatch Host. The Host binds
// the actor; callers still authorize their own domain action before offering
// the options.
type RoleOptionsProvider interface {
ListRoleOptions(context.Context) ([]RoleOption, error)
}
type SystemActionDecision struct {
ActionID string `json:"actionId"`
Allowed bool `json:"allowed"`
+4
View File
@@ -9,6 +9,7 @@ AccountDirectoryProvider.LookupAccountDirectoryOwner
AccountDirectoryProvider.QueryAccountDirectory
AccountDirectoryPurposeDocumentDuties
AccountDirectoryPurposeProjectMembers
AccountDirectoryPurposeProjectRoster
AccountDirectoryPurposeSystemUsers
AccountDirectoryQuery
AccountDirectoryRegistrar
@@ -206,6 +207,9 @@ RoleAuthorityImportProjection
RoleAuthorityImportResult
RoleAuthorityImportRole
RoleBindingResult
RoleOption
RoleOptionsProvider
RoleOptionsProvider.ListRoleOptions
RolePermission
RolePolicyAdmin
RolePolicyAdmin.ActivateRoleAuthorityImport