Compare commits

...
Author SHA1 Message Date
weichen 03f7f39a9b Merge origin/main into fix/agent-stack-audit-b1
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m20s
2026-10-22 03:58:45 +00:00
weichen 92a4118381 Merge pull request 'Complete the W9 host-audit surface per the approved requirement' (#243) from fix/w9-host-audit-surface into main
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 1m39s
2026-10-22 03:45:04 +00:00
agent-exec-transport 97f60c1df6 agent-exec transport f461d74086dd6a80db72f83748f34b04/128fee1609ad
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m20s
2026-10-22 03:41:15 +00:00
weichen 275b1e234d Merge pull request 'Close the W6 review MINOR findings' (#242) from fix/w6-review-minors into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Successful in 1m25s
2026-10-22 03:07:56 +00:00
agent-exec-transport 4c2db4c89b agent-exec transport baad876f1320016310be2522c373a71b/9d3ed5593bfb
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m27s
2026-10-22 03:03:31 +00:00
weichen b6141800cc Merge commit '528e3a4c1ff9b59c4df998f4fffb39f0b52dbbcd' into fix/agent-stack-audit-b1
ci / vet-and-test (pull_request) Successful in 2m3s
governance / governance (pull_request) Successful in 14s
2026-10-21 18:24:33 +00:00
weichen 1f61131e72 Pin the nested agent-squad to the sqlite test alignment
Why: composition consistency - the squad container advanced to the
U3B repair tip; backend's nested copy must match (check-pins).
Evidence: pin only; squad store tests pass (verify-u3br2).
Impact: nested gitlink pointer change.
Refs: feature/agent-stack-audit-remediation-b1
2026-10-21 18:06:17 +00:00
weichen f8a190801c Pin nested agent-native and agent-squad to the batch-1 fixes
Why: composition consistency - Dev pins agent-native at the U3A tip
and agent-squad at the U3B/U4 tip; the backend container's nested
copies must match (check-pins).
Evidence: pin only; all libraries verified per-unit.
Impact: nested gitlink pointer changes.
Refs: feature/agent-stack-audit-remediation-b1
2026-10-21 17:46:17 +00:00
weichen 49c1259e00 Keep the server admission test inside the intent boundary
Why: the U6 admission test imported agent-intent directly from
internal/server, which lint-boundaries forbids (only intentgate,
intentsem and the squad intent adapter may import it).
Evidence: repair receipt success; server/intentgate/route tests pass
(verify-u6r.json); fixtures moved behind intentgate helpers.
Impact: test-only rework plus a small intentgate fixture helper.
Refs: feature/agent-stack-audit-remediation-b1 U6 repair
2026-10-21 17:35:53 +00:00
weichen 4030449f4b Report honest receipts when handlers err after side effects
Why: F10 - any handler error became a no-side-effect rejected receipt
and an audit-store failure dropped the generated operation ID, losing
the reconciliation handle and inviting unsafe retries.
Evidence: cmdbus tests pass (verify-u7b.json); post-execution-unknown,
audit-failure-keeps-ID and confirmation-idempotency cases added.
Impact: unknown receipts now carry the trusted operation ID via the
U7A constructor; rejected stays ID-free; no auto-replay anywhere.
Refs: feature/agent-stack-audit-remediation-b1 U7B
2026-10-21 16:55:01 +00:00
weichen e8d0c9bd2f Bump the nested agent-command pin to the U7A receipt fix
Why: U7B needs unknown-with-trusted-operation-ID receipts and the
executed-vs-not result distinction landed by U7A.
Evidence: pin only; library verified in deps/agent-command (verify-u7a).
Impact: nested gitlink pointer change.
Refs: feature/agent-stack-audit-remediation-b1
2026-10-21 16:34:13 +00:00
weichen 0e55875329 Admit intents with full slot validity and schema-typed slots
Why: F09 - invalid slot information was dropped at the gate, the router
saw only Start, and the command entrance passed untyped text, so
invalid-slot recognitions could still reach business execution paths.
Evidence: intentgate/route/server tests pass (verify-u6.json);
admission regression cases added.
Impact: invalid or missing slots never start executable actions;
clarification flows unchanged; cmdbus confirmation untouched.
Refs: feature/agent-stack-audit-remediation-b1 U6
2026-10-21 16:34:12 +00:00
weichen 406cffdc37 Bump the nested agent-intent pin to the U5 snapshot fix
Why: U6 needs the authoritative recognition result with full slot
validation and isolated snapshots landed by U5.
Evidence: pin only; library verified in deps/agent-intent (verify-u5).
Impact: nested gitlink pointer change.
Refs: feature/agent-stack-audit-remediation-b1
2026-10-21 16:15:51 +00:00
weichen 80faf3edbd Resolve squad routes from the run owner and project
Why: F13 - the squad factory read the default account with an empty
project, pairing the run user's model name with another account's
endpoint and credentials; direct and squad paths disagreed on
reasoning split and route contract.
Evidence: app/runtime/squadadapter tests pass (verify-u10b.json);
run-scoped snapshot, project override, profile override and
fail-closed cases added with synthetic keys.
Impact: squad execution now uses the same effective-config rules as
the direct path; default setups unchanged.
Refs: feature/agent-stack-audit-remediation-b1 U10
2026-10-21 16:15:03 +00:00
weichen 16538f1c46 Merge pull request 'Port the IAM permission-management API from the abandoned iam-perm-ux branch' (#240) from fix/iam-perm-ux-port into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Successful in 1m22s
2026-10-21 15:38:14 +00:00
weichen 4a8cc0170b Merge remote-tracking branch 'origin/main' into fix/iam-perm-ux-port
governance / governance (pull_request) Successful in 23s
ci / vet-and-test (pull_request) Successful in 1m42s
2026-10-21 15:14:16 +00:00
weichen c5a599e28d Record port scope, build fix and PASS review for the IAM permission API
governance / governance (pull_request) Successful in 29s
ci / vet-and-test (pull_request) Successful in 1m31s
Why: the port's authorization boundary, the build correction and the review
verdict must be recorded before delivery.

Evidence:
  /tmp/be-iam-perm-ux-impl-r1-20261021.json: pi, success (port)
  /tmp/be-iam-perm-ux-buildfix-r2-20261021.json: pi, success (build fix;
    r1 failed-unknown produced no record so nothing was imported)
  /tmp/be-iam-perm-ux-review-r1-20261021.json: pi, PASS, no blockers
  Host-side checks on the final head: gofmt -l clean; go build ./... and
  go vet on store/server/authz silent; go test on those packages green.

Impact: card metadata only.
Refs: fix/iam-perm-ux-port; backup /home/weichen/projects/backups/iam-perm-ux-20261021T140345Z
2026-10-21 15:13:59 +00:00
agent-exec-transport 53e78ffb73 agent-exec transport 5aab730b1b5c159f2ce8f0cf9b2c50b5/5dfb74fd02f1 2026-10-21 15:13:33 +00:00
agent-exec-transport f10fb2e65c agent-exec transport 362d13b3c56f54861bc1cae9cd38cc36/2b675cb19c4f 2026-10-21 15:01:35 +00:00
agent-exec-transport 76abebe790 agent-exec transport 64b951970cf4308149cee90d2ab93fbb/078c7847c409 2026-10-21 14:41:59 +00:00
weichen 2d1e5f5b16 chore(task): start fix/iam-perm-ux-port 2026-10-21 14:13:28 +00:00
56 changed files with 6703 additions and 199 deletions
+68
View File
@@ -0,0 +1,68 @@
# fix/iam-perm-ux-port 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:进行中;owner:weichen
## 基线与授权
- 权威仓:ssh://git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git;root_target:main;immediate_target:main
- branch_kind:standalone;initiative_id:
- R0(initial_main_sha):01f4c47a2b5798c9b43fffb9e298aa87138a1537;工作区:/home/weichen/projects/Agent-System-Backend-wt-fix-iam-perm-ux-port(worktree);分支:fix/iam-perm-ux-port
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=no accepting initiative;triage 收据=/tmp/be-triage-iam-perm-ux-20261021.json;receipt_sha256=bb4a700446f22b5799e94f1c6efa488c4a30b65814a457b76ab41d30b0dc5038;requirement_sha256=9495f0dc1c3cbf04feb6c712bfa93c6bbb6803801285a518ae9f309c74ec836b
- 授权与交付终点:把仅存在于被放弃分支 feature/iam-perm-ux-backend 的权限管理 API 移植到当前 main,使其可从主干维护与调用。冲突一律以 main 现状裁决,不照搬旧实现;不得绕过 main 的账户作用域写入围栏与授权检查;不得新增范围外功能或 schema 迁移。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标:main 此前不具备七个权限管理入口(PatchRolePermissionsBatch、PreviewRolePermissions、AssignUserRolesBatch、PreviewUserRoleAssignments、ListRoleMembersForActor、ActiveRolesForUsers、CreateRoleCopyAndAssign)与配套类型(RoleBinding、PolicyReceipt、RoleAdminView、CapabilityAssociation、RoleAuthorityImport*、RoleCreate/RolePatch、RolePermissionChange/Conflict)。本任务把这些能力移植到当前 main(其间 Backend 已合入 #228、#234),并保留其安全语义。
非目标:不整条合并/cherry-pick 旧分支(其基线早于 #228/#234);不新增范围外功能;不做无关重构;除移植确需新表外不改 schema;不改 Makefile/CI/子模块/文档。
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 复用材料(task-start 自动检索结果摘录)
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
- 相关经验条目:<检索命中;无则写无>。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
| 验收前 | <填写> | <填写> | <填写> | <填写> |
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
## 交付调度(有多个就绪任务时)
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:七个权限管理入口(PatchRolePermissionsBatch、PreviewRolePermissions、AssignUserRolesBatch、PreviewUserRoleAssignments、ListRoleMembersForActor、ActiveRolesForUsers、CreateRoleCopyAndAssign)与配套类型(RoleBinding、PolicyReceipt、RoleAdminView、CapabilityAssociation、RoleAuthorityImport*、RoleCreate/RolePatch、RolePermissionChange/Conflict)全部移植到当前 main 的角色/权限模型上,8 文件 / +2184 行。移植首版并未编译通过(role_policy.go:2394 向 upsertRolePermissionsTx 传参错位:空 map 当 current、slice 当 after),已由独立 repair 单元按 main 的差量语义修正。
- 验证:PASS——gofmt -l 干净;go build ./... 与 go vet ./internal/store/ ./internal/server/ ./internal/authz/... 均无输出(宿主独立执行);go test -count=1 ./internal/store/ ./internal/server/ ./internal/authz/... 全绿(store 13.3s / server 29.5s / catalog ok);独立 Review PASS、无阻塞项,逐 API 核对安全语义。
- 影响与剩余项:只新增能力面,不改 schema(移植未引入新表),不改 Makefile/CI/子模块;未新增范围外功能。非阻塞遗留:Review 指出 PostgreSQL 下不同角色跨 batch 处理的潜在 lock-ordering 风险,以及若干测试缺口,两者已记入报告留作后续增量。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
@@ -0,0 +1,441 @@
# 独立代码审查:fix/iam-perm-ux-port(IAM 权限管理 API 移植)
- 审查基线(main 当前 HEAD):01f4c47a2b5798c9b43fffb9e298aa87138a1537(merge #238 前一刻)
- 审查终点(fix/iam-perm-ux-port HEAD):f10fb2e
- 审查 commit 范围:01f4c47a2b5..HEAD(3 提交,2 个 agent-exec transport + 1 个 task-start)
- 受影响文件:8 个源码文件 / .task/fix-iam-perm-ux-port.md
- 审查者:独立审查会话(未参与实现)
- 审查时间:2026-10-21 UTC
- 仓库:`/tmp/agent-exec-1000/5aab730b1b5c159f2ce8f0cf9b2c50b5/wt-5dfb74fd02f1`
- 工作区状态:detached HEAD @ f10fb2e(无未提交修改)
## 审查结论:PASS
本审查未发现阻塞项。七个移植 API 在语义层与 main 现有 role_policy.go / roles.go / account_search.go
一致;安全语义保持 main 现有的 `system.permissions.*` / `system.roles.*` 授权门 + 账户作用域写入围栏
(含 `requireSuperAdminTargetAccess` / `roleGrantsAccountAdministrationTx` / `requireRoleAuthorityWritableTx` /
`policyWriteBatch` 等);事务与一致性、审计与回执、与 #228/#234 的关系、测试质量均无证据证明存在
继承能力丢失或静默行为改变。仅有一处非阻塞担心(PostgreSQL 下不同角色跨 batch 的
潜在 lock-ordering 风险)与若干测试缺口,已列入"非阻塞建议"。
---
## 1. 受影响文件与体量(依据:`git diff --stat`)
```
.task/fix-iam-perm-ux-port.md | 66 ++
internal/authz/catalog/catalog_test.go | 16 +
internal/server/account_search.go | 126 +++
internal/server/role_authorization_test.go | 226 ++++++
internal/server/roles.go | 162 +++
internal/server/server.go | 6 +
internal/store/role_policy.go | 1141 ++++++++++++++++++++++++++++
internal/store/role_policy_test.go | 494 ++++++++++++
```
实际为 8 个源码/任务文件(与 packet 的 8 文件一致);packet 标的 "+2184" 略低于实际
`+2237`,差异来自本盘点把 `.task/fix-iam-perm-ux-port.md` 计入或不算的口径差——本评审
只把它当作任务卡,不审其内容。
---
## 2. 七个 API 与对应实现
| # | 端点(routes,`server.go`) | handler(`server/roles.go` / `account_search.go`) | store(`store/role_policy.go`) | 授权闸 |
|---|---|---|---|---|
| 1 | `POST /api/admin/roles/permissions/preview` | `listRolePermissionPreview` | `PreviewRolePermissions` / `previewRolePermissionsTx` | `system.permissions.view` |
| 2 | `POST /api/admin/roles/permissions/batch` | `batchPatchRolePermissions` | `PatchRolePermissionsBatch` / `patchRolePermissionsTx` / `upsertRolePermissionsTx` | `system.permissions.manage` |
| 3 | `POST /api/admin/roles/copy-assign` | `copyAssignRole` | `CreateRoleCopyAndAssign` | `system.roles.manage`+`system.permissions.manage`+`system.roles.assign`(三同时) |
| 4 | `GET /api/admin/roles/{roleId}/members` | `listRoleMembers` | `ListRoleMembersForActor` | `system.users.view`+`system.roles.view`(两同时) |
| 5 | `POST /api/admin/users/role-assignments` | `assignUserRolesBatch` | `AssignUserRolesBatch` / `assignRolesTx` / `rebindUserRoleTx` / `checkRoleAssignmentTx` | `system.roles.assign` |
| 6 | `POST /api/admin/users/role-assignments/preview` | `previewUserRoleAssignments` | `PreviewUserRoleAssignments` / `activeRoleTx` | `system.users.view`+`system.roles.view` |
| 7 | `GET /api/admin/users/search`(追加 role 投影) | `searchAdminUsers`(修改) | `ActiveRolesForUsers` | `system.users.view`;role 字段附加条件 `system.roles.view`(`systemActionAllowed` 静默跳过) |
类型承载面(`store/role_policy.go` 新增):`RolePermissionPreviewInput`、`RolePermissionStateChange`、
`RolePermissionConflict`、`RolePermissionPreview`、`RolePermissionPatchInput`、
`RolePermissionBatchReceipt`、`RolePermissionBatch`、`UserRoleAssignmentInput`、
`UserRoleAssignmentResult`、`UserRoleAssignmentBatch`、`UserRoleAssignmentPreview`、
`RoleCopySource`、`RoleCopyAssignment`、`RoleCopyAssignResult`、`RoleMember`、`UserRoleSummary`。
常量、错误:`RolePermissionPreviewReplace`/`Patch`、`permissionStateDenied/Project/Default/Invalid`、
`linkedExpansionLinked`、`maxRolePermissionPreviewItem=100`、`maxRolePermissionBatch=20`、
`maxRoleAssignmentBatch=100`、`roleAssignmentUserMissing/Disabled/StateChanged/RoleInvalid`、
`rolePermissionChangeError`(带 `Unwrap()` 指向 `ErrInvalidRolePolicy`,与既有错误链兼容)。
---
## 3. 重点 1 —— 移植正确性(语义一致)
主要结论:未发现语义丢失或静默改变;与 main 现有 role_policy.go / roles.go / account_search.go
行为对齐。
- `normalizePermissionChanges`(store/role_policy.go:1474-1504):与 main 现有的
`ReplaceRolePermissions`(同文件 line 379)一样把"非 project.create"和"受保护动作"
在写入前拒绝;duplicate 拒绝;"Allowed=true && CapAllowed=false"非法组合拒绝。
对 `project.create` 之外的 audit 行保持原状(patch 语义),与
`TestRolePermissionsNormalizeLegacyRowsToProjectAccess`(main 既有的旧审计行单点投影测)
行为兼容。
- `RolePermissionStateChange` 报告 `Before`/`After` 是按当前行 vs 合并后行计算的,并跳过
`ProjectAccessAction` 自身(`rolePermissionStateChanges` 把 expansion 跳过),所以预览
在关闭 project.create 时不会把 projection-row 误判为增删。
- 关闭 project.create 后 `KeptLinkedActions` 仅列举"原本就显式有 row 的"linked 动作
(`keptLinkedRows`);没有 row 的不会出现在 migration note 中,避免误导。
- `CreateRoleCopyAndAssign` 选择 `current=nil` 作为新角色的 prior 状态(注释明确解释),
因为新角色行是在同一事务内插入的;这与原 feature 分支行为一致,避免在 helper 里
伪装既有行。
- `AssignUserRolesBatch` 用与 `AssignUserRole` 同形态的 SQL
(`UPDATE user_role_bindings SET active=0 ... ; INSERT ... ; UPDATE users SET state_version=state_version+1 WHERE state_version=?`)
复用既有单用户路径所具备的乐观并发控制——`state_version` 重核确保 rebind 不覆盖并发变更。
- `PolicyReceipt` 包含的 `ReceiptID` / `AuthorityEpoch` 直接读自 `PolicyReceipt` 结构
(已有),`RolePermissionBatchReceipt` 嵌入了该类型——回执格式与传统 `ReplaceRolePermissions`
完全对齐,没有改写 main 既有的回执 schema。
- `RoleAdminView` 与既有的 `RoleAdmin`/`ListRoles` 投影共享 `RoleRecord + Permissions + ProjectAccess`
复合字段(`RoleCopyAssignResult.Role` 直接构造),不引入新读路径。
未验证:原 feature 分支的代码已不在本仓(packet 注:"唯一副本已备份",且本工作区 detached HEAD
无源分支 ref)。本评审只能与 main 现状 + diff 做对比,无法逐行比对其与原版的差异。这是结构性
限制,已在"未验证"列出。
---
## 4. 重点 2 —— 安全语义(最高优先,逐 API 核对)
| API | 入口授权 | store 内额外防御 | 是否绕过 main 既有 owner/admin 校验 / isAdmin 判定 / account-checked 写路径 |
|---|---|---|---|
| listRolePermissionPreview | `requireSystemAction("system.permissions.view")`(非 admin 路径同样拒绝) | `PreviewRolePermissions` 起手 `actor=="" → ErrAccountForbidden`;开 tx 后 `requireEnabledActorTx` 二次校验 actor 行;逐 item `previewRolePermissionsTx` 不写盘;没有走 `policyWrite`/`policyWriteBatch` | 否;纯读 |
| batchPatchRolePermissions | `requireSystemAction("system.permissions.manage")` | `PatchRolePermissionsBatch` 走 `policyWriteBatch(actor, nil, …)` —— 即 `lockAccountUsers(actor)`,再 `requireRoleAuthorityWritableTx`,再 `replayReceipt`(同 key + 同 request_hash 才允许重放);每 item 通过 `s.roleTx(item.RoleID, true)` 在 Postgres 上对 role row 加 `FOR UPDATE`;每个 item 走 `normalizePermissionChanges`(拒绝非 project.create / 受保护 / duplicate / 非法组合),`patchRolePermissionsTx` 内 `s.upsertRolePermissionsTx` 按 patch 语义 upsert,并对 `roles SET role_version=role_version+1 WHERE role_version=expected` 二次乐观并发 | 否;任何 `system.permissions.manage` 持有人都是该路径的合法写入者(与 `ReplaceRolePermissions` 对等,没有 target-side 校验,因为这是 system 层面的角色authority) |
| assignUserRolesBatch | `requireSystemAction("system.roles.assign")` | `policyWriteBatch(actor, userIDs, …)` —— 同时锁 actor + 全部 target owner;`assignRolesTx` 内逐 item 调用 `requireSuperAdminTargetAccess(actorAccount, target)`(R5 抗 super-admin 篡改),非 super-admin 还检查 `roleGrantsAccountAdministrationTx(tx, item.RoleID)`(防止把 admin 角色批给用户形成提权路径,R4);`checkRoleAssignmentTx` 校验 disabled / state_version / role enabled;`rebindUserRoleTx` 在 SQL 层再用 `state_version=? AND enabled=?` 重核;最后 `checkCapabilityAssociationTx`(project.create 联合关联门);一笔失败全事务回滚 | 否;与单用户 `AssignUserRole` 同等保护,没有批量化造成的"开窗" |
| previewUserRoleAssignments | `requireSystemAction("system.users.view")` + `system.roles.view`(双闸) | `PreviewUserRoleAssignments` 起手 `actor==""` 与 `validateRoleAssignmentItems`;开 tx 后 `requireEnabledActorTx` 二次校验 actor;逐 item 读 users 行并构造 preview,`account == nil` 通过 `roleAssignmentUserMissing` 区分;不写盘,不调 `policyWriteBatch` | 否;纯读;返回的字段范围与 `getUserRole` 视图对齐(owner/name/current role/状态匹配/错误原因) |
| copyAssignRole | 三闸(`system.roles.manage`/`system.permissions.manage`/`system.roles.assign`) | `CreateRoleCopyAndAssign` 走 `policyWriteBatch(actor, userIDs, …)`;源 role `roleTx(..., true)` 行锁;`requireRoleAuthorityWritableTx`;按源 role 的 row 拷贝非受保护动作(`descriptor.Protected` 跳过),覆盖用 `normalizePermissionChanges`(同样拒绝非 project.create);源 enabled project.create 但 merge 后 disabled 的情形被显式拒绝(`sourceWasCreator && !copyIsCreator → ErrInvalidRolePolicy`);写后 `checkCapabilityAssociationTx`;最后通过 `assignRolesTx` —— 与 `assignUserRolesBatch` 完全相同的 anti-elevation + state-version 二次校验 | 否;copy 路径不会复制系统 grant(`copied` map 跳过 protected),不会复制 `IsSuperAdmin`/admin 身份(`CreateRole` 只插 roles 行),不会复制 project membership(不参与任何 project_* 表) |
| listRoleMembers | `requireSystemAction("system.users.view")` + `system.roles.view`(双闸) | `ListRoleMembersForActor` 在 Postgres 走 `BeginTx(..., LevelRepeatableRead)`,SQLite 走普通 tx;起手 `lockAccountUsers(actor)`,对 actor 行 `FOR UPDATE`;然后 `requireSystemActionTx(tx, locked[actor], "system.users.view")` 与 `system.roles.view` 二次校验;`s.roleTx(roleID, false)` 校验 role 存在;SQL `SELECT ... FROM user_role_bindings b JOIN users u ON u.id=b.user_id WHERE b.role_id=? AND b.active=?`;附带 `account_directory_revision` 同一快照读 | 否;handler 层 cursor 用 `accountSearchKey` HMAC 绑定到 role/query/mode/status/private revision,不能跨 role/param 重放(`parseAdminCursor` `hmac.Equal` + version 比对) |
| searchAdminUsers 的 role 投影 | 入口:既有 `system.users.view`(无变化) | 投影附加条件 `systemActionAllowed(actor, "system.roles.view")` —— 不开 reader 时静默跳过(无 role 字段),与既有的 read projection 模式一致 | 否;授权差异只在响应字段是否多一项 `role` 上,没有改变授权面。无 role 时一律为 `null`,无信息泄露 |
关于越权路径的关键判断:
- **检查 1:super-admin 保护**。`assignUserRolesBatch` 通过 `assignRolesTx` 强制 `requireSuperAdminTargetAccess`。
即便 actor 拿到 `system.roles.assign` 与 is_admin,把一个非自身的 super-admin 用户塞进
assignments 也立刻被拒绝。`copyAssignRole` 通过 `assignRolesTx` 走的是同一函数,复用同保护。
- **检查 2:admin 角色 escalation**。`roleGrantsAccountAdministrationTx` 检查目标 role 是否
携带任何 `system.users.* / system.roles.* / system.permissions.*`,非 super-admin 一律拒绝。
与单用户 `AssignUserRole` 行为完全一致。无绕过。
- **检查 3:account-checked 写路径**。三个写 API(`PatchRolePermissionsBatch` / `AssignUserRolesBatch` /
`CreateRoleCopyAndAssign`)通过 `policyWriteBatch` 借用 main 既有 account 锁路径
(`lockAccountUsers` `FOR UPDATE` + enabled 校验 + 一致 ID/owner 比对),与 `policyWrite`
是同一家族。可以理解为对现有单写围栏的"批量化",没有引入新的、非现有围栏之外的捷径。
- **检查 4:跨账户读取**。`ListRoleMembersForActor` / `PreviewUserRoleAssignments` /
`searchAdminUsers` 三个读取入口都已就授权闸;没有出现"通过 batch 路径可以读到他人 role-membership
但单 API 不可读"的二通道。
- **检查 5:审计/回执旁路**。`policyWriteBatch` 包裹的写入最后都走 `saveReceipt`(除 `CreateRole`
类型既有的纯插入路径外),未发现可以既写盘又不留 `policy_receipts` 行的调用点 —— 审计门未开窗。
---
## 5. 重点 3 —— 事务与一致性
`policyWriteBatch` 的事务结构与 `policyWrite`(同文件 line 181)严格同形态:
1. 把 actor 与所有 target owner 加入 owner 集合;
2. `s.db.Begin()`,defer Rollback;
3. `lockAccountUsers(tx, owners...)` —— owner 字典序排序后逐个 `FOR UPDATE`,规避锁顺序;
4. 校验 `actorAccount.Enabled` 与每个 target `target.Enabled && target.ID == userID`;
5. 读 `authority_state.authority_epoch`(PG 下 `FOR UPDATE`);
6. 调用上层闭包;
7. 闭包负责回写 epoch 与保存 receipt;
8. `tx.Commit()`。
提交或回滚边界:
- `AssignUserRolesBatch`:验证循环 + rebind 循环均在同一事务;任何一次 `ErrAccountForbidden /
ErrConflict / ErrInvalidRolePolicy` 都立即返回事务回滚;`rebindUserRoleTx` 内的
`UPDATE users SET state_version=state_version+1 WHERE id=? AND state_version=? AND enabled=?`
在 SQL 层提供最后一道乐观并发;批内任意用户状态版本与 `expectedStateVersion` 不匹配即整批回滚。
- `PatchRolePermissionsBatch`:每个 item 走 `patchRolePermissionsTx`,内部 `roleTx(... true)`;
`s.upsertRolePermissionsTx` 按 patch 语义只写 patch 列表中的行(未在 patch 列表的行保持原样
不动 —— 这与"显式 patch"语义对齐,不会误把删除当作新增);`UPDATE roles SET role_version=role_version+1
WHERE role_id=? AND role_version=?` 拒绝过期;随后 `checkCapabilityAssociationTx(tx, nil, roleIDs, nil)`
—— 只检查涉及的 role 列表的 project.create + linked 关联。
- `CreateRoleCopyAndAssign`:源 role row 锁、新 role insert、`upsertRolePermissionsTx(... nil ...)`、
重新把 `items[].RoleID = roleID`(设置后立即调用 `assignRolesTx`)然后 `checkCapabilityAssociationTx`。
关闭源已有的 project.create 又把 copy 设成非 creator 的情形被显式拒绝
(`sourceWasCreator && !copyIsCreator → ErrInvalidRolePolicy`),这避免了"复制即失去能力"
的伪操作混淆。
`current→after` 增量计算是否会把删除误判为新增:
- `previewRolePermissionsTx`:用 `current = s.rolePermissionBitsTx(tx, role.RoleID)` 与
`next = normalizePermissionChanges(item.Changes)`,合并 `after`,对所有 `catalog.Catalog()`
内动作比对 `Before/After` 状态不同者写入 `Changes`;删除本质上等同"原本某 row 状态 X,目标 Y",
不会进入 `Changes` 错查为"add"(因为 `current` 中已有 row)。
- `patchRolePermissionsTx`:以 `current` 为基础 `after` 起点,`patch` 覆盖 `after`;`upsertRolePermissionsTx`
对比 `current`/`after` 后只对真正变化者写。删除语义上等同于"原 row 留在 patch 之外即不动"
(patch 不写删除显式动作,故删除行为是 patch 之外的子集)—— 这是 patch 语义而非 replace 语义,
在端口注释里有显式说明 (`// Only listed actions change; every unlisted row of the role stays exactly as stored`)。
回滚完整性(基于代码路径核查,每条都在收到 err 时让事务回到 Begin 状态,因为没有分支绕过 defer
`tx.Rollback()`,且所有写入函数都用同一 `tx`):
- `policyWriteBatch`:`tx.Commit()` 之前的任何 error 都回滚;commit 失败由 defer 再 Rollback 兜底。
- 三个写 store 函数都没有副作用排序的反向操作(除 rebindUserRoleTx 之外)。
未验证:未运行 `go test ./...` —— 见"未验证"节。
---
## 6. 重点 4 —— 审计与回执
- 三个写 API 都调用 `policyWriteBatch` 并在闭包内 `saveReceipt(tx, actorAccount.ID, key, operation, requestHash, out)`,
写入 `policy_receipts` 表(既有 main 表)。
- 操作名分别用 `"role.permissions.batch_patch"`、`"user.role.assign.batch"`、`"role.copy.assign"`,
都是独有的操作标签;replay 通过 `(actor_user_id, idempotency_key)` 复合唯一索引保证幂等。
- `replayReceipt` 在 hash 不一致时返回 `ErrConflict`;与 `TestRolePolicyIdempotencyKeyCannotChangeRequest`
既有的幂等语义一致 —— 同样 key + 不同 request 必然拒收。
- `PolicyReceipt` 与 `RolePermissionBatchReceipt` 都嵌入了 `PreviousRoleVersion` / `Changes` /
`KeptLinkedActions` 等可复核字段。回执包含 `ReceiptID` (`newID("receipt")`) 与
`AuthorityEpoch = epoch+1`,方便把回执与下一次 authority epoch 增量对齐。
- `CreateRoleCopyAndAssign` 在写盘后用 `s.roleTx(roleID, false)`(不带锁,二次 snapshot)+
`s.rolePermissionRecords(...)` 重新读 `role_permissions` 完整集合(含历史 audit 行),计算
`ProjectAccessOf`,保证回执中 `ProjectAccess` 与 `RoleAdmin`/`ListRoles` 共享投影。
- `RolePermissionConflict` 仅在预览路径出现 —— 由 `state.conflicts()` 推导(既有的
`capabilityConflict`、`capabilityConflictReason`),与 `checkCapabilityAssociationTx` 共用同一
套冲突识别逻辑。冲突原因 `使用此角色的用户已直接获授创建项目...` / `创建项目需要本人项目...` 都是
user-facing 中文,不暴露内部 ID。
- 审计旁路检查:grep 没有发现 `tx.Exec(...)` 后绕过 `saveReceipt` 的旁路;
`policyWriteBatch` 接受的是一个闭包,闭包内未调用 `tx.Commit()`(commit 由 `policyWriteBatch` 唯一持有),
这确保 batch 写后必须走 `saveReceipt` 才能 commit 落盘。
未发现绕过审计的写入。
---
## 7. 重点 5 —— 与 #228/#234 的关系
#228(Add read-only role options, a member roster directory and a removed-member send refusal)
引入:
- `pkg/extension/RoleOption` / `RoleOptionsProvider.ListRoleOptions`(read-only enabled
role directory;走 `h.caps.st.ListRoles` 投影为 `roleId+name`)
- `pkg/extension.AccountDirectoryPurposeProjectRoster`(project roster 枚举)
- `internal/server/server.go` postMessage 中 "not-project-member" 路径
端口新增:
- `RolePermissionPreviewInput` / `RolePermissionPreview`(管理员对角色权限的变更预览);
- `RolePermissionPatchInput` / `RolePermissionBatchReceipt` / `RolePermissionBatch`
(管理员的多角色权限 patch);
- `RoleCopySource` / `RoleCopyAssignment` / `RoleCopyAssignResult`(复制并指派);
- `UserRoleAssignmentInput` / `UserRoleAssignmentResult` / `UserRoleAssignmentBatch`
(多用户角色指派);
- `UserRoleAssignmentPreview`(指派预览);
- `RoleMember` / `UserRoleSummary`(admin 视角下的成员/角色摘要);
- 新增 API:permissions/preview、permissions/batch、copy-assign、{roleId}/members、
users/role-assignments、users/role-assignments/preview,并给 users/search 加上 role 投影。
没有发现重复或冲突:#228 的 `RoleOptionsProvider.ListRoleOptions` 仍是非 admin 派发面
("trust a trusted Host",且"callers still authorize their own domain action"),
本端口的 7 个 admin API 在 main 当前路由表里尚不存在;`GET /api/admin/roles/{roleId}/members`
是 admin 视角的成员名册,没有触及 `AccountDirectoryPurposeProjectRoster` 的项目名册枚举。
`UserRoleSummary` 是 store-side projection 类型,没有出现在 `pkg/extension/surface.txt` 新增
位置上(不污染扩展面)—— 这一点有意的:内部 store 投影不应进入扩展契约。
#234(Land the project access and lifecycle role and work-state work on Backend main)
引入:
- `internal/app/hostcaps.go` 中 `RoleAdminProvider`、`CreateRole/UpdateRole/DeleteRole/...`、
`ReplaceRolePermissions` 的派发 Host 实现(与 AdminProvider 一起);catalog 测试
`TestCatalogLinkedActionsStayProjectScopedAndUnprotected` 与 `R2` 的角色模型断言。
- `internal/authz/service.go` 的 system action 决策函数、`ExplainCapabilities` 投影;
- `internal/server/project_lifecycle.go` 中 `projectWork` / `lifecycleHost`;
- 测试 `internal/server/project_iam_integration_test.go`、`role_authorization_test.go`、
`roles_test.go`、`explain_test.go` 等。
端口新加的项目 1:1 与 #234 项目没有重叠:
- 不动 `RoleAdminProvider` / `RoleAdminView` schema(仍由既有 `RoleAdmin` 函数读出来)。
- 不动 `AuthorizeSystem`、`ProjectCreateAction`、`ProjectAccessAction`、`ProjectCreateLinkedActions()`。
- 不动 `lifecycleHost`、`projectWork`、`ExplainCapabilities`。
- `TestCatalogLinkedActionsStayProjectScopedAndUnprotected` 添加的测试(catalog_test.go 末尾
的 +16 行)仅是个保险钉——验证 catalog 中的 linked 动作仍为 project-scope 且非受保护,
与本端口在 `normalizePermissionChanges` / `CreateRoleCopyAndAssign` 里加的限制条件一致。
- 既有的 `TestRoleProjectCreateLinksFullManagement`、`TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean`、
`TestCapabilityAssociationRejectsStaleVersionAndMissingRole` 等 R2 / R4 锚点测试没有被
改写、删除或弱化(详见 §8)。
---
## 8. 重点 6 —— 测试质量
新测试(14 项,全部新增,未删/未跳过既有断言):
store 层(`internal/store/role_policy_test.go`):
- `TestPatchRolePermissionsBatchEnablesAndDisablesAcrossRoles`(happy,双 role 同步切换)
- `TestPatchRolePermissionsBatchAtomicityRejectsWholeBatch`(一坏拒整批 + 验证两 role
维持原样 + epoch 不前进)
- `TestPatchRolePermissionsBatchStaleVersion`(过期 version 拒整批 + role 维持原样)
- `TestPatchRolePermissionsBatchRejectsLimitAndIdempotency`(20 上限、空批、同 key 重放返回
同一 receipt/epoch)
- `TestPreviewRolePermissionsMatchesCommitResult`(预览允许/拒绝与 commit 一致)
- `TestPreviewRolePermissionsRejectsInvalidBatchItem`(非法动作仅在 item 级别报错,不写盘)
- `TestAssignUserRolesBatchRebindsInOneTransaction`(两人 rebind 在一个事务中,epoch+1 +
同 key 重放 epoch 不变)
- `TestAssignUserRolesBatchRejectsWholeBatch`(一个用户 state_version 过时 → 整批回滚
+ 两角色 binding_id 与 state_version 未被改写)
- `TestPreviewUserRoleAssignmentsJudgesEachItem`(三种 case:成功、state_version 过时、
用户不存在;预览不写盘不前进 epoch)
- `TestListRoleMembersForActorRequiresGrantsAndPaginates`(admin 看见、ordinary 被拒)
- `TestActiveRolesForUsersReturnsActiveBindings`(仅返回 active binding;结束后不再见)
- `TestCreateRoleCopyAndAssignCreatesRoleAndBindsInOneTransaction`(happy:新 role +
绑定 + epoch+1)
- `TestCreateRoleCopyAndAssignRejectsWithoutResidue`(覆盖坏 overrides / 坏 name / 坏
state version 三种;检查 epoch 未进、新 role 未落盘)
server 层(`internal/server/role_authorization_test.go`):
- `TestRolePermissionPreviewRequiresPermissionViewOverHTTP`(403 with only `system.roles.view`;
200 with admin;未知 role 单 item 报 `角色不存在或已停用`;空 roles 列表 400)
- `TestBatchRolePermissionPatchOverHTTP`(403 with `roles.view+roles.manage`;200 提交;
过时 version → 409;非法变更(含 `system.users.view`)→ 400)
- `TestBatchRoleAssignmentPreviewAndCommitOverHTTP`(happy 路径:preview + commit)
- `TestCopyAssignRoleOverHTTP`(happy 路径:源 role 启用 → copy → 用户绑定)
- `TestRoleMembersFilterBeforePagingAndBindCursor`(admin 列出 + filter `query=alice&mode=exact`
仅留 alice)
- `TestAccountSearchIncludesRoleProjection`(search 结果中含 role 投影含 roleID)
测试覆盖到的关键断言:
- 每个写 API 都验证了 "atomic"(整批失败回滚写零)
- `replayReceipt` 的 "同 key + 同 hash → 同 receipt/epoch" 显式断言
- `PatchRolePermissionsBatch` 验证过期 role_version 走 `ErrInvalidRolePolicy` 并(按 HTTP
层)映射到 409
- 端口的 `TestRolePermissionsNormalizeLegacyRowsToProjectAccess`、`TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean`、
`TestRolePolicyRejectsNonProjectAccessRowsAtomically`、`TestCapabilityAssociationRejectsStaleVersionAndMissingRole`、
`TestCapabilityAssociationImportRejectsLinkedConflict`、`TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop`、
`TestRolePolicyProjectAccessFailsClosedForDisabledRoleOrAccount`、`TestRolePolicyIdempotencyKeyCannotChangeRequest`、
`TestImportRoleAuthorityPreservesStableIDsAndBindingsAtomically` —— 全部
**未改写、未删除、未弱化**。
测试未被覆盖到的几个点(详见 §10 非阻塞建议)。
---
## 9. 阻塞项
无。
---
## 10. 非阻塞建议(advice,不阻断合入)
按"安全优先 → 可靠性 → 测试"排列:
1. **[安全] `assignUserRolesBatch` 缺少"非 admin 调用被 403"的 HTTP 层负测**。
`TestBatchRolePermissionPatchOverHTTP` 与 `TestRolePermissionPreviewRequiresPermissionViewOverHTTP`
已经验证了"持有弱权限的 actor 拿不到 403"。`assignUserRolesBatch` 与 `previewUserRoleAssignments`
仅在 happy 路径上测过(`TestBatchRoleAssignmentPreviewAndCommitOverHTTP`)。
建议补一条"持有 `system.roles.view` 但无 `system.roles.assign` 的 actor 调用
`/api/admin/users/role-assignments` 收到 403"的测试。预期实现确实会拒绝
(`assignUserRolesBatch` 起手 `requireSystemAction("system.roles.assign")`),但缺少语义钉。
2. **[安全] `copyAssignRole` 缺少 HTTP 层负测(任一闸不足被拒)**。
当前只有 happy path。建议补:
- 仅 `system.roles.manage` + `system.permissions.manage`,缺 `system.roles.assign` → 403;
- 仅 `system.permissions.manage` + `system.roles.assign`,缺 `system.roles.manage` → 403;
- 仅 `system.roles.manage` + `system.roles.assign`,缺 `system.permissions.manage` → 403。
三个闸同时持有的语义在生产代码中已写明,但测试未钉死。
3. **[安全/一致性] `assignUserRolesBatch` 缺 user-missing/role-missing/role-disabled 的负测**。
`TestAssignUserRolesBatchRejectsWholeBatch` 仅覆盖 stale-state-version 一种失败模式。
store 层 `assignRolesTx` 通过 `roleAssignmentUserMissing` / `roleAssignmentUserDisabled` /
`roleAssignmentRoleInvalid` 区分三种原因(preview 路径已经分别测过这三种原因,
`TestPreviewUserRoleAssignmentsJudgesEachItem`),但 commit 路径没有显式锁定
"bad userId / disabled user / disabled role" 都会回滚。
4. **[可靠性] PostgreSQL 下潜在的锁顺序风险**。
`assignRolesTx` / `CreateRoleCopyAndAssign` 内部对每个 item 调用
`s.roleTx(tx, item.RoleID, true)`,锁定顺序由 items 切片顺序决定。
与 `policyWriteBatch` 内部 `lockAccountUsers` 的字典序 owner 锁不同,role-row 锁顺序
没有全局规范化。当两个并发 batch 提交 items 涉及交叠 role 集合但顺序相反时,
可能出现 PG 层 `40P01 deadlock_detected`,客户端表现为 500(因为 writeStoreErr 对
deadlock 没特殊映射)。SQLite 不受影响。
建议(任一):
(a) 批前按 `item.RoleID` 排序后再 `roleTx`,让所有 batch 走同一序;
(b) 在 writeStoreErr 中对 deadlock 检测加一条 `502/503` 提示;
(c) 把 SQL 锁与 `policyWriteBatch` 的 owner 锁合并成同一把 `pg_advisory_xact_lock`
key。在评估其工作量后再决定。
当前批大小(≤100)下并发风险不大,但应在 batch 大小被放大或 PG 高并发场景重新审视。
5. **[测试] `ListRoleMembersForActor` 缺跨越多 role 测试**。
`TestListRoleMembersForActorRequiresGrantsAndPaginates` 只在单 role + active-vs-inactive
验证成员计数;没有测 "两个 role,actor 同时查询 role-A,结果只包含 role-A 成员"。
6. **[风格] handler 层 `listRolePermissionPreview` 的 400 路径**。
当 `roles` 为空时,handler 直接 400;store 里 `len(items) == 0 || len(items) > maxRolePermissionPreviewItem`
也会返回 `ErrInvalidRolePolicy`。两处都在做相同的合法性检查。这是
#228/#234 现有 pattern 的一致沿用,但建议未来统一只用一处。
7. **[风格] `previewUserRoleAssignments` 与 `listRoleMembers` 重复了 cursor 计算**。
两处都用 SHA256 over `{Kind, Actor, RoleID, Query, Mode, Status, Revision}` 派生
version;HMAC 用 `accountSearchKey` 派 cursor token。建议抽 `roleMembersVersion(actor, roleID, query, mode, status, revision)` helper
到 `account_search.go`,避免后续在 `copyAssignRole`、`batchPatchRolePermissions` 等
写入响应里再次手写。
---
## 11. 依据与实际只读命令结果(evidence)
| 命令 | 输出摘要 / 关键引用 |
|---|---|
| `git status` → `HEAD detached at f10fb2e; nothing to commit, working tree clean` | 工作区干净;fix/iam-perm-ux-port HEAD = f10fb2e |
| `git log --oneline 01f4c47a2b5..HEAD` | `f10fb2e` / `76abebe` / `2d1e5f5` —— 与 packet 一致(2 transport + task-start) |
| `git diff 01f4c47a2b5..HEAD --stat` | 8 文件 / +2237 行(packet 标 "+2184" 视为合理近似口径差异) |
| `git diff ... -- internal/server/server.go` | 新增 6 条 `mux.HandleFunc` 注册(行 323-328),全部位于既有 `/api/admin/roles` 与 `/api/admin/users` 区间内,无路由表破坏 |
| `git diff ... -- internal/store/role_policy.go`(约 +1141 行) | 新增 `RolePermissionPreviewInput/Patch/Batch`/`UserRoleAssignmentInput/Batch/Preview`/`RoleCopy*/RoleMember/UserRoleSummary`/`policyWriteBatch`/`PreviewRolePermissions`/`PatchRolePermissionsBatch`/`CreateRoleCopyAndAssign`/`AssignUserRolesBatch`/`PreviewUserRoleAssignments`/`ListRoleMembersForActor`/`ActiveRolesForUsers` —— 全部以 `policyWriteBatch` 或显式 tx/actor 校验为入口 |
| `git diff ... -- internal/server/roles.go` | 6 个 handler(`listRolePermissionPreview`/`batchPatchRolePermissions`/`decodeRoleAssignments`/`assignUserRolesBatch`/`previewUserRoleAssignments`/`copyAssignRole`) + `systemActionAllowed` 静默判定辅助 |
| `git diff ... -- internal/server/account_search.go` | `searchAdminUsers` 追加 `system.roles.view` 守门 role 投影(`systemActionAllowed` 静默跳过路径);新增 `listRoleMembers` handler(角色过滤 + 绑定 cursor)|
| `git diff ... -- internal/store/role_policy_test.go` | +494 行:14 新测试覆盖 happy / 原子回滚 / 过期版本 / 幂等重放 / 上限 / preview 一次 item level / user-missing 等;既有测试零修改 |
| `git diff ... -- internal/server/role_authorization_test.go` | +226 行:6 新 HTTP 测试,含 `403 with weaker grant`、`409 on stale version`、`400 on invalid action`;既有测试零修改 |
| `git diff ... -- internal/authz/catalog/catalog_test.go` | +16 行:仅加 `TestCatalogLinkedActionsStayProjectScopedAndUnprotected`,未改既有 |
| `grep requireSuperAdminTargetAccess internal/store/*.go` | 定义于 `super_admin.go:36`,被既有 `AssignUserRole`/`ReplaceSystemGrants` 与本次新增 `assignRolesTx`(*Batch 路径)/ `CreateRoleCopyAndAssign` 内的 `assignRolesTx` 调用 |
| `grep roleGrantsAccountAdministrationTx internal/store/role_policy.go` | 定义 line 743;被既有 `AssignUserRole`/`replaceSystemGrantsTx` 与新增 `assignRolesTx` 调用 |
| `grep requireRoleAuthorityWritableTx internal/store/role_policy.go` | 定义 line 245(`FOR UPDATE` of `role_authority_import_state`);被既有 `CreateRole`/`PatchRole`/`ReplaceRolePermissions`/`AssignUserRole` 与新增 `PatchRolePermissionsBatch`/`AssignUserRolesBatch`/`CreateRoleCopyAndAssign` 调用 |
| `grep policyWriteBatch internal/store/role_policy.go` | 定义 line 1620;与既有 `policyWrite`(line 181)同形态(owner 字典序锁 + tx + epoch + 闭包 + commit)|
| `grep mux.HandleFunc internal/server/server.go` | 路由按既有 admin/role/user 区段插入 |
| `grep saveReceipt internal/store/role_policy.go` | 三个 batch 入口都调用 `saveReceipt`;store 没有 tx.Commit() 之外的提交途径 |
| `grep CREATE.*user_role_bindings internal/store/migrate.go` | line 1284 的 `CREATE UNIQUE INDEX uq_user_role_bindings_active ON user_role_bindings(user_id) WHERE active=TRUE` —— 强制单 active binding/user;`Update user_role_bindings SET active=0 ... WHERE active=1` 不会同时关错多行 |
| 跳过 | `git submodule init/update`(packet 禁用)/ `git add/commit/push` / `git config` / `git stash` —— 全部未执行 |
| 跳过 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_shell_pipe_hazard` —— packet 在 test 列表中给出该命令,但本仓库不存在 `tests/` 目录也不存在 `test_shell_pipe_hazard` 测试。该命令不可执行,与端口内容无关(疑为模板残留),已在"未验证"列出 |
---
## 12. 未验证(structural limits of detached-HEAD review-only session)
按 packet 第 3 项要求"未验证的结论写明未验证"。下列条目不是结论本身的缺失,而是本会话
无法跑通验证路径:
1. **未跑 `go test ./...`**:deps 子模块 gitlink 在本 detached-HEAD 工作区中尚未初始化
(worktree 内 `deps/*/` 是空目录),且 packet 禁用 `git submodule init/update`,
所以 `go build ./...` 也无法走通。所有"编译通过性"和"测试通过性"的结论均以**静态
diff/源码对比**为依据,不以运行为依据。评审者**未亲眼见到测试 PASS 输出**。
2. **未对源 feature 分支作交叉比对**:packet 注明 `feature/iam-perm-ux-backend` "唯一副本
已备份",本工作区没有该分支 ref 也无任何 transport 元数据用于追溯。本评审**仅按 diff
与 main 既有语义做正向校验**,不与原 feature 分支逐行比对。
3. **PG dialect 下不跑 `go test ./... -tags pg`**:同上。
4. **`tests.test_shell_pipe_hazard` 不可执行**:模板命令,本仓无对应目录与模块,已注明。
5. **未跑 `make precheck`**:packet 在 Dev 仓推进;本仓为子仓,"独立开发"路径见
PROJECT.md 但 precheck 归属于 Dev 仓,本审查会话不对其做运行。
---
## 13. 决定
- `terminal_status`: `completed`(独立审查产出完毕,未改动任何非报告文件;非"BLOCKED")
- 审查者 ID:iam-perm-ux-port-independent-review
- 提交建议:可合入 main(前提是 §10 中 (1)(2)(3) 三条负测在合入前补齐或与跟进任务
挂钩 —— 推荐补齐后再合,以避免 main 出现"缺少 admin-grant 校准测试"的退化窗口)
— end of review —
+11 -92
View File
@@ -24,13 +24,10 @@ import (
"logging-system/logsys"
"logging-system/sink"
"agent-native/runtime/checkpoint"
agentllm "agent-native/runtime/llm"
agentmcp "agent-native/runtime/mcp"
"agent-native/runtime/policy"
"agent-native/runtime/telemetry"
agenttools "agent-native/runtime/tools"
nativeadp "agent-squad/adapters/native"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
@@ -332,98 +329,20 @@ func Run(opt Options) error {
}
}
tools.SetSkillsRoot(skillsRoot)
// 小队的 agent-native 内核工厂(组合根装配,逐次运行时从 settings 解析):
// 模型走 settings.models.chat 同一路由;搜索/解释器/MCP/技能根复用产品配置;
// 委派桥接产品 SpawnSubagent。主模型未配置时返回未启用(不阻塞产品)。
// 小队的 agent-native 内核工厂(组合根装配,逐次运行时按当前运行的用户/项目
// 解析):模型走 settings.models.chat 同一路由与同一 per-route 规则;
// 搜索/解释器/MCP/技能根复用产品配置;委派桥接产品 SpawnSubagent。
// 主模型未配置时返回未启用(不阻塞产品)。装配见 squad_native.go。
var pluginHost *plugins.Host
squadadapter.AgentNativeOptions = func(rc squadadapter.Ctx) (nativeadp.Config, bool) {
effNow := setSvc.Effective(store.OwnerDefault, "")
route := runtime.ResolveChatModel(core.AgentProfile{}, effNow)
if route.Model == "" || route.Base == "" {
return nativeadp.Config{}, false
}
mode := "default"
if src, ok := rc.(interface{ PermissionMode() string }); ok {
mode = src.PermissionMode()
}
cfg := nativeadp.Config{
Client: &agentllm.Client{BaseURL: route.Base, APIKey: route.Key, HTTPClient: &http.Client{}},
DefaultModel: route.Model,
MaxTurns: 0,
Timeout: 0,
Subagent: squadadapter.SubagentFactory(rc),
RepeatGuard: true,
BeforeTool: func(call agentllm.ToolCall) error {
effect := policy.Decide(policy.Request{
Mode: policy.Normalize(mode), Name: call.Name, Args: call.Arguments,
Traits: policy.TraitsFor(call.Name),
}, nil)
if effect == policy.EffectDeny {
return fmt.Errorf("计划模式拒绝了工具 %s", call.Name)
}
squadadapter.AgentNativeOptions = squadAgentNativeFactory(squadNativeDeps{
set: setSvc, secrets: st, skillsRoot: skillsRoot, trace: traceFn, mcp: mcpManagerFor,
pluginMCP: func() []core.PluginMCPServer {
if pluginHost == nil {
return nil
},
}
cfg.Stream = runtime.GuardProjectStream(rc, "", cfg.Client.Stream)
if keys := tavilyKeyPool(st, effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 {
cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
}
if effNow.GetBool("runtime.exec", true) {
cfg.Exec = tools.InterpreterExecConfig()
}
if st2, err := os.Stat(skillsRoot); err == nil && st2.IsDir() {
cfg.SkillsRoots = []string{skillsRoot}
}
rawMCP := strings.TrimSpace(effNow.GetString("tools.mcp.servers", ""))
if pluginHost != nil {
rawMCP = mergeMCPServers(rawMCP, pluginHost.MCPServers())
}
if mgr := mcpManagerFor(rawMCP, strings.TrimSpace(effNow.GetString("tools.mcp.overrides", ""))); mgr != nil {
cfg.MCP = mgr
}
// Native squad tools do not yet expose a fail-closed result hook to the
// project observation outbox. Project runs therefore use the audited
// runCtx retrieval/tool path only; enabling these direct providers would
// let their results reach the next model turn without source analysis.
if rc.Run().ProjectID != "" {
cfg.Search = nil
cfg.Exec = nil
cfg.Fetch = nil
cfg.MCP = nil
}
cfg.CodeOrchestration = rc.Run().ProjectID == "" && effNow.GetBool("runtime.codeOrchestration", false)
if ask, ok := rc.(interface {
AskUser(card core.HITLCard) (core.HITLResponse, error)
}); ok {
cfg.Ask = func(_ context.Context, question string) (string, error) {
resp, err := ask.AskUser(core.HITLCard{
Question: question, Kind: core.HITLKindInput, AllowFreeInput: true,
})
if err != nil {
return "", err
}
if strings.TrimSpace(resp.FreeText) != "" {
return strings.TrimSpace(resp.FreeText), nil
}
return resp.ChoiceID, nil
}
}
cfg.Trace = traceFn
// N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达
// runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。
cfg.PermissionAsk = runtime.PermissionAskOf(rc)
if host, ok := rc.(interface {
OpenCheckpoints() (*checkpoint.Log, error)
CheckpointAnchor() string
}); ok {
if lg, err := host.OpenCheckpoints(); err == nil {
cfg.Checkpoints = lg
anchor := host.CheckpointAnchor()
cfg.CheckpointAnchor = func() string { return anchor }
}
}
return cfg, true
}
return pluginHost.MCPServers()
},
})
profSvc := profiles.New(st)
if err := profSvc.EnsureDefault(); err != nil {
return fmt.Errorf("default profile: %w", err)
+62 -8
View File
@@ -247,6 +247,12 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
if h.actor == "" || h.actor != h.owner || q.ProjectID == "" {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
// An export is not a page. This slice has no whole-history export capability,
// so the request is refused outright rather than answered with a first page a
// consumer could mistake for the complete record.
if q.Export {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
if h.caps.projectAudit == nil || h.caps.st == nil {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
@@ -267,9 +273,16 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
if err := ctx.Err(); err != nil {
return extension.ProjectAuditPage{}, err
}
if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 {
if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 ||
q.LinkedResourceKind != "" || q.LinkedResourceID != "" || q.LinkedRunID != "" {
return h.queryLinkedProjectAudit(ctx, q, slot)
}
// The actor-owned page is not bound to one verified object, so no per-object
// body right can be decided for it. Refusing keeps Coverage.ResourceBodies
// honest instead of answering a page trimmed by an unasked decision.
if q.IncludeBody {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit)
if err != nil {
return extension.ProjectAuditPage{}, err
@@ -279,7 +292,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
NextOffset: next, CutoffOffset: cutoff,
Coverage: extension.ProjectAuditCoverage{
CommandAudit: "actor-owned-project-bound-only", CutoffConsistency: "committed-prefix", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported",
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported",
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", Export: "unsupported",
},
}
for _, row := range rows {
@@ -298,7 +311,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
}
func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.ProjectAuditQuery, slot *projectAuditSlot) (extension.ProjectAuditPage, error) {
if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 {
if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 || q.Export {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
slot.mu.RLock()
@@ -311,9 +324,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro
if errors.Is(err, store.ErrNotFound) {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
if errors.Is(err, store.ErrConflict) {
// The host row was edited after publication: the receipt no longer matches
// the binding the domain verified. Fail closed instead of re-projecting it.
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
if err != nil {
return extension.ProjectAuditPage{}, err
}
// Caller locators only narrow the exact binding. Disagreement with stored host
// state denies; it never selects a different object.
if (q.LinkedResourceKind != "" && q.LinkedResourceKind != link.ResourceKind) ||
(q.LinkedResourceID != "" && q.LinkedResourceID != link.ResourceID) ||
(q.LinkedRunID != "" && q.LinkedRunID != link.RunID) {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
// A run relation was verified at publication; re-check it against current
// storage so a deleted or moved run stops serving its receipt.
if link.RunID != "" {
bound, err := h.caps.st.RunBoundToAudit(link.Owner, link.ProjectID, row.Envelope.ConversationID, link.RunID)
if err != nil {
return extension.ProjectAuditPage{}, err
}
if !bound {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
}
resource := extension.ProjectAuditLinkedResource{ProjectID: q.ProjectID, Owner: link.Owner, SourceKind: link.SourceKind, SourceID: link.SourceID,
OperationID: link.OperationID, ResourceKind: link.ResourceKind, ResourceID: link.ResourceID, RunID: link.RunID, DisclosureClass: link.DisclosureClass}
allowed, err := disclose(ctx, h.actor, resource)
@@ -323,14 +359,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro
if !allowed.Metadata {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
// audit.read and the per-object body right are separate authorities: a body
// request without that right is refused, never trimmed into a partial page.
if q.IncludeBody && !allowed.Body {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
if err := ctx.Err(); err != nil {
return extension.ProjectAuditPage{}, err
}
nameStatus := "not-recorded"
if row.ActorName != "" {
nameStatus = "recorded"
}
return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{{SourceOffset: row.Offset, Time: row.Envelope.Time,
receipt := extension.HostAuditReceipt{SourceOffset: row.Offset, Time: row.Envelope.Time,
ActorID: row.Envelope.Actor, ActorName: row.ActorName, ActorNameStatus: nameStatus, CommandID: row.Envelope.CommandID,
State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}},
Coverage: extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported"}}, nil
State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}
coverage := extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", Export: "unsupported"}
if link.RunID != "" {
coverage.RunRelations = "verified-exact-link-run"
}
if q.IncludeBody {
// Body-permitted projection of the verified binding only: the object identity
// the domain attributed this command to, never resource content.
receipt.Resource = &extension.ProjectAuditReceiptResource{Kind: link.ResourceKind, ID: link.ResourceID, RunID: link.RunID}
coverage.ResourceBodies = "verified-exact-link-body"
}
return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{receipt}, Coverage: coverage}, nil
}
+702
View File
@@ -0,0 +1,702 @@
package app
// W9 X2 出口矩阵:可选 ProjectAuditHost 的读取面按【受信项目、actor、resource、
// run、source offset 与截止】读取,并结合 RDM audit.read 回调与每对象正文权做
// 服务端裁剪。本文件覆盖批准需求第(4)条列出的九个出口场景,以及第(3)条
// “缺基座能力返回 unsupported 覆盖范围”的语义。
//
// RDM 消费端(audit.read / doc.read 回调的领域实现)不在本仓:这里只验证宿主
// 面必须向注册方询问、按对象裁剪、缺能力即 unsupported,不在宿主侧复制策略。
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// auditMatrix 是出口矩阵的共用基座:alice 拥有 A/B 两个项目并在其中各有一份
// 会话,bob 是 A 的第二成员(会话建在 A 下),carol 只存在于账号目录、不参与
// 任何审计行。alice 的 op-a 是一条已发布链接(绑定 document/doc-1 与一次 run)。
type auditMatrix struct {
t *testing.T
st *store.Store
caps hostcaps
projectA core.ID
projectB core.ID
foreign core.ID
aliceConv core.ID
bobConv core.ID
runID string
link extension.ProjectAuditLinkReceipt
// 策略旋钮:每个用例按出口场景改写,回调只读它们。
auditAll bool
auditRead map[core.ID]bool
metadata map[string]bool
body map[string]bool
auditCalls []string
discloseCall int
}
func newAuditMatrix(t *testing.T) *auditMatrix {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
m := &auditMatrix{t: t, st: st, auditRead: map[core.ID]bool{}, metadata: map[string]bool{}, body: map[string]bool{}}
for _, account := range []struct{ owner, name string }{
{"alice", "Alice at commit"}, {"bob", "Bob"}, {"carol", "Carol directory only"},
} {
if err := st.EnsureUser(account.owner, account.name); err != nil {
t.Fatal(err)
}
}
projectA, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
projectB, err := st.CreateProject("alice", "B")
if err != nil {
t.Fatal(err)
}
foreign, err := st.CreateProject("carol", "C")
if err != nil {
t.Fatal(err)
}
m.projectA, m.projectB, m.foreign = projectA.ID, projectB.ID, foreign.ID
aliceConv, err := st.CreateConversation("alice", string(projectA.ID), "A work", "")
if err != nil {
t.Fatal(err)
}
otherConv, err := st.CreateConversation("alice", string(projectB.ID), "B work", "")
if err != nil {
t.Fatal(err)
}
bobConv, err := st.CreateConversationInProject("bob", string(projectA.ID), "A shared", "")
if err != nil {
t.Fatal(err)
}
m.aliceConv, m.bobConv = aliceConv.ID, bobConv.ID
run := core.Run{ConversationID: aliceConv.ID, ProjectID: projectA.ID, Owner: "alice",
Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking}
if err := st.CreateRun(&run); err != nil {
t.Fatal(err)
}
m.runID = string(run.ID)
m.append("alice", string(aliceConv.ID), "alice.submit", "op-a", m.runID)
m.append("alice", string(otherConv.ID), "alice.other", "op-b", "")
m.append("bob", string(bobConv.ID), "bob.submit", "op-bob", "")
m.caps = hostcaps{st: st, projectAudit: &projectAuditSlot{}}
reg := pluginRegistry{pluginID: "rdm", caps: m.caps}
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
m.auditCalls = append(m.auditCalls, actor+"@"+string(projectID))
return m.auditAll || m.auditRead[projectID], nil
}); err != nil {
t.Fatal(err)
}
factory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) {
return proof.Owner == "alice" && proof.ProjectID == m.projectA && proof.SourceKind == "domain-commit" &&
proof.SourceID == "commit-a" && proof.OperationID == "op-a" && proof.ResourceKind == "document" &&
proof.ResourceID == "doc-1" && proof.RunID == m.runID, nil
})
if err != nil {
t.Fatal(err)
}
if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, resource extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) {
m.discloseCall++
return extension.ProjectAuditDisclosure{Metadata: actor == "bob" && m.metadata[resource.ResourceID],
Body: actor == "bob" && m.body[resource.ResourceID]}, nil
}); err != nil {
t.Fatal(err)
}
publisher, err := factory.ForOwner("alice")
if err != nil {
t.Fatal(err)
}
receipt, err := publisher.PublishProjectAuditLink(context.Background(), extension.ProjectAuditLinkRequest{
ProjectID: m.projectA, SourceKind: "domain-commit", SourceID: "commit-a", OperationID: "op-a",
ResourceKind: "document", ResourceID: "doc-1", RunID: m.runID, DisclosureClass: "metadata", IdempotencyKey: "outbox-a",
})
if err != nil {
t.Fatal(err)
}
m.link = receipt
return m
}
func (m *auditMatrix) append(owner, conversationID, commandID, operationID, runID string) {
m.t.Helper()
if err := m.st.AppendAudit(owner, agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: owner, CommandID: commandID,
ConversationID: conversationID, State: agentcommand.StateCommitted, OperationID: operationID, RunID: runID,
ParamsDigest: "sensitive-digest"}); err != nil {
m.t.Fatal(err)
}
}
func (m *auditMatrix) host(owner string) inprocHost {
return inprocHost{caps: m.caps, owner: owner, actor: owner, pluginID: "rdm"}
}
func (m *auditMatrix) linkedQuery() extension.ProjectAuditQuery {
return extension.ProjectAuditQuery{ProjectID: m.projectA, LinkedSourceKind: "domain-commit",
LinkedSourceID: "commit-a", LinkedHostOffset: m.link.HostAuditOffset}
}
func (m *auditMatrix) openBody() {
m.metadata["doc-1"], m.body["doc-1"] = true, true
}
// 同 owner 跨项目/两成员:受信项目归属与成员身份都必须在服务端判定。
func TestProjectAuditExitMatrixSameOwnerCrossProjectAndTwoMembers(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.auditRead[m.projectB] = true
m.metadata["doc-1"] = true
bobPage, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
if err != nil {
t.Fatal(err)
}
if len(bobPage.Receipts) != 1 || bobPage.Receipts[0].CommandID != "bob.submit" || bobPage.Receipts[0].ActorName != "Bob" {
t.Fatalf("second member sees another member's row: %+v", bobPage.Receipts)
}
alicePage, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
if err != nil {
t.Fatal(err)
}
if len(alicePage.Receipts) != 1 || alicePage.Receipts[0].CommandID != "alice.submit" || alicePage.Receipts[0].ActorName != "Alice at commit" {
t.Fatalf("owner page left its project scope: %+v", alicePage.Receipts)
}
otherProject, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB})
if err != nil {
t.Fatal(err)
}
if len(otherProject.Receipts) != 1 || otherProject.Receipts[0].CommandID != "alice.other" {
t.Fatalf("project B page: %+v", otherProject.Receipts)
}
m.auditRead[m.projectB] = false
if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB}); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("non-member read another project: %v", err)
}
if got := m.auditCalls[len(m.auditCalls)-1]; got != "bob@"+string(m.projectB) {
t.Fatalf("host did not ask the domain about the caller's project: %s", got)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{}); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("empty project id: %v", err)
}
}
// 恶意 projectId:调用方自报的项目标识只是定位符,既不能换出别的项目,也不能
// 让“看起来通过”的未知项目变成受信项目流。
func TestProjectAuditExitMatrixMaliciousProjectID(t *testing.T) {
m := newAuditMatrix(t)
// 领域回调对所有项目一律放行(配置失误):宿主仍只能按受信存储归集。
m.auditAll = true
for _, projectID := range []core.ID{m.foreign, "no-such-project"} {
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: projectID})
if err != nil {
t.Fatalf("project %q: %v", projectID, err)
}
if len(page.Receipts) != 0 {
t.Fatalf("project %q returned rows: %+v", projectID, page.Receipts)
}
if page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.Export != "unsupported" {
t.Fatalf("project %q coverage: %+v", projectID, page.Coverage)
}
}
// 领域误放行也不能换出受信任存储之外的对象:每个主体只看得到自己的行。
for _, c := range []struct {
owner string
projectID core.ID
want string
}{
{"alice", m.projectA, "alice.submit"}, {"bob", m.projectA, "bob.submit"},
{"alice", m.projectB, "alice.other"}, {"bob", m.projectB, ""},
} {
page, err := m.host(c.owner).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: c.projectID})
if err != nil {
t.Fatalf("%s@%s: %v", c.owner, c.projectID, err)
}
got := []string{}
for _, receipt := range page.Receipts {
got = append(got, receipt.CommandID)
}
if strings.Join(got, ",") != c.want {
t.Fatalf("%s@%s rows = %v, want [%s]", c.owner, c.projectID, got, c.want)
}
}
// 他人项目下的链接按 projectId 定位失败:不得跨项目取回。
crossProject := m.linkedQuery()
crossProject.ProjectID = m.foreign
if _, err := m.host("bob").QueryProjectAudit(context.Background(), crossProject); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("cross-project link locator: %v", err)
}
// 调用方自报的 resource/run 定位符必须与受信绑定一致,否则拒绝。
// 先打开逐对象元数据权,使拒绝只能来自定位符校验本身。
m.metadata["doc-1"] = true
for _, mutate := range []func(*extension.ProjectAuditQuery){
func(q *extension.ProjectAuditQuery) { q.LinkedResourceID = "doc-2" },
func(q *extension.ProjectAuditQuery) { q.LinkedResourceKind = "run" },
func(q *extension.ProjectAuditQuery) { q.LinkedRunID = "run-other" },
} {
q := m.linkedQuery()
mutate(&q)
if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("forged locator was accepted: %+v err=%v", q, err)
}
}
q := m.linkedQuery()
q.LinkedResourceKind, q.LinkedResourceID, q.LinkedRunID = "document", "doc-1", m.runID
if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); err != nil {
t.Fatalf("matching locators were refused: %v", err)
}
}
// audit.read 无正文:元数据权成立、正文权不成立时只出元数据;请求正文则拒绝,
// 不得返回一份“被悄悄裁剪”的页冒充有正文覆盖。
func TestProjectAuditExitMatrixAuditReadWithoutBody(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.metadata["doc-1"] = true
page, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
if err != nil {
t.Fatal(err)
}
if len(page.Receipts) != 1 || page.Receipts[0].Resource != nil || page.Receipts[0].ActorName != "Alice at commit" {
t.Fatalf("metadata-only page: %+v", page.Receipts)
}
if page.Coverage.ResourceBodies != "unsupported" || page.Coverage.CommandAudit != "verified-exact-link-only" {
t.Fatalf("coverage overstated body coverage: %+v", page.Coverage)
}
body := m.linkedQuery()
body.IncludeBody = true
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("body requested without the body right: %v", err)
}
// 无受信对象绑定的 actor 自有页没有每对象正文权可言:显式 unsupported。
owned := extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), owned); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("actor-owned body read: %v", err)
}
// 正文权成立时按对象裁剪下发,且不下发正文载荷。
m.openBody()
// 有权但未请求:不主动多给对象身份。
undeclared, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
if err != nil {
t.Fatal(err)
}
if len(undeclared.Receipts) != 1 || undeclared.Receipts[0].Resource != nil || undeclared.Coverage.ResourceBodies != "unsupported" {
t.Fatalf("body right was exercised without a body request: %+v coverage=%+v", undeclared.Receipts, undeclared.Coverage)
}
bodyPage, err := m.host("bob").QueryProjectAudit(context.Background(), body)
if err != nil {
t.Fatal(err)
}
if len(bodyPage.Receipts) != 1 || bodyPage.Receipts[0].Resource == nil ||
bodyPage.Receipts[0].Resource.Kind != "document" || bodyPage.Receipts[0].Resource.ID != "doc-1" ||
bodyPage.Receipts[0].Resource.RunID != m.runID || bodyPage.Coverage.ResourceBodies != "verified-exact-link-body" ||
bodyPage.Coverage.RunRelations != "verified-exact-link-run" {
t.Fatalf("body-permitted page: %+v coverage=%+v", bodyPage.Receipts, bodyPage.Coverage)
}
raw, err := json.Marshal(bodyPage)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") ||
strings.Contains(string(raw), string(m.aliceConv)) {
t.Fatalf("body-permitted page leaked a payload or private locator: %s", raw)
}
}
// 正文无 audit.read:对象权先成立也必须先被项目 audit.read 挡住,且不得因为
// 逐对象判定而向无 audit.read 的主体泄露“这个对象存在”。
func TestProjectAuditExitMatrixBodyWithoutAuditRead(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.openBody()
body := m.linkedQuery()
body.IncludeBody = true
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); err != nil {
t.Fatalf("baseline body read: %v", err)
}
m.auditRead[m.projectA] = false
before := m.discloseCall
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("body right survived revoked audit.read: %v", err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("body read without audit.read: %v", err)
}
if m.discloseCall != before {
t.Fatalf("per-object decision was consulted before audit.read: %d -> %d", before, m.discloseCall)
}
}
// 对象已撤权/删改:撤权走逐对象判定;删除与改动让存储侧无法再把该链接解析成
// 受信任回执;已删除的 run 关系也不再服务回执。
func TestProjectAuditExitMatrixObjectRevokedDeletedOrEdited(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.openBody()
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
t.Fatalf("baseline link: %v", err)
}
m.metadata["doc-1"], m.body["doc-1"] = false, false
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("revoked object: %v", err)
}
m.openBody()
// run 关系被改(run 移出本项目):链接仍在,但受约束的 run 不再匹配。
if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectB), m.runID); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("moved run kept serving its receipt: %v", err)
}
if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectA), m.runID); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
t.Fatalf("restored run: %v", err)
}
// 宿主回执被改动(载荷里的 operationId 漂移):不再是领域验证过的那一条。
var stored string
if err := m.st.DB().QueryRow(`SELECT payload FROM command_audit WHERE id=?`, m.link.HostAuditOffset).Scan(&stored); err != nil {
t.Fatal(err)
}
forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice",
CommandID: "alice.submit", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted, OperationID: "op-forged"})
if err != nil {
t.Fatal(err)
}
if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, string(forged), m.link.HostAuditOffset); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("edited receipt still resolved: %v", err)
}
if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, stored, m.link.HostAuditOffset); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
t.Fatalf("restored receipt: %v", err)
}
// 链接被删:关联不再存在,回执不再属于本项目审计。
if _, err := m.st.DB().Exec(`DELETE FROM project_audit_links WHERE id=?`, m.link.LinkID); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("deleted link still resolved: %v", err)
}
// 宿主回执的项目归属被改:外键仍在,但它已不是这一条被验证的绑定。
if _, err := m.st.DB().Exec(`INSERT INTO project_audit_links(id,host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, m.link.LinkID, m.link.HostAuditOffset, string(m.projectA), "alice", "domain-commit",
"commit-a", "op-a", "document", "doc-1", m.runID, "metadata", "outbox-a", time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
if _, err := m.st.DB().Exec(`UPDATE command_audit SET project_id=? WHERE id=?`, string(m.projectB), m.link.HostAuditOffset); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("re-attributed receipt still resolved: %v", err)
}
}
// 历史 cutoff:第一页固定的上限在其后写入的行不得进入本次分页视图。
func TestProjectAuditExitMatrixHistoricalCutoff(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
for i := 0; i < 4; i++ {
m.append("bob", string(m.bobConv), "bob.bulk-"+string(rune('a'+i)), "op-bulk", "")
}
first, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 2})
if err != nil {
t.Fatal(err)
}
if len(first.Receipts) != 2 || first.CutoffOffset == 0 || first.NextOffset == 0 {
t.Fatalf("first page: %+v", first)
}
m.append("bob", string(m.bobConv), "bob.after-cutoff", "op-after", "")
seen := map[string]bool{}
for _, receipt := range first.Receipts {
seen[receipt.CommandID] = true
}
before, cutoff := first.NextOffset, first.CutoffOffset
for page := first; page.NextOffset != 0; {
if before >= cutoff {
t.Fatalf("cursor did not move: before=%d cutoff=%d", before, cutoff)
}
page, err = m.host("bob").QueryProjectAudit(context.Background(),
extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 2})
if err != nil {
t.Fatal(err)
}
if page.CutoffOffset != cutoff {
t.Fatalf("cutoff moved mid-read: %d -> %d", cutoff, page.CutoffOffset)
}
before = page.NextOffset
for _, receipt := range page.Receipts {
if seen[receipt.CommandID] {
t.Fatalf("duplicate receipt across pages: %s", receipt.CommandID)
}
seen[receipt.CommandID] = true
}
}
if len(seen) != 5 || seen["bob.after-cutoff"] {
t.Fatalf("historical cutoff read drifted: %v", seen)
}
// 不带 cutoff 的新读取才能看到其后写入的行。
fresh, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(fresh.Receipts) != 6 || fresh.CutoffOffset <= cutoff {
t.Fatalf("fresh read did not advance: n=%d cutoff=%d", len(fresh.Receipts), fresh.CutoffOffset)
}
}
// 导出:审计授权不是导出授权。本切片不提供整段导出,只能分页,且覆盖显式声明。
func TestProjectAuditExitMatrixExportIsRefusedNotDegraded(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.openBody()
for _, q := range []extension.ProjectAuditQuery{
{ProjectID: m.projectA, Export: true},
{ProjectID: m.projectA, Export: true, Limit: 100000},
} {
page, err := m.host("bob").QueryProjectAudit(context.Background(), q)
if !errors.Is(err, extension.ErrProjectAuditUnsupported) || len(page.Receipts) != 0 {
t.Fatalf("export was not refused: page=%+v err=%v", page, err)
}
}
exportLink := m.linkedQuery()
exportLink.Export = true
if _, err := m.host("bob").QueryProjectAudit(context.Background(), exportLink); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("export through the linked path: %v", err)
}
linked, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
if err != nil {
t.Fatal(err)
}
if linked.Coverage.Export != "unsupported" {
t.Fatalf("linked page export coverage: %+v", linked.Coverage)
}
for i := 0; i < 205; i++ {
m.append("bob", string(m.bobConv), "bob.flood", "op-flood", "")
}
huge, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100000})
if err != nil {
t.Fatal(err)
}
if len(huge.Receipts) > 200 || huge.NextOffset == 0 || huge.Coverage.Export != "unsupported" {
t.Fatalf("unbounded read was not capped and reported: n=%d next=%d coverage=%+v", len(huge.Receipts), huge.NextOffset, huge.Coverage)
}
}
// 长分页:多次翻页必须完整、不重不漏,且游标单调收敛。
func TestProjectAuditExitMatrixLongPagination(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
const total = 25
for i := 0; i < total; i++ {
m.append("bob", string(m.bobConv), "bob.page", "op-page", "")
}
seen := map[int64]bool{}
before, cutoff := int64(0), int64(0)
for pages := 0; ; pages++ {
if pages > 10 {
t.Fatalf("pagination did not terminate: %d rows", len(seen))
}
page, err := m.host("bob").QueryProjectAudit(context.Background(),
extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 7})
if err != nil {
t.Fatal(err)
}
switch {
case cutoff == 0:
cutoff = page.CutoffOffset
if cutoff == 0 {
t.Fatal("first page must establish a cutoff")
}
case page.CutoffOffset != cutoff:
t.Fatalf("cutoff drifted on page %d: %d != %d", pages, page.CutoffOffset, cutoff)
}
if len(page.Receipts) > 7 {
t.Fatalf("page %d exceeded the requested limit: %d", pages, len(page.Receipts))
}
for i, receipt := range page.Receipts {
if seen[receipt.SourceOffset] {
t.Fatalf("offset %d repeated on page %d", receipt.SourceOffset, pages)
}
if i > 0 && receipt.SourceOffset >= page.Receipts[i-1].SourceOffset {
t.Fatalf("page %d is not strictly descending: %+v", pages, page.Receipts)
}
seen[receipt.SourceOffset] = true
}
if page.NextOffset == 0 {
break
}
if before != 0 && page.NextOffset >= before {
t.Fatalf("cursor did not advance: %d -> %d", before, page.NextOffset)
}
before = page.NextOffset
}
if len(seen) != total+1 {
t.Fatalf("long pagination lost rows: got %d want %d", len(seen), total+1)
}
}
// 旧记录不补造字段:显示名是动作当时记录的最小快照,旧行保持 not-recorded;
// 账号目录也不会随审计页下发。
func TestProjectAuditExitMatrixLegacyRowsAreNotBackfilled(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
legacy, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice",
CommandID: "alice.legacy", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted})
if err != nil {
t.Fatal(err)
}
if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id)
VALUES (?,?,'legacy','','committed','',?,?,?,'',?)`, time.Now().UTC().Format(time.RFC3339Nano), "alice.legacy",
string(m.aliceConv), "alice", string(legacy), string(m.projectA)); err != nil {
t.Fatal(err)
}
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
if err != nil {
t.Fatal(err)
}
var legacyReceipt *extension.HostAuditReceipt
for i := range page.Receipts {
if page.Receipts[i].CommandID == "alice.legacy" {
legacyReceipt = &page.Receipts[i]
}
}
if legacyReceipt == nil || legacyReceipt.ActorName != "" || legacyReceipt.ActorNameStatus != "not-recorded" {
t.Fatalf("legacy row fabricated a display name: %+v", page.Receipts)
}
if _, err := m.st.DB().Exec(`UPDATE users SET name=? WHERE owner=?`, "Alice renamed", "alice"); err != nil {
t.Fatal(err)
}
after, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
if err != nil {
t.Fatal(err)
}
seen := 0
for _, receipt := range after.Receipts {
seen++
switch receipt.CommandID {
case "alice.legacy":
if receipt.ActorName != "" || receipt.ActorNameStatus != "not-recorded" {
t.Fatalf("rename backfilled an old row: %+v", receipt)
}
case "alice.submit":
if receipt.ActorName != "Alice at commit" || receipt.ActorNameStatus != "recorded" {
t.Fatalf("recorded name was not preserved: %+v", receipt)
}
}
}
if seen != 2 {
t.Fatalf("unexpected row count after rename: %d", seen)
}
m.append("alice", string(m.aliceConv), "alice.after-rename", "op-rename", "")
fresh, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(fresh.Receipts) != 1 || fresh.Receipts[0].CommandID != "alice.after-rename" || fresh.Receipts[0].ActorName != "Alice renamed" {
t.Fatalf("new row did not record the current name: %+v", fresh.Receipts)
}
raw, err := json.Marshal(page)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "Carol directory only") || strings.Contains(string(raw), "Bob") {
t.Fatalf("account directory leaked through the audit page: %s", raw)
}
}
// owner 全量审计不是项目流:租户级审计与项目审计互不越界;缺基座能力一律
// unsupported(而非静默空页或降级读取)。
func TestProjectAuditOwnerWideAuditIsNotAProjectFlowAndUnsupportedIsExplicit(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.metadata["doc-1"] = true
// 租户级审计(含无项目归属的旧行)不得成为项目流的取数来源。
if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload)
VALUES (?,'unbound','','','committed','','','alice','{}')`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
ownerWide, err := m.st.ListAudits("alice", "", 100)
if err != nil {
t.Fatal(err)
}
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100})
if err != nil {
t.Fatal(err)
}
if len(ownerWide) <= len(page.Receipts) {
t.Fatalf("owner-wide audit unexpectedly matched the project page: %d vs %d", len(ownerWide), len(page.Receipts))
}
for _, receipt := range page.Receipts {
if receipt.CommandID == "unbound" || receipt.CommandID == "bob.submit" {
t.Fatalf("owner-wide or cross-member row reached the project page: %+v", receipt)
}
}
if page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" {
t.Fatalf("coverage must state the project-flow boundary: %+v", page.Coverage)
}
// 缺基座能力:没有槽位、没有注册方、不是注册插件的宿主句柄。
if _, err := (inprocHost{caps: hostcaps{st: m.st}, owner: "bob", actor: "bob", pluginID: "rdm"}).
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("missing audit slot: %v", err)
}
if _, err := (inprocHost{caps: hostcaps{st: m.st, projectAudit: &projectAuditSlot{}}, owner: "bob", actor: "bob", pluginID: "rdm"}).
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("unregistered authorizer: %v", err)
}
if _, err := (inprocHost{caps: m.caps, owner: "bob", actor: "bob", pluginID: "other"}).
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("another plugin borrowed the policy: %v", err)
}
// 逐对象判定缺失时,精确链接读取也必须 unsupported。
noDisclosure := hostcaps{st: m.st, projectAudit: &projectAuditSlot{}}
if err := (pluginRegistry{pluginID: "rdm", caps: noDisclosure}).RegisterProjectAuditAuthorizer(
func(string, core.ID) (bool, error) { return true, nil }); err != nil {
t.Fatal(err)
}
if _, err := (inprocHost{caps: noDisclosure, owner: "bob", actor: "bob", pluginID: "rdm"}).
QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("missing disclosure authorizer: %v", err)
}
// 声明式扩展与缺少该接口的注册表都明确拒绝注册,不静默忽略。
if err := (compiledRegistry{}).RegisterProjectAuditAuthorizer(func(string, core.ID) (bool, error) { return true, nil }); err == nil {
t.Fatal("declarative plugin registered an audit authorizer")
}
if err := (recordRegistry{Registry: pluginRegistry{pluginID: "rdm", caps: hostcaps{}}}).RegisterProjectAuditAuthorizer(
func(string, core.ID) (bool, error) { return true, nil }); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("registry without audit capability: %v", err)
}
// 导出与无对象正文权同样是显式 unsupported。
if _, err := m.host("bob").QueryProjectAudit(context.Background(),
extension.ProjectAuditQuery{ProjectID: m.projectA, Export: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("export on the actor-owned page: %v", err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(),
extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("body on the actor-owned page: %v", err)
}
}
+13 -2
View File
@@ -119,8 +119,19 @@ func (s *projectContextSlot) readEvidence(ctx context.Context, req extension.Pro
}
func (s *projectContextSlot) recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
if s == nil || req.Scope.Actor == "" || req.Scope.ProjectID == "" || req.Scope.Goal != "" {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope missing")
// Each unusable scope is reported on its own so host-side logs name the
// actual defect; every branch still rejects (fail-closed).
if s == nil {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project context source recheck slot missing")
}
if req.Scope.Actor == "" {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope has no actor")
}
if req.Scope.ProjectID == "" {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope has no project")
}
if req.Scope.Goal != "" {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: source recheck scope carries a generation goal")
}
s.mu.RLock()
p, holder := s.provider, s.holder
@@ -27,6 +27,45 @@ func registeredRecheckSlot(t *testing.T, recheck func(context.Context, extension
return slot
}
func TestProjectSourceRecheckScopeErrorsAreDistinguishable(t *testing.T) {
var nilSlot *projectContextSlot
slot := registeredRecheckSlot(t, func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
t.Error("unusable recheck scope reached the provider")
return extension.ProjectContextSourceRecheckResult{}, nil
})
cases := map[string]func() (extension.ProjectContextSourceRecheckResult, error){
"slot missing": func() (extension.ProjectContextSourceRecheckResult, error) {
return nilSlot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}})
},
"no actor": func() (extension.ProjectContextSourceRecheckResult, error) {
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{ProjectID: "p1"}})
},
"no project": func() (extension.ProjectContextSourceRecheckResult, error) {
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice"}})
},
"carries goal": func() (extension.ProjectContextSourceRecheckResult, error) {
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1", Goal: "secret"}})
},
}
seen := map[string]string{}
for name, call := range cases {
result, err := call()
if err == nil {
t.Fatalf("%s scope accepted", name)
}
if result.Bundle.Status != "" || result.Bundle.ProjectID != "" {
t.Fatalf("%s scope returned a non-empty bundle: %+v", name, result.Bundle)
}
if other, dup := seen[err.Error()]; dup {
t.Fatalf("%s and %s share one error message: %q", other, name, err)
}
seen[err.Error()] = name
}
if len(seen) != len(cases) {
t.Fatalf("expected %d distinct errors, got %d: %v", len(cases), len(seen), seen)
}
}
func TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling(t *testing.T) {
var remaining time.Duration
slot := registeredRecheckSlot(t, func(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
+443
View File
@@ -0,0 +1,443 @@
// 小队内核配置的归属测试(F13 / KTD3):内核用的模型、端点、密钥必须整条来自
// 「本次运行」的生效快照,不得按默认账号重新解析——否则一个用户的模型名会配
// 上另一个账号的端点/密钥。全部使用合成密钥与本地假服务。
package app
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// squadRunCtx 是适配器窄端口的最小宿主实现:只有配置作用域(无 Profile 端口,
// 属主只能来自 run)。
type squadRunCtx struct {
run core.Run
eff core.EffectiveSettings
}
func (c *squadRunCtx) Context() context.Context { return context.Background() }
func (c *squadRunCtx) Run() core.Run { return c.run }
func (c *squadRunCtx) Settings() core.EffectiveSettings { return c.eff }
func (c *squadRunCtx) Emit(core.StreamEventType, any) error { return nil }
func (c *squadRunCtx) UpdatePlan(core.PlanSnapshot) error { return nil }
func (c *squadRunCtx) SpawnSubagent(string, string, string) (core.AgentResultPayload, error) {
return core.AgentResultPayload{}, nil
}
func (c *squadRunCtx) AskHITL(core.HITLCard) (core.HITLResponse, error) {
return core.HITLResponse{}, nil
}
func (c *squadRunCtx) WriteArtifact(string, string, string, []byte) (core.Artifact, error) {
return core.Artifact{}, nil
}
var _ squadadapter.Ctx = (*squadRunCtx)(nil)
// squadProfileRunCtx 是带画像端口的宿主(runmgr 的 runCtx 形态):画像的
// Owner 与 run.Owner 一致,模型覆盖按直连规则生效。
type squadProfileRunCtx struct {
*squadRunCtx
prof core.AgentProfile
}
func (c *squadProfileRunCtx) Profile() core.AgentProfile { return c.prof }
// squadSecretStub 是受管密钥来源桩(Tavily 池按 owner 查)。
type squadSecretStub map[string]string
func (s squadSecretStub) ActiveSecret(owner, _ string) (string, bool) {
v, ok := s[owner]
return v, ok
}
func squadStore(t *testing.T) (*store.Store, *settings.Service) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "squad.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st, settings.NewService(st)
}
// squadLLMCall 是一次上游请求的观测(只记合成字段)。
type squadLLMCall struct {
Path string
Authorization string
Model string
ReasoningSplit bool
}
func squadLLMServer(t *testing.T) (*httptest.Server, func() []squadLLMCall) {
t.Helper()
var mu sync.Mutex
var calls []squadLLMCall
frame := func(v any) string {
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return "data: " + string(raw) + "\n\n"
}
body := frame(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": "ok"}}}}) +
frame(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "stop"}}}) +
frame(map[string]any{"choices": []any{}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}) +
"data: [DONE]\n\n"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var decoded struct {
Model string `json:"model"`
ReasoningSplit bool `json:"reasoning_split"`
}
_ = json.Unmarshal(raw, &decoded)
mu.Lock()
calls = append(calls, squadLLMCall{
Path: r.URL.Path, Authorization: r.Header.Get("Authorization"),
Model: decoded.Model, ReasoningSplit: decoded.ReasoningSplit,
})
mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, body)
}))
t.Cleanup(server.Close)
return server, func() []squadLLMCall {
mu.Lock()
defer mu.Unlock()
return append([]squadLLMCall(nil), calls...)
}
}
// squadCall 驱动装配出的内核流一次,返回终态事件。
func squadCall(t *testing.T, stream agentllm.StreamFunc, model string) []agentllm.Event {
t.Helper()
if stream == nil {
t.Fatal("squad config produced no stream")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var terminal []agentllm.Event
for ev := range stream(ctx, model, []agentllm.Message{{Role: agentllm.RoleUser, Content: "hi"}}, nil, agentllm.StreamOptions{}) {
if ev.Kind == agentllm.KindMessageEnd || ev.Kind == agentllm.KindError {
terminal = append(terminal, ev)
}
}
if len(terminal) != 1 || terminal[0].Kind != agentllm.KindMessageEnd || terminal[0].Err != nil {
t.Fatalf("terminal events = %+v, want one clean message_end", terminal)
}
return terminal
}
func chatEff(model, base, key string) core.EffectiveSettings {
return core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
"model": model, "baseUrl": base, "apiKey": key,
}}}
}
// 两位用户的端点/密钥/模型各不相同:装配必须整条取自本次运行的快照。
func TestSquadNativeOptionsResolvesRouteFromRunScope(t *testing.T) {
aliceSrv, aliceCalls := squadLLMServer(t)
bobSrv, bobCalls := squadLLMServer(t)
// 默认账号:与两位用户都不同;小队路径不得解析到它。
_, svc := squadStore(t)
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
"model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic",
}}}); err != nil {
t.Fatal(err)
}
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
for _, tc := range []struct {
owner core.ID
model string
base string
key string
calls func() []squadLLMCall
foreign []string
}{
{"alice", "alice-model", aliceSrv.URL + "/v1", "sk-alice-synthetic", aliceCalls,
[]string{"bob-model", "sk-bob-synthetic", "default-account-model", "sk-default-synthetic"}},
{"bob", "bob-model", bobSrv.URL + "/v1", "sk-bob-synthetic", bobCalls,
[]string{"alice-model", "sk-alice-synthetic", "default-account-model", "sk-default-synthetic"}},
} {
t.Run(string(tc.owner), func(t *testing.T) {
cfg, enabled := factory(&squadRunCtx{
run: core.Run{ID: "run_" + string(tc.owner), Owner: tc.owner},
eff: chatEff(tc.model, tc.base, tc.key),
})
if !enabled || cfg.Client == nil {
t.Fatalf("factory disabled: enabled=%v cfg=%+v", enabled, cfg)
}
if cfg.DefaultModel != tc.model {
t.Fatalf("model = %q, want %q", cfg.DefaultModel, tc.model)
}
if cfg.Client.BaseURL != tc.base || cfg.Client.APIKey != tc.key {
t.Fatalf("route = %q/%q, want %q/%q", cfg.Client.BaseURL, cfg.Client.APIKey, tc.base, tc.key)
}
for _, foreign := range tc.foreign {
if strings.Contains(cfg.DefaultModel+cfg.Client.BaseURL+cfg.Client.APIKey, foreign) {
t.Fatalf("squad config leaked another account's value %q: model=%q base=%q key=%q",
foreign, cfg.DefaultModel, cfg.Client.BaseURL, cfg.Client.APIKey)
}
}
// 真正发一次请求:模型名、端点与密钥必须成对出现在同一次调用里。
squadCall(t, cfg.Stream, cfg.DefaultModel)
calls := tc.calls()
if len(calls) != 1 {
t.Fatalf("upstream calls = %d, want exactly 1", len(calls))
}
if calls[0].Path != "/v1/chat/completions" {
t.Fatalf("request path = %q, want the run's own endpoint", calls[0].Path)
}
if calls[0].Authorization != "Bearer "+tc.key {
t.Fatalf("authorization = %q, want the run's own key", calls[0].Authorization)
}
if calls[0].Model != tc.model {
t.Fatalf("request model = %q, want %q", calls[0].Model, tc.model)
}
})
}
}
// 受管密钥按属主隔离:画像里的 Owner 决定密钥层归属,不得借默认/系统账号的。
func TestSquadNativeOptionsNeverBorrowsAnotherOwnerSecret(t *testing.T) {
st, svc := squadStore(t)
srv, _ := squadLLMServer(t)
base := srv.URL + "/v1"
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
"model": "shared-model", "baseUrl": base,
}}}); err != nil {
t.Fatal(err)
}
for owner, value := range map[string]string{
settings.OwnerSystemRow: "sk-system-synthetic",
"alice": "sk-alice-synthetic",
"bob": "sk-bob-synthetic",
store.OwnerDefault: "sk-default-synthetic",
} {
if _, err := st.AddSecret(owner, "models.chat", "test", value); err != nil {
t.Fatal(err)
}
}
// 与组合根同一套解析规则:属主 → 系统行 → 无。
saved := runtime.SecretLookup
runtime.SecretLookup = func(owner, service string) (string, string, bool) {
if owner == "" {
owner = store.OwnerDefault
}
if v, ok := st.ActiveSecret(owner, service); ok {
return v, "owner", true
}
if v, ok := st.ActiveSecret(settings.OwnerSystemRow, service); ok {
return v, "system", true
}
return "", "", false
}
t.Cleanup(func() { runtime.SecretLookup = saved })
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
for _, tc := range []struct {
owner, model, key string
}{
{"alice", "alice-profile-model", "sk-alice-synthetic"},
{"bob", "bob-profile-model", "sk-bob-synthetic"},
// 无自有密钥的租户仍按既有规则回落系统行受管密钥(不是默认账号的)。
{"carol", "shared-model", "sk-system-synthetic"},
} {
t.Run(tc.owner, func(t *testing.T) {
prof := core.AgentProfile{Owner: tc.owner}
if tc.model != "shared-model" {
prof.Model = core.ModelRoute{Chat: tc.model}
}
cfg, enabled := factory(&squadProfileRunCtx{
squadRunCtx: &squadRunCtx{
run: core.Run{ID: "run_" + tc.owner, Owner: tc.owner},
eff: svc.Effective(tc.owner, ""),
},
prof: prof,
})
if !enabled {
t.Fatal("factory disabled for a configured run")
}
if cfg.DefaultModel != tc.model || cfg.Client.APIKey != tc.key {
t.Fatalf("route = %q/%q, want %q/%q", cfg.DefaultModel, cfg.Client.APIKey, tc.model, tc.key)
}
if cfg.Client.BaseURL != base {
t.Fatalf("base = %q, want %q", cfg.Client.BaseURL, base)
}
// 无 Profile 端口时属主只能来自 run.Owner——密钥层不得退化成默认账号。
bare, _ := factory(&squadRunCtx{
run: core.Run{ID: "run_" + tc.owner, Owner: tc.owner},
eff: svc.Effective(tc.owner, ""),
})
if bare.Client.APIKey != tc.key || bare.Client.BaseURL != base {
t.Fatalf("owner from run = %q/%q, want %q/%q", bare.Client.APIKey, bare.Client.BaseURL, tc.key, base)
}
})
}
}
// 项目运行消费的是「用户层 + 项目层」合并后的快照:项目覆盖的键取项目,
// 未覆盖的键仍留用户层,绝不整包换回默认账号。
func TestSquadNativeOptionsHonorsProjectLayerSnapshot(t *testing.T) {
_, svc := squadStore(t)
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
"model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic",
}}}); err != nil {
t.Fatal(err)
}
projSrv, projCalls := squadLLMServer(t)
userLayer := map[string]any{
"models": map[string]any{"chat": map[string]any{
"model": "user-model", "baseUrl": "https://user.invalid/v1", "apiKey": "sk-user-synthetic",
}},
"tools": map[string]any{"tavily": map[string]any{"apiKeys": "tvly-user-synthetic"}},
"runtime": map[string]any{"codeOrchestration": true, "exec": true},
}
projectLayer := map[string]any{"models": map[string]any{"chat": map[string]any{
"baseUrl": projSrv.URL + "/v1", "apiKey": "sk-project-synthetic",
}}}
eff := core.EffectiveSettings(core.MergeSettings(userLayer, projectLayer))
factory := squadAgentNativeFactory(squadNativeDeps{
set: svc, secrets: squadSecretStub{store.OwnerDefault: "tvly-active-synthetic"},
})
cfg, enabled := factory(&squadProfileRunCtx{
squadRunCtx: &squadRunCtx{run: core.Run{ID: "run_proj", Owner: "alice", ProjectID: "proj1"}, eff: eff},
prof: core.AgentProfile{Owner: "alice"},
})
if !enabled {
t.Fatal("factory disabled for a configured project run")
}
if cfg.DefaultModel != "user-model" {
t.Fatalf("model = %q, want the user-layer value %q", cfg.DefaultModel, "user-model")
}
if cfg.Client.BaseURL != projSrv.URL+"/v1" || cfg.Client.APIKey != "sk-project-synthetic" {
t.Fatalf("route = %q/%q, want the project-layer endpoint and key", cfg.Client.BaseURL, cfg.Client.APIKey)
}
// 直接驱动内核客户端(受保护的 cfg.Stream 在无项目上下文端口的桩上按
// 设计 fail-closed,不用于本断言):模型名、端点与密钥成对出现。
squadCall(t, cfg.Client.Stream, cfg.DefaultModel)
calls := projCalls()
if len(calls) != 1 || calls[0].Authorization != "Bearer sk-project-synthetic" || calls[0].Model != "user-model" {
t.Fatalf("upstream calls = %+v, want the project endpoint with the user-layer model", calls)
}
// 项目来源的观察/授权限制不得因为改配置解析而放宽。
if cfg.Search != nil || cfg.Exec != nil || cfg.Fetch != nil || cfg.MCP != nil {
t.Fatalf("project run kept direct providers: search=%v exec=%v fetch=%v mcp=%v", cfg.Search != nil, cfg.Exec != nil, cfg.Fetch != nil, cfg.MCP != nil)
}
if cfg.CodeOrchestration {
t.Fatal("project run kept code orchestration")
}
}
// per-route 规则与直连同源:画像模型覆盖后,reasoning split 与受限 EOF 契约
// 都按「覆盖后这条路由」判定。
func TestSquadNativeOptionsMatchesDirectRouteRules(t *testing.T) {
_, svc := squadStore(t)
// 契约只认规范化后的 https 端点,所以这里用合成端点(不发请求)。
base := "https://model.example.com/v1"
if err := svc.MergeSystem(map[string]any{"models": map[string]any{
"chat": map[string]any{"model": "MiniMax-M1", "baseUrl": base},
"routeContractEOF": map[string]any{
"model": "MiniMax-M1",
"baseUrl": base,
"identitySha256": agentllm.RouteContractIdentity("MiniMax-M1", base),
},
}}); err != nil {
t.Fatal(err)
}
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
eff := svc.Effective("alice", "")
for _, tc := range []struct {
name string
profile core.AgentProfile
model string
wantSplit bool
wantContract bool
}{
{"settings route", core.AgentProfile{Owner: "alice"}, "MiniMax-M1", true, true},
{"profile keeps reasoning split, drops contract", core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "MiniMax-M2"}}, "MiniMax-M2", true, false},
{"profile override to plain model", core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "plain-model"}}, "plain-model", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
prof := tc.profile
cfg, enabled := factory(&squadProfileRunCtx{
squadRunCtx: &squadRunCtx{run: core.Run{ID: "run_rules", Owner: "alice"}, eff: eff},
prof: prof,
})
if !enabled {
t.Fatal("factory disabled for a configured run")
}
if cfg.DefaultModel != tc.model {
t.Fatalf("model = %q, want %q", cfg.DefaultModel, tc.model)
}
if cfg.Client.ReasoningSplit != tc.wantSplit {
t.Fatalf("reasoning split = %v, want %v", cfg.Client.ReasoningSplit, tc.wantSplit)
}
if (cfg.Client.RouteContract != nil) != tc.wantContract {
t.Fatalf("route contract = %+v, want attached=%v", cfg.Client.RouteContract, tc.wantContract)
}
// 逐字段等于直连路径同一快照构造出的客户端。
direct := runtime.OpenRouteClient(runtime.ResolveChatModel(prof, eff), eff)
if cfg.Client.BaseURL != direct.BaseURL || cfg.Client.APIKey != direct.APIKey ||
cfg.Client.ReasoningSplit != direct.ReasoningSplit {
t.Fatalf("squad client %+v differs from the direct-path client %+v", cfg.Client, direct)
}
if (cfg.Client.RouteContract == nil) != (direct.RouteContract == nil) {
t.Fatalf("contract decision differs from the direct path: squad=%v direct=%v",
cfg.Client.RouteContract, direct.RouteContract)
}
})
}
}
// 宿主没有 Settings 端口(旧骨架/测试桩)时仍按默认账号快照装配,行为不变。
func TestSquadNativeOptionsFallsBackToDefaultAccountSnapshot(t *testing.T) {
_, svc := squadStore(t)
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
"model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic",
}}}); err != nil {
t.Fatal(err)
}
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
for name, eff := range map[string]core.EffectiveSettings{
"nil snapshot": nil,
"empty snapshot": {},
"settings without run": {"models": map[string]any{"chat": map[string]any{"model": "snapshot-model", "baseUrl": "https://snapshot.invalid/v1"}}},
} {
t.Run(name, func(t *testing.T) {
cfg, enabled := factory(&squadRunCtx{run: core.Run{ID: "run_default", Owner: store.OwnerDefault}, eff: eff})
if !enabled {
t.Fatal("default-config run stopped working")
}
want := "snapshot-model"
wantBase := "https://snapshot.invalid/v1"
if len(eff) == 0 {
want, wantBase = "default-account-model", "https://default.invalid/v1"
}
if cfg.DefaultModel != want || cfg.Client.BaseURL != wantBase {
t.Fatalf("route = %q/%q, want %q/%q", cfg.DefaultModel, cfg.Client.BaseURL, want, wantBase)
}
})
}
// 没有快照来源也没有默认账号配置时保持未启用(不阻塞产品)。
if _, enabled := squadAgentNativeFactory(squadNativeDeps{})(&squadRunCtx{run: core.Run{ID: "run_none"}}); enabled {
t.Fatal("factory reported enabled without any configuration source")
}
}
+149
View File
@@ -0,0 +1,149 @@
// 小队 agent-native 内核工厂的装配(组合根注入 squadadapter.AgentNativeOptions)。
//
// 配置解析的硬约束(KTD3 / 审计 F13):内核用的模型、端点、密钥必须整条来自
// 「本次运行」的生效快照——rc.Settings() 已按 run.Owner + run.ProjectID 合并,
// 画像模型覆盖叠加其上,per-route 规则(reasoning split / 受限 EOF 契约)由
// runtime.OpenRouteClient 与直连路径同一函数判定。不得在这里按默认账号重新
// 解析:那会把一个用户的模型名和另一个账号的端点/密钥拼在一起。
package app
import (
"context"
"fmt"
"os"
"strings"
"agent-native/runtime/checkpoint"
agentllm "agent-native/runtime/llm"
agentmcp "agent-native/runtime/mcp"
"agent-native/runtime/policy"
agenttools "agent-native/runtime/tools"
nativeadp "agent-squad/adapters/native"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
core "agent-system-core"
)
// squadNativeDeps 是内核工厂的依赖。mcp 与 pluginMCP 取函数而非值:插件宿主在
// Run 晚些时候才装配,工厂必须按调用时读,不能把启动期的空值固化进去。
type squadNativeDeps struct {
// set 提供回落快照:宿主没有 Settings 端口时(骨架/旧宿主)才用默认账号。
set *settings.Service
// secrets 是受管密钥来源(Tavily 池)。
secrets activeSecretSource
// skillsRoot 是交付配方技能根。
skillsRoot string
trace func(name string, attrs map[string]string) func()
mcp func(raw, overrides string) *agentmcp.Manager
pluginMCP func() []core.PluginMCPServer
}
// squadAgentNativeFactory 返回注入 squadadapter.AgentNativeOptions 的工厂。
// 主模型未配置时返回未启用(不阻塞产品);其余装配对每次运行重新解析。
func squadAgentNativeFactory(deps squadNativeDeps) func(squadadapter.Ctx) (nativeadp.Config, bool) {
return func(rc squadadapter.Ctx) (nativeadp.Config, bool) {
// 当前运行的用户/项目快照;宿主无 Settings 端口时才回落默认账号。
effNow, ok := runtime.RunEffectiveSettings(rc)
if !ok {
if deps.set == nil {
return nativeadp.Config{}, false
}
effNow = deps.set.Effective(store.OwnerDefault, "")
}
// 画像模型覆盖走同一条解析规则(Owner 决定密钥层归属)。
route := runtime.ResolveChatModel(runtime.RunProfile(rc), effNow)
if route.Model == "" || route.Base == "" {
return nativeadp.Config{}, false
}
mode := "default"
if src, ok := rc.(interface{ PermissionMode() string }); ok {
mode = src.PermissionMode()
}
cfg := nativeadp.Config{
Client: runtime.OpenRouteClient(route, effNow),
DefaultModel: route.Model,
MaxTurns: 0,
Timeout: 0,
Subagent: squadadapter.SubagentFactory(rc),
RepeatGuard: true,
BeforeTool: func(call agentllm.ToolCall) error {
effect := policy.Decide(policy.Request{
Mode: policy.Normalize(mode), Name: call.Name, Args: call.Arguments,
Traits: policy.TraitsFor(call.Name),
}, nil)
if effect == policy.EffectDeny {
return fmt.Errorf("计划模式拒绝了工具 %s", call.Name)
}
return nil
},
}
cfg.Stream = runtime.GuardProjectStream(rc, "", cfg.Client.Stream)
var keys []string
if deps.secrets != nil {
keys = tavilyKeyPool(deps.secrets, effNow.GetString("tools.tavily.apiKeys", ""))
}
if len(keys) > 0 {
cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
}
if effNow.GetBool("runtime.exec", true) {
cfg.Exec = tools.InterpreterExecConfig()
}
if st2, err := os.Stat(deps.skillsRoot); err == nil && st2.IsDir() {
cfg.SkillsRoots = []string{deps.skillsRoot}
}
rawMCP := strings.TrimSpace(effNow.GetString("tools.mcp.servers", ""))
if deps.pluginMCP != nil {
rawMCP = mergeMCPServers(rawMCP, deps.pluginMCP())
}
if deps.mcp != nil {
if mgr := deps.mcp(rawMCP, strings.TrimSpace(effNow.GetString("tools.mcp.overrides", ""))); mgr != nil {
cfg.MCP = mgr
}
}
// Native squad tools do not yet expose a fail-closed result hook to the
// project observation outbox. Project runs therefore use the audited
// runCtx retrieval/tool path only; enabling these direct providers would
// let their results reach the next model turn without source analysis.
if rc.Run().ProjectID != "" {
cfg.Search = nil
cfg.Exec = nil
cfg.Fetch = nil
cfg.MCP = nil
}
cfg.CodeOrchestration = rc.Run().ProjectID == "" && effNow.GetBool("runtime.codeOrchestration", false)
if ask, ok := rc.(interface {
AskUser(card core.HITLCard) (core.HITLResponse, error)
}); ok {
cfg.Ask = func(_ context.Context, question string) (string, error) {
resp, err := ask.AskUser(core.HITLCard{
Question: question, Kind: core.HITLKindInput, AllowFreeInput: true,
})
if err != nil {
return "", err
}
if strings.TrimSpace(resp.FreeText) != "" {
return strings.TrimSpace(resp.FreeText), nil
}
return resp.ChoiceID, nil
}
}
cfg.Trace = deps.trace
// N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达
// runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。
cfg.PermissionAsk = runtime.PermissionAskOf(rc)
if host, ok := rc.(interface {
OpenCheckpoints() (*checkpoint.Log, error)
CheckpointAnchor() string
}); ok {
if lg, err := host.OpenCheckpoints(); err == nil {
cfg.Checkpoints = lg
anchor := host.CheckpointAnchor()
cfg.CheckpointAnchor = func() string { return anchor }
}
}
return cfg, true
}
}
+16
View File
@@ -54,3 +54,19 @@ func TestCatalogReturnsIndependentCopies(t *testing.T) {
t.Fatal("Catalog exposed mutable registry storage")
}
}
// TestCatalogLinkedActionsStayProjectScopedAndUnprotected pins the narrowing
// that the ported batch writers enforce: project.create remains the single
// role authority, and the linked own-project management actions stay
// project-scoped and never protected so the role cannot inherit them.
func TestCatalogLinkedActionsStayProjectScopedAndUnprotected(t *testing.T) {
for _, actionID := range ProjectCreateLinkedActions() {
descriptor, ok := DescriptorByID(actionID)
if !ok || descriptor.Scope != ScopeProject || descriptor.Protected {
t.Fatalf("linked %s descriptor=%+v ok=%v", actionID, descriptor, ok)
}
if IsProjectCreateLinkedAction(ProjectAccessAction) {
t.Fatal("project.create reported as a linked action of itself")
}
}
}
@@ -2,7 +2,10 @@ package cmdbus
import (
"context"
"errors"
"strings"
"testing"
"time"
agentcommand "agent-command"
)
@@ -16,3 +19,46 @@ func TestStrictAccountWritesRejectLegacyBusPorts(t *testing.T) {
t.Fatalf("legacy port wrote under strict mode: %+v, cards=%d audit=%d pending=%d", resp.Receipt, len(cards.created), len(audit.logs), len(b.pending))
}
}
// fencedAuditStore 是"账号围栏内审计落盘失败"的替身:围栏写路径已经跑完,
// 只有审计行写不进去(围栏内审计失败=结果不确定,不是"什么都没发生")。
type fencedAuditStore struct{ appends int }
func (f *fencedAuditStore) AppendAudit(string, agentcommand.AuditEnvelope) error {
f.appends++
return errors.New("audit fenced")
}
func (f *fencedAuditStore) AppendAuditAccountChecked(string, agentcommand.AuditEnvelope, bool) error {
f.appends++
return errors.New("audit fenced")
}
// TestStrictAccountWriteAuditLossKeepsOperationID:围栏档下 handler 已提交、
// 审计写失败时,回执仍是 unknown 且带上 handler 拿到的 operationID,
// 调用方才能在围栏外按记录对账(F10 Bug 2 在围栏路径上的同一语义)。
func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) {
audit := &fencedAuditStore{}
b := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
b.RequireAccountCheckedWrites(true)
var seen string
if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.fenced", Title: "围栏写入"}, "", func(_ context.Context, req Request) (any, error) {
seen = req.OperationID
return "written", nil
}); err != nil {
t.Fatal(err)
}
resp := b.Dispatch(context.Background(), Request{ID: "demo.fenced", Owner: "alice", Actor: "alice"})
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 resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil {
t.Fatalf("fenced receipt must declare possible side effects and validate: %+v", resp.Receipt)
}
if audit.appends != 1 {
t.Fatalf("account-checked audit appends = %d, want 1", audit.appends)
}
}
+173 -14
View File
@@ -26,6 +26,43 @@ type AuditStore interface {
AppendAudit(owner string, env agentcommand.AuditEnvelope) error
}
// OperationRecord 是"已记录操作"的最小对账投影(R7/KTD6)。它回答的是
// “这个 operationID 在本 owner/project 名下到底是什么结果”,而不是重放入口。
// 刻意保持最小:审计信封只存状态与标识,不存理由文本,所以这里没有 Reason——
// 不为不存在的字段留位置。
type OperationRecord struct {
OperationID string
CommandID string
State agentcommand.ResultState
Time time.Time
}
// OperationQuery 是 operationID 的对账查询端口(组合根装配;nil = 不提供)。
//
// 授权口径:owner 与 projectID 都是可信过滤条件,实现必须把它们放进查询谓词
// (SQL 的 owner/project 等值条件),不得由调用方参数扩大可见范围;查不到就
// 返回 not found,不得回退为“未执行”。
//
// 诚实边界:本端口只解析“已经落成记录”的 operation。宿主侧的命令回执记录就是
// 命令审计信封(command_audit 按 owner+project 归属);handler 自己的领域操作表
// (如文档投递 document_render_deliveries)由各自领域查询负责,不经本端口。某些
// 命令根本不持久化 req.OperationID(内置的 builtin.help、runtime.mock、
// models.set、conversation.profile、conversation.create 都是无操作表的即时用例),
// 对它们的 operationID 查询恒为 not found——这类命令只能按领域资源(会话、画像、
// 设置项)对账,不得声称可按 operationID 查询。
type OperationQuery interface {
LookupOperation(owner, projectID, operationID string) (OperationRecord, bool, error)
}
var (
// ErrOperationScope 缺少可信 owner/project/operation 作用域:fail-closed,无作用域即无可见性。
ErrOperationScope = errors.New("cmdbus: operation lookup requires owner, project and operation scope")
// ErrOperationNotRecorded 该作用域内没有此 operation 的记录(未记录 ≠ 未执行)。
ErrOperationNotRecorded = errors.New("cmdbus: no operation record in this owner and project scope")
// ErrOperationQueryUnavailable 组合根未装配对账查询端口。
ErrOperationQueryUnavailable = errors.New("cmdbus: operation query unavailable")
)
// CardPort 确认卡所需卡片能力(cardsvc.Service 实现)。
type CardPort interface {
Create(owner string, card core.ActivityCard, ref string) (core.ActivityCard, error)
@@ -35,6 +72,39 @@ type CardPort interface {
// Handler 宿主执行适配器:同步内置用例返回 payload;异步执行自行返回 accepted 回执。
type Handler func(ctx context.Context, req Request) (any, error)
// ErrNotExecuted 标记"失败确定发生在任何副作用之前"的 handler 错误(F10/U7b)。
// 语义口径:只有带此标记的错误才可归 rejected(确定未执行、无副作用、回执不得
// 携带 operationID);未标记的 handler 错误一律按"已执行随后不确定"归 unknown,
// 并带上本次分发已签发的 trusted operation ID 交由对账收敛。宿主不得据
// "处理方报了错"臆断未执行——那会把已写库的操作诱使成不安全重试。
//
// 进程内插件(pkg/extension.Handler)当前还没有等价的声明位:插件命令的
// handler 错误一律按 unknown 对账,直到扩展面补上对应标记为止。
var ErrNotExecuted = errors.New("cmdbus: not executed")
// NotExecutedError 是带"确定未执行"声明的错误包装。Error() 保留原始文案
// (回执 reason 不因标记而变形),Unwrap/Is 让 errors.Is 同时匹配
// ErrNotExecuted 与被包装的原始错误。
type NotExecutedError struct{ Err error }
func (e *NotExecutedError) Error() string { return e.Err.Error() }
func (e *NotExecutedError) Unwrap() error { return e.Err }
func (e *NotExecutedError) Is(target error) bool {
return target == ErrNotExecuted
}
// NotExecuted 声明该失败确定未执行(校验/权限/确认缺失/领域事务回滚等任何
// 副作用之前的拒绝)。nil 原样返回;重复标记幂等。
func NotExecuted(err error) error {
if err == nil || IsNotExecuted(err) {
return err
}
return &NotExecutedError{Err: err}
}
// IsNotExecuted 报告该错误是否声明了"确定未执行"。
func IsNotExecuted(err error) bool { return errors.Is(err, ErrNotExecuted) }
// CommandTool EXT-14:带 exposeAsTool 声明的命令的工具投影视图(runmgr 消费)。
// Risk 取 low|medium|high(工具权限声明口径);ID 即工具名(命令 id)。
type CommandTool struct {
@@ -171,6 +241,8 @@ type Bus struct {
strictAccountWrites bool
allowImplicitLocal bool
projectWorkPreflight func(context.Context, Request) error
// operationQuery operationID 对账查询端口(nil = 不提供,见 OperationQuery)。
operationQuery OperationQuery
}
func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) {
@@ -200,6 +272,33 @@ func (b *Bus) Registry() *agentcommand.Registry { return b.reg }
// SetPendingStore 注入确认挂起态的持久化实现(nil = 仅进程内)。
func (b *Bus) SetPendingStore(st PendingStore) { b.pendingStore = st }
// SetOperationQuery 装配 operationID 对账查询端口(组合根;启动期一次)。
func (b *Bus) SetOperationQuery(q OperationQuery) { b.operationQuery = q }
// LookupOperation 按 owner/project 作用域查一个已记录 operation 的当前状态
// (R7/KTD6)。这是 unknown 回执的对账入口:拿到 operationID 的调用方先查
// 记录,再决定下一步,而不是重发命令。
//
// 三重 fail-closed:作用域任一为空即拒(不猜、不按 owner=“”放行)、未装配端口
// 即拒、查不到即 ErrOperationNotRecorded(不推断“未执行”)。跨 owner 查询由
// 端口的 owner 等值谓词拒绝——本层只把 owner 当可信条件往下传。
func (b *Bus) LookupOperation(owner, projectID, operationID string) (OperationRecord, error) {
if owner == "" || projectID == "" || operationID == "" {
return OperationRecord{}, ErrOperationScope
}
if b.operationQuery == nil {
return OperationRecord{}, ErrOperationQueryUnavailable
}
record, found, err := b.operationQuery.LookupOperation(owner, projectID, operationID)
if err != nil {
return OperationRecord{}, err
}
if !found || record.OperationID == "" {
return OperationRecord{}, ErrOperationNotRecorded
}
return record, nil
}
// SetEffectiveAccountGuard installs the composition-root check at startup.
// Handler Store writes still need their own final transaction fence.
func (b *Bus) SetEffectiveAccountGuard(g EffectiveAccountGuard) { b.accountGuard = g }
@@ -405,10 +504,12 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
if b.pendingStore != nil {
raw, _ := json.Marshal(req)
if err := b.savePending(card.ID, accountActor(req), string(raw)); err != nil {
return receipt(agentcommand.StateUnknown, "", "confirm pending: "+err.Error())
// 确认卡已创建(副作用已发生),挂起态却没落库:结果不确定,且
// 卡 ID 就是本次受理的操作标识——随 unknown 一并给出,供对账。
return receipt(agentcommand.StateUnknown, card.ID, "confirm pending: "+err.Error())
}
} else if b.strictAccountWrites {
return receipt(agentcommand.StateUnknown, "", "confirm pending: account-checked store required")
return receipt(agentcommand.StateUnknown, card.ID, "confirm pending: account-checked store required")
}
b.pendMu.Lock()
b.pending[card.ID] = pending{req: req, cardID: card.ID}
@@ -425,7 +526,9 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
}
// The domain handler can persist this trusted ID with its transaction/outbox.
// Entropy, not the clock or caller Params, distinguishes concurrent operations.
// A handler failure still writes only a rejected audit receipt without an ID.
// A handler error keeps the ID only on the "executed then uncertain" path
// (unknown): rejected must never carry one, and unknown without a queryable
// handle would be a dead end for reconciliation.
var opBytes [24]byte
if _, err := rand.Read(opBytes[:]); err != nil {
return receipt(agentcommand.StateUnknown, "", "operation ID unavailable")
@@ -433,11 +536,26 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
req.OperationID = "op_" + hex.EncodeToString(opBytes[:])
payload, err := h(ctx, req)
if err != nil {
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
if IsNotExecuted(err) {
// handler 声明失败发生在任何副作用之前:确定未执行,可安全归
// rejected(回执契约禁止 rejected 携带 operationID)。
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
}
// handler 已开始执行才报错:宿主无法断言"无副作用"(F10)。按"已执行
// 随后不确定"归 unknown,并带上本次分发已签发的 trusted operation ID,
// 调用方据此按记录对账,而不是盲目重试。本路径不做任何自动重放。
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() 通过且对账句柄不丢。
func (b *Bus) unknownAfterExecution(req Request, reason string, start time.Time) Response {
return Response{Receipt: b.auditReceipt(req, agentcommand.StateUnknown, req.OperationID, reason, start)}
}
// Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。
func (b *Bus) Confirm(owner, cardID, actionID string) Response {
return b.ConfirmContext(context.Background(), owner, cardID, actionID)
@@ -456,7 +574,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", Time: b.now().UTC()}}
return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
}
if b.pendingStore != nil {
storedOwner, payload, found, err := b.takePending(cardID, owner)
@@ -466,7 +584,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", Time: b.now().UTC()}}
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
}
p = pending{req: req, cardID: cardID}
ok = true
@@ -477,11 +595,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)", Time: b.now().UTC()}}
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()}}
}
req := p.req
if req.Owner != "" && req.Owner != owner {
return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
}
b.pendMu.Lock()
delete(b.pending, cardID)
@@ -497,15 +615,29 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
}
resp := b.Dispatch(ctx, req)
if b.cards != nil {
if resp.Receipt.State == agentcommand.StateCommitted {
switch resp.Receipt.State {
case agentcommand.StateCommitted:
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "ok", "confirmed")
} else {
case agentcommand.StateUnknown:
// 已执行随后不确定:卡片必须落到终态(悬在 action_required 会诱导
// 二次确认,而挂起项已被消费,重放只会得到"已解决"),但不得断言
// 失败——tone 用 warn,note 写明不确定原因与对账用 operationID。
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "warn", uncertainNote(resp.Receipt))
default:
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason)
}
}
return resp
}
// uncertainNote 组成"结果未知"卡片的说明:不确定原因 + 对账句柄。
func uncertainNote(r agentcommand.ExecReceipt) string {
if r.OperationID == "" {
return r.Reason
}
return r.Reason + " (operation " + r.OperationID + ")"
}
func accountActor(req Request) string {
// Plugin and delegated commands use Actor for an audit label such as a
// plugin ID; Owner is the effective account whose state controls effects.
@@ -528,7 +660,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", Time: start.UTC(),
Reason: "effective account unavailable", SideEffects: agentcommand.SideEffectsNone, Time: start.UTC(),
}}
}
@@ -555,10 +687,37 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas
err = b.audit.AppendAudit(req.Owner, env)
}
if err != nil {
// 审计不可用:结果标记未知,不臆断成功(四态语义)。
return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()}
// 审计不可用:结果标记未知,不臆断成功(四态语义)。已经签发过操作标识的
// 路径(committed/accepted/已执行后不确定)连同该标识一并返回——信封丢失
// 不等于操作没发生,调用方仍需按 operationID 对账;确定未执行的路径不带
// 标识(rejected 契约禁止带 ID,unknown 无 ID 时也无需对账)。
return unknownReceipt(req.ID, state, op, "audit unavailable: "+err.Error(), b.now().UTC())
}
return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason, Time: b.now().UTC()}
if state == agentcommand.StateUnknown && op != "" {
// 已执行随后不确定:只有库内受信构造器允许 unknown 携带本流程签发的 ID。
return agentcommand.NewUnknownAfterExecution(req.ID, op, reason, b.now().UTC())
}
return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason,
SideEffects: sideEffectsFor(state), Time: b.now().UTC()}
}
// unknownReceipt 构造"结果未知"回执:已签发过操作标识时走受信构造器携带该标识
// (供对账),否则返回不带标识的 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)
}
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 {
+256 -3
View File
@@ -52,7 +52,8 @@ func newTestBus(a AuditStore, c CardPort) *Bus {
}, "/risky", func(ctx context.Context, req Request) (any, error) {
t, _ := req.Params["target"].(string)
if t == "boom" {
return nil, errors.New("handler rejected")
// 参数级拒绝发生在任何副作用之前:显式声明 not-executed(F10)。
return nil, NotExecuted(errors.New("handler rejected"))
}
return map[string]any{"did": t}, nil
})
@@ -106,13 +107,20 @@ func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) {
if req.OperationID == "" {
t.Fatal("handler must receive a tentative host operation ID")
}
return nil, errors.New("domain transaction rejected")
// 领域事务回滚 = 确定未执行:声明 not-executed 才是 rejected 的依据。
return nil, NotExecuted(errors.New("domain transaction rejected"))
}); err != nil {
t.Fatal(err)
}
failed := bus.Dispatch(context.Background(), Request{ID: "rdm.fail", Actor: "alice", Owner: "alice"})
if failed.Receipt.State != agentcommand.StateRejected || failed.Receipt.OperationID != "" || len(audit.logs) != 2 || audit.logs[1].OperationID != "" {
t.Fatalf("failed handler was marked committed: %+v audits=%+v", failed.Receipt, audit.logs)
t.Fatalf("not-executed handler was not rejected: %+v audits=%+v", failed.Receipt, audit.logs)
}
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)
}
}
@@ -342,3 +350,248 @@ func TestHighDangerPluginCommandConfirmsFirst(t *testing.T) {
t.Fatalf("confirm: %+v executed=%d", resp2.Receipt, executed)
}
}
// ---- F10/U7b:确定未执行 vs 已执行后不确定 + operationID 对账 ----
// memOperationQuery 是按 owner/project 等值谓词过滤的操作记录替身(模拟 SQL
// 作用域):跨 owner 或跨 project 的 operationID 一律查不到。
type memOperationQuery struct {
records map[string]OperationRecord // key: owner|projectID|operationID
}
func operationKey(owner, projectID, operationID string) string {
return owner + "|" + projectID + "|" + operationID
}
func (q *memOperationQuery) LookupOperation(owner, projectID, operationID string) (OperationRecord, bool, error) {
rec, ok := q.records[operationKey(owner, projectID, operationID)]
return rec, ok, nil
}
// recordAudit 把总线真实落下的审计信封登记为可查操作记录(对齐"审计信封就是
// 宿主侧操作记录"的口径),用于验证 unknown 回执给出的对账句柄真的查得到。
func (q *memOperationQuery) recordAudit(owner, projectID string, env agentcommand.AuditEnvelope) {
if env.OperationID == "" {
return
}
q.records[operationKey(owner, projectID, env.OperationID)] = OperationRecord{
OperationID: env.OperationID, CommandID: env.CommandID, State: env.State, Time: env.Time,
}
}
// TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID 是 F10 的核心回归:
// handler 已经写库、之后才失败时,回执必须是 unknown 且携带它拿到的
// operationID,理由点明"执行开始后失败",绝不退化成 rejected(会诱发不安全重试)。
func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
var writes []string
var seen string
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, "", func(_ context.Context, req Request) (any, error) {
seen = req.OperationID
writes = append(writes, req.OperationID) // 副作用已发生
return nil, errors.New("commit acknowledgement lost")
}); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"})
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 !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)
}
if len(writes) != 1 {
t.Fatalf("handler ran %d times, want 1", len(writes))
}
// 对账凭证:审计信封落 unknown + 同一个 operationID。
if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateUnknown || audit.logs[0].OperationID != seen {
t.Fatalf("audit envelope is not reconcilable: %+v", audit.logs)
}
// 宿主不得自动重放:再次分发是一次新操作(新的 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 {
t.Fatalf("re-dispatch must be a new operation, not a replay: %+v writes=%d", again.Receipt, len(writes))
}
}
// TestHandlerNotExecutedErrorStaysRejected:handler 显式声明未执行才是
// rejected 的依据;回执不得携带 operationID(库契约)。
func TestHandlerNotExecutedErrorStaysRejected(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
calls := 0
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.guard", Title: "守卫"}, "", func(context.Context, Request) (any, error) {
calls++
boom := errors.New("permission denied")
return nil, NotExecuted(boom)
}); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), Request{ID: "demo.guard", Owner: "alice", Actor: "alice"})
if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" {
t.Fatalf("not-executed failure must stay rejected without an ID: %+v", resp.Receipt)
}
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 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)
}
// 标记语义:errors.Is 同时命中 ErrNotExecuted 与原始错误;nil 幂等。
wrapped := NotExecuted(errors.New("boom"))
if !IsNotExecuted(wrapped) || !errors.Is(wrapped, ErrNotExecuted) || wrapped.Error() != "boom" {
t.Fatalf("not-executed wrapper semantics: %v / %v", wrapped, IsNotExecuted(wrapped))
}
if NotExecuted(nil) != nil || !IsNotExecuted(NotExecuted(wrapped)) {
t.Fatal("not-executed marking must be nil-safe and idempotent")
}
}
// TestCommittedWithAuditFailureKeepsOperationID 是 F10 Bug 2 的回归:审计
// 落盘失败时回执仍是 unknown,但必须带着已交给 handler 的 operationID,
// 理由点名审计不可用,调用方才能按记录对账而不是重发。
func TestCommittedWithAuditFailureKeepsOperationID(t *testing.T) {
audit := &memAudit{fail: true}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
var seen string
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "", func(_ context.Context, req Request) (any, error) {
seen = req.OperationID
return map[string]any{"pong": true}, nil
}); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), Request{ID: "demo.ping", Owner: "alice", Actor: "alice"})
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 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 len(audit.logs) != 0 {
t.Fatalf("audit rows written while failing: %+v", audit.logs)
}
// 校验:未执行路径(rejected)在审计不可用时不得凭空出现 operationID。
rejected := bus.Dispatch(context.Background(), Request{ID: "demo.absent"})
if rejected.Receipt.OperationID != "" || rejected.Receipt.State != agentcommand.StateUnknown {
t.Fatalf("audit loss must not invent an operation ID: %+v", rejected.Receipt)
}
}
// TestValidationRejectionNeverRunsHandler:显式校验拒绝仍是 rejected、无 ID、
// 无副作用,且 handler 一次都不许跑。
func TestValidationRejectionNeverRunsHandler(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
calls := 0
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{
ID: "demo.needs", Title: "需要参数",
ParamsSchema: json.RawMessage(`{"type":"object","required":["target"],"properties":{"target":{"type":"string"}}}`),
}, "", func(context.Context, Request) (any, error) {
calls++
return "ran", nil
}); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), Request{ID: "demo.needs", Owner: "alice", Params: map[string]any{}})
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 len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" {
t.Fatalf("validation audit row: %+v", audit.logs)
}
}
// TestRepeatedConfirmExecutesOnce:确认卡重复确认不得二次执行(F10 分类改动
// 不得放宽既有收口语义);unknown 卡面也不得悬在待确认。
func TestRepeatedConfirmExecutesOnce(t *testing.T) {
audit, cards := &memAudit{}, &memCards{}
bus := New(audit, cards, func() time.Time { return time.Unix(1700000000, 0).UTC() })
executed := 0
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{
ID: "demo.once", Title: "只执行一次", Danger: agentcommand.DangerHigh,
}, "/once", func(context.Context, Request) (any, error) {
executed++
return "done", nil
}); err != nil {
t.Fatal(err)
}
if r := bus.Dispatch(context.Background(), Request{ID: "demo.once", Owner: "alice", ConversationID: "cnv_1"}); r.Receipt.State != agentcommand.StateAccepted {
t.Fatalf("pre-confirm: %+v", r.Receipt)
}
first := bus.Confirm("alice", "card_x1", "confirm")
second := bus.Confirm("alice", "card_x1", "confirm")
if first.Receipt.State != agentcommand.StateCommitted || executed != 1 {
t.Fatalf("confirm: %+v executed=%d", first.Receipt, executed)
}
if second.Receipt.State != agentcommand.StateRejected || second.Receipt.OperationID != "" || executed != 1 {
t.Fatalf("re-confirm executed twice: %+v executed=%d", second.Receipt, executed)
}
}
// TestLookupOperationIsOwnerProjectScoped(R7/KTD6):unknown 回执的对账查询
// 复用 owner/project 等值作用域;跨 owner、跨 project、缺作用域、未装配端口
// 一律 fail-closed,查不到绝不推断"未执行"。
func TestLookupOperationIsOwnerProjectScoped(t *testing.T) {
audit := &memAudit{}
query := &memOperationQuery{records: map[string]OperationRecord{}}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
if _, err := bus.LookupOperation("alice", "proj_1", "op_1"); !errors.Is(err, ErrOperationQueryUnavailable) {
t.Fatalf("unwired query must fail closed: %v", err)
}
bus.SetOperationQuery(query)
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, "", func(context.Context, Request) (any, error) {
// 副作用之后失败 → unknown + operationID(对账句柄)。
return nil, errors.New("ack lost")
}); err != nil {
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 == "" {
t.Fatalf("setup dispatch: %+v", resp.Receipt)
}
// 对账记录=总线真实落下的审计信封(同一 owner/project 快照)。
for _, env := range audit.logs {
query.recordAudit("alice", "proj_1", env)
}
op := resp.Receipt.OperationID
record, err := bus.LookupOperation("alice", "proj_1", op)
if err != nil {
t.Fatalf("owner-scoped lookup: %v", err)
}
if record.OperationID != op || record.CommandID != "demo.write" || record.State != agentcommand.StateUnknown {
t.Fatalf("lookup returned the wrong record: %+v", record)
}
if _, err := bus.LookupOperation("bob", "proj_1", op); !errors.Is(err, ErrOperationNotRecorded) {
t.Fatalf("cross-owner lookup must not resolve: %v", err)
}
if _, err := bus.LookupOperation("alice", "proj_other", op); !errors.Is(err, ErrOperationNotRecorded) {
t.Fatalf("cross-project lookup must not resolve: %v", err)
}
for _, scope := range [][3]string{{"", "proj_1", op}, {"alice", "", op}, {"alice", "proj_1", ""}} {
if _, err := bus.LookupOperation(scope[0], scope[1], scope[2]); !errors.Is(err, ErrOperationScope) {
t.Fatalf("scope %v must fail closed: %v", scope, err)
}
}
if _, err := bus.LookupOperation("bob", "proj_1", "op_absent"); !errors.Is(err, ErrOperationNotRecorded) {
t.Fatalf("unknown operation must not be reported as executed: %v", err)
}
}
+34
View File
@@ -2,7 +2,9 @@ package cmdbus
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
agentcommand "agent-command"
@@ -31,3 +33,35 @@ func TestPendingConfirmRestoredAfterRestart(t *testing.T) {
t.Fatalf("restored confirm: %+v", again.Receipt)
}
}
// failingPendingStore 是"确认卡已创建、挂起态写不进去"的替身:受理已经发生
// (卡在收件箱里),只是结果不可知。
type failingPendingStore struct{ err error }
func (p *failingPendingStore) SavePendingConfirm(string, string, string) error { return p.err }
func (p *failingPendingStore) TakePendingConfirm(string) (string, string, bool, error) {
return "", "", false, p.err
}
// TestConfirmPendingWriteFailureKeepsCardOperationID:挂起态落盘失败不得吞掉操作
// 标识——卡 ID 就是本次受理的句柄,unknown 必须带出去(否则受理事实无凭证)。
func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) {
audit, cards := &memAudit{}, &memCards{}
b := newTestBus(audit, cards)
b.SetPendingStore(&failingPendingStore{err: errors.New("pending store down")})
resp := b.Dispatch(context.Background(), Request{
ID: "demo.risky", Params: map[string]any{"target": "prod"}, ConversationID: "cnv_1", Owner: "alice",
})
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 {
t.Fatalf("pending write failure dropped the card operation handle: %+v", resp.Receipt)
}
if len(cards.created) != 1 || len(b.pending) != 0 {
t.Fatalf("card created=%d pending=%d", len(cards.created), len(b.pending))
}
if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateUnknown || audit.logs[0].OperationID != "card_x1" {
t.Fatalf("audit envelope: %+v", audit.logs)
}
}
+84 -20
View File
@@ -7,6 +7,8 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
agentintent "agent-intent/intent"
@@ -14,8 +16,21 @@ import (
// SlotQuestion 是命中意图后仍缺的必填槽位,以及建议的提问。
type SlotQuestion struct {
Key string
Question string
Key string `json:"key"`
Question string `json:"question"`
}
// SlotDefect 是一条槽位缺陷,来自权威识别结果的校验(F09):
// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠/
// 生成路径的未知键),Reason 是库层原因原文;
// - Invalid=false:必填槽位根本没给(等价于 SlotQuestion,Reason 为空)。
//
// 宿主据此区分「没给」与「给错了」,两种情形都不派发业务动作。
type SlotDefect struct {
Key string `json:"key"`
Reason string `json:"reason,omitempty"`
Question string `json:"question"`
Invalid bool `json:"invalid"`
}
// MatchResult 是一次识别。Branch 与 Match 相同。
@@ -24,25 +39,45 @@ type MatchResult struct {
ID string
Slots map[string]string
Missing []SlotQuestion
// Invalid 是捕获到但未通过权威校验的槽位(含生成路径的未知键)。
// 与 Missing 分开投影:必填且非法只进此处,不重复计入 Missing。
Invalid []SlotDefect
// Risk/RequiresConfirm/Action 是权威候选的声明(规则路径与生成路径同款):
// 风险档、是否要求显式确认、spec.action 显式动作引用。nil Action 表示未声明。
Risk string
RequiresConfirm bool
Action *ActionRef
// Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。
// guard_hit/expectation_hit 被折叠为 fallback 时为空。
Commands []Command
}
// 命令类型经闸门投影(别名):生态库按边界规则只许本包导入,
// Router/runmgr 等消费方只依赖本包表面。
// Complete 报告槽位是否齐全且全部通过权威校验:false 时业务动作不得派发。
func (m MatchResult) Complete() bool { return len(m.Missing) == 0 && len(m.Invalid) == 0 }
// 命令与目录类型经闸门投影(别名):生态库按边界规则只许本包导入,
// Router/runmgr 等消费方只依赖本包表面。CatalogSpec/CatalogSlot 随生成器端口
// (SetGenerator 的入参)投影,实现生成器不必越过本包直接引用生态库。
type (
Command = agentintent.Command
ActionRef = agentintent.ActionRef
Command = agentintent.Command
ActionRef = agentintent.ActionRef
CatalogSpec = agentintent.CatalogSpec
CatalogSlot = agentintent.CatalogSlot
)
const (
CommandStart = agentintent.CommandStart
ActionAuto = agentintent.ActionAuto
ActionCommand = agentintent.ActionCommand
ActionWorkflow = agentintent.ActionWorkflow
ActionBlueprint = agentintent.ActionBlueprint
ActionHandler = agentintent.ActionHandler
CommandStart = agentintent.CommandStart
CommandSetSlot = agentintent.CommandSetSlot
CommandChangeSlot = agentintent.CommandChangeSlot
CommandCancel = agentintent.CommandCancel
CommandClarify = agentintent.CommandClarify
CommandChitchat = agentintent.CommandChitchat
CommandHandoff = agentintent.CommandHandoff
ActionAuto = agentintent.ActionAuto
ActionCommand = agentintent.ActionCommand
ActionWorkflow = agentintent.ActionWorkflow
ActionBlueprint = agentintent.ActionBlueprint
ActionHandler = agentintent.ActionHandler
)
type Gate struct {
@@ -133,7 +168,7 @@ func (g *Gate) Match(ctx context.Context, input string) (string, string) {
return res.Branch, res.ID
}
// Recognize 在 Match 的结果上带上槽位和缺失提问。
// Recognize 在 Match 的结果上带上槽位、缺失提问、非法原因与风险/确认/动作声明。
func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
g.mu.RLock()
reg, rec, byID := g.reg, g.rec, g.specsByID
@@ -163,16 +198,45 @@ 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 {
action := *res.Top.Action
out.Action = &action
}
spec := byID[out.ID]
for _, key := range res.Top.MissingRequiredSlots {
q := key
for _, sl := range spec.Slots {
if sl.Key == key && sl.Question != "" {
q = sl.Question
}
}
out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: q})
out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: questionOf(spec, key)})
}
// 非法槽位按键名排序(识别结果里的 map 无序,投影必须确定)。
for _, key := range sortedKeys(res.Top.InvalidSlots) {
out.Invalid = append(out.Invalid, SlotDefect{
Key: key, Reason: res.Top.InvalidSlots[key], Invalid: true,
Question: questionOf(spec, key),
})
}
return out
}
// questionOf 取 spec 为该槽位声明的建议提问;没有就回落键名本身。
func questionOf(spec agentintent.IntentSpec, key string) string {
for _, sl := range spec.Slots {
if sl.Key == key && strings.TrimSpace(sl.Question) != "" {
return sl.Question
}
}
return key
}
// sortedKeys 按键名排序,保证同一识别结果的投影顺序确定。
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+147
View File
@@ -3,6 +3,7 @@ package intentgate
import (
"context"
"encoding/json"
"strings"
"testing"
agentintent "agent-intent/intent"
@@ -176,3 +177,149 @@ func TestGateGeneratorProducesCommands(t *testing.T) {
t.Fatalf("关闭生成层:branch = %s", branch)
}
}
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径与生成路径同款)----
// modeSpec 声明一个带枚举校验的风险意图:mode 与 topic 都是必填。
func modeSpec(t *testing.T) json.RawMessage {
t.Helper()
return raw(t, map[string]any{
"id": "demo.mode", "domain": "demo", "version": 1,
"matcher": map[string]any{"includes_any": []string{"切换"}},
"risk": "high",
"requires_confirm": true,
"action": map[string]any{"kind": "command", "id": "demo.mode"},
"slots": []map[string]any{
{"key": "mode", "required": true, "pattern": `模式[::]\s*(\S+)`, "enum": []string{"摘要", "全文"}, "question": "要哪种模式?"},
{"key": "topic", "required": true, "pattern": `主题[::]\s*(\S+)`, "question": "主题是什么?"},
},
})
}
func TestRecognizeKeepsInvalidSlotDetail(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
t.Fatal(err)
}
bad := g.Recognize(context.Background(), "切换 主题:核能 模式:随手写")
if bad.Branch != "matched" || bad.ID != "demo.mode" {
t.Fatalf("识别分支:%+v", bad)
}
if bad.Complete() {
t.Fatalf("非法枚举值不得算作完整:%+v", bad)
}
if len(bad.Missing) != 0 {
// 必填且非法只进 Invalid,不重复进 Missing。
t.Fatalf("非法槽位不应重复计入缺失:%+v", bad.Missing)
}
if len(bad.Invalid) != 1 {
t.Fatalf("非法槽位详情:%+v", bad.Invalid)
}
d := bad.Invalid[0]
if d.Key != "mode" || !d.Invalid || d.Reason != "value not in enum" || d.Question != "要哪种模式?" {
t.Fatalf("非法原因/提问未透传:%+v", d)
}
if _, leaked := bad.Slots["mode"]; leaked {
t.Fatalf("非法值不得进入已校验槽位表:%+v", bad.Slots)
}
if bad.Slots["topic"] != "核能" {
t.Fatalf("合法槽位丢失:%+v", bad.Slots)
}
// 风险/确认/动作声明一并透传(宿主据此决定确认与派发)。
if bad.Risk != "high" || !bad.RequiresConfirm {
t.Fatalf("风险元数据未透传:%+v", bad)
}
if bad.Action == nil || bad.Action.Kind != ActionCommand || bad.Action.ID != "demo.mode" {
t.Fatalf("动作声明未透传:%+v", bad.Action)
}
ok := g.Recognize(context.Background(), "切换 主题:核能 模式:摘要")
if !ok.Complete() || ok.Slots["mode"] != "摘要" || len(ok.Invalid) != 0 {
t.Fatalf("合法槽位识别:%+v", ok)
}
}
// 生成路径同样经过权威槽位校验:非法值与未知键都要带原因回到宿主。
func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
t.Fatal(err)
}
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
Slots: map[string]string{"mode": "随手写", "topic": "核能"},
}}})
res := g.Recognize(context.Background(), "跟昨天一样")
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 _, leaked := res.Slots["mode"]; leaked || res.Complete() {
t.Fatalf("生成路径非法值不得进入槽位表:%+v", res.Slots)
}
if res.Risk != "high" || !res.RequiresConfirm || res.Action == nil {
t.Fatalf("生成路径风险元数据:%+v", res)
}
// 未知键:库层判为 invalid(reason=unknown slot key),宿主据此拒绝派发。
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"] != "摘要" {
t.Fatalf("生成路径合法槽位:%+v", clean)
}
}
// 必填槽位缺失与非法是两条独立通道:缺失走 Missing,非法走 Invalid。
func TestRecognizeDistinguishesMissingFromInvalid(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
t.Fatal(err)
}
res := g.Recognize(context.Background(), "切换 主题:核能")
if len(res.Missing) != 1 || res.Missing[0].Key != "mode" || res.Missing[0].Question != "要哪种模式?" {
t.Fatalf("缺失投影:%+v", res.Missing)
}
if len(res.Invalid) != 0 {
t.Fatalf("缺失不得混进非法:%+v", res.Invalid)
}
if res.Complete() {
t.Fatal("缺必填槽位不得算作完整")
}
}
// 格式校验失败与枚举失败同款透传(原因原文来自库层,不做改写)。
func TestRecognizeKeepsFormatFailureReason(t *testing.T) {
g := New()
spec := raw(t, map[string]any{
"id": "demo.ticket", "domain": "demo", "version": 1,
"matcher": map[string]any{"includes_any": []string{"开工单"}},
"slots": []map[string]any{{
"key": "issue", "required": true, "pattern": `编号[::]\s*(\S+)`,
"format": `[A-Z]{2}-\d+`, "question": "编号是什么?",
}},
})
if err := g.Rebuild(context.Background(), []json.RawMessage{spec}); err != nil {
t.Fatal(err)
}
bad := g.Recognize(context.Background(), "开工单 编号:随手写的")
if len(bad.Invalid) != 1 || bad.Invalid[0].Key != "issue" || bad.Invalid[0].Question != "编号是什么?" {
t.Fatalf("格式失败明细:%+v", bad.Invalid)
}
if !strings.HasPrefix(bad.Invalid[0].Reason, "format mismatch") {
t.Fatalf("格式失败原因:%+v", bad.Invalid[0])
}
if bad.Complete() || len(bad.Missing) != 0 {
t.Fatalf("格式失败不得算作完整或缺失:%+v", bad)
}
if ok := g.Recognize(context.Background(), "开工单 编号:AB-12"); !ok.Complete() || ok.Slots["issue"] != "AB-12" {
t.Fatalf("格式合法:%+v", ok)
}
}
+177 -20
View File
@@ -10,15 +10,22 @@
// - PromptTrigger(技能触发)是提示词装配、不参与路由决胜;
// 意图产出 Start/Clarify 类命令时路由优先。
//
// 槽位准入(F09):可执行业务动作(命令总线)必须在槽位齐全且全部通过
// 权威校验时才派发;缺失或非法一律不派发,转澄清诉求(Clarification)。
// 工作流是「边收集边执行」的草稿:按既有规则照常启动,缺失清单随决策透出,
// 由流程自身追问——非法值不会进入槽位表(库层只把合法值写进 Slots)。
//
// 当前版本的已知边界(如实记录,不静默):
// - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项);
// 本版本调用方按普通回合处理并保留 Decision 供审计。
// - SetSlot/Cancel/Handoff 命令由库层产出,但 Backend 尚无会话槽位状态机
// 与语义层,Router v1 不消费它们(expectation_hit 在 intentgate 已被折叠为
// fallback,Start 只来自 matched)。
// - SetSlot/ChangeSlot/Cancel/Handoff 由库层产出,但 Backend 尚无会话槽位
// 状态机与语义层:它们记入 Unsupported 并回落普通回合,绝不假装已支持
// (例如用户说「取消」时不会把同一句话里的 Start 当成业务动作派发)。
package route
import (
"strings"
"agent-system-backend/internal/intentgate"
core "agent-system-core"
)
@@ -33,6 +40,18 @@ const (
KindTurn Kind = "turn"
)
// Clarification 是「业务动作未派发,需要向用户澄清」的诉求。
// 槽位缺失与非法都带结构化原因(key + reason + question),
// 宿主据此渲染澄清话术或转成回合提示,不再自行猜测。
type Clarification struct {
Needed bool
Question string
Missing []intentgate.SlotQuestion
Invalid []intentgate.SlotDefect
// Command 是被拦下的动作 ID(诊断与审计用,未派发)。
Command string
}
// Decision 是一次路由裁决。
type Decision struct {
Kind Kind
@@ -41,8 +60,17 @@ type Decision struct {
Blueprint string
Slots map[string]string
Missing []intentgate.SlotQuestion
Reason string // 回落 turn 的原因(显式声明不可解析时)
Invalid []intentgate.SlotDefect
Risk string // 权威意图声明的风险档(low/medium/high)
Reason string // 回落 turn 的原因(显式声明不可解析、准入未过等)
StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用)
// RequiresConfirm 是意图声明的显式确认要求(与命令自身的 danger 分级正交,
// 本包只透传,不替代 cmdbus 的高危确认收口)。
RequiresConfirm bool
// Clarification 非空时业务动作未派发(Needed=true 时必有槽位原因)。
Clarification Clarification
// Unsupported 是本次识别里宿主不消费的库层命令种类(如 cancel/set_slot)。
Unsupported []string
}
// Env 是路由裁决的环境解析器(由调用方注入;nil 视为不可解析)。
@@ -52,23 +80,52 @@ type Env struct {
SquadReady bool
}
// unconsumedCommandKinds 是宿主 v1 不消费的库层命令种类(无会话槽位状态机)。
// 命中即不派发业务动作:把「取消」当成「启动」是比不响更糟的错配。
var unconsumedCommandKinds = map[string]bool{
string(intentgate.CommandSetSlot): true,
string(intentgate.CommandChangeSlot): true,
string(intentgate.CommandCancel): true,
string(intentgate.CommandHandoff): true,
string(intentgate.CommandChitchat): true,
}
// Decide 裁决一次识别结果。规则:
// 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。
// 2. 找到首个 Start 命令;没有时按 TopID 走 auto(兼容兜底,与现网一致)。
// 3. 按 ActionRef.Kind 解析:command→命令总线;workflow→工作流;
// blueprint→squad 决策;handler/auto→命令总线 > 工作流 > turn。
// 2. 出现宿主不消费的库层命令(cancel/set_slot/…)→ turn,并如实记录
// Unsupported 与 Reason(不假装支持,也不派发同句的 Start)。
// 3. 找到首个 Start 命令;没有时按 TopID(或显式 action 声明)走 auto
// (兼容兜底,与现网一致)。
// 4. 按 ActionRef.Kind 解析:command→命令总线(先过槽位准入);
// workflow→工作流(草稿,缺槽照常启动);blueprint→squad 决策;
// handler/auto→命令总线 > 工作流 > turn。
func Decide(res intentgate.MatchResult, env Env) Decision {
d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing}
d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing, Invalid: res.Invalid,
Risk: res.Risk, RequiresConfirm: res.RequiresConfirm}
if res.Branch != "matched" {
return d
}
var start *intentgate.Command
d.Unsupported = unconsumedKinds(res.Commands)
var start, clarify *intentgate.Command
for i := range res.Commands {
if res.Commands[i].Kind == intentgate.CommandStart {
start = &res.Commands[i]
break
switch res.Commands[i].Kind {
case intentgate.CommandStart:
if start == nil {
start = &res.Commands[i]
}
case intentgate.CommandClarify:
if clarify == nil {
clarify = &res.Commands[i]
}
}
}
if len(d.Unsupported) > 0 {
d.Reason = "宿主未消费库层命令:" + strings.Join(d.Unsupported, "、")
if clarify != nil {
d.Clarification = clarificationFrom("", res, clarify)
}
return d
}
action := intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: res.ID}
if start != nil {
d.StartFound = true
@@ -76,19 +133,19 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
if len(start.Slots) > 0 {
d.Slots = start.Slots
}
} else if res.Action != nil {
action = *res.Action
}
switch action.Kind {
case intentgate.ActionCommand:
if env.HasCommand != nil && env.HasCommand(action.ID) {
d.Kind, d.CommandID = KindCommand, action.ID
return d
return d.dispatchCommand(action.ID, res, clarify)
}
d.Reason = "声明的命令不存在:" + action.ID
case intentgate.ActionWorkflow:
if env.WorkflowFor != nil {
if def, ok := env.WorkflowFor(action.ID); ok {
d.Kind, d.Workflow = KindWorkflow, def
return d
return d.startWorkflowDraft(def)
}
}
d.Reason = "声明的工作流未注册:" + action.ID
@@ -100,15 +157,115 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
d.Reason = "声明的小队未启用:" + action.ID
default: // auto / handler
if env.HasCommand != nil && env.HasCommand(action.ID) {
d.Kind, d.CommandID = KindCommand, action.ID
return d
return d.dispatchCommand(action.ID, res, clarify)
}
if env.WorkflowFor != nil {
if def, ok := env.WorkflowFor(action.ID); ok {
d.Kind, d.Workflow = KindWorkflow, def
return d
return d.startWorkflowDraft(def)
}
}
}
return d
}
// dispatchCommand 派发可执行业务动作前的最后一道闸:槽位必须齐全且通过权威校验。
// 未就绪时不碰业务动作,改为带结构化原因的澄清诉求(Kind 留在 turn)。
func (d Decision) dispatchCommand(id string, res intentgate.MatchResult, clarify *intentgate.Command) Decision {
if !res.Complete() {
d.Clarification = clarificationFrom(id, res, clarify)
d.Reason = "槽位未就绪,未派发命令:" + id
return d
}
d.Kind, d.CommandID = KindCommand, id
return d
}
// startWorkflowDraft 工作流是收集槽位的草稿:缺失与非法都不阻断启动
// (非法值本就进不了 Slots),缺失清单随 Decision 透出,由流程自身追问。
func (d Decision) startWorkflowDraft(def core.WorkflowDef) Decision {
d.Kind, d.Workflow = KindWorkflow, def
return d
}
// unconsumedKinds 列出命令列表里宿主不消费的种类(按首次出现序,确定性)。
func unconsumedKinds(cmds []intentgate.Command) []string {
var out []string
for _, c := range cmds {
if !unconsumedCommandKinds[string(c.Kind)] {
continue
}
if contains(out, string(c.Kind)) {
continue
}
out = append(out, string(c.Kind))
}
return out
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// clarificationFrom 组装澄清诉求:优先用 spec 声明的建议提问,
// 缺失时回落库层 clarify 命令里的 Question,再缺就按缺陷键列一句兜底。
func clarificationFrom(id string, res intentgate.MatchResult, clarify *intentgate.Command) Clarification {
c := Clarification{Needed: true, Command: id,
Missing: append([]intentgate.SlotQuestion(nil), res.Missing...),
Invalid: append([]intentgate.SlotDefect(nil), res.Invalid...)}
if len(c.Missing) > 0 {
c.Question = strings.TrimSpace(c.Missing[0].Question)
}
if c.Question == "" && len(c.Invalid) > 0 {
c.Question = strings.TrimSpace(c.Invalid[0].Question)
}
if clarify != nil {
if c.Question == "" {
c.Question = strings.TrimSpace(clarify.Question)
}
// 库层 clarify 的 Missing 是缺失与非法键的合并;补齐宿主侧没有投影的键。
for _, k := range clarify.Missing {
if defectKeyKnown(c, k) {
continue
}
c.Missing = append(c.Missing, intentgate.SlotQuestion{Key: k, Question: k})
}
}
if c.Question == "" {
c.Question = fallbackQuestion(c)
}
return c
}
func defectKeyKnown(c Clarification, key string) bool {
for _, m := range c.Missing {
if m.Key == key {
return true
}
}
for _, iv := range c.Invalid {
if iv.Key == key {
return true
}
}
return false
}
// fallbackQuestion 没有任何声明提问时的兜底话术:按缺失在前、非法在后列键。
func fallbackQuestion(c Clarification) string {
var keys []string
for _, m := range c.Missing {
keys = append(keys, m.Key)
}
for _, iv := range c.Invalid {
keys = append(keys, iv.Key)
}
if len(keys) == 0 {
return ""
}
return "请补充:" + strings.Join(keys, "、")
}
+121
View File
@@ -81,3 +81,124 @@ func TestSlotsFromStartCommand(t *testing.T) {
t.Fatalf("slots = %v", d.Slots)
}
}
// ---- F09:槽位准入——业务动作必须齐全且合法,否则转澄清 ----
func matchedCommand(action intentgate.ActionRef) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "matched", ID: action.ID,
Commands: []intentgate.Command{startCmd(action)}}
}
func hasCommandEnv() Env { return Env{HasCommand: func(string) bool { return true }} }
func TestMissingSlotsBlockCommandDispatch(t *testing.T) {
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"})
in.Missing = []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}
d := Decide(in, hasCommandEnv())
if d.Kind != KindTurn || d.CommandID != "" {
t.Fatalf("缺必填槽位不得派发命令:%+v", d)
}
if !d.Clarification.Needed || d.Clarification.Command != "demo.brief" {
t.Fatalf("澄清诉求缺失:%+v", d.Clarification)
}
if d.Clarification.Question != "主题是什么?" || len(d.Clarification.Missing) != 1 {
t.Fatalf("澄清提问未透传:%+v", d.Clarification)
}
if d.Reason == "" || !d.StartFound {
t.Fatalf("回落原因或 Start 标记缺失:%+v", d)
}
}
func TestInvalidSlotsBlockCommandWithReason(t *testing.T) {
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.mode"})
in.Invalid = []intentgate.SlotDefect{{Key: "mode", Reason: "value not in enum", Question: "要哪种模式?", Invalid: true}}
d := Decide(in, hasCommandEnv())
if d.Kind != KindTurn || d.CommandID != "" {
t.Fatalf("非法槽位不得派发命令:%+v", d)
}
if len(d.Clarification.Invalid) != 1 || d.Clarification.Invalid[0].Reason != "value not in enum" {
t.Fatalf("非法原因未透传:%+v", d.Clarification)
}
if d.Clarification.Question != "要哪种模式?" {
t.Fatalf("澄清提问未透传:%+v", d.Clarification)
}
}
func TestCompleteSlotsDispatchCommand(t *testing.T) {
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"})
in.Slots = map[string]string{"topic": "核能"}
in.Risk, in.RequiresConfirm = "high", true
d := Decide(in, hasCommandEnv())
if d.Kind != KindCommand || d.CommandID != "demo.brief" {
t.Fatalf("完整合法槽位应派发:%+v", d)
}
if d.Risk != "high" || !d.RequiresConfirm || d.Clarification.Needed {
t.Fatalf("风险元数据未透传或误报澄清:%+v", d)
}
}
// 工作流是「边收集边执行」的草稿:缺失清单照常启动,由流程自身追问。
func TestWorkflowDraftStartsWhileGatheringSlots(t *testing.T) {
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"})
in.Missing = []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}
d := Decide(in, Env{
HasCommand: func(string) bool { return false },
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
})
if d.Kind != KindWorkflow || d.Workflow.ID != "wf" {
t.Fatalf("工作流草稿应照常启动:%+v", d)
}
if len(d.Missing) != 1 || d.Clarification.Needed {
t.Fatalf("缺失清单应随决策透出:%+v", d)
}
}
// 显式的取消/改槽命令不得被静默当成已支持:回落普通回合,如实记录。
func TestUnsupportedLibraryCommandsAreNotClaimed(t *testing.T) {
in := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{
{Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}},
{Kind: intentgate.CommandCancel, Target: "demo.brief"},
}}
d := Decide(in, hasCommandEnv())
if d.Kind != KindTurn || d.CommandID != "" {
t.Fatalf("未消费的库层命令不得派发业务动作:%+v", d)
}
if len(d.Unsupported) != 1 || d.Unsupported[0] != string(intentgate.CommandCancel) {
t.Fatalf("未消费命令种类未如实记录:%+v", d.Unsupported)
}
if d.Reason == "" {
t.Fatalf("回落原因缺失:%+v", d)
}
// 只有改槽命令时同样回落,且不得声称支持。
only := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{
{Kind: intentgate.CommandSetSlot, Name: "topic", Value: "核能"},
}}
got := Decide(only, hasCommandEnv())
if got.Kind != KindTurn || got.CommandID != "" || len(got.Unsupported) != 1 {
t.Fatalf("set_slot 不得被当成可派发:%+v", got)
}
}
// 澄清提问优先用 spec 声明,缺失时回落库层 clarify 命令,最后按键名兜底。
func TestClarificationQuestionSources(t *testing.T) {
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.mode"})
in.Missing = []intentgate.SlotQuestion{{Key: "mode"}}
in.Commands = append(in.Commands, intentgate.Command{Kind: intentgate.CommandClarify, Question: "要哪种模式?"})
if q := Decide(in, hasCommandEnv()).Clarification.Question; q != "要哪种模式?" {
t.Fatalf("库层澄清提问未用上:%q", q)
}
in.Commands = in.Commands[:1]
if q := Decide(in, hasCommandEnv()).Clarification.Question; q != "请补充:mode" {
t.Fatalf("兜底提问:%q", q)
}
}
// 旧消费方(命令列表为空)仍按 TopID 走 auto,且必须槽位完整。
func TestLegacyAutoStillNeedsCompleteSlots(t *testing.T) {
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
Missing: []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}}
d := Decide(in, hasCommandEnv())
if d.Kind != KindTurn || d.CommandID != "" || !d.Clarification.Needed {
t.Fatalf("旧路径也必须过槽位准入:%+v", d)
}
}
+22 -1
View File
@@ -127,7 +127,10 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
}
expectedRefs[ref.ID] = ref
}
if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !slices.Equal(current.Missing, m.Missing) {
// Set-wise, not provider-order-wise: a benign reordering of the same
// recorded identities is tolerated, while any added, dropped or replaced
// entry still fails closed. Order carries no authorization meaning.
if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !sameMissingSet(current.Missing, m.Missing) {
return errors.New("project source recheck returned an incomplete source set")
}
// Each returned item must account for exactly one recorded input; the hash
@@ -180,9 +183,27 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
return errors.New("project evidence permission or content changed")
}
}
// Load-bearing fail-closed close: a context cancelled or expired after the
// last structural pass above must still reject. Do not relax this to
// `return nil`; a late cancellation would otherwise admit machine context
// that was never rechecked against live authorization.
return ctx.Err()
}
// sameMissingSet compares the rechecked and recorded missing-source lists as
// sets. Neither input is mutated; any difference other than order is a
// rejection, because a withdrawn or newly missing source changes what the
// original model request actually saw.
func sameMissingSet(current, recorded []string) bool {
if len(current) != len(recorded) {
return false
}
left, right := slices.Clone(current), slices.Clone(recorded)
slices.Sort(left)
slices.Sort(right)
return slices.Equal(left, right)
}
func (c *runCtx) contextRequest() extension.ProjectContextRequest {
c.projectContextMu.Lock()
defer c.projectContextMu.Unlock()
@@ -314,6 +314,103 @@ func TestProjectStepOutputRejectsUnsettledAttemptInSameStep(t *testing.T) {
}
}
func TestProjectSourceRecheckComparesMissingSourcesAsASet(t *testing.T) {
f := newRecheckFixture(t)
receipt := f.receipt
receipt.Missing = []string{"source-b", "source-a"}
// Order carries no authorization meaning, so a reordered list of the same
// unresolved sources stays a pass.
reordered := f.current
reordered.Missing = []string{"source-a", "source-b"}
f.answer(reordered)
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err != nil {
t.Fatalf("reordered missing sources treated as a changed source set: %v", err)
}
if strings.Join(receipt.Missing, ",") != "source-b,source-a" || strings.Join(reordered.Missing, ",") != "source-a,source-b" {
t.Fatalf("missing-source comparison mutated a caller slice: recorded=%q current=%q", receipt.Missing, reordered.Missing)
}
// Any difference other than order still fails closed.
for name, missing := range map[string][]string{
"dropped": {"source-a"},
"added": {"source-a", "source-b", "source-c"},
"replaced": {"source-a", "source-z"},
"masked-by-dup": {"source-a", "source-a"},
"withdrawn": nil,
} {
changed := f.current
changed.Missing = missing
f.answer(changed)
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err == nil {
t.Fatalf("changed missing-source set %q was accepted", name)
}
}
f.answer(reordered)
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err != nil {
t.Fatalf("unrelated failure left the authorized baseline broken: %v", err)
}
}
func TestProjectStepOutputRejectsEvidenceRefWithChangedHash(t *testing.T) {
st := openRecoveryStore(t)
m := newRecoveryManager(t, st)
conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunPending)
m.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
ref := extension.ProjectEvidenceRef{ID: "ref-a", SourceID: "doc-a", Revision: "r1", Hash: "hd"}
evidenceHash := ref.Hash
bundle := func() extension.ProjectContextBundle {
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: run.ProjectID, Version: "v1", Watermark: "w1",
AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1",
Items: []extension.ProjectContextItem{{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true}},
Evidence: []extension.ProjectEvidenceRef{{ID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, Hash: evidenceHash}}}
}
m.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
return bundle(), nil
})
m.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return extension.ProjectContextSourceRecheckResult{Bundle: bundle()}, nil
})
m.SetProjectEvidenceReader(func(_ context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) {
return extension.ProjectEvidence{Reference: req.Reference, Text: "DETAIL_A"}, nil
})
rc := &runCtx{mgr: m, run: run, ctx: context.Background()}
current, _, err := rc.ProjectContext(context.Background(), "draft", 900)
if err != nil {
t.Fatal(err)
}
_, receipt, err := projectcontext.Assemble(current, 900)
if err != nil {
t.Fatal(err)
}
receipt.InputSHA256, receipt.AttemptID, receipt.WorkflowStepAttemptID = "input-1", "model-1", "engine-run/draft/1"
receipt.ReadEvidence = []extension.ProjectContextManifestEntry{{ReferenceID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, SourceHash: ref.Hash,
InputSHA256: fmt.Sprintf("%x", sha256.Sum256([]byte("DETAIL_A")))}}
scoped := projectcontext.WithResolveScope(context.Background(), "draft", "")
for _, delivery := range []string{"attempted", "responded"} {
receipt.Delivery = delivery
if err := rc.SaveContextManifest(scoped, receipt); err != nil {
t.Fatal(err)
}
}
output := []byte("AUTHORIZED_A report")
if err := rc.BindProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", time.Now().Add(-time.Second), output); err != nil {
t.Fatalf("unchanged evidence source rejected at binding: %v", err)
}
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil {
t.Fatalf("unchanged evidence source rejected at the critic boundary: %v", err)
}
// Same ReferenceID and revision, different source hash: a re-published or
// substituted document must not inherit the recorded grant.
evidenceHash = "hd2"
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err == nil {
t.Fatal("same reference id with a changed source hash passed the critic boundary")
}
evidenceHash = ref.Hash
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil {
t.Fatalf("restoring the recorded hash did not restore the verdict: %v", err)
}
}
func TestSaveContextManifestRejectsUnrecheckableModelInput(t *testing.T) {
st := openRecoveryStore(t)
m := newRecoveryManager(t, st)
+1 -8
View File
@@ -252,7 +252,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
if stream == nil {
// 重试经库默认(WP6:runtime.llmRetries → Config.RetryAttempts 映射),
// 此处不再显式包装(防双重)。
client := openRouteClient(route, eff)
client := OpenRouteClient(route, eff)
stream = client.Stream
}
stream = annotateKeyFailure(stream, route)
@@ -601,13 +601,6 @@ func openLLMClient(base, key, model string) *agentllm.Client {
return c
}
// openRouteClient 按一次解析出的路由构造客户端,并为该路由独立判定 RouteContract。
func openRouteClient(route ResolvedModel, eff core.EffectiveSettings) *agentllm.Client {
c := openLLMClient(route.Base, route.Key, route.Model)
c.RouteContract = routeContractFor(eff, route)
return c
}
// routeContractFor 只看本客户端实际解析出的 model 与 base,不看 route.Kind,
// 也不读画像:三项系统设置任一为空、模型名不逐字相等、规范化后的端点与配置
// 不逐字相等(配置本身须已是规范形态)或身份摘要不符,都返回 nil(关闭)。
+131
View File
@@ -90,3 +90,134 @@ func TestResolveVisionModel(t *testing.T) {
t.Fatalf("text fallbacks %#v", fb)
}
}
// 只有 Settings 端口的宿主:快照可取,没有就报缺失(调用方自己决定回落)。
type settingsOnlyCtx struct{ eff core.EffectiveSettings }
func (c settingsOnlyCtx) Settings() core.EffectiveSettings { return c.eff }
// 只有 Run 端口的宿主:属主只能来自 run。
type runOnlyCtx struct{ owner string }
func (c runOnlyCtx) Run() core.Run { return core.Run{ID: "run_t", Owner: core.ID(c.owner)} }
// 两个端口都有的宿主(runmgr 的 runCtx 形态)。
type runProfileCtx struct {
settingsOnlyCtx
runOnlyCtx
prof core.AgentProfile
}
func (c runProfileCtx) Profile() core.AgentProfile { return c.prof }
func TestRunEffectiveSettingsNeedsHostPort(t *testing.T) {
eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "m"}}})
if got, ok := RunEffectiveSettings(settingsOnlyCtx{eff: eff}); !ok || got.GetString("models.chat.model", "") != "m" {
t.Fatalf("run snapshot = %+v ok=%v, want the host snapshot", got, ok)
}
if _, ok := RunEffectiveSettings(settingsOnlyCtx{}); ok {
t.Fatal("empty snapshot must be reported as missing so the caller can fall back")
}
if _, ok := RunEffectiveSettings(runOnlyCtx{owner: "alice"}); ok {
t.Fatal("host without a Settings port must not be reported as having one")
}
}
func TestRunProfileNeverFallsBackToDefaultOwner(t *testing.T) {
prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "alice-model"}}
both := runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "alice"}, prof: prof}
if got := RunProfile(both); got.Owner != "alice" || got.Model.Chat != "alice-model" {
t.Fatalf("profile port must win: %+v", got)
}
if got := RunProfile(both).Owner; got != "alice" {
t.Fatalf("owner = %q, want alice", got)
}
// 画像端口缺席(或没带 Owner)时取 run.Owner——密钥层归属不能退化成默认账号。
bare := runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "bob"}}
if got := RunProfile(bare).Owner; got != "bob" {
t.Fatalf("owner = %q, want the run owner bob", got)
}
if got := RunProfile(runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "bob"}, prof: core.AgentProfile{}}).Owner; got != "bob" {
t.Fatalf("empty profile owner = %q, want the run owner bob", got)
}
if got := RunProfile(runOnlyCtx{}).Owner; got != "" {
t.Fatalf("owner without any port = %q, want empty", got)
}
}
// 带自定义快照的运行上下文(其余端口由 fakeRunCtx 满足)。
type runSnapCtx struct {
*fakeRunCtx
eff core.EffectiveSettings
}
func (c *runSnapCtx) Settings() core.EffectiveSettings { return c.eff }
// 运行内解析主路由:快照 + 画像覆盖 + 属主密钥层,与直连同一条规则。
func TestRunChatRouteUsesRunSnapshotAndOwnerKey(t *testing.T) {
saved := SecretLookup
defer func() { SecretLookup = saved }()
secrets := map[string]string{"alice": "sk-alice-synthetic", "bob": "sk-bob-synthetic"}
SecretLookup = func(owner, _ string) (string, string, bool) {
if owner == "" {
owner = "local"
}
v, ok := secrets[owner]
return v, "owner", ok
}
eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{
"model": "settings-model", "baseUrl": "https://shared.example/v1", "apiKey": "sk-settings-synthetic",
}}})
for _, owner := range []string{"alice", "bob"} {
rc := &runSnapCtx{fakeRunCtx: &fakeRunCtx{prof: core.AgentProfile{Owner: owner, Model: core.ModelRoute{Chat: owner + "-profile-model"}}}, eff: eff}
got := RunChatRoute(rc)
want := ResolveChatModel(rc.Profile(), rc.Settings())
if got != want {
t.Fatalf("%s: run route %+v != direct rule %+v", owner, got, want)
}
if got.Model != owner+"-profile-model" || got.Base != "https://shared.example/v1" {
t.Fatalf("%s: route = %+v, want the profile model on the shared endpoint", owner, got)
}
if got.Key != "sk-"+owner+"-synthetic" || got.KeySource != "owner" {
t.Fatalf("%s: key = %q (%s), want that owner's own secret", owner, got.Key, got.KeySource)
}
}
}
// 客户端构造:reasoning split 与受限 EOF 契约都只看本路由自己的 model+base,
// 画像覆盖后的路由重新判定(与直连路径同一个构造器)。
func TestOpenRouteClientDecidesPerRoute(t *testing.T) {
const base = "https://api.example.com/v1"
contract := routeEOFMatching(routeEOFModel, base)
eff := routeEOFEff(map[string]any{"model": routeEOFModel, "baseUrl": base, "apiKey": "k"}, contract)
route := ResolvedModel{Kind: "chat", Model: routeEOFModel, Base: base, Key: "k"}
client := OpenRouteClient(route, eff)
if client.BaseURL != base || client.APIKey != "k" {
t.Fatalf("client route = %q/%q, want %q/k", client.BaseURL, client.APIKey, base)
}
if client.ReasoningSplit {
t.Fatal("non-MiniMax model must not enable reasoning split")
}
if client.RouteContract == nil {
t.Fatal("matching configuration must attach the contract")
}
// 画像覆盖改的是模型名:分流与契约都按覆盖后的路由重新判定。
for _, tc := range []struct {
model string
wantSplit bool
wantEOfRule bool
}{
{"MiniMax-M2", true, false},
{routeEOFModel, false, true},
{"other-model", false, false},
} {
prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: tc.model}}
overridden := ResolveChatModel(prof, eff)
got := OpenRouteClient(overridden, eff)
if got.ReasoningSplit != tc.wantSplit || (got.RouteContract != nil) != tc.wantEOfRule {
t.Fatalf("profile model %q: split=%v contract=%v, want split=%v contract=%v",
tc.model, got.ReasoningSplit, got.RouteContract != nil, tc.wantSplit, tc.wantEOfRule)
}
}
}
+64 -2
View File
@@ -41,7 +41,7 @@ func routeEOFMatching(model, base string) map[string]any {
func TestRouteContractDefaultOff(t *testing.T) {
route := ResolvedModel{Kind: "chat", Model: routeEOFModel, Base: "https://api.example.com/v1", Key: "k"}
if c := openRouteClient(route, core.EffectiveSettings{}); c.RouteContract != nil {
if c := OpenRouteClient(route, core.EffectiveSettings{}); c.RouteContract != nil {
t.Fatalf("no settings must leave RouteContract nil, got %+v", c.RouteContract)
}
full := routeEOFMatching(routeEOFModel, "https://api.example.com/v1")
@@ -206,7 +206,7 @@ func routeEOFHTTPClient(t *testing.T, server *httptest.Server) *http.Client {
func routeEOFClient(t *testing.T, server *httptest.Server, route ResolvedModel, eff core.EffectiveSettings) *agentllm.Client {
t.Helper()
c := openRouteClient(route, eff)
c := OpenRouteClient(route, eff)
c.HTTPClient = routeEOFHTTPClient(t, server)
return c
}
@@ -373,3 +373,65 @@ func TestRouteContractEOFReleasesOneToolCallInLoop(t *testing.T) {
}
})
}
// 小队路径的路由来自同一份运行快照:契约对得上的路由与直连一样放行一次
// 工具调用;画像把模型名改掉之后,契约不再跟随,放行也一并消失。
func TestRouteContractEOFForRunScopedRoute(t *testing.T) {
var requests atomic.Int32
server := routeEOFServer(t, &requests)
base := server.URL + "/v1"
models := map[string]any{
"chat": map[string]any{"model": routeEOFModel, "baseUrl": base, "apiKey": "k"},
"routeContractEOF": routeEOFMatching(routeEOFModel, base),
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
decls := []agentllm.ToolDecl{{Name: routeEOFTool, Description: "report", Parameters: routeEOFParams}}
run := func(t *testing.T, prof core.AgentProfile) (*agentllm.Client, []agentllm.Event) {
t.Helper()
rc := &runSnapCtx{
fakeRunCtx: &fakeRunCtx{ctx: ctx, prof: prof},
eff: core.EffectiveSettings{"models": models},
}
route := RunChatRoute(rc)
client := routeEOFClient(t, server, route, rc.Settings())
stream := GuardProjectStream(rc, "", annotateKeyFailure(client.Stream, route))
var terminal []agentllm.Event
for ev := range stream(ctx, route.Model, []agentllm.Message{{Role: agentllm.RoleUser, Content: "hi"}}, decls, agentllm.StreamOptions{}) {
if ev.Kind == agentllm.KindMessageEnd || ev.Kind == agentllm.KindError {
terminal = append(terminal, ev)
}
}
return client, terminal
}
t.Run("route matching the configuration releases one call", func(t *testing.T) {
client, terminal := run(t, core.AgentProfile{Owner: "alice"})
if client.RouteContract == nil {
t.Fatal("run-scoped route must carry the contract, as on the direct path")
}
if len(terminal) != 1 || terminal[0].Kind != agentllm.KindMessageEnd || terminal[0].Err != nil {
t.Fatalf("terminal events = %+v, want one clean message_end", terminal)
}
if len(terminal[0].Message.ToolCalls) != 1 || terminal[0].Message.ToolCalls[0].Name != routeEOFTool {
t.Fatalf("released calls = %+v, want exactly one %s", terminal[0].Message.ToolCalls, routeEOFTool)
}
if terminal[0].CompletionBasis != agentllm.CompletionRouteContractEOF {
t.Fatalf("completion basis = %q, want %q", terminal[0].CompletionBasis, agentllm.CompletionRouteContractEOF)
}
})
t.Run("profile override drops the contract and the release", func(t *testing.T) {
client, terminal := run(t, core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "profile-model"}})
if client.RouteContract != nil {
t.Fatalf("overridden route kept the contract: %+v", client.RouteContract)
}
if len(terminal) != 1 || terminal[0].Err == nil || !strings.Contains(terminal[0].Err.Error(), routeEOFRejection) {
t.Fatalf("terminal events = %+v, want the %q rejection", terminal, routeEOFRejection)
}
if len(terminal[0].Message.ToolCalls) != 0 {
t.Fatalf("tool call released without the contract: %+v", terminal[0].Message.ToolCalls)
}
})
}
+62
View File
@@ -0,0 +1,62 @@
package runtime
import (
"strings"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// 运行配置解析的窄端口。直连 RunCtx 与小队 squadadapter.Ctx 共用同一组
// 可选接口:任一执行面都能从「本次运行」拿到同一份生效快照,绝不按默认账号
// 另起一套解析(KTD3)。
type runSettingsPort interface{ Settings() core.EffectiveSettings }
type runProfilePort interface{ Profile() core.AgentProfile }
type runScopePort interface{ Run() core.Run }
// RunEffectiveSettings 返回本次运行生效的配置快照(Settings 端口已由宿主按
// run.Owner + run.ProjectID 合并 defaults→system→user→project)。
// ok=false 表示宿主没有该端口或快照为空;调用方自行决定回落策略,
// 不得在有快照时回落到别的属主。
func RunEffectiveSettings(rc any) (core.EffectiveSettings, bool) {
port, ok := rc.(runSettingsPort)
if !ok {
return nil, false
}
eff := port.Settings()
if len(eff) == 0 {
return nil, false
}
return eff, true
}
// RunProfile 返回本次运行的画像:宿主实现 Profile 端口时直接取;否则按
// run.Owner 合成零值画像——Owner 决定密钥层归属(overlayKey),缺失时
// 绝不能退化成默认账号,否则会用别人的密钥。
func RunProfile(rc any) core.AgentProfile {
var prof core.AgentProfile
if port, ok := rc.(runProfilePort); ok {
prof = port.Profile()
}
if strings.TrimSpace(prof.Owner) == "" {
if scope, ok := rc.(runScopePort); ok {
prof.Owner = scope.Run().Owner
}
}
return prof
}
// RunChatRoute 解析本次运行的主对话路由(画像 ModelRoute > 当前用户/项目设置层)。
// SquadRunner 用它给小队入口定模型,使小队与直连共用同一条解析规则。
func RunChatRoute(rc RunCtx) ResolvedModel { return ResolveChatModel(rc.Profile(), rc.Settings()) }
// OpenRouteClient 按一次解析出的路由构造 OpenAI 兼容客户端:端点与密钥取自
// 本路由自己的解析结果,MiniMax 系模型的 reasoning split 与受限 EOF route
// contract 都只看本路由的 model+base。直连、小队与其他执行面必须共用它,
// 避免同一个 run 里出现两套 per-route 规则(分流的思考、EOF 放行会不一致)。
func OpenRouteClient(route ResolvedModel, eff core.EffectiveSettings) *agentllm.Client {
c := openLLMClient(route.Base, route.Key, route.Model)
c.RouteContract = routeContractFor(eff, route)
return c
}
+4 -2
View File
@@ -80,7 +80,9 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
Hitl: s.Hitl,
MaxTurns: maxTurns,
MaxToolCalls: maxTools,
Model: ResolveChatModel(prof, rc.Settings()).Model,
// 模型走本次运行的生效配置(当前用户/项目 + 画像覆盖),与直连同一
// 解析规则:内核工厂用同一快照配端点/密钥,绝不与默认账号混搭。
Model: RunChatRoute(rc).Model,
})
if rc.Context().Err() != nil {
return rc.Context().Err()
@@ -143,7 +145,7 @@ func newModelRouter(rc RunCtx) modelRouter {
if route.Model == "" || route.Base == "" {
return modelRouter{}
}
client := openRouteClient(route, rc.Settings())
client := OpenRouteClient(route, rc.Settings())
return modelRouter{stream: GuardProjectStream(rc, "", client.Stream), model: route.Model}
}
+1 -1
View File
@@ -80,7 +80,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
if route.Model == "" || route.Base == "" {
return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base)
}
client := openRouteClient(route, rc.Settings())
client := OpenRouteClient(route, rc.Settings())
stream = client.Stream
}
stream = GuardProjectStream(rc, subagentUserText(rc), stream)
+1 -1
View File
@@ -58,7 +58,7 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence,
if route.Model == "" || route.Base == "" {
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
}
client := openRouteClient(route, rc.Settings())
client := OpenRouteClient(route, rc.Settings())
// 验证器不经内核循环:显式接重试(WP6 同映射)与 5xx 回退链。
stream = agentllm.WithFallback(
agentllm.WithRetry(client.Stream, retryAttemptsOf(rc.Settings()), nil),
+1 -1
View File
@@ -81,7 +81,7 @@ func judgeStreamFor(rc RunCtx) agentllm.StreamFunc {
if route.Model == "" || route.Base == "" {
return nil
}
client := openRouteClient(route, rc.Settings())
client := OpenRouteClient(route, rc.Settings())
model := route.Model
base := func(ctx context.Context, _ string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
return client.Stream(ctx, model, msgs, tools, opts)
+126
View File
@@ -194,6 +194,24 @@ func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
for _, account := range page {
items = append(items, accountProfileJSON(account))
}
if s.systemActionAllowed(actor, "system.roles.view") {
userIDs := make([]string, 0, len(page))
for _, account := range page {
userIDs = append(userIDs, account.ID)
}
roles, err := s.st.ActiveRolesForUsers(userIDs)
if err != nil {
writeStoreErr(w, err)
return
}
for i, account := range page {
var role *store.UserRoleSummary
if found, ok := roles[account.ID]; ok {
role = &found
}
items[i]["role"] = role
}
}
next := ""
if more {
last := page[len(page)-1]
@@ -202,3 +220,111 @@ func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{"accounts": items, "nextCursor": next, "directoryVersion": directoryVersion})
}
// listRoleMembers filters a role's active members before paging them in owner
// order. The signed cursor is bound to the role, the filter, and the account
// directory revision so it cannot be replayed across roles or parameters.
func (s *Server) listRoleMembers(w http.ResponseWriter, r *http.Request) {
if s.accountSearchKeyErr != nil || s.accountSearchKey.Version < 1 {
http.Error(w, "account directory signing unavailable", http.StatusServiceUnavailable)
return
}
actor, ok := s.requireSystemAction(w, r, "system.users.view")
if !ok {
return
}
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
return
}
params := r.URL.Query()
query := strings.TrimSpace(params.Get("query"))
if !utf8.ValidString(query) || utf8.RuneCountInString(query) > 128 {
http.Error(w, "invalid account search query", http.StatusBadRequest)
return
}
for _, ch := range query {
if unicode.IsControl(ch) {
http.Error(w, "invalid account search query", http.StatusBadRequest)
return
}
}
mode := params.Get("mode")
if mode == "" {
mode = "fuzzy"
}
if mode != "exact" && mode != "fuzzy" {
http.Error(w, "unknown account search mode", http.StatusBadRequest)
return
}
status := params.Get("status")
if status == "" {
status = "all"
}
if status != "all" && status != "enabled" && status != "disabled" {
http.Error(w, "unknown account status filter", http.StatusBadRequest)
return
}
limit := 20
if raw := params.Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 100 {
http.Error(w, "invalid account search limit", http.StatusBadRequest)
return
}
limit = parsed
}
roleID := r.PathValue("roleId")
members, revision, err := s.st.ListRoleMembersForActor(actor, roleID)
if err != nil {
writeStoreErr(w, err)
return
}
visibleRevision := sha256.Sum256([]byte(revision.Private))
fingerprint, _ := json.Marshal(struct {
Kind, Actor, RoleID, Query, Mode, Status, Revision string
}{"role-members", actor, roleID, query, mode, status, hex.EncodeToString(visibleRevision[:])})
digest := sha256.Sum256(fingerprint)
version := hex.EncodeToString(digest[:])
var after adminSearchCursor
if token := params.Get("cursor"); token != "" {
var valid bool
after, valid = s.parseAdminCursor(token, version)
if !valid {
http.Error(w, "role member cursor expired or invalid", http.StatusConflict)
return
}
}
page := make([]store.RoleMember, 0, limit+1)
matches := func(value string) bool {
if query == "" {
return true
}
if mode == "exact" {
return strings.EqualFold(value, query)
}
return strings.Contains(strings.ToLower(value), strings.ToLower(query))
}
for _, member := range members {
if status == "enabled" && !member.Enabled || status == "disabled" && member.Enabled {
continue
}
if after.Owner != "" && (member.Owner < after.Owner || member.Owner == after.Owner && member.UserID <= after.UserID) {
continue
}
if query != "" && !matches(member.Owner) && !matches(member.Name) && !matches(member.UserID) {
continue
}
page = append(page, member)
if len(page) > limit {
break
}
}
next := ""
if len(page) > limit {
page = page[:limit]
last := page[len(page)-1]
next = s.signAdminCursor(adminSearchCursor{Version: version, Owner: last.Owner, UserID: last.UserID})
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{"members": page, "nextCursor": next})
}
+1 -1
View File
@@ -946,7 +946,7 @@ func TestStaticServed(t *testing.T) {
_ = io.Discard
}
// TestHITLIntentPivot:装载插件意图后,自由输入命中其他意图 → 转轨(agent-intent 闸门)。
// TestHITLIntentPivot:装载插件意图后,自由输入命中其他意图 → 转轨(intentgate 闸门)。
func TestHITLIntentPivot(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()
+206
View File
@@ -0,0 +1,206 @@
package server
// 意图接纳(F09):把「一次识别」落到「一次命令派发」之前,
// 按命令 DECLARED schema 完成参数映射与准入判定:
// - 只映射命令声明过的槽位键,并按声明类型转换(string/number/integer/boolean);
// 未声明的键一律丢弃——不把任意字符串复制进 params。
// - 声明的必填参数无法由「原文 + 已识别槽位」满足时不派发:不把拒绝留给
// 总线层的 schema 校验(那会变成一条让人困惑的 "params: missing required
// param x"),而是转成澄清诉求,与路由层的槽位缺口同一出口。
//
// 确认语义(高危命令的确认卡)完全由 cmdbus 收口,本文件不参与。
import (
"encoding/json"
"sort"
"strconv"
"strings"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/route"
)
// chatTextParam 是 chat 入口恒定提供的原始文本参数名(既有文本处理器契约)。
const chatTextParam = "text"
// intentAdmission 是一次「意图命中 → 命令派发」的接纳结果。
type intentAdmission struct {
// Params 是派发参数:恒含 text,其余按声明 schema 映射。
Params map[string]any
// Blocked 表示准入未过(业务动作不得派发)。
Blocked bool
// Reason 是准入未过的原因(诊断用;面向用户的说法走澄清诉求)。
Reason string
// MissingParams 是声明要求但槽位无法满足的参数名(按声明序)。
MissingParams []string
}
// admitIntentCommand 按命令声明的 schema 把已识别槽位映射成派发参数。
// 声明不可解析(命令不存在/未启用)时按 fail-closed 返回 Blocked。
func admitIntentCommand(bus *cmdbus.Bus, commandID string, slots map[string]string, text string) intentAdmission {
if bus == nil {
return intentAdmission{Blocked: true, Reason: "命令总线不可用:" + commandID}
}
spec, _, err := bus.Registry().Resolve(commandID)
if err != nil {
return intentAdmission{Blocked: true, Reason: "命令不可解析:" + commandID}
}
params, missing := mapSchemaParams(spec.ParamsSchema, slots)
params[chatTextParam] = text
if len(missing) > 0 {
return intentAdmission{Params: params, Blocked: true,
Reason: "命令必填参数未满足:" + strings.Join(missing, "、"),
MissingParams: missing}
}
return intentAdmission{Params: params}
}
// mapSchemaParams 把槽位按 JSON Schema(draft-07 子集,与总线侧校验同款)
// 投影成参数。只认声明过的键,并按声明类型转换;转换不成的不写参数
// (数组/对象等非标量声明也不会收到字符串)。必填参数里无法满足的按键名返回。
func mapSchemaParams(schema json.RawMessage, slots map[string]string) (map[string]any, []string) {
params := map[string]any{}
props, required, ok := objectSchema(schema)
if !ok {
// 没有可用声明:不复制任何槽位(fail-closed),只保留原文入口参数。
return params, nil
}
keys := make([]string, 0, len(slots))
for k := range slots {
if _, declared := props[k]; declared {
keys = append(keys, k)
}
}
sort.Strings(keys)
for _, k := range keys {
if v, ok := castParam(props[k], slots[k]); ok {
params[k] = v
}
}
var missing []string
for _, k := range required {
if k == chatTextParam {
continue // 原文入口恒供
}
if _, ok := params[k]; !ok {
missing = append(missing, k)
}
}
return params, missing
}
// objectSchema 解析命令的 paramsSchema;非对象/非法 schema 返回 ok=false。
func objectSchema(schema json.RawMessage) (props map[string]any, required []string, ok bool) {
if len(schema) == 0 {
return nil, nil, false
}
var sc struct {
Type string `json:"type"`
Required []string `json:"required"`
Properties map[string]any `json:"properties"`
}
if err := json.Unmarshal(schema, &sc); err != nil {
return nil, nil, false
}
if sc.Type != "" && sc.Type != "object" {
return nil, nil, false
}
if sc.Properties == nil {
sc.Properties = map[string]any{}
}
return sc.Properties, sc.Required, true
}
// castParam 按声明类型把槽位字符串转成参数值;类型不支持或转换失败返回 false。
func castParam(decl any, raw string) (any, bool) {
m, _ := decl.(map[string]any)
want, _ := m["type"].(string)
switch want {
case "", "string":
return raw, true
case "number":
f, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return nil, false
}
return f, true
case "integer":
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return nil, false
}
return n, true
case "boolean":
b, err := strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
return nil, false
}
return b, true
default:
// array/object 等非标量声明:拒绝把字符串塞进去。
return nil, false
}
}
// clarifyPayload 是回给调用方的澄清诉求(postMessage 响应里的 clarify 字段)。
// 既有端点上的附加字段,不新增 API 面:缺失与非法各自带 key/原因/建议提问。
type clarifyPayload struct {
Intent string `json:"intent,omitempty"`
Command string `json:"command,omitempty"`
Question string `json:"question,omitempty"`
Reason string `json:"reason,omitempty"`
Missing []intentgate.SlotQuestion `json:"missing,omitempty"`
Invalid []intentgate.SlotDefect `json:"invalid,omitempty"`
}
// newClarifyPayload 把路由裁决与准入结果合成一条澄清诉求;无需澄清时返回 nil。
func newClarifyPayload(res intentgate.MatchResult, dec route.Decision, adm *intentAdmission) *clarifyPayload {
if !dec.Clarification.Needed && (adm == nil || !adm.Blocked) {
return nil
}
c := dec.Clarification
out := &clarifyPayload{
Intent: res.ID,
Command: firstNonEmpty(c.Command, dec.CommandID),
Question: strings.TrimSpace(c.Question),
Missing: append([]intentgate.SlotQuestion(nil), c.Missing...),
Invalid: append([]intentgate.SlotDefect(nil), c.Invalid...),
}
if adm != nil && adm.Blocked {
out.Reason = adm.Reason
names := make([]string, 0, len(adm.MissingParams))
for _, name := range adm.MissingParams {
if slotQuestionKnown(out.Missing, name) {
continue
}
out.Missing = append(out.Missing, intentgate.SlotQuestion{Key: name, Question: name})
names = append(names, name)
}
if out.Question == "" && len(names) > 0 {
out.Question = "请补充:" + strings.Join(names, "、")
}
}
if out.Question == "" {
return nil
}
return out
}
func slotQuestionKnown(list []intentgate.SlotQuestion, key string) bool {
for _, q := range list {
if q.Key == key {
return true
}
}
return false
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
+398
View File
@@ -0,0 +1,398 @@
package server
import (
"context"
"encoding/json"
"sync"
"testing"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/intentgate"
)
// F09 意图接纳:槽位缺失/非法一律不派发业务动作;合法槽位按命令 DECLARED
// schema 映射后进处理器;高危命令的确认语义完全由 cmdbus 收口(此处不动)。
// reportIntent 声明一个带枚举校验与多个可选槽位的意图(合成数据)。
func reportIntent() json.RawMessage {
return json.RawMessage(`{"id":"demo.report","domain":"demo","version":1,
"matcher":{"includes_any":["生成报告"]},
"slots":[
{"key":"topic","required":true,"pattern":"主题[::]\\s*(\\S+)","question":"主题是什么?"},
{"key":"mode","required":true,"pattern":"模式[::]\\s*(\\S+)","enum":["摘要","全文"],"question":"要哪种模式?"},
{"key":"count","pattern":"份数[::]\\s*(\\S+)"},
{"key":"enabled","pattern":"归档[::]\\s*(\\S+)"},
{"key":"note","pattern":"备注[::]\\s*(\\S+)"}]}`)
}
// reportCommandSpec 声明参数 schema:count 是 integer、enabled 是 boolean,
// note 未声明——未声明的键不得被塞进 params。
func reportCommandSpec() agentcommand.CommandSpec {
return agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告命令",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","topic","mode"],
"properties":{"topic":{"type":"string"},"mode":{"type":"string"},
"count":{"type":"integer"},"enabled":{"type":"boolean"}}}`)}
}
// dispatchLog 记录每次真实派发(处理器被调用才算执行)。
type dispatchLog struct {
mu sync.Mutex
calls []cmdbus.Request
params []map[string]any
}
func (l *dispatchLog) record(req cmdbus.Request) {
l.mu.Lock()
defer l.mu.Unlock()
copied := map[string]any{}
for k, v := range req.Params {
copied[k] = v
}
l.calls = append(l.calls, req)
l.params = append(l.params, copied)
}
func (l *dispatchLog) snapshot() []map[string]any {
l.mu.Lock()
defer l.mu.Unlock()
return append([]map[string]any(nil), l.params...)
}
func (l *dispatchLog) byOwner() map[string]map[string]any {
l.mu.Lock()
defer l.mu.Unlock()
out := map[string]map[string]any{}
for _, req := range l.calls {
out[req.Owner] = req.Params
}
return out
}
// admissionServer 组装带意图闸门与 demo.report 命令的测试服务器。
func admissionServer(t *testing.T) (*Server, *dispatchLog) {
t.Helper()
return admissionServerWith(t, reportCommandSpec())
}
func admissionServerWith(t *testing.T, spec agentcommand.CommandSpec) (*Server, *dispatchLog) {
t.Helper()
srv, _ := newTestServer(t)
log := &dispatchLog{}
if err := srv.Bus.RegisterBuiltin(spec, "", func(_ context.Context, req cmdbus.Request) (any, error) {
log.record(req)
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
gate := intentgate.New()
if err := gate.Rebuild(context.Background(), []json.RawMessage{reportIntent()}); err != nil {
t.Fatal(err)
}
srv.Gate = gate
return srv, log
}
func clarifyOf(t *testing.T, out map[string]any) map[string]any {
t.Helper()
c, ok := out["clarify"].(map[string]any)
if !ok {
t.Fatalf("响应缺少澄清诉求:%v", out)
}
return c
}
// 缺必填槽位:不执行业务命令,澄清诉求带正确的提问。
func TestMissingSlotBlocksCommandAndSurfacesClarification(t *testing.T) {
srv, log := admissionServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "生成报告 模式:摘要"})
if code != 200 {
t.Fatalf("status %d body %v", code, out)
}
if _, executed := out["command"]; executed {
t.Fatalf("缺必填槽位不得执行业务命令:%v", out)
}
if out["run"] == nil {
t.Fatalf("未派发命令时应回落普通回合:%v", out)
}
c := clarifyOf(t, out)
if c["question"] != "主题是什么?" {
t.Fatalf("澄清提问:%v", c)
}
if c["command"] != "demo.report" || c["intent"] != "demo.report" {
t.Fatalf("被拦下的动作未标明:%v", c)
}
missing, _ := c["missing"].([]any)
if len(missing) != 1 {
t.Fatalf("缺失槽位明细:%v", c)
}
if m := missing[0].(map[string]any); m["key"] != "topic" || m["question"] != "主题是什么?" {
t.Fatalf("缺失槽位键/提问:%v", m)
}
if _, bad := c["invalid"]; bad {
t.Fatalf("缺失不应混进非法:%v", c)
}
if got := log.snapshot(); len(got) != 0 {
t.Fatalf("命令处理器被调用:%v", got)
}
}
// 非法枚举值:不执行业务命令,结构化原因原样透出。
func TestInvalidSlotBlocksCommandWithReason(t *testing.T) {
srv, log := admissionServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "生成报告 主题:核能 模式:随手写"})
if code != 200 {
t.Fatalf("status %d body %v", code, out)
}
if _, executed := out["command"]; executed {
t.Fatalf("非法槽位不得执行业务命令:%v", out)
}
c := clarifyOf(t, out)
if c["question"] != "要哪种模式?" {
t.Fatalf("澄清提问:%v", c)
}
invalid, _ := c["invalid"].([]any)
if len(invalid) != 1 {
t.Fatalf("非法槽位明细:%v", c)
}
got := invalid[0].(map[string]any)
if got["key"] != "mode" || got["reason"] != "value not in enum" || got["question"] != "要哪种模式?" {
t.Fatalf("非法原因/提问未透传:%v", got)
}
if got["invalid"] != true {
t.Fatalf("非法标记:%v", got)
}
if _, missing := c["missing"]; missing {
t.Fatalf("非法不应重复计入缺失:%v", c)
}
if len(log.snapshot()) != 0 {
t.Fatalf("命令处理器被调用:%v", log.snapshot())
}
}
// 合法槽位:按声明 schema 映射后到达处理器(类型正确、未声明键不复制)。
func TestValidSlotsReachHandlerMappedPerSchema(t *testing.T) {
srv, log := admissionServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
text := "生成报告 主题:核能 模式:摘要 份数:3 归档:true 备注:随便"
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text})
if code != 200 || out["command"] != "demo.report" {
t.Fatalf("status %d body %v", code, out)
}
if _, ok := out["clarify"]; ok {
t.Fatalf("完整合法槽位不应带澄清诉求:%v", out)
}
calls := log.snapshot()
if len(calls) != 1 {
t.Fatalf("处理器调用次数:%d", len(calls))
}
params := calls[0]
if params["topic"] != "核能" || params["mode"] != "摘要" || params["text"] != text {
t.Fatalf("字符串参数映射:%v", params)
}
if params["count"] != int64(3) {
t.Fatalf("integer 参数映射:%#v", params["count"])
}
if params["enabled"] != true {
t.Fatalf("boolean 参数映射:%#v", params["enabled"])
}
if _, ok := params["note"]; ok {
t.Fatalf("未声明的槽位不得复制进 params:%v", params)
}
if len(params) != 5 {
t.Fatalf("参数量超出声明:%v", params)
}
}
// 声明的必填参数无法由原文+槽位满足:不派发,转澄清(不再由总线报 schema 错)。
func TestDeclaredRequiredParamBlocksDispatch(t *testing.T) {
srv, log := admissionServerWith(t, agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告命令",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","target"],
"properties":{"topic":{"type":"string"},"target":{"type":"string"}}}`)})
h := srv.Handler()
_, convID := setupConversation(t, h)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "生成报告 主题:核能 模式:摘要"})
if code != 200 {
t.Fatalf("status %d body %v", code, out)
}
if _, executed := out["command"]; executed {
t.Fatalf("必填参数缺失不得执行业务命令:%v", out)
}
c := clarifyOf(t, out)
missing, _ := c["missing"].([]any)
found := false
for _, m := range missing {
if m.(map[string]any)["key"] == "target" {
found = true
}
}
if !found {
t.Fatalf("缺失必填参数未透出:%v", c)
}
if c["reason"] == "" {
t.Fatalf("准入原因缺失:%v", c)
}
if len(log.snapshot()) != 0 {
t.Fatalf("命令处理器被调用:%v", log.snapshot())
}
}
// 高危命令仍走 cmdbus 的确认卡收口:接纳通过后创建确认卡,处理器不执行。
func TestHighRiskCommandStillRequiresConfirmation(t *testing.T) {
srv, log := admissionServer(t)
h := srv.Handler()
gate := intentgate.New()
if err := gate.Rebuild(context.Background(), []json.RawMessage{
reportIntent(),
json.RawMessage(`{"id":"demo.purge","domain":"demo","version":1,
"matcher":{"includes_any":["清除报告"]},
"action":{"kind":"command","id":"demo.purge"},
"slots":[{"key":"topic","required":true,"pattern":"对象[::]\\s*(\\S+)","question":"清除哪个对象?"}]}`),
}); err != nil {
t.Fatal(err)
}
srv.Gate = gate
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.purge", Title: "清除报告",
Danger: agentcommand.DangerHigh,
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","topic"],
"properties":{"topic":{"type":"string"}}}`)}, "", func(context.Context, cmdbus.Request) (any, error) {
t.Fatalf("高危命令未经确认不得执行处理器")
return nil, nil
}); err != nil {
t.Fatal(err)
}
// 缺必填槽位时连确认卡都不该出现(业务动作根本没到总线)。
_, blocked := setupConversation(t, h)
code, out := doJSON(t, h, "POST", "/api/conversations/"+blocked+"/messages",
map[string]any{"text": "清除报告"})
if code != 200 {
t.Fatalf("status %d body %v", code, out)
}
if _, hasCard := out["card"]; hasCard {
t.Fatalf("槽位未就绪不得创建确认卡:%v", out)
}
if _, executed := out["command"]; executed {
t.Fatalf("槽位未就绪不得执行业务命令:%v", out)
}
// 槽位齐全:派发到总线,高危收口创建确认卡,处理器仍未执行。
_, ready := setupConversation(t, h)
code, out = doJSON(t, h, "POST", "/api/conversations/"+ready+"/messages",
map[string]any{"text": "清除报告 对象:旧归档"})
if code != 200 || out["command"] != "demo.purge" {
t.Fatalf("status %d body %v", code, out)
}
if out["card"] == nil {
t.Fatalf("高危命令必须要求确认:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("确认前不得派发其他命令:%v", log.snapshot())
}
}
// 显式取消命令宿主未实现:如实回落普通回合,不派发同一句里的 Start。
type cancelGenerator struct{}
// 生成器只经 intentgate 表面接线:目录与命令类型都由闸门投影,
// 宿主实现不必(也不应)直接导入生态库。
func (cancelGenerator) Generate(context.Context, string, []intentgate.CatalogSpec) ([]intentgate.Command, error) {
return []intentgate.Command{
{Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.report"}},
{Kind: intentgate.CommandCancel, Target: "demo.report"},
}, nil
}
func TestUnsupportedCancelCommandFallsBackToTurn(t *testing.T) {
srv, log := admissionServer(t)
h := srv.Handler()
gate, ok := srv.Gate.(*intentgate.Gate)
if !ok {
t.Fatalf("闸门类型:%T", srv.Gate)
}
gate.SetGenerator(cancelGenerator{})
_, convID := setupConversation(t, h)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "算了吧 生成报告"})
if code != 200 {
t.Fatalf("status %d body %v", code, out)
}
if _, executed := out["command"]; executed {
t.Fatalf("未支持的取消命令不得派发业务动作:%v", out)
}
if out["run"] == nil {
t.Fatalf("未支持的库层命令应回落普通回合:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("命令处理器被调用:%v", log.snapshot())
}
}
// 两个租户的槽位上下文互不串味:各自只看到自己那句输入映射出的参数。
func TestIntentSlotsDoNotLeakAcrossOwners(t *testing.T) {
srv, log := admissionServer(t)
srv.Auth = NewAuthenticator(srv.st, true)
convID := map[string]string{}
for _, owner := range []string{"alice", "bob"} {
if err := srv.st.CreateAccount(owner, owner, "", true); err != nil {
t.Fatal(err)
}
proj, err := srv.st.CreateProject(owner, owner+" 的项目")
if err != nil {
t.Fatal(err)
}
conv, err := srv.st.CreateConversation(owner, proj.ID, owner+" 的会话", "")
if err != nil {
t.Fatal(err)
}
convID[owner] = conv.ID
}
h := srv.Handler()
send := func(owner, text string) map[string]any {
t.Helper()
key, err := srv.st.CreateAPIKey(owner, owner+"-key")
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(map[string]any{"text": text})
if err != nil {
t.Fatal(err)
}
rec := bearerRequest(h, "POST", "/api/conversations/"+convID[owner]+"/messages", string(body), key)
out := map[string]any{}
if rec.Body.Len() > 0 {
json.Unmarshal(rec.Body.Bytes(), &out)
}
if rec.Code != 200 {
t.Fatalf("%s 发送 %q: status %d body %s", owner, text, rec.Code, rec.Body.String())
}
return out
}
if out := send("alice", "生成报告 主题:核能 模式:摘要"); out["command"] != "demo.report" {
t.Fatalf("alice 派发:%v", out)
}
if out := send("bob", "生成报告 主题:水利 模式:全文"); out["command"] != "demo.report" {
t.Fatalf("bob 派发:%v", out)
}
if got := log.byOwner(); len(got) != 2 {
t.Fatalf("派发归属:%v", got)
}
byOwner := log.byOwner()
if byOwner["alice"]["topic"] != "核能" || byOwner["alice"]["mode"] != "摘要" {
t.Fatalf("alice 收到别人的槽位:%v", byOwner["alice"])
}
if byOwner["bob"]["topic"] != "水利" || byOwner["bob"]["mode"] != "全文" {
t.Fatalf("bob 收到别人的槽位:%v", byOwner["bob"])
}
if byOwner["alice"]["topic"] == byOwner["bob"]["topic"] {
t.Fatalf("两个租户共享了同一份槽位上下文:%v", byOwner)
}
}
+87
View File
@@ -0,0 +1,87 @@
package server
import (
"errors"
"net/http"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
)
// commandOperationQuery 把"已记录 operation 的状态查询"落在既有的 owner+project
// 作用域审计查询上(store.ListProjectCommandAudits),不新建通用幂等平台:不加表、
// 不加索引、不引入新的重试语义。命令回执(审计信封)本就是宿主侧的操作记录,
// handler 自己事务/发件箱里的领域操作表(如文档投递 document_render_deliveries)
// 仍由各自领域查询负责。
//
// 诚实边界:
// - 只有成功落下审计信封的 operation 查得到。审计不可用时总线回 unknown +
// operationID,但那一行没有落库:这里返回"无记录",绝不推断"未执行"。
// - 扫描有界(最近若干页):超出窗口的旧操作返回无记录,而不是全表扫描。
// - project_id 是追加时按可信会话快照下来的归属;改绑/删除会话不改写历史归属,
// 与既有项目审计查询同一口径。
type commandOperationQuery struct{ st *store.Store }
const (
operationLookupPageSize = 100
operationLookupMaxPages = 20 // 有界扫描:最近 2000 行,超窗即"无记录"
)
func (q commandOperationQuery) LookupOperation(owner, projectID, operationID string) (cmdbus.OperationRecord, bool, error) {
if q.st == nil || owner == "" || projectID == "" || operationID == "" {
return cmdbus.OperationRecord{}, false, nil
}
var before, cutoff int64
for page := 0; page < operationLookupMaxPages; page++ {
rows, resolvedCutoff, next, err := q.st.ListProjectCommandAudits(owner, projectID, before, cutoff, operationLookupPageSize)
if err != nil {
return cmdbus.OperationRecord{}, false, err
}
cutoff = resolvedCutoff // 首帧固定游标,后续页在同一提交前缀内翻页
for _, row := range rows {
if row.Envelope.OperationID != operationID {
continue
}
return cmdbus.OperationRecord{
OperationID: row.Envelope.OperationID,
CommandID: row.Envelope.CommandID,
State: row.Envelope.State,
Time: row.Envelope.Time,
}, true, nil
}
if next == 0 {
return cmdbus.OperationRecord{}, false, nil
}
before = next
}
return cmdbus.OperationRecord{}, false, nil
}
// getCommandOperation 是 unknown 回执的对账入口(R7/KTD6):
// GET /api/commands/operations/{operationId}?projectId=…
//
// 授权复用既有口径:owner 只取自认证上下文(不读请求参数),projectId 仅用于
// 收窄到该 owner 名下的项目——与 /api/commands/audit 同源,跨租户天然不可见。
// 查不到一律 404:HTTP 层的"没有记录"同样不得被读成"未执行"。
func (s *Server) getCommandOperation(w http.ResponseWriter, r *http.Request) {
if s.Bus == nil {
http.Error(w, "commands disabled", http.StatusServiceUnavailable)
return
}
record, err := s.Bus.LookupOperation(s.owner(r), r.URL.Query().Get("projectId"), r.PathValue("operationId"))
switch {
case errors.Is(err, cmdbus.ErrOperationScope):
http.Error(w, "projectId and operationId are required", http.StatusBadRequest)
return
case errors.Is(err, cmdbus.ErrOperationQueryUnavailable):
http.Error(w, "operation query unavailable", http.StatusServiceUnavailable)
return
case errors.Is(err, cmdbus.ErrOperationNotRecorded):
http.Error(w, "no operation record in this scope", http.StatusNotFound)
return
case err != nil:
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"operation": record})
}
+96
View File
@@ -0,0 +1,96 @@
package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
)
// TestCommandOperationLookupIsOwnerProjectScoped 覆盖 U7b/R7 的对账查询:
// unknown 回执给出的 operationID 能按认证 owner + project 查到落库记录;
// 跨 owner、错 project、缺 projectId 一律查不到或拒(fail-closed),任何
// "查不到"都不得被读成"未执行"。
func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) {
srv, st := newAuthTestServer(t)
srv.Bus = cmdbus.New(st, nil, time.Now)
srv.WireBuiltinCommands() // 生产装配点:总线拿到宿主侧对账查询端口
h := srv.Handler()
aliceKey, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "对账项目")
if err != nil {
t.Fatal(err)
}
conversation, err := st.CreateConversation("alice", project.ID, "对账会话", "")
if err != nil {
t.Fatal(err)
}
// 写库之后才失败的命令:unknown + operationID(审计信封按会话快照落项目)。
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)
}
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 == "" {
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()
req := httptest.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
rec := get(aliceKey, "/api/commands/operations/"+operationID+"?projectId="+project.ID)
if rec.Code != http.StatusOK {
t.Fatalf("owner lookup: %d %s", rec.Code, rec.Body.String())
}
var out struct {
Operation struct {
OperationID string `json:"OperationID"`
CommandID string `json:"CommandID"`
State string `json:"State"`
} `json:"operation"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Operation.OperationID != operationID || out.Operation.CommandID != "demo.write" || out.Operation.State != string(agentcommand.StateUnknown) {
t.Fatalf("lookup projection wrong: %+v", out.Operation)
}
for _, tc := range []struct {
name, key, url string
want int
}{
{"cross owner", bobKey, "/api/commands/operations/" + operationID + "?projectId=" + project.ID, http.StatusNotFound},
{"wrong project", aliceKey, "/api/commands/operations/" + operationID + "?projectId=prj_other", http.StatusNotFound},
{"missing project", aliceKey, "/api/commands/operations/" + operationID, http.StatusBadRequest},
{"missing operation", aliceKey, "/api/commands/operations/op_absent?projectId=" + project.ID, http.StatusNotFound},
} {
if rec := get(tc.key, tc.url); rec.Code != tc.want {
t.Fatalf("%s: %d %s (want %d)", tc.name, rec.Code, rec.Body.String(), tc.want)
}
}
}
@@ -266,10 +266,13 @@ func newIAMFixture(t *testing.T) *iamFixture {
member, _ := req.Params["member"].(string)
err := f.provider.addMember(ctx, srv.lifecycleHost(req.Owner), projectID, member)
if failure, ok := categorizeWorkFailure(extension.WorkOperationMemberAdd, err); ok {
return nil, failure
// 假 provider 的每条失败路径都发生在改成员之前(唯一写点在最后一行
// 且随后返回 nil),因此显式声明 not-executed:拒绝语义与 4xx 映射
// 由此成立,而不是靠“报了错就算没执行”(F10)。
return nil, cmdbus.NotExecuted(failure)
}
if err != nil {
return nil, err
return nil, cmdbus.NotExecuted(err)
}
return map[string]any{"ok": true}, nil
}); err != nil {
+226
View File
@@ -469,3 +469,229 @@ func TestLegacyRoleRowsDoNotAuthorizeSystemActionsOrAccess(t *testing.T) {
t.Fatalf("legacy true,true row denied project access: %d %s", rec.Code, rec.Body.String())
}
}
// =====================================================================
// HTTP-level coverage for the ported IAM batch endpoints (port from
// feature/iam-perm-ux-backend). The endpoints reuse the existing auth
// gates (system.permissions.* and system.roles.*) and main's narrowed role
// model (only project.create is role authority).
// =====================================================================
func TestRolePermissionPreviewRequiresPermissionViewOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootSuperAdmin(t, h, st)
// Preview is a permission-management read: system.permissions.view is
// required, and holding system.roles.view alone must not unlock it.
roleViewer := grantSystemActionsFixture(t, h, st, admin, "preview-role-viewer", "system.roles.view")
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"预览职位"}`, admin)
if roleRec.Code != http.StatusOK {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
body := `{"roles":[{"roleId":"` + role.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}]}`
if rec := bearerRequest(h, "POST", "/api/admin/roles/permissions/preview", body, roleViewer); rec.Code != http.StatusForbidden {
t.Fatalf("role viewer previewed permissions: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "POST", "/api/admin/roles/permissions/preview", body, admin); rec.Code != http.StatusOK {
t.Fatalf("preview denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), role.RoleID) {
t.Fatalf("preview body: %s", rec.Body.String())
}
// Preview judges every item separately, so an unknown role is reported
// inside that item's preview instead of failing the whole read.
var unknown struct {
Previews []struct {
RoleID string `json:"roleId"`
VersionMatches bool `json:"versionMatches"`
Error string `json:"error"`
ErrorActionID string `json:"errorActionId"`
} `json:"previews"`
}
unknownRec := do(h, "POST", "/api/admin/roles/permissions/preview",
`{"roles":[{"roleId":"unknown","expectedRoleVersion":1,"changes":[]}]}`, admin)
if unknownRec.Code != http.StatusOK {
t.Fatalf("unknown-role preview: %d %s", unknownRec.Code, unknownRec.Body.String())
}
if err := json.Unmarshal(unknownRec.Body.Bytes(), &unknown); err != nil || len(unknown.Previews) != 1 {
t.Fatalf("decode unknown preview: %v body=%s", err, unknownRec.Body.String())
}
// store's roleAssignmentRoleInvalid reason ("角色不存在或已停用"):
// preview reports an unusable role per item instead of failing the read.
const roleInvalidReason = "角色不存在或已停用"
if unknown.Previews[0].RoleID != "unknown" || unknown.Previews[0].VersionMatches ||
unknown.Previews[0].Error != roleInvalidReason || unknown.Previews[0].ErrorActionID != "" {
t.Fatalf("unknown preview item: %+v", unknown.Previews[0])
}
// An empty item list is still a malformed request.
if rec := do(h, "POST", "/api/admin/roles/permissions/preview", `{"roles":[]}`, admin); rec.Code != http.StatusBadRequest {
t.Fatalf("empty preview: %d %s", rec.Code, rec.Body.String())
}
}
func TestBatchRolePermissionPatchOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootSuperAdmin(t, h, st)
// Batch commit is a permission-management write: system.permissions.manage
// is required, roles.view/manage alone is not enough.
roleManager := grantSystemActionsFixture(t, h, st, admin, "batch-role-manager", "system.roles.view", "system.roles.manage")
first := createRoleHTTP(t, h, admin, "批量职位一")
second := createRoleHTTP(t, h, admin, "批量职位二")
body := `{"idempotencyKey":"batch-1","roles":[` +
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]},` +
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}` +
`]}`
if rec := bearerRequest(h, "POST", "/api/admin/roles/permissions/batch", body, roleManager); rec.Code != http.StatusForbidden {
t.Fatalf("role manager patched permissions: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", body, admin); rec.Code != http.StatusOK {
t.Fatalf("batch denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), "receipts") {
t.Fatalf("batch body: %s", rec.Body.String())
}
// A stale expectedRoleVersion in any item rejects the whole batch with
// 409 Conflict (main's role-version class) and writes nothing at all.
stale := `{"idempotencyKey":"batch-stale","roles":[` +
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]},` +
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}` +
`]}`
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", stale, admin); rec.Code != http.StatusConflict {
t.Fatalf("stale batch: %d %s", rec.Code, rec.Body.String())
}
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 {
t.Fatalf("stale batch wrote %s: %+v", role.RoleID, view)
}
}
// A change outside project access is an invalid policy: 400 Bad Request.
invalid := `{"idempotencyKey":"batch-invalid","roles":[` +
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"system.users.view","allowed":true,"capAllowed":true}]},` +
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}` +
`]}`
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", invalid, admin); rec.Code != http.StatusBadRequest {
t.Fatalf("invalid batch: %d %s", rec.Code, rec.Body.String())
}
}
func TestBatchRoleAssignmentPreviewAndCommitOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
bob, _ := st.GetAccountByOwner("bob")
role := createRoleHTTP(t, h, admin, "职位预览")
preview := `{"assignments":[` +
`{"userId":"` + alice.ID + `","roleId":"` + role.RoleID + `","expectedStateVersion":` + strconv.FormatInt(alice.StateVersion, 10) + `},` +
`{"userId":"` + bob.ID + `","roleId":"` + role.RoleID + `","expectedStateVersion":` + strconv.FormatInt(bob.StateVersion, 10) + `}` +
`]}`
if rec := do(h, "POST", "/api/admin/users/role-assignments/preview", preview, admin); rec.Code != http.StatusOK {
t.Fatalf("preview denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), alice.ID) {
t.Fatalf("preview missing alice: %s", rec.Body.String())
}
commit := `{"idempotencyKey":"assign-batch","assignments":[` +
`{"userId":"` + alice.ID + `","roleId":"` + role.RoleID + `","expectedStateVersion":` + strconv.FormatInt(alice.StateVersion, 10) + `},` +
`{"userId":"` + bob.ID + `","roleId":"` + role.RoleID + `","expectedStateVersion":` + strconv.FormatInt(bob.StateVersion, 10) + `}` +
`]}`
if rec := do(h, "POST", "/api/admin/users/role-assignments", commit, admin); rec.Code != http.StatusOK {
t.Fatalf("commit denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), "bindings") {
t.Fatalf("commit body: %s", rec.Body.String())
}
}
func TestCopyAssignRoleOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
source := createRoleHTTP(t, h, admin, "源职位")
enable := do(h, "PUT", "/api/admin/roles/"+source.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"copy-source","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
if enable.Code != http.StatusOK {
t.Fatalf("enable source: %d %s", enable.Code, enable.Body.String())
}
body := `{"source":{"roleId":"` + source.RoleID + `","expectedRoleVersion":2},"name":"复制职位","description":"","idempotencyKey":"copy-1","assignments":[{"userId":"` + alice.ID + `","expectedStateVersion":` + strconv.FormatInt(alice.StateVersion, 10) + `}]}`
if rec := do(h, "POST", "/api/admin/roles/copy-assign", body, admin); rec.Code != http.StatusOK {
t.Fatalf("copy denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), "复制职位") {
t.Fatalf("copy body: %s", rec.Body.String())
}
}
func TestRoleMembersFilterBeforePagingAndBindCursor(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
bob, _ := st.GetAccountByOwner("bob")
role := createRoleHTTP(t, h, admin, "成员职位")
assign := func(userID string, version int64, key string) {
t.Helper()
rec := do(h, "PUT", "/api/admin/users/"+userID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(version, 10)+`,"idempotencyKey":"`+key+`"}`, admin)
if rec.Code != http.StatusOK {
t.Fatalf("assign %s: %d %s", userID, rec.Code, rec.Body.String())
}
}
assign(alice.ID, alice.StateVersion, "assign-alice")
assign(bob.ID, bob.StateVersion, "assign-bob")
if rec := do(h, "GET", "/api/admin/roles/"+role.RoleID+"/members", "", admin); rec.Code != http.StatusOK {
t.Fatalf("list members: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), alice.ID) || !strings.Contains(rec.Body.String(), bob.ID) {
t.Fatalf("members=%s", rec.Body.String())
}
filtered := do(h, "GET", "/api/admin/roles/"+role.RoleID+"/members?query=alice&mode=exact", "", admin)
if filtered.Code != http.StatusOK || !strings.Contains(filtered.Body.String(), alice.ID) || strings.Contains(filtered.Body.String(), bob.ID) {
t.Fatalf("filter=%d %s", filtered.Code, filtered.Body.String())
}
}
func TestAccountSearchIncludesRoleProjection(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
role := createRoleHTTP(t, h, admin, "账户目录职位")
if rec := do(h, "PUT", "/api/admin/users/"+alice.ID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(alice.StateVersion, 10)+`,"idempotencyKey":"search-assign"}`, admin); rec.Code != http.StatusOK {
t.Fatalf("assign: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "GET", "/api/admin/users/search?query=alice&mode=exact", "", admin); rec.Code != http.StatusOK {
t.Fatalf("search: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), role.RoleID) {
t.Fatalf("search missing role projection: %s", rec.Body.String())
}
}
// createRoleHTTP creates a role over HTTP and returns the decoded view. It
// reuses roles_test.go's decodeRoleHTTP so the package keeps one decoder.
func createRoleHTTP(t *testing.T, h http.Handler, admin, name string) roleHTTPView {
t.Helper()
rec := do(h, "POST", "/api/admin/roles", `{"name":"`+name+`"}`, admin)
if rec.Code != http.StatusOK {
t.Fatalf("create role %s: %d %s", name, rec.Code, rec.Body.String())
}
return decodeRoleHTTP(t, rec)
}
+162
View File
@@ -31,6 +31,17 @@ func isAccountAdministrationAction(actionID string) bool {
return store.IsAccountAdministrationAction(actionID)
}
// systemActionAllowed reports, without writing a response, whether the actor
// holds a system action. Optional read projections (e.g. role summary in the
// account directory) use it to skip the projection when the caller cannot
// see it, so a missing grant stays invisible rather than being denied loudly.
func (s *Server) systemActionAllowed(actor, actionID string) bool {
if s.Auth == nil || !s.Auth.Enabled {
return true
}
return s.authorizer.AuthorizeSystem(actor, actionID).Allowed
}
func (s *Server) listPermissionCatalog(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok {
return
@@ -191,3 +202,154 @@ func (s *Server) explainSystemAuthorization(w http.ResponseWriter, r *http.Reque
}
writeJSON(w, http.StatusOK, decision)
}
// listRolePermissionPreview predicts role permission replace/patch effects
// without writing rows or receipts. Viewing is enough to call it; the commit
// revalidates every item.
func (s *Server) listRolePermissionPreview(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.permissions.view")
if !ok {
return
}
var req struct {
Roles []store.RolePermissionPreviewInput `json:"roles"`
}
if err := decodeStrictJSON(r, &req); err != nil || len(req.Roles) == 0 {
http.Error(w, "invalid role permission preview", http.StatusBadRequest)
return
}
for _, role := range req.Roles {
if role.RoleID == "" {
http.Error(w, "invalid role permission preview", http.StatusBadRequest)
return
}
}
previews, err := s.st.PreviewRolePermissions(actor, req.Roles)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"previews": previews})
}
// batchPatchRolePermissions applies an explicit patch to several roles in
// one transaction. The actor must hold system.permissions.manage (R4, R5).
func (s *Server) batchPatchRolePermissions(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.permissions.manage")
if !ok {
return
}
var req struct {
IdempotencyKey string `json:"idempotencyKey"`
Roles []store.RolePermissionPatchInput `json:"roles"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.IdempotencyKey == "" || len(req.Roles) == 0 {
http.Error(w, "invalid role permission batch", http.StatusBadRequest)
return
}
for _, role := range req.Roles {
if role.RoleID == "" || role.ExpectedRoleVersion < 1 {
http.Error(w, "invalid role permission batch", http.StatusBadRequest)
return
}
}
batch, err := s.st.PatchRolePermissionsBatch(actor, req.Roles, req.IdempotencyKey)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"receipts": batch.Receipts})
}
// decodeRoleAssignments parses a role assignment payload shared by the
// batch commit (withKey=true, requires idempotencyKey) and the read-only
// preview (withKey=false).
func decodeRoleAssignments(w http.ResponseWriter, r *http.Request, withKey bool) ([]store.UserRoleAssignmentInput, string, bool) {
var req struct {
IdempotencyKey string `json:"idempotencyKey"`
Assignments []store.UserRoleAssignmentInput `json:"assignments"`
}
if err := decodeStrictJSON(r, &req); err != nil || len(req.Assignments) == 0 {
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
return nil, "", false
}
if withKey && req.IdempotencyKey == "" {
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
return nil, "", false
}
for _, assignment := range req.Assignments {
if assignment.UserID == "" || assignment.RoleID == "" || assignment.ExpectedStateVersion < 1 {
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
return nil, "", false
}
}
return req.Assignments, req.IdempotencyKey, true
}
func (s *Server) assignUserRolesBatch(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.roles.assign")
if !ok {
return
}
assignments, key, ok := decodeRoleAssignments(w, r, true)
if !ok {
return
}
batch, err := s.st.AssignUserRolesBatch(actor, assignments, key)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"bindings": batch.Bindings})
}
// previewUserRoleAssignments reads the commit rule for each assignment and
// returns it without writing rows or receipts. Viewing is enough to call it.
func (s *Server) previewUserRoleAssignments(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.users.view"); !ok {
return
}
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
return
}
actor := s.owner(r)
assignments, _, ok := decodeRoleAssignments(w, r, false)
if !ok {
return
}
previews, err := s.st.PreviewUserRoleAssignments(actor, assignments)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"previews": previews})
}
func (s *Server) copyAssignRole(w http.ResponseWriter, r *http.Request) {
var actor string
for _, actionID := range []string{"system.roles.manage", "system.permissions.manage", "system.roles.assign"} {
var ok bool
if actor, ok = s.requireSystemAction(w, r, actionID); !ok {
return
}
}
var req struct {
Source store.RoleCopySource `json:"source"`
Name string `json:"name"`
Description string `json:"description"`
Overrides []store.RolePermissionChange `json:"overrides"`
Assignments []store.RoleCopyAssignment `json:"assignments"`
IdempotencyKey string `json:"idempotencyKey"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.Source.RoleID == "" || req.Source.ExpectedRoleVersion < 1 || req.IdempotencyKey == "" {
http.Error(w, "invalid role copy", http.StatusBadRequest)
return
}
result, err := s.st.CreateRoleCopyAndAssign(actor, req.Source, store.RoleCreate{Name: req.Name, Description: req.Description},
req.Overrides, req.Assignments, req.IdempotencyKey)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"role": result.Role, "bindings": result.Bindings, "linkedExpansion": result.LinkedExpansion, "receiptId": result.ReceiptID, "authorityEpoch": result.AuthorityEpoch})
}
+40 -5
View File
@@ -197,11 +197,14 @@ func (s *Server) recognize(ctx context.Context, text string) intentgate.MatchRes
// WireBuiltinCommands 注册内置命令(`/` 菜单全清单的数据源;处理器为宿主用例适配)。
// 响应保持既有 {command, ...} 契约(frontend store.sendMessage 按此消费)。
// 组合根在注入 Bus 之后调用一次(启动期);此处也是 operationID 对账查询端口的
// 装配点:总线只在启动时刻拿到宿主端口,不在请求路径上改写总线状态。
func (s *Server) WireBuiltinCommands() {
b := s.Bus
if b == nil {
return
}
b.SetOperationQuery(commandOperationQuery{st: s.st})
must := func(err error) {
if err != nil {
panic("server: builtin command: " + err.Error()) // 装配期错误属编程错误
@@ -320,6 +323,12 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/admin/roles/{roleId}", s.getRole)
mux.HandleFunc("PATCH /api/admin/roles/{roleId}", s.patchRole)
mux.HandleFunc("PUT /api/admin/roles/{roleId}/permissions", s.replaceRolePermissions)
mux.HandleFunc("POST /api/admin/roles/permissions/preview", s.listRolePermissionPreview)
mux.HandleFunc("POST /api/admin/roles/permissions/batch", s.batchPatchRolePermissions)
mux.HandleFunc("POST /api/admin/roles/copy-assign", s.copyAssignRole)
mux.HandleFunc("GET /api/admin/roles/{roleId}/members", s.listRoleMembers)
mux.HandleFunc("POST /api/admin/users/role-assignments", s.assignUserRolesBatch)
mux.HandleFunc("POST /api/admin/users/role-assignments/preview", s.previewUserRoleAssignments)
mux.HandleFunc("GET /api/admin/users/{userId}/role", s.getUserRole)
mux.HandleFunc("PUT /api/admin/users/{userId}/role", s.assignUserRole)
mux.HandleFunc("GET /api/admin/users/{userId}/system-grants", s.getSystemGrants)
@@ -421,6 +430,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/workspaces", s.listWorkspaces)
mux.HandleFunc("POST /api/commands/{id}", s.execCommand)
mux.HandleFunc("GET /api/commands/audit", s.listAudits)
// U7b/R7:operationID 对账查询(unknown 回执的收敛入口;owner 取自认证
// 上下文,projectId 只收窄作用域)。
mux.HandleFunc("GET /api/commands/operations/{operationId}", s.getCommandOperation)
mux.HandleFunc("GET /api/parsers", s.listParsers)
mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge)
mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge)
@@ -1150,6 +1162,8 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
routeKind := route.KindTurn
var recognized intentgate.MatchResult
var workflowDef core.WorkflowDef
var admission *intentAdmission
var clarify *clarifyPayload
if s.Gate != nil && !strings.HasPrefix(text, "/") && req.TaskRef == nil {
recognized = s.recognize(r.Context(), text)
dec := route.Decide(recognized, route.Env{
@@ -1164,13 +1178,23 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
})
switch dec.Kind {
case route.KindCommand:
routeKind = route.KindCommand
recognized.ID = dec.CommandID
recognized.Slots = dec.Slots
// 接纳:按命令 DECLARED schema 把槽位映射成参数;准入未过
// (必填参数无法由原文+槽位满足)就不派发,降为澄清回合(F09)。
adm := admitIntentCommand(s.Bus, dec.CommandID, dec.Slots, text)
clarify = newClarifyPayload(recognized, dec, &adm)
if !adm.Blocked {
routeKind = route.KindCommand
recognized.ID = dec.CommandID
recognized.Slots = dec.Slots
admission = &adm
}
case route.KindWorkflow:
routeKind = route.KindWorkflow
workflowDef = dec.Workflow
recognized.Slots = dec.Slots
clarify = newClarifyPayload(recognized, dec, nil)
default:
clarify = newClarifyPayload(recognized, dec, nil)
}
}
claimed := false
@@ -1204,8 +1228,14 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
return
}
if routeKind == route.KindCommand {
// 参数按命令 DECLARED schema 映射(槽位→声明键/声明类型);原文恒供。
params := map[string]any{chatTextParam: text}
if admission != nil && admission.Params != nil {
params = admission.Params
params[chatTextParam] = text
}
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner,
ID: recognized.ID, Params: params, Entrance: "chat", Actor: owner,
Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID, HostProjectID: string(conv.ProjectID),
})
out := map[string]any{"message": msg, "receipt": resp.Receipt, "command": recognized.ID}
@@ -1242,7 +1272,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
return
}
msg.RunID = run.ID
writeJSON(w, http.StatusOK, map[string]any{"message": msg, "run": run})
out := map[string]any{"message": msg, "run": run}
if clarify != nil {
// 澄清诉求(业务动作未派发):既有端点上的附加字段,缺失/非法各带结构化原因。
out["clarify"] = clarify
}
writeJSON(w, http.StatusOK, out)
}
// writeExistingMessage 把同一 requestId 的已有消息和仍在进行的 run 原样返回。
+5 -1
View File
@@ -59,10 +59,14 @@ func TestCommandBusBeatsWorkflow(t *testing.T) {
t.Fatal(err)
}
_, convID := setupConversation(t, srv.Handler())
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"})
// 槽位齐全才派发命令(F09):必填槽位缺失时命令不得执行,改走澄清回合。
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "主题:核能 写一份简报"})
if code != 200 || out["command"] != "demo.brief" {
t.Fatalf("status %d body %v", code, out)
}
if _, ok := out["clarify"]; ok {
t.Fatalf("complete slots must not clarify: %v", out)
}
if _, ok := out["run"]; ok {
t.Fatal("a command hit must not start a workflow run")
}
+5
View File
@@ -225,6 +225,11 @@ func isHoldStatus(s string) bool {
// AgentNativeOptions 可选组合根注入:返回配置且启用时,为每次小队运行注册
// "agent-native" 内核后端工厂(模型/搜索/执行/MCP/技能根由组合根从 settings
// 解析;Subagent 工厂经 rc 桥接产品 SpawnSubagent)。
//
// 组合根必须按「本次运行」解析配置:模型、端点与密钥整条来自 rc.Settings()
// 已合并的当前用户/项目快照(叠加 rc 的画像模型覆盖),per-route 规则与直连
// 共用同一构造器。按默认账号重新解析会让一个用户的模型名配上另一个账号的
// 端点/密钥。
var AgentNativeOptions func(rc Ctx) (nativeadp.Config, bool)
// SubagentFactory 把内核委派端口(delegate_task)桥接到产品 SpawnSubagent:
+3 -1
View File
@@ -27,7 +27,9 @@ type EntryOptions struct {
MaxTurns int
MaxToolCalls int
RepairBudget int
Model string
// Model 是本次运行的主模型(与 AgentNativeOptions 解析出的路由同源:
// 同一份运行快照 + 画像覆盖),不得与工厂配的端点/密钥分属不同属主。
Model string
}
// EntryResult 是入口跑完后的文本。工件登记由调用方在虚假交付检查之后做。
+61
View File
@@ -40,6 +40,67 @@ func TestContextManifestTerminalRequiresMatchingAttempt(t *testing.T) {
}
}
func TestContextManifestAttemptIsNotReusableAcrossProjectOrOwner(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "attempt-identity.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
base := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "attempt-1", InputSHA256: "input-hash", Delivery: "attempted"}
if err := st.AppendContextManifest("alice", "run-1", base); err != nil {
t.Fatal(err)
}
base.Delivery = "responded"
if err := st.AppendContextManifest("alice", "run-1", base); err != nil {
t.Fatal(err)
}
// A terminal receipt is matched against the whole persisted identity
// (attempt, owner, target, project, input hash), so the same attempt id
// cannot claim a trusted response under another project or owner.
crossProject := base
crossProject.ProjectID = "p2"
if err := st.AppendContextManifest("alice", "run-1", crossProject); err == nil {
t.Fatal("attempt id reused across project ids to claim a trusted response")
}
crossOwner := base
if err := st.AppendContextManifest("bob", "run-1", crossOwner); err == nil {
t.Fatal("attempt id reused across owners to claim a trusted response")
}
// Planting the send under a foreign scope is refused too: attempt identity
// is global, so one trusted send has exactly one receipt pair.
if err := st.AppendContextManifest("alice", "run-1", base); err == nil {
t.Fatal("attempt id reused across projects to open a second send")
}
if err := st.AppendContextManifest("bob", "run-1", base); err == nil {
t.Fatal("attempt id reused across owners to open a second send")
}
kept, err := st.ListContextManifests("alice", "run-1")
if err != nil || len(kept) != 2 {
t.Fatalf("refused cross-scope receipts changed the trusted set: %+v err=%v", kept, err)
}
for _, m := range kept {
if m.ProjectID != "p1" || m.AttemptID != "attempt-1" {
t.Fatalf("foreign scope leaked into the trusted receipts: %+v", m)
}
}
if foreign, err := st.ListContextManifests("bob", "run-1"); err != nil || len(foreign) != 0 {
t.Fatalf("cross-owner attempt became visible: %+v err=%v", foreign, err)
}
// A genuinely different attempt in another owner/project still works, so
// the refusals above are identity-based rather than scope-based.
other := extension.ProjectContextManifest{ProjectID: "p2", AttemptID: "attempt-2", InputSHA256: "other-input", Delivery: "attempted"}
if err := st.AppendContextManifest("bob", "run-2", other); err != nil {
t.Fatalf("a distinct attempt in another project/owner was refused: %v", err)
}
other.Delivery = "responded"
if err := st.AppendContextManifest("bob", "run-2", other); err != nil {
t.Fatalf("a distinct attempt could not record its own response: %v", err)
}
if mine, err := st.ListContextManifests("alice", "run-2"); err != nil || len(mine) != 0 {
t.Fatalf("another owner's response became visible: %+v err=%v", mine, err)
}
}
func TestContextManifestDoesNotPersistWorkflowOutputInResolveScope(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "scope-privacy.db"))
if err != nil {
+11
View File
@@ -148,6 +148,10 @@ func (s *Store) SaveProjectAuditLink(link ProjectAuditLink) (ProjectAuditLink, e
// ProjectAuditLinkedReceipt reads one exact association and the underlying
// host row. It does not enumerate private links or decide field disclosure.
// The published binding is immutable: a row that was deleted, or edited after
// publication (state, operation, owner, or the stored envelope), no longer
// resolves as this link. A read must never project something the domain
// verifier never vouched for, so drift is refused instead of re-projected.
func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string, hostOffset int64) (ProjectAuditLink, ProjectCommandAuditRow, error) {
var row ProjectCommandAuditRow
var link ProjectAuditLink
@@ -157,6 +161,7 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string
}
err := s.db.QueryRow(s.q(`SELECT `+projectAuditLinkAliasedColumns+`,a.actor_name,a.payload FROM project_audit_links l
JOIN command_audit a ON a.id=l.host_audit_offset AND a.owner=l.owner AND a.project_id=l.project_id
AND a.state='committed' AND a.operation_id=l.operation_id
WHERE l.project_id=? AND l.source_kind=? AND l.source_id=? AND l.host_audit_offset=?`),
projectID, sourceKind, sourceID, hostOffset).Scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner,
&link.SourceKind, &link.SourceID, &link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID,
@@ -171,5 +176,11 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string
if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil {
return link, row, err
}
// The link was published only against a committed row owned by link.Owner whose
// envelope carried this operation. Re-check the envelope itself: a payload that
// no longer matches the verified binding is drift, not a new fact.
if row.Envelope.OperationID != link.OperationID || row.Envelope.Actor != link.Owner || string(row.Envelope.State) != "committed" {
return link, row, ErrConflict
}
return link, row, nil
}
@@ -1,7 +1,9 @@
package store
import (
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
@@ -95,3 +97,84 @@ func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) {
t.Fatalf("ambiguous host operation must not choose one: %v", err)
}
}
// 已发布链接的绑定不可变:宿主回执在发布后被删或被改(状态、operation、载荷)
// 时,读取侧不得再把它当作领域验证过的那一条返回。SQLite 是本测试的落点——
// PostgreSQL 由 all-writer 触发器在写侧直接拒绝改动。
func TestProjectAuditLinkedReceiptRefusesEditedOrDeletedHostRows(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "links.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
p, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
c, err := st.CreateConversation("alice", string(p.ID), "work", "")
if err != nil {
t.Fatal(err)
}
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm",
OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
t.Fatal(err)
}
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
link := ProjectAuditLink{HostAuditOffset: rows[0].Offset, ProjectID: string(p.ID), Owner: "alice", SourceKind: "domain-commit",
SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
if _, err := st.SaveProjectAuditLink(link); err != nil {
t.Fatal(err)
}
if got, audit, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); err != nil ||
got.OperationID != "op-1" || audit.Envelope.CommandID != "rdm.confirm" {
t.Fatalf("baseline link: %+v %+v %v", got, audit, err)
}
var stored string
if err := st.db.QueryRow(st.q(`SELECT payload FROM command_audit WHERE id=?`), rows[0].Offset).Scan(&stored); err != nil {
t.Fatal(err)
}
forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm",
OperationID: "op-forged", ConversationID: string(c.ID), State: agentcommand.StateCommitted})
if err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), string(forged), rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrConflict) {
t.Fatalf("edited envelope still resolved as the verified link: %v", err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), stored, rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-other", rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
t.Fatalf("edited operation column still resolved: %v", err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-1", rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "accepted", rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
t.Fatalf("uncommitted row still resolved: %v", err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "committed", rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET project_id=? WHERE id=?`), "", rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
t.Fatalf("re-attributed row still resolved: %v", err)
}
}
File diff suppressed because it is too large Load Diff
+494
View File
@@ -758,3 +758,497 @@ func TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean(t *testing.T) {
t.Fatalf("legacy audit row removed: %d err=%v", legacy, err)
}
}
// =====================================================================
// Tests for the ported batch entry points (feature/iam-perm-ux-backend).
// Each test stays inside main's narrowed role model (only project.create is
// role authority) and reuses the existing account-scoped write fences.
// =====================================================================
// enableRoleOne creates a role with its project.create boolean turned on
// and returns the role after the receipt. The test owns the idempotency
// key so multiple batch steps stay reproducible.
func enableRoleOne(t *testing.T, st *Store, actor, name string) RoleRecord {
t.Helper()
role, err := st.CreateRole(actor, RoleCreate{Name: name})
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions(actor, role.RoleID, role.RoleVersion,
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
"enable-"+role.RoleID); err != nil {
t.Fatal(err)
}
current, err := st.GetRole(role.RoleID)
if err != nil {
t.Fatal(err)
}
return current
}
// TestPatchRolePermissionsBatchEnablesAndDisablesAcrossRoles covers the
// happy path: two roles get their project.create boolean toggled in one
// transaction with one authority epoch step.
func TestPatchRolePermissionsBatchEnablesAndDisablesAcrossRoles(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
first := enableRoleOne(t, st, "admin", "批量职位一")
second := enableRoleOne(t, st, "admin", "批量职位二")
epochBefore, _ := st.AuthorityEpoch()
disable := RolePermissionChange{ActionID: "project.create", Allowed: false, CapAllowed: true}
batch, err := st.PatchRolePermissionsBatch("admin",
[]RolePermissionPatchInput{
{RoleID: first.RoleID, ExpectedRoleVersion: first.RoleVersion, Changes: []RolePermissionChange{disable}},
{RoleID: second.RoleID, ExpectedRoleVersion: second.RoleVersion, Changes: []RolePermissionChange{disable}},
}, "batch-disable")
if err != nil {
t.Fatal(err)
}
if batch.AuthorityEpoch != epochBefore+1 || len(batch.Receipts) != 2 {
t.Fatalf("batch=%+v epochBefore=%d", batch, epochBefore)
}
for _, receipt := range batch.Receipts {
if receipt.PreviousRoleVersion == receipt.RoleVersion {
t.Fatalf("role_version not advanced: %+v", receipt)
}
}
for _, roleID := range []string{first.RoleID, second.RoleID} {
view, err := st.RoleAdmin(roleID)
if err != nil || view.ProjectAccess {
t.Fatalf("role %s still has project access: %+v err=%v", roleID, view, err)
}
}
}
// TestPatchRolePermissionsBatchAtomicityRejectsWholeBatch ensures one bad
// item rolls back the whole batch: the surviving role keeps its prior
// state and the authority epoch does not advance.
func TestPatchRolePermissionsBatchAtomicityRejectsWholeBatch(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
first := enableRoleOne(t, st, "admin", "回滚职位一")
second := enableRoleOne(t, st, "admin", "回滚职位二")
epochBefore, _ := st.AuthorityEpoch()
bogus := []RolePermissionPatchInput{
{RoleID: first.RoleID, ExpectedRoleVersion: first.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}},
{RoleID: second.RoleID, ExpectedRoleVersion: second.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "system.users.view", Allowed: true, CapAllowed: true}}},
}
if _, err := st.PatchRolePermissionsBatch("admin", bogus, "batch-bad"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("bogus batch accepted: %v", err)
}
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
t.Fatalf("rejected batch advanced epoch: before=%d after=%d", epochBefore, epoch)
}
for _, roleID := range []string{first.RoleID, second.RoleID} {
view, err := st.RoleAdmin(roleID)
if err != nil || !view.ProjectAccess {
t.Fatalf("role %s lost project access after rejection: %+v err=%v", roleID, view, err)
}
}
}
// TestPatchRolePermissionsBatchStaleVersion rolls back the batch and
// refuses when one role's expected version mismatches.
func TestPatchRolePermissionsBatchStaleVersion(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
first := enableRoleOne(t, st, "admin", "过期职位一")
second := enableRoleOne(t, st, "admin", "过期职位二")
disable := RolePermissionChange{ActionID: "project.create", Allowed: false, CapAllowed: true}
if _, err := st.PatchRolePermissionsBatch("admin",
[]RolePermissionPatchInput{
{RoleID: first.RoleID, ExpectedRoleVersion: first.RoleVersion, Changes: []RolePermissionChange{disable}},
{RoleID: second.RoleID, ExpectedRoleVersion: second.RoleVersion + 9, Changes: []RolePermissionChange{disable}},
}, "batch-stale"); !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrConflict) {
t.Fatalf("stale batch accepted: %v", err)
}
for _, roleID := range []string{first.RoleID, second.RoleID} {
view, err := st.RoleAdmin(roleID)
if err != nil || !view.ProjectAccess {
t.Fatalf("role %s lost project access: %+v err=%v", roleID, view, err)
}
}
}
// TestPatchRolePermissionsBatchRejectsLimitAndIdempotency enforces the 20-item
// batch ceiling and the idempotency replay contract.
func TestPatchRolePermissionsBatchRejectsLimitAndIdempotency(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
enabled := enableRoleOne(t, st, "admin", "上限职位")
disable := RolePermissionChange{ActionID: "project.create", Allowed: false, CapAllowed: true}
oversized := make([]RolePermissionPatchInput, maxRolePermissionBatch+1)
for i := range oversized {
oversized[i] = RolePermissionPatchInput{RoleID: enabled.RoleID, ExpectedRoleVersion: 1, Changes: []RolePermissionChange{disable}}
}
if _, err := st.PatchRolePermissionsBatch("admin", oversized, "oversize"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("oversized batch accepted: %v", err)
}
if _, err := st.PatchRolePermissionsBatch("admin", nil, "empty"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("empty batch accepted: %v", err)
}
first, err := st.PatchRolePermissionsBatch("admin",
[]RolePermissionPatchInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion, Changes: []RolePermissionChange{disable}}},
"idempotency-key")
if err != nil {
t.Fatal(err)
}
replay, err := st.PatchRolePermissionsBatch("admin",
[]RolePermissionPatchInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion, Changes: []RolePermissionChange{disable}}},
"idempotency-key")
if err != nil || replay.AuthorityEpoch != first.AuthorityEpoch || replay.ReceiptID != first.ReceiptID {
t.Fatalf("replay=%+v first=%+v err=%v", replay, first, err)
}
}
// TestPreviewRolePermissionsMatchesCommitResult keeps the read surface honest:
// the preview's "after" state is exactly what the matching commit produces.
func TestPreviewRolePermissionsMatchesCommitResult(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
enabled := enableRoleOne(t, st, "admin", "预览职位")
previews, err := st.PreviewRolePermissions("admin",
[]RolePermissionPreviewInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}}})
if err != nil {
t.Fatal(err)
}
if len(previews) != 1 || previews[0].RoleID != enabled.RoleID || previews[0].VersionMatches != true {
t.Fatalf("preview=%+v", previews)
}
commit, err := st.PatchRolePermissionsBatch("admin",
[]RolePermissionPatchInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}}},
"preview-commit")
if err != nil {
t.Fatal(err)
}
if commit.Receipts[0].RoleVersion <= enabled.RoleVersion {
t.Fatalf("commit=%+v", commit)
}
}
// TestPreviewRolePermissionsRejectsInvalidBatchItem returns the item-level
// rejection in the preview without writing, exactly mirroring the commit gate.
func TestPreviewRolePermissionsRejectsInvalidBatchItem(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
enabled := enableRoleOne(t, st, "admin", "预览拒绝职位")
previews, err := st.PreviewRolePermissions("admin",
[]RolePermissionPreviewInput{
{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "system.users.view", Allowed: true, CapAllowed: true}}},
{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}},
})
if err != nil {
t.Fatal(err)
}
if len(previews) != 2 || previews[0].Error == "" || previews[0].ErrorActionID != "system.users.view" {
t.Fatalf("previews=%+v", previews)
}
if previews[1].Error != "" {
t.Fatalf("second item reported error without writing: %+v", previews[1])
}
if view, _ := st.RoleAdmin(enabled.RoleID); !view.ProjectAccess {
t.Fatalf("preview mutating role: %+v", view)
}
}
// TestAssignUserRolesBatchRebindsInOneTransaction covers the rebind happy
// path: two users get a new role in one transaction with one authority epoch.
func TestAssignUserRolesBatchRebindsInOneTransaction(t *testing.T) {
st := openTestStore(t)
admin, alice, bob := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "批量职位"})
if err != nil {
t.Fatal(err)
}
epochBefore, _ := st.AuthorityEpoch()
batch, err := st.AssignUserRolesBatch("admin",
[]UserRoleAssignmentInput{
{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion},
{UserID: bob.ID, RoleID: role.RoleID, ExpectedStateVersion: bob.StateVersion},
}, "assign-batch")
if err != nil {
t.Fatal(err)
}
if batch.AuthorityEpoch != epochBefore+1 || len(batch.Bindings) != 2 {
t.Fatalf("batch=%+v", batch)
}
for _, userID := range []string{alice.ID, bob.ID} {
view, err := st.RoleForUser(userID)
if err != nil || view.Binding == nil || view.Role == nil || view.Role.RoleID != role.RoleID {
t.Fatalf("user %s not bound: %+v err=%v", userID, view, err)
}
}
// Replay returns the same receipt.
replay, err := st.AssignUserRolesBatch("admin",
[]UserRoleAssignmentInput{
{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion},
{UserID: bob.ID, RoleID: role.RoleID, ExpectedStateVersion: bob.StateVersion},
}, "assign-batch")
if err != nil || replay.AuthorityEpoch != batch.AuthorityEpoch {
t.Fatalf("replay=%+v err=%v", replay, err)
}
_ = admin
}
// TestAssignUserRolesBatchRejectsWholeBatch ensures a single rejected item
// (stale state) rolls back both rebinds; the prior active binding stays.
func TestAssignUserRolesBatchRejectsWholeBatch(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
roleOne, _ := st.CreateRole("admin", RoleCreate{Name: "职位Alpha"})
roleTwo, _ := st.CreateRole("admin", RoleCreate{Name: "职位Beta"})
initialOne, err := st.AssignUserRole("admin", alice.ID, roleOne.RoleID, alice.StateVersion, "assign-alice-init")
if err != nil {
t.Fatal(err)
}
initialTwo, err := st.AssignUserRole("admin", bob.ID, roleTwo.RoleID, bob.StateVersion, "assign-bob-init")
if err != nil {
t.Fatal(err)
}
epochBefore, _ := st.AuthorityEpoch()
bad := []UserRoleAssignmentInput{
{UserID: alice.ID, RoleID: roleTwo.RoleID, ExpectedStateVersion: initialOne.UserStateVersion},
{UserID: bob.ID, RoleID: roleOne.RoleID, ExpectedStateVersion: bob.StateVersion + 99},
}
if _, err := st.AssignUserRolesBatch("admin", bad, "batch-bad"); !errors.Is(err, ErrConflict) {
t.Fatalf("bad batch accepted: %v", err)
}
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
t.Fatalf("rejected batch advanced epoch: before=%d after=%d", epochBefore, epoch)
}
for _, check := range []struct {
userID string
binding RoleBinding
roleID string
previous string
}{
{alice.ID, initialOne, roleOne.RoleID, alice.ID},
{bob.ID, initialTwo, roleTwo.RoleID, bob.ID},
} {
view, err := st.RoleForUser(check.userID)
if err != nil || view.Binding == nil || view.Role == nil || view.Role.RoleID != check.roleID {
t.Fatalf("user %s rebound across rollback: %+v err=%v", check.userID, view, err)
}
// The rolled-back batch also leaves the earlier binding record
// itself untouched: same binding id and user state version.
if view.Binding.BindingID != check.binding.BindingID || view.Binding.UserStateVersion != check.binding.UserStateVersion {
t.Fatalf("user %s binding rewritten across rollback: got %+v want %+v", check.userID, view.Binding, check.binding)
}
_ = check.previous
}
}
// TestPreviewUserRoleAssignmentsJudgesEachItem returns one preview per
// input; mismatches carry reason text without writing rows or receipts.
// A batch names each user at most once (validateRoleAssignmentItems), so
// the three judgements are asked of three distinct users.
func TestPreviewUserRoleAssignmentsJudgesEachItem(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "预览分配"})
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-alice"); err != nil {
t.Fatal(err)
}
alice, _ = st.GetAccount(alice.ID)
epochBefore, _ := st.AuthorityEpoch()
previews, err := st.PreviewUserRoleAssignments("admin",
[]UserRoleAssignmentInput{
{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion},
{UserID: bob.ID, RoleID: role.RoleID, ExpectedStateVersion: bob.StateVersion + 99},
{UserID: "user_missing", RoleID: role.RoleID, ExpectedStateVersion: 1},
})
if err != nil {
t.Fatal(err)
}
if len(previews) != 3 {
t.Fatalf("previews=%+v", previews)
}
if previews[0].Error != "" || previews[0].CurrentRoleID != role.RoleID {
t.Fatalf("fresh match: %+v", previews[0])
}
if previews[1].Error != roleAssignmentStateChanged {
t.Fatalf("stale match: %+v", previews[1])
}
if previews[2].Error != roleAssignmentUserMissing {
t.Fatalf("missing user: %+v", previews[2])
}
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
t.Fatalf("preview advanced epoch: before=%d after=%d", epochBefore, epoch)
}
// The commit's first item still passes; rebinding still works.
if _, err := st.AssignUserRolesBatch("admin",
[]UserRoleAssignmentInput{{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion}},
"preview-then-commit"); err != nil {
t.Fatalf("commit after preview: %v", err)
}
}
// TestListRoleMembersForActorRequiresGrantsAndPaginates exercises the read
// surface: only callers with system.users.view + system.roles.view see
// members, and active-vs-inactive counts follow the binding table.
func TestListRoleMembersForActorRequiresGrantsAndPaginates(t *testing.T) {
st := openTestStore(t)
admin, alice, bob := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "成员职位"})
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-alice"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", bob.ID, role.RoleID, bob.StateVersion, "assign-bob"); err != nil {
t.Fatal(err)
}
// Re-assigning alice ends her previous binding, leaving bob as the only
// active member for the second assignment.
bobAccount, _ := st.GetAccount(bob.ID)
other, err := st.CreateRole("admin", RoleCreate{Name: "另一职位"})
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, other.RoleID,
alice.StateVersion+1, "assign-alice-other"); err != nil {
t.Fatal(err)
}
if _, _, err := st.ListRoleMembersForActor("ordinary", role.RoleID); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("ordinary caller accepted: %v", err)
}
members, _, err := st.ListRoleMembersForActor("admin", role.RoleID)
if err != nil {
t.Fatal(err)
}
if len(members) != 1 || members[0].UserID != bobAccount.ID || !members[0].Enabled {
t.Fatalf("members=%+v", members)
}
_ = admin
}
// TestActiveRolesForUsersReturnsActiveBindings only returns the active
// role for each user; an ended binding is absent.
func TestActiveRolesForUsersReturnsActiveBindings(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
roleOne, _ := st.CreateRole("admin", RoleCreate{Name: "现役"})
roleTwo, _ := st.CreateRole("admin", RoleCreate{Name: "退役"})
if _, err := st.AssignUserRole("admin", alice.ID, roleOne.RoleID, alice.StateVersion, "assign-alice-1"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", bob.ID, roleTwo.RoleID, bob.StateVersion, "assign-bob-2"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, roleTwo.RoleID, alice.StateVersion+1, "assign-alice-2"); err != nil {
t.Fatal(err)
}
roles, err := st.ActiveRolesForUsers([]string{alice.ID, bob.ID, "user_missing"})
if err != nil {
t.Fatal(err)
}
if roles[alice.ID].RoleID != roleTwo.RoleID || roles[bob.ID].RoleID != roleTwo.RoleID {
t.Fatalf("active roles=%+v want alice=%s bob=%s", roles, roleTwo.RoleID, roleTwo.RoleID)
}
if _, present := roles["user_missing"]; present {
t.Fatalf("phantom user active: %+v", roles)
}
}
// TestCreateRoleCopyAndAssignCreatesRoleAndBindsInOneTransaction is the happy
// path: the copy lands as a new role, picks up any override, and rebinds the
// assigned users in one transaction with one authority epoch step.
func TestCreateRoleCopyAndAssignCreatesRoleAndBindsInOneTransaction(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
source, err := st.CreateRole("admin", RoleCreate{Name: "源职位"})
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", source.RoleID, source.RoleVersion,
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
"enable-source"); err != nil {
t.Fatal(err)
}
source, _ = st.GetRole(source.RoleID)
epochBefore, _ := st.AuthorityEpoch()
result, err := st.CreateRoleCopyAndAssign("admin",
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
RoleCreate{Name: "复制职位"},
nil,
[]RoleCopyAssignment{
{UserID: alice.ID, ExpectedStateVersion: alice.StateVersion},
{UserID: bob.ID, ExpectedStateVersion: bob.StateVersion},
}, "copy-1")
if err != nil {
t.Fatal(err)
}
if result.AuthorityEpoch != epochBefore+1 || len(result.Bindings) != 2 || result.Role.RoleID == source.RoleID {
t.Fatalf("result=%+v", result)
}
if !result.Role.ProjectAccess {
t.Fatalf("copy lost project access: %+v", result.Role)
}
for _, userID := range []string{alice.ID, bob.ID} {
view, err := st.RoleForUser(userID)
if err != nil || view.Role == nil || view.Role.RoleID != result.Role.RoleID {
t.Fatalf("user %s not bound to copy: %+v err=%v", userID, view, err)
}
}
}
// TestCreateRoleCopyAndAssignRejectsWithoutResidue keeps the fail-closed
// path: no role row, no bindings, no epoch advance when any item fails.
func TestCreateRoleCopyAndAssignRejectsWithoutResidue(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
source, err := st.CreateRole("admin", RoleCreate{Name: "源职位"})
if err != nil {
t.Fatal(err)
}
source, _ = st.GetRole(source.RoleID)
epochBefore, _ := st.AuthorityEpoch()
if _, err := st.CreateRoleCopyAndAssign("admin",
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
RoleCreate{Name: "复制失败"},
[]RolePermissionChange{{ActionID: "system.users.view", Allowed: true, CapAllowed: true}},
[]RoleCopyAssignment{{UserID: alice.ID, ExpectedStateVersion: alice.StateVersion}},
"copy-bad-overrides"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("bad overrides accepted: %v", err)
}
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
t.Fatalf("rejected copy advanced epoch: before=%d after=%d", epochBefore, epoch)
}
if _, err := st.CreateRoleCopyAndAssign("admin",
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
RoleCreate{Name: ""},
nil,
[]RoleCopyAssignment{{UserID: alice.ID, ExpectedStateVersion: alice.StateVersion}},
"copy-bad-name"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("bad name accepted: %v", err)
}
if _, err := st.CreateRoleCopyAndAssign("admin",
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
RoleCreate{Name: "复制失败"},
nil,
[]RoleCopyAssignment{{UserID: alice.ID, ExpectedStateVersion: 0}, {UserID: bob.ID, ExpectedStateVersion: bob.StateVersion}},
"copy-bad-state"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("bad state version accepted: %v", err)
}
if _, ok := getRoleByName(st, "复制失败"); ok {
t.Fatalf("rejected copy created a role row")
}
}
func getRoleByName(st *Store, name string) (RoleRecord, bool) {
roles, _, err := st.ListRoles(name, "exact", "all", "", "", 20)
if err != nil || len(roles) == 0 {
return RoleRecord{}, false
}
return roles[0].RoleRecord, true
}
+7
View File
@@ -558,6 +558,13 @@ func (r *hostRunner) readStepOutput(workDir, stepID string) string {
// renderRefs 运行期解析残留的 {{steps.X.output}} 占位符(宿主步实参/正文;
// workDir 为本步子工作区,经其上两级回到运行根再定位目标步产出)。
// 信任不变式(本函数不校验任何东西,只依赖调用顺序):直接调用方只有 doTool
// 与 doArtifact,二者所在的宿主步在 Run 里以 strict=true 先跑
// verifyPriorWorkflowOutputs,逐个 prompt 步调用 VerifyProjectStepOutput
// 重校验来源之后才会走到这里;strict 覆盖 wf.tool/wf.artifact/wf.script/
// wf.critic,即所有把上文送模型或落盘的目的步。因此 renderRefs 不会读到
// 未重校验的机器产出。新增不以 strict 运行的调用方(如 wf.card/wf.hitl
// 直读他步产出)会打破该不变式,必须在新增调用点自行校验,不得依赖本函数。
func (r *hostRunner) renderRefs(s, workDir string) string {
if !strings.Contains(s, "{{steps.") {
return s
+7 -1
View File
@@ -75,7 +75,13 @@ import (
// 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"
// 2.29.0-experimental: X2 project audit read completion. ProjectAuditQuery gains
// optional linked resource/run locators, IncludeBody, and Export; receipts may
// carry ProjectAuditReceiptResource only under the domain's per-object body
// right; ProjectAuditCoverage states Export scope. Every addition is an optional
// field or a new exported type: existing ProjectAuditHost/ProjectAuditRegistry
// providers and queries keep compiling and behaving unchanged.
const SurfaceVersion = "2.29.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
+58 -9
View File
@@ -74,6 +74,19 @@ type ProjectAuditDisclosure struct {
Body bool
}
// ProjectAuditDisclosureAuthorizer answers one per-object decision for the
// authenticated actor on a stored, immutable link. Metadata and Body are
// separate authorities: the host never substitutes one for the other, and it
// never answers a body request from a metadata-only right.
//
// TODO(RDM consumer, out of this repository): the decision itself — the current
// project audit.read grant, the document read duty plus stage scope that carries
// 正文权, and any export authority — belongs to the domain plugin that registers
// this callback. The Backend only refuses, trims, and reports coverage; it must
// not grow a local account/project policy to answer in the domain's place. A
// later RDM unit implements the callbacks and can then widen Coverage
// (Export, ResourceBodies, RunRelations) from this base's current honest
// "unsupported" statements.
type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error)
// ProjectAuditPublisherFactory is issued only by successful verifier
@@ -107,6 +120,11 @@ type ProjectAuditPublisher interface {
// database guards prove a
// committed prefix; otherwise the host rejects the read instead of claiming a
// fixed snapshot. See Coverage.CutoffConsistency.
//
// A read is keyed by the trusted project, the authenticated actor, the verified
// resource and run, and the source offset plus cutoff. Every caller-supplied
// field is a locator, never an authorization: the host re-checks it against
// stored host facts and the domain callbacks before a receipt is projected.
type ProjectAuditQuery struct {
ProjectID core.ID `json:"projectId"`
BeforeOffset int64 `json:"beforeOffset,omitempty"`
@@ -117,20 +135,48 @@ type ProjectAuditQuery struct {
LinkedSourceKind string `json:"linkedSourceKind,omitempty"`
LinkedSourceID string `json:"linkedSourceId,omitempty"`
LinkedHostOffset int64 `json:"linkedHostOffset,omitempty"`
// LinkedResourceKind/LinkedResourceID/LinkedRunID narrow the same exact
// locator to the verified binding. A value that disagrees with stored host
// state denies the read; it never widens it and never stands in for the
// domain's current decision on that object.
LinkedResourceKind string `json:"linkedResourceKind,omitempty"`
LinkedResourceID string `json:"linkedResourceId,omitempty"`
LinkedRunID string `json:"linkedRunId,omitempty"`
// IncludeBody asks for the per-object body projection of one verified link.
// The host asks the domain disclosure authorizer again for that object: a
// metadata-only right serves metadata only, and a request without a verified
// object binding is reported unsupported instead of a silently trimmed page.
IncludeBody bool `json:"includeBody,omitempty"`
// Export asks for a whole-history export. No base capability serves one, so
// the host refuses instead of degrading into a partial paginated read that
// would claim a coverage it cannot establish. See Coverage.Export.
Export bool `json:"export,omitempty"`
}
// ProjectAuditReceiptResource names the object one verified link attributes a
// receipt to. The host copies it from the stored binding and only when the
// domain granted the per-object body right; it is never derived from caller
// input and never carries resource content.
type ProjectAuditReceiptResource struct {
Kind string `json:"kind"`
ID string `json:"id"`
RunID string `json:"runId,omitempty"`
}
// HostAuditReceipt is a server-projected receipt, never a raw audit payload.
// ActorName is the name recorded with this command; old rows have no snapshot.
// Resource appears only for a body-permitted verified link.
type HostAuditReceipt struct {
SourceOffset int64 `json:"sourceOffset"`
Time time.Time `json:"time"`
ActorID string `json:"actorId,omitempty"`
ActorName string `json:"actorName,omitempty"`
ActorNameStatus string `json:"actorNameStatus"`
CommandID string `json:"commandId"`
State string `json:"state"`
OperationID string `json:"operationId,omitempty"`
ConversationID string `json:"conversationId"`
SourceOffset int64 `json:"sourceOffset"`
Time time.Time `json:"time"`
ActorID string `json:"actorId,omitempty"`
ActorName string `json:"actorName,omitempty"`
ActorNameStatus string `json:"actorNameStatus"`
CommandID string `json:"commandId"`
State string `json:"state"`
OperationID string `json:"operationId,omitempty"`
ConversationID string `json:"conversationId"`
Resource *ProjectAuditReceiptResource `json:"resource,omitempty"`
}
// ProjectAuditCoverage states the exact scope of this first X2 slice. Missing
@@ -144,6 +190,9 @@ type ProjectAuditCoverage struct {
ModelReceipts string `json:"modelReceipts"`
BackgroundReceipts string `json:"backgroundReceipts"`
ResourceBodies string `json:"resourceBodies"`
// Export states whether a whole-history export is served. An audit grant is
// not an export grant: this slice only pages, and says so.
Export string `json:"export"`
}
type ProjectAuditPage struct {
+99
View File
@@ -0,0 +1,99 @@
package extension
// W9 X2 读取面补完(2.29.0-experimental)的公开面契约:
// 1. 新增项全部是可选字段/新导出类型:既有 ProjectAuditHost、ProjectAuditRegistry
// 提供者与既有查询字面量照旧编译、照旧语义(不破坏旧 provider);
// 2. 新字段序列化带 omitempty:未使用的读取请求与旧消费者的线格式不变;
// 3. 正文投影与导出声明是显式契约字段,未开启时不下发。
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
core "agent-system-core"
)
// legacyAuditHost 是 2.14.0 形态的消费者:只实现既有方法,不感知新增可选字段。
type legacyAuditHost struct{}
func (legacyAuditHost) QueryProjectAudit(context.Context, ProjectAuditQuery) (ProjectAuditPage, error) {
return ProjectAuditPage{}, nil
}
// legacyAuditRegistry 是 2.14.0 形态的注册方实现。
type legacyAuditRegistry struct{}
func (legacyAuditRegistry) RegisterProjectAuditAuthorizer(ProjectAuditAuthorizer) error { return nil }
var (
_ ProjectAuditHost = legacyAuditHost{}
_ ProjectAuditRegistry = legacyAuditRegistry{}
)
func TestProjectAuditReadContractIsAdditiveAndOptional(t *testing.T) {
if SurfaceVersion < "2.29.0" {
t.Fatalf("SurfaceVersion must record the audit read completion, got %s", SurfaceVersion)
}
// 既有查询字面量(只填项目与游标)仍然可用,新增字段全部零值。
legacy := ProjectAuditQuery{ProjectID: core.ID("p1"), Limit: 10, BeforeOffset: 4, CutoffOffset: 9}
if legacy.IncludeBody || legacy.Export || legacy.LinkedResourceID != "" || legacy.LinkedRunID != "" {
t.Fatalf("optional fields defaulted to non-zero: %+v", legacy)
}
raw, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
for _, unwanted := range []string{"includeBody", "export", "linkedResourceKind", "linkedResourceId", "linkedRunId"} {
if strings.Contains(string(raw), unwanted) {
t.Fatalf("omitted optional field %s reached the wire: %s", unwanted, raw)
}
}
for _, want := range []string{`"projectId":"p1"`, `"beforeOffset":4`, `"cutoffOffset":9`, `"limit":10`} {
if !strings.Contains(string(raw), want) {
t.Fatalf("existing field %s missing from the wire form: %s", want, raw)
}
}
// 显式请求时字段可见,消费端能区分“正文/导出请求”与普通分页。
full, err := json.Marshal(ProjectAuditQuery{ProjectID: core.ID("p1"), IncludeBody: true, Export: true,
LinkedResourceKind: "document", LinkedResourceID: "doc-1", LinkedRunID: "run-1"})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"includeBody":true`, `"export":true`, `"linkedResourceKind":"document"`,
`"linkedResourceId":"doc-1"`, `"linkedRunId":"run-1"`} {
if !strings.Contains(string(full), want) {
t.Fatalf("requested field %s missing: %s", want, full)
}
}
// 未授予正文时回执不带 resource 字段;授予后才出现对象身份(不含正文载荷)。
metadataOnly, err := json.Marshal(ProjectAuditPage{Receipts: []HostAuditReceipt{{SourceOffset: 3, CommandID: "rdm.confirm"}}})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(metadataOnly), `"resource"`) {
t.Fatalf("metadata-only receipt carried a resource: %s", metadataOnly)
}
withBody, err := json.Marshal(ProjectAuditPage{Receipts: []HostAuditReceipt{{SourceOffset: 3, CommandID: "rdm.confirm",
Resource: &ProjectAuditReceiptResource{Kind: "document", ID: "doc-1", RunID: "run-1"}}},
Coverage: ProjectAuditCoverage{ResourceBodies: "verified-exact-link-body", Export: "unsupported"}})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"resource":{"kind":"document","id":"doc-1","runId":"run-1"}`, `"export":"unsupported"`} {
if !strings.Contains(string(withBody), want) {
t.Fatalf("body-permitted projection missing %s: %s", want, withBody)
}
}
// 四个审计哨兵互不相等:unsupported(缺基座能力)与 denied(策略拒绝)
// 不得互相冒充。
if !errors.Is(ErrProjectAuditUnsupported, ErrProjectAuditUnsupported) {
t.Fatal("unsupported sentinel must stay comparable with errors.Is")
}
for _, distinct := range []error{ErrProjectAuditDenied, ErrProjectAuditLinkConflict, ErrProjectAuditPending} {
if errors.Is(ErrProjectAuditUnsupported, distinct) || errors.Is(distinct, ErrProjectAuditUnsupported) {
t.Fatalf("audit sentinels must stay distinct: %v vs %v", distinct, ErrProjectAuditUnsupported)
}
}
}
+11
View File
@@ -100,6 +100,17 @@ type ProjectContextSourceRecheckResult struct{ Bundle ProjectContextBundle }
// ProjectContextSourceRechecker is an optional method of the same registered
// ProjectContextProvider. Project workflow reuse fails closed without it.
//
// Fail-closed comparison contract: Backend compares the returned Bundle with
// the recorded receipt as a set of stable identities, never as provider-ordered
// output and never by the provider's own echo. Every recorded Included entry
// must be answered by exactly one item with the same SourceID, Revision and
// Hash whose text still hashes to the recorded InputSHA256; every recorded
// OfferedReference and ReadEvidence must be answered by the same
// (ID, SourceID, Revision, Hash); Missing must be equal as a set, so a benign
// reordering is accepted while an added, dropped or replaced entry is refused.
// Anything unproven, extra or absent rejects; reordering never widens
// authorization.
type ProjectContextSourceRechecker interface {
Recheck(context.Context, ProjectContextSourceRecheckRequest) (ProjectContextSourceRecheckResult, error)
}
+1
View File
@@ -110,6 +110,7 @@ ProjectAuditPublisher.PublishProjectAuditLink
ProjectAuditPublisherFactory
ProjectAuditPublisherFactory.ForOwner
ProjectAuditQuery
ProjectAuditReceiptResource
ProjectAuditRegistry
ProjectAuditRegistry.RegisterProjectAuditAuthorizer
ProjectContextBundle