Compare commits

...
Author SHA1 Message Date
weichen 50bc120fed Merge remote-tracking branch 'origin/main' into fix/role-project-scope-permissions
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 2m33s
2026-10-23 08:49:30 +00:00
agent-exec-transport e7b345e127 Carry project-scope defaults for roles with project management
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m52s
Why: After the role-authority cutover a role could only carry the single
project.create boolean, so every project-scope action resolved as undefined and
project conversations plus member collaboration were rejected. Owner product
intent: a role carries one project-management toggle, and holding it grants the
in-project defaults (read, write, members, traces, stage, archive, audit);
three-duty and knowledge publishing stay duty-gated.

Evidence: go test ./internal/store ./internal/app ./internal/server -count=1
passes; TestRoleCarriesProjectScopeDefaults covers the write, the snapshot
projection and the unchanged invariant that a role enabling project.create may
not deny a linked management action. The agent-exec transport diff is identical
to the owner-accepted revision deployed on imc-tx-ecs (binary 941f8eef).

Impact: ReplaceRolePermissions accepts project-scope actions while protected
system actions stay refused, RolePermissions projects project-scope rows plus
the project-management implied defaults into the role policy snapshot, and an
explicit capability-association gate runs before each write.

Refs: feature/role-project-scope-permissions
2026-10-23 08:47:11 +00:00
weichen 8007e6028f Merge pull request 'Integrate recall-eval stack, endpoint migration and command governance line' (#249) from integration/gitea-endpoint-migration into main
governance / governance (push) Successful in 21s
ci / vet-and-test (push) Successful in 2m39s
2026-10-23 08:27:20 +00:00
agent-exec-transport 605a6a1acd agent-exec transport e9d94e119f97160ecaacf3684ac0c472/2a00d532384e
governance / governance (pull_request) Successful in 33s
ci / vet-and-test (pull_request) Successful in 2m56s
2026-10-23 08:15:21 +00:00
agent-exec-transport 32ae91e8d9 agent-exec transport ae6850a4e3e4d28ecaa38f92061f2782/f8e97c931854 2026-10-23 08:02:29 +00:00
weichen 96393b77f7 Integration: endpoint migration into the recall-eval stack
Why: combine the in-flight recall-eval stack with the endpoint
migration so superproject pins can advance safely.

Evidence: host-verified; see Dev PR #706

Impact: integration commit.

Refs: Agent-System-Dev PR #706

# Conflicts:
#	deps/agent-intent
#	deps/agent-native
#	deps/agent-squad
2026-10-23 04:30:03 +00:00
weichen bd5c0f39e6 Pin nested intent, native and squad to the mis-execution fix tips
Why: composition consistency - Dev pins the three containers at their
negation-aware tips; the backend container's nested copies must match
(check-pins).
Evidence: pin only.
Impact: nested gitlink pointer changes.
Refs: feature/recall-misexec-fixes
2026-10-23 04:05:44 +00:00
weichen b1713173ef Merge pull request 'Align Backend to the current dependency APIs' (#248) from fix/deps-api-alignment into main
governance / governance (push) Successful in 24s
ci / vet-and-test (push) Successful in 2m30s
2026-10-23 04:04:40 +00:00
agent-exec-transport 56700f0a26 agent-exec transport 2db9c40c541363422c723d1144447eef/125a24e9759a
governance / governance (pull_request) Successful in 28s
ci / vet-and-test (pull_request) Successful in 2m32s
2026-10-23 01:54:26 +00:00
weichen 8dc91c1783 Pin nested intent, native and squad to the A3 tips
Why: composition consistency - Dev pins the three containers at their
A3 evaluation/repair tips; the backend container's nested copies must
match (check-pins).
Evidence: pin only.
Impact: nested gitlink pointer changes.
Refs: feature/catalog-recall-eval-a3
2026-10-23 01:39:59 +00:00
weichen a2e9f003bc Merge pull request 'Update Gitea endpoints after server migration' (#247) from fix/gitea-endpoint-migration into main
governance / governance (push) Successful in 19s
ci / vet-and-test (push) Successful in 2m38s
2026-10-22 15:43:18 +00:00
weichen 2da3d76ce1 Move the published renderer pin to the migrated renderer main
governance / governance (pull_request) Successful in 25s
ci / vet-and-test (pull_request) Successful in 2m23s
Why: the document-renderer pin moved to its migrated main for the
endpoint migration; identity regenerated and the published-pin
expectation updated to match.

Evidence: go test ./internal/docgen → ok (local, host-verified)

Impact: generated identity + test constant; part of the coordinated
endpoint migration.

Refs: Agent-System-Dev PR #706
2026-10-22 15:34:06 +00:00
weichen ea497ed149 Regenerate the renderer identity for the migrated renderer pin
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Failing after 2m31s
Why: the document-renderer pin moved to its migrated main; the
checked-in identity must match or docgen tests fail.

Evidence: host-verified; see Dev PR #706

Impact: generated constant only.

Refs: Agent-System-Dev PR #706
2026-10-22 15:23:24 +00:00
weichen e853260bc7 Track the updated squad compat branch
governance / governance (pull_request) Successful in 30s
ci / vet-and-test (pull_request) Failing after 2m54s
Why: squad compat now pins the intent compat branch for recursion.

Evidence: host-verified; see Dev PR #706

Impact: pin update only.

Refs: Agent-System-Dev PR #706
2026-10-22 15:13:48 +00:00
weichen 1f952a7ef7 Pin legacy-line deps to endpoint-migration compat branches
governance / governance (pull_request) Successful in 29s
ci / vet-and-test (pull_request) Failing after 1m24s
Why: these deps' latest mains moved APIs beyond what this branch
compiles against; the compat branches carry migrated URLs at the
pinned API eras.

Evidence: host-verified; see Dev PR #706

Impact: pin updates only.

Refs: Agent-System-Dev PR #706
2026-10-22 15:05:02 +00:00
weichen aee1d9bcf2 Bump migrated dependency pins for the endpoint migration
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Failing after 1m56s
Why: recursive submodule checkout reads pinned dep commits; their
.gitmodules now carries the new Gitea endpoint base only in the
migrated mains.

Evidence: host-verified; see Dev PR #706

Impact: pin updates only.

Refs: Agent-System-Dev PR #706
2026-10-22 14:54:13 +00:00
weichen 503ae000e0 Bump migrated dependency pins for the endpoint migration
governance / governance (pull_request) Successful in 39s
ci / vet-and-test (pull_request) Failing after 42s
Why: recursive submodule checkout reads pinned dep commits; their
.gitmodules now carries the new Gitea endpoint base only in the
migrated mains.

Evidence: host-verified; see Dev PR #706

Impact: pin updates only, part of the coordinated endpoint migration.

Refs: Agent-System-Dev PR #706
2026-10-22 14:36:15 +00:00
weichen 392bb68506 Bump the development-manager pin for the endpoint migration
ci / vet-and-test (pull_request) Failing after 1m1s
governance / governance (pull_request) Successful in 1m1s
Why: governance checks validate candidate .gitmodules URLs against the
pinned development-manager scaffold, which now carries the new Gitea
endpoint base after its own migration.

Evidence: host-verified; see Dev PR #706

Impact: pin update only, part of the coordinated endpoint migration.

Refs: Agent-System-Dev PR #706
2026-10-22 14:25:42 +00:00
weichen 30e18af70e Update Gitea endpoints after server migration
ci / vet-and-test (pull_request) Failing after 27s
governance / governance (pull_request) Failing after 34s
Why: the Gitea server moved to https://gitea.naitangbaobao.cn with SSH
on port 52222; endpoint references must follow or superproject CI
checkout refuses the old URLs.

Evidence: host-verified API reach and SSH auth (Dev task card
fix/gitea-endpoint-migration)

Impact: configuration only; no runtime behavior change.

Refs: Dev PR #706
2026-10-22 14:16:47 +00:00
34 changed files with 665 additions and 330 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
echo "SUBMODULE_TOKEN is not configured" >&2
exit 1
fi
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
git config --global url."https://weichen:${SUBMODULE_TOKEN}@gitea.naitangbaobao.cn/".insteadOf "ssh://git@gitea.naitangbaobao.cn:52222/"
git -c fetch.recurseSubmodules=false submodule update --init --recursive
- name: Install Go 1.27.1
run: |
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
+10 -10
View File
@@ -1,30 +1,30 @@
[submodule "deps/agent-system-core"]
path = deps/agent-system-core
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-System-Core.git
[submodule "deps/agent-native"]
path = deps/agent-native
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Native.git
[submodule "deps/agent-intent"]
path = deps/agent-intent
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git
[submodule "deps/logging-system"]
path = deps/logging-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Logging-System.git
[submodule "deps/error-code-system"]
path = deps/error-code-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Error-Code-System.git
[submodule "deps/agent-squad"]
path = deps/agent-squad
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Squad.git
[submodule "deps/agent-command"]
path = deps/agent-command
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Command.git
[submodule "deps/agent-document-renderer"]
path = deps/agent-document-renderer
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Document-Renderer.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Document-Renderer.git
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git
+3 -3
View File
@@ -23,9 +23,9 @@
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
- **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
@@ -33,7 +33,7 @@
(baseline 硬边界 2:只记录来源、字段与可用状态)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
+33 -7
View File
@@ -1,6 +1,7 @@
package app
import (
"agent-system-backend/internal/authz/catalog"
"context"
"errors"
"testing"
@@ -53,16 +54,33 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) {
t.Fatalf("snapshot=%+v", snapshot)
}
permission := snapshot.Permissions["project.create"]
if len(snapshot.Permissions) != 1 || !permission.Allowed || !permission.CapAllowed {
if !permission.Allowed || !permission.CapAllowed {
t.Fatalf("permissions=%+v", snapshot.Permissions)
}
// 「项目管理」一个开关即带出项目内工作默认;三权与知识发布不在其中。
for _, actionID := range catalog.ProjectManagementImpliedActions() {
row, ok := snapshot.Permissions[actionID]
if !ok || !row.Allowed || !row.CapAllowed {
t.Fatalf("implied %s missing: %+v", actionID, snapshot.Permissions)
}
}
for _, duty := range []string{"document.review", "document.approve", "document.publish", "document.duties.assign", "knowledge.publish"} {
if row, ok := snapshot.Permissions[duty]; ok && row.Allowed {
t.Fatalf("duty %s implied: %+v", duty, snapshot.Permissions)
}
}
decision, err := provider.CheckRoleAction(context.Background(), alice.ID, "project.create")
if err != nil || !decision.Allowed || !decision.CapAllowed || decision.RoleID != role.RoleID {
t.Fatalf("decision=%+v err=%v", decision, err)
}
legacy, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read")
if err != nil || legacy.Allowed || legacy.Reason != "action-unconfigured" {
t.Fatalf("non-project role action=%+v err=%v", legacy, err)
// 项目内工作动作随「项目管理」默认放行;三权仍按未配置处理。
implied, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read")
if err != nil || !implied.Allowed || !implied.CapAllowed {
t.Fatalf("implied project action=%+v err=%v", implied, err)
}
duty, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.publish")
if err != nil || duty.Allowed || duty.Reason != "action-unconfigured" {
t.Fatalf("duty action=%+v err=%v", duty, err)
}
systemProvider, ok := any(h).(extension.SystemActionProvider)
if !ok {
@@ -128,14 +146,22 @@ func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T)
if err != nil {
t.Fatal(err)
}
if _, err := admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "legacy-action"); !errors.Is(err, store.ErrInvalidRolePolicy) {
t.Fatalf("non-project role action accepted: %v", err)
// 系统范围动作仍然拒绝;项目范围动作随职位定义。
if _, err := admin.SetRolePermission(context.Background(), role.RoleID, "system.settings.write", true, true, "legacy-action"); !errors.Is(err, store.ErrInvalidRolePolicy) {
t.Fatalf("system role action accepted: %v", err)
}
role, err = admin.SetRolePermission(context.Background(), role.RoleID, "project.create", true, true, "project-access")
access := role.Permissions["project.create"]
if err != nil || len(role.Permissions) != 1 || !access.Allowed || !access.CapAllowed {
if err != nil || !access.Allowed || !access.CapAllowed {
t.Fatalf("role=%+v err=%v", role, err)
}
role, err = admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "project-scope")
if err != nil {
t.Fatalf("project scope action: %v", err)
}
if read := role.Permissions["document.read"]; !read.Allowed || !read.CapAllowed {
t.Fatalf("project scope permissions=%+v", role.Permissions)
}
binding, err := admin.AssignRoleByOwner(context.Background(), "alice", role.RoleID, "legacy-assign")
if err != nil || binding.RoleID != role.RoleID {
t.Fatalf("binding=%+v err=%v", binding, err)
+28
View File
@@ -96,3 +96,31 @@ func IsProjectCreateLinkedAction(id string) bool {
}
return false
}
// projectManagementImpliedActions 是「项目管理」蕴含的项目内工作默认:拿到项目管理的
// 账号在自己参与的项目里可以直接读写文档、管成员、看追溯、推进阶段。三权与知识发布
// (document.review/approve/publish/duties.assign、knowledge.publish)不在其中——它们
// 仍按项目内的职责分配与三权约束判定。
var projectManagementImpliedActions = []string{
"document.read",
"document.author",
"project.stage.advance",
"project.archive",
"project.audit.read",
"trace.edit",
}
// ProjectManagementImpliedActions 返回「项目管理」开启时随角色投影的项目内默认动作。
func ProjectManagementImpliedActions() []string {
return append([]string(nil), projectManagementImpliedActions...)
}
// IsProjectManagementImpliedAction 报告该动作是否随「项目管理」默认放行。
func IsProjectManagementImpliedAction(id string) bool {
for _, action := range projectManagementImpliedActions {
if action == id {
return true
}
}
return false
}
+7 -4
View File
@@ -37,6 +37,9 @@ func (f *fencedAuditStore) AppendAuditAccountChecked(string, agentcommand.AuditE
// TestStrictAccountWriteAuditLossKeepsOperationID:围栏档下 handler 已提交、
// 审计写失败时,回执仍是 unknown 且带上 handler 拿到的 operationID,
// 调用方才能在围栏外按记录对账(F10 Bug 2 在围栏路径上的同一语义)。
//
// 上游 agent-command main 移除了 ExecReceipt.SideEffects 与 NewUnknownAfterExecution,
// 且契约禁止 unknown 携带 OperationID;对账句柄改由 Reason 承载(见 cmdbus.unknownReceipt)。
func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) {
audit := &fencedAuditStore{}
b := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
@@ -52,11 +55,11 @@ func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) {
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") {
t.Fatalf("fenced audit loss must stay unknown: %+v", resp.Receipt)
}
if seen == "" || resp.Receipt.OperationID != seen {
t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
if seen == "" || resp.Receipt.OperationID != "" || !strings.Contains(resp.Receipt.Reason, seen) {
t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen)
}
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil {
t.Fatalf("fenced receipt must declare possible side effects and validate: %+v", resp.Receipt)
if err := resp.Receipt.Validate(); err != nil {
t.Fatalf("fenced receipt must satisfy the library contract: %v", err)
}
if audit.appends != 1 {
t.Fatalf("account-checked audit appends = %d, want 1", audit.appends)
+21 -27
View File
@@ -558,17 +558,17 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
// rejected(回执契约禁止 rejected 携带 operationID)。
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
}
// handler 已开始执行才报错:宿主无法断言"无副作用"(F10)。按"已执行
// 随后不确定"归 unknown,并带上本次分发已签发的 trusted operation ID,
// 调用方据此按记录对账,而不是盲目重试。本路径不做任何自动重放。
// handler 已开始执行才报错:宿主无法断言"无副作用"。按"已执行随后不确定"
// 归 unknown,并带上本次分发已签发的 operation ID(写在回执说明里,理由见
// unknownReceipt),调用方据此按记录对账,而不是盲目重试。本路径不做任何重放。
return b.unknownAfterExecution(req, "handler failed after execution began: "+err.Error(), start)
}
return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, req.OperationID, "", start), Payload: payload}
}
// unknownAfterExecution 记录并返回"已执行随后不确定"的回执(F10/U7b):审计
// 信封带 state=unknown 与已签发的 operationID(可查凭证),回执走库内受信
// 构造器 NewUnknownAfterExecution,保证 Validate() 通过且对账句柄不丢。
// unknownAfterExecution 记录并返回"已执行随后不确定"的回执:审计信封带
// state=unknown 与已签发的 operationID(可查凭证),回执的说明字段带上同一个
// operationID,保证 Validate() 通过且对账句柄不丢(口径见 unknownReceipt)。
func (b *Bus) unknownAfterExecution(req Request, reason string, start time.Time) Response {
return Response{Receipt: b.auditReceipt(req, agentcommand.StateUnknown, req.OperationID, reason, start)}
}
@@ -591,7 +591,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
p, ok := b.pending[cardID]
b.pendMu.Unlock()
if ok && p.req.Owner != "" && p.req.Owner != owner {
return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
}
if b.pendingStore != nil {
storedOwner, payload, found, err := b.takePending(cardID, owner)
@@ -601,7 +601,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
if found {
var req Request
if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil {
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", Time: b.now().UTC()}}
}
p = pending{req: req, cardID: cardID}
ok = true
@@ -612,11 +612,11 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
ok = false
}
if !ok {
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}}
}
req := p.req
if req.Owner != "" && req.Owner != owner {
return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
}
b.pendMu.Lock()
delete(b.pending, cardID)
@@ -677,7 +677,7 @@ func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error {
func (b *Bus) accountRejected(commandID string, start time.Time) Response {
return Response{Receipt: agentcommand.ExecReceipt{
CommandID: commandID, State: agentcommand.StateRejected,
Reason: "effective account unavailable", SideEffects: agentcommand.SideEffectsNone, Time: start.UTC(),
Reason: "effective account unavailable", Time: start.UTC(),
}}
}
@@ -711,32 +711,26 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas
return unknownReceipt(req.ID, state, op, "audit unavailable: "+err.Error(), b.now().UTC())
}
if state == agentcommand.StateUnknown && op != "" {
// 已执行随后不确定:只有库内受信构造器允许 unknown 携带本流程签发的 ID。
return agentcommand.NewUnknownAfterExecution(req.ID, op, reason, b.now().UTC())
// 已执行随后不确定:对账句柄随说明一起给出(unknownReceipt)。
return unknownReceipt(req.ID, state, op, reason, b.now().UTC())
}
return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason,
SideEffects: sideEffectsFor(state), Time: b.now().UTC()}
Time: b.now().UTC()}
}
// unknownReceipt 构造"结果未知"回执:已签发过操作标识时走受信构造器携带该标识
// (供对账),否则返回不带标识的 unknown。state=rejected 恒不带标识。
// unknownReceipt 构造"结果未知"回执。
//
// 上游 command.ExecReceipt 已移除 NewUnknownAfterExecution,且现行契约禁止
// StateUnknown 携带 OperationID(Validate 直接拒绝)。最接近的等价表达:已签发过
// 操作标识时把该标识写进 Reason(unknown 的稳定说明字段),状态与文案语义不变,
// 对账句柄不丢;state=rejected 恒不带标识。
func unknownReceipt(commandID string, state agentcommand.ResultState, op, reason string, at time.Time) agentcommand.ExecReceipt {
if op != "" && state != agentcommand.StateRejected {
return agentcommand.NewUnknownAfterExecution(commandID, op, reason, at)
reason += " (operation " + op + ")"
}
return agentcommand.ExecReceipt{CommandID: commandID, State: agentcommand.StateUnknown, Reason: reason, Time: at}
}
// sideEffectsFor 回执的副作用声明:总线自身发起的 rejected 均为任何副作用之前
// 的拒绝(未知命令/参数校验/项目前置/账号预检/无处理器/确认取消/handler 声明的
// not-executed),故断言 none;committed/accepted 维持今日语义(未声明)。
func sideEffectsFor(state agentcommand.ResultState) agentcommand.SideEffects {
if state == agentcommand.StateRejected {
return agentcommand.SideEffectsNone
}
return agentcommand.SideEffectsUnset
}
func digest(params map[string]any) string {
if len(params) == 0 {
return ""
+35 -20
View File
@@ -119,9 +119,8 @@ func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) {
if err := failed.Receipt.Validate(); err != nil {
t.Fatalf("rejected receipt must satisfy the library contract: %v", err)
}
if failed.Receipt.SideEffects != agentcommand.SideEffectsNone {
t.Fatalf("rejected receipt must declare no side effects, got %q", failed.Receipt.SideEffects)
}
// 上游 agent-command main 移除了 ExecReceipt.SideEffects(“确定无副作用”
// 的显式声明),rejected 不得携带 operationID 这条库契约即是等价断言。
}
func TestOperationIDsRemainUniqueWithFixedClockAndConcurrentDispatch(t *testing.T) {
@@ -379,9 +378,28 @@ func (q *memOperationQuery) recordAudit(owner, projectID string, env agentcomman
}
}
// operationHandle 取回 unknown 回执给出的对账句柄。agent-command main 契约禁止
// StateUnknown 携带 OperationID,句柄改写在 Reason 里("(operation <id>)"),
// 这里统一读出来供断言与按记录对账使用。
func operationHandle(r agentcommand.ExecReceipt) string {
if r.OperationID != "" {
return r.OperationID
}
const marker = " (operation "
i := strings.Index(r.Reason, marker)
if i < 0 || !strings.HasSuffix(r.Reason, ")") {
return ""
}
return r.Reason[i+len(marker) : len(r.Reason)-1]
}
// TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID 是 F10 的核心回归:
// handler 已经写库、之后才失败时,回执必须是 unknown 且携带它拿到的
// operationID,理由点明"执行开始后失败",绝不退化成 rejected(会诱发不安全重试)。
//
// 上游 agent-command main 移除了 ExecReceipt.SideEffects 与 NewUnknownAfterExecution,
// 且 Validate 禁止 unknown 携带 OperationID;对账句柄改由 Reason 承载(见
// cmdbus.unknownReceipt),断言随之改写到新等价行为。
func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
@@ -398,17 +416,14 @@ func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
if resp.Receipt.State != agentcommand.StateUnknown {
t.Fatalf("post-execution failure must not be reported as rejected: %+v", resp.Receipt)
}
if seen == "" || resp.Receipt.OperationID != seen {
t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
if seen == "" || resp.Receipt.OperationID != "" || !strings.Contains(resp.Receipt.Reason, seen) {
t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen)
}
if !strings.Contains(resp.Receipt.Reason, "after execution began") {
t.Fatalf("reason must name post-execution uncertainty: %q", resp.Receipt.Reason)
}
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible {
t.Fatalf("side effects = %q, want %q", resp.Receipt.SideEffects, agentcommand.SideEffectsPossible)
}
if err := resp.Receipt.Validate(); err != nil {
t.Fatalf("trusted unknown receipt must satisfy the library contract: %v", err)
t.Fatalf("unknown receipt must satisfy the library contract: %v", err)
}
if len(writes) != 1 {
t.Fatalf("handler ran %d times, want 1", len(writes))
@@ -419,7 +434,7 @@ func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
}
// 宿主不得自动重放:再次分发是一次新操作(新的 operationID),旧的不被复用。
again := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"})
if again.Receipt.State != agentcommand.StateUnknown || again.Receipt.OperationID == resp.Receipt.OperationID || len(writes) != 2 {
if again.Receipt.State != agentcommand.StateUnknown || operationHandle(again.Receipt) == operationHandle(resp.Receipt) || len(writes) != 2 {
t.Fatalf("re-dispatch must be a new operation, not a replay: %+v writes=%d", again.Receipt, len(writes))
}
}
@@ -444,8 +459,8 @@ func TestHandlerNotExecutedErrorStaysRejected(t *testing.T) {
if resp.Receipt.Reason != "rejected: permission denied" {
t.Fatalf("not-executed marking must not distort the reason: %q", resp.Receipt.Reason)
}
if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || resp.Receipt.Validate() != nil {
t.Fatalf("rejected receipt must declare no side effects and validate: %+v", resp.Receipt)
if err := resp.Receipt.Validate(); err != nil {
t.Fatalf("rejected receipt must satisfy the library contract: %v", err)
}
if calls != 1 || len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" {
t.Fatalf("unexpected effects: calls=%d audits=%+v", calls, audit.logs)
@@ -477,11 +492,11 @@ func TestCommittedWithAuditFailureKeepsOperationID(t *testing.T) {
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") {
t.Fatalf("audit loss must stay unknown and name the audit: %+v", resp.Receipt)
}
if seen == "" || resp.Receipt.OperationID != seen {
t.Fatalf("audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
if seen == "" || resp.Receipt.OperationID != "" || !strings.Contains(resp.Receipt.Reason, seen) {
t.Fatalf("audit loss dropped the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen)
}
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil {
t.Fatalf("audit-loss receipt must declare possible side effects and validate: %+v", resp.Receipt)
if err := resp.Receipt.Validate(); err != nil {
t.Fatalf("audit-loss receipt must satisfy the library contract: %v", err)
}
if len(audit.logs) != 0 {
t.Fatalf("audit rows written while failing: %+v", audit.logs)
@@ -512,8 +527,8 @@ func TestValidationRejectionNeverRunsHandler(t *testing.T) {
if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" || resp.Receipt.Validate() != nil {
t.Fatalf("validation rejection: %+v", resp.Receipt)
}
if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || calls != 0 {
t.Fatalf("validation rejection ran the handler or claimed effects: calls=%d %+v", calls, resp.Receipt)
if calls != 0 {
t.Fatalf("validation rejection ran the handler: calls=%d %+v", calls, resp.Receipt)
}
if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" {
t.Fatalf("validation audit row: %+v", audit.logs)
@@ -565,14 +580,14 @@ func TestLookupOperationIsOwnerProjectScoped(t *testing.T) {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Owner: "alice", Actor: "alice"})
if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" {
if resp.Receipt.State != agentcommand.StateUnknown || operationHandle(resp.Receipt) == "" {
t.Fatalf("setup dispatch: %+v", resp.Receipt)
}
// 对账记录=总线真实落下的审计信封(同一 owner/project 快照)。
for _, env := range audit.logs {
query.recordAudit("alice", "proj_1", env)
}
op := resp.Receipt.OperationID
op := operationHandle(resp.Receipt)
record, err := bus.LookupOperation("alice", "proj_1", op)
if err != nil {
t.Fatalf("owner-scoped lookup: %v", err)
+2 -1
View File
@@ -45,6 +45,7 @@ func (p *failingPendingStore) TakePendingConfirm(string) (string, string, bool,
// TestConfirmPendingWriteFailureKeepsCardOperationID:挂起态落盘失败不得吞掉操作
// 标识——卡 ID 就是本次受理的句柄,unknown 必须带出去(否则受理事实无凭证)。
// agent-command main 契约禁止 unknown 携带 OperationID,句柄随 Reason 给出。
func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) {
audit, cards := &memAudit{}, &memCards{}
b := newTestBus(audit, cards)
@@ -55,7 +56,7 @@ func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) {
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "confirm pending") {
t.Fatalf("pending write failure: %+v", resp.Receipt)
}
if resp.Receipt.OperationID != "card_x1" || resp.Receipt.Validate() != nil {
if resp.Receipt.OperationID != "" || operationHandle(resp.Receipt) != "card_x1" || resp.Receipt.Validate() != nil {
t.Fatalf("pending write failure dropped the card operation handle: %+v", resp.Receipt)
}
if len(cards.created) != 1 || len(b.pending) != 0 {
@@ -2,6 +2,6 @@
package docgen
const rendererRevision = "3f259990b5d43ec0d318f39d496816140ab559b5"
const rendererRevision = "40e5c671d1ef786ceba62bf784bf3aa53c5da26d"
const RendererIdentity = "agent-document-renderer@" + rendererRevision
+1 -1
View File
@@ -367,7 +367,7 @@ func TestPersistedControlledMetadataRoutesBeforeDefaultProfileLoad(t *testing.T)
}
func TestRendererIdentityMatchesPublishedPin(t *testing.T) {
const want = "agent-document-renderer@3f259990b5d43ec0d318f39d496816140ab559b5"
const want = "agent-document-renderer@40e5c671d1ef786ceba62bf784bf3aa53c5da26d"
if RendererIdentity != want {
t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want)
}
+10 -7
View File
@@ -21,8 +21,8 @@ type SlotQuestion struct {
}
// SlotDefect 是一条槽位缺陷,来自权威识别结果的校验(F09):
// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠/
// 生成路径的未知键),Reason 是库层原因原文;
// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠),
// Reason 是库层原因原文;
// - Invalid=false:必填槽位根本没给(等价于 SlotQuestion,Reason 为空)。
//
// 宿主据此区分「没给」与「给错了」,两种情形都不派发业务动作。
@@ -39,11 +39,15 @@ type MatchResult struct {
ID string
Slots map[string]string
Missing []SlotQuestion
// Invalid 是捕获到但未通过权威校验的槽位(含生成路径的未知键)。
// Invalid 是捕获到但未通过权威校验的槽位。
// 与 Missing 分开投影:必填且非法只进此处,不重复计入 Missing。
Invalid []SlotDefect
// Risk/RequiresConfirm/Action 是权威候选的声明(规则路径与生成路径同款):
// 风险档、是否要求显式确认、spec.action 显式动作引用。nil Action 表示未声明。
// Risk/RequiresConfirm/Action 是权威候选的声明:风险档、是否要求显式确认、
// spec.action 显式动作引用。nil Action 表示未声明。
//
// 上游 agent-intent main 已移除生成路径的候选合成(candidateFromGenerated
// 与 validateProvidedSlots):生成出来的 Top 只带槽位,不再挂 risk/confirm/
// action,也不再跑 enum/format 校验。这三个字段目前只由规则路径填充。
Risk string
RequiresConfirm bool
Action *ActionRef
@@ -198,8 +202,7 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
out.Slots[k] = v
}
}
// 风险/确认/动作声明:规则路径与生成路径(candidateFromGenerated)同款字段,
// 宿主据此决定是否必须走确认,不在本包内裁决。
// 风险/确认/动作声明:规则路径由库层填充;宿主只投影、不在本包内裁决。
out.Risk = res.Top.Risk
out.RequiresConfirm = res.Top.RequiresConfirm
if res.Top.Action != nil {
+44 -10
View File
@@ -178,7 +178,7 @@ func TestGateGeneratorProducesCommands(t *testing.T) {
}
}
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径与生成路径同款)----
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径)----
// modeSpec 声明一个带枚举校验的风险意图:mode 与 topic 都是必填。
func modeSpec(t *testing.T) json.RawMessage {
@@ -239,7 +239,9 @@ func TestRecognizeKeepsInvalidSlotDetail(t *testing.T) {
}
}
// 生成路径同样经过权威槽位校验:非法值与未知键都要带原因回到宿主。
// 生成路径与规则路径同款:库层对生成槽位跑全量权威校验(candidateFromGenerated
// → validateProvidedSlots:enum/format/validator 失败进 Invalid,必填未给进 Missing),
// 并按权威 spec 合成 risk/confirm/action;宿主只做投影,不重复裁决、不二次拦。
func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
@@ -254,27 +256,59 @@ func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
if res.Branch != "matched" || res.ID != "demo.mode" {
t.Fatalf("生成路径识别:%+v", res)
}
if len(res.Invalid) != 1 || res.Invalid[0].Key != "mode" || res.Invalid[0].Reason != "value not in enum" ||
res.Invalid[0].Question != "要哪种模式?" {
t.Fatalf("生成路径非法详情:%+v", res.Invalid)
// 非法枚举值由库层拦下并给出权威原因,原因原文不做改写。
if res.Complete() {
t.Fatalf("非法枚举值不得算作完整:%+v", res)
}
if _, leaked := res.Slots["mode"]; leaked || res.Complete() {
t.Fatalf("生成路径非法值不得进入槽位表:%+v", res.Slots)
if len(res.Missing) != 0 {
// 必填且非法只进 Invalid,不重复进 Missing。
t.Fatalf("非法槽位不应重复计入缺失:%+v", res.Missing)
}
if res.Risk != "high" || !res.RequiresConfirm || res.Action == nil {
if len(res.Invalid) != 1 {
t.Fatalf("生成路径非法槽位详情:%+v", res.Invalid)
}
d := res.Invalid[0]
if d.Key != "mode" || !d.Invalid || d.Reason != "value not in enum" || d.Question != "要哪种模式?" {
t.Fatalf("生成路径非法原因/提问未透传:%+v", d)
}
if _, leaked := res.Slots["mode"]; leaked {
t.Fatalf("非法值不得进入已校验槽位表:%+v", res.Slots)
}
if res.Slots["topic"] != "核能" {
t.Fatalf("生成路径合法槽位丢失:%+v", res.Slots)
}
// 风险/确认/动作声明按 modeSpec 合成(生成路径与规则路径一致)。
if res.Risk != "high" || !res.RequiresConfirm {
t.Fatalf("生成路径风险元数据:%+v", res)
}
if res.Action == nil || res.Action.Kind != ActionCommand || res.Action.ID != "demo.mode" {
t.Fatalf("生成路径动作声明:%+v", res.Action)
}
// 未知键:库层判为 invalid(reason=unknown slot key),宿主据此拒绝派发。
// 合法槽位:matched、完整、无缺陷,元数据同样齐备。
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
Slots: map[string]string{"mode": "摘要", "topic": "核能"},
}}})
clean := g.Recognize(context.Background(), "跟昨天一样")
if !clean.Complete() || clean.Slots["mode"] != "摘要" {
if !clean.Complete() || clean.Slots["mode"] != "摘要" || len(clean.Invalid) != 0 {
t.Fatalf("生成路径合法槽位:%+v", clean)
}
if clean.Risk != "high" || !clean.RequiresConfirm || clean.Action == nil {
t.Fatalf("生成路径合法槽位的风险元数据:%+v", clean)
}
// 未知槽位键仍由库层 fail-closed 拦住(整单回 fallback,不带 Top)。
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
Slots: map[string]string{"unknown_key": "值"},
}}})
unknown := g.Recognize(context.Background(), "跟昨天一样")
if unknown.Branch != "fallback" || unknown.ID != "" || len(unknown.Slots) != 0 {
t.Fatalf("未知槽位键必须 fail-closed:%+v", unknown)
}
}
// 必填槽位缺失与非法是两条独立通道:缺失走 Missing,非法走 Invalid。
+23 -30
View File
@@ -11,38 +11,30 @@ import (
// toolResultSummaryLen 是工具结果摘要的截断长度(与既有展示一致)。
const toolResultSummaryLen = 160
// toolEventID 是宿主侧的工具调用身份:优先用上游 CallID(并行同名工具据此配对,
// 而不是按到达顺序);空串 = legacy/未知,退回工具名,保持既有展示不变。
func toolEventID(ev squad.ProcessEvent) string {
if id := strings.TrimSpace(ev.CallID); id != "" {
return id
}
return ev.Tool
}
// toolStatusOK 是宿主展示的工具结局取值域(ok | error | denied | canceled)。
// squad.ProcessEvent 的 Status 字段与 squad.ToolStatus* 常量已在上游 main 被移除,
// 这里保留同名的宿主侧常量,使部件与发射载荷继续用同一取值域,不改语义。
const toolStatusOK = "ok"
// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的真实结局。
// toolEventID 是宿主侧的工具调用身份。
//
// 取值域与上游一致(ok | error | denied | canceled),不新增、不改写上游状态:
// - Status 非空:原样透传;
// - Status 为空且没有失败信号(ErrorReason 也空):legacy/未知,沿用旧的 "ok"
// 与 Detail 摘要(字节一致,不臆造结局);
// - Status 为空但有 ErrorReason:有失败信号而状态未知,报 error 而不是 ok;
// - 非 ok 时摘要取 ErrorReason 原文(截断),上游没给原因才回退 Detail。
// 上游 squad.ProcessEvent 已不再携带每次调用的 CallID(并行同名工具的按 ID 配对
// 能力随该字段一并移除),因此退到既有的 legacy 口径:按工具名配对 start/end。
// 上游恢复 CallID 时只需在此处换回优先取 ID。
func toolEventID(ev squad.ProcessEvent) string { return ev.Tool }
// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的结局。
//
// 上游不再提供 Status/ErrorReason,无法区分 error/denied/canceled,故按最接近的
// 等价口径处理:结局报 ok(不臆造上游没给的判定),摘要取上游实际带来的工具结果
// ——新 API 把结果放在 Text(Detail 只在 start 帧携带调用参数),Text 为空时回退
// Detail,保持 legacy 帧的字节一致。
func toolEventOutcome(ev squad.ProcessEvent) (status, summary string) {
status = strings.TrimSpace(ev.Status)
if status == "" {
if strings.TrimSpace(ev.ErrorReason) == "" {
return squad.ToolStatusOK, truncateRun(ev.Detail, toolResultSummaryLen)
}
status = squad.ToolStatusError
summary = ev.Text
if summary == "" {
summary = ev.Detail
}
if status == squad.ToolStatusOK {
return status, truncateRun(ev.Detail, toolResultSummaryLen)
}
if strings.TrimSpace(ev.ErrorReason) != "" {
return status, truncateRun(ev.ErrorReason, toolResultSummaryLen)
}
return status, truncateRun(ev.Detail, toolResultSummaryLen)
return toolStatusOK, truncateRun(summary, toolResultSummaryLen)
}
// toolFailure 是一次未被恢复的工具失败(用于终局文本一致性检查,不覆盖工具部件)。
@@ -108,7 +100,7 @@ func (l *squadEventLog) onEvent(ev squad.ProcessEvent) {
// 发射与部件同源:前端过程行与落库部件说的是同一件事。
_ = l.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: id, Status: status, Summary: summary})
l.parts = append(l.parts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: id, Status: status, Summary: summary}})
if status == squad.ToolStatusOK {
if status == toolStatusOK {
l.noteRecovered(ev.Tool)
} else {
l.failure = &toolFailure{Tool: ev.Tool, CallID: id, Status: status, Summary: summary}
@@ -159,7 +151,8 @@ func (l *squadEventLog) unresolvedFailure() *toolFailure {
// finalReply 决定终局文本。模型文本原样保留(不代写、不改写);只有宿主兜底的
// 「已完成。」在仍有未恢复的工具失败时会被换成如实说明,避免收尾文本与失败部件
// 自相矛盾。legacy 事件(无 CallID/Status)走不到这里,展示与旧行为一致。
// 自相矛盾。上游 main 的事件帧不带结局判定(见 toolEventOutcome),宿主不臆造
// 失败,因此该分支暂时走不到:失败聚合原样保留,上游恢复结局信号即重新生效。
func (l *squadEventLog) finalReply(modelText, summary, fallback string) string {
reply := strings.TrimSpace(modelText)
if reply == "" {
+90 -100
View File
@@ -69,43 +69,47 @@ func toolResultsOf(parts []core.Part) []core.ToolResultPart {
return out
}
// TestSquadEventsPairByCallID:两个同名工具的 start/end 必须按 CallID 配对,
// 不得因名字相同而互相对调(这是并行同名工具显示错乱的根因)。
func TestSquadEventsPairByCallID(t *testing.T) {
// TestSquadEventsPairSameNameToolsByToolName:squad.ProcessEvent 在上游 main 已不再
// 携带 CallID,宿主退回 legacy 口径按工具名配对 start/end:同名工具不再按 ID 区分,
// 但到达顺序与每帧的内容必须一一落成部件,不得丢失或错位。
func TestSquadEventsPairSameNameToolsByToolName(t *testing.T) {
p := newEventProbe()
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_a", Detail: `{"q":"a"}`})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_b", Detail: `{"q":"b"}`})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"a"}`})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"b"}`})
// 交错到达:B 先结束。
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_b", Status: squad.ToolStatusOK, Detail: "命中 B"})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_a", Status: squad.ToolStatusError, ErrorReason: "路径不存在"})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "命中 B"})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "路径不存在"})
parts := p.parts()
calls := toolCallsOf(parts)
if len(calls) != 2 || calls[0].ID != "call_a" || calls[1].ID != "call_b" {
if len(calls) != 2 || calls[0].ID != "grep" || calls[1].ID != "grep" {
t.Fatalf("tool call parts = %+v", calls)
}
emittedCalls := p.calls()
if len(emittedCalls) != 2 || emittedCalls[0].CallID != "call_a" || emittedCalls[1].CallID != "call_b" {
if len(emittedCalls) != 2 || emittedCalls[0].CallID != "grep" || emittedCalls[1].CallID != "grep" || emittedCalls[0].Name != "grep" {
t.Fatalf("emitted tool calls = %+v", emittedCalls)
}
results := toolResultsOf(parts)
if len(results) != 2 {
t.Fatalf("tool result parts = %+v", results)
}
if results[0].CallID != "call_b" || results[0].Status != squad.ToolStatusOK || results[0].Summary != "命中 B" {
// 到达顺序保留:先到的 end 先落部件。
if results[0].CallID != "grep" || results[0].Status != toolStatusOK || results[0].Summary != "命中 B" {
t.Fatalf("result B = %+v", results[0])
}
if results[1].CallID != "call_a" || results[1].Status != squad.ToolStatusError || results[1].Summary != "路径不存在" {
if results[1].CallID != "grep" || results[1].Status != toolStatusOK || results[1].Summary != "路径不存在" {
t.Fatalf("result A = %+v", results[1])
}
if p.rec.unresolvedFailure() == nil || p.rec.unresolvedFailure().CallID != "call_a" {
t.Fatalf("unresolved failure = %+v", p.rec.unresolvedFailure())
// 上游没给失败结局,宿主不得自己臆造一次失败。
if p.rec.unresolvedFailure() != nil {
t.Fatalf("no upstream verdict must not register a failure: %+v", p.rec.unresolvedFailure())
}
}
// TestSquadEventsStatusIsTruthful:失败/取消/拒绝必须原样透传,不得一律报 ok,
// 摘要取真实原因(截断),部件与发射载荷完全一致。
func TestSquadEventsStatusIsTruthful(t *testing.T) {
// TestSquadEventsOutcomeFollowsUpstreamFields:上游 main 移除了 ProcessEvent.Status /
// ErrorReason(ToolStatus* 常量同时消失),结局只能取最接近的等价口径:恒为 ok,
// 摘要取上游实际传来的工具结果文本(Text,为空时回退 Detail)。
func TestSquadEventsOutcomeFollowsUpstreamFields(t *testing.T) {
long := strings.Repeat("失败原因", 80)
cases := []struct {
name string
@@ -113,17 +117,11 @@ func TestSquadEventsStatusIsTruthful(t *testing.T) {
status string
summary string
}{
{"error", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c1",
Status: squad.ToolStatusError, ErrorReason: "权限不足", Detail: "旧摘要"}, squad.ToolStatusError, "权限不足"},
{"denied", squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2",
Status: squad.ToolStatusDenied, ErrorReason: "策略拒绝执行"}, squad.ToolStatusDenied, "策略拒绝执行"},
{"canceled", squad.ProcessEvent{Type: "tool_execution_end", Tool: "web_search", CallID: "c3",
Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"}, squad.ToolStatusCanceled, "用户取消"},
{"ok", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c4",
Status: squad.ToolStatusOK, Detail: "命中 3 处"}, squad.ToolStatusOK, "命中 3 处"},
// 有失败信号但状态未知:不得报 ok。
{"status-less error signal", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c5",
ErrorReason: "连接被拒绝", Detail: "旧摘要"}, squad.ToolStatusError, "连接被拒绝"},
{"result text", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "命中 3 处"}, toolStatusOK, "命中 3 处"},
{"legacy detail", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Detail: "命中 3 处"}, toolStatusOK, "命中 3 处"},
{"no payload", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep"}, toolStatusOK, ""},
// 上游把失败原因也放在结果文本里(无独立字段)时不得丢。
{"failure text", squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", Text: "权限不足"}, toolStatusOK, "权限不足"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -132,28 +130,28 @@ func TestSquadEventsStatusIsTruthful(t *testing.T) {
t.Fatalf("outcome = %q/%q, want %q/%q", status, summary, tc.status, tc.summary)
}
p := newEventProbe()
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: tc.ev.Tool, CallID: tc.ev.CallID})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: tc.ev.Tool})
p.onEvent(tc.ev)
got := toolResultsOf(p.parts())
if len(got) != 1 {
t.Fatalf("parts = %+v", p.parts())
}
// 落库部件与发射载荷同源。
if got[0].Status != tc.status || got[0].Summary != tc.summary || got[0].CallID != tc.ev.CallID {
if got[0].Status != tc.status || got[0].Summary != tc.summary || got[0].CallID != tc.ev.Tool {
t.Fatalf("result part = %+v", got[0])
}
wantPayload := core.ToolResultPayload{CallID: tc.ev.CallID, Status: tc.status, Summary: tc.summary}
wantPayload := core.ToolResultPayload{CallID: tc.ev.Tool, Status: tc.status, Summary: tc.summary}
if gotPayloads := p.results(); len(gotPayloads) != 1 || gotPayloads[0] != wantPayload {
t.Fatalf("emitted payload = %+v", gotPayloads)
}
if gotCalls := p.calls(); len(gotCalls) != 1 || gotCalls[0].CallID != tc.ev.CallID || gotCalls[0].Name != tc.ev.Tool {
if gotCalls := p.calls(); len(gotCalls) != 1 || gotCalls[0].CallID != tc.ev.Tool || gotCalls[0].Name != tc.ev.Tool {
t.Fatalf("emitted tool call = %+v", gotCalls)
}
})
}
t.Run("summary truncated", func(t *testing.T) {
status, summary := toolEventOutcome(squad.ProcessEvent{Status: squad.ToolStatusError, ErrorReason: long})
if status != squad.ToolStatusError {
status, summary := toolEventOutcome(squad.ProcessEvent{Text: long})
if status != toolStatusOK {
t.Fatalf("status = %q", status)
}
if want := truncateRun(long, toolResultSummaryLen); summary != want {
@@ -202,30 +200,19 @@ func TestSquadEventsLegacyFramesUnchanged(t *testing.T) {
}
// TestSquadEventsConcurrentCallbacksKeepEveryEntry:并行回调(-race)下不得丢条目、
// 不得串字段;每次调用自己的 start/end 仍成对。
// 不得串字段;每次调用自己的 start/end 仍成对。身份退到工具名后用互不相同的
// 工具名保证配对关系仍然可断言。
func TestSquadEventsConcurrentCallbacksKeepEveryEntry(t *testing.T) {
const calls = 64
p := newEventProbe()
wantStatus := map[string]string{}
var wg sync.WaitGroup
for i := 0; i < calls; i++ {
id := fmt.Sprintf("call_%02d", i)
wantStatus[id] = squad.ToolStatusOK
failing := i%8 == 0
if failing {
wantStatus[id] = squad.ToolStatusError
}
name := fmt.Sprintf("tool_%02d", i)
wg.Add(1)
go func() {
defer wg.Done()
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: id, Detail: id})
if failing {
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id,
Status: squad.ToolStatusError, ErrorReason: "boom " + id})
return
}
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id,
Status: squad.ToolStatusOK, Detail: "ok " + id})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: name, Detail: name})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: name, Text: "ok " + name})
}()
}
wg.Wait()
@@ -256,8 +243,8 @@ func TestSquadEventsConcurrentCallbacksKeepEveryEntry(t *testing.T) {
}
}
for id, status := range emitted {
if status != wantStatus[id] {
t.Fatalf("end %s status = %q, want %q", id, status, wantStatus[id])
if status != toolStatusOK {
t.Fatalf("end %s status = %q, want %q", id, status, toolStatusOK)
}
}
}
@@ -295,37 +282,35 @@ func TestSquadEventsConcurrentToolCallbacksShareArtifactList(t *testing.T) {
}
}
// TestSquadEventsFinalTextMatchesToolOutcome:未恢复的失败不得被收尾文本改写成成功;
// 同名工具重试成功后收尾文本恢复旧行为。
func TestSquadEventsFinalTextMatchesToolOutcome(t *testing.T) {
// TestSquadEventsFinalTextFollowsUpstreamOutcome:上游未给出失败结局时,宿主不得
// 自己臆造一次失败去改写收尾文本——模型原话与兜底文案都保持不变,工具结果部件
// 照实记录上游传来的结果文本。宿主侧的失败聚合结构原样保留,等上游恢复结局信号
// 即重新生效。
func TestSquadEventsFinalTextFollowsUpstreamOutcome(t *testing.T) {
p := newEventProbe()
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1",
Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"})
if got := p.rec.finalReply("", "", "已完成。"); got == "已完成。" || !strings.Contains(got, "run_command") || !strings.Contains(got, "用户取消") {
t.Fatalf("fallback text = %q", got)
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command"})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", Text: "用户取消"})
if got := p.rec.finalReply("", "", "已完成。"); got != "已完成。" {
t.Fatalf("no upstream verdict must not rewrite the fallback: %q", got)
}
if got := p.rec.finalReply("已按你的要求处理。", "", "已完成。"); got != "已按你的要求处理。" {
t.Fatalf("model text must be preserved: %q", got)
}
// 失败部件必须留在最终部件里,不被收尾文本覆盖。
if results := toolResultsOf(p.parts()); len(results) != 1 || results[0].Status != squad.ToolStatusCanceled {
t.Fatalf("failure part dropped: %+v", results)
// 结果部件仍在最终部件里,不被收尾文本覆盖。
if results := toolResultsOf(p.parts()); len(results) != 1 || results[0].Status != toolStatusOK || results[0].Summary != "用户取消" {
t.Fatalf("result part dropped: %+v", results)
}
if f := p.rec.unresolvedFailure(); f != nil {
t.Fatalf("no upstream verdict must not register a failure: %+v", f)
}
// 同名工具重试成功 → 失败记为已恢复,兜底文本回到旧行为。
// 摘要截断口径不变。
long := strings.Repeat("结果", 200)
q := newEventProbe()
q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"})
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1",
Status: squad.ToolStatusError, ErrorReason: "超时"})
q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c2"})
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2",
Status: squad.ToolStatusOK, Detail: "done"})
if f := q.rec.unresolvedFailure(); f != nil {
t.Fatalf("retry should clear the failure: %+v", f)
}
if got := q.rec.finalReply("", "", "已完成。"); got != "已完成。" {
t.Fatalf("recovered run fallback = %q", got)
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: long})
got := toolResultsOf(q.parts())
if len(got) != 1 || got[0].Summary != truncateRun(long, toolResultSummaryLen) {
t.Fatalf("truncated summary = %+v", got)
}
}
@@ -365,9 +350,12 @@ func (p *entryProbe) resultPayloads() []core.ToolResultPayload {
return append([]core.ToolResultPayload(nil), p.results...)
}
// TestSquadEntryFailedToolReachesFinalParts:真实跑一次小队入口,同名并行工具
// 必须按各自 CallID 落部件,失败必须显示为失败,且不被收尾文本改写成成功。
func TestSquadEntryFailedToolReachesFinalParts(t *testing.T) {
// TestSquadEntryToolFramesReachFinalParts:真实跑一次小队入口,工具帧必须落成部件。
//
// 上游 squad.ProcessEvent 在 pin 前移后不再携带 CallID/Status:调用身份退到工具名,
// 结局恒为 ok(上游没给判定就不臆造),摘要取上游传来的工具结果文本——失败工具
// 的失败原因也在这段文本里,仍会如实展示给用户。
func TestSquadEntryToolFramesReachFinalParts(t *testing.T) {
dir := t.TempDir()
base := baseEntry(dir)
rc := &entryProbe{entryFake: base}
@@ -393,50 +381,52 @@ func TestSquadEntryFailedToolReachesFinalParts(t *testing.T) {
}
parts := base.parts
calls := toolCallsOf(parts)
ids := map[string]bool{}
search := 0
for _, c := range calls {
if c.Name != "knowledge_search" {
continue
}
if c.ID == "knowledge_search" || ids[c.ID] {
t.Fatalf("tool call part lost its call id: %+v (all %+v)", c, calls)
search++
if c.ID != "knowledge_search" {
t.Fatalf("tool call part lost its identity: %+v (all %+v)", c, calls)
}
ids[c.ID] = true
}
if len(ids) != 2 {
t.Fatalf("distinct call ids = %v", ids)
if search != 2 {
t.Fatalf("knowledge_search call parts = %d, want 2 (all %+v)", search, calls)
}
results := toolResultsOf(parts)
search := map[string]core.ToolResultPart{}
var searchResults []core.ToolResultPart
for _, r := range results {
if ids[r.CallID] {
search[r.CallID] = r
if r.CallID == "knowledge_search" {
searchResults = append(searchResults, r)
}
}
if len(search) != 2 {
if len(searchResults) != 2 {
t.Fatalf("tool results for knowledge_search = %+v", results)
}
for id, r := range search {
if r.Status != squad.ToolStatusError {
t.Fatalf("%s status = %q, want error", id, r.Status)
for _, r := range searchResults {
if r.Status != toolStatusOK {
t.Fatalf("%s status = %q, want %q", r.CallID, r.Status, toolStatusOK)
}
if r.Summary != "索引不可用" {
t.Fatalf("%s summary = %q", id, r.Summary)
t.Fatalf("%s summary = %q", r.CallID, r.Summary)
}
}
// 发射载荷与落库部件一致:前端过程行不得说 ok。
emitted := map[string]string{}
// 发射载荷与落库部件一致。
emitted := 0
for _, pl := range rc.resultPayloads() {
if ids[pl.CallID] {
emitted[pl.CallID] = pl.Status
if pl.CallID != "knowledge_search" {
continue
}
emitted++
if pl.Status != toolStatusOK || pl.Summary != "索引不可用" {
t.Fatalf("emitted payload = %+v", pl)
}
}
for id, r := range search {
if emitted[id] != r.Status {
t.Fatalf("emitted %s status = %q, part status = %q", id, emitted[id], r.Status)
}
if emitted != 2 {
t.Fatalf("emitted knowledge_search results = %d, want 2", emitted)
}
// 收尾文本保留模型原话,失败部件不被覆盖。
// 收尾文本保留模型原话。
if last := parts[len(parts)-1]; last.Type != core.PartText || last.Text != "写完了。" {
t.Fatalf("final part = %+v", last)
}
+11 -6
View File
@@ -161,17 +161,22 @@ func TestMemberBasicCollaborateAndDocumentQualifyOverHTTP(t *testing.T) {
t.Fatalf("%s=%+v want %+v", actionID, duty, want)
}
}
// Member content is refused at the policy endpoint instead of being stored.
// 三权动作是项目范围动作:现在随职位定义,并立刻成为默认值。
role := decodeRoleHTTP(t, do(h, "POST", "/api/admin/roles", `{"name":"文档职责"}`, admin))
refused := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
accepted := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"duty-policy","changes":[{"actionId":"document.author","allowed":true,"capAllowed":true}]}`, admin)
if accepted.Code != http.StatusOK {
t.Fatalf("document duty rejected: %d %s", accepted.Code, accepted.Body.String())
}
// 系统范围动作仍然整批拒绝,角色保持原样。
refused := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":2,"idempotencyKey":"system-policy","changes":[{"actionId":"system.settings.write","allowed":true,"capAllowed":true}]}`, admin)
if refused.Code != http.StatusBadRequest {
t.Fatalf("document duty accepted: %d %s", refused.Code, refused.Body.String())
t.Fatalf("system row accepted: %d %s", refused.Code, refused.Body.String())
}
// The refused batch left the role untouched: same version, no permissions.
stored := decodeRoleProjectAccess(t, do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin))
if stored.RoleVersion != 1 || stored.ProjectAccess || len(stored.Permissions) != 0 {
t.Fatalf("refused duty row changed the role: %+v", stored)
if stored.RoleVersion != 2 || stored.ProjectAccess || len(stored.Permissions) != 1 || stored.Permissions[0].ActionID != "document.author" {
t.Fatalf("refused system row changed the role: %+v", stored)
}
}
+11 -2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -41,6 +42,7 @@ func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) {
}
// 写库之后才失败的命令:unknown + operationID(审计信封按会话快照落项目)。
// agent-command main 契约禁止 unknown 携带 OperationID,句柄写在 Reason 里。
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"},
func(context.Context, cmdbus.Request) (any, error) { return nil, errors.New("ack lost") }); err != nil {
t.Fatal(err)
@@ -48,10 +50,17 @@ func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) {
resp := srv.Bus.Dispatch(context.Background(), cmdbus.Request{
ID: "demo.write", Owner: "alice", Actor: "alice", ConversationID: conversation.ID,
})
if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" {
const marker = " (operation "
reason := resp.Receipt.Reason
at := strings.Index(reason, marker)
operationID := ""
if resp.Receipt.State == agentcommand.StateUnknown && resp.Receipt.OperationID == "" &&
at >= 0 && strings.HasSuffix(reason, ")") {
operationID = reason[at+len(marker) : len(reason)-1]
}
if operationID == "" {
t.Fatalf("post-execution failure must be unknown with an operation ID: %+v", resp.Receipt)
}
operationID := resp.Receipt.OperationID
get := func(key, url string) *httptest.ResponseRecorder {
t.Helper()
@@ -638,9 +638,19 @@ func TestProjectIAMCapabilityExplainCreatorAndMember(t *testing.T) {
t.Fatalf("linked cap accepted: %v", err)
}
permissions, err := f.st.RolePermissions(capped.RoleID)
if err != nil || len(permissions) != 1 || !permissions[0].Allowed || !permissions[0].CapAllowed {
if err != nil {
t.Fatal(err)
}
byAction := map[string]store.RolePermissionRecord{}
for _, permission := range permissions {
byAction[permission.ActionID] = permission
}
if !byAction["project.create"].Allowed || !byAction["project.create"].CapAllowed {
t.Fatalf("refused cap changed the role: %+v err=%v", permissions, err)
}
if row := byAction["project.members.manage"]; row.Allowed {
t.Fatalf("refused cap persisted: %+v", permissions)
}
if role, err := f.st.GetRole(capped.RoleID); err != nil || role.RoleVersion != receipt.RoleVersion {
t.Fatalf("refused cap bumped the role version: %+v err=%v", role, err)
}
+23 -8
View File
@@ -87,13 +87,12 @@ func TestProjectCreateIsTheOnlyRolePolicyOverHTTP(t *testing.T) {
const enableCreate = `{"actionId":"project.create","allowed":true,"capAllowed":true}`
const denyLinked = `{"actionId":"project.members.manage","allowed":false,"capAllowed":false}`
// A batch that enables project.create and explicitly denies a linked
// own-project action is refused as a whole: the linked action is not role
// authority any more, so nothing in that batch is written.
// 系统范围动作仍然整批拒绝;项目范围动作(linked 的管理动作)现在随职位定义。
// 开启项目准入时不得在同一职位收回 linked 管理动作(既有不变量)。
both := do(h, "PUT", permissions,
`{"expectedRoleVersion":1,"idempotencyKey":"create-and-deny-linked","changes":[`+enableCreate+`,`+denyLinked+`]}`, admin)
if both.Code != http.StatusBadRequest {
t.Fatalf("linked deny accepted beside create: %d %s", both.Code, both.Body.String())
t.Fatalf("linked deny beside create: %d %s", both.Code, both.Body.String())
}
stale := do(h, "PUT", permissions,
`{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[`+enableCreate+`]}`, admin)
@@ -115,14 +114,26 @@ func TestProjectCreateIsTheOnlyRolePolicyOverHTTP(t *testing.T) {
}
requireRoleProjectAccess(t, roleView(), 2, true)
// 项目范围动作(document.read)可以与项目准入并存;系统范围动作整批拒绝。
projectRead := do(h, "PUT", permissions,
`{"expectedRoleVersion":2,"idempotencyKey":"create-and-read","changes":[{"actionId":"document.read","allowed":true,"capAllowed":true}]}`, admin)
if projectRead.Code != http.StatusOK {
t.Fatalf("project read beside create: %d %s", projectRead.Code, projectRead.Body.String())
}
bothSystem := do(h, "PUT", permissions,
`{"expectedRoleVersion":3,"idempotencyKey":"create-and-system","changes":[{"actionId":"system.settings.write","allowed":true,"capAllowed":true}]}`, admin)
if bothSystem.Code != http.StatusBadRequest {
t.Fatalf("system row accepted: %d %s", bothSystem.Code, bothSystem.Body.String())
}
// Split across two submissions the linked deny is refused on its own, so the
// role keeps the project.create policy the first submission wrote.
denyLater := do(h, "PUT", permissions,
`{"expectedRoleVersion":2,"idempotencyKey":"deny-linked-later","changes":[`+denyLinked+`]}`, admin)
`{"expectedRoleVersion":3,"idempotencyKey":"deny-linked-later","changes":[`+denyLinked+`]}`, admin)
if denyLater.Code != http.StatusBadRequest {
t.Fatalf("linked deny accepted in a later batch: %d %s", denyLater.Code, denyLater.Body.String())
}
requireRoleProjectAccess(t, roleView(), 2, true)
requireRoleProjectAccess(t, roleView(), 3, true)
assign := do(h, "PUT", "/api/admin/users/"+creator.ID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(creator.StateVersion, 10)+`,"idempotencyKey":"assign-creator"}`, admin)
@@ -456,13 +467,17 @@ func TestLegacyRoleRowsDoNotAuthorizeSystemActionsOrAccess(t *testing.T) {
roleRec := do(h, "GET", "/api/admin/roles/"+roleID, "", superAdmin)
requireRoleProjectAccess(t, roleRec, 1, false)
list := do(h, "GET", "/api/admin/roles", "", superAdmin)
// 系统范围旧行仍不对外暴露;项目范围旧行现在作为职位默认值投影。
for _, body := range []string{roleRec.Body.String(), list.Body.String()} {
for _, actionID := range []string{"system.settings", "system.users", "system.roles", "document.read"} {
for _, actionID := range []string{"system.settings", "system.users", "system.roles"} {
if strings.Contains(body, actionID) {
t.Fatalf("role API exposed legacy %s: %s", actionID, body)
}
}
}
if !strings.Contains(roleRec.Body.String(), "document.read") {
t.Fatalf("project scope legacy row hidden: %s", roleRec.Body.String())
}
legacy("project.create", true, true)
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"旧行允许项目"}`, key); rec.Code != http.StatusOK {
@@ -562,7 +577,7 @@ func TestBatchRolePermissionPatchOverHTTP(t *testing.T) {
for _, role := range []roleHTTPView{first, second} {
rec := do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
view := decodeRoleProjectAccess(t, rec)
if view.RoleVersion != 2 || !view.ProjectAccess || len(view.Permissions) != 1 {
if view.RoleVersion != 2 || !view.ProjectAccess || !hasRolePermission(view.Permissions, "project.create") {
t.Fatalf("stale batch wrote %s: %+v", role.RoleID, view)
}
}
+32 -13
View File
@@ -89,14 +89,25 @@ func TestRoleHTTPCreateSearchPatchPermissionAndAssignment(t *testing.T) {
}
}
type rolePermissionHTTPView struct {
ActionID string `json:"actionId"`
Allowed bool `json:"allowed"`
CapAllowed bool `json:"capAllowed"`
}
type roleProjectAccessHTTPView struct {
RoleVersion int64 `json:"roleVersion"`
ProjectAccess bool `json:"projectAccess"`
Permissions []struct {
ActionID string `json:"actionId"`
Allowed bool `json:"allowed"`
CapAllowed bool `json:"capAllowed"`
} `json:"permissions"`
RoleVersion int64 `json:"roleVersion"`
ProjectAccess bool `json:"projectAccess"`
Permissions []rolePermissionHTTPView `json:"permissions"`
}
func hasRolePermission(permissions []rolePermissionHTTPView, actionID string) bool {
for _, permission := range permissions {
if permission.ActionID == actionID {
return permission.Allowed && permission.CapAllowed
}
}
return false
}
func decodeRoleProjectAccess(t *testing.T, rec *httptest.ResponseRecorder) roleProjectAccessHTTPView {
@@ -108,20 +119,28 @@ func decodeRoleProjectAccess(t *testing.T, rec *httptest.ResponseRecorder) roleP
return view
}
// requireRoleProjectAccess asserts the role endpoint exposes exactly the
// normalized project-access boolean and nothing else.
// requireRoleProjectAccess asserts the normalized project-access boolean and
// that every projected row is a project-scope role default.
func requireRoleProjectAccess(t *testing.T, rec *httptest.ResponseRecorder, version int64, want bool) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("read role: %d %s", rec.Code, rec.Body.String())
}
view := decodeRoleProjectAccess(t, rec)
if view.RoleVersion != version || view.ProjectAccess != want || len(view.Permissions) != 1 {
if view.RoleVersion != version || view.ProjectAccess != want {
t.Fatalf("role access=%+v want version=%d access=%v", view, version, want)
}
permission := view.Permissions[0]
if permission.ActionID != "project.create" || permission.Allowed != want || permission.CapAllowed != want {
t.Fatalf("role permission=%+v want=%v", permission, want)
var access *rolePermissionHTTPView
for i, permission := range view.Permissions {
if permission.ActionID == "project.create" {
access = &view.Permissions[i]
}
}
if access == nil {
t.Fatalf("project access row missing=%+v", view.Permissions)
}
if access.Allowed != want || access.CapAllowed != want {
t.Fatalf("role permission=%+v want=%v", access, want)
}
}
+34 -18
View File
@@ -65,9 +65,9 @@ func TestExplainCapabilityCreatorSourcesMatchLinkedCapabilities(t *testing.T) {
}
// TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel is the
// explanation face of the narrowed role model (R2): a role stores the single
// project-access boolean, a linked action row cannot be written any more, and
// an explanation therefore never reports a conflict or a migration note.
// explanation face of the role model (R2): a role stores the project-access
// boolean plus project-scope defaults (never system-scope rows), and an
// explanation therefore never reports a conflict or a migration note.
func TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel(t *testing.T) {
st := openTestStore(t)
admin, alice, _ := rolePolicyAccounts(t, st)
@@ -75,14 +75,13 @@ func TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel(t *testing
if err != nil {
t.Fatal(err)
}
// A linked action row is not role authority any more: the write is refused
// whole and the role keeps no permissions at all.
// 没有项目范围行时职位仍然不携带任何权限;系统范围行整批拒绝。
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion,
[]RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}, "cap-members"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("linked cap accepted: %v", err)
[]RolePermissionChange{{ActionID: "system.settings.write", Allowed: true, CapAllowed: true}}, "system-row"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("system row accepted: %v", err)
}
if permissions := capabilityRolePermissions(t, st, capped.RoleID); len(permissions) != 0 {
t.Fatalf("refused cap persisted: %+v", permissions)
t.Fatalf("refused write persisted: %+v", permissions)
}
// Bootstrap admin keeps project.create through is_admin even without the
// grant, so the bound role cannot contradict it either.
@@ -185,30 +184,40 @@ func TestExplainCapabilityMemberBasicCollaborate(t *testing.T) {
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "member-assign"); err != nil {
t.Fatal(err)
}
// Member content is not role authority any more, so the batch is refused
// and the role keeps the project-access boolean it already had.
// 项目范围动作现在是职位默认的一部分,直接随角色写入。
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, []RolePermissionChange{
{ActionID: "document.read", Allowed: true, CapAllowed: true},
}, "member-content"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("member content accepted: %v", err)
}, "member-content"); err != nil {
t.Fatalf("member content rejected: %v", err)
}
explanation, err := st.ExplainCapabilities("alice")
if err != nil {
t.Fatal(err)
}
actions := explainActionMap(explanation.MemberBasicCollaborate.Actions)
// document.read 现在来自角色默认;其余成员动作仍未配置。
if read := actions["document.read"]; !read.Allowed || read.Source != "role" {
t.Fatalf("document.read=%+v", read)
}
if len(actions) != len(memberBasicCollaborateActions) {
t.Fatalf("actions=%+v", explanation.MemberBasicCollaborate.Actions)
}
for _, actionID := range memberBasicCollaborateActions {
want := CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeMemberProjects, Reason: "action-unconfigured"}
if actionID == "document.read" {
want = CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeMemberProjects, Allowed: true, CapAllowed: true, Source: "role"}
}
if actions[actionID] != want {
t.Fatalf("%s=%+v want %+v", actionID, actions[actionID], want)
}
}
// A disabled role stops every member action at the role instead.
disabled := false
if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
roleAfter, err := st.GetRole(role.RoleID)
if err != nil {
t.Fatal(err)
}
if _, err := st.PatchRole("admin", role.RoleID, roleAfter.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
explanation, err = st.ExplainCapabilities("alice")
@@ -261,12 +270,11 @@ func TestExplainCapabilityDocumentQualify(t *testing.T) {
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "duty-assign"); err != nil {
t.Fatal(err)
}
// Assigning a document duty to the role is refused whole; the role keeps the
// project-access boolean and nothing else.
// 三权动作同样属于职位默认,随角色一起定义。
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, []RolePermissionChange{
{ActionID: "document.author", Allowed: true, CapAllowed: true},
}, "duty-row"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("duty row accepted: %v", err)
}, "duty-row"); err != nil {
t.Fatalf("duty row rejected: %v", err)
}
explanation, err := st.ExplainCapabilities("alice")
if err != nil {
@@ -279,14 +287,22 @@ func TestExplainCapabilityDocumentQualify(t *testing.T) {
if len(got) != len(documentQualifyDuties) {
t.Fatalf("duties=%+v", explanation.DocumentQualify.Duties)
}
// document.author 已随角色定义,其他三权仍按未配置处理。
for _, actionID := range documentQualifyDuties {
want := DocumentDutyQualify{ActionID: actionID, Reason: "action-unconfigured"}
if actionID == "document.author" {
want = DocumentDutyQualify{ActionID: actionID, Qualified: true, DefaultAllowed: true, Source: "role"}
}
if got[actionID] != want {
t.Fatalf("%s=%+v want %+v", actionID, got[actionID], want)
}
}
disabled := false
if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
roleAfter, err := st.GetRole(role.RoleID)
if err != nil {
t.Fatal(err)
}
if _, err := st.PatchRole("admin", role.RoleID, roleAfter.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
explanation, err = st.ExplainCapabilities("alice")
+66 -14
View File
@@ -405,17 +405,43 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha
next := map[string]RolePermissionChange{}
for _, change := range changes {
descriptor, ok := catalog.DescriptorByID(change.ActionID)
if !ok || descriptor.Protected || change.ActionID != catalog.ProjectAccessAction ||
// 角色可携带项目范围动作:职位默认(负责人上下限)由 Backend 角色快照
// 投影到 RDM 的 evalPerm,没有它则项目内读/写一律拒绝。受保护的系统
// 授权仍只能按用户单独授予。
if !ok || descriptor.Protected ||
(change.ActionID != catalog.ProjectAccessAction && descriptor.Scope != catalog.ScopeProject) ||
seen[change.ActionID] || (change.Allowed && !change.CapAllowed) {
return ErrInvalidRolePolicy
}
seen[change.ActionID] = true
next[change.ActionID] = change
}
// Rows for other actions are inert legacy audit data and stay untouched.
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=? AND action_id=?`), roleID, catalog.ProjectAccessAction); err != nil {
// 准入与 linked 管理动作的一致性门:开启 project.create 的职位不得收回
// 自己的四个管理动作(有绑定或系统授权的账号同理)。
pending := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{}, bindings: map[string]string{}}
currentBits, err := s.rolePermissionBitsTx(tx, roleID)
if err != nil {
return err
}
for _, change := range changes {
currentBits[change.ActionID] = change
}
pending.roles[roleID] = currentBits
if err := s.checkCapabilityAssociationTx(tx, nil, nil, &pending); err != nil {
return err
}
// 受本次变更影响的动作先清后写;其他动作的历史审计行保持不动。
// 空批次沿用旧语义:只清除 project.create(“关闭项目准入”)。
if len(changes) == 0 {
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=? AND action_id=?`), roleID, catalog.ProjectAccessAction); err != nil {
return err
}
}
for _, change := range changes {
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=? AND action_id=?`), roleID, change.ActionID); err != nil {
return err
}
}
now := nowISO()
for _, change := range changes {
access := change.Allowed && change.CapAllowed
@@ -558,21 +584,47 @@ func (s *Store) RoleAdmin(roleID string) (RoleAdminView, error) {
// RolePermissions returns the role's normalized capability: at most one
// project.create record whose allowed and capAllowed both equal the stored
// allowed&&capAllowed. Legacy rows for any other action are filtered out.
// RolePermissions 投影角色上的全部动作行:项目准入(project.create)与项目范围
// 动作(document.*、project.settings 等)都进入快照,否则项目内读写没有任何职位
// 默认来源。project.create 继续按 allowed && cap_allowed 归一。
func (s *Store) RolePermissions(roleID string) ([]RolePermissionRecord, error) {
var permission RolePermissionRecord
var allowed, capAllowed bool
err := s.db.QueryRow(s.q(`SELECT allowed,cap_allowed,permission_version FROM role_permissions WHERE role_id=? AND action_id=?`),
roleID, catalog.ProjectAccessAction).Scan(&allowed, &capAllowed, &permission.PermissionVersion)
if errors.Is(err, sql.ErrNoRows) {
return []RolePermissionRecord{}, nil
}
permissions, err := s.rolePermissionRecords(s.db, roleID)
if err != nil {
return nil, err
}
permission.ActionID = catalog.ProjectAccessAction
permission.Allowed = allowed && capAllowed
permission.CapAllowed = permission.Allowed
return []RolePermissionRecord{permission}, nil
out := make([]RolePermissionRecord, 0, len(permissions))
projectManagement := false
for _, permission := range permissions {
descriptor, known := catalog.DescriptorByID(permission.ActionID)
switch {
case permission.ActionID == catalog.ProjectAccessAction:
permission.Allowed = permission.Allowed && permission.CapAllowed
permission.CapAllowed = permission.Allowed
projectManagement = permission.Allowed
case known && descriptor.Scope == catalog.ScopeProject:
// 项目范围动作按职位默认值投影
default:
// 系统范围旧行仍是不授权的审计数据,不进快照。
continue
}
out = append(out, permission)
}
// 角色只承载一个「项目管理」开关:开启即在参与的项目里获得项目内工作默认,
// 无需逐项配置;显式写出的项目范围行仍然优先。
if projectManagement {
seen := map[string]bool{}
for _, permission := range out {
seen[permission.ActionID] = true
}
for _, actionID := range catalog.ProjectManagementImpliedActions() {
if seen[actionID] {
continue
}
out = append(out, RolePermissionRecord{ActionID: actionID, Allowed: true, CapAllowed: true})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ActionID < out[j].ActionID })
return out, nil
}
func (s *Store) ListRoles(query, mode, status, actionID, cursor string, limit int) ([]RoleAdminView, string, error) {
+157 -35
View File
@@ -5,6 +5,8 @@ import (
"strings"
"sync"
"testing"
"agent-system-backend/internal/authz/catalog"
)
func rolePolicyAccounts(t *testing.T, st *Store) (Account, Account, Account) {
@@ -208,6 +210,26 @@ func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) {
}
}
// assertProjectManagementDefaults 断言「项目管理」开启时投影出的项目内默认动作。
func assertProjectManagementDefaults(t *testing.T, permissions map[string]RolePermissionRecord) {
t.Helper()
for _, actionID := range catalog.ProjectManagementImpliedActions() {
row, ok := permissions[actionID]
if !ok || !row.Allowed || !row.CapAllowed {
t.Fatalf("project management default %s missing: %+v", actionID, permissions)
}
}
}
func projectAccessRecord(permissions []RolePermissionRecord) *RolePermissionRecord {
for i, permission := range permissions {
if permission.ActionID == "project.create" {
return &permissions[i]
}
}
return nil
}
func capabilityRolePermissions(t *testing.T, st *Store, roleID string) map[string]RolePermissionRecord {
t.Helper()
permissions, err := st.RolePermissions(roleID)
@@ -245,9 +267,16 @@ func TestRoleProjectCreateLinksFullManagement(t *testing.T) {
}
permissions := capabilityRolePermissions(t, st, role.RoleID)
access := permissions["project.create"]
if len(permissions) != 1 || !access.Allowed || !access.CapAllowed {
if !access.Allowed || !access.CapAllowed {
t.Fatalf("permissions=%+v", permissions)
}
// 项目管理蕴含项目内工作默认;三权与知识发布不在其中。
assertProjectManagementDefaults(t, permissions)
for _, duty := range []string{"document.review", "document.approve", "document.publish", "document.duties.assign", "knowledge.publish"} {
if row, ok := permissions[duty]; ok && row.Allowed {
t.Fatalf("duty action %s implied by project management: %+v", duty, permissions)
}
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-creator"); err != nil {
t.Fatal(err)
}
@@ -277,7 +306,7 @@ func TestRolePolicyRejectsNonProjectAccessRowsAtomically(t *testing.T) {
epoch, _ := st.AuthorityEpoch()
conflict := []RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "project.settings", Allowed: false, CapAllowed: false},
{ActionID: "system.settings.write", Allowed: false, CapAllowed: false},
}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, conflict, "explicit-conflict"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("explicit role conflict: %v", err)
@@ -287,20 +316,22 @@ func TestRolePolicyRejectsNonProjectAccessRowsAtomically(t *testing.T) {
if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 || after.RoleVersion != role.RoleVersion || afterEpoch != epoch {
t.Fatalf("partial write: role=%+v epoch=%d->%d", after, epoch, afterEpoch)
}
// A linked action on its own is the same rejection: the role has no row to
// cap off any more, so nothing is written.
// 项目范围动作可以单独写入并即刻生效(项目内读写的职位默认值来源)。
version := role.RoleVersion
for i, only := range [][]RolePermissionChange{
{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}},
{{ActionID: "project.settings", Allowed: true, CapAllowed: true}},
{{ActionID: "document.duties.assign", Allowed: false, CapAllowed: true}},
{{ActionID: "document.read", Allowed: true, CapAllowed: true}},
{{ActionID: "document.author", Allowed: false, CapAllowed: true}},
} {
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, only, "linked-only-"+string(rune('a'+i))); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("linked-only batch %d accepted: %v", i, err)
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, version, only, "project-scope-"+string(rune('a'+i)))
if err != nil {
t.Fatalf("project-scope batch %d rejected: %v", i, err)
}
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 0 {
t.Fatalf("linked-only batch %d persisted=%+v", i, permissions)
version = receipt.RoleVersion
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) == 0 {
t.Fatalf("project-scope batch %d persisted nothing", i)
}
}
role.RoleVersion = version
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "enable-create")
if err != nil {
@@ -311,18 +342,20 @@ func TestRolePolicyRejectsNonProjectAccessRowsAtomically(t *testing.T) {
}
permissions := capabilityRolePermissions(t, st, role.RoleID)
access := permissions["project.create"]
if len(permissions) != 1 || !access.Allowed || !access.CapAllowed {
if !access.Allowed || !access.CapAllowed {
t.Fatalf("permissions=%+v", permissions)
}
// A later batch cannot resolve a conflict either, because no second row is
// accepted beside the project-access boolean.
resolved := append([]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
RolePermissionChange{ActionID: "project.members.manage", Allowed: false, CapAllowed: true})
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, resolved, "explicitly-resolved"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("explicit resolution: %v", err)
// 项目范围动作可以与项目准入并存在同一批次。
current, err := st.GetRole(role.RoleID)
if err != nil {
t.Fatal(err)
}
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 1 || !permissions["project.create"].Allowed {
t.Fatalf("rejected resolution changed permissions=%+v", permissions)
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, current.RoleVersion,
[]RolePermissionChange{{ActionID: "document.publish", Allowed: true, CapAllowed: true}}, "project-publish"); err != nil {
t.Fatalf("project publish row: %v", err)
}
if permissions := capabilityRolePermissions(t, st, role.RoleID); !permissions["document.publish"].Allowed {
t.Fatalf("project publish row missing=%+v", permissions)
}
}
@@ -388,16 +421,13 @@ func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) {
if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 {
t.Fatalf("roleless role kept permissions=%+v", permissions)
}
// A linked row cannot be written beside that grant holder either.
// 项目范围动作与系统授权并存:系统授权仍是唯一项目准入来源。
if _, err := st.ReplaceRolePermissions("admin", open.RoleID, open.RoleVersion,
[]RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}}, "cap-under-holder"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("role cap under grant holder: %v", err)
[]RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "read-under-holder"); err != nil {
t.Fatalf("project read under grant holder: %v", err)
}
if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 {
t.Fatalf("rejected role cap persisted: %+v", permissions)
}
if role, _ := st.GetRole(open.RoleID); role.RoleVersion != open.RoleVersion {
t.Fatalf("rejected write bumped role version: %+v", role)
if permissions := capabilityRolePermissions(t, st, open.RoleID); !permissions["document.read"].Allowed {
t.Fatalf("project read row under grant holder=%+v", permissions)
}
// Revoking the grant closes the loop again and reports it.
@@ -605,20 +635,40 @@ func TestRolePermissionsNormalizeLegacyRowsToProjectAccess(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
insertLegacyRolePermission(t, st, role.RoleID, "project.create", tc.allowed, tc.capAllowed)
// 项目范围旧行(document.read)随职位投影;系统范围旧行仍被丢弃;
// 项目准入开启时另带出项目管理蕴含的项目内默认。
permissions, err := st.RolePermissions(role.RoleID)
if err != nil || len(permissions) != 1 {
t.Fatalf("permissions=%+v err=%v", permissions, err)
if err != nil {
t.Fatal(err)
}
got := permissions[0]
if got.ActionID != "project.create" || got.Allowed != tc.want || got.CapAllowed != tc.want {
byAction := map[string]RolePermissionRecord{}
for _, permission := range permissions {
byAction[permission.ActionID] = permission
}
got := byAction["project.create"]
if got.Allowed != tc.want || got.CapAllowed != tc.want {
t.Fatalf("normalized=%+v want=%v", got, tc.want)
}
if read := byAction["document.read"]; !read.Allowed || !read.CapAllowed {
t.Fatalf("project scope row dropped=%+v", permissions)
}
if tc.want {
assertProjectManagementDefaults(t, byAction)
}
for _, permission := range permissions {
if strings.HasPrefix(permission.ActionID, "system.") {
t.Fatalf("system row projected=%+v", permissions)
}
}
view, err := st.RoleAdmin(role.RoleID)
if err != nil || view.ProjectAccess != tc.want || len(view.Permissions) != 1 || view.Permissions[0].ActionID != "project.create" {
if err != nil || view.ProjectAccess != tc.want || len(view.Permissions) < 2 {
t.Fatalf("role admin=%+v err=%v", view, err)
}
if access := projectAccessRecord(view.Permissions); access == nil || access.Allowed != tc.want {
t.Fatalf("role admin access row=%+v want=%v", access, tc.want)
}
roles, _, err := st.ListRoles("", "fuzzy", "all", "", "", 20)
if err != nil || len(roles) != 1 || roles[0].ProjectAccess != tc.want || len(roles[0].Permissions) != 1 {
if err != nil || len(roles) != 1 || roles[0].ProjectAccess != tc.want || len(roles[0].Permissions) < 2 {
t.Fatalf("list roles=%+v err=%v", roles, err)
}
allowed, capAllowed, err := st.RolePermissionAllowed("alice", "project.create")
@@ -711,12 +761,14 @@ func TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean(t *testing.T) {
}
return allowed, capAllowed, rows
}
// 项目范围动作(如 document.read)现在可以随职位一起定义;受保护的系统授权
// 仍然拒绝,并且任何失败批次都不部分写入。
for i, bad := range [][]RolePermissionChange{
{{ActionID: "project.create", Allowed: true, CapAllowed: true}, {ActionID: "system.settings.read", Allowed: true, CapAllowed: true}},
{{ActionID: "project.create", Allowed: true, CapAllowed: true}, {ActionID: "system.users.create", Allowed: false, CapAllowed: false}},
{{ActionID: "document.read", Allowed: true, CapAllowed: true}},
{{ActionID: "project.create", Allowed: true, CapAllowed: true}, {ActionID: "project.create", Allowed: true, CapAllowed: true}},
{{ActionID: "project.create", Allowed: true, CapAllowed: false}},
{{ActionID: "document.read", Allowed: true, CapAllowed: false}},
} {
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, bad, "bad-"+string(rune('a'+i))); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("batch %d accepted: %v", i, err)
@@ -1252,3 +1304,73 @@ func getRoleByName(st *Store, name string) (RoleRecord, bool) {
}
return roles[0].RoleRecord, true
}
// TestRoleCarriesProjectScopeDefaults covers the project-scope role rows: a role
// may define the project defaults that RDM projects them onto project work, and
// a row that caps a linked management action is reported as a conflict instead of
// being silently ignored.
func TestRoleCarriesProjectScopeDefaults(t *testing.T) {
st := openTestStore(t)
_, alice, _ := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "项目工作职位"})
if err != nil {
t.Fatal(err)
}
alice, _ = st.GetAccount(alice.ID)
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-work"); err != nil {
t.Fatal(err)
}
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "document.read", Allowed: true, CapAllowed: true},
{ActionID: "document.author", Allowed: false, CapAllowed: true},
}, "project-defaults")
if err != nil {
t.Fatal(err)
}
permissions, err := st.RolePermissions(role.RoleID)
if err != nil {
t.Fatal(err)
}
byAction := map[string]RolePermissionRecord{}
for _, permission := range permissions {
byAction[permission.ActionID] = permission
}
if !byAction["project.create"].Allowed || !byAction["document.read"].Allowed {
t.Fatalf("project defaults=%+v", byAction)
}
if byAction["document.author"].Allowed {
t.Fatalf("explicit revoke=%+v", byAction["document.author"])
}
alice, _ = st.GetAccount(alice.ID)
explanation, err := st.ExplainCapabilities("alice")
if err != nil {
t.Fatal(err)
}
actions := explainActionMap(explanation.MemberBasicCollaborate.Actions)
if read := actions["document.read"]; !read.Allowed || read.Source != "role" {
t.Fatalf("member read=%+v", read)
}
if author := actions["document.author"]; author.Allowed {
t.Fatalf("member author=%+v", author)
}
// 项目准入开启时不得在同一职位里收回 linked 管理动作(既有不变量)。
capped, err := st.CreateRole("admin", RoleCreate{Name: "受限工作职位"})
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, []RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "project.members.manage", Allowed: false, CapAllowed: false},
}, "cap-linked"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("linked deny beside create accepted: %v", err)
}
// 同一动作的显式授予合法,并立即成为快照里的默认值。
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, []RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "project.members.manage", Allowed: true, CapAllowed: true},
}, "allow-linked"); err != nil {
t.Fatalf("linked allow beside create: %v", err)
}
_ = receipt
}