Compare commits

...
Author SHA1 Message Date
weichen 35c6995546 Fix cross-action slot inheritance and fail-closed cancel errors
Why: review blockers B1/M2 - Suspend inherited the previous action's
accumulated slots into a new action, and applyCancel swallowed store
errors so an abandoned fill could become an unconfirmed real cancel.
Evidence: review-a1-run.json findings fixed; intentsession/server
tests pass with the reviewer's sequences (verify-a1fix.json).
Impact: different-action suspend starts fresh; non-stale cancel store
errors fail closed; all other flows unchanged.
Refs: feature/intent-multi-turn-a1
2026-10-22 11:27:45 +00:00
weichen 9ee752c380 Add intent_session to the storage-migrate manifest
Why: the A1 session table joined the schema but the backup tool's
copyOrder list lagged; the coverage guard caught it.
Evidence: storage-migrate tests pass (verify-a1sr.json, exit=0).
Impact: backup tool manifest only; existing table semantics unchanged.
Refs: feature/intent-multi-turn-a1
2026-10-22 10:41:58 +00:00
weichen c2e3104970 Route the cancel intent through cmdbus with explicit targets
Why: A1 - the cancel library command was declined as host-unconsumed,
so "取消刚才的任务" could not act on a real run.
Evidence: route/server/intentsession tests pass (verify-a1c.json);
explicit-id, session-bound, ambiguous and cross-owner cases added;
confirmation card semantics unchanged.
Impact: cancel decisions dispatch via the existing host path; other
intents unchanged.
Refs: feature/intent-multi-turn-a1
2026-10-22 10:04:23 +00:00
weichen 0fb64c488e Add conversation-scoped intent sessions for multi-turn closure
Why: A1 - the host declined set_slot/change_slot/cancel and never kept
pending expectations, so multi-turn clarifications could not complete
an action.
Evidence: intentsession/store/server tests pass (verify-a1b.json);
TTL, revision, isolation, expiry and legacy-behavior cases added.
Impact: new table + service; chat flow suspends/fills expectations;
cmdbus confirmation semantics untouched (U-A1c routes cancel next).
Refs: feature/intent-multi-turn-a1
2026-10-22 09:30:25 +00:00
weichen a75783fd2d Merge commit '7bcbbdb37684' into HEAD 2026-10-22 07:10:53 +00:00
weichen 92c2b4eb12 Pin the nested agent-squad to the followups tip
Why: composition consistency - Dev pins agent-squad at the nested
native M5 bump; the backend container's nested copy must match
(check-pins).
Evidence: pin only.
Impact: nested gitlink pointer change.
Refs: feature/audit-followups-pin-gate-m5
2026-10-22 06:48:37 +00:00
weichen 7bcbbdb376 Merge pull request 'Qualify the role permission version upsert and wait for the second HITL card' (#246) from fix/pg-role-policy-version-and-hitl-wait into main
governance / governance (push) Successful in 21s
ci / vet-and-test (push) Successful in 1m29s
2026-10-22 06:42:24 +00:00
weichenandCursor 8cd4dd0ab7 Qualify the role permission version upsert and wait for the second HITL card
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m27s
Why: Dev composite test-pg failed: PostgreSQL rejects the unqualified
permission_version self-reference in the role_permissions upsert
(SQLSTATE 42702), and TestResolveHITLSecondRoundKeepsTerminalStatus read
the round-2 card before it was persisted.

Evidence: go test ./internal/store ./internal/runmgr pass and the HITL
test passes 20 consecutive runs locally on SQLite (SELF); PostgreSQL is
verified by the Dev composite test-pg job.

Impact: role_permissions.permission_version+1 on conflict; test-only
wait mirrors round 1. No other behavior change.

Refs: Agent-System-Backend#240, Agent-System-Dev#691 (test-pg run 4841);
agent-exec receipt u7-run (pi, success)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 06:40:12 +00:00
weichen 3ab7cd71a4 Pin the nested agent-native to the M5 fix
Why: composition consistency - Dev pins agent-native at the M5
preflight tip; the backend container's nested copy must match
(check-pins).
Evidence: pin only; adapters/native tests green on the new pin.
Impact: nested gitlink pointer change.
Refs: feature/audit-followups-pin-gate-m5
2026-10-22 06:26:13 +00:00
weichen fd69932c7d Merge pull request 'Land agent-stack audit batch-2 on main' (#245) from fix/agent-stack-audit-b2 into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Successful in 1m34s
2026-10-22 05:46:42 +00:00
weichen 443e7af1b8 Merge branch 'main' into fix/agent-stack-audit-b2
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m22s
2026-10-22 05:44:47 +00:00
weichen 029917e843 Merge pull request 'Add isolated scratch sessions (no-project general-agent chats)' (#241) from feature/scratch-session-general-agent into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Failing after 1m18s
2026-10-22 05:14:44 +00:00
weichen 9b40595728 Pin the nested agent-squad to the repaired batch-2 tip
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 1m35s
Why: composition consistency - the squad container advanced with the
frame-order repair; backend's nested copy (and its nested native) must
match so the composed chain carries the new event fields.
Evidence: pin only; squad and native verified per-unit.
Impact: nested gitlink pointer changes.
Refs: feature/agent-stack-audit-remediation-b2
2026-10-22 04:18:21 +00:00
weichen 7b8a34d1aa Synchronize the shared tool-run state behind the direct path
Why: the U11C concurrent event plumbing exposed a data race between
runToolCtx writes and callback reads; -race failed the package on
fc0a050 but passed on its parent.
Evidence: full-package race run green (verify-u11cr.json, exit=0).
Impact: synchronization only; no behavior change.
Refs: feature/agent-stack-audit-remediation-b2 U11c repair
2026-10-22 04:17:55 +00:00
weichenandCursor 2424f8e3f7 Merge origin/main into feature/scratch-session-general-agent
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m24s
Why: main advanced with agent-stack audit batch-1; resolve the Bus
struct conflict by keeping both hostProjectGuard and operationQuery.

Evidence: go test ./internal/cmdbus ./internal/app ./internal/runmgr
./internal/server ./internal/store pass locally (SELF).

Impact: scratch host guard retained alongside the new operation query
port; deps gitlinks match origin/main.

Refs: Agent-System-Backend#241
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 04:06:52 +00:00
weichenandCursor b704ba9897 Fail closed both ways when the scratch host lookup fails
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m23s
Why: Returning true from scratchProject on a lookup error closed the
isolation gates correctly, but the same helper also skipped RDM
observation and project context for a normal project during a
transient store failure.

Evidence: go test ./internal/runmgr ./internal/cmdbus ./internal/app
./internal/server ./internal/store pass locally (SELF); CI is authoritative.

Impact: confirmedScratchProject (lookup succeeded and marked scratch)
drives the relaxing gates; scratchProject (lookup error => true) keeps
driving the tightening ones.

Refs: Agent-System-Backend#241; agent-exec receipt u2d-run (pi, success)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 04:02:11 +00:00
weichen b4290c03bd Merge pull request 'Land the agent stack audit batch-1 fixes on main' (#244) from fix/agent-stack-audit-b1 into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Successful in 1m21s
2026-10-22 04:00:42 +00:00
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
weichenandCursor 2fc6e7e0a7 Gate project context and project commands for scratch hosts at the source
Why: Independent review found scratch runs still called the project
context resolver, and chat intent routing plus system.intent re-dispatch
could deliver rdm.* commands with a scratch host project.

Evidence: go test ./internal/runmgr ./internal/cmdbus ./internal/app
./internal/server ./internal/store pass locally (SELF); CI is authoritative.

Impact: ProjectContext returns nothing when project context is disabled;
Bus.Dispatch applies a host-project guard to every request carrying a host
project, rejecting rdm.* and formal project commands on scratch hosts;
scratch lookups that fail keep isolation gates closed.

Refs: Agent-System-Backend#241; agent-exec receipt u2c-run (pi, success)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 03:53:46 +00:00
weichen fc0a0505b3 Honor tool-call identity and real status in squad runner events
Why: F14 backend half - tool events used the tool name as the call id
and hardcoded status ok, so same-named tools could not be paired and
failures displayed as success; parallel callbacks shared artifact state.
Evidence: squad runner event tests pass (verify-u11c.json); pairing,
error mapping and legacy passthrough cases added; artifact list locked.
Impact: legacy empty-field events keep byte-identical output.
Refs: feature/agent-stack-audit-remediation-b2 U11c
2026-10-22 03:49:37 +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 f8e9e9f2d2 Pin nested agent-native and agent-squad to the batch-2 tips
Why: U11C consumes the new ProcessEvent CallID/Status/ErrorReason
fields; the backend container's nested copies must match the Dev pins
for U11c and check-pins.
Evidence: pin only; both libraries verified per-unit.
Impact: nested gitlink pointer changes.
Refs: feature/agent-stack-audit-remediation-b2
2026-10-22 03:25:54 +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
weichenandCursor f3f70a7978 Mark scratch conversations in conversation responses
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 1m27s
Why: The Frontend groups scratch sessions under a separate sidebar category and
needs an explicit marker instead of inferring it from the project list.
Evidence: agent-exec run 2d662c46 (pi, success); go test ./internal/store ./internal/server pass.
Impact: listTasks, listConversations, conversationDetail and POST /api/scratch-sessions
return scratch: true for scratch conversations; other conversations keep the same JSON (omitempty).
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 03:05:25 +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
weichenandCursor 613c068c79 Isolate scratch sessions from project-only capabilities
Why: A scratch session is a private general-agent chat; it must work for its
owner without the RDM provider and must not share documents or context with any
other conversation.
Evidence: agent-exec run fb04c4a9 (pi, success); go test ./internal/store ./internal/server ./internal/runmgr ./internal/app pass.
Impact: Owner work/read on scratch bypasses the domain provider; non-owners and
shared adapters get NotFound. Scratch runs skip project context, knowledge
retrieval, owner/project context entries, user/project memory, and cross-session
reads (both directions). Knowledge add/search 400, rdm.* and formal project
commands 403, settings writes 400, uploads skip indexing, and deleting the sole
conversation removes the scratch project.
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 02:53:31 +00:00
weichenandCursor 3b4a7af4b4 Add scratch sessions as inherent private no-project conversations
Why: Accounts without project.create need an isolated general-agent chat; each
scratch session is a private shell project (settings host.kind=scratch) with
exactly one cross-private conversation.
Evidence: agent-exec run 9b3d31a3 (pi, success); go test ./internal/store ./internal/server -run Scratch pass.
Impact: New POST /api/scratch-sessions (no project.create needed); project lists
omit scratch; second conversation under scratch 409; client writes cannot add or
remove the scratch marker; POST /api/projects rejects the marker.
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 02:16:15 +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
weichen 528e3a4c1f Merge pull request 'Require the caller's conversation when creating a surface' (#239) from fix/surface-conversation-required into main
governance / governance (push) Successful in 13s
ci / vet-and-test (push) Successful in 1m13s
2026-10-21 15:05:03 +00:00
agent-exec-transportandCursor 954e744d6d Require the caller's conversation when creating a surface
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m14s
POST /api/surfaces with an empty surfaceId now requires a conversationId
that belongs to the caller: empty returns 400, missing or another owner's
conversation returns 404. Revisions keep the previous behavior. Without
this check a direct API caller could create a project-less surface and
skip the project lifecycle gate on actions.

Why: an empty conversationId produced surfaces with no project ownership.
Evidence: agent-exec unit backend-surface-submit-conversation-required (pi, attempt 1 after authorized rerun, terminal=success); go test ./internal/server/... ./internal/surfacesvc/... .
Impact: HTTP create-surface validation only.
Refs: Dev task fix/svc-superadmin-envfile-surface-conv.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 15:02:15 +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
weichen 01f4c47a2b Merge pull request 'Backfill and archive three merged task cards (#212, #226, #237)' (#238) from fix/close-merged-task-cards into main
governance / governance (push) Successful in 13s
ci / vet-and-test (push) Successful in 1m14s
2026-10-21 12:03:12 +00:00
weichen a89a04f803 Backfill and archive three merged task cards
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m17s
Why: the three cards whose PRs are merged (Backend #212, #226, #237) were
still sitting at .task/ top level, which keeps the main checkout reporting
card debt. Only the status line and the merge fact were added; the historical
record inside each card is untouched.

Evidence: Gitea API for PRs 212/226/237 all report state=closed merged=true
(merge commits 56a7f9f440, 508288fbed, f084d10aa5); triage receipt
/tmp/be-triage-cardclose-20261021.json (verdict NEW).

Impact: .task bookkeeping only; no code change, no submodule pin change.
Refs: fix/close-merged-task-cards
2026-10-21 11:42:28 +00:00
weichen 57421f6683 chore(task): start fix/close-merged-task-cards 2026-10-21 11:42:07 +00:00
weichen f084d10aa5 Merge pull request 'Fix run-status write-ordering race in ResolveHITL (U7 multi-instance flake)' (#237) from fix/resolve-hitl-run-status-ordering into main
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 1m17s
2026-10-21 07:38:21 +00:00
weichen f39b683188 Record task authorization and agent receipt for the run-status fix
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m19s
Why: the card must record the scope, the (r1 scope-expansion -> granted
expansion -> r2 success) trajectory, and the rebase onto 241e1b3 before delivery.
Evidence: /tmp/be-runhitl-impl-r2-20261021.json (pi, success, transport 789f67f);
no PR/merge facts yet.
Impact: card metadata only; no behaviour change.
Refs: fix/resolve-hitl-run-status-ordering
2026-10-21 07:12:13 +00:00
agent-exec-transport 789f67f317 agent-exec transport d7af319ad1e36046a46be5ad16007582/d3f2d0786d5b 2026-10-21 07:10:20 +00:00
weichen cf9a0d4d75 chore(task): start fix/resolve-hitl-run-status-ordering 2026-10-21 07:10:20 +00:00
weichen 241e1b3c76 Merge pull request 'Tighten A01 final reserve and add final eval label' (#236) from feature/fast-slow-o2-a01-final-budget into main
governance / governance (push) Successful in 15s
ci / vet-and-test (push) Successful in 1m20s
2026-10-21 06:18:35 +00:00
agent-exec-transport aec92b7684 agent-exec transport bf732506478b02c63a7b4fe251f0ef0f/ba449849829e
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m18s
2026-10-21 06:08:09 +00:00
agent-exec-transport 9a6f0f880f agent-exec transport fb210f7fecc330e94bf619a5fb064a9c/3e52e52b4541 2026-10-21 05:38:29 +00:00
weichen 07228aa381 Merge pull request 'Announce final-only on every material tool result' (#235) from feature/fast-slow-o2-final-only-notice-all-tools into main
governance / governance (push) Successful in 15s
ci / vet-and-test (push) Successful in 1m22s
2026-10-21 04:40:07 +00:00
agent-exec-transport 52a3e497ed agent-exec transport 2aba0efd75f9b3a95cb01b82b7fcc45c/866c2dd62a06
governance / governance (pull_request) Successful in 13s
ci / vet-and-test (pull_request) Successful in 1m15s
2026-10-21 04:36:36 +00:00
weichen 7158bead16 Merge pull request 'Land the project access and lifecycle role and work-state work on Backend main' (#234) from feature/project-access-lifecycle-u3-backend-merge into main
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 1m15s
2026-10-21 04:06:34 +00:00
agent-exec-transport 284d3ac781 agent-exec transport 39075300fab9c8ac1d78ca2dd93074fd/55144c34e7ce
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m36s
2026-10-21 03:53:35 +00:00
weichen 0408a032c1 Merge the lifecycle role and project-state work into Backend main
Why: the project access and lifecycle units (U1 role narrowing to a single
project-access boolean, U2 creator authority, U3 member history, U4 project
work states) were implemented and pushed on this line but never landed on
Backend main, so Dev's pinned Backend does not contain them.

Evidence: merge is conflict-free against current origin/main; full
vet-and-test runs on the merge commit.

Impact: Backend only. RDM side is delivered separately.

Refs: Dev PR #634.
2026-10-21 03:47:35 +00:00
weichen e02e25a0ce Merge pull request 'Announce final-only in the material checkpoint tool result' (#233) from feature/fast-slow-o2-final-only-notice into main
governance / governance (push) Successful in 13s
ci / vet-and-test (push) Successful in 1m17s
2026-10-21 03:17:08 +00:00
agent-exec-transport 7867449920 agent-exec transport 3908146c8a3ed2d0c3967ffe7fdf69cf/9e8e3bf5a934
governance / governance (pull_request) Successful in 19s
ci / vet-and-test (pull_request) Successful in 1m12s
2026-10-21 02:58:14 +00:00
weichen 7a41f22ee1 Merge pull request 'W6 X1 source recheck: trusted per-source re-authorization' (#231) from feature/w6-x1-source-recheck into main
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 1m17s
2026-10-21 01:51:35 +00:00
weichen 5a4edf3bc1 Merge remote-tracking branch 'origin/main' into feature/w6-x1-source-recheck
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 1m28s
2026-10-21 01:48:57 +00:00
agent-exec-transport 65122b874c agent-exec transport 6147f5d6a94694710f90bbbb8dc92c2d/0ec99c9c36be
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 1m46s
2026-10-20 16:48:12 +00:00
weichen dfb31da20a Merge pull request 'Tighten work-checkpoint evidence guidance and allow one eval correction' (#232) from feature/fast-slow-o2-a01-evidence-guidance into main
governance / governance (push) Successful in 18s
ci / vet-and-test (push) Successful in 1m20s
2026-10-20 16:32:57 +00:00
agent-exec-transport 837480d4c1 agent-exec transport fe547395130f262af6f15a09d8a4aa2d/1f4b762f53be
governance / governance (pull_request) Successful in 23s
ci / vet-and-test (pull_request) Successful in 1m26s
2026-10-20 16:15:37 +00:00
agent-exec-transport 8c410b9ea4 agent-exec transport 412eff9b36afd457500606d468b89f94/0636a7e1c0f0
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 1m30s
2026-10-20 15:59:51 +00:00
weichen b4399e3000 Merge remote-tracking branch 'origin/main' into feature/w6-x1-source-recheck 2026-10-20 15:50:02 +00:00
agent-exec-transport bd640df8d0 agent-exec transport 70575de7333e033d2c2b5f7bc0eec883/362fcbe4d749 2026-10-20 13:13:39 +00:00
weichen 689e1768c7 Merge pull request 'Raise server e2e run-terminal wait windows for slow CI' (#230) from fix/server-e2e-terminal-wait into main
governance / governance (push) Successful in 16s
ci / vet-and-test (push) Successful in 1m21s
2026-10-20 12:53:17 +00:00
agent-exec-transport 58228c21f3 agent-exec transport d0da8a3d648b5dc044f808fb094916f8/2838ce0af93e
governance / governance (pull_request) Successful in 20s
ci / vet-and-test (pull_request) Successful in 1m45s
2026-10-20 12:44:00 +00:00
agent-exec-transport da1d9fa447 agent-exec transport db62fd9b2ada67370988679837bbb3d1/b712680caa58 2026-10-20 12:39:28 +00:00
weichen f18c265c5d Merge Backend main (align agent-native/agent-squad nested pins with the Dev composition) 2026-10-20 12:14:40 +00:00
weichen 74e5396861 Merge pull request 'Fail the paid eval fast when the route contract is not attached' (#229) from feature/fast-slow-o2-p1-eval-contract-failfast into main
governance / governance (push) Successful in 15s
ci / vet-and-test (push) Successful in 1m19s
2026-10-20 12:08:59 +00:00
weichen 43575816e5 Merge remote-tracking branch 'origin/main' into feature/fast-slow-o2-p1-eval-contract-failfast
governance / governance (pull_request) Successful in 20s
ci / vet-and-test (pull_request) Successful in 1m31s
2026-10-20 12:04:26 +00:00
agent-exec-transport ddb6e293b2 agent-exec transport 8b42e6435f6b297a2cdf67ab43c40db3/a83f1be549ed
governance / governance (pull_request) Successful in 20s
ci / vet-and-test (pull_request) Successful in 1m29s
2026-10-20 12:01:33 +00:00
weichen ed956a4d44 Merge pull request 'Add read-only role options, a member roster directory and a removed-member send refusal' (#228) from fix/multiuser-collab-followups into main
governance / governance (push) Successful in 22s
ci / vet-and-test (push) Successful in 1m30s
2026-10-20 12:00:22 +00:00
agent-exec-transport 612e04402d agent-exec transport be0c4744ff5899017313eb3f4fbd4a3f/43fe057c8332
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m43s
2026-10-20 11:57:53 +00:00
agent-exec-transport 424c611a36 agent-exec transport 5231be3441402d174e41c888e044ef05/78ce1b6b9dc4 2026-10-20 11:13:54 +00:00
agent-exec-transport 97c8cc8c23 agent-exec transport f7332a745364f439ad5fdc66fc3e3ec5/3040807e4420 2026-10-20 11:08:03 +00:00
weichen 4a38266320 Merge Backend main into the lifecycle line (narrowed role model wins per plan R2)
Why: compose the lifecycle line (role narrowing to a single project-access
boolean, U3b member-history read path) with Backend main (capability
association, explanation, super-admin). Per plan 2026-10-19-1440 R2/KTD2
the narrowed single-boolean write model supersedes the linked-cap expansion
and its association invariant; main non-conflicting work (explanation,
super-admin, tavily, O2 pins) is incorporated.

Impact: integration merge on the lifecycle branch; suites arbitrate.
2026-10-20 11:02:16 +00:00
weichen d8923f18ec Merge pull request 'Run the SQLite-only FTS deadlock step only on SQLite' (#227) from fix/search-deadlock-test-pg-dialect into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Successful in 1m20s
2026-10-20 09:28:57 +00:00
weichen 803d64e8a2 Merge remote-tracking branch 'origin/main' into fix/search-deadlock-test-pg-dialect
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m25s
2026-10-20 09:26:59 +00:00
agent-exec-transport 9b23713c18 agent-exec transport 245f757ebcf7a1f107feccad1ce631b8/1faa105e7d0b
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 1m22s
2026-10-20 09:21:01 +00:00
weichen 508288fbed Merge the bounded Backend CI test repair (#226)
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 1m18s
2026-10-20 09:18:31 +00:00
weichen 910d8e5fd2 Merge remote-tracking branch 'origin/main' into fix/ci-pg-flaky-tests
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 1m23s
2026-10-20 09:13:27 +00:00
weichen 570467879b Record the verified CI test repair for delivery
Why: Close the scoped repair after controlled transport and host verification.

Evidence: Delegated red/green checks, host repeated tests, vet and full tests pass.

Impact: Task record only; fixed-head CI remains required before merge.

Refs: Dev PR #651
2026-10-20 09:12:10 +00:00
agent-exec-transport 2ee469011e agent-exec transport 662a926e726431b930433273fc26f39a/e8392e75611f 2026-10-20 09:03:51 +00:00
weichen bb9274d737 Merge pull request 'Point agent-squad at the O2 nested agent-native pin' (#225) from feature/fast-slow-o2-i3-agent-squad-pin into main
governance / governance (push) Successful in 15s
ci / vet-and-test (push) Successful in 1m25s
2026-10-20 09:00:43 +00:00
agent-exec-transport 74f655c30a agent-exec transport 70cdf684ee429ce192b35a716fadf230/fcc4d2e0f387
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m29s
2026-10-20 08:58:18 +00:00
weichen e927694818 Record the reconciled CI repair workspace and scope
Why: Resume the authorized repair after the previous fail-stop without replaying it.

Evidence: Original receipt and event log retained; current governance checks pass.

Impact: Task metadata only; all implementation remains delegated.

Refs: Dev PR #651
2026-10-20 08:47:43 +00:00
weichen 944f60a2a3 Merge remote-tracking branch 'origin/main' into fix/ci-pg-flaky-tests 2026-10-20 08:46:20 +00:00
weichen c16495006d Merge pull request 'Attach the O2 route contract to an exactly configured model route' (#224) from feature/fast-slow-o2-i2-route-contract into main
governance / governance (push) Successful in 21s
ci / vet-and-test (push) Successful in 1m32s
2026-10-20 08:06:49 +00:00
agent-exec-transport 16f98b8e4f agent-exec transport 4792e7917ab99a9f3fd0e21823af20c0/c578cddeabbd
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 1m26s
2026-10-20 07:58:27 +00:00
weichen 07f7b2a9af chore(task): start fix/ci-pg-flaky-tests 2026-10-20 07:39:36 +00:00
agent-exec-transport e1a90a5d31 agent-exec transport 8eed31d0d96c50c2dfb6a834d78cb5e9/d97e9636ff2e
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m29s
2026-10-20 06:29:12 +00:00
weichen b5eb89c557 Merge pull request 'Fix shared retrieval deadlock, run start time and interrupted-run notice' (#223) from fix/multiuser-project-collab into main
governance / governance (push) Successful in 20s
ci / vet-and-test (push) Failing after 2m45s
2026-10-20 05:53:11 +00:00
agent-exec-transport d3380de4a0 agent-exec transport 47f1c6dec77e33ca7e6408150b0d3dd0/5116ee9fd8bf 2026-10-20 05:00:06 +00:00
weichen 5543a44859 Temporarily disable the failing antigravity profile for backend dispatch
Why: the Gemini endpoint behind agy dropped the final streaming response
with EOF in three consecutive attempts, each after ~30 minutes of completed
work; routing falls through to pi. Re-enable when the endpoint recovers.
Reason recorded on the U3 task card.

Impact: AGENT-EXECUTION.md only.

Refs: plan 2026-10-19-1440 U3.
2026-10-20 04:51:50 +00:00
weichen 377e965265 Merge branch 'main' into fix/multiuser-project-collab
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m15s
2026-10-20 04:18:07 +00:00
agent-exec-transport 60f37adc04 agent-exec transport 9b5caca2968f510a4f81402ca7dfdb4c/f6d16260d971
governance / governance (pull_request) Successful in 20s
ci / vet-and-test (pull_request) Successful in 1m27s
2026-10-20 03:51:12 +00:00
weichen adc2348052 Merge pull request 'IAM U5-Backend integration-tests on top of U1+U3A' (#222) from feature/iam-link-u1-capability-association into main
governance / governance (push) Successful in 16s
ci / vet-and-test (push) Successful in 1m14s
2026-10-20 03:33:55 +00:00
weichen 7437547dee Merge Backend main into L1 branch for U5-Backend sync
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 1m35s
2026-10-20 03:31:35 +00:00
weichen 001ba38f6d Temporarily disable the exhausted cursor profile for backend dispatch
Why: the cursor account hit its monthly usage limit (resets 2026-11-10);
attempts fail before useful work. Routing falls through to antigravity and
pi per the existing candidate order; re-enable when the quota resets.
Reason recorded on the U3 task card.

Impact: AGENT-EXECUTION.md only (rides the U3 PR, auditable).

Refs: plan 2026-10-19-1440 U3.
2026-10-20 03:23:19 +00:00
weichen 8050c6b289 Merge pull request 'Replace recovery proposals with administrator one-time passwords' (#221) from feature/auth-one-time-password-r2 into main
governance / governance (push) Successful in 19s
ci / vet-and-test (push) Successful in 1m12s
2026-10-20 01:56:46 +00:00
agent-exec-transport a3b67ee891 agent-exec transport 34e93ed2817e051b9b824abe27d8c08a/3eba67b6e84f
governance / governance (pull_request) Successful in 22s
ci / vet-and-test (pull_request) Successful in 1m15s
2026-10-20 01:45:06 +00:00
agent-exec-transport 4d1ede2adf agent-exec transport c2933fd3bec7cf8194e9e35ec557ecbf/133d229ce0aa 2026-10-20 01:35:08 +00:00
agent-exec-transport 1214ad43f7 agent-exec transport 9af431f5ca8df9d01679f14b09a2a50d/be45632be6b8 2026-10-19 16:32:29 +00:00
agent-exec-transport 03646b568b agent-exec transport 9a972977c78e74d4c92c9978a0a916ac/15be41c34e62 2026-10-19 15:59:23 +00:00
agent-exec-transport cf9c35aa0a agent-exec transport 875f5aca7406765a0c4913ffa0fbf707/3f6d66d1ffaa 2026-10-19 15:40:32 +00:00
weichen b351fd5d08 Merge pull request 'Make TestDSNFileMode independent of the process umask' (#220) from fix/nonblocking-followups into main
governance / governance (push) Successful in 19s
ci / vet-and-test (push) Successful in 1m27s
2026-10-19 14:31:22 +00:00
agent-exec-transport 965eda5786 agent-exec transport 4453a2e5e5465aca56a8081251ccd9ca/8b3678a73905
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m10s
2026-10-19 14:26:29 +00:00
weichen 3f4b34a5e8 Merge pull request 'IAM U1 + U3A capability-association + capability-explanation' (#219) from feature/iam-link-u1-capability-association into main
ci / vet-and-test (push) Successful in 1m26s
governance / governance (push) Successful in 19s
2026-10-19 13:47:25 +00:00
weichen e1af281e1e Merge Backend main into feature branch for host integration step 1/8
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m27s
2026-10-19 13:47:15 +00:00
weichen c0fa51d5d7 Merge pull request 'Load the super-admin config in the product composition root' (#218) from fix/link-super-admin-config into main
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 1m12s
2026-10-19 12:46:12 +00:00
agent-exec-transport 821599ffcb agent-exec transport 7e8283631fb518cee0db0eba1be4c918/79c99324c3c7
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 1m16s
2026-10-19 12:36:49 +00:00
agent-exec-transport e942a35b73 agent-exec transport 8315c523cef26780543b9b2130f4f4a4/5c8333ec4e92
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 1m18s
2026-10-19 12:21:32 +00:00
weichen 5578ad347f Merge pull request 'Fall back to system-row Tavily secret and regulations KB in knowledge_search' (#216) from fix/tavily-secret-kb-fallback into main
governance / governance (push) Successful in 19s
ci / vet-and-test (push) Successful in 1m18s
2026-10-19 10:19:40 +00:00
weichen f0336d6db9 Merge Backend main into tavily KB fallback
governance / governance (pull_request) Successful in 1m4s
ci / vet-and-test (pull_request) Successful in 2m1s
2026-10-19 10:00:30 +00:00
weichen bcbda8f148 Merge pull request 'Merge the accepted super-admin changes into main' (#217) from feature/super-admin-merge into main
governance / governance (push) Successful in 16s
ci / vet-and-test (push) Successful in 1m33s
2026-10-19 09:43:08 +00:00
weichen c8b1b7eb46 Merge feature/super-admin history onto main
governance / governance (pull_request) Successful in 40s
ci / vet-and-test (pull_request) Successful in 1m48s
2026-10-19 09:40:38 +00:00
agent-exec-transport 203516b870 agent-exec transport 64fcfc65eef6a9c12fe5ef9d4427a2bb/47019d8fae2f 2026-10-19 09:39:18 +00:00
weichen a141f044fa Merge Backend main into tavily KB fallback
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 2m9s
2026-10-19 09:37:45 +00:00
weichen 1e019a684f Merge pull request 'Deliver reliable sourced material checkpoints' (#193) from feature/fast-slow-u2-u3 into main
governance / governance (push) Successful in 15s
ci / vet-and-test (push) Successful in 1m16s
2026-10-19 09:26:49 +00:00
weichen f010af2ff4 Merge feature/fast-slow-u2-u3 history after syncing main
governance / governance (pull_request) Successful in 13s
ci / vet-and-test (pull_request) Successful in 1m11s
2026-10-19 09:24:59 +00:00
agent-exec-transport c65ba8eb6d agent-exec transport 2f80ef82aa46c436ab31ad04be4314d5/5a44c92c20c8 2026-10-19 09:24:56 +00:00
agent-exec-transport 2182a51aa1 agent-exec transport ed457bf5f823c43a1d72b50b1f3cfeef/8e408ea19f4b
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m15s
2026-10-19 09:24:43 +00:00
agent-exec-transport f24a6108c0 agent-exec transport e9d8f561ca338eeee85c28d94f01421d/875286e54f44
ci / vet-and-test (pull_request) Successful in 1m37s
governance / governance (pull_request) Successful in 19s
2026-10-19 05:23:49 +00:00
agent-exec-transport 9bcf1b5efb agent-exec transport ef2206bd1920ebcee5b2c856f366584d/92d3eda9d0d8
governance / governance (pull_request) Successful in 12s
ci / vet-and-test (pull_request) Successful in 1m10s
2026-10-19 03:53:58 +00:00
weichen c7b37e888a Merge pull request 'Expose the regulations provider to the chat model as built-in read-only tools' (#215) from feature/regulations-chat-tools into main
governance / governance (push) Successful in 25s
ci / vet-and-test (push) Successful in 1m38s
2026-10-19 03:46:53 +00:00
agent-exec-transport 5ffbd5c17e agent-exec transport 3f843a59bf0ba67b5da8afc678b2ae58/c62ed6222ef4
governance / governance (pull_request) Successful in 32s
ci / vet-and-test (pull_request) Successful in 1m41s
2026-10-19 03:44:34 +00:00
agent-exec-transport 992c36efc3 agent-exec transport 4d71b0c8fa88f2fd198634b9971b3ac6/4a10fd491b02
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m16s
2026-10-19 03:39:49 +00:00
agent-exec-transport 611cf1b0f7 agent-exec transport ed0695ad06374fcfa0dcd0d20527bd22/94d5f91fcaed
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m26s
2026-10-19 03:37:14 +00:00
weichen 63c2c227b6 Merge feature/fast-slow-u2-u3 history after syncing with main
governance / governance (pull_request) Successful in 12s
ci / vet-and-test (pull_request) Successful in 1m8s
2026-10-19 03:32:50 +00:00
agent-exec-transport 4cf3cc121e agent-exec transport cb58c2fc6b359ca164b81f439f4a431e/09c8a82fe0b2 2026-10-19 03:28:02 +00:00
agent-exec-transport 69177feb3b agent-exec transport e2fe8c1e9f3f7a12267d58526d14caee/2261db6dd595 2026-10-19 03:08:37 +00:00
agent-exec-transport afb65c5678 agent-exec transport dd2fa5d8f3774523c4c97a4afaa4360d/4fd52114d316 2026-10-19 03:08:12 +00:00
agent-exec-transport c5dcb84a42 agent-exec transport 2eb00297accbad8eeae1744d70a55363/91b105b7880c 2026-10-19 02:47:00 +00:00
agent-exec-transport 484b7fdf2a agent-exec transport c02cd0aaeb66ee1daf163331e6d123da/dffc8e4ac3b0 2026-10-19 02:19:01 +00:00
weichen 646dc57673 Merge pull request 'Replace schema-seeded role singletons during storage migration' (#214) from fix/storage-migrate-seeded-role-singletons into main
governance / governance (push) Successful in 13s
ci / vet-and-test (push) Successful in 1m6s
2026-10-18 14:52:42 +00:00
agent-exec-transport d8d7b8bbeb agent-exec transport e22dcba544696b290bc056e1a51e4132/df1c90387b2b
ci / vet-and-test (pull_request) Successful in 1m2s
governance / governance (pull_request) Successful in 17s
2026-10-18 14:50:13 +00:00
weichen 6594e1d21b Merge pull request 'Add Backend role policy authority for IAM U4' (#213) from feature/iam-u4-role-authority into main
governance / governance (push) Successful in 19s
ci / vet-and-test (push) Successful in 1m4s
2026-10-18 14:20:29 +00:00
weichenandCursor b4a19e784d Merge Backend main into IAM U4 role authority
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m5s
Why: the U4 branch is 89 commits behind main, which now carries the shared
document renderer, two-trees project work, the SQLite instance guard and
exec env layers (PR #209 merged as c76d8bd); it conflicted in storage
migrations, host capabilities and the extension surface. The user authorized
syncing U4 after the renderer line on 2026-10-18.
Evidence: content is the agent-exec transport 3b64eb5 (cursor worker, unit
backend-iam-u4-onto-main-c76d8bd-r1, run on a checkout whose tree equals main,
receipt /tmp/cleanup-20261018/render/u4-backend-onto-run.json, SELF). Host
check: main migrations 1-48 unchanged (migrate.go has no deleted lines versus
main), U4 migrations moved from 35-36 to 49-50 with identical SQL,
surface.txt is the sorted union, deps stay at main's pins.
Impact: databases that ran unmerged U4 builds with the old numbering must be
recreated. SurfaceVersion becomes 2.25.0-experimental. Main's admin password
account creation now requires the system.users.create action like the other
U4 account writes.
Refs: Backend main c76d8bd, PR #209

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 12:50:32 +00:00
weichen c76d8bdcfb Merge pull request 'Integrate the shared document renderer and two-trees project work' (#209) from feature/unified-document-renderer-task10 into main
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 1m5s
2026-10-18 12:41:31 +00:00
weichenandCursor 414747ce43 Record the W9 card closure status
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m11s
Why: the previous commit archived the card without its closure line.
Evidence: card text only.
Impact: none outside .task.
Refs: PR #209
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 12:39:49 +00:00
weichenandCursor 7b4e2791b5 Close the W9 project audit task card before merge
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m14s
Why: PR #209 delivers this W9 increment; the card must not stay open on main.
Evidence: Backend HEAD 84a6f6f CI vet-and-test and governance succeeded (GATE).
Impact: card archived; records the 37-48 migration renumbering and the user-owned v0.6.0 tag.
Refs: PR #209
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 12:39:44 +00:00
weichenandCursor 84a6f6ff56 Merge Backend main into the shared renderer and two-trees work
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m2s
Why: PR #209 is behind main by 14 commits (admin passwords, managed
interpreter path, U8 SQLite instance guard, EXT-20 regulations) and conflicted
in app wiring, host capabilities, migrations and the extension surface; the
user authorized merging the renderer line on 2026-10-18.
Evidence: content is the agent-exec transport 87a99ece (cursor worker, unit
backend-task10-sync-main-56a7f9f-r2, receipt
/tmp/cleanup-20261018/render/backend-sync-run2.json, SELF): gofmt, go build,
go vet and go test ./... (43 packages) passed. Host check: main migrations
1-36 unchanged, feature migrations moved from 36-47 to 37-48 in order with
identical SQL, surface.txt is the sorted union.
Impact: main's migration 36 instance_guard stays authoritative; databases that
ran unmerged feature builds with the old numbering must be recreated.
SurfaceVersion becomes 2.24.0-experimental.
Refs: PR #209, Backend main 56a7f9f

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 12:34:42 +00:00
weichen 56a7f9f440 Merge the EXT-20 shared regulations and web evidence extension point
governance / governance (push) Successful in 21s
ci / vet-and-test (push) Successful in 1m13s
Why: U9 base public contract for the regulations knowledge base.
Evidence: CI success on f3d2698d (after syncing main) (ci/vet-and-test, governance).
Impact: Additive optional capability; SurfaceVersion 2.15.0-experimental.
Refs: .task/feature-regulations-kb-ext20.md
2026-10-18 09:58:40 +00:00
weichen f3d2698dff Merge remote-tracking branch 'origin/main' into feature/regulations-kb-ext20
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m17s
2026-10-18 09:56:32 +00:00
weichenandCursor 499ad47153 Close the EXT-20 task card before merge
governance / governance (pull_request) Successful in 19s
ci / vet-and-test (pull_request) Successful in 1m11s
Why: Card closes in the same PR per delivery rules.
Evidence: Card only.
Impact: None.
Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/212
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 09:52:33 +00:00
weichenandCursor 3793366048 Record the EXT-20 implementation receipt and verification
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m14s
Why: Track U9 delivery evidence.
Evidence: Targeted vet/test PASS at aaed108; full suite only fails a pre-existing environment test.
Impact: Card only.
Refs: Agent-System-Dev feature/regulations-kb
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 09:51:50 +00:00
agent-exec-transport 276dd73824 agent-exec transport 473a7db9497fd0ab028ed664d4e26aec/87e91bc00054
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m0s
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 1m0s
2026-10-18 09:44:31 +00:00
agent-exec-transport aaed108d79 agent-exec transport cfc2e055f212789112cf11716f5649c5/c04b36e76696 2026-10-18 09:38:04 +00:00
weichen d7be3fde5c Merge branch 'main' into feature/u8-sqlite-instance-guard-r2
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 1m9s
2026-10-18 09:37:01 +00:00
weichen a670d2b246 Land admin password management and interpreter path fix pinned by Dev (#211)
governance / governance (push) Successful in 15s
ci / vet-and-test (push) Successful in 1m8s
2026-10-18 09:36:56 +00:00
weichen 78ae39702d Merge branch 'main' into fix/execenv-python-sandbox-path
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m4s
2026-10-18 09:35:30 +00:00
weichenandCursor 3187a009f6 Record the EXT-20 regulations provider task card
Why: Track U9 base contract delivery for the regulations KB plan.
Evidence: none (task record).
Impact: Task record only.
Refs: Agent-System-Dev .task/feature-regulations-kb.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 09:29:02 +00:00
agent-exec-transport 7b379d28e7 agent-exec transport d2c63b4a83821a5716997a28174233b1/d6e403e1e778
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m6s
2026-10-18 09:20:03 +00:00
weichenandCursor c34279ad55 Bump the renderer to 3f25999 and bind documents to published profile versions
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m2s
Why: Agent-Document-Renderer main publishes company.default@1.1.0 with a
declarative TOC (spec 04, acceptance 10 §4 §7) and keeps 1.0.0 loadable by
version. The fixed defaultProfileVersion "1.0.0" no longer matched
LoadCompanyDefaultContext (1.1.0), so every delivery failed with a profile
mismatch. New documents must take the latest published version, while
documents that already bind a published version keep rendering with exactly
that version, because published rules are immutable and old versions remain
retrievable (spec 08).

Evidence: go vet ./... ok; go test -count=1 ./... ok (all packages);
go run ./internal/docgen/internal/genrendereridentity -check ok;
scripts/agent-exec validate --repo . ok; scripts/dm governance check --repo .
ok; gofmt -l clean; git diff --cached --check clean. New tests cover the
default binding (1.1.0, TOC present), pinned 1.0.0 (no TOC) and 1.1.0 for
both a request pin and a persisted-Markdown replay, and refusal of unknown
versions and other profile IDs before any profile load.

Impact: docgen resolves the profile version from the persisted Markdown
binding or the request, defaults new documents to
profiles.LatestCompanyDefaultVersion, and loads it through
LoadCompanyDefaultVersionContext. The legacy migration comparison records the
declared TOC as an intentional corporate-layout difference
(RDR-T10-CMP-002) and keeps it out of the body visible-text dimension.
RendererIdentity is regenerated for 3f259990b5d4.

Refs: unified renderer plan Task 13; renderer PR #7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 06:24:36 +00:00
weichenandCursor bf8b912147 Merge origin/main to adopt the shared governance kit
Why: Bring feature/unified-document-renderer-task10 up to date with main
(PR #208 governance kit adoption: deps/development-guidelines and
deps/development-manager gitlinks, AGENT-EXECUTION.md, scripts, CI workflow).

Evidence: .gitmodules conflict resolved by keeping all three new submodule
entries (agent-document-renderer from this branch; development-guidelines and
development-manager from main). go vet ./... ok; go test -count=1 ./... ok
(42 packages); genrendereridentity -check ok; scripts/agent-exec validate ok;
scripts/dm governance check ok.

Impact: No code changes beyond main's governance files; renderer gitlink
stays at 1095077.

Refs: origin/main cac60c0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 01:13:40 +00:00
weichen b00e192440 Execute the managed interpreter through its real path
ci / vet-and-test (pull_request) Successful in 1m6s
Why: the sandbox hides /home and only remounts the resolved Python prefix, so executing python/current/bin/python3 failed before the base layer could be imported.
Evidence: go test ./internal/tools/ -run TestResolveInterpreterFollowsManagedSymlink passed, and env doctor against the built base layer printed doctor ok (SELF).
Impact: run_python and env doctor follow the symlink into the mounted prefix or /usr/bin/python3. The layer and the prefix mount stay the same.
Refs: Agent-System-Backend PR 207
Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 28544cfe5d)
2026-10-17 15:06:28 +00:00
weichen 5f65059bc2 Clarify admin reset contract 2026-10-17 11:56:33 +00:00
weichen b8b3493bb7 Enable admin password creation and reset 2026-10-17 10:00:56 +00:00
weichen cac60c0803 Merge pull request 'Adopt the shared governance kit (development-governance-v2)' (#208) from feature/governance-adoption into main
governance / governance (push) Successful in 16s
ci / vet-and-test (push) Successful in 1m2s
2026-10-17 08:11:39 +00:00
weichen aa3e3b12e6 Close and archive the governance adoption card
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m1s
Why: the adoption PR is reviewed and its candidate HEAD passed CI; the card closure is folded into the PR so no separate close PR is needed.
Evidence: CI GATE on 19e86b5150e019e45f86ee9a97c6dfa153654d06; independent Review REPLAY (codex exec --ephemeral(fresh_session=true,session id not-observable)VERDICT: APPROVE,无阻塞项).
Impact: task card only (moved to .task/archive/).
Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/208
2026-10-17 08:03:10 +00:00
weichen 19e86b5150 Record the adoption unit run and positive/negative evidence
governance / governance (pull_request) Successful in 23s
ci / vet-and-test (pull_request) Successful in 1m9s
Why: Phase 6 requires one positive executor run and the negative cases (missing config, bad receipt, out-of-scope path, unknown side effect) before adoption becomes active.
Evidence: .task/evidence/governance-adoption/ (SELF): governance check, agent-exec validate and adoption cases; this commit passed the generated pre-commit hook (admit).
Impact: task card and evidence logs only.
Refs: .task/feature-governance-adoption.md
2026-10-17 07:15:43 +00:00
agent-exec-transport 472e5ab313 agent-exec transport a1d7d74c9a1234bc70cf56b24de00ac9/385f4b22e1dd 2026-10-17 06:56:21 +00:00
weichen 39c17561e1 Bootstrap AGENT-EXECUTION.md and the governance adoption card
Why: development-governance-v2 Phase 6 adopts the shared governance kit in every formal repository; the executor needs a valid AGENT-EXECUTION.md before any implementation unit can run.
Evidence: generated by dm-server governance init at Development-Manager bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c; dm-server agent-exec validate passes.
Impact: configuration and task card only; no code or CI change yet.
Refs: Agent-System-Dev docs/superpowers/plans/2026-10-16-development-governance-v2-master.md
2026-10-17 06:53:54 +00:00
weichenandCursor 935c82276d Bump agent-document-renderer to 1095077 for node-located content diagnostics
Why: agent-document-renderer 1095077 (origin/main 10950774) adds node-located
content diagnostics (required_content_missing, intentionally_empty,
template_placeholder_left, mapping_fallback, signature_data_missing,
mapping_unresolved, mapping_ambiguous), a mapping Required flag, the
intentionally-empty model property, refusal of approved output with missing
required content or trusted signature data, expansion of empty
w:sdtContent so content lands inside content controls, and nested mapped
content controls rendered inside parent slots. Backend pinned 31fbe54.

Evidence: go generate ./internal/docgen and go run
./internal/docgen/internal/genrendereridentity -check ok; go vet ./...
exit 0; gofmt -l on changed Go files empty; go test -count=1 ./... exit 0
(42 packages ok, including renderer integration tests in internal/docgen);
renderer submodule go test -count=1 ./... ok at 10950774. New
TestRendererNodeDiagnosticsPassThroughUnchanged asserts renderer codes,
severities, NodeID and RulePath pass unchanged through the manifest and
DiagnosticError. git diff --cached --check clean.

Impact: Renderer identity and the published pin move to 10950774736b64b5d8e544ce03d45ae5a6ceca7c.
Backend only renders ordinary workspace working/preview documents; approved and
review modes still route to RDM before the renderer runs, so the approved
refusal paths are not reachable from Backend. Typical Backend documents produce
no new diagnostics and their DOCX output is unchanged in the test suite. Node
diagnostics on the failure path are still returned only as DiagnosticError
values and are not added to the operation receipt, which carries error text
only; that contract is unchanged.

Refs: unified renderer plan Tasks 10/13
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 05:24:24 +00:00
weichenandCursor 1afe164798 Pin published company default renderer and drop candidate wording
Why: agent-document-renderer 31fbe54 publishes company.default@1.0.0 with
review status approved and publicationAllowed true (owner decision
2026-10-16: A4, ~2.5cm margins, SimSun + Times New Roman 12pt body, SimHei
headings, no logo). Backend still hard-coded Candidate=true and every prompt,
skill and tool text called the default a pending-human-visual-review
candidate.

Evidence: go vet ./... exit 0; gofmt -l on changed packages empty;
go test -count=1 ./internal/docgen/... ./internal/prompts/...
./internal/skills/... ./internal/tools/... ok; go test -count=1 ./... exit 0
(42 packages ok); go build ./... and CGO_ENABLED=0 go build ./cmd/agent-system
ok; git diff --cached --check clean. New tests failed before the change.

Impact: bump deps/agent-document-renderer 0d1c512 -> 31fbe54 and regenerate
the renderer identity. The manifest profile receipt now reports the
renderer's own review status and derives Candidate from the snapshot state
(false for the published default). Prompts (base.deliverables v12,
tool.render_document v2, task.report v4, task.doc-minutes v4), docx-author
and house-style skills, the render_document tool description and comparison
exception details describe the default as published (approved by owner
2026-10-16) while still limiting it to ordinary uncontrolled workspace
working/preview Word. Routing is unchanged: project, controlled,
special-template, review and approved outputs still go to RDM, with no
Backend default fallback and no legacy Word builder fallback.

Refs: unified renderer plan Tasks 7/10
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 05:07:19 +00:00
weichen a2f4978be8 Detect Word packages from OPC metadata
Why: The registration gate classified Word packages from a hard-coded ZIP member path, allowing alternate main-part paths to bypass the gate and rejecting unrelated ZIP archives.

Evidence: Focused OPC registration tests; go test -count=1 ./internal/tools; go test -count=1 ./...; go test -race -count=1 ./internal/tools ./internal/runmgr; go vet ./...; go build ./...; git diff --check.

Impact: Resolve package-level officeDocument targets safely, classify all conventional Word main content types through OPC defaults or overrides, and fail closed on malformed, ambiguous, external, or traversing metadata while preserving non-Word artifacts.

Refs: Task 10; /tmp/task10-backend-final-closure-last.md
2026-10-15 22:46:21 +00:00
weichen 6c01d00a3f Close remaining Task 10 renderer review findings
Why: Direct artifact registration trusted filename extensions, the shared renderer parity test used a reduced fixture, and migration comparison omitted canonical list/task/rule/alignment properties plus their Word presentation.
Evidence: go test -count=1 ./...; go test -race ./internal/docgen ./internal/tools ./internal/runmgr; go vet ./...; go build ./...; PYTHONPATH=. python3 -m unittest tests.test_housestyle; git diff --check.
Impact: Reject renamed WordprocessingML packages while preserving non-Word artifacts, pin the reviewed renderer semantics, and report canonical and DOCX presentation regressions as accidental differences.
Refs: Task 10; /tmp/task10-backend-final-review-last.md
2026-10-15 22:33:29 +00:00
weichen fec2d16cc6 Close Task 10 renderer delivery gaps
Why:
- Independent review found trusted-boundary, semantic parity, replay-budget,
  comparison, persistence, cancellation, provenance, and routing gaps.
- Storage migration must include the new durable delivery table.

Evidence:
- go test -count=1 ./...
- go test -race ./internal/docgen ./internal/tools ./internal/runmgr
- go test ./... (deps/agent-document-renderer)
- go vet ./...
- go build ./...
- CGO_ENABLED=0 go build -trimpath ./cmd/agent-system
- Python house-style unittest suite and git diff --check

Impact:
- Word delivery now uses host-owned freezes, Markdown-first resumable persistence,
  closed registration boundaries, semantic shared rendering, and real comparison.
- Controlled/project documents still fail closed to RDM and non-Word flows remain
  unchanged.

Refs:
- Unified Document Renderer implementation plan Task 10
- /tmp/task10-backend-review-last.md
- Renderer d5cd8c126045a27d5a5cc5da23f64d3c737e116a
2026-10-15 21:44:03 +00:00
weichen 35bd87cad8 Update renderer for semantic and cancellation repair
Why: The Task 10 corrective pass requires the shared Renderer semantic Markdown model and synchronous cooperative cancellation APIs.
Evidence: Renderer go test ./... and Backend go test ./internal/docgen -count=1 passed at d5cd8c126045a27d5a5cc5da23f64d3c737e116a.
Impact: Advances only the immutable agent-document-renderer gitlink; Backend repair behavior is committed separately.
Refs: Unified Document Renderer Task 10 review repair; /tmp/task10-backend-review-last.md
2026-10-15 21:13:48 +00:00
weichen 9aa9129753 Retire legacy Word guidance from production workflows
Why: Agent-facing prompts and house-style recipes must exercise the shared renderer instead of leaving the old Python Word builder as the ordinary production route.
Evidence: Prompt routing tests, house-style Python tests, affected-package race checks, full Go tests, vet, builds, and static binary verification passed locally.
Impact: Word guidance now calls render_document, labels the pending organization-default candidate, routes controlled/project work to RDM, and gates housestyle.build behind AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1.
Refs: Unified Document Renderer Task 10; Renderer d449b9f779f2f968f8ac556b89940a6c9aaf3fdc
2026-10-15 19:41:39 +00:00
weichen a21a3b57b4 Route workspace Word delivery through the shared renderer
Why: Task 10 replaces Backend's independent Markdown-to-Word production path with the reviewed canonical parser, embedded organization-default candidate, and Word adapter.
Evidence: Canonical parity, deterministic package, routing, safety, cancellation, comparison, persistence, affected-package race, full test, vet, build, and static build checks passed locally.
Impact: Ordinary workspace renders now persist Markdown, deterministic DOCX, manifest, and archive with hashes; controlled/project/review/approved routes fail closed to RDM, and legacy Go rendering is comparison-only.
Refs: Unified Document Renderer Task 10; Renderer d449b9f779f2f968f8ac556b89940a6c9aaf3fdc
2026-10-15 19:41:14 +00:00
weichen b0dd533385 Add shared document renderer dependency
Why: Task 10 requires Backend to consume the reviewed canonical renderer and its embedded organization-default candidate without copying assets.
Evidence: Fresh GOMODCACHE resolution loaded agent-document-renderer/profiles and passed its package tests at d449b9f779f2f968f8ac556b89940a6c9aaf3fdc.
Impact: Adds one immutable Renderer gitlink plus the local Go module require/replace; no production path changes yet.
Refs: Unified Document Renderer Task 10; Renderer d449b9f779f2f968f8ac556b89940a6c9aaf3fdc
2026-10-15 19:04:37 +00:00
weichen d9d8f016ec Merge current Backend main into project decision work
Why: Integrate Backend origin/main 7aa6243 while retaining the product's mandatory trusted project-context startup contract.

Evidence: go test ./pkg/extension/... ./internal/app -count=1; go vet ./...; go test ./... -count=1; go build ./...; CGO_ENABLED=0 go build ./cmd/agent-system.

Impact: Keep the appOptions helper and exec-environment fallbacks while forwarding RequireProjectContext without loss.

Refs: 92f9286e6c4004074f014902f8b04554287ddfb2; 7aa624370a
2026-10-15 18:10:23 +00:00
weichen 92f9286e6c Merge layered exec environments into project decision work
Why: Integrate the exact Dev main pin while preserving trusted project context, vision routing, work-result context, and distinct migration identities.

Evidence: go vet ./...; go test ./... -count=1; go build ./...; CGO_ENABLED=0 go build ./cmd/agent-system; isolated PostgreSQL migration history tests.

Impact: Keep exec_env_layers at schema v35, move the feature migrations to v36-v46 in order, and fail closed on the old unpublished numbering.

Refs: 14e780c6fa32fdd4742b6232150c5efcb0d766ff; ab12fea474
2026-10-15 17:58:14 +00:00
weichen 7aa624370a Pass exec environment paths through the product entry
ci / vet-and-test (push) Successful in 59s
Product binaries now receive the layered Python environment.
2026-10-15 14:44:48 +00:00
weichenandCursor 28544cfe5d Execute the managed interpreter through its real path
ci / vet-and-test (pull_request) Successful in 1m3s
Why: the sandbox hides /home and only remounts the resolved Python prefix, so executing python/current/bin/python3 failed before the base layer could be imported.
Evidence: go test ./internal/tools/ -run TestResolveInterpreterFollowsManagedSymlink passed, and env doctor against the built base layer printed doctor ok (SELF).
Impact: run_python and env doctor follow the symlink into the mounted prefix or /usr/bin/python3. The layer and the prefix mount stay the same.
Refs: Agent-System-Backend PR 207
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 14:43:13 +00:00
weichenandCursor c5a4c6306b Write the pip dry-run report to a file
ci / vet-and-test (pull_request) Successful in 1m7s
Why: pip --report - mixes Collecting progress and the JSON document on stdout, so the first base-layer resolve failed before any wheel was stored.
Evidence: go test ./internal/execenv/ passed on this worktree (SELF). A live dry-run of python-docx showed the progress text before the JSON object.
Impact: resolve reads report.json and ignores the progress stream. Package selection is unchanged.
Refs: Agent-System-Backend PR 207
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 14:38:26 +00:00
weichenandCursor 50d25ac690 Pass exec environment paths through the product entry
ci / vet-and-test (pull_request) Successful in 1m1s
Why: RDM starts the server through extension.Main, which dropped ExecDepsDir, ExecWorkspace, and ExecEnvRoot, so a deployed product ignored the layered Python environment.
Evidence: go test ./pkg/extension/link/ passed on this worktree (SELF).
Impact: a product binary reads the same AGENT_SYSTEM_EXEC_* variables as the base CLI when its own flags are empty; explicit Options still win.
Refs: Agent-System-Dev PR 570
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 14:35:46 +00:00
weichen edc2e69f9f Merge pull request 'Add immutable layered Python environments'
ci / vet-and-test (push) Successful in 1m2s
Reviewed against the layered environment plan. Required CI on the current head passed where this repository runs CI.
2026-10-15 14:24:46 +00:00
weichenandCursor ab12fea474 Register exec env tables in storage migration
ci / vet-and-test (pull_request) Successful in 1m1s
Why: Schema 35 adds exec_env_requests and exec_env_bindings. The SQLite-to-Postgres copier refuses to run when a business table is missing from its ordered list.
Evidence: SELF go test ./cmd/storage-migrate -run TestCopyOrderCoversAllFixtureTables. CI run 3343 failed on this exact check.
Impact: Those two tables move with the rest of the database. They have no foreign keys, so they can follow the existing recovery tables.
Refs: pull 206
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 14:04:03 +00:00
weichen 9a54845d40 Merge remote-tracking branch 'origin/main' into feature/layered-python-env
ci / vet-and-test (pull_request) Failing after 59s
2026-10-15 14:01:27 +00:00
weichenandCursor b85a1e1ca3 Add immutable layered Python environments for the sandbox.
Why: The sandbox hides the host home directory and has no network, so pip install --user cannot supply python-docx, and the service PATH resolved python3 to a linuxbrew path that does not exist inside the sandbox.
Evidence: SELF go test of execenv, tools, store, app, server, runmgr, config, and cmd/agent-system on this worktree. CI has not run.
Impact: Sessions inherit a read-only base layer. Extra packages are requested through python_env, auto-approved only from the allowlist, and mounted per conversation. Schema 35 adds the request and binding tables.
Refs: .task/feature-layered-python-env.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 13:52:48 +00:00
weichen 11c337d3df Merge pull request 'Keep a newly seeded model key ahead of a stale secret' (#205) from fix/model-key-secret-overlay into main
ci / vet-and-test (push) Successful in 1m1s
Why: a stale secret row was winning over a newly seeded chat key.
Evidence: CI run 3337 job vet-and-test success on 24bbb7b777.
Impact: system settings adopt a changed secret; conflicting plaintext is kept.
Refs: fix/model-key-secret-overlay
2026-10-15 11:29:34 +00:00
weichenandCursor 24bbb7b777 Keep a newly seeded model key ahead of a stale secret
ci / vet-and-test (pull_request) Successful in 1m1s
Why: The active secret_keys row overrides settings plaintext, so a replaced chat key kept returning 401. Migration also mutated the settings map in place, so clearing a matching plaintext never reached the database.
Evidence: go test ./internal/settings/ ./internal/runtime/ -count=1 (SELF, worktree).
Impact: System settings writes adopt a changed secret; conflicting plaintext is kept and logged; 401 errors name the key layer and an 8-hex fingerprint.
Refs: fix/model-key-secret-overlay
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 10:33:14 +00:00
weichen c7b4ad7ed5 Merge pull request 'Keep background work usable without blocking the conversation' (#204) from feature/workbench-flow-completion into main
ci / vet-and-test (push) Successful in 1m17s
2026-10-15 08:18:44 +00:00
weichen 7d6490feee Stop untracked background execution and retain results on record failures
ci / vet-and-test (pull_request) Successful in 1m2s
Why: Card registration or transcript persistence failures must not orphan paid work or discard a generated result, and ordinary questions must not match summary requests.

Evidence: Registration/promote and message-insert fault regressions, implicit-question checks and scoped race tests pass; legacy projection caching preserves historical times.

Impact: Fail before execution when tracking is unavailable, preserve full public final text with an explicit failure state, cache legacy projections once, and repair the concurrent material test fixture.

Refs: feature/workbench-flow-completion independent review closure
2026-10-15 07:37:10 +00:00
weichen 5561df657e Recover saved work projections and honor explicit reference routing
Why: A failed completion-card write could strand durable results, and routed messages could discard a selected result reference.

Evidence: Regressions cover missing projection recovery without execution, unavailable saved runs, pre-child dispatch failures and existing work/authorization paths.

Impact: Reconcile terminal saved results on authorized reads, keep known failures visible, and route explicit references through the conversation context.

Refs: feature/workbench-flow-completion review reliability and API findings
2026-10-15 07:23:25 +00:00
weichen 0f4ca18957 Honor explicit follow-up questions after a submitted work choice
Why: A fresh question referencing an earlier result could be mistaken for a duplicate background request.

Evidence: The new English-summary reference regression failed with the old receipt branch and passes with the explicit-reference distinction.

Impact: Deduplicate inferred pending choices while allowing explicit new requests to use the saved result.

Refs: feature/workbench-flow-completion review P1
2026-10-15 07:14:17 +00:00
weichen aed45d7f31 Deliver background results through durable nonblocking work cards
Why: Background notice runs blocked chat, truncated results, and duplicated deliverable execution.

Evidence: Focused run manager, card, server, recovery and race regressions pass locally; final combination checks are tracked in Dev.

Impact: Persist full results and optional choices, fence dispatch by run identity, retain public child process, and repair legacy projections without a schema change. No paid model trial was run.

Refs: feature/workbench-flow-completion
2026-10-15 07:05:57 +00:00
weichen 14e780c6fa Bind formal commands to exact project sources
Why: Chat intent, cross-conversation anchors, payload changes, and native workspace tools could bypass project source analysis.

Evidence: Command-bus, app, server, and squad-adapter suites pass with centralized preflight and project-tool fail-closed behavior.

Impact: Every classified formal entrance binds actor, conversation, project, command, payload digest, and source watermark before dispatch.

Refs: TT-N03 TT-R01
2026-10-15 07:00:18 +00:00
weichen 6bafc3580b Consume the optional work card contract
Why: Persist background results and nonblocking next-step choices together.

Evidence: Core 00f1826 package tests passed locally.

Impact: Advance the Core contract dependency only.

Refs: feature/workbench-flow-completion
2026-10-15 06:46:50 +00:00
weichen f8ea71f6b1 Bind auth-off observations to the local project epoch
Why: The real local-mode product has authoritative project access without an enabled account-model row.

Evidence: The app suite passes; non-local missing or disabled accounts still fail closed.

Impact: Auth-off local acceptance can record a nonempty authorization epoch while authenticated deployments retain account-version binding.

Refs: TT-N03
2026-10-15 06:38:18 +00:00
weichen 021295a188 Merge pull request 'Send current-turn images and keep vision fallbacks capable' (#203) from feature/four-model-vision into main
ci / vet-and-test (push) Successful in 1m2s
Why: land current-turn image routing on backend main.
Evidence: vet-and-test success on 629c2502ad02e33708fe7ee39afd4b45d1b25483; parent pin 6532fad remains an ancestor.
Impact: vision routing is on backend main.
Refs: #203
2026-10-15 06:38:05 +00:00
weichen 5b1b137c4c Explain blocked project command preflights
Why: A generic conflict response hid whether source intake, authorization, or risk analysis blocked a formal command.

Evidence: The server suite passes with bounded domain reasons included in the conflict response.

Impact: Clients can present the actionable gate reason without exposing source contents.

Refs: TT-N03 TT-R01
2026-10-15 06:37:35 +00:00
weichen 629c2502ad Merge backend main into the vision branch
ci / vet-and-test (pull_request) Successful in 1m0s
Why: the backend pull request cannot merge while its head is behind main.
Evidence: origin/main 9fd0793 is one commit ahead of the pin already contained in 6532fad.
Impact: the parent gitlink stays on 6532fad, which remains reachable.
Refs: #203
2026-10-15 06:35:33 +00:00
weichen 5ccbe8fbbc Require source preflight for formal project commands
Why: Direct HTTP and surface command dispatch could mutate RDM project facts without an analyzed committed-message manifest.

Evidence: Command-bus, server, and app suites pass with a classification regression for formal writes and read/proof exceptions.

Impact: Formal HTTP writes require a conversation anchor and committed proceed decision; surface writes fail closed until they can carry the same proof.

Refs: TT-N03 TT-R01
2026-10-15 06:34:52 +00:00
weichen 1ec21f1c05 Disable unaudited native tools for project runs
Why: Direct search, exec, fetch, and MCP providers could return project-driving results without entering the trusted source observation gate.

Evidence: App and run-manager suites pass with project runs confined to the audited runCtx tool path.

Impact: Non-project native runs retain configured tools; project runs fail closed until native result hooks can prove durable observation.

Refs: TT-N03 TT-R01
2026-10-15 06:33:31 +00:00
weichen 99f1d3b3dd Recheck project sources after clarification
Why: Accepted retrieval and tool sources could become stale, and a pending HITL answer could release a changed source set.

Evidence: App and run-manager suites cover per-preflight resource verification, authorization epochs, and a second exact-source preflight after HITL.

Impact: Project work fails closed when authorization, source bytes, run binding, or the source watermark changes.

Refs: TT-N03 TT-R01
2026-10-15 06:22:20 +00:00
weichen c5c6416135 Merge current backend main into human decisions
Why: The W4b verifier branch must include the current compiled-plugin host option surface before review.

Evidence: Focused extension, app, server, and command-bus tests pass after preserving both public API additions.

Impact: The public surface advances to 2.23.0-experimental and exposes both human-decision and compiled-plugin capabilities.

Refs: W4b human decision verifier
2026-10-15 06:03:03 +00:00
weichen 451aa49954 Alias the core package consistently
Why: An interrupted import cleanup removed the cmdbus core import while two human-decision files used an explicit alias.

Evidence: go test ./internal/cmdbus ./internal/server passes.

Impact: The backend worktree is buildable and consistently names the core package without behavior changes.

Refs: W4b human decision verifier
2026-10-15 06:01:32 +00:00
weichen 6532fad4d5 Merge backend main into the vision branch
ci / vet-and-test (pull_request) Successful in 1m24s
Why: the parent sync needs a backend commit that contains both vision routing and the current main pin.
Evidence: incoming pin 80bb123 is an ancestor of origin/main; merge keeps cfde38d.
Impact: backend gitlink can record one resolved commit.
Refs: feature/four-model-vision
2026-10-15 05:49:30 +00:00
weichen c8c49a6511 Merge remote-tracking branch 'origin/main' into feature/iam-u4-role-authority
# Conflicts:
#	pkg/extension/extension.go
#	pkg/extension/surface.txt
2026-10-15 03:56:22 +00:00
weichen 9fd079388b Merge pull request 'Host options for compiled plugins' (#201) from feature/svc-plugin-host-options into main
ci / vet-and-test (push) Successful in 58s
Why: compiled plugins need host options and a stable surface version.
Evidence: Actions run 3286 job 5051 vet-and-test success on 80bb123c87.
Impact: surface 2.14.0-experimental; process-event child page no longer assumes an exact two-row window.
Refs: #201
2026-10-15 03:54:16 +00:00
weichenandCursor 80bb123c87 Accept a late parent terminal event in the child process page
ci / vet-and-test (pull_request) Successful in 1m2s
Why: waitRunTerminal can return before run.completed is stored, so that event may land between the appended tool output and the child events.
Evidence: CI run 3274 failed twice on TestRunProcessEventsAreOrderedAndBoundToConversation with run.completed ahead of the child progress and result. Local go test of that test passed after this assertion.
Impact: the test still rejects leaked child tool output and still requires child progress then result, in that order.
Refs: Agent-System-Backend pulls/201
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 03:41:17 +00:00
weichenandCursor a4d6404c5f Register the human decision migration as readable by old binaries
Why: The new confirmation table is additive, and the compatibility ledger must name that fact or the store suite rejects the schema.

Evidence: SELF go test ./internal/store/ -run TestMigrationCompatRegistered passed after the registration.

Impact: Old readers keep their current floor. They do not need to understand human decision rows.

Refs: W4b human confirmation design r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 03:39:15 +00:00
weichen bffd37c42f Merge remote-tracking branch 'origin/main' into feature/svc-plugin-host-options
ci / vet-and-test (pull_request) Failing after 1m13s
2026-10-15 02:43:04 +00:00
weichenandCursor b740f8b6f6 Add optional proofs for an exact human structure decision
Why: A model-visible flag cannot prove that a person confirmed one proposal revision and payload hash.
Evidence: go test ./pkg/extension ./internal/app ./internal/server -run 'HumanDecision|SurfaceSnapshot|ResolveHuman|NaturalLanguage' passed locally.
Impact: SurfaceVersion 2.22.0-experimental adds HumanDecisionVerifier. Existing Host methods stay unchanged, and the base does not consume the proof.
Refs: W4b human confirmation design r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 02:33:37 +00:00
weichen 9793a778be Merge pull request 'Harden OOXML gate against reference-integrity breaks' (#202) from feature/pptx-gate-reference-integrity into main
ci / vet-and-test (push) Successful in 1m6s
2026-10-15 02:33:10 +00:00
weichen 06ef17a70a Harden delegated role authority boundaries
Why: Independent review found delegated account actions, stale resource policy, and role migration cutover gaps in the initial U4 implementation.

Evidence: Backend full SQLite suite passed; isolated PostgreSQL role and migration suite passed; independent final review found no remaining findings.

Impact: Enforces transactional system grants, current role snapshots, staged role import with v36 migration, and fail-closed activation.

Refs: feature/iam-u4-role-authority
2026-10-15 02:27:50 +00:00
weichen 0a57853154 Harden OOXML gate against reference-integrity breaks
ci / vet-and-test (pull_request) Successful in 58s
Why: A second broken pptx passed the shipped gate despite being unopenable
in Office: parts were all present but presentation.xml and the master used
an undeclared xmlns:r prefix, sldIdLst listed 12 slides while the rels file
wired only one, and [Content_Types].xml overrode only slide1. The prior
part-existence checks cannot see any of these; the generating instance also
predated the first gate deployment.

Evidence: The upgraded gate rejects the real broken file (undeclared
xmlns:r) and 16 synthetic break cases spanning both incident classes, while
passing the manually repaired package and kit output; python round-trip on
the repaired deck extracts all 12 slides. Full-suite receipt follows on the
task card.

Impact: register_artifact now enforces strict namespace declarations across
every XML part, bidirectional consistency between id-lists and relationships
(presentation and masters), and per-part content-type overrides. Error
messages name the broken reference. run_python prompt v3 adds that failed
kit attempts must retry through the kit, never fall back to hand assembly.

Refs: Dev task feature/pptx-gate-reference-integrity
2026-10-15 02:14:12 +00:00
weichen 8c60ed092b Merge remote-tracking branch 'origin/main' into feature/svc-plugin-host-options
ci / vet-and-test (pull_request) Successful in 1m2s
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	pkg/extension/extension.go
2026-10-15 01:58:24 +00:00
weichenandCursor cfde38de31 Send current-turn images and keep vision fallbacks capable
Why: Uploaded images never reached the model, so a text-only chat model could be asked to read a picture it cannot see.

Evidence: go test ./internal/... passed locally before this commit. Image-link rejection is covered by vision package tests.

Impact: Current-turn attachments and fetched image links become image input. History keeps a text placeholder. Image turns swap to the configured vision model when the chat model is not in the capable list, and fallbacks are filtered the same way.

Refs: Agent-System-Dev feature/four-model-vision
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 17:51:17 +00:00
weichen b2a1cd0a60 Expose effective system actions to the client
Why: Non-root managers need the system-management entry and controls to follow their explicit grants.

Evidence: The full Backend test suite passed and /api/me action projection is covered by the grant-isolation test.

Impact: /api/me now returns only the current actor system actions evaluated by the same authorizer.

Refs: Focused role and permission system task 7
2026-10-14 17:37:39 +00:00
weichen 94732f3c87 Add trusted role administration compatibility
Why: Existing RDM role commands must reach the Backend authority during UI and migration cutover without dual-writing RDM role tables.

Evidence: The full Backend suite passed, including actor-bound role administration, owner compatibility, provider, and public-surface tests.

Impact: Adds temporary trusted Host administration methods and completes the project-action catalog used by RDM.

Refs: Focused role and permission system task 5
2026-10-14 17:28:31 +00:00
weichen 5abd851256 Expose the role policy provider
Why: RDM needs a trusted, non-secret view of Backend-owned role policy without importing Backend internals or accepting a caller-supplied actor.

Evidence: The full Backend test suite passed, including public-surface and provider contract tests.

Impact: Adds optional extension surface 2.14 with actor-bound role snapshots and action decisions.

Refs: Focused role and permission system task 4
2026-10-14 17:09:11 +00:00
weichen 4d1ca60067 Expose role and system permission APIs
Why: System, role, and project entry permissions need explicit actions instead of a single administrator flag or unrestricted project creation.

Evidence: The full Backend test suite passed, including role APIs, action isolation, project.create denial/allow, storage migration coverage, and account write fences.

Impact: Adds versioned role and grant endpoints, maps account actions to explicit permissions, and requires project.create authority.

Refs: Focused role and permission system task 3
2026-10-14 17:02:11 +00:00
weichen da4b8c9956 Gate project runs and replies on source analysis
Why: Project messages, retrieved evidence, tool results, and final replies must not affect model work or reach users before the domain risk decision is complete.

Evidence: RED tests proved the runner, final reply, and message deltas advanced before the gate; focused tests, go test -p 1 ./..., go build ./..., and isolated PostgreSQL runtime tests now pass.

Impact: Wire the optional RDM observer into project run start, retrieval, tool-result, HITL clarification, and final reply buffering; hosts without the optional provider keep existing behavior.

Refs: TT-N03, TT-R01; approved work-observation design and stage-five plan
2026-10-14 16:54:13 +00:00
weichen 74096dbdd7 Add atomic role and binding policies
Why: Role assignment and permission changes must preserve one active position, version conflicts, idempotency, and the final policy manager.

Evidence: The full Store suite passed on SQLite and focused role/policy tests passed on isolated PostgreSQL.

Impact: Adds transactional role mutations and seeds explicit system grants for existing administrators.

Refs: Focused role and permission system task 2
2026-10-14 16:51:57 +00:00
weichen 6b1afab82b Add the role permission catalog and schema
Why: Backend needs a single trusted catalog and durable role authority before RDM can stop owning position data.

Evidence: Authz catalog tests and the full store suite passed on SQLite; migration rollback and one-active-role constraints are covered.

Impact: Adds schema v35 and stable role/binding types without changing existing authorization callers.

Refs: Focused role and permission system task 1
2026-10-14 16:47:09 +00:00
weichen 7cee124b8f Capture actual project work sources before reuse
Why: Project retrieval hits, tool results, and saved visible analysis must be individually traceable and fully analyzed before later project actions consume them.

Evidence: RED tests first showed missing run-source capture, tool-result capture, and run-wide preflight; go test -p 1 ./... and go build ./... passed; isolated PostgreSQL source, migration, audit, and context tests passed.

Impact: Persist actual run-bound sources with current-status checks and exact manifests behind the disabled X3 assembly gate; no live product path, migration, deployment, or release is enabled.

Refs: TT-N03, TT-R01; approved X3 stage-five plan r1
2026-10-14 16:25:22 +00:00
weichen 928da87a93 Require analyzed source manifests before project actions
Why: A durable observation receipt must not be mistaken for completed risk analysis or permission to execute a project action.

Evidence: Exact-source preflight tests cover pending versus proceed, partial attachment, post-ACK source tampering, current authorization, and receipt idempotency; go test -p 1 ./... and go build ./... passed.

Impact: Add a fail-closed host preflight helper and per-message manifest read, still unwired to the public product path pending recovery and four-route coverage.

Refs: TT-N03, TT-R01; approved X3 design r1
2026-10-14 16:03:21 +00:00
weichen 18cd0d4247 Persist private project message observations atomically
Why: Project user messages and final assistant replies need exact, replayable source parts before a domain observer can safely analyze them.

Evidence: go test -p 1 ./... passed; isolated PostgreSQL outbox, audit, migration, and work-context tests passed against a temporary cluster; request dedup, fenced settlement rollback, authorization recheck, and receipt idempotency tests passed.

Impact: Add migration 44 and a private host outbox for committed message parts; actual observer delivery and action preflight are not yet wired into the product path.

Refs: TT-N03, TT-R01; approved X3 stage-five plan r1
2026-10-14 15:52:42 +00:00
weichen 136e93bf1d Combine project context and audit with account main
Why: X3 must share one Backend surface with the X1 context and X2 audit candidates while current main now owns account migrations 32-34.

Evidence: go test -p 1 ./... and go build ./... passed on the merged tree; isolated PostgreSQL project audit, migration, and work-context tests passed against a temporary instance. Default parallel go test ./... had only the unrelated sandbox orphan timing test fail; its isolated ten-run replay passed.

Impact: Preserve account migrations 32-34, place X1/X2 migrations at 35-43, and retain account-effective write guards alongside project audit/context behavior. X3 observer remains a contract only.

Refs: TT-C01-TT-C04, TT-A01-TT-A04, TT-N03, TT-R01; combination probe d9b00c2
2026-10-14 15:41:11 +00:00
weichen 2c4ccc33c4 Define trusted project work observation contract
Why: Real project messages and retrieved material need a scoped observer and preflight decision contract before they can become governed project candidates.

Evidence: Registration denial test was RED before the implementation; go test ./... passed on Backend origin/main 2716b9e.

Impact: Add public X3 types and trusted single-provider registration; the production observer slot is deliberately not wired yet, so no project observation behavior is claimed.

Refs: TT-N03, TT-R01; approved work observation stage-five plan r1
2026-10-14 15:33:26 +00:00
weichenandCursor 4c9743ae42 Let compiled plugins prepare startup after host defaults merge
Why: RDM's sandbox policy must run after HostOptions are merged and be able to refuse startup when bwrap is missing.
Evidence: go test ./pkg/extension/ ./cmd/agent-system/ -count=1 passed on this tree (SELF).
Impact: InProc.Prepare and PrepareCompiled; serve applies plugin rewrites before app.Run.
Refs: feature/svc-plugin-host-options
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 15:15:16 +00:00
weichenandCursor dee7a9e845 Add compiled-plugin host defaults and CLI hooks
Why: a composed base binary must apply plugin startup defaults and plugin-owned flags without a downstream main.
Evidence: go test ./pkg/extension/ ./cmd/agent-system/ -count=1 (SELF, this commit's parent tree plus these files).
Impact: blank-imported plugins can set HostOptions and a CLI hook; GET /api/registry gains a compiled id list; explicit serve flags still win.
Refs: plugins/registry compose deploy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 15:12:24 +00:00
weichen d39e77c076 Merge remote-tracking branch 'origin/main' into feature/fast-slow-u2-u3
ci / vet-and-test (pull_request) Successful in 57s
2026-10-14 09:28:11 +00:00
weichen c23239ebae Exercise queued and failed material intake through HTTP
ci / vet-and-test (pull_request) Successful in 49s
Why: The stage-6 browser proof must cover the real queued and failed-start user states.

Evidence: Two Playwright paths failed before fixture support, then passed; full Backend vet and Go tests passed.

Impact: Adds only task-owned mechanism fixture behavior under the live test tag.

Refs: U3 r0.5 independent review finding
2026-10-14 09:13:18 +00:00
weichen 8f5e32dca1 Bound provider terminal labels in evaluation records
ci / vet-and-test (pull_request) Successful in 53s
Why: A provider-controlled finish reason could leak arbitrary content into the supposed content-free audit file.

Evidence: Red then green terminal-label regression and full Backend vet/Go test in Dev receipts.

Impact: Only evaluation records map unknown terminal labels to a finite class; tool execution rules are unchanged.

Refs: U3 r0.5 independent review finding
2026-10-14 09:07:07 +00:00
weichen bd41302310 Align Backend Squad pin with terminal observation contract
ci / vet-and-test (pull_request) Successful in 4m20s
Why: The Backend composition must use the same Agent-Native revision through Squad.

Evidence: Dev task receipt for full Backend vet and Go test with pinned dependencies.

Impact: Updates only the Agent-Squad gitlink.

Refs: U3 r0.5 implementation plan Task 5
2026-10-14 08:42:11 +00:00
weichen d862372efe Hold first checkpoint in browser timing fixture
ci / vet-and-test (pull_request) Successful in 4m14s
Why: The safe acknowledgement must be observed before fake findings arrive.

Evidence: Dev evidence receipt for ten browser scenarios and isolated PostgreSQL/Redis tests.

Impact: Changes only the task-owned zero-paid fixture delay.

Refs: U3 r0.5 implementation plan Task 4
2026-10-14 08:40:28 +00:00
weichen 4790e8c260 Persist content-free model terminal evidence
ci / vet-and-test (pull_request) Successful in 1m11s
Why: The controlled A01 record retained raw prompt/tool payload while lacking enough transport classification to explain the rejected tool terminal.

Evidence: TestEvaluationRecordsContentFreeTerminal and error-bearing terminal regressions failed before the change and passed after; full Backend vet/Go and selected zero-paid live-tag SELF receipt /tmp/fast-slow-u3-r05-task3-backend-full.json exited 0.

Impact: Pin Agent-Native 6a1dcdd, retain per-call Usage and safe terminal facts, remove future raw messages/tool declarations/terminal snapshots, and clear tools on an error terminal before forwarding. Historical trial artifacts remain untouched.

Refs: U3 r0.5 approved design and plan; Backend PR #193
2026-10-14 08:29:02 +00:00
weichen fb7104d7ce Acknowledge automatic material intake from task state
Why: The first material reply could stream an unsupported numeric relationship before the sourced background card had any verified finding.

Evidence: Task 2 red/green SQLite run tests cover parent provider suppression, queue/reuse, completed source binding, focus retention, squad entry, failed/unknown state, and attempt ordering; full Backend vet/Go/race SELF receipt /tmp/fast-slow-u3-r05-task2-backend-final-v2.json exited 0.

Impact: Prestarted material sends receive one server-owned status message with zero parent model calls; small-text and explicit completed follow-up paths retain their existing runner. No public contract or model budget increases.

Refs: U3 r0.5 approved design and plan; Backend PR #193
2026-10-14 08:20:11 +00:00
weichen d9b00c23c8 Refuse rollback after migration ledger identity drift
Why: An already-open Store could otherwise execute a numbered down migration after its ledger name changed.

Evidence: The targeted rollback test failed before the guard and passed after it; focused SQLite and isolated PostgreSQL store/migrator checks passed.

Impact: MigrationStatus and MigrateDown fail closed on version-name mismatch before changing schema.

Refs: feature/rdm-two-trees-latest-templates Backend combination probe
2026-10-14 06:52:29 +00:00
weichen f4ed6b063f Verify nonempty workflow context tables survive migration
Why: Combined W6 and W9 storage validation must cover actual work task and step-source rows, not only empty tables.

Evidence: Isolated PostgreSQL migration test copies and reads both rows with their trusted scope intact.

Impact: Adds integration coverage for the combined v31-v40 migration; no production behavior change.

Refs: feature/rdm-two-trees-latest-templates Backend combination probe
2026-10-14 06:49:34 +00:00
weichen 14dc48de79 Reject reused migration identities before data copy
Why: Parallel unpublished candidates reused numeric versions, so version-only checks could reinterpret a different schema after combination.

Evidence: SQLite ledger and source-copy mismatch tests failed before the guard and pass now; PostgreSQL store/migrator and full Go suites pass.

Impact: Fail closed on version-name mismatch before opening a migration target or serving a database.

Refs: feature/rdm-two-trees-latest-templates Backend combination probe
2026-10-14 06:39:18 +00:00
weichen 3a08cd4260 Combine project context and audit candidates for validation
Why: Both approved Backend slices share extension surfaces and migration numbers, and current main added account audit v31.

Evidence: The isolated probe resolves additive interfaces and v31-v40 ordering; focused and full Go tests passed without PostgreSQL.

Impact: Produces a local integration candidate for isolated PostgreSQL and race verification only; shipping branches remain unchanged.

Refs: feature/rdm-two-trees-latest-templates W6 W9
2026-10-14 06:30:53 +00:00
weichen 06367422d6 Integrate context candidate with current Backend main
Why: Validate W6 against account-audit migration v31 on the current authoritative base.

Evidence: Focused store/runmgr/migrator and full Go tests passed in the isolated probe after resolving migration collisions.

Impact: Renumbers unpublished W6 migrations to v32-v36; this probe commit is not a shipping branch or live migration.

Refs: feature/rdm-two-trees-latest-templates W6 integration probe
2026-10-14 06:26:36 +00:00
weichen 130ac344aa Guard every PostgreSQL command audit write
Why: Explicit low IDs bypassed the implicit sequence allocator and could commit after a higher audit page cutoff.

Evidence: Isolated PostgreSQL RED receipt for cross-page omission; local GREEN for explicit and default writes, trigger drift, isolation, mutation denial, offline copy rollback, retry, and sequence non-rewind. Fixed-HEAD receipts follow.

Impact: Add v34 all-writer and append-only guards; reject unsafe strong reads; make command audit storage migration exclusive and atomic with guard restoration and a forward-only sequence.

Refs: Approved W9 all-writers cutoff adjustment r1; W9 Backend task card.
2026-10-14 06:15:01 +00:00
weichen 0637547cc5 Count omitted totals after successful subagent turns
ci / vet-and-test (pull_request) Successful in 47s
Why: A failed terminal without total_tokens can follow a successful tool turn, leaving the cumulative reported total stale even though prompt and completion include both turns.

Evidence: Mixed-turn persisted child test failed before the correction and passed after; SELF receipts usage-fix-mixed-turn-red-diagnostic.json, usage-fix-mixed-turn-green.json, and usage-fix-mixed-turn-full.json.

Impact: Reconcile against the larger of cumulative total or prompt-plus-completion while preserving cache and tool counts. No real-model sample was rerun.

Refs: U3 r0.4 A01; independent review /tmp/fast-slow-u3-r04-20261014/usage-fix-independent-review.md
2026-10-14 05:54:45 +00:00
weichen d8129abead Persist candidate scope scrub state across restarts
Why: A crash after dropping candidate raw prompt columns but before SQLite compaction left physical prompt bytes without a visible reason to retry, and storage backup could accept that file.

Evidence: RED post-commit interruption and pending-backup tests; full Go suite, targeted race, isolated PostgreSQL package tests, vet, and build passed locally before commit.

Impact: Commit a durable pending marker with v35, retry compaction and WAL truncation until completion, conservatively re-scrub earlier v35 candidates, and reject backup or migration while cleanup is pending or unverified.

Refs: Approved RDM stage6 W6 source-recheck design adjustment r1; W6 independent review crash-window finding.
2026-10-14 05:48:41 +00:00
weichen 61625c609f Account failed subagent terminal usage
ci / vet-and-test (pull_request) Successful in 52s
Why: A01 returned paid Usage on a rejected tool terminal, but the failed child run persisted zero because the runtime did not emit message_end.

Evidence: RED then GREEN for failed-terminal and persisted-child regression tests; SELF receipt /tmp/fast-slow-u3-r04-20261014/usage-fix-backend-verification.json records vet, full tests, and zero-paid live-tag checks.

Impact: Reconcile the returned cumulative Usage before persisting a failed child run; preserve actual tool-call counts. The original A01 quality result remains FAIL.

Refs: U3 r0.4 A01; /tmp/fast-slow-u3-r04-20261014/A01-independent-assessment.md
2026-10-14 05:46:54 +00:00
weichen 63ebc85695 Make PostgreSQL project audit cutoffs commit ordered
Why: PostgreSQL sequence IDs could commit out of order and make fixed project audit cursors permanently skip a late row.

Evidence: SELF PG cross-page RED, CACHE 10 counterexample, configuration drift RED/GREEN, and pre-commit Go, PG migration, and race receipts are recorded in the W9 task card. Fixed-HEAD checks follow this commit.

Impact: Guard implicit command audit ID allocation with a transaction advisory lock; reject unsafe sequence/default drift; preserve explicit-ID storage migration; report committed-prefix only for verified project pages.

Refs: Approved W9 PostgreSQL cutoff adjustment r1; W9 Backend task card.
2026-10-14 05:41:37 +00:00
weichen ecb86f4309 Recheck project workflow sources without storing prompts
Why: Goal-sensitive workflow verification persisted model prompts and prior output text in trusted receipt rows, while cross-step model inputs could reuse stale machine output.

Evidence: RED receipt privacy test; full Go suite, targeted race, isolated PostgreSQL store/runmgr/storage-migrate tests, vet, and static build passed locally before commit. A known unrelated TempDir cleanup flake failed once and passed on targeted and serial full replay.

Impact: Add optional same-provider source recheck, verify every included item and evidence body before reuse, gate cross-step outputs, reject unsettled attempts, remove raw scope columns, scrub isolated candidate v34 SQLite/PG schemas, and reject unsafe backups.

Refs: Approved RDM stage6 W6 source-recheck design adjustment r1; W6 independent review.
2026-10-14 05:38:11 +00:00
weichen 37e2cb7adb Add one-shot U3 r0.4 A01 evaluation label
ci / vet-and-test (pull_request) Successful in 46s
Why: The separately approved r0.4 trial must use its own bounded authorization label and output file, without reusing exhausted r0.3 artifacts.

Evidence: Both new label tests failed before the change and passed afterward; full Backend vet/Go suite and selected zero-paid live-tag budget/evaluation tests passed. No real provider call was made.

Impact: Evaluation-only selector and output name; production model routing, budgets and runtime behavior unchanged. Trial remains gated on fixed Dev CI and preflight.

Refs: U3 r0.4 one-time A01 resource approval in this conversation; Dev PR #512
2026-10-14 05:23:55 +00:00
weichen 6ba53e43ed Preserve workflow model Resolve scopes for source checks
Why: A goal-sensitive project provider can return different authorized sources for a prompt step and its judge, causing a valid workflow to fail when the judge re-resolves with an empty goal.

Evidence: RED goal-sensitive step test; full compiled workflow positive and withdrawal negative tests; go test ./... -count=1 and targeted race passed locally before commit.

Impact: Persist each trusted model attempt selector outside the public manifest, enforce receipt state continuity, and reauthorize every contributing model turn with its original selector before judging.

Refs: RDM two-trees stage5 W6; W6 independent review P2.
2026-10-14 05:08:35 +00:00
weichen 549798c0a0 Merge current Backend main into U3 candidate
ci / vet-and-test (pull_request) Successful in 46s
Why: Preserve the accepted U7 account transaction and identity safeguards while retaining U3 material checkpoint startup ordering and runtime fixes.

Evidence: The shared app.go edits are in separate startup/account-maintenance sections; full Go and parent composition checks will be rerun after this merge.

Impact: Includes mainline account changes without changing U3 public contracts or model-call budget.

Refs: U3 r0.4 resource-preparation sync; Dev main 05b997b
2026-10-14 05:04:29 +00:00
weichen 7a00b20cd4 Bind workflow judge inputs to trusted step attempts
Why: Compiled workflow critics could resend a prior prompt output after document duty or publication changed across separate model guards.

Evidence: RED cross-guard revocation and retry tests; go test ./... -count=1 and targeted go test -race passed locally before commit.

Impact: Bind materialized prompt bytes to the engine step attempt and responded model receipt; recheck current project sources and epochs before judging, failing closed for unproven or ambiguous output.

Refs: RDM two-trees stage5 W6; stage6 X1 public extension request; W6 independent review P1.
2026-10-14 04:55:14 +00:00
weichen 5f62b3d226 Protect project context from stale history and forged receipts
Why: Revoked project material could remain in later model turns, and plugin Audit could impersonate a model-input manifest.

Evidence: SELF red tests reproduced both paths; updated project revocation, trusted receipt, migration, and entrypoint tests pass locally with go test ./... -count=1 and targeted race checks.

Impact: Rebuild direct model requests after scope changes, sanitize or block unproven generic history, omit legacy project injections, and persist manifests in a dedicated state-checked table.

Refs: TT-C02 TT-C03 TT-C04; stage5 W6; review P1 stale context and manifest provenance
2026-10-14 04:33:05 +00:00
weichen 0e8cd040cc Bind audit publishers and randomize operation IDs
Why: Clock-derived IDs could collide and string-scoped hosts could impersonate a registered audit publisher.

Evidence: Fixed-clock concurrent, ambiguous private offset, forged scoped-host, and registered publisher RED-to-GREEN tests; go test ./... -count=1 and targeted race test passed locally as SELF.

Impact: Generate 192-bit host IDs, reject ambiguous explicit offsets, and issue a publication-only capability at verifier registration.

Refs: Stage 5 W9; approved Stage 6 audit-link design adjustment r1; review of a8fa2fe
2026-10-14 04:29:38 +00:00
weichen e8cd0a45d7 Pin the complete stream terminal guard
ci / vet-and-test (pull_request) Successful in 45s
Why: Backend and Agent-Squad must consume the Agent-Native revision that rejects empty tool_calls and function_call terminals.

Evidence: Full Backend vet and Go test suite passed with both exact gitlinks.

Impact: Dependency gitlinks only; no Backend source change.

Refs: Agent-Native PR #46; Agent-Squad PR #53; U3 r0.4 review closure
2026-10-14 04:12:40 +00:00
weichen a8fa2fe6c9 Bind trusted operation IDs before domain handlers
Why: A domain outbox could not correlate its committed event with the host receipt because the bus generated the operation ID after the handler returned.

Evidence: RED-to-GREEN bus and compiled-handler tests; local go test ./... -count=1 and isolated PostgreSQL operation/link checks passed as SELF.

Impact: Expose a host-generated operation ID to handlers and resolve a unique committed audit offset after dispatch; missing receipts remain retryable and ambiguous rows fail closed.

Refs: Stage 5 W9; approved Stage 6 audit-link design adjustment r1
2026-10-14 04:11:09 +00:00
weichen 44ccd63d3b Add trusted project context to work model calls
Why: Project work currently reaches model calls without current authorized project facts or a persisted input receipt.

Evidence: SELF go test ./... -count=1, targeted go test -race for W6 paths, go vet ./..., and CGO_ENABLED=0 go build ./cmd/agent-system passed on this change set.

Impact: Add optional X1 interfaces, trusted persisted work-task binding, request-scoped context injection, evidence reauthorization, and attempted/responded/unknown manifests. Existing non-project behavior stays available.

Refs: TT-C01 TT-C02 TT-C03 TT-C04; stage5 W6; Dev stage6 X1 public extension request r1
2026-10-14 04:09:58 +00:00
weichen 48fdddee25 Add verified project audit links and button actors
Why: Owner-only command audit cannot prove shared business commits, and button dispatch omitted its authenticated actor.

Evidence: Local go test ./... passed; isolated PostgreSQL cutoff, link, and SQLite-to-PostgreSQL migration receipts are recorded as SELF in the task card.

Impact: Add an optional domain-verified metadata link, exact authorized cross-member read, idempotent storage, actor propagation, and honest PostgreSQL cutoff coverage. Broad audit pagination and other sources remain pending.

Refs: Stage 5 W9; approved Stage 6 audit-link design adjustment r1
2026-10-14 04:05:10 +00:00
weichen 583c074aa4 Close U3 review gaps before provider continuation
ci / vet-and-test (pull_request) Successful in 50s
Why: Missing Usage must stop a paid sequence before a tool terminal is executable; exact attachment names and host-owned choices must also remain reliable.

Evidence: Each reported behavior had a failing regression before its fix. Full Backend vet/Go tests, live-tag budget tests, ten race replays, and six task-owned HTTP/Playwright scenarios passed with the aligned Agent-Native and Agent-Squad pins.

Impact: Evaluation terminal accounting becomes durable before delivery; material fallback matches bounded filenames; ready choices use host options without redundant model fields. No new model calls or budget authorization.

Refs: U3 r0.4 independent review Critical 1 and Important 3-4
2026-10-14 03:57:33 +00:00
weichen c8dd169a1f Add scoped project command audit read slice
Why: Project audit cannot use owner-wide logs or current conversation project state to establish trusted attribution.

Evidence: RED cases were observed locally; go test ./pkg/extension ./internal/store ./internal/app -count=1 passed on this tree (SELF).

Impact: Add optional X2 read and audit.read callback, immutable project/name snapshots, safe owner-only projection, and explicit unsupported coverage. Cross-member publication remains pending.

Refs: Stage 5 W9; Stage 6 X2 public extension request
2026-10-14 03:42:28 +00:00
weichen 726e4a6d2e Exercise material notice outcomes in the browser fixture
ci / vet-and-test (pull_request) Successful in 1m3s
Why: The task-owned mechanism must reproduce stage progress, partial and empty failures, and source-bound choices without a paid provider call.

Evidence: Backend vet/full Go tests and live-tag terminal tests passed; six task-owned HTTP/Playwright cases passed with this fixture.

Impact: Evaluation fixture only; model comparison still disables notice turns unless the explicit mechanism browser flag is set.

Refs: U3 r0.4 Task 6
2026-10-14 03:34:27 +00:00
weichen 8ea9a6e788 Align Agent-Squad dependency with stream guard
ci / vet-and-test (pull_request) Successful in 50s
Why: The Dev pin gate requires every Agent-Native consumer to use the same exact commit; Agent-Squad now pins the U3 r0.4 stream guard.

Evidence: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... passed with this pin.

Impact: Backend dependency gitlink only; no source or public contract change.

Refs: Agent-Squad PR #53; U3 r0.4 Task 6
2026-10-14 03:24:36 +00:00
weichen efed68ca2c Stop evaluation after an incomplete provider terminal
ci / vet-and-test (pull_request) Successful in 53s
Why: the controlled A01 wrapper still allowed another paid request after a malformed tool terminal with returned Usage.

Evidence: local SSE RED→GREEN tests cover invalid JSON, missing DONE, late errors, missing Usage, rejected checkpoints, cross-run stop and record-write failure; live-tag fake tests and full Backend vet/tests pass.

Impact: diagnostic metadata and batch-wide fail-closed stop only; no new paid authorization or production route change.

Refs: U3 r0.4 design; internal/server/work_checkpoint_eval_test.go
2026-10-14 02:57:17 +00:00
weichen fb4b1e3c58 Gate material choices on verified results
Why: failed or ungrounded material notices still asked nontechnical users to choose deliverables and could act on invented model options.

Evidence: failure, partial, no-suggestion, forged-marker, source-drift, reload and host-option RED→GREEN tests; race, isolated PostgreSQL, full Backend vet and tests pass.

Impact: only a completed source-bound result with 3-4 bounded suggestions can offer a choice; failed or stale notices preserve truthful status and block unsolicited delegation.

Refs: U3 r0.4 design; internal/runmgr/material_notice.go; internal/runtime/material_choice_notice.go
2026-10-14 02:51:30 +00:00
weichen 5cdda85e18 Prioritize the first sourced material checkpoint
Why: the background model spent its first turns searching already provided text and failed before saving any staged finding.

Evidence: first-finding and two-phase RED→GREEN tests; SQLite and isolated PostgreSQL pass; full Backend vet and tests pass; v11 prompt grew 337 UTF-8 bytes in the fixed fixture.

Impact: material recipe v11 asks for one short sourced fact before optional search without changing the checkpoint schema or adding a separate model call.

Refs: U3 r0.4 design; internal/runmgr/material.go
2026-10-14 02:28:56 +00:00
weichen 495e72adac Scope material search fallback to current attachments
Why: combined filename and Chinese-keyword queries returned no hits even when the uploaded material was present, delaying useful background findings.

Evidence: combined-query, owner/project, duplicate, resource-policy, version-drift and excerpt RED→GREEN tests; SQLite and isolated PostgreSQL pass; Backend full vet and tests pass with the Agent-Native pin.

Impact: only a no-hit material subagent query may read a verified current-task attachment snapshot; general knowledge retrieval remains unchanged.

Refs: U3 r0.4 design; internal/runmgr/material_retrieve.go
2026-10-14 02:24:47 +00:00
weichen 4363a29653 Serialize tool deltas through runtime events
Why: wrapper-side tool deltas raced with serialized run events and could disappear under load.

Evidence: TestToolCallDeltaBeforeExecution race reproduced on the old path, then passed ten race runs with Agent-Native e86e74b; full Backend vet and tests pass.

Impact: one owner emits conversation events; Backend pins the Agent-Native event identity contract.

Refs: Agent-Native PR #46; internal/runtime/llm_direct.go
2026-10-14 02:24:26 +00:00
weichen 5f834544b0 Reserve a distinct r0.3 A01 evaluation entry
ci / vet-and-test (pull_request) Successful in 45s
Why: A future separately approved model trial must not reuse the consumed r0.2 label or overwrite its raw result.

Evidence: New label/output and 30000-token guard tests failed before implementation, then selected live-tag tests and Backend full Go suite passed.

Impact: Zero-paid evaluation entry only; its name does not grant resource approval or issue provider requests.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 plan Task 5
2026-10-13 17:59:26 +00:00
weichen 5f53f804e6 Constrain unsupported material analysis to cautious forms
Why: A model could put an affirmative approval before a late caution word and have the unsupported claim shown as analysis.

Evidence: Approval-behind-caution and task-prompt tests failed before repair; Backend full Go and no-paid live-tag mechanism suites passed after.

Impact: Keep a small set of cautious qualitative findings, replace all other free analysis with pending, clear model ancillary prose and bump material recipe to v10.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 fifth review Important finding
2026-10-13 17:47:31 +00:00
weichen badf80d491 Reject unchecked relation sources and unknown count units
Why: The fourth independent review found extra short quotes in direct relations and stale counts revised with an unfamiliar unit.

Evidence: Both inputs failed before repair; Backend full Go and selected no-paid live-tag mechanism tests passed after.

Impact: Direct relations accept one complete source, and count proof rejects unsupported numeric segments while preserving the bounded A01 grammar.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 fourth review Important findings
2026-10-13 17:43:13 +00:00
weichen 3f0141de47 Reject incomplete and revised numeric premises
Why: Independent review showed a truncated visible prefix, revised count and same-source shortcut could still produce unsupported claims.

Evidence: Each counterexample failed before repair; positive same-named scope, Backend go test ./... -count=1 and live-tag fake-stream tests passed after.

Impact: Require a visible sentence boundary or complete block, reject unrecognized count/revision segments, and require explicit common scope for unique membership.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 third review Important findings
2026-10-13 17:37:30 +00:00
weichen 6d50e487ca Canonicalize material quotes and own checkpoint status
Why: Independent re-review found single-word decision quotes, project-prefix scope mixing, revoked totals and approval status bypasses.

Evidence: Each new counterexample failed before repair; Backend go test ./... -count=1 and selected live-tag fake-stream tests passed after.

Impact: Display complete visible source sentences, parse only bounded positive count clauses, and set material progress text from the host phase.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 second review Important findings
2026-10-13 17:32:01 +00:00
weichen 9019f9082d Guard checkpoint claims against missing source context
Why: Independent review found negated short quotes, cross-project counts and unstructured batch placement could be shown as facts.

Evidence: Each counterexample failed before repair; Backend go test ./... -count=1 and selected live-tag mechanism/stop tests passed after repair.

Impact: Require unambiguous source sentence context and matching explicit scopes, keep only cautious ordinary analysis without structured evidence, neutralize unsafe status text.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 review P1 findings
2026-10-13 17:20:36 +00:00
weichen 756679dace Exercise pending set relations in mechanism fixtures
Why: Browser-level U3 validation must render a real host-normalized needs_user finding, not only source records.

Evidence: The new Playwright assertion failed against the previous fixture; selected live-tag mechanism and stop-rule tests pass.

Impact: Test-only fake stream adds an unsupported membership claim with source-indexed premises; no provider call.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 plan Task 4
2026-10-13 17:01:52 +00:00
weichen 62ad89578a Pin Core evidence export correction
Why: The generated TypeScript mirror must keep evidence helpers private to the module.

Evidence: Backend go test ./... -count=1 passed with Core b474394; frontend knip fix verification follows after regeneration.

Impact: Nested Core gitlink only; no runtime semantic change.

Refs: Backend PR #193; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 16:48:39 +00:00
weichen 6841e7f45e Apply evidence normalization before checkpoint commits
Why: Valid but underdetermined model claims must become pending without losing the batch or leaking unsupported status text.

Evidence: Mixed-batch, source-input binding, approval-status, tool-receipt and prompt regressions failed before changes; go test ./... -count=1 and selected live-tag fake-provider tests passed.

Impact: Validate all task-bound sources, normalize once, return a safe receipt, and version material processing as v9; no extra provider call.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 evidence implementation plan Task 3
2026-10-13 16:27:33 +00:00
weichen 03ea625c54 Normalize evidence-backed material checkpoint claims
Why: The r0.2 model sample assigned interface-dependent requirements to a phase without source support.

Evidence: New source-count and set-bound tests failed before implementation; budget-item and internal-size regressions failed then passed; go test ./... -count=1 passed.

Impact: Add a pure, conservative normalizer and pin Core c722c16; no checkpoint submission or provider behavior changes yet.

Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 evidence implementation plan Task 2
2026-10-13 16:13:48 +00:00
weichen b0f6d27499 Merge main project access into U3 checkpoint work
Why: The current main adds private project member event handling that must coexist with U3 task projections.

Evidence: Resolved cardsvc and SSE conflicts; corrected atomic project-event proof placement after a compile RED; go test ./... -count=1 passed.

Impact: Preserve both private project visibility and task checkpoint behavior; no real-model rerun.

Refs: Backend PR #193; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 15:53:43 +00:00
weichen 062b6f5af5 Bind evaluation budgets to actual run IDs
ci / vet-and-test (pull_request) Successful in 1m2s
Why: One paid A01 run split into multiple prompt-fingerprint groups, and persisted Usage omitted real checkpoint tool calls.

Evidence: Stable-run grouping, missing-ID refusal and tool-call usage regressions failed before and passed after; full Go, race and live-fixture checks passed.

Impact: Future controlled evaluations count per-run calls and deadlines by host run identity and persist tool-call usage. The failed r0.2 A01 record is unchanged.

Refs: U3 r0.2 A01 independent replay, Task 6
2026-10-13 14:44:53 +00:00
weichen d89c72e018 Reserve a distinct one-shot A01 evaluation label
ci / vet-and-test (pull_request) Successful in 45s
Why: Reusing an exhausted authorization label would overwrite or misattribute the next controlled quality sample.

Evidence: New label/output and 30000-token guarded-run tests failed before implementation and passed after; full Backend Go suite passed.

Impact: The r0.2 A01 harness can be used only after separate resource approval, with an independent output name and existing two-run guard.

Refs: U3 quality implementation plan r0.2, Task 6 preparation
2026-10-13 11:10:33 +00:00
weichen 9126daed90 Preserve valid checkpoint claims and evaluator run identity
ci / vet-and-test (pull_request) Successful in 45s
Why: Independent review found false approval rejections, duplicate checkpoint counting and final-only reminder changes to evaluator grouping.

Evidence: Three focused regressions failed before fixes and passed after; go test ./... -count=1, race and live-tag fixture checks passed.

Impact: Valid questions and denials persist, only distinct checkpoint IDs count, and final-only requests keep their original run fingerprint.

Refs: U3 quality revision r0.2, independent final review
2026-10-13 10:51:05 +00:00
weichen 627b6e4f31 Apply numeric output and retry limits in checkpoint evaluation
Why: The isolated fixture passed integer settings to a float64-only reader, so the intended output cap and retry policy were ineffective.

Evidence: numeric-settings regression failed before and passed after; go test ./... -count=1 and live-tag fixture tests passed.

Impact: Mechanism and future explicitly approved model evaluations use their stated per-call output cap and disabled retries.

Refs: U3 quality revision r0.2, browser mechanism failure
2026-10-13 10:26:44 +00:00
weichen d480030de7 Merge current main contracts into material checkpoint work
Why: Main advanced its account search and embedded Core contracts while the U3 candidate remained in review.

Evidence: go test ./... -count=1 on the merged working tree; Core, UI and Frontend merged tests passed separately.

Impact: Preserve the U3 work and account search while pinning the merged Core contract.

Refs: U3 quality revision r0.2, Dev task-sync conflict
2026-10-13 10:20:23 +00:00
weichen 373f8a1141 Reserve material subagent budget for final answers
Why: Material runs could spend the remaining budget on checkpoints and leave no final response.

Evidence: go test ./... -count=1; go test -race ./internal/runtime -run TestMaterialFinalReserve -count=1; offline replay of the prior A01 usage boundary.

Impact: Material subagents enter final-only mode or stop before another provider request; ordinary subagents retain their existing behavior.

Refs: U3 quality revision r0.2, Task 4
2026-10-13 10:13:48 +00:00
weichen 6345546b18 Reject unsupported approval claims in material checkpoints
Why: A source-valid analysis in the approved A01 sample promoted a requirement into an approved decision.

Evidence: The observed claim and six display fields failed before the guard, then passed with negative/question and mixed-clause regressions; full runmgr tests passed.

Impact: Keep exact material records while rejecting model-authored affirmative approval assertions before persistence; final model quality remains unverified.

Refs: Agent-System-Backend#193; Agent-System-Dev#512
2026-10-13 09:55:27 +00:00
weichen 6ecd75870d Present material sources once beside provided text
Why: The old source index repeated most of a three-document input and its test fixture depended on that duplicate layout.

Evidence: Source prefix, ID scope, parser revision, table, truncation and mechanism tests failed before the change and pass after; full runmgr tests pass.

Impact: Compact the material prompt without widening quote authority, and version the recipe so old results are not reused.

Refs: Agent-System-Backend#193; Agent-System-Dev#512
2026-10-13 09:46:59 +00:00
weichen f74f6a010e Enforce the evaluation stop rule across runs
ci / vet-and-test (pull_request) Successful in 2m26s
Why: A rejected checkpoint did not stop a later paid request, and a history-only fix could miss JSON errors or other active runs.

Evidence: Source and JSON rejection fixtures failed before the instance-level guard, then passed with fail-closed and unrelated-tool checks; independent replay matched both real failed databases.

Impact: Read-only event correlation blocks future provider requests after a checkpoint rejection; original sample failure and stop-rule deviation remain recorded.

Refs: Agent-System-Backend#193; Agent-System-Dev#512
2026-10-13 08:13:23 +00:00
weichen 71fc384154 Stop live evaluation after rejected checkpoints
ci / vet-and-test (pull_request) Successful in 49s
Why: An approved one-shot sample continued to a paid model call after a nonliteral quote was rejected, contrary to its stop rule.

Evidence: A zero-paid provider fixture failed before the guard and passed afterward; the original 14231-token deviation remains recorded.

Impact: Future controlled evaluations refuse subsequent provider requests after a checkpoint tool error; this does not reclassify the failed sample.

Refs: Agent-System-Backend#193; Agent-System-Dev#512
2026-10-13 08:02:06 +00:00
weichen 51d7c6ab87 Count persisted checkpoints in live evaluation
ci / vet-and-test (pull_request) Successful in 51s
Why: A 250ms card poll missed a checkpoint saved about one millisecond before failure.

Evidence: The persistence regression failed before the fix, passed afterward, and independent read-only replay isolated the current conversation and running state.

Impact: Evaluation reads committed card events without changing model output or re-running the paid sample; U3 quality remains failed.

Refs: Agent-System-Backend#193; Agent-System-Dev#512
2026-10-13 07:42:09 +00:00
weichen 7f8674fe68 Bound the approved follow-up A01 evaluation
ci / vet-and-test (pull_request) Successful in 50s
Why: The prior one-shot A01 result failed and its resource approval is exhausted.

Evidence: Follow-up authorization tests failed before the change and pass after; full Backend suite passes.

Impact: Only live evaluation harness gains a separate one-shot result and 30000-token run budget; production validation remains strict.

Refs: Agent-System-Backend#193; Agent-System-Dev#512
2026-10-13 07:19:51 +00:00
weichen e1c994c60c Guide smaller source-literal checkpoint publications
ci / vet-and-test (pull_request) Successful in 52s
Why: The bounded A01 model call produced a five-finding malformed payload and an abbreviated quote that strict validation correctly rejected.

Evidence: Full Backend suite passed; original paid failure and rejected source citations remain unchanged.

Impact: Advertise one to three findings per publication and continuous literal quotes, bumping material recipe identity to v7; Core authority and budget remain unchanged.

Refs: U2/U3 failed A01 assessment and proposed bounded follow-up
2026-10-13 06:50:16 +00:00
weichen 9cbdc82908 Merge current account search before the compact checkpoint candidate
Why: Retain the newest protected Backend main alongside the approved work branch.

Evidence: Exact upstream object fetched; affected checks follow.

Impact: Preserve both histories and review the new model-tool guidance as a separate delta.

Refs: Agent-System-Dev U2/U3 delivery
2026-10-13 06:47:18 +00:00
weichen 0bc4b0f794 Merge current account hardening into work checkpoint branch
ci / vet-and-test (pull_request) Successful in 46s
Why: Preserve the newest protected main for the combined review candidate.

Evidence: Exact incoming object fetched; affected checks will replay.

Impact: Retain both histories without relaxing source or task authority.

Refs: Dev main aa6364f
2026-10-13 06:24:35 +00:00
weichen e69865e11b Seed isolated evaluation routes through the trusted system layer
Why: Upstream now reserves model and mock settings for system authority.

Evidence: Complete merged Backend suite and live resource/cap guards pass.

Impact: Fixture-only startup configuration, same effective route and profile; no further paid calls.

Refs: Upstream scoped settings contract; U2/U3 evaluation
2026-10-13 05:59:57 +00:00
weichen 7855d82bc0 Merge scoped settings and session checks with the work checkpoint contracts
Why: Resolve the component conflict using the tested combined Core lineage.

Evidence: Both exact Core histories are included in beafd753; no source conflict in App or Server.

Impact: Preserve TaskSnapshot and upstream managed settings/session checks; composition replay follows.

Refs: Core beafd753; Backend main 741da4a0
2026-10-13 05:56:45 +00:00
weichen 62a2ab47e8 Report zero accepted findings honestly after stopped work
Why: The failed extra A01 had no accepted findings but claimed saved discoveries and future progress.

Evidence: The zero-findings regression failed before the fix; full RunMgr tests now pass.

Impact: Use truthful conclusion, remaining and next-step text while retaining strict source validation.

Refs: Independent extra A01 assessment; approved R08
2026-10-13 05:54:21 +00:00
weichen 31efafa479 Run the explicitly approved A01 replay with a two-run limit
Why: The user approved one additional A01 sample after the original six-case batch failed its quality gate.

Evidence: Live fixture tests preserve the production output cap and reject paid requests when a third actual run exists.

Impact: Evaluation-only case selection and resource guard; no production behavior changes, no automatic replay.

Refs: User approval 2026-10-13; reported token target 60000
2026-10-13 05:08:35 +00:00
weichen 6a2d85cbb9 Clear continuation promises during restart recovery
Why: A reconciled task must not retain the old queue or running next step after becoming interrupted.

Evidence: Owned queue recovery failed before the fix; RunMgr, actual App startup and evaluation output-cap checks now pass.

Impact: Recovery uses the stopped-task next step, and the mechanism fixture quotes the actual acceptance source text.

Refs: Approved recovery contract; independent terminal-state review
2026-10-13 04:31:49 +00:00
weichen 2a64432fc8 Make stopped work actionable and retain detailed execution errors
Why: A budget-stopped task retained a promise to generate a final result, while readable card errors need the full diagnosis in the execution record.

Evidence: Continuation-promise and missing-error-record regressions failed on the prior implementation; the full Backend suite now passes.

Impact: Terminal next steps require user action, stopped work has clear labels, and precise subagent errors persist outside the default task summary.

Refs: Independent A04 model assessment; approved R08 and R10
2026-10-13 04:26:38 +00:00
weichen ee65604d83 Keep material records literal and preserve production evaluation limits
Why: Authentic quotes do not establish an unstated approval, and the evaluation wrapper must honor tighter production output limits.

Evidence: Approval-overstatement and 780-token-cap regressions failed first; complete Backend suite and fixture cap checks now pass.

Impact: Render material records from trusted labels and validated quotations, reserve interpretation for explicit finding kinds, bump recipe identity and cap the fixture without overriding smaller limits.

Refs: Approved R05; independent model assessment; original failed samples preserved
2026-10-13 04:16:31 +00:00
weichen f6b536029a Merge commit '467718a519eb21980e61b8df541de53e94130e95' into feature/fast-slow-u2-u3 2026-10-13 04:06:22 +00:00
weichen 3e330e3d37 Publish material findings progressively and retain the final conclusion
Why: The first real-model sample batched discoveries, overcalled scope differences and exposed process chatter as a terminal conclusion.

Evidence: Preserve the failed A01 sample; process-text regression failed before the fix, then complete Backend and focused runtime checks passed.

Impact: Bump recipe identity, qualify quantities and approvals, exclude Process parts from canonical results and capture independent historical evaluation snapshots.

Refs: Approved U2/U3 plan; independent model review and native SELF receipts
2026-10-13 04:05:21 +00:00
weichen 4a0418e211 Preserve source authority and task identity through delivery and recovery
Why: Delayed notices, plugin name collisions and startup recovery exposed stale sources or silently created new attempts; deletion lost search index cleanup.

Evidence: Meaningful red regressions and the corrected complete Backend suite passed; production startup was exercised through an isolated subprocess.

Impact: Revalidate notices at delivery, retain plugin dispatch, restore index cleanup and complete all service wiring before recovery and public intake. Scope fixture processes and wait for owned transcript writes.

Refs: Agent-System-Dev feature-fast-slow-u2-u3-plan; independent review findings 1-3 and startup supplement
2026-10-13 03:55:04 +00:00
weichen 3d94c5df1a Connect deferred material intake to the runtime stop hook
Why: Text with short attachments previously completed without starting the intended background material task.

Evidence: SELF real HTTP/browser reproduction shows the absent second task; runtime, manager and server regressions pass with the existing optional port connected.

Impact: Preserves dispatch timing and makes deferred intake observable; comparison records the baseline gap without rerunning a sample.

Refs: fast-slow-u2-u3 A02 and reading acceptance
2026-10-13 03:08:33 +00:00
weichen c50f9f07e8 Guard source previews against content version substitution
Why: Opening a processed source must not show changed current bytes as the quoted version.

Evidence: SELF TaskSourceContent regression fails before and passes after expected-hash enforcement; artifact regressions and live compilation pass.

Impact: Optional content hash narrows the existing authorized artifact read; controlled fixtures select a genuinely provided quotation.

Refs: fast-slow-u2-u3 source-version acceptance
2026-10-13 02:51:07 +00:00
weichen 0dad4f3f70 Merge commit 'dc736e339d01c59e658abc63c81909059bd6ea02' into feature/fast-slow-u2-u3 2026-10-13 02:44:19 +00:00
weichen c2851d498f Use repository prompt recipes in controlled model comparison
Why: Baseline and candidate must exercise their actual default prompts with identical route and resource limits.

Evidence: SELF live-tag compilation passes.

Impact: Evaluation harness only; normal automatic notice quality remains outside the controlled comparison.

Refs: fast-slow-u2-u3 Task 7
2026-10-13 02:41:16 +00:00
weichen 4184430d6d Keep source changes visible and bound evaluation calls consistently
Why: Updated or missing sources must remain visible as limitations, and comparison calls need stable prompt grouping.

Evidence: SELF card/server suites and live-tag compilation pass.

Impact: Adds neutral source limitations and refines test-only provider accounting.

Refs: fast-slow-u2-u3 Tasks 6 and 7
2026-10-13 02:36:57 +00:00
weichen d7597fea05 Build the isolated evaluator with the native provider client
Why: The live harness must use the actual client constructor and preserve MiniMax reasoning separation.

Evidence: SELF live-tag compilation passes without provider calls.

Impact: Test harness only; no production route changes.

Refs: fast-slow-u2-u3 Task 7
2026-10-13 02:31:10 +00:00
weichen 13e880feec Publish validated material findings during background work
Why: Real source-backed discoveries should reach the same task card before the slow channel completes.

Evidence: SELF receipts record runtime and manager checkpoint red-green tests, scoped source/version checks, privacy filtering, quota corrections, and SQLite/PG regressions; fixed composition and live comparison follow.

Impact: Adds a local material-only checkpoint sink with no summary model calls, uses version witnesses for source commits, and preserves existing reading and notification paths.

Refs: fast-slow-u2-u3 Tasks 6 and 7; test-only live HTTP harness bounds shared provider requests without approving HITL
2026-10-13 02:29:23 +00:00
weichen b2f4f98d3c Bind background lifecycle and recovery to persistent tasks
Why: Queued work and completed results need stable identity, execution fencing, and honest recovery.

Evidence: SELF make evidence records four-task promotion, restart reconciliation, stale-executor fencing, and existing material/notice tests.

Impact: Uses namespaced existing execution leases and canonical atomic task updates; legacy results require trusted input bindings before reuse.

Refs: fast-slow-u2-u3 Tasks 2 and 4
2026-10-13 01:39:02 +00:00
weichen dd0c3ce7d0 Persist task projections and replay cursors atomically
Why: Background work needs consistent cards, events, and user reading state.

Evidence: SELF make evidence records SQLite rollback, projection, pin, and regression checks; PostgreSQL replay is pending the composition gate.

Impact: Adds an optional atomic task path and a consistent card replay cursor without schema changes.

Refs: Agent-System-Dev fast-slow-u2-u3 plan Task 3
2026-10-13 01:31:46 +00:00
weichen f9daf64cf3 Identify materials by content and register grounded source blocks
Why: equal-length edits and parser changes must not reuse prior results.

Evidence: content identity and provided-source tests RED to GREEN; backend suite recorded through evidence.

Impact: trusted optional document origins, content keys and source catalog; no new routing or DB schema.

Refs: U2/U3 Task2
2026-10-13 01:16:44 +00:00
weichen 2c740cd13f Consume persistent work snapshot contracts
Why: compile the work projection against the reviewed Core vocabulary.

Evidence: exact local Core task commit materialized; no backend behavior changed in this dependency step.

Impact: Core gitlink only.

Refs: U2/U3 Task1
2026-10-13 01:05:26 +00:00
426 changed files with 63296 additions and 3165 deletions
+150
View File
@@ -0,0 +1,150 @@
name: governance
on:
pull_request:
push:
branches: [main]
jobs:
governance:
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
# 只物化两个治理 gitlink(Guidelines 与 deps/development-manager),非递归;
# 其它 gitlink 不物化、不取回。凭据只以 URL 限定的 extraheader 存在于本进程树内。
python3 - <<'PY_CHECKOUT'
import os
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import urlsplit
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
"deps/development-manager": "Development-Manager",
}
TARGET = "weichen/Agent-System-Backend"
root_token = os.environ.pop("TOKEN", "")
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
if not root_token or not component_token:
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
if os.environ.get("GITHUB_REPOSITORY") != TARGET:
sys.exit("Checkout repository is outside the reviewed allowlist")
sha = os.environ.get("GITHUB_SHA", "")
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
sys.exit("Checkout requires a fixed commit SHA")
# 丢弃继承的 Git 追踪/配置/SSH 钩子;凭据只存在于单个 Git 进程树的精确 URL 头中。
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
config = {
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
"init.templateDir": "", "credential.helper": "",
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
"http.followRedirects": "false", "protocol.allow": "never",
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
}
def git(repo, *args, extra=None):
values = config | (extra or {})
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
for index, (key, value) in enumerate(values.items()):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
return subprocess.run(["git", "-C", str(repo), *args], env=env,
check=True, stdout=subprocess.PIPE).stdout
def materialize_governance(root):
# -r 列出任意深度的 gitlink,但不进入 submodule 内部;其它 gitlink 只记录、从不取回。
links = {}
for entry in git(root, "ls-tree", "-rz", "HEAD").split(b"\0"):
if not entry:
continue
metadata, path = entry.split(b"\t", 1)
mode, kind, commit = metadata.decode().split()
if mode == "160000":
links[path.decode()] = commit
for name in REPOSITORIES:
if name not in links:
raise ValueError("Governance gitlink is missing from the fixed commit")
raw = git(root, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
entries = {}
for item in raw.split(b"\0"):
if not item:
continue
key, value = item.decode().split("\n", 1)
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
if not match:
continue
name, field = match.groups()
if name not in REPOSITORIES:
continue
if field in entries.setdefault(name, {}):
raise ValueError("Duplicate candidate .gitmodules setting")
entries[name][field] = value
# 先整体校验两个治理条目,再进行任何带凭据的取回。
for name, repository in REPOSITORIES.items():
fields = entries.get(name, {})
if fields.get("path") != name:
raise ValueError("Governance submodule path is outside the reviewed allowlist")
trusted_url = f"{SERVER}/weichen/{repository}.git"
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
raise ValueError("Governance submodule URL is outside the reviewed allowlist")
for name, repository in REPOSITORIES.items():
url = f"{SERVER}/weichen/{repository}.git"
# 仅持久化审查过的公开 URL,使 Git 在进程级配置消失后仍识别该 submodule。
git(root, "config", f"submodule.{name}.url", url)
git(root, "submodule", "update", "--init", "--checkout", "--", name,
extra={f"submodule.{name}.url": url,
f"submodule.{name}.update": "checkout",
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
child = root / name
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
raise ValueError("Materialized governance gitlink does not match the fixed SHA")
try:
root = Path.cwd()
# 必须是干净的临时工作区;不复用既有 Git 状态。
if (root / ".git").exists():
raise ValueError("Checkout requires a fresh workspace")
git(root, "init", "-q")
url = f"{SERVER}/{TARGET}.git"
git(root, "remote", "add", "origin", url)
git(root, "fetch", "--depth=1", "origin", sha,
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
materialize_governance(root)
except (ValueError, OSError, subprocess.CalledProcessError) as error:
sys.exit(f"Checkout refused: {error}")
print("Checkout complete; credentials were not persisted in Git configuration")
PY_CHECKOUT
unset TOKEN SUBMODULE_TOKEN
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Validate agent execution configuration
run: scripts/agent-exec validate --repo .
- name: Governance check
run: scripts/dm governance check --repo .
+9
View File
@@ -19,3 +19,12 @@
[submodule "deps/agent-command"]
path = deps/agent-command
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git
[submodule "deps/agent-document-renderer"]
path = deps/agent-document-renderer
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Document-Renderer.git
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
@@ -0,0 +1,47 @@
# Agent-System-Backend 采纳 development-governance-v2 治理套件 任务卡
任务主记录:本文件随 PR 描述维护(PR 链接见下方"结果与验证")。
状态:进行中;owner:weichen。
## 基线与授权
- 权威仓:weichen/Agent-System-Backend;root_target:main;immediate_target:main
- branch_kind:initiative;initiative_id:governance-adoption-Agent-System-Backend
- R0(initial_main_sha):7aa624370af347c3dd3740d7453b13af54ae3ed6;工作区:/home/weichen/projects/Agent-System-Backend-wt-feature-governance-adoption(worktree);分支:feature/governance-adoption
- 父任务信息(仅子任务填写):无
- accepting_requirements:false(单仓采纳 initiative,范围固定为下列十步,不接收新需求)
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=development-governance-v2 Phase 6 每个正式仓库独立 initiative;triage 收据=无(本仓采纳前尚无 triage 入口)
- 授权与交付终点:owner 已授权 development-governance-v2 全部实施(Agent-System-Dev 主记录 docs/superpowers/plans/2026-10-16-development-governance-v2-master.md);终点为本 PR 合入 main 且采纳状态 active。分支保护与 tag 由用户执行。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标(Phase 6 十步):固定 Guidelines SHA(deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb);增加 AGENT-EXECUTION.md;增加共享执行器薄入口(deps/development-manager @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c + scripts/dm、scripts/agent-exec、scripts/task);四条路由(implementation/repair/test/review);任务卡模板与分支元数据;CI 中启用 schema 校验(.gitea/workflows/governance.yml:agent-exec validate + governance check);一次正例运行;负例(缺配置、坏收据、越界路径、未知副作用);独立 Review + CI;采纳状态 active。
非目标:不改业务代码与既有测试;不改分支保护(由用户执行);不发布 tag。
引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| governance-adoption-Agent-System-Backend-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | 39c17561 | implementation | cursor→antigravity→pi | run a1d7d74c(dm-server pinned,attempts=1) | cursor / not-observable | success | 472e5ab31331(apply 快进) | 否 | /tmp/gov-receipt-Agent-System-Backend-r1.json |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | 7aa624370af347c3dd3740d7453b13af54ae3ed6 | 7aa624370af347c3dd3740d7453b13af54ae3ed6 | 2026-10-17T06:53:54Z | 一致 |
## 结果与验证(完成后填写)
- 结果:NOT_RUN
- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。
- 影响与剩余项:NOT_RUN
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| 20261017T071539Z | `scripts/dm governance check --repo .` | 472e5ab31331 | 0 | SELF | ok fixtures;[输出](evidence/governance-adoption/00-20261017T071539Z.log) sha256:f7016c2e2af62a17 |
| 20261017T071539Z | `scripts/agent-exec validate --repo .` | 472e5ab31331 | 0 | SELF | validate: ok;[输出](evidence/governance-adoption/01-20261017T071539Z.log) sha256:6ded2d91ffdfe365 |
| 20261017T071539Z | `python3 /tmp/gov-neg/adopt_cases.py .` | 472e5ab31331 | 0 | SELF | [verdict] ALL PASS cases=5;[输出](evidence/governance-adoption/02-20261017T071539Z.log) sha256:2834e3639ade85b8 |
@@ -0,0 +1,40 @@
# W9 Backend project audit candidate
- 状态:已完成(合入前闭合,2026-10-18)。本 W9 增量随统一渲染器 Backend 分支经 PR #209 合入 main;合入前已同步 Backend main `56a7f9f`(合并提交 `84a6f6f`,当前 HEAD CI vet-and-test/governance 成功)。同步时 main 的迁移 36 `instance_guard` 保持不变,本分支迁移整体顺延为 37–48(文中 v33/v34 等编号为同步前历史编号),跑过旧编号未合入构建的数据库须重建。用户 2026-10-18 授权按计划完成并收尾、业务验收在部署上自行进行;受信 tag `v0.6.0` 由用户亲手发布。
- Scope: TT-A01/A02/A04/U04, approved stage 5 W9. Stage 6 local implementation; human review, formal acceptance, PR, CI, tag, and deployment are pending.
- Branch/worktree: `feature/rdm-two-trees-project-audit` at `/home/weichen/projects/Agent-System-Backend-wt-feature-rdm-two-trees-project-audit`.
- R0: Backend authoritative `origin/main` `5d5fc9891c0eb8994545ccb8b740535d9b99fb0e`; main was clean and equal to the fetched remote before this worktree was created. Core gitlink in this worktree is `5773e74086791731059730bd3726dc8c9cd1381f`.
- Startup receipt: `/tmp/backend-w9-precheck-20261014-01.json` (SELF, exit 2, `SYNC_REQUIRED` for the separate Dev checkout `feature/rdm-increment-acceptance-note`, six commits behind Dev `origin/main`). This Backend task uses the independent-development path in Backend `AGENTS.md`; it does not write that Dev checkout or its task card. The receipt is preserved as a failure, not relabeled READY.
- Approved affected design: `rdm-two-trees-stage6-audit-link-design-adjustment-r1.md`, SHA256 `385d9d7570217ddcce9fe60a60c99dc58c9a4875ad790584a724d09a210a6e52`; user approval in the task session covers this local Backend increment, not tag publication or Stage 7 acceptance.
- Approved PostgreSQL cutoff adjustment: `rdm-two-trees-stage6-w9-pg-audit-cutoff-adjustment-r1.md`, SHA256 `2357b715a7c8f3451b34cc25a578d655e8a48f3f26d9cf7561ea7a13998ca857`; scope is isolated Backend implementation/testing, with no real database migration or release authorization.
- Approved all-writers affected design: `rdm-two-trees-stage6-w9-all-writers-cutoff-adjustment-r1.md`, SHA256 `44f6f7cd34bf95a9c1eb844bfdd70a5c2233e1b5ffc89b3091359dcb5fc3b5d8`; the direct user approval allows the local v34 correction and isolated tests only.
## Implemented local slice
- Optional `ProjectAuditHost` and domain `audit.read` registration. A different plugin, absent registration, or unbound actor cannot read.
- Store write snapshots the project from the owner-matched conversation; query requires the authenticated actor's owner and that immutable project. This deliberately excludes other members' private conversations and unbound historical rows, and does not move old receipts when a conversation changes projects.
- Command audit stores the current verified account display name at write time; old rows expose `not-recorded`. Public receipts omit raw payload and parameter digest. Cursor uses project-and-owner scoped source cutoff.
- Initial commit `c8dd169a1fdc1451fab3b475c14f9636a7f5bf6c` exposed only the actor-owned project slice. The next local increment fixes button actor propagation and adds a trusted domain-commit link publisher, verifier callback, exact linked metadata read, current `audit.read` and per-object metadata checks, and durable idempotency. Unlinked personal conversations remain private.
- Trusted command dispatch now creates an operation ID before calling the compiled handler and passes that ID through `extension.Request`. The handler can commit the same ID with its domain event/outbox; the final Backend audit/receipt uses it. Client `params.operationId` is ignored. An outbox link request with `HostAuditOffset=0` resolves a unique committed host row by owner, fixed project, and operation ID; a missing row returns retryable `ErrProjectAuditPending`, while duplicate rows fail closed. The separate idempotency key still controls retry equivalence.
- Independent review of `a8fa2fe` found two P2 security defects: clock-based operation IDs could collide; and `NewScopedHost(owner,"rdm")` let another compiled plugin self-report the publisher's plugin ID. The new local increment uses 192 bits from `crypto/rand` for each host-generated operation ID. Both zero-offset and explicit-offset publication require a unique committed row for owner/project/operation; a historical duplicate rejects publication, including attempts to link another private command. Verifier registration returns a private `ProjectAuditPublisherFactory` for the registering plugin's background outbox; a generic scoped Host has no publisher method. Fixed-clock concurrent IDs, forged params, ambiguous private offset, forged scoped Host, and legitimate registered publisher each have focused tests.
- The minted publisher exposes only `ProjectAuditPublisher`, not the full owner-scoped `Host`; tests reject attempts to use `NewScopedHost` with a self-reported `rdm` string as publisher authority. A publisher is a registration-time capability, while `IdempotencyKey` remains the separate retry identity.
- Exact-source linked reads do not enumerate hidden links or reveal raw payload, private conversation ID, or content. Body disclosure and a broad project link list are not implemented; the public coverage reports the narrower capability.
- A PostgreSQL-only v33 default function takes a table-specific transaction advisory lock before assigning `command_audit.id`, making supported implicit-ID writes commit in source-offset order. Migration and each PG append/project page verify CACHE 1, ascending non-cyclic positive IDs, the exact guarded default expression, and the next ID above the committed maximum; unsafe drift fails closed. SQLite remains single-writer. The owner-only project page reports `committed-prefix` only after runtime checks. v34 below closes the explicit-ID bypass; online sequence maintenance remains prohibited.
- Isolated PG RED receipt `/tmp/backend-w9-explicit-low-red-20261014-01.json` proved v33 still misreported `committed-prefix`: explicit ID 3 committed after normal ID 4 and disappeared across pages. Local v34 adds `ENABLE ALWAYS` insert and append-only mutation triggers. Every online INSERT checks READ COMMITTED, obtains the same transaction lock, and rejects a nonpositive, nonmonotone, or beyond-reserved ID. UPDATE/DELETE reject. PG append and every strong project page verify both trigger identities, exact event shape, and enablement; the existing default allocator also checks this guard. This is a local candidate, not yet independently reviewed or deployed.
- `storage-migrate` now copies `command_audit` within one ACCESS EXCLUSIVE transaction: it disables only the named insert guard, copies explicit IDs, verifies count and checksum, advances the owned sequence only forward, restores and verifies the guard, and records done before commit. Failure rolls back table data, trigger state, and done; nontransactional `setval` may leave a safe forward gap. Idempotent done checks guard health before reporting success. Other tables retain their existing path.
## Verification and remaining work
- The session saw RED for the missing store and host interfaces, unscoped cutoff, cross-member private disclosure, and conversation-project reassignment before the respective fixes. These RED outputs were not captured by `make evidence`, so this card does not use them as formal evidence.
- SELF receipt `/tmp/backend-w9-go-test-20261014-01.json`: `go test ./...`, exit 0, run on the pre-commit worktree based on R0.
- SELF receipt `/tmp/backend-w9-build-20261014-01.json`: `CGO_ENABLED=0 go build ./cmd/agent-system`, exit 0, run on that pre-commit tree. Generated local binary removed after the build.
- Historical SELF PostgreSQL receipt `/tmp/backend-w9-pg-cutoff-20261014-01.json`: isolated PostgreSQL 18.6 instance on port 55442 reproduced a low-ID transaction committing after a higher visible ID. `/tmp/backend-w9-pg-link-20261014-02.json` verifies the link table, retry/conflict behavior, and the pre-v33 cutoff counterexample. These receipts describe the old `offset-upper-bound-only` candidate, not the new v33 guarantee.
- SELF PostgreSQL receipt `/tmp/backend-w9-pg-migrate-20261014-01.json`: isolated SQLite→PostgreSQL migration preserved one linked receipt and advanced its target sequence; the same migration ID replayed idempotently. The temporary PG instance was stopped and removed after verification.
- SELF PostgreSQL receipt `/tmp/backend-w9-pg-operation-20261014-01.json`: a fresh isolated PG instance reran the link lookup, idempotency/conflict, and low-ID late-commit checks after trusted operation-offset resolution was added. The instance was stopped and removed.
- New PG cross-page RED receipt `/tmp/backend-w9-pg-crosspage-red-20261014-01.json`: before v33, page 1 returned offsets 4 and 2, page 2 returned 1 after offset 3 committed, and the paged view omitted 3. The same test now passes with v33. Isolated PG tests also cover CACHE 10 counterexample/rejection, CACHE 1, sequence rewind into a vacant low ID, down/up with unchanged link IDs, advisory-lock timeout and rollback gaps, and SQLite→PG explicit-ID migration.
- Historical pre-commit SELF load receipt `/tmp/backend-w9-pg-load-precommit-20261014-01.json` captured an initial v33 variant whose unlocked status function later produced false `cutoff unsafe` errors under concurrency; it is **not** valid performance acceptance evidence. The corrected status check reads committed MAX before non-MVCC sequence state and does not lock readers; the default ID allocator alone holds the transaction advisory lock and rechecks after `nextval`. `TestProjectAuditConcurrentReadWriteNeverReportsFalseDrift` and repeated PG load/cross-page runs protect that ordering. Three-condition pre-commit SELF raw receipts: `/tmp/backend-w9-pg-load-readlock-precommit-20261014-01.json` uses a test-only locked status wrapper, while `/tmp/backend-w9-pg-load-unlocked2-precommit-20261014-01.json` uses the final MAX-first unlocked status; each run also logs its v32 direct-INSERT baseline, PostgreSQL 18.6 version, 192 writes per mode/8 workers, elapsed time, p95, and throughput. The wrapper adds overhead and all measurements vary with hardware/load; no performance threshold has been approved, so they are Stage 6 cost evidence only.
- Pre-commit SELF `/tmp/backend-w9-default-drift-red-20261014-01.json` showed that a replacement default containing the guarded function name but executing unguarded `nextval` was accepted; `/tmp/backend-w9-default-drift-green-20261014-01.json` verifies exact-expression rejection. Pre-commit SELF `/tmp/backend-w9-precommit-all-20261014-02.json` and `/tmp/backend-w9-precommit-pg-20261014-02.json` passed before this last guard tightening; fixed-HEAD runs are required below.
- After the guard tightening, pre-commit SELF receipts `/tmp/backend-w9-precommit-all-final-20261014-01.json` (`go test ./... -count=1`), `/tmp/backend-w9-precommit-pg-final-20261014-01.json` (isolated PG store/storage-migrate), and `/tmp/backend-w9-precommit-pg-race-20261014-01.json` (cross-page/concurrent read-write with race detector) all exited 0. These still identify the dirty pre-commit tree, so they do not replace fixed-HEAD verification.
- v34 pre-commit SELF `/tmp/backend-w9-allwriters-load-precommit-20261014-01.json` records isolated PostgreSQL 18.6, three runs of 192 writes per mode with 8 workers, comparing v32 direct INSERT with v34 guarded `Store.AppendAudit`; it also logs three mixed runs of 180 writes with 6 writers and 160 200-row pages with 4 readers. Protected write throughput ranged about 2.7–3.0k/s, write p95 6.4–6.6ms, and mixed large-page p95 1.2–1.9ms on this one host. These are SELF cost signals with no approved threshold and no Stage 7 acceptance meaning; fixed-HEAD tests still follow.
- Integration constraint: this branch uses schema v31 (audit attribution), v32 (links), v33 (PG implicit cutoff), and v34 (all-writer guard), while the independent W6 Backend branch also has a local v31. Neither branch has consumed the other's unmerged work. After the first branch lands, the later branch owner must sync the new main, renumber its unpublished migrations without rewriting an already released version, and rerun fresh/old SQLite and PG migration plus combined regression on the actual merged source. These isolated tests do not prove W6+W9 compatibility.
- Required next W9 work: real RDM committed-event verifier/outbox consumer, Backend project-event source, run ID propagation, model/tool/background receipts, body/detail disclosure, broad authorized linked-operation pagination, history/export, and combined product verification remain unfinished. A domain commit followed by a lost Backend audit append leaves the link pending; recovery must retain and reconcile that unknown state without claiming a host receipt. Exact-link and owner-only paging do not satisfy Stage 7 audit acceptance. PostgreSQL v33 adds a global command-audit append lock; its measured wait/throughput cost has no approved performance threshold and needs human Stage 6 review.
@@ -0,0 +1,30 @@
# feature/regulations-kb-ext20 任务卡
任务主记录:本文件随 PR 描述维护。
状态:已完成(合入前闭合);owner:weichen。已合入 (merge 56a7f9f440);本卡随合入闭合,归档时移入 .task/archive/。
## 基线与授权
- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git;root_target:main;immediate_target:main
- branch_kind:standalone;跨仓总任务:Agent-System-Dev `feature/regulations-kb`([Dev PR #614](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/614))
- R0(initial_main_sha):cac60c0;工作区:/home/weichen/projects/Agent-System-Backend-wt-feature-regulations-kb-ext20;分支:feature/regulations-kb-ext20
- 授权与交付终点:用户 2026-10-18 /goal 指示按法规知识库计划实现全部内容;本任务交付 U9 的基座公开契约 EXT-20(提案:Documents `docs/proposals/2026-10-18-ext-20-regulations-provider.md`,分支 `feature/regulations-kb-u9-contract`),经 PR 合入 main。不含法规提供方实现(属 KB 仓)与 RDM 消费(U6)。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
## 目标与非目标
- 目标:`pkg/extension` 新增 `RegulationsProvider`/`RegulationsRegistrar`/`RegulationsHost` 与 DTO;进程内注册槽仅接受插件 `kbreg` 且只注册一次;分发 Host 实现四个方法并强制调用方身份、查询最小化(机密词拒绝、长度限制)、状态透传与无明文审计;快照、边界与契约/兼容测试。
- 非目标:不实现搜索或语料检索;不改 `KnowledgeSearch` 语义。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| backend-ext20-regulations-provider | pkg/extension、internal/app | implementation | cursor, antigravity, pi | 1 | cursor-agent-stable / auto(session not-observable,新进程) | success | refs/agent-exec/cfc2e055f212789112cf11716f5649c5/c04b36e76696 → aaed108(apply 快进) | 否 | /tmp/regkb-u9-receipt.json |
## 结果与验证(完成后填写)
- 结果:`pkg/extension/regulations.go`(EXT-20 接口、DTO、状态常量、`ErrRegulationsUnavailable`)、`surface.txt` 与 `SurfaceVersion` 2.15.0-experimental;`internal/app/regulations.go` 注册槽(仅 kbreg、一次、非 nil;声明式拒绝)与 inprocHost 四方法(有效账户检查、调用方取自分发上下文、schema 校验、查询去空白/1–256 字符/排除词拒绝且不调用提供方、状态透传、审计仅含修订/计数/状态/查询 sha256);单元与兼容测试。
- 验证(SELF,host 于 HEAD aaed108):`go vet ./pkg/extension/ ./internal/app/` 与 `go test ./pkg/extension/... ./internal/app/...` PASS;全量 `GOWORK=off go vet ./... && go test -count=1 ./...` 40 包 ok,仅 `pkg/identity TestDSNFileMode` 失败——在未改动基线 cac60c0 上同样失败(本机环境问题,本变更未触及 pkg/identity),以 CI(GATE)为准。
- Review:host 主模型审阅实际 diff(base 3187a00 / head aaed108),与提案第 2、4、5 节一致,无阻塞项。
- 影响与剩余项:新增可选能力,未注册提供方时返回不可用,既有行为不变。
+85
View File
@@ -0,0 +1,85 @@
# fix/ci-pg-flaky-tests 任务卡
任务主记录:本文件随 Backend PR 描述维护;阻塞来源为 Dev PR #651。
状态:已完成(合入前闭合);owner:weichen。已合入 (PR #226,merge 508288fbed);本卡随合入闭合,归档时移入 .task/archive/。
## 基线与授权
- 权威仓: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):b5eb89c557e69d532c8d0a6e2e2cbe0258a7f086;工作区:/home/weichen/projects/Agent-System-Backend-wt-fix-ci-pg-flaky-tests(worktree);分支:fix/ci-pg-flaky-tests
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=no accepting initiative;triage 收据=/tmp/backend-ci-flakes-triage-20261020.json;receipt_sha256=21ec3e6337883b983cc00d6b798c30fff859a94b77e0be197744832acd9f35e1;requirement_sha256=1a3a7a2160a5c543f0ab9f7f766034de5b90a8984d99989454b340966758d50e
- 授权与交付终点:用户授权排查和修复阻塞 Dev PR #651 合入的 Backend 测试失败,按项目门禁交付 Backend 修复;不部署、不改 CI、路由或其他业务。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
修复 CI 已观察到的门面卡锚点、后台完成等待、项目事件私有历史分页测试失败。保留断言与隐私边界,以真实异步完成条件消除竞态;不修改与这三个失败无关的功能、依赖或执行器。
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| stabilize-ci-pg-server-tests | 原工作包文件清单与误用的 ** 模式 | repair | cursor, antigravity, pi | 1 | cursor / auto | scope-expansion | 无 | false | /tmp/backend-ci-flakes-implementation-20261020.json |
| ci-pg-async-completion-r2 | e2e_test.go / project_events_test.go / background_test.go | repair | cursor, antigravity, pi | 1 | cursor / auto (effective not-observable) | completed (controller success) | 2ee469011e66b6bedcf6e9d87c8b1c026f7a8cef | false | /tmp/pr651-repair-r2.json |
## 复用材料(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)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:仅修复三个测试文件的异步完成等待;门面卡等待 run.completed 持久事件,项目分页读到共享帧即结束,后台 idle 使用独立有界等待。生产代码、CI 与断言未修改。
- 验证:worker red/green 对照与 focused count=10 通过;host 独立相关用例 count=3、go vet ./...、go test -count=1 ./... 通过(SELF)。完整原始收据由 Dev make evidence 捕获:/tmp/pr651-backend-host-full-20261020.json;源码对象 2ee469011e66b6bedcf6e9d87c8b1c026f7a8cef。当前最终 HEAD GATE 待运行,不以 SELF 放行。
- 影响与剩余项:只影响测试等待;30s/20s 是基于磁盘实测及停顿注入的卡死上限,实际 CI 停顿时长仍不确定。AuthOwner 每帧持久写入的性能观察不在本次范围。旧 fail-stop 隔离现场保留。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
## 恢复与重新定义单元(2026-10-20)
- 用户在交接后明确“继续”;旧 run 290c8b13f4a4a9e5d67df066754ab3fd 已停止,不自动重试或接纳其诊断文件。
- 现场已对账:唯一源文件差异为原隔离目录的 zz_diag_tmp_test.go;实际事件中仅有读源码、局部构建与本机测试/缓存操作,没有发现提交/推送、正式工作树修改或外部请求。原进程与现场写者已退出;隔离目录与本机诊断产物均保留。
- 原执行器 allowed_paths 使用精确路径/目录前缀匹配,工作包的 ** 不起通配作用。新单元采用明确文件清单;诊断只能在这些现有测试中或仓外生成,禁止继续使用原会话。
- 任务 worktree 已完整移至本卡登记位置,Git 与 development-manager 子模块路径已修复;没有删除文件。
- Dev 启动 SELF 收据:/tmp/pr651-resume-20261020-a1.json(发现嵌套 worktree)、a2 至 a5(后续恢复检查,均未宣称通过)。原始完整文件在 /tmp。
- 权威 Backend main 已由 API 核验并合入 c16495006dbf1cf6ee581b48d99bba1e7f9a377c;继承的 O2 改动仅为必需目标同步,不属本次修复。固定 Guidelines 与依赖已物化,配置与治理准入均通过。
- 新单元目标:分别确认三项失败根因,在现有测试的精确路径内建立 red/green 证据;若确需修改生产逻辑,先形成能复现真实行为缺陷的失败测试。禁止弱化断言、仅拉长时间或不受控压力测试。
## Review 与交付
- 主协调者独立 Review:base c16495006dbf1cf6ee581b48d99bba1e7f9a377c,实现对象 2ee469011e66b6bedcf6e9d87c8b1c026f7a8cef;已核对真实完成事件的顺序、共享帧位于所有私有帧之后、等待仍有界、所有隐私与锚点断言保留;无阻塞发现。
- 实施包与终态原始事件分开保存:/tmp/pr651-repair-unit-r2.json、/tmp/pr651-repair-r2.json、/tmp/agent-exec-1000/662a926e726431b930433273fc26f39a/raw-e8392e75611f.log;有效 completed 已在实际 result envelope 中核验。
- 原始实验输出:/tmp/pr651-r2-red-anchor.txt、/tmp/pr651-r2-green-anchor-experiment.txt、/tmp/pr651-r2-stall-redgreen.txt。临时实验代码已撤除。
- 交付:当前最终 HEAD 仍需自身 CI(ci/vet-and-test 与 governance),权威目标同步核验后经 PR 合入;GATE 与实际合入结果在源码外记录,不修改冻结候选。
@@ -0,0 +1,69 @@
# fix/resolve-hitl-run-status-ordering 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:已完成(合入前闭合)。owner:weichen。已合入 PR #237(merge f084d10aa5);本卡随合入闭合,归档时移入 .task/archive/。
## 基线与授权
- 权威仓: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):07228aa38139d974e88573a84ad992fd66166168;工作区:/home/weichen/projects/Agent-System-Backend-wt-fix-resolve-hitl-run-status-ordering(worktree);分支:fix/resolve-hitl-run-status-ordering
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=no accepting initiative;triage 收据=/tmp/be-triage-runhitl-20261021.json;receipt_sha256=39dc1067007f31d2871e03c86a389f882925d5279198b0d3bdf9d8ccd253a6f7;requirement_sha256=2756850b023061065b52aac9b8f318a9802d56bdc97882ac24b749e940ea8559
- 授权与交付终点:修复 Manager.ResolveHITL 的 run 状态写入顺序竞态并补回归测试;按 Backend 仓现行门禁完成 Review、PR、CI、合入、任务卡闭合与回收。不得改其它子系统、不得改 HTTP/server 层、不得放宽断言。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标:Manager.ResolveHITL 先向 `hitlCh` 投递答案、再无条件 `UpdateRun(running)`,会在 runner 已把 run 写成 `completed/ended_at` 之后被覆盖回 `running/ended_at=NULL`,导致 run 永久卡在 running(无执行者)、会话永不释放、后续每次提交都是 409。修复为先以条件更新 `WHERE status='waiting_hitl'` 把 run 推进到 running(由 store 报告是否命中),再交答案给 runner;交答案后该 run 行此后唯一写者是 runner,不会被反向覆盖。
非目标:不改 HTTP/server 层、不改其它 store 表的写入语义、不放宽任何断言、不引入新依赖、不改 schema。
验收条件:(1) 新回归测试在修复前红(runner 抢先结算后 DB 仍为 `completed/ended_at`)、修复后绿;(2) `go test ./internal/runmgr ./internal/store` 全绿;(3) 独立 Review 无阻塞项。
## 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)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:<交付了什么;未发生写无>。
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
- 影响与剩余项:<影响面、遗留风险、后续任务>。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
@@ -0,0 +1,19 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-System-Backend-wt-feature-governance-adoption
head: 472e5ab313318181f1ecad1a351a0c612c453ac0
exit: 0
utc: 20261017T071539Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -0,0 +1,7 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-System-Backend-wt-feature-governance-adoption
head: 472e5ab313318181f1ecad1a351a0c612c453ac0
exit: 0
utc: 20261017T071539Z
---
validate: ok
@@ -0,0 +1,13 @@
command: python3 /tmp/gov-neg/adopt_cases.py .
cwd: /home/weichen/projects/Agent-System-Backend-wt-feature-governance-adoption
head: 472e5ab313318181f1ecad1a351a0c612c453ac0
exit: 0
utc: 20261017T071539Z
---
[object] Agent-System-Backend-wt-feature-governance-adoption HEAD=472e5ab313318181f1ecad1a351a0c612c453ac0
PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=cb5172f341c9
PASS missing-config: run exit=2 terminal=invalid-config validate exit=2
PASS bad-receipt: apply exit=4 head_unchanged=True msg=apply: no local integrity record: changed paths do not match the transport diff
PASS out-of-scope-path: run exit=5 terminal=scope-expansion transport=False
PASS unknown-side-effect: run exit=4 terminal=failed-unknown transport=False
[verdict] ALL PASS cases=5
+68
View File
@@ -0,0 +1,68 @@
# fix/close-merged-task-cards 任务卡
任务主记录:<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):f084d10aa5ea2e4ad2ab4e6eb1268d3e83c6e991;工作区:/home/weichen/projects/Agent-System-Backend-wt-fix-close-merged-task-cards(worktree);分支:fix/close-merged-task-cards
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=no accepting initiative;triage 收据=/tmp/be-triage-cardclose-20261021.json;receipt_sha256=155aba399116f6e47f74df3198f749947a20d5974a2f130eb671cd55ce9cad73;requirement_sha256=a40da80ed0cd7d0bfa59ad4998f17307131c5149c3f9ee3c0575a7a654b1f2de
- 授权与交付终点:把三张已合入但仍留在 `.task/` 顶层的任务卡回填合入事实并移入 `.task/archive/`,消除主检出的卡片欠账。终点为 PR 合入 main。不得改任何生产代码、不得改卡内历史记录内容(只补合入事实与状态)、不得动他人工作区。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标:Backend 主检出的 `.task/` 顶层留有三张 PR 已合并但未归档的任务卡,构成 precheck 的卡片欠账:`feature-regulations-kb-ext20`(PR #212,merge `56a7f9f440`)、`fix-ci-pg-flaky-tests`(PR #226,merge `508288fbed`)、`fix/resolve-hitl-run-status-ordering`(PR #237,merge `f084d10aa5`)。三张卡的合入事实来自 Gitea API(`state=closed, merged=true`)。本任务只做两件事:把状态行补成「已完成(合入前闭合)+ 已合入 PR/merge」并附归档注记,然后把三张卡 `git mv` 到 `.task/archive/`。
非目标:不改任何生产代码或测试;不改卡内的需求、实施、验证、Review 等历史记录,只补状态行与合入事实;不动 `wt-pal-merge` 工作区(他人 PR #237 之外的进行中任务);不改任何子模块 pin。
## 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)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:<交付了什么;未发生写无>。
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
- 影响与剩余项:<影响面、遗留风险、后续任务>。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
+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 —
+66
View File
@@ -0,0 +1,66 @@
# <任务标题> 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:<进行中 / 待审核 / 已合入 / 已放弃>;owner:<填写>。
## 基线与授权
- 权威仓:<仓 URL 或逻辑 ID>;root_target:main;immediate_target:<main 或 feature/initiative>
- branch_kind:<initiative | child | standalone>;initiative_id:<稳定 ID,普通任务为空>
- R0(initial_main_sha):<完整 SHA>;工作区:<路径>(worktree / clone);分支:<feature|fix|hotfix/...>
- 父任务信息(仅子任务填写):parent_branch=<父分支>;parent_head_sha=<SHA>;parent_pr=<父 PR>
- accepting_requirements:<true | false>(仅 initiative 可为 true;冻结后为 false)
- 需求分流:verdict=<JOIN | NEW>;selected_initiative=<分支或无>;候选=<分支@HEAD 列表或无>;理由=<不适配/适配依据>;triage 收据=<源码外路径与摘要>
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## 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)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:<交付了什么;未发生写无>。
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
- 影响与剩余项:<影响面、遗留风险、后续任务>。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
+70
View File
@@ -0,0 +1,70 @@
# Project Agent Execution Configuration
# 项目工程 Agent 执行路由配置
适用范围:定义本项目中由 AI / Agent 主导的代码实施、修复、测试与审阅所调用的 Agent CLI 路由。
解析器识别正文中唯一一个 ```agent-execution 代码块。正文外的说明文字可根据项目需要调整。
通用硬规则见 Development-Guidelines 的 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)。
本配置为项目本地路由权威,声明具体的候选 profile、尝试次数上限、超时、集成入口与豁免路径;
代码块须通过 `deps/development-guidelines/templates/schemas/agent-execution.schema.json` 校验后才能登记为 active。
`fallback_to_host` 只能为 `never`;旧值 `all-unavailable` 仅供迁移工具读取,不能通过准入。
下例的 CLI 名称、命令与路径均为占位,按项目实际执行器替换。
```agent-execution
schema: agent-execution/v1
execution:
fresh_process_per_unit: true
permission: run-everything
fallback_to_host: never
max_parallel: 3
timeout_seconds: 7200
idle_timeout_seconds: 600
summary_bytes: 16384
raw_output_bytes: 10485760
max_attempts_per_profile: 3
emergency_profile: codex
roles:
implementation: [cursor, antigravity, pi]
repair: [cursor, antigravity, pi]
test: [pi, antigravity, cursor]
review: [pi, antigravity, cursor, codex]
profiles:
cursor:
adapter: cursor
executable: "cursor-agent-stable"
model: auto
# 2026-10-20 临时禁用:cursor 账本月配额耗尽(重置 2026-11-10),原因记录于
# Dev U3 任务卡;恢复时改回 enabled: true。
enabled: false
antigravity:
adapter: antigravity
executable: agy
model: auto
# 2026-10-20 临时禁用:Gemini 端点连续 3 次在最终流式响应 EOF(长会话不稳定),恢复时改回 enabled: true。
enabled: false
pi:
adapter: pi
executable: pi
model: auto
enabled: true
codex:
adapter: codex
executable: codex
model: auto
enabled: true
integration:
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
transport:
command: "scripts/agent-exec apply"
admission:
command: "scripts/agent-exec admit"
cleanup:
command: "scripts/agent-exec cleanup"
```
+18
View File
@@ -34,3 +34,21 @@
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
1. 缺 Guidelines 固定版本(`deps/development-guidelines` gitlink 未就绪或 HEAD 不符)时,Agent 只能只读分析,不得实施。
2. 缺有效 `AGENT-EXECUTION.md`(`scripts/dm governance check` 未通过)时禁止 AI 实施。
3. 缺共享执行器(`scripts/agent-exec` 或 `deps/development-manager` 不可用)时,状态为 `blocked/unavailable`,不得改由 host 替代。
4. 不允许 host 代写实施单元;实施、修复与测试必须经 `AGENT-EXECUTION.md` 登记的候选执行。
5. 人类直接开发按其自身授权与项目规则进行,不受本条款约束。
6. 分支保护与规范 tag 由用户负责设置与维护,Agent 不得代为设置。
入口:
- 需求分流:`scripts/task triage discover|decide|verify`
- 任务启动:`scripts/task start …`
- PR 检查:`scripts/task pr-check …`
- Agent 执行:`scripts/agent-exec run|apply|admit|validate`
AI 实施单元仅通过 `scripts/agent-exec run` 运行(见 AGENT-EXECUTION.md),其结果经 `scripts/agent-exec apply` 集成。
+2
View File
@@ -9,6 +9,8 @@
- `go test ./...` — 单元 + 端到端(mock runner 全链路:流式/折叠部件/HITL 三分支/子代理任务树/工件/上传解析/中文检索/SSE 在线与重放/命令路由/插件装卸 fail-closed)。
- `CGO_ENABLED=0 go build ./cmd/agent-system` — 单二进制(静态,内嵌前端后)。
- `scripts/agent-exec validate --repo .` — 校验 AGENT-EXECUTION.md 执行路由配置。
- `scripts/dm governance check --repo .` — 只读核验治理文件与 gitlink(CI:.gitea/workflows/governance.yml)。
- 存储:嵌入式 SQLite(modernc.org/sqlite,纯 Go);FTS5 全文 + CJK LIKE 兜底。
- 依赖:agent-system-core(deps submodule + replace)、agent-native(runtime/llm 复用)。
+6 -16
View File
@@ -43,17 +43,14 @@ func cliStore(t *testing.T) (string, *store.Store) {
}
return dir, s
}
func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) {
for _, sub := range []string{"create", "initialize-account", "bootstrap-admin"} {
func TestCreateAndInitializeAccountDoNotRequireCredentialOutput(t *testing.T) {
for _, sub := range []string{"create", "initialize-account"} {
t.Run(sub, func(t *testing.T) {
dir, s := cliStore(t)
if sub != "bootstrap-admin" {
if _, e := s.BootstrapFirstAdminWrite(store.AccountWrite{Owner: "root", Name: "Root", Actor: "test"}); e != nil {
t.Fatal(e)
}
if _, e := s.BootstrapFirstAdminWrite(store.AccountWrite{Owner: "root", Name: "Root", Actor: "test"}); e != nil {
t.Fatal(e)
}
out := filepath.Join(t.TempDir(), "secret")
args := []string{sub, "--data", dir, "--owner", "ada", "--out-file", out}
args := []string{sub, "--data", dir, "--owner", "ada"}
if sub == "initialize-account" {
a, e := s.CreatePendingAccount("root", "ada", store.AccountProfile{Name: "Ada"})
if e != nil {
@@ -63,14 +60,7 @@ func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) {
}
rc, _ := accountCLI(t, args...)
if rc != 0 {
t.Fatalf("credential command failed: %d", rc)
}
info, e := os.Stat(out)
if e != nil {
t.Fatal("credential output file missing")
}
if info.Mode().Perm() != 0600 {
t.Fatal("credential output permissions are not private")
t.Fatalf("command failed without credential output file: %d", rc)
}
})
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"agent-system-backend/internal/execenv"
)
func runEnv(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system env <ensure|lock|gc|doctor|rebase|python> --root <dir>")
return 2
}
sub := args[0]
fs := flag.NewFlagSet("env "+sub, flag.ExitOnError)
rootPath := fs.String("root", "", "环境根目录")
ttl := fs.Duration("ttl", 7*24*time.Hour, "gc 保留窗口")
bin := fs.String("bin", "", "python link 要指向的解释器")
_ = fs.Parse(args[1:])
if *rootPath == "" {
fmt.Fprintln(os.Stderr, "env: --root 必填")
return 2
}
root, err := execenv.Open(*rootPath)
if err != nil {
fmt.Fprintln(os.Stderr, "env:", err)
return 1
}
ctx := context.Background()
switch sub {
case "ensure":
py, err := root.EnsurePython(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "env ensure:", err)
return 1
}
id, err := execenv.NewBroker(root, nil, execenv.Pip{Python: py}).EnsureBase(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "env ensure:", err)
return 1
}
fmt.Println(id)
return runDoctor(ctx, root, py, true)
case "lock":
id, err := root.CurrentBase()
if err != nil || id == "" {
fmt.Fprintln(os.Stderr, "env lock: 没有 current 基础层")
return 1
}
lock, err := root.ReadLock(id)
if err != nil {
fmt.Fprintln(os.Stderr, "env lock:", err)
return 1
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(lock); err != nil {
fmt.Fprintln(os.Stderr, "env lock:", err)
return 1
}
return 0
case "gc":
removed, err := execenv.NewBroker(root, nil, execenv.Pip{}).GC(*ttl)
if err != nil {
fmt.Fprintln(os.Stderr, "env gc:", err)
return 1
}
for _, id := range removed {
fmt.Println(id)
}
return 0
case "doctor":
py, ok := root.PythonBin()
if !ok {
py = "/usr/bin/python3"
}
return runDoctor(ctx, root, py, true)
case "rebase":
py, err := root.EnsurePython(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "env rebase:", err)
return 1
}
id, err := execenv.NewBroker(root, nil, execenv.Pip{Python: py}).Rebase(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "env rebase:", err)
return 1
}
fmt.Println(id)
return 0
case "python":
action := "install"
if fs.NArg() > 0 {
action = fs.Arg(0)
}
switch action {
case "install":
py, err := root.EnsurePython(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "env python:", err)
return 1
}
fmt.Println(py)
return 0
case "link":
if *bin == "" {
fmt.Fprintln(os.Stderr, "env python link: --bin 必填")
return 2
}
if err := root.LinkPython(*bin); err != nil {
fmt.Fprintln(os.Stderr, "env python link:", err)
return 1
}
fmt.Println(*bin)
return 0
default:
fmt.Fprintln(os.Stderr, "env python: install | link")
return 2
}
default:
fmt.Fprintf(os.Stderr, "env: 未知动作 %s\n", sub)
return 2
}
}
func runDoctor(ctx context.Context, root *execenv.Root, py string, sandbox bool) int {
var pyPath []string
if id, err := root.CurrentBase(); err == nil && id != "" {
pyPath = append(pyPath, root.LayerDir(id))
}
mounts := append([]string{}, pyPath...)
if prefix := root.PythonPrefix(); prefix != "" {
mounts = append(mounts, prefix)
}
if resolved, err := filepath.EvalSymlinks(py); err == nil {
py = resolved
}
if err := execenv.Doctor(ctx, py, pyPath, nil, sandbox, mounts); err != nil {
fmt.Fprintln(os.Stderr, "env doctor:", err)
return 1
}
fmt.Println("doctor ok")
return 0
}
+97 -53
View File
@@ -10,11 +10,11 @@ import (
"fmt"
"os"
"strings"
"time"
"agent-system-backend/internal/app"
"agent-system-backend/internal/config"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
"agent-system-backend/pkg/identity"
)
@@ -45,9 +45,13 @@ func main() {
os.Exit(runIdentity(os.Args[2:]))
case "capabilities":
os.Exit(runCapabilities(os.Args[2:]))
case "env":
os.Exit(runEnv(os.Args[2:]))
case "migrate":
os.Exit(runMigrate(os.Args[2:]))
default:
fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1])
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities> [参数]")
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities|env|migrate> [参数]")
os.Exit(2)
}
}
@@ -67,20 +71,37 @@ func main() {
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
execDepsDir := fs.String("exec-deps-dir", "", "sandbox 档只读依赖层目录(site-packages 等;空=不挂载)")
execWorkspace := fs.String("exec-workspace", "", "sandbox 档工作区模式:空=直写会话目录 | overlay=冻结基底+私有 upper+CAS 回收(S0)")
execEnvRoot := fs.String("exec-env-root", "", "分层 Python 环境根(解释器、基础层、扩展层;空=不启用)")
tier := fs.String("tier", "", "部署档:空=auto(DSN→PG,否则 SQLite)| local(SQLite,不允许 DSN)| team(PG fail-closed:缺 DSN/未认证拒绝启动,PG 故障拒绝就绪)")
pgMaxOpen := fs.Int("pg-max-open", 0, "PG 最大打开连接(0=默认 16)")
pgMaxIdle := fs.Int("pg-max-idle", 0, "PG 最大空闲连接(0=默认 8)")
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
adoptGuard := fs.Bool("adopt-instance-guard", false, "数据库被替换或锁文件被重建后,显式重写 SQLite 活动服务守卫")
envFile := fs.String("env-file", "", "受保护 dotenv 文件(权限 600;补齐进程环境未设置的 SUPER_ADMIN_OWNER/SUPER_ADMIN_PASSWORD;空 = 取 AGENT_SYSTEM_ENV_FILE)")
extension.RegisterCLIFlags(fs)
fs.Parse(os.Args[1:])
handled, hookErr := extension.RunCLIHooks(extension.Options{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, AuthMode: *auth, DisableExecTools: *noExec, ExecBackend: *execBackend,
})
if hookErr != nil {
fmt.Fprintln(os.Stderr, "agent-system:", hookErr)
os.Exit(1)
}
if handled {
return
}
cliSet := visited(fs)
delete(cliSet, "adopt-instance-guard")
delete(cliSet, "env-file")
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace, ExecEnvRoot: *execEnvRoot,
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
}, cliSet)
@@ -94,13 +115,48 @@ func main() {
}
os.Exit(1)
}
superAdmin, err := config.LoadSuperAdmin(os.LookupEnv, *envFile, resolved.AuthEnabled())
if err == nil {
err = app.ValidateSuperAdmin(resolved.AuthEnabled(), superAdmin)
}
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
pluginDefaults, merr := extension.MergeHostOptions(extension.Options{})
if merr != nil {
fmt.Fprintln(os.Stderr, "agent-system:", merr)
os.Exit(1)
}
disableExec := resolved.DisableExecTools
if !cliSet["disableExecTools"] {
disableExec = disableExec || pluginDefaults.DisableExecTools
}
execBackendResolved := resolved.ExecBackend
if !cliSet["execBackend"] && execBackendResolved == "" {
execBackendResolved = pluginDefaults.ExecBackend
}
extOpt := extension.Options{
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir,
DisableUploadIndexing: pluginDefaults.DisableUploadIndexing,
DisableConvCardMirror: pluginDefaults.DisableConvCardMirror,
DisableExecTools: disableExec, ExecBackend: execBackendResolved,
}
if err := extension.PrepareCompiled(&extOpt); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
if err := app.Run(app.Options{
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
ExecWorkspace: resolved.ExecWorkspace,
Tier: resolved.Tier,
DisableUploadIndexing: extOpt.DisableUploadIndexing,
DisableConvCardMirror: extOpt.DisableConvCardMirror,
DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
ExecWorkspace: resolved.ExecWorkspace, ExecEnvRoot: resolved.ExecEnvRoot,
Tier: resolved.Tier,
AdoptInstanceGuard: *adoptGuard,
SuperAdmin: superAdmin,
PGPool: store.PoolOptions{
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
@@ -124,6 +180,14 @@ func visited(fs *flag.FlagSet) map[string]bool {
name = "allowedHosts"
case "disable-exec-tools":
name = "disableExecTools"
case "exec-backend":
name = "execBackend"
case "exec-deps-dir":
name = "execDepsDir"
case "exec-workspace":
name = "execWorkspace"
case "exec-env-root":
name = "execEnvRoot"
case "pg-max-open":
name = "pgMaxOpen"
case "pg-max-idle":
@@ -162,6 +226,7 @@ func runConfig(args []string) int {
execBackend := fs.String("exec-backend", "", "")
execDepsDir := fs.String("exec-deps-dir", "", "")
execWorkspace := fs.String("exec-workspace", "", "")
execEnvRoot := fs.String("exec-env-root", "", "")
tier := fs.String("tier", "", "")
pgMaxOpen := fs.Int("pg-max-open", 0, "")
pgMaxIdle := fs.Int("pg-max-idle", 0, "")
@@ -173,7 +238,7 @@ func runConfig(args []string) int {
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace, ExecEnvRoot: *execEnvRoot,
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
}, visited(fs))
@@ -367,7 +432,7 @@ func (out *credentialOutput) deliver(kind, owner, plaintext string, expiresAt an
// runUser:本地 owner 账号初始化、续办和只读/撤销操作。
func runUser(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|initialize-account|list|revoke-login-key|bootstrap-admin> [flags]")
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|initialize-account|list|revoke-login-key> [flags]")
return 2
}
sub := args[0]
@@ -382,26 +447,36 @@ func runUser(args []string) int {
name := fs.String("name", "", "显示名")
password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)")
passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码")
outFile := fs.String("out-file", "", "把明文凭据独占写入该文件(0600)")
outFile := fs.String("out-file", "", "凭据交付文件(仅历史兼容子命令可用)")
instanceID := fs.String("instance-id", "", "严格核验库内服务身份")
admin := fs.Bool("admin", false, "管理员")
role := fs.String("role", "user", "角色 user|admin")
admin := fs.Bool("admin", false, "管理员(现已拒绝:仅超级管理员可在服务端任命管理员)")
role := fs.String("role", "user", "角色 user|admin(admin 现已拒绝)")
openExisting := fs.Bool("open-existing", false, "兼容旧参数;账户命令始终只打开既有存储")
generatePassword := fs.Bool("generate-password", false, "旧随机初始密码参数(现已拒绝)")
fs.String("purpose", "recover", "旧 issue-login-key 参数(现已拒绝)")
fs.Int("ttl-minutes", 30, "旧 issue-login-key 参数(现已拒绝)")
fs.Bool("revoke-all-credentials", false, "旧 issue-login-key 参数(现已拒绝)")
keyID := fs.String("id", "", "revoke-login-key 凭据编号")
out := fs.String("out", "", "凭据保护交付文件(0600 JSON)")
out := fs.String("out", "", "凭据交付文件(JSON;仅历史兼容子命令可用)")
fs.Parse(args[1:])
if sub == "reset-password" || sub == "issue-login-key" {
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery")
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password")
return 2 // reject before DSN/password reads, storage opens or output reservation
}
if sub == "create" && (*password != "" || *passwordStdin || *generatePassword) {
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only")
return 2
}
// No CLI path may appoint an administrator or establish the super
// administrator (R4, R5); reject before storage opens or output reservation.
if sub == "create" && (*admin || *role == "admin") {
fmt.Fprintln(os.Stderr, "user create:", app.ErrCLIAdminAppointment)
return 2
}
if sub == "bootstrap-admin" {
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", app.ErrCLIBootstrapAdminDisabled)
return 2
}
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
@@ -417,24 +492,14 @@ func runUser(args []string) int {
}
*dsn = resolvedDSN
*password = resolvedPassword
credentialAction := sub == "create" || sub == "initialize-account" || sub == "bootstrap-admin"
if credentialAction && *outFile == "" && *out == "" {
fmt.Fprintln(os.Stderr, "user: --out or --out-file is required for protected credential delivery")
if (sub == "create" || sub == "initialize-account") && (*outFile != "" || *out != "") {
fmt.Fprintln(os.Stderr, "user:", "local activation redemption is removed; do not use --out/--out-file. Ask an administrator to issue POST /api/admin/users/{owner}/one-time-password")
return 2
}
if !credentialAction && (*outFile != "" || *out != "") {
if sub != "create" && sub != "initialize-account" && (*outFile != "" || *out != "") {
fmt.Fprintln(os.Stderr, "user: 此子命令不生成凭据")
return 2
}
var delivery *credentialOutput
if credentialAction {
delivery, err = reserveCredentialOutput(*outFile, *out)
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
return 2
}
defer delivery.abort()
}
switch sub {
case "create":
if *owner == "" {
@@ -450,15 +515,12 @@ func runUser(args []string) int {
fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role)
return 2
}
pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
_, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
if err != nil {
fmt.Fprintln(os.Stderr, "user create:", err)
return 1
}
if err := delivery.deliver("login-key", *owner, pw, nil); err != nil {
fmt.Fprintln(os.Stderr, "user create:", err)
return 1
}
fmt.Fprintf(os.Stderr, "owner=%s created as pending; ask an administrator to issue POST /api/admin/users/%s/one-time-password\n", *owner, *owner)
fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID)
return 0
case "initialize-account":
@@ -466,18 +528,15 @@ func runUser(args []string) int {
fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation 必填")
return 2
}
plain, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration)
_, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration)
if err != nil {
fmt.Fprintln(os.Stderr, "user initialize-account:", err)
return 1
}
if err := delivery.deliver("activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
fmt.Fprintln(os.Stderr, "user initialize-account:", err)
return 1
}
fmt.Fprintf(os.Stderr, "owner=%s remains pending; ask an administrator to issue POST /api/admin/users/%s/one-time-password\n", targetOwner, targetOwner)
return 0
case "reset-password":
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED")
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password")
return 2
case "list":
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
@@ -489,7 +548,7 @@ func runUser(args []string) int {
fmt.Println(string(out))
return 0
case "issue-login-key":
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED")
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password")
return 2
case "revoke-login-key":
if *keyID == "" {
@@ -502,21 +561,6 @@ func runUser(args []string) int {
}
fmt.Println("revoked")
return 0
case "bootstrap-admin":
if *owner == "" {
fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填")
return 2
}
plain, err := app.BootstrapAdmin(*dsn, *data, *owner, *openExisting)
if err != nil {
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
return 1
}
if err := delivery.deliver("bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
return 1
}
return 0
default:
fmt.Fprintln(os.Stderr, "user: 未知子命令", sub)
return 2
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"flag"
"fmt"
"os"
"agent-system-backend/internal/app"
)
func runMigrate(args []string) int {
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
data := fs.String("data", "./data", "数据目录(SQLite 档)")
dsn := fs.String("dsn", "", "PostgreSQL DSN(空则读 AGENT_SYSTEM_DSN)")
adopt := fs.Bool("adopt-instance-guard", false, "数据库被替换或锁文件被重建后,显式重写守卫")
_ = fs.Parse(args)
storage := *dsn
if storage == "" {
storage = os.Getenv("AGENT_SYSTEM_DSN")
}
if err := app.Migrate(app.Options{
DataDir: *data,
StorageDSN: storage,
AdoptInstanceGuard: *adopt,
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
return 1
}
return 0
}
+58
View File
@@ -5,6 +5,8 @@ import (
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
)
func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) {
@@ -29,6 +31,62 @@ func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) {
}
}
func TestUserCLIRejectsAdministratorAppointmentBeforeOpeningStorage(t *testing.T) {
data := t.TempDir()
out := filepath.Join(data, "delivery.json")
for _, args := range [][]string{
{"create", "--data", data, "--owner", "mallory", "--admin", "--out", out},
{"create", "--data", data, "--owner", "mallory", "--role", "admin", "--out", out},
{"bootstrap-admin", "--data", data, "--owner", "mallory", "--out", out},
} {
if code := runUser(args); code != 2 {
t.Fatalf("administrator appointment accepted: %v code=%d", args, code)
}
}
if _, err := os.Stat(filepath.Join(data, "agent-system.db")); !os.IsNotExist(err) {
t.Fatalf("rejected commands opened/created a database: %v", err)
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Fatalf("rejected commands wrote a delivery file: %v", err)
}
}
func TestUserCLIAdministratorAppointmentLeavesExistingStoreUnchanged(t *testing.T) {
dir, s := cliStore(t)
if _, err := s.BootstrapSuperAdmin("root", mustHash(t, "Root-Password-1")); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"create", "--data", dir, "--owner", "mallory", "--admin", "--out-file", filepath.Join(t.TempDir(), "a")},
{"create", "--data", dir, "--owner", "mallory", "--role", "admin", "--out-file", filepath.Join(t.TempDir(), "b")},
{"bootstrap-admin", "--data", dir, "--owner", "mallory", "--out-file", filepath.Join(t.TempDir(), "c")},
} {
rc, output := accountCLI(t, args...)
if rc != 2 || !strings.Contains(output, "super administrator") {
t.Fatalf("%v: rc=%d output=%q", args, rc, output)
}
}
if _, err := s.GetUser("mallory"); err == nil {
t.Fatal("rejected CLI appointment created an account")
}
rc, _ := accountCLI(t, "create", "--data", dir, "--owner", "ada")
if rc != 0 {
t.Fatalf("ordinary local create failed: %d", rc)
}
if ada, err := s.GetAccountByOwner("ada"); err != nil || ada.IsAdmin || ada.IsSuperAdmin {
t.Fatalf("ordinary local create: %+v %v", ada, err)
}
}
func mustHash(t *testing.T, password string) string {
t.Helper()
hash, err := store.HashPassword(password)
if err != nil {
t.Fatal(err)
}
return hash
}
func TestCredentialDeliveryCreatesExclusive0600File(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "credential.json")
+178
View File
@@ -9,8 +9,78 @@ import (
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func TestMigrateTrustedContextManifestWithoutRawScope(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
src, err := store.OpenSQLite(fixture)
if err != nil {
t.Fatal(err)
}
m := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "model-attempt", InputSHA256: "request-hash", Delivery: "attempted",
Included: []extension.ProjectContextManifestEntry{{SourceID: "source-a", Revision: "r1", SourceHash: "ha", InputSHA256: "input-hash"}}}
if err := src.AppendContextManifestWithScope("alice", "r1", m, store.ContextManifestScope{Bound: true}); err != nil {
t.Fatal(err)
}
m.Delivery = "responded"
if err := src.AppendContextManifestWithScope("alice", "r1", m, store.ContextManifestScope{Bound: true}); err != nil {
t.Fatal(err)
}
task, err := src.CreateWorkTask("alice", "rdm", "p1", "c1", "document", "Prepare document")
if err != nil {
t.Fatal(err)
}
stepHash := strings.Repeat("a", 64)
if _, err := src.DB().Exec(`INSERT INTO workflow_step_sources(run_id,step_id,owner,project_id,step_attempt_id,output_sha256,attempt_id,authorization_epoch,publication_epoch,watermark,version,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
"r1", "draft", "alice", "p1", "step-attempt", stepHash, "model-attempt", "auth-1", "pub-1", "water-1", "v1", "2026-10-14T00:00:00Z"); err != nil {
t.Fatal(err)
}
if err := src.Close(); err != nil {
t.Fatal(err)
}
snapshot := filepath.Join(t.TempDir(), "snapshot.db")
if err := cmdBackup([]string{"--source", fixture, "--out", snapshot}); err != nil {
t.Fatal(err)
}
if err := cmdMigrate([]string{"--source", snapshot, "--target", target, "--migration-id", "context-copy"}); err != nil {
t.Fatal(err)
}
pg, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer pg.Close()
receipts, err := pg.ListContextManifests("alice", "r1")
if err != nil || len(receipts) != 2 || len(receipts[1].Included) != 1 || receipts[1].Included[0].SourceID != "source-a" {
t.Fatalf("content-free model receipts not copied: count=%d err=%v", len(receipts), err)
}
workScope, err := pg.WorkTaskScope("alice", "rdm", task.ID)
if err != nil || workScope.ProjectID != "p1" || workScope.ConversationID != "c1" {
t.Fatalf("trusted work task binding not copied: scope=%+v err=%v", workScope, err)
}
stepSource, err := pg.WorkflowStepSource("alice", "r1", "draft")
if err != nil || stepSource.StepAttemptID != "step-attempt" || stepSource.AttemptID != "model-attempt" || stepSource.ProjectID != "p1" {
t.Fatalf("workflow step source not copied: source=%+v err=%v", stepSource, err)
}
var rawColumns int
if err := pg.DB().QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil || rawColumns != 0 {
t.Fatalf("migrated PG schema has raw prompt columns: count=%d err=%v", rawColumns, err)
}
if err := cmdMigrate([]string{"--source", snapshot, "--target", target, "--migration-id", "context-copy"}); err != nil {
t.Fatalf("idempotent context migration rerun: %v", err)
}
receipts, err = pg.ListContextManifests("alice", "r1")
if err != nil || len(receipts) != 2 {
t.Fatalf("rerun duplicated or lost model receipts: count=%d err=%v", len(receipts), err)
}
if err := pg.DB().QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil || rawColumns != 0 {
t.Fatalf("rerun reintroduced raw prompt columns: count=%d err=%v", rawColumns, err)
}
}
// U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例):
// 夹具 → 受管迁移 → 校验和验证 → 幂等重跑 → 断点续传 → 非空目标拒绝 →
// 未知 owner 阻塞。每用例独立 PG schema(与 store 测试同隔离约定)。
@@ -107,6 +177,15 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) {
if _, err := db.Exec(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES ('2026-10-11T03:00:00Z','cmd-2','http','chat','done','op-2','c1','{}','alice')`); err != nil {
t.Fatalf("序列推进失败(复制后插入撞主键):%v", err)
}
var linkedResource string
if err := db.QueryRow(`SELECT resource_id FROM project_audit_links WHERE host_audit_offset=7 AND source_id='commit-1'`).Scan(&linkedResource); err != nil || linkedResource != "doc-1" {
t.Fatalf("审计链接迁移丢失: resource=%q err=%v", linkedResource, err)
}
var newLinkID int64
if err := db.QueryRow(`INSERT INTO project_audit_links(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 (7,'p1','alice','domain-commit','commit-2','op-1','document','doc-2','','metadata','outbox-2','2026-10-11T03:00:01Z') RETURNING id`).Scan(&newLinkID); err != nil || newLinkID <= 3 {
t.Fatalf("审计链接序列未推进: id=%d err=%v", newLinkID, err)
}
// 报告不含秘密值。
var report string
if err := db.QueryRow(`SELECT report FROM migration_control WHERE migration_id='mig-e2e'`).Scan(&report); err != nil || strings.Contains(report, "sk-secret-value") {
@@ -114,6 +193,105 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) {
}
}
func TestCommandAuditExclusiveCopyRollsBackAndNeverRewindsSequence(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
src, err := openSourceRO(fixture)
if err != nil {
t.Fatal(err)
}
defer src.Close()
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
db := st.DB()
if _, err := db.Exec(controlDDL); err != nil {
t.Fatal(err)
}
cols, pk, err := targetColumns(db, "command_audit")
if err != nil {
t.Fatal(err)
}
bools, err := boolColumns(db, "command_audit", cols)
if err != nil {
t.Fatal(err)
}
count, checksum, err := sourceScan(src, "command_audit", cols, pk, bools)
if err != nil {
t.Fatal(err)
}
blocker, err := db.Begin()
if err != nil {
t.Fatal(err)
}
var existing int
if err := blocker.QueryRow(`SELECT COUNT(*) FROM command_audit`).Scan(&existing); err != nil {
t.Fatal(err)
}
timeoutDB, err := sql.Open("pgx", store.AppendPGRuntimeParam(target, "lock_timeout", "100"))
if err != nil {
t.Fatal(err)
}
if _, err := migrateCommandAudit(timeoutDB, src, "retry-audit", cols, pk, bools, count, checksum); err == nil || !strings.Contains(err.Error(), "lock timeout") {
t.Fatalf("exclusive copy did not fail on a held reader: %v", err)
}
timeoutDB.Close()
if err := blocker.Rollback(); err != nil {
t.Fatal(err)
}
if _, err := migrateCommandAudit(db, src, "retry-audit", cols, pk, bools, count+1, checksum); err == nil {
t.Fatal("mismatched copy committed despite in-transaction target count check")
}
var rows, done int
if err := db.QueryRow(`SELECT COUNT(*) FROM command_audit`).Scan(&rows); err != nil || rows != 0 {
t.Fatalf("failed copy left rows: %d %v", rows, err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM migration_table_state WHERE table_name='command_audit'`).Scan(&done); err != nil || done != 0 {
t.Fatalf("failed copy recorded done: %d %v", done, err)
}
var enabled string
if err := db.QueryRow(`SELECT tgenabled FROM pg_trigger WHERE tgrelid='command_audit'::regclass AND tgname='project_audit_insert_guard_v1'`).Scan(&enabled); err != nil || enabled != "A" {
t.Fatalf("failed copy left guard disabled: %q %v", enabled, err)
}
var seq string
if err := db.QueryRow(`SELECT pg_get_serial_sequence('command_audit','id')`).Scan(&seq); err != nil {
t.Fatal(err)
}
// setval is non-transactional; an interrupted offline attempt can leave a
// forward gap even though its rows and trigger-state changes roll back.
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(`LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(`ALTER TABLE command_audit DISABLE TRIGGER project_audit_insert_guard_v1`); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(`SELECT setval($1::regclass,50,true)`, seq); err != nil {
t.Fatal(err)
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
if _, err := migrateCommandAudit(db, src, "retry-audit", cols, pk, bools, count, checksum); err != nil {
t.Fatalf("retry after rollback: %v", err)
}
var last int64
if err := db.QueryRow(`SELECT last_value FROM ` + seq).Scan(&last); err != nil || last < 50 {
t.Fatalf("copy rewound sequence: last=%d err=%v", last, err)
}
var nextID int64
if err := db.QueryRow(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload)
VALUES ('2026-10-14T00:00:00Z','after-copy','','','committed','','','alice','{}') RETURNING id`).Scan(&nextID); err != nil || nextID <= 50 {
t.Fatalf("post-copy ID did not advance: id=%d err=%v", nextID, err)
}
}
func TestMigrateResumeAfterInterrupt(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
+205 -32
View File
@@ -242,6 +242,9 @@ func cmdBackup(args []string) error {
return err
}
defer db.Close()
if err := refuseCandidateRawContextScope(db); err != nil {
return err
}
if _, err := db.Exec(`VACUUM INTO ?`, *out); err != nil {
return fmt.Errorf("vacuum into: %w", err)
}
@@ -281,13 +284,15 @@ func cmdBackup(args []string) error {
// copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。
// admission_leases 是运行态执行许可(迁移无意义),明确排除。
var copyOrder = []string{
"projects", "users", "account_directory_revision", "profiles", "settings", "plugins",
"conversations", "runs", "messages", "events", "artifacts",
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
"activity_cards", "project_event_shares", "command_audit", "prompt_modules",
"projects", "users", "roles", "role_permissions", "user_role_bindings", "system_grants", "authority_state", "policy_receipts", "role_authority_import_state",
"account_directory_revision", "profiles", "settings", "plugins",
"conversations", "runs", "messages", "project_work_outbox", "events", "artifacts",
"document_render_deliveries",
"hitl_cards", "human_decisions", "plans", "todos", "knowledge", "knowledge_vectors",
"activity_cards", "project_event_shares", "command_audit", "project_audit_links", "prompt_modules",
"sessions", "api_keys",
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks",
"memory_entries", "audit_events", "pending_confirms", "llm_usage_daily",
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks", "work_task_bindings",
"memory_entries", "audit_events", "context_manifests", "workflow_step_sources", "pending_confirms", "llm_usage_daily",
"secret_keys", "settings_snapshots", "regen_pending", "context_entries",
"user_commands", "pending_notices", "recovery_holds",
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
@@ -298,6 +303,22 @@ var copyOrder = []string{
"account_events",
"redeem_attempts",
"recovery_proposals", "recovery_proposal_proofs",
// v35 分层 Python 环境(无外键;申请与会话绑定要随库迁走)
"exec_env_requests", "exec_env_bindings",
// U8:活动服务锁的库内标记(无外键;迁移须保留以免目标库丢掉锁根)
"instance_guard",
// v52 intent_session(A1/U-A1b):会话维度的挂起动作预期 + 已收集槽位
// (无外键,纯数据表;键含 owner+project+conversation,随 conversations
// 之后迁移即可,无需额外排序)
"intent_session",
}
// schemaSeededSingletons:schema 迁移在每个新库上预置的单行表。它们不是目标
// 业务数据,复制时须由源库的行替换。
var schemaSeededSingletons = map[string]bool{
"account_directory_revision": true,
"authority_state": true,
"role_authority_import_state": true,
}
const controlDDL = `
@@ -337,6 +358,9 @@ func cmdMigrate(args []string) error {
return err
}
defer src.Close()
if err := refuseCandidateRawContextScope(src); err != nil {
return err
}
snapID, _, err := fileSHA256(*source)
if err != nil {
return err
@@ -345,6 +369,9 @@ func cmdMigrate(args []string) error {
if err != nil {
return err
}
if err := validateSourceMigrationLedger(src); err != nil {
return err
}
// 目标:先用 store 同款迁移器建 schema/扩展(OpenPostgres 会跑迁移与
// 未知版本门禁),再落控制表。DSN 不回显、不入报告。
@@ -411,6 +438,57 @@ func cmdMigrate(args []string) error {
return writeReport(rep, *out)
}
func validateSourceMigrationLedger(src *sql.DB) error {
rows, err := src.Query(`SELECT version,name FROM schema_migrations`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var version int
var name string
if err := rows.Scan(&version, &name); err != nil {
return err
}
if err := store.ValidateMigrationIdentity(version, name); err != nil {
return fmt.Errorf("source %w", err)
}
}
return rows.Err()
}
// Candidate context-scope v35 may contain full model prompts in private columns.
// Never copy or back up such a source implicitly; it must be migrated and
// reviewed in an isolated Backend store before this tool accepts it.
func refuseCandidateRawContextScope(db *sql.DB) error {
if !sqliteHasTable(db, "context_manifests") {
return nil
}
var columns int
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('context_manifests') WHERE name IN ('resolve_goal','resolve_target_id')`).Scan(&columns); err != nil {
return err
}
if columns > 0 {
return fmt.Errorf("candidate context scope text columns remain; isolate and scrub source before backup or migration")
}
var version int
if sqliteHasTable(db, "schema_migrations") {
if err := db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&version); err != nil {
return err
}
}
if version >= 35 {
if !sqliteHasTable(db, "context_scope_scrub_state") {
return fmt.Errorf("candidate context physical scrub marker missing; isolate and scrub source before backup or migration")
}
var pending int
if err := db.QueryRow(`SELECT pending FROM context_scope_scrub_state WHERE id=1`).Scan(&pending); err != nil || pending != 0 {
return fmt.Errorf("candidate context physical scrub pending or unverified; isolate and scrub source before backup or migration")
}
}
return nil
}
// guardIdentity:目标非空且身份不同即拒绝(R7:不覆盖他人数据)。
// - 存在其他 migration_id 的完成记录 → 拒绝;
// - 同 migration_id 但 source_snapshot_id 不同(换了快照续传)→ 拒绝;
@@ -451,9 +529,9 @@ func guardIdentity(db *sql.DB, migrationID, snapID string, srcVersion int) error
func businessRows(db *sql.DB) (int64, error) {
var total int64
for _, t := range copyOrder {
// Schema setup seeds this singleton before a migration starts. It is
// replaced from the source during copying and is not target business data.
if t == "account_directory_revision" {
// Schema setup seeds these singletons before a migration starts. They are
// replaced from the source during copying and are not target business data.
if schemaSeededSingletons[t] {
continue
}
var n int64
@@ -514,9 +592,18 @@ func migrateTable(db *sql.DB, src *sql.DB, migrationID, table string) (map[strin
if countNow != srcCount || sumNow != srcChecksum {
return nil, fmt.Errorf("源与登记不符(疑似换快照):count %d/%d checksum %s…/%s…", countNow, srcCount, sumNow[:12], srcChecksum[:12])
}
if table == "command_audit" {
var guarded bool
if err := db.QueryRow(`SELECT project_audit_cutoff_safe('command_audit'::regclass) AND project_audit_writers_guarded('command_audit'::regclass)`).Scan(&guarded); err != nil || !guarded {
return nil, fmt.Errorf("command_audit idempotent guard is unsafe: safe=%v err=%v", guarded, err)
}
}
entry := map[string]any{"table": table, "source_count": countNow, "target_count": dstCountStored, "checksum": sumNow, "state": "done(idempotent)"}
return entry, nil
}
if table == "command_audit" {
return migrateCommandAudit(db, src, migrationID, cols, pk, bools, countNow, sumNow)
}
if err := copyRows(db, src, table, cols); err != nil {
return nil, err
}
@@ -669,7 +756,11 @@ func sourceScan(src *sql.DB, table string, cols, pk []string, bools map[string]b
return scanChecksum(rows, cols, bools)
}
func targetScan(db *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
type rowsQuerier interface {
Query(string, ...any) (*sql.Rows, error)
}
func targetScan(db rowsQuerier, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
rows, err := db.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ORDER BY ` + quoteJoin(pk, `"`))
if err != nil {
return 0, "", err
@@ -692,6 +783,18 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
if err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := copyRowsInTx(tx, src, table, cols, bools); err != nil {
return err
}
return tx.Commit()
}
func copyRowsInTx(tx *sql.Tx, src *sql.DB, table string, cols []string, bools map[string]bool) error {
colList := quoteJoin(cols, `"`)
ph := make([]string, len(cols))
for i := range ph {
@@ -708,16 +811,12 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
for i := range vals {
ptrs[i] = &vals[i]
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// The destination migrator creates a fresh singleton directory revision at
// schema startup. Replace it with the source's opaque revision so existing
// cursors/visible-change history remain tied to the migrated account data.
if table == "account_directory_revision" {
if _, err := tx.Exec(`DELETE FROM account_directory_revision`); err != nil {
// The destination migrator seeds fresh singletons (directory revision,
// authority epoch, role import state) at schema startup. Replace them with
// the source's rows so existing cursors/visible-change history and authority
// state remain tied to the migrated data.
if schemaSeededSingletons[table] {
if _, err := tx.Exec(`DELETE FROM "` + table + `"`); err != nil {
return err
}
}
@@ -748,7 +847,76 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
if err := srcRows.Err(); err != nil {
return err
}
return tx.Commit()
return nil
}
// command_audit alone needs an offline transaction: explicit source IDs do not
// pass the online monotone guard, and disabling it must never become visible
// separately from the copied rows, sequence advance, or done marker.
func migrateCommandAudit(db *sql.DB, src *sql.DB, migrationID string, cols, pk []string, bools map[string]bool, sourceCount int64, sourceChecksum string) (map[string]any, error) {
tx, err := db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := tx.Exec(`LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`); err != nil {
return nil, err
}
if _, err := tx.Exec(`ALTER TABLE command_audit DISABLE TRIGGER project_audit_insert_guard_v1`); err != nil {
return nil, err
}
if err := copyRowsInTx(tx, src, "command_audit", cols, bools); err != nil {
return nil, err
}
targetCount, targetChecksum, err := targetScan(tx, "command_audit", cols, pk, bools)
if err != nil {
return nil, err
}
if targetCount != sourceCount || targetChecksum != sourceChecksum {
return nil, fmt.Errorf("command_audit copy mismatch: count %d/%d checksum %s/%s", targetCount, sourceCount, targetChecksum, sourceChecksum)
}
if err := advanceCommandAuditSequence(tx); err != nil {
return nil, err
}
if _, err := tx.Exec(`ALTER TABLE command_audit ENABLE ALWAYS TRIGGER project_audit_insert_guard_v1`); err != nil {
return nil, err
}
var guarded bool
if err := tx.QueryRow(`SELECT project_audit_cutoff_safe('command_audit'::regclass) AND project_audit_writers_guarded('command_audit'::regclass)`).Scan(&guarded); err != nil || !guarded {
return nil, fmt.Errorf("command_audit guard not restored: safe=%v err=%v", guarded, err)
}
if _, err := tx.Exec(`INSERT INTO migration_table_state (migration_id, table_name, source_count, target_count, checksum, state, finished_at)
VALUES ($1,'command_audit',$2,$3,$4,'done',$5)
ON CONFLICT(migration_id, table_name) DO UPDATE SET source_count=excluded.source_count, target_count=excluded.target_count, checksum=excluded.checksum, state=excluded.state, finished_at=excluded.finished_at`,
migrationID, sourceCount, targetCount, sourceChecksum, nowISO()); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return map[string]any{"table": "command_audit", "source_count": sourceCount, "target_count": targetCount, "checksum": sourceChecksum, "state": "done"}, nil
}
func advanceCommandAuditSequence(tx *sql.Tx) error {
var seq string
if err := tx.QueryRow(`SELECT pg_get_serial_sequence('command_audit','id')`).Scan(&seq); err != nil || seq == "" {
return fmt.Errorf("command_audit sequence missing: %v", err)
}
var last int64
var called bool
if err := tx.QueryRow(fmt.Sprintf(`SELECT last_value,is_called FROM %s`, seq)).Scan(&last, &called); err != nil {
return err
}
var maxID sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(id) FROM command_audit`).Scan(&maxID); err != nil {
return err
}
if maxID.Valid && (maxID.Int64 > last || (maxID.Int64 == last && !called)) {
if _, err := tx.Exec(`SELECT setval($1::regclass,$2,true)`, seq, maxID.Int64); err != nil {
return err
}
}
return nil
}
func sqliteBool(v any) bool {
@@ -766,30 +934,34 @@ func sqliteBool(v any) bool {
// 后续应用插入会撞主键)。无序列表为空操作。
func advanceSequences(db *sql.DB, table string) error {
rows, err := db.Query(`
SELECT pg_get_serial_sequence($1, column_name)
SELECT column_name, pg_get_serial_sequence(format('%I.%I',table_schema,table_name), column_name)
FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name=$1
AND (column_default LIKE 'nextval%' OR is_identity='YES')`, table)
WHERE table_schema=current_schema() AND table_name=$1`, table)
if err != nil {
return err
}
defer rows.Close()
var seqs []string
var seqs []struct{ name, column string }
for rows.Next() {
var s any
if err := rows.Scan(&s); err != nil {
var column string
var seq sql.NullString
if err := rows.Scan(&column, &seq); err != nil {
return err
}
if s != nil {
seqs = append(seqs, s.(string))
if seq.Valid {
seqs = append(seqs, struct{ name, column string }{seq.String, column})
}
}
if err := rows.Err(); err != nil {
return err
}
for _, s := range seqs {
if _, err := db.Exec(fmt.Sprintf(`SELECT setval('%s', COALESCE((SELECT MAX(id) FROM "%s"), 1))`, s, table)); err != nil {
return fmt.Errorf("setval %s: %w", s, err)
// Sequence ownership remains authoritative when a column's default is a
// guarded function rather than a literal nextval expression.
column := `"` + strings.ReplaceAll(s.column, `"`, `""`) + `"`
tableName := `"` + strings.ReplaceAll(table, `"`, `""`) + `"`
if _, err := db.Exec(fmt.Sprintf(`SELECT setval($1::regclass, COALESCE((SELECT MAX(%s) FROM %s), 1))`, column, tableName), s.name); err != nil {
return fmt.Errorf("setval %s (%s): %w", s.name, s.column, err)
}
}
return nil
@@ -799,7 +971,8 @@ func advanceSequences(db *sql.DB, table string) error {
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
func checkOwners(db *sql.DB, src *sql.DB) error {
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards",
"project_event_shares", "command_audit", "sessions", "api_keys", "login_keys", "account_events", "recovery_proposals"}
"project_event_shares", "command_audit", "project_audit_links", "project_work_outbox", "work_task_bindings", "context_manifests", "workflow_step_sources",
"sessions", "api_keys", "login_keys", "account_events", "recovery_proposals"}
for _, t := range ownerTables {
if !sqliteHasTable(src, t) {
continue
+131 -3
View File
@@ -1,7 +1,10 @@
package main
import (
"bytes"
"database/sql"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
@@ -27,6 +30,23 @@ func buildFixture(t *testing.T) string {
return path
}
func TestMigrateRejectsSourceLedgerNameCollisionBeforeTargetOpen(t *testing.T) {
source := buildFixture(t)
db, err := sql.Open("sqlite", source)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE schema_migrations SET name='work_task_bindings' WHERE version=35`); err != nil {
db.Close()
t.Fatal(err)
}
db.Close()
err = cmdMigrate([]string{"--source", source, "--target", "invalid://target", "--migration-id", "name-collision"})
if err == nil || !strings.Contains(err.Error(), "source migration identity mismatch") || !strings.Contains(err.Error(), "legacy feature migration numbering") {
t.Fatalf("candidate branch ledger was not rejected before opening target: %v", err)
}
}
func seedEdgeRows(t *testing.T, st *store.Store) {
t.Helper()
// users(含 is_admin/must_change 布尔)与单租户默认身份。
@@ -70,8 +90,10 @@ func seedEdgeRows(t *testing.T, st *store.Store) {
mustExec(t, st, `INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version) VALUES
('ac1', 'p1', 'c1', '', 'alice', '{"title":"卡"}', 'active', 1, '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 2)`)
// 命令审计(SERIAL 主键:显式 id 插入)。
mustExec(t, st, `INSERT INTO command_audit (id, time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES
(7, '2026-10-11T01:00:00Z', 'cmd-1', 'http', 'chat', 'done', 'op-1', 'c1', '{"actor":"alice"}', 'alice')`)
mustExec(t, st, `INSERT INTO command_audit (id, time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner, project_id, actor_name) VALUES
(7, '2026-10-11T01:00:00Z', 'cmd-1', 'http', 'chat', 'committed', 'op-1', 'c1', '{"actor":"alice","commandId":"cmd-1","operationId":"op-1","state":"committed","conversationId":"c1"}', 'alice', 'p1', '爱丽丝')`)
mustExec(t, st, `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
(3,7,'p1','alice','domain-commit','commit-1','op-1','document','doc-1','','metadata','outbox-1','2026-10-11T01:00:01Z')`)
// 设置('' 默认租户 scope)。
mustExec(t, st, `INSERT INTO settings (owner, scope, "values") VALUES ('', 'runtime', '{"rateLimitPerMin":0}')`)
// 秘密字段(值不外泄:迁移按行复制,报告无正文)。
@@ -170,7 +192,8 @@ func TestCopyOrderCoversAllFixtureTables(t *testing.T) {
for _, t := range copyOrder {
known[t] = true
}
known["schema_migrations"] = true // 迁移器管理,不随迁
known["schema_migrations"] = true // 迁移器管理,不随迁
known["context_scope_scrub_state"] = true // SQLite 候选原文物理清理状态,不随迁
// 运行态表不随迁:执行许可是进程瞬态;执行租约与跨实例命令属当前
// 实例的调度现场(接管语义在 U5 内由 lease/fencing 处理,不复制历史)。
for _, rt := range []string{"admission_leases", "conversation_executions", "run_commands"} {
@@ -195,3 +218,108 @@ func TestCopyOrderCoversAllFixtureTables(t *testing.T) {
t.Fatalf("copyOrder 漏表(新表加入 schema 后须同步工具清单):%v", missing)
}
}
func TestBackupAndMigrateRejectCandidateRawContextScope(t *testing.T) {
path := buildFixture(t)
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`DELETE FROM schema_migrations WHERE version=41`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO context_manifests(id,attempt_id,owner,target_id,project_id,input_sha256,delivery,manifest_json,created_at,scope_bound,resolve_goal,resolve_target_id) VALUES('candidate-ctx','candidate-attempt','alice','r1','p1','input-hash','attempted','{}','2026-10-14T00:00:00Z',1,'OLD_RESTRICTED_BODY','doc-private')`); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "unsafe-backup.db")
if err := cmdBackup([]string{"--source", path, "--out", out}); err == nil || !strings.Contains(err.Error(), "candidate context scope text") {
t.Fatalf("backup accepted candidate v38 raw scope: %v", err)
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Fatalf("rejected backup still created output: %v", err)
}
if err := cmdMigrate([]string{"--source", path, "--target", "postgres://invalid", "--migration-id", "unsafe"}); err == nil || !strings.Contains(err.Error(), "candidate context scope text") {
t.Fatalf("migration accepted candidate v38 raw source: %v", err)
}
scrubbed, err := store.OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
if err := scrubbed.Close(); err != nil {
t.Fatal(err)
}
if err := cmdBackup([]string{"--source", path, "--out", out}); err != nil {
t.Fatalf("scrubbed source rejected: %v", err)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(data, []byte("OLD_RESTRICTED_BODY")) {
t.Fatal("backup retained candidate raw scope body")
}
}
func TestBackupAndMigrateRejectPendingOrMissingContextScrubMarker(t *testing.T) {
path := buildFixture(t)
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for _, query := range []string{
`CREATE TABLE IF NOT EXISTS context_scope_scrub_state(id INTEGER PRIMARY KEY,pending INTEGER NOT NULL)`,
`INSERT OR REPLACE INTO context_scope_scrub_state(id,pending) VALUES(1,1)`,
} {
if _, err := db.Exec(query); err != nil {
t.Fatal(err)
}
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
assertRejected := func(name string) {
t.Helper()
out := filepath.Join(t.TempDir(), name+".db")
if err := cmdBackup([]string{"--source", path, "--out", out}); err == nil {
t.Fatalf("backup accepted %s context scrub state", name)
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Fatalf("rejected %s backup created output: %v", name, err)
}
if err := cmdMigrate([]string{"--source", path, "--target", "postgres://invalid", "--migration-id", name}); err == nil {
t.Fatalf("migration accepted %s context scrub state", name)
}
}
assertRejected("pending")
db, err = sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`DROP TABLE context_scope_scrub_state`); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
assertRejected("missing")
db, err = sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`DELETE FROM schema_migrations WHERE version=41`); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
assertRejected("v38-missing")
}
@@ -0,0 +1,101 @@
package main
import (
"database/sql"
"path/filepath"
"testing"
"agent-system-backend/internal/store"
)
// 新库 schema 预置的单行表:必须在 copyOrder 内、确由迁移预置、不计入
// businessRows,且复制时由源库行替换(而非 ON CONFLICT 保留目标预置行)。
func openFreshStore(t *testing.T, name string) *store.Store {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), name))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func tableColumns(t *testing.T, db *sql.DB, table string) []string {
t.Helper()
rows, err := db.Query(`SELECT name FROM pragma_table_info(?) ORDER BY cid`, table)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var cols []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
t.Fatal(err)
}
cols = append(cols, c)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if len(cols) == 0 {
t.Fatalf("%s 无列", table)
}
return cols
}
func TestSchemaSeededSingletonsAreSeededAndCopied(t *testing.T) {
inOrder := map[string]bool{}
for _, tbl := range copyOrder {
inOrder[tbl] = true
}
fresh := openFreshStore(t, "fresh.db").DB()
for tbl := range schemaSeededSingletons {
if !inOrder[tbl] {
t.Errorf("%s 不在 copyOrder 内", tbl)
}
var n int
if err := fresh.QueryRow(`SELECT COUNT(*) FROM "` + tbl + `"`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("%s 新库预置行数=%d,期望 1", tbl, n)
}
}
n, err := businessRows(fresh)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("新库 businessRows=%d,预置单行表不得计为业务数据", n)
}
}
func TestCopyReplacesSeededAuthorityState(t *testing.T) {
src := openFreshStore(t, "src.db").DB()
if _, err := src.Exec(`UPDATE authority_state SET authority_epoch=7, catalog_version=9 WHERE id=1`); err != nil {
t.Fatal(err)
}
dst := openFreshStore(t, "dst.db").DB()
cols := tableColumns(t, src, "authority_state")
tx, err := dst.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if err := copyRowsInTx(tx, src, "authority_state", cols, nil); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
var count int
var epoch, catalog int64
if err := dst.QueryRow(`SELECT COUNT(*), MAX(authority_epoch), MAX(catalog_version) FROM authority_state`).Scan(&count, &epoch, &catalog); err != nil {
t.Fatal(err)
}
if count != 1 || epoch != 7 || catalog != 9 {
t.Fatalf("目标 authority_state 未被源行替换:count=%d epoch=%d catalog=%d", count, epoch, catalog)
}
}
Vendored Submodule
+1
@@ -0,0 +1,235 @@
# W6-X1 Source-Recheck Review (r1)
**Reviewer:** independent (read-only)
**Branch:** `feature/w6-x1-source-recheck` @ `8c410b9`
**Base:** `b4399e3` (merge of candidate `6ba53e4` + `main`)
**Scope (per packet):** `pkg/extension/project_context.go`, `internal/store/context_manifest*`,
`internal/runmgr/project_context*`, `internal/app/project_context*.go`,
`internal/workflow` (judge + consumption points), `internal/ctxasm/`,
`cmd/storage-migrate/`
**Methodology:** static code review of the diff vs base, no file mutation, no git operation;
test files were read as evidence of behaviour, not run.
---
## 0. What the diff actually changed
`git diff --stat 689e176..8c410b9` (also equals `b4399e3..8c410b9`):
```
cmd/storage-migrate/integration_test.go | 10 +
internal/app/project_source_recheck_test.go | 97 +++++++
internal/runmgr/project_context.go | 24 +-
internal/runmgr/project_context_test.go | 2 +-
internal/runmgr/project_source_recheck_test.go | 349 +++++++++++++++++++++++++
internal/store/context_manifest.go | 24 ++
internal/store/context_manifest_gate_test.go | 45 +++
```
The patch is **purely additive / strengthening** of the r1 (candidate `6ba53e4`) revision. The
only existing-file edits are:
1. `internal/runmgr/project_context.go` — three narrow inserts inside `recheckManifest`:
* `ctx.Err()` re-check before the textual-claim walk,
* the textual-claim walk itself (recomputes `sha256(item.Text)` for every `current.Items[i]`
and refuses if it doesn't equal `used.InputSHA256`),
* final `return ctx.Err()` after a successful recheck (fail-closed on late cancellation).
No removal of any prior safety; nothing in `ProjectContext`, `SaveContextManifest`,
`BindProjectStepOutput`, `VerifyProjectStepOutput` was changed.
2. `internal/store/context_manifest.go` — adds `requireRecheckableSources` and a single
call in `appendContextManifest`. No existing branch removed.
3. `internal/runmgr/project_context_test.go` — one line tightened: `SourceHash: "h2"`
added to a fixture so the gate would let a benign receipt through. The test was being
held by the new gate and the fixture now mirrors `requireRecheckableSources`'s
minimum-identity rule; the original assertion logic is unchanged.
Everything else (`internal/app`, `cmd/storage-migrate`, all new `*_test.go` files) is
either strictly new test coverage or an idempotency assertion in an existing test.
---
## 1. Hard-requirement verdict (PASS / FAIL / NOT_RUN)
| # | Hard requirement | Verdict | Evidence |
| - | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----: | ----------------------------------------------------------------------------------------------------- |
| 1 | Recheck request is constructed **only** from a trusted persisted run/step/attempt + Manifest that was actually included; client/model/plugin cannot supplement; **no Goal/prompt/output/body** in the recheck. | **PASS** | `internal/runmgr/project_context.go:80–116`; `internal/app/project_context.go:118–139`; `TestProjectSourceRecheckRequestCarriesOnlyTrustedIdentities` (runmgr). |
| 2 | Per-item identity check (count/ID/revision/sourceHash); Backend **recomputes** `Text → InputSHA256` instead of trusting provider echo; `EvidenceRef` is reauthorized and `ReadEvidence` re-reads the body; `Text` does not enter generic receipts/logs. | **PASS** | `internal/runmgr/project_context.go:138–163`; `internal/store/context_manifest.go:88–108`; `TestProjectSourceRecheckRejectsUnrelatedOrDuplicateSource`; `TestSaveContextManifestRejectsUnrecheckableModelInput`. |
| 3 | All consumption points (judge, second prompt, blackboard, `{{steps.X.output}}` interpolation, background resume) are gated. Unidentifiable old machine provenance ⇒ project state refuses to send; one recheck request must re-validate **every** early-round and **every** parallel source. | **PASS** | `internal/wfcompile/project_sources.go:18–56`; `internal/wfcompile/host.go:102–110,440–500,559–584`; `internal/runtime/project_stream.go:71–107,107–160`; `TestProjectCrossGuardRechecksEarlierRoundAfterLaterSource`. |
| 4 | **Receipt gate**: `Included` lacks `sourceID` / `revision` / `sourceHash` **or** `OfferedReferences` lacks a re-authorizable identity ⇒ round is rejected. | **PASS** | `internal/store/context_manifest.go:87–107` (`requireRecheckableSources`); `internal/store/context_manifest_gate_test.go` (`TestContextManifestRejectsSourcesWithoutStableIdentity`). |
| 5 | Storage: fresh install has no `resolve_goal`/`resolve_target_id` columns; legacy migrations condition-ally purge them; `cmdBackup` refuses old v34/v35 sources; SQLite→isolated PG migration ⇒ target schema has no raw columns and is idempotent. | **PASS** | `internal/store/context_manifest_test.go` (`TestCandidateV35RawScopeColumnsAreScrubbedOnUpgrade`, `…CommittedDrop…`, `…WithoutMarker…`, `…PostgresRawScopeScrub…`); `cmd/storage-migrate/main.go:295–329` (`refuseCandidateRawContextScope`, also invoked from `cmdBackup`); `cmd/storage-migrate/integration_test.go:72–83` (idempotent re-run assertion). |
| 6 | Fail-closed: missing item / same-id different text / cross-project / provider refusal / >3 s timeout / mixed `attempted`+`unknown` / epoch drift ⇒ reject; **candidate resolve must not impersonate actually-seen items**; current-turn user-typed input does **not** inherit old fragment permissions. | **PASS** | `internal/runmgr/project_context.go:130–184`; `internal/app/project_context.go:136–143` (3 s deadline + project/status guard); `internal/store/workflow_step_source.go:65–110` (`states` bitmask 3 forbids any attempt without attempted+responded); `TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling` (app); `TestProjectSourceRecheckFailsClosedOnRefusalDenialAndCancellation`; `TestProjectStepOutputRejectsUnsettledAttemptInSameStep`. |
| 7 | Compatibility: non-project flows untouched; project runs whose provider does **not** expose the optional `Recheck` method fail-closed at the first attempt instead of being silently admitted. | **PASS** | `internal/runmgr/project_context.go:83–85` (`c.mgr.projectSourceRechecker == nil`); `internal/app/project_context.go:128–132` (`p.(extension.ProjectContextSourceRechecker)` type assertion, returns explicit unavailable error). Verified by `TestProjectSourceRecheckUsesSameRegisteredProviderAndDeadline` (legacy provider rejected). |
All seven requirements pass on code evidence. None are NOT_RUN; all are reproducible
through the unit tests added in this r1.
---
## 2. Findings
### BLOCKING
*None.*
The r1 patch threads the three previously identified gaps:
1. (Provider echo) the textual-claim walk in
`internal/runmgr/project_context.go:138–163` plus
`internal/runmgr/project_context.go:144–148` (sha recompute) closes it.
2. (Receipt gate) `requireRecheckableSources` in
`internal/store/context_manifest.go:87–107` enforces identities on **every**
append.
3. (Failure modes) `ctx.Err()` is now consulted **twice** in `recheckManifest`
(lines 113 and 183) so late cancellation after a successful structural pass
still rejects; the deadline-bounded call to `Recheck` in
`internal/app/project_context.go:136–143` enforces the 3 s ceiling from the
host side.
### MAJOR
*None.* The recheck call site in `internal/wfcompile/project_sources.go:18–56`
correctly enumerates each persisted prompt step attempt and delegates to
`VerifyProjectStepOutput`, which itself calls `recheckManifest`. The
`{{steps.X.output}}` interpolation in `internal/wfcompile/host.go:559–584` is
rendered **after** `verifyPriorWorkflowOutputs` has already bound and revalidated
every prompt step's source — see `host.go:102–110` for the strict-mode gate.
### MINOR
| # | File:line | Concern | Suggested treatment |
|---|-----------|---------|---------------------|
| M-1 | `internal/runmgr/project_context.go:142` | `if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !slices.Equal(current.Missing, m.Missing)` — `current.Missing` ordering is required to be *identical*, not just set-equal. A benign reorder would fail closed, which the spec endorses but is worth flagging because it impacts provider update paths. | Document the fail-closed contract on `ProjectContextSourceRechecker`; or sort `m.Missing` before comparison. |
| M-2 | `internal/runmgr/project_context.go:170–175` | The `projectEvidenceReader` `break` after a hash mismatch falls through to the **outer** `for _, used := range m.ReadEvidence`. Each `used` is then matched against `current.Evidence` once. Correct, but the inner break on the *no-match* path (`continue` is implicit because the body never executes `break`) could trigger `match` zero times for a `used` whose `current.Evidence` carries a newer `SourceID` revision. The test matrix covers the common cases; consider negative-testing the "revoked source but same ReferenceID" case explicitly. | Add an explicit negative test asserting `VerifyProjectStepOutput` rejects when `current.Evidence` carries a same-ID ref whose `Hash` differs from `used.SourceHash`. |
| M-3 | `internal/runmgr/project_context.go:182` | `return ctx.Err()` is the final statement after a successful structural pass. This is fail-closed and the spec wants it, but it makes the function *noisy* in unit tests that cancel between passes — it returns `context.Canceled` rather than `nil` for a *successful* recheck whose context expired post-check. | If a future change relaxes the rule, comment this line as load-bearing. |
| M-4 | `internal/app/project_context.go:118–120` | `s == nil`, `Actor==""`, `ProjectID==""`, *and* `Goal!=""` all return the same generic error. Helpful for the spec but harder to diagnose. | Differentiate the four conditions for host-side observability. |
| M-5 | `internal/store/context_manifest.go:67–80` | The terminal-state match query requires `attempt_id=? AND delivery='attempted' AND owner=? AND target_id=? AND project_id=? AND input_sha256=?` — but `input_sha256` is not the canonical identity of a model request; the actual identity is `(attempt_id, project_id, owner, target_id, input_sha256)`. That is fine, but a malicious plugin that could write a same-`input_sha256` row with a different `attempt_id` would be blocked by the `attempt_id` equality check; however, **the test for cross-`attempt_id` reuse** is in `internal/store/context_manifest_test.go:45` (attempt reused across target) and not for the other columns. | Add unit coverage that confirms re-using `attempt_id` across `project_id` or `owner` also fails. |
| M-6 | `internal/wfcompile/host.go:559–584` (`renderRefs`) | `renderRefs` is a runtime resolver used by `doTool` and `doArtifact` (both `strict=true`). `doCard` and `doScript` also reach `renderRefs` indirectly via the `out_<id>` field population; if `doScript` ever feeds `{{steps.X.output}}` into a non-strict step, the trust barrier is the early guard. Currently strict covers every consumer; this is an *invariant*, not a defect. | Document this invariant on `renderRefs`. |
### Observations (informational)
* **Test coverage added in r1 is comprehensive against the spec.** Each requirement
has at least one named test whose prefix matches the requirement number:
timeout (3 s) → `TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling`,
refused/denied/cancellation → `…RefusalDenialAndCancellation`,
cross-guard multi-round → `TestProjectCrossGuardRechecksEarlierRoundAfterLaterSource`,
receipt gate → `TestSaveContextManifestRejectsUnrecheckableModelInput`.
* **`internal/ctxasm/` is not in the change set.** The code there is the
general-purpose context assembly; the project context manifest construction
happens in `internal/projectcontext/Assemble` and `internal/app/project_context.go`
(`assembleWorkCompletionWithEvidence`). `ctxasm.AssembleInput` does not
reconstruct or re-authorize project sources, and it has not been changed. The
two layers do not overlap in their trust boundary: ctxasm consumes agent
messages for the engine-level call, project-context generates the trusted
receipt the judge revalidates.
* **`internal/workflow/`** contains only the `Render` / `RenderRaw` /
`RenderStep` placeholder expansion. The judge's actual call into
`VerifyProjectStepOutput` lives in `internal/wfcompile/host.go` (`doCritic`,
`applyScriptPlan`) and `internal/wfcompile/project_sources.go`
(`verifyPriorWorkflowOutputs`). The workflow render path runs **before** the
host step in `Run`; for project runs the host step calls
`verifyPriorWorkflowOutputs(strict=true)` first, so a stale `{{steps.X.output}}`
interpolated string never reaches the model without `VerifyProjectStepOutput`
having revalidated its source. This matches requirement (3).
* **`cmd/storage-migrate/main.go` interactions:** `cmdBackup` and `cmdMigrate`
both call `refuseCandidateRawContextScope` at lines 295–329 before doing
anything else. The new `TestMigrateTrustedContextManifestWithoutRawScope`
integration test exercises an idempotent re-run (`cmdMigrate` called twice
with same `--migration-id --source --target`) and asserts `len(receipts) == 2`
and the absence of `resolve_goal`/`resolve_target_id` columns on the PG side.
The PG schema bootstrap (`store.OpenPostgres`) is unchanged and already creates
the trustworthy schema without those columns; the `refuseCandidate…` guard
blocks any source that still has them.
* **Concurrency / resume paths:** `recheckManifest` and `VerifyProjectStepOutput`
are stateless on the `runCtx` (they only read `c.run`, `c.mgr.st`,
`c.mgr.projectSourceRechecker`, `c.mgr.projectEvidenceReader`). Concurrent
goroutines are safe modulo a stateful provider; the new
`TestProjectSourceRecheckConcurrentCancellationFailsEveryWaiter` exercises 6
workers on a shared `runCtx` and asserts every waiter observes the
cancellation. Background resume is exercised through re-reading the run row
from the store (`trustedProjectContextRequest`) on every recheck call — there
is no path that can resurrect a stale in-memory run identity.
---
## 3. Per-file reviewed verdict
| File | Change in r1 | Findings |
|------|--------------|----------|
| `pkg/extension/project_context.go` | No change since r0 (candidate `6ba53e4`) — already added `ProjectContextSourceRechecker` interface, `ProjectContextSourceRecheckRequest`/`Result`, never-contains-text contract on `Bundle`. | Pre-existing contract matches requirement (1); no r1 regression. |
| `internal/store/context_manifest.go` | `+24`: `requireRecheckableSources` + call site. | **PASS** for (4), (5). |
| `internal/store/context_manifest_gate_test.go` | New file, `+45`. | **PASS** — exercises every missing-identity variant (incl. `OfferedReferences` w/o `ID`, `ReadEvidence` w/o `ReferenceID`/`Revision`). |
| `internal/runmgr/project_context.go` | `+24/-2`: 3 narrow inserts into `recheckManifest`. | **PASS** for (1)–(7). Minor M-1/M-2/M-3 above. |
| `internal/runmgr/project_context_test.go` | `+1/-1`: one fixture row received `SourceHash` to satisfy the new gate. | **PASS** — change keeps the assertion intent intact. |
| `internal/runmgr/project_source_recheck_test.go` | New file, `+349`. | **PASS** — covers (1)/(2)/(3)/(6). The concurrent-cancel test specifically exercises M-3's intent. |
| `internal/app/project_source_recheck_test.go` | New file, `+97`. | **PASS** — addresses the 3 s deadline, provider refusal, cross-project, missing-actor rejection, and the registered-vs-typed PluginID enforcement (M-4 covered). |
| `internal/app/project_context.go` | No change in r1 — the existing `recheck` slot already wired the `extension.ProjectContextSourceRechecker` interface and the 3 s deadline. | **PASS** for (3)/(6)/(7). |
| `cmd/storage-migrate/integration_test.go` | `+10`: idempotency assertion for context manifests. | **PASS** for (5). |
| `cmd/storage-migrate/main.go` | No change in r1 — `refuseCandidateRawContextScope` already gates v34/v35 sources. | **PASS** for (5). |
| `internal/workflow/` (render.go, validate.go, registry.go) | No change in r1. | Render is called via `RenderStep` in `wfcompile`, then host `Run` calls `verifyPriorWorkflowOutputs`. **PASS** for (3). |
| `internal/wfcompile/host.go`, `project_sources.go`, `project_backend.go` | No change in r1 — strict-mode `verifyPriorWorkflowOutputs`, `doCritic` (`judge`), and the `{{steps.X.output}}` `renderRefs` runtime path are already gated. | **PASS** for (3). |
| `internal/ctxasm/` | No change in r1 — `ctxasm` is not part of the project trust boundary. | **PASS** (out of scope by design). |
---
## 4. Concurrency & recovery deep dive (per spec)
* **Race on `c.projectContextReq`.** Guarded by `c.projectContextMu`
(`internal/runmgr/project_context.go:187–196`). `recheckManifest` calls
`trustedProjectContextRequest` which builds a fresh `req` value and returns
it; the assignment to `c.projectContextReq` happens via `setContextRequest`
**only** when a fresh `Resolve` runs. A recheck that races a fresh `Resolve`
could observe the *prior* scope value, but the recheck compares against
`m.AttemptID`/`m.ProjectID` (persisted) — never against the in-memory scope
identity.
* **Read reauthorization races.** `projectEvidenceReader` is called inside
`recheckManifest` and `SaveContextManifest`. Both compare the freshly-read
`sha256(evidence.Text)` against the persisted `used.InputSHA256`. The reader
cannot impersonate a previously-recorded body because the hash is recomputed
every time.
* **In-flight cancellation.** `recheckManifest` consults `ctx.Err()` **before**
the textual-claim walk (line 113) and **after** (line 183). The `Recheck`
provider call itself is wrapped in a 3 s `context.WithTimeout` in
`internal/app/project_context.go:136–143`. `TestProjectSourceRecheckConcurrentCancellationFailsEveryWaiter`
exercises 6 concurrent rechecks against a single shared `runCtx` and confirms
cancellation propagates to every waiter.
* **Backend resume path.** `trustedProjectContextRequest` re-reads the run and
conversation from `c.mgr.st` on every call. `internal/runmgr/project_context_test.go`
has `TestProjectContextResumeReloadsCurrentVersionAndAuthorization` which
constructs a *second* `runCtx{mgr:m, run:reloaded}` for an `Interrupted` run
and confirms the resume path uses the current authorization (not the
frozen-in-memory snapshot).
* **Workflow sink ambiguity.** `internal/wfcompile/project_sources.go:88–141`
records the step start, *invalidates* any prior `WorkflowStepSource` binding
on each start, and marks the step `ambiguous` whenever two start events
coexist in the in-progress set. The downstream `BindProjectStepOutput` is a
no-op when `ambiguous`, leaving the judge to fail closed.
`internal/store/workflow_step_source.go:65–110` enforces a bitmask
`state == 3` (bits `attempted` + `responded`) for every attempt ID covered
by the step; `TestProjectStepOutputRejectsUnsettledAttemptInSameStep`
exercises the `attempted` and `unknown` mixed storage cases.
* **Recovery store under interruption.** The recheck call re-reads from
`ListContextManifests` every time (`internal/runmgr/project_context.go:52`),
so a row committed by an in-flight `SaveContextManifest` is visible to the
next cross-guard call but invisible to a prior one. There is no `BEGIN/END`
window during which `recheckManifest` could observe an inconsistent set of
receipts because each row's gate (`requireRecheckableSources`) is applied
in the same SQL transaction as the insert.
---
## 5. Verdict
**APPROVED for merge into `feature/w6-x1-source-recheck`** as far as the reviewer's
evidence permits. No blocking or major issues identified. The single grade-M
observations are refinements, not corrections; none block the safety property
the revision claims.
The reviewer makes no claim about CI green-ness; that requires running the
test suite. Tests were read; not run.
@@ -0,0 +1,40 @@
//go:build live
package live
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
)
func TestPersistedRunningCheckpointMaxDoesNotMissShortWindow(t *testing.T) {
path := filepath.Join(t.TempDir(), "checkpoint.db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err = db.Exec(`CREATE TABLE events (conversation_id TEXT, seq INTEGER, type TEXT, payload TEXT)`); err != nil {
t.Fatal(err)
}
for seq, item := range []struct {
conversation, status, state string
checkpoint int
}{
{"wanted", "active", "running", 1},
{"other", "active", "running", 9},
{"wanted", "active", "running", 2},
{"wanted", "failed", "failed", 99},
} {
payload := fmt.Sprintf(`{"card":{"status":%q,"task":{"executionState":%q,"checkpointSeq":%d}}}`, item.status, item.state, item.checkpoint)
if _, err = db.Exec(`INSERT INTO events(conversation_id,seq,type,payload) VALUES(?,?,?,?)`, item.conversation, seq+1, "card.updated", payload); err != nil {
t.Fatal(err)
}
}
got, err := persistedRunningCheckpointMax(path, "wanted")
if err != nil || got != 2 {
t.Fatalf("persisted in-flight checkpoints: max=%d err=%v; want 2", got, err)
}
}
+307
View File
@@ -0,0 +1,307 @@
//go:build live
package live
import (
core "agent-system-core"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
_ "modernc.org/sqlite"
)
type checkpointManifest struct {
PID int `json:"pid"`
Binary, BinarySHA256, BackendSHA, DevSHA, Base, DataDir, Mode string
}
func checkpointEvalOutputName(filter, authorization string) (string, bool) {
if filter == "A01" && authorization == "approved-20261013" {
return "candidate-extra-a01", true
}
if filter == "A01" && authorization == "approved-followup-20261013" {
return "candidate-followup-a01", true
}
if filter == "A01" && authorization == "approved-r02-20261013" {
return "candidate-r02-a01", true
}
if filter == "A01" && authorization == "approved-r03-20261013" {
return "candidate-r03-a01", true
}
if filter == "A01" && authorization == "approved-r04-20261014" {
return "candidate-r04-a01", true
}
if filter == "A01" && authorization == "approved-o2-a01-evidence" {
return "candidate-o2-a01-evidence", true
}
if filter == "A01" && authorization == "approved-o2-a01-final" {
return "candidate-o2-a01-final", true
}
if filter == "A02,A04" {
return "candidate-remaining", true
}
return "", false
}
func persistedRunningCheckpointMax(path, conversationID string) (int, error) {
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
return 0, err
}
defer db.Close()
rows, err := db.Query(`SELECT payload FROM events WHERE conversation_id = ? AND type = 'card.updated' ORDER BY seq`, conversationID)
if err != nil {
return 0, err
}
defer rows.Close()
maximum := 0
for rows.Next() {
var raw []byte
if err = rows.Scan(&raw); err != nil {
return 0, err
}
var event struct {
Card struct {
Status string `json:"status"`
Task struct {
ExecutionState string `json:"executionState"`
CheckpointSeq int `json:"checkpointSeq"`
} `json:"task"`
} `json:"card"`
}
if err = json.Unmarshal(raw, &event); err != nil {
return 0, err
}
if event.Card.Status == "active" && event.Card.Task.ExecutionState == "running" && event.Card.Task.CheckpointSeq > maximum {
maximum = event.Card.Task.CheckpointSeq
}
}
return maximum, rows.Err()
}
func TestFollowupA01KeepsItsOwnOneShotResult(t *testing.T) {
for _, tc := range []struct {
filter, authorization, name string
allowed bool
}{
{"A01", "approved-20261013", "candidate-extra-a01", true},
{"A01", "approved-followup-20261013", "candidate-followup-a01", true},
{"A01", "approved-r02-20261013", "candidate-r02-a01", true},
{"A01", "approved-r03-20261013", "candidate-r03-a01", true},
{"A01", "approved-r04-20261014", "candidate-r04-a01", true},
{"A01", "approved-o2-a01-evidence", "candidate-o2-a01-evidence", true},
{"A01", "approved-o2-a01-final", "candidate-o2-a01-final", true},
{"A02,A04", "approved-r03-20261013", "candidate-remaining", true},
{"A01", "", "", false},
{"A01", "unknown", "", false},
{"A02,A04", "", "candidate-remaining", true},
} {
name, allowed := checkpointEvalOutputName(tc.filter, tc.authorization)
if name != tc.name || allowed != tc.allowed {
t.Fatalf("filter=%q authorization=%q: got %q/%v; want %q/%v", tc.filter, tc.authorization, name, allowed, tc.name, tc.allowed)
}
}
}
func verifiedCheckpointManifest(t *testing.T) checkpointManifest {
t.Helper()
path := os.Getenv("WORK_CHECKPOINT_EVAL_OWNERSHIP_MANIFEST")
if path == "" {
t.Fatal("explicit task-owned process manifest required")
}
raw, e := os.ReadFile(path)
if e != nil {
t.Fatal(e)
}
var m checkpointManifest
if json.Unmarshal(raw, &m) != nil {
t.Fatal("invalid manifest")
}
u, e := url.Parse(m.Base)
if e != nil || u.Hostname() != "127.0.0.1" || m.Base != os.Getenv("AGENT_EVAL_BASE") || m.PID <= 0 || !filepath.IsAbs(m.DataDir) || !strings.Contains(m.DataDir, "fast-slow-u2-u3-") {
t.Fatal("manifest scope mismatch")
}
if e = syscall.Kill(m.PID, 0); e != nil {
t.Fatal("owned process is not live")
}
actual, e := os.Readlink(fmt.Sprintf("/proc/%d/exe", m.PID))
if e != nil || actual != m.Binary {
t.Fatal("process executable mismatch")
}
bytes, e := os.ReadFile(actual)
if e != nil {
t.Fatal(e)
}
sum := sha256.Sum256(bytes)
if hex.EncodeToString(sum[:]) != m.BinarySHA256 {
t.Fatal("binary hash mismatch")
}
if _, e = os.Stat(filepath.Join(m.DataDir, "agent-system.db")); e != nil {
t.Fatal("isolated data missing")
}
return m
}
// The batch driver invokes this once per baseline/candidate, using the same
// three synthetic materials, route and profile. It never resolves a business HITL.
func TestWorkCheckpoints(t *testing.T) {
m := verifiedCheckpointManifest(t)
if m.Mode != "model" {
t.Fatal("real model evaluation requires model mode")
}
materials := os.Getenv("AGENT_EVAL_MATERIALS")
outDir := os.Getenv("AGENT_EVAL_OUT")
if materials == "" || outDir == "" {
t.Fatal("explicit external material and evidence directories required")
}
if e := os.MkdirAll(outDir, 0700); e != nil {
t.Fatal(e)
}
c := &client{base: m.Base, http: &http.Client{Timeout: 30 * time.Second}, t: t}
label := os.Getenv("WORK_CHECKPOINT_EVAL_LABEL")
if label != "baseline" && label != "candidate" {
t.Fatal("fixed baseline/candidate label required")
}
caseFilter := os.Getenv("WORK_CHECKPOINT_EVAL_CASES")
outputName := label
if caseFilter != "" {
if label != "candidate" {
t.Fatal("candidate cases only")
}
var allowed bool
outputName, allowed = checkpointEvalOutputName(caseFilter, os.Getenv("WORK_CHECKPOINT_EXTRA_A01"))
if !allowed {
t.Fatal("explicitly authorized case selection required")
}
}
if _, err := os.Stat(filepath.Join(outDir, outputName+".json")); err == nil {
t.Fatal("sample already exists; do not retry")
}
all := []map[string]any{}
total := 0
for _, testCase := range []struct {
id, focus string
files []string
}{{"A01", "", []string{"需求.txt", "会议.txt", "评审.txt"}}, {"A02", "重点核对预算、外部依赖和交付日期;请保留限制", []string{"需求.txt", "会议.txt", "评审.txt"}}, {"A04", "", []string{"需求.txt", "会议.txt", "扫描.pdf"}}} {
if caseFilter != "" && !strings.Contains(caseFilter, testCase.id) {
continue
}
if total >= 120000 {
t.Fatal("instance reported token budget exhausted")
}
var project struct {
ID string `json:"id"`
}
if e := c.do("POST", "/api/projects", map[string]any{"name": "checkpoint-" + label + "-" + testCase.id}, &project); e != nil {
t.Fatal(e)
}
var conv struct {
ID string `json:"id"`
}
if e := c.do("POST", "/api/projects/"+project.ID+"/conversations", map[string]any{"title": testCase.id, "profileId": "checkpoint-eval"}, &conv); e != nil {
t.Fatal(e)
}
ids := []string{}
for _, name := range testCase.files {
bytes, e := os.ReadFile(filepath.Join(materials, name))
if e != nil {
t.Fatal(e)
}
ids = append(ids, c.upload(t, project.ID, name, bytes))
}
var send map[string]any
if e := c.do("POST", "/api/conversations/"+conv.ID+"/messages", map[string]any{"text": testCase.focus, "attachmentIds": ids, "requestId": "checkpoint-" + label + "-" + testCase.id}, &send); e != nil {
t.Fatal(e)
}
deadline := time.Now().Add(300 * time.Second)
var snapshot struct {
Cards []core.ActivityCard `json:"cards"`
ReplaySeq int64 `json:"replaySeq"`
}
observations := []map[string]any{}
seen := map[string]bool{}
for {
// Decode into fresh storage: reusing a slice lets JSON overwrite Task
// pointers already saved as historical observations.
snapshot.Cards = nil
if e := c.do("GET", "/api/conversations/"+conv.ID+"/cards", nil, &snapshot); e != nil {
t.Fatal(e)
}
terminal := false
for _, card := range snapshot.Cards {
if card.Kind != core.CardKindProgress {
continue
}
if card.Task != nil {
key := fmt.Sprintf("%s:%d", card.ID, card.Task.Revision)
if !seen[key] {
seen[key] = true
observations = append(observations, map[string]any{"observedAt": time.Now().UTC(), "card": card, "replaySeq": snapshot.ReplaySeq})
}
terminal = card.Task.Terminal()
} else {
terminal = card.Status == core.CardDone || card.Status == core.CardFailed
}
}
if terminal {
break
}
if label == "baseline" && time.Until(deadline) < 298*time.Second && len(snapshot.Cards) == 0 {
runObj, ok := send["run"].(map[string]any)
if ok {
var state struct {
Run core.Run `json:"run"`
}
if c.do("GET", "/api/runs/"+fmt.Sprint(runObj["id"]), nil, &state) == nil && state.Run.Status == core.RunCompleted {
break
}
}
}
if time.Now().After(deadline) {
t.Fatalf("%s background did not finish within budget", testCase.id)
}
time.Sleep(250 * time.Millisecond)
}
var detail map[string]json.RawMessage
if e := c.do("GET", "/api/conversations/"+conv.ID, nil, &detail); e != nil {
t.Fatal(e)
}
var runs []core.Run
_ = json.Unmarshal(detail["runs"], &runs)
for _, r := range runs {
if r.Usage != nil {
total += r.Usage.TotalTokens
}
}
record := map[string]any{"case": testCase.id, "projectId": project.ID, "conversationId": conv.ID, "send": send, "observations": observations, "finalCards": snapshot.Cards, "detail": detail, "reportedTokensCumulative": total, "quality": "requires independent comparison against provided materials; quote existence is not semantic proof"}
all = append(all, record)
raw, _ := json.MarshalIndent(map[string]any{"manifest": m, "label": label, "cases": all, "reportedTokens": total, "noticeModelTurns": "disabled equally in controlled comparison"}, "", " ")
if e := os.WriteFile(filepath.Join(outDir, outputName+".json"), raw, 0600); e != nil {
t.Fatal(e)
}
if label == "candidate" && testCase.id == "A01" {
maximum, err := persistedRunningCheckpointMax(filepath.Join(m.DataDir, "agent-system.db"), conv.ID)
if err != nil {
t.Fatal(err)
}
if maximum < 2 {
t.Fatal("A01 has fewer than two persisted running checkpoints; inspect raw evidence")
}
}
if total > 120000 {
t.Fatal("reported instance token target exceeded; no further sample started")
}
}
t.Logf("%s: %d material tasks, cumulative reported tokens=%d; raw quality evidence=%s", label, len(all), total, filepath.Join(outDir, outputName+".json"))
}
+6
View File
@@ -4,6 +4,7 @@ go 1.27
require (
agent-command v0.0.0
agent-document-renderer v0.0.0
agent-intent v0.0.0
agent-native v0.0.0
agent-squad v0.0.0
@@ -26,12 +27,15 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
@@ -52,3 +56,5 @@ replace error-code-system => ./deps/error-code-system
replace logging-system => ./deps/logging-system
replace agent-command => ./deps/agent-command
replace agent-document-renderer => ./deps/agent-document-renderer
+9
View File
@@ -4,6 +4,7 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -25,6 +26,10 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
@@ -37,6 +42,8 @@ github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01j
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -55,6 +62,8 @@ golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+15 -7
View File
@@ -115,9 +115,14 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
}
scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"},
MatchFields: []string{"owner", "name", "staffNo"}, AllCandidates: true, ScopeVersion: "system.users.v1"}
case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeDocumentDuties:
case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeProjectRoster, extension.AccountDirectoryPurposeDocumentDuties:
// project.member-roster is an explicit enumeration of current project
// members: only the trusted RDM resolver may name them, every account
// status is allowed and an empty query lists the whole roster.
roster := q.Purpose == extension.AccountDirectoryPurposeProjectRoster
if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") ||
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || p.status != "enabled" {
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || (roster && q.DocumentID != "") ||
(!roster && p.status != "enabled") {
return p, store.ErrAccountForbidden
}
h.caps.directory.mu.RLock()
@@ -132,7 +137,7 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
if err != nil {
return p, store.ErrAccountForbidden
}
if q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && scope.AllCandidates {
if (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties || roster) && scope.AllCandidates {
return p, store.ErrAccountForbidden
}
default:
@@ -311,7 +316,10 @@ func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool
func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) {
query := strings.TrimSpace(q.Query)
p, err := h.directoryPolicy(ctx, q, query != "")
// project.member-roster enumerates the resolved members: an empty query
// lists every row inside the scope with normal limit/cursor paging.
enumerate := query == "" && q.Purpose == extension.AccountDirectoryPurposeProjectRoster
p, err := h.directoryPolicy(ctx, q, query != "" || enumerate)
if err != nil {
return extension.AccountDirectoryPage{}, err
}
@@ -326,13 +334,13 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
}
page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version}
if query == "" {
if query == "" && !enumerate {
if q.Cursor != "" {
return extension.AccountDirectoryPage{}, store.ErrConflict
}
return page, nil
}
if q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 {
if query != "" && q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 {
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
}
var after directoryCursor
@@ -344,7 +352,7 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou
}
matched := make([]store.DirectoryAccountRow, 0, q.Limit+1)
for _, row := range p.rows {
if !directoryMatches(row, p.match, q.Mode, query) {
if !enumerate && !directoryMatches(row, p.match, q.Mode, query) {
continue
}
if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) {
+96
View File
@@ -154,6 +154,102 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
}
}
// project.member-roster:可信 RDM 解析方点名的项目成员清单。空 query 枚举作用域内
// 全部行(含已停用账号),limit/游标翻页照旧;非 rdm 插件、带 documentId、
// AllCandidates 解析结果一律拒绝;member-candidate 的空 query 仍是空页。
func TestAccountDirectoryProjectRosterEnumeratesResolvedMembers(t *testing.T) {
st := openDirectoryTestStore(t)
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
alice, err := st.CreatePendingAccount("admin", "alice", store.AccountProfile{Name: "阿丽", StaffNo: "hidden-a"})
if err != nil {
t.Fatal(err)
}
bob, err := st.CreatePendingAccount("admin", "bob", store.AccountProfile{Name: "小博", StaffNo: "hidden-b"})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "局外人", StaffNo: "hidden-x"}); err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
t.Fatal(err)
}
var requests []extension.AccountDirectoryScopeRequest
resolver := func(_ context.Context, req extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
requests = append(requests, req)
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
CandidateOwners: []string{alice.Owner, bob.Owner}, ScopeVersion: "roster-1"}, nil
}
slot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
slot.pluginID, slot.resolver = "rdm", resolver
h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"}
roster := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
Purpose: extension.AccountDirectoryPurposeProjectRoster, ProjectID: "project-1",
Mode: extension.AccountDirectoryModeFuzzy, Status: "all"}
page, err := h.QueryAccountDirectory(context.Background(), roster)
if err != nil {
t.Fatal(err)
}
if len(page.Entries) != 2 || page.NextCursor != "" {
t.Fatalf("roster page=%+v", page)
}
if page.Entries[0] != (extension.AccountDirectoryEntry{UserID: alice.ID, Owner: "alice", Name: "阿丽", Valid: true}) ||
page.Entries[1] != (extension.AccountDirectoryEntry{UserID: bob.ID, Owner: "bob", Name: "小博", Valid: false}) {
t.Fatalf("roster entries=%+v", page.Entries)
}
if len(requests) != 1 || requests[0].Purpose != extension.AccountDirectoryPurposeProjectRoster ||
requests[0].ProjectID != "project-1" || requests[0].DocumentID != "" || requests[0].Actor != "admin" {
t.Fatalf("resolver requests=%+v", requests)
}
// 游标翻页:同一签名游标,limit 1 两页走完。
roster.Limit = 1
first, err := h.QueryAccountDirectory(context.Background(), roster)
if err != nil || len(first.Entries) != 1 || first.Entries[0].UserID != alice.ID || first.NextCursor == "" {
t.Fatalf("roster first page=%+v %v", first, err)
}
roster.Cursor = first.NextCursor
second, err := h.QueryAccountDirectory(context.Background(), roster)
if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" {
t.Fatalf("roster second page=%+v %v", second, err)
}
roster.Cursor, roster.Limit = "", 0
// 非检索目的的空 query 依旧空页:member-candidate 行为不变。
roster.Purpose, roster.Status = extension.AccountDirectoryPurposeProjectMembers, "enabled"
if page, err := h.QueryAccountDirectory(context.Background(), roster); err != nil || len(page.Entries) != 0 {
t.Fatalf("member candidate empty query=%+v %v", page, err)
}
roster.Purpose = extension.AccountDirectoryPurposeProjectRoster
// 非 rdm 插件、documentId 限定、全量作用域解析结果:均拒绝。
other := h
other.pluginID = "genui"
if _, err := other.QueryAccountDirectory(context.Background(), roster); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("non-rdm roster: %v", err)
}
withDocument := roster
withDocument.DocumentID = "doc-1"
if _, err := h.QueryAccountDirectory(context.Background(), withDocument); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("document roster: %v", err)
}
wideSlot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
wideSlot.pluginID, wideSlot.resolver = "rdm", func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
AllCandidates: true, ScopeVersion: "roster-wide"}, nil
}
wide := h
wide.caps.directory = wideSlot
if _, err := wide.QueryAccountDirectory(context.Background(), roster); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("all-candidate roster: %v", err)
}
}
func TestPGDirectoryCursorContinuesInAnotherProcess(t *testing.T) {
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
if baseDSN == "" {
+191 -197
View File
@@ -5,8 +5,10 @@ package app
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net"
@@ -23,14 +25,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/blobstore"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
@@ -60,16 +58,17 @@ import (
)
type Options struct {
Addr string
DataDir string
PluginsDir string
ForceMock bool
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
InsecureListen bool
AllowedHosts []string
Addr string
DataDir string
PluginsDir string
ForceMock bool
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
RequireProjectContext bool
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
InsecureListen bool
AllowedHosts []string
// DisableUploadIndexing EXT-9:true 时上传端点不自动写检索索引(发布决定
// 移交下游命令层);零值=false=现状。由 link 层自 extension.Options 透传。
DisableUploadIndexing bool
@@ -86,6 +85,9 @@ type Options struct {
// ExecDepsDir sandbox 档只读依赖层(site-packages 等,空=不挂):修复
// --tmpfs /home 遮蔽 ~/.local 导致交付物生成链路不可用(F3)。
ExecDepsDir string
// ExecEnvRoot 分层 Python 环境根(解释器、基础层、扩展层、白名单)。
// 设置后,未显式给出 ExecDepsDir 时使用其中的 current 基础层。
ExecEnvRoot string
// ExecWorkspace = "overlay" 时 sandbox 档启用 S0 工作区(冻结基底+私有
// upper+CAS 回收);空 = EXT-16 现状直写会话目录。
ExecWorkspace string
@@ -94,6 +96,11 @@ type Options struct {
Tier string
// PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。
PGPool store.PoolOptions
// AdoptInstanceGuard 在数据库被替换或锁文件被重建后,显式重写守卫记录。
AdoptInstanceGuard bool
// SuperAdmin 启动期超级管理员凭据(R1):开启认证时必填且启动即引导;
// 口令只留在进程内存,不落库、不进日志。
SuperAdmin config.SuperAdmin
}
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
@@ -172,48 +179,16 @@ func Run(opt Options) error {
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
}
var st *store.Store
var err error
if opt.StorageDSN != "" {
artifacts := opt.ArtifactsDir
if artifacts == "" {
artifacts = filepath.Join(opt.DataDir, "artifacts")
}
// 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享);
// 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。
blobs, berr := blobstore.NewS3FromEnv()
if berr != nil {
return fmt.Errorf("blobstore s3: %w", berr)
}
if blobs != nil {
log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix)
}
st, err = store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs)
if err != nil && opt.Tier == "team" {
// team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。
return fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err)
}
} else {
st, err = store.OpenSQLite(filepath.Join(opt.DataDir, "agent-system.db"))
if err := ValidateSuperAdmin(resolved.AuthEnabled(), opt.SuperAdmin); err != nil {
return err
}
st, releaseStore, err := openServiceStore(opt)
if err != nil {
return fmt.Errorf("open store: %w", err)
return err
}
defer st.Close()
// U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" {
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
if dbref == "" {
if opt.StorageDSN != "" {
dbref = "pg"
} else {
dbref = "sqlite"
}
}
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
return fmt.Errorf("system identity: %w", err)
}
defer releaseStore()
if err := BootstrapSuperAdmin(st, resolved.AuthEnabled(), opt.SuperAdmin); err != nil {
return err
}
pool := st.Pool()
if versions, verr := st.MigrationStatus(); verr == nil {
@@ -249,9 +224,17 @@ func Run(opt Options) error {
if tools.ExecBackendName() == "sandbox" {
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
}
broker, err := wireExecEnv(st, &opt)
if err != nil {
return fmt.Errorf("exec env: %w", err)
}
if opt.ExecDepsDir != "" {
tools.SetExecDepsDir(opt.ExecDepsDir)
log.Printf("exec tools: sandbox deps layer=%s", opt.ExecDepsDir)
if err := tools.ExecDoctor(); err != nil {
return fmt.Errorf("exec doctor: %w", err)
}
log.Printf("exec tools: doctor ok (docx/openpyxl/pptx)")
}
if opt.ExecWorkspace == "overlay" {
tools.SetWorkspaceOverlay(true)
@@ -292,16 +275,19 @@ func Run(opt Options) error {
if err := setSvc.SnapshotAndMigrateSecrets(); err != nil {
return fmt.Errorf("migrate secrets: %w", err)
}
runtime.SecretLookup = func(owner, service string) (string, bool) {
runtime.SecretLookup = func(owner, service string) (string, string, bool) {
if owner == "" {
owner = store.OwnerDefault
}
if v, ok := st.ActiveSecret(owner, service); ok {
return v, true
return v, "owner", true
}
// P2 受管:密钥统一迁入 secrets 表后,系统层受管密钥挂在系统行
//(owner=settings.OwnerSystemRow);租户未单独配置时回退系统密钥。
return st.ActiveSecret(settings.OwnerSystemRow, service)
if v, ok := st.ActiveSecret(settings.OwnerSystemRow, service); ok {
return v, "system", true
}
return "", "", false
}
rag.SecretLookup = func(service, fallback string) string {
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
@@ -311,11 +297,7 @@ func Run(opt Options) error {
// 未配置时调用返回明确的「未配置」提示,不静默降级为空结果。
if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider {
effNow := setSvc.Effective(store.OwnerDefault, "")
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
tavilyNow = active
}
keys := splitKeyPool(tavilyNow)
keys := tavilyKeyPool(st, effNow.GetString("tools.tavily.apiKeys", ""))
if len(keys) == 0 {
return nil
}
@@ -348,91 +330,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
},
}
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
tavilyNow = active
}
if keys := splitKeyPool(tavilyNow); 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
}
cfg.CodeOrchestration = 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)
@@ -450,6 +361,15 @@ func Run(opt Options) error {
subRunner := &runtime.SubLLMRunner{Prompts: promptSvc, Trace: traceFn}
mockRunner := &runtime.MockRunner{}
mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{})
if broker != nil {
mgr.SetExecEnvDecider(func(actor, id string, allow, isolate bool) (string, error) {
out, err := broker.Decide(context.Background(), id, actor, false, allow, isolate)
if err != nil {
return "", err
}
return out.Detail, nil
})
}
authOn := resolved.AuthEnabled()
effectiveAccount := accountEffectiveAuthorization(st, authOn)
mgr.SetAccountEffectiveAuthorization(effectiveAccount, !authOn)
@@ -463,11 +383,6 @@ func Run(opt Options) error {
}
st.SetInstanceID(instanceID)
mgr.SetInstanceID(instanceID)
if err := mgr.ReconcileInterrupted(); err != nil {
return fmt.Errorf("reconcile interrupted runs: %w", err)
}
// 后台子代理通知重启重投(N2 WP5):未投递结果装回队列并按空闲语义投递。
mgr.LoadPendingNotices()
mgr.SetSquadRunner(&runtime.SquadRunner{})
// 意图闸门:插件意图 overlay → agent-intent 注册表(未装载意图 = 行为回退)。
@@ -526,17 +441,6 @@ func Run(opt Options) error {
}
// fs 预留能力沙箱根:<DataDir>/workspaces/<owner>/<conversationID>/。
mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces"))
// 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。
mgr.ResumeInterruptedChildrenChecked(context.Background(), func(ctx context.Context, owner string) error {
valid, err := effectiveAccount(ctx, owner)
if err != nil {
return err
}
if !valid {
return store.ErrAccountForbidden
}
return nil
})
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
@@ -553,8 +457,6 @@ func Run(opt Options) error {
// 执行身份与命令消费(协作存储 U5):PG 档启动跨实例命令轮询;
// SQLite 本地档无跨实例命令(StartCommandConsumer 自行短路)。
mgr.SetInstanceTag(instanceTag(opt), opt.Addr)
stopCommands := mgr.StartCommandConsumer(context.Background())
defer stopCommands()
// 执行预算(协作存储 U3,R8):runtime.execBudgetGlobal / runtime.execBudgetPerUser
// 全零=不限(本地档默认,行为不变);开启后执行许可共享账本生效,
// 待执行队列上限 runtime.execQueueMax(默认 200,容量合同数值待 U7 实测调整)。
@@ -582,17 +484,7 @@ func Run(opt Options) error {
// U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发
//(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。
surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params,
Entrance: "button", Owner: d.Owner, ConversationID: d.ConversationID})
state := "committed"
if resp.Receipt.State != "" {
state = string(resp.Receipt.State)
}
cardID := ""
if resp.Card != nil {
cardID = string(resp.Card.ID)
}
return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
return dispatchSurfaceAction(ctx, bus, d)
})
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
@@ -620,13 +512,27 @@ func Run(opt Options) error {
bus.SetPendingStore(st)
pluginHost.OnCommands = bus.RegisterPluginCommands
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
authzSlot := &projectAuthz{}
authzSlot := &projectAuthz{enrich: func(q extension.ResourceAuthzQuery) (extension.ResourceAuthzQuery, error) {
account, err := st.GetAccountByOwner(q.Actor)
if err != nil {
return q, err
}
snapshot, err := rolePolicySnapshotForUser(st, account.ID)
if err != nil {
return q, err
}
q.AuthorityEpoch = snapshot.AuthorityEpoch
q.RolePolicy = &snapshot
return q, nil
}}
projectSlot := &projectLifecycleSlot{}
workObserverSlot := &projectWorkObserverSlot{}
directorySlot, err := newAccountDirectorySlot(st)
if err != nil {
return err
}
caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn,
contextSlot := &projectContextSlot{}
caps := hostcaps{projects: projectSlot, workObserver: workObserverSlot, contexts: contextSlot, projectAudit: &projectAuditSlot{}, directory: directorySlot, regulations: &regulationsSlot{}, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn,
accountGuard: func(owner string) error {
valid, err := effectiveAccount(context.Background(), owner)
if err != nil {
@@ -637,6 +543,35 @@ func Run(opt Options) error {
}
return nil
}}
bus.SetHostProjectGuard(func(_ context.Context, req cmdbus.Request) error {
return scratchCommandHostGuard(st, req)
})
bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error {
if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" {
return fmt.Errorf("formal project command requires an authenticated conversation source")
}
if err := rejectScratchCommandHost(st, req); err != nil {
return err
}
conv, err := st.GetConversation(req.Owner, req.ConversationID)
if err != nil || string(conv.ProjectID) != req.HostProjectID {
return fmt.Errorf("formal project command conversation binding changed")
}
raw, err := json.Marshal(req.Params)
if err != nil {
return err
}
digest := sha256.Sum256(raw)
decision, err := preflightProjectMessage(ctx, st, workObserverSlot, projectSlot.work(), req.AnchorMessageID,
"project-command:"+req.ID, req.Owner, req.ConversationID, req.HostProjectID, req.ID, hex.EncodeToString(digest[:]))
if err != nil {
return err
}
if decision.State != extension.ProjectWorkProceed || decision.DecisionID == "" {
return fmt.Errorf("source analysis did not commit a proceed decision")
}
return nil
})
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
@@ -649,7 +584,20 @@ func Run(opt Options) error {
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
wireScopedHostFactory(caps)
compiledSummary := applyCompiled(skillsRoot, bus, caps)
// EXT-20:法规检索作为内置只读注册表工具暴露给模型(走 inprocHost 审计路径)。
if err := registerRegulationsTools(reg, caps); err != nil {
return fmt.Errorf("regulations tools: %w", err)
}
if err := requireProjectContextProvider(opt.RequireProjectContext, contextSlot); err != nil {
return err
}
mgr.SetProjectWorkAuthorization(projectSlot.work())
wireProjectWorkGate(mgr, st, caps, workObserverSlot, projectSlot.work())
if contextSlot.active() {
mgr.SetProjectContextResolver(contextSlot.resolve)
mgr.SetProjectEvidenceReader(contextSlot.readEvidence)
mgr.SetProjectContextSourceRechecker(contextSlot.recheck)
}
mgr.SetKnowledgeProjectScope(func(ctx context.Context, actor string, id core.ID) (core.ID, error) {
return projectSlot.resolve(ctx, caps, actor, id)
})
@@ -694,6 +642,7 @@ func Run(opt Options) error {
}
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
srv.ExecEnv = broker
projectSlot.wire(srv, caps)
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
srv.SetProjectAuthorizer(authzSlot.Authorize)
@@ -737,23 +686,13 @@ func Run(opt Options) error {
// 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。
srv.Auth = server.NewAuthenticator(st, authOn)
if authOn {
srv.Auth.SetSuperAdminFallback(opt.SuperAdmin.Owner, opt.SuperAdmin.Password())
log.Printf("auth: API key required (create with: agent-system apikey create --owner <name>)")
}
srv.Subscribe = func(conversationID string) (<-chan core.StreamEvent, func()) {
return b.Subscribe(conversationID)
}
httpSrv := &http.Server{Addr: opt.Addr, ReadHeaderTimeout: 10 * time.Second, Handler: server.RequestGuard{
AuthOn: authOn, AllowedHosts: opt.AllowedHosts, Next: srv.Handler(),
}}
go func() {
log.Printf("agent-system serving on http://%s (data: %s, plugins: %s)", opt.Addr, opt.DataDir, opt.PluginsDir)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http: %v", err)
}
}()
// PG 档:事件跨实例扇出(NOTIFY/LISTEN;SQLite 档不需要)。
if st.Dialect() == "postgres" {
mgr.SetEventNotifier(pgbus.NewNotifier(st).Notify)
@@ -764,6 +703,35 @@ func Run(opt Options) error {
defer listener.Close(context.Background())
log.Printf("pgbus: LISTEN agent_events(跨实例事件扇出)")
}
// Recovery may execute model turns. All card, policy, command, budget and
// authorization ports must be ready before recovery or public intake begins.
if err := mgr.ReconcileInterrupted(); err != nil {
return fmt.Errorf("reconcile interrupted runs: %w", err)
}
mgr.LoadPendingNotices()
// 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。
mgr.ResumeInterruptedChildrenChecked(context.Background(), func(ctx context.Context, owner string) error {
valid, err := effectiveAccount(ctx, owner)
if err != nil {
return err
}
if !valid {
return store.ErrAccountForbidden
}
return nil
})
stopCommands := mgr.StartCommandConsumer(context.Background())
defer stopCommands()
httpSrv := &http.Server{Addr: opt.Addr, ReadHeaderTimeout: 10 * time.Second, Handler: server.RequestGuard{
AuthOn: authOn, AllowedHosts: opt.AllowedHosts, Next: srv.Handler(),
}}
go func() {
log.Printf("agent-system serving on http://%s (data: %s, plugins: %s)", opt.Addr, opt.DataDir, opt.PluginsDir)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
@@ -808,6 +776,9 @@ func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmi
if password != "" || generatePassword {
return "", "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only")
}
if isAdmin {
return "", "", ErrCLIAdminAppointment
}
var out store.AccountOutcome
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
@@ -853,19 +824,10 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error
// BootstrapAdmin 首位管理员引导:单事务串行(KTD12——PG 咨询锁/SQLite 插入后复查),
// 建号、激活 Key 与审计共同提交;库内已有用户即拒绝接管。
// 现已停用:超级管理员只由服务启动按 SUPER_ADMIN_OWNER/SUPER_ADMIN_PASSWORD
// 引导,其余管理员只由超级管理员在服务端任命;此入口在打开存储前拒绝。
func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) {
var plain string
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
return err
}
outc, err := st.BootstrapFirstAdminWrite(store.AccountWrite{
Owner: owner, Name: owner, Actor: cliActor(), InstanceID: accountInstanceID(),
})
plain = outc.Plain
return err
})
return plain, err
return "", ErrCLIBootstrapAdminDisabled
}
// ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、
@@ -1087,6 +1049,38 @@ func splitKeyPool(s string) []string {
return out
}
// ErrScratchCommandHost 是命令分发的临时会话门禁:临时会话没有 RDM 项目域,
// 任何以基座项目为前提的命令(rdm.* 与正式项目命令)在里面一律不可用。
var ErrScratchCommandHost = errors.New("not available in scratch sessions")
// rejectScratchCommandHost 在命令总线内部分发(宿主与插件处理器共用的分发面)
// 上执行同一个门禁。基座项目是临时会话时拒绝;不是临时会话(包括项目读不到)
// 时交回原路径,不改变任何现有判定。
func rejectScratchCommandHost(st *store.Store, req cmdbus.Request) error {
if st == nil || req.HostProjectID == "" {
return nil
}
handled, err := st.ScratchProjectDecision(req.HostProjectID, req.Owner)
if err != nil {
return err
}
if handled {
return ErrScratchCommandHost
}
return nil
}
// scratchCommandHostGuard 是命令总线上的基座项目门禁(组合根装配):
// 项目域命令(rdm.* 全族与正式项目命令 cmdbus.RequiresProjectWorkDecision)在
// 临时会话宿主上一律拒绝,其它命令与普通项目宿主原样通过。它挂在分发面本身,
// 所以聊天意图路由与 system.intent 再分发不再绕过 HTTP 403 与正式命令预检。
func scratchCommandHostGuard(st *store.Store, req cmdbus.Request) error {
if !strings.HasPrefix(req.ID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(req.ID) {
return nil
}
return rejectScratchCommandHost(st, req)
}
// busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口
// (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。
type busAdapter struct{ bus *cmdbus.Bus }
+23 -2
View File
@@ -64,10 +64,12 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
return fmt.Errorf("extension: command spec: %w", err)
}
return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) {
host := requestBoundHost{inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor}}
host := requestBoundHost{inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor,
conversationID: core.ID(req.ConversationID), hostProjectID: core.ID(req.HostProjectID),
entrance: req.Entrance, humanGrant: req.HumanGrant}}
return h(ctx, host, extension.Request{
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID, OperationID: req.OperationID,
HostProjectID: req.HostProjectID,
})
})
@@ -127,6 +129,17 @@ func (r pluginRegistry) RegisterAccountDirectoryScopeResolver(fn extension.Accou
return r.caps.directory.register(r.pluginID, fn)
}
func (r compiledRegistry) RegisterRegulationsProvider(extension.RegulationsProvider) error {
return errors.New("extension: declarative plugins cannot register a regulations provider")
}
func (r pluginRegistry) RegisterRegulationsProvider(p extension.RegulationsProvider) error {
if r.caps.regulations == nil {
return errors.New("extension: regulations provider registration unavailable")
}
return r.caps.regulations.register(r.pluginID, p)
}
// RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
@@ -169,6 +182,14 @@ func (r recordRegistry) RegisterAccountDirectoryScopeResolver(fn extension.Accou
return registrar.RegisterAccountDirectoryScopeResolver(fn)
}
func (r recordRegistry) RegisterRegulationsProvider(p extension.RegulationsProvider) error {
registrar, ok := r.Registry.(extension.RegulationsRegistrar)
if !ok {
return errors.New("extension: regulations provider registration unavailable")
}
return registrar.RegisterRegulationsProvider(p)
}
func (r recordRegistry) AddCommand(spec json.RawMessage) error {
if err := r.Registry.AddCommand(spec); err != nil {
return err
+132
View File
@@ -0,0 +1,132 @@
package app
import (
"agent-system-backend/internal/execenv"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
core "agent-system-core"
)
// envStore 把 store 的行类型适配成 execenv.Store。
type envStore struct{ *store.Store }
func (s envStore) InsertExecEnvRequest(r execenv.Request) error {
return s.Store.InsertExecEnvRequest(store.ExecEnvRequest{
ID: r.ID, ConversationID: r.ConversationID, Owner: r.Owner, Spec: r.Spec,
Reason: r.Reason, Status: r.Status, LayerID: r.LayerID, Detail: r.Detail,
Approver: r.Approver, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
})
}
func (s envStore) GetExecEnvRequest(id string) (execenv.Request, error) {
r, err := s.Store.GetExecEnvRequest(id)
if err != nil {
return execenv.Request{}, err
}
return execenv.Request{
ID: r.ID, ConversationID: r.ConversationID, Owner: r.Owner, Spec: r.Spec,
Reason: r.Reason, Status: r.Status, LayerID: r.LayerID, Detail: r.Detail,
Approver: r.Approver, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
}, nil
}
func (s envStore) ListExecEnvRequests(conversationID string, limit int) ([]execenv.Request, error) {
rows, err := s.Store.ListExecEnvRequests(conversationID, limit)
if err != nil {
return nil, err
}
out := make([]execenv.Request, 0, len(rows))
for _, r := range rows {
out = append(out, execenv.Request{
ID: r.ID, ConversationID: r.ConversationID, Owner: r.Owner, Spec: r.Spec,
Reason: r.Reason, Status: r.Status, LayerID: r.LayerID, Detail: r.Detail,
Approver: r.Approver, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
})
}
return out, nil
}
func (s envStore) UpdateExecEnvRequest(id, status, layerID, detail, approver string) error {
return s.Store.UpdateExecEnvRequest(id, status, layerID, detail, approver)
}
func (s envStore) GetExecEnvBinding(conversationID string) (execenv.Binding, error) {
b, err := s.Store.GetExecEnvBinding(conversationID)
if err != nil {
return execenv.Binding{}, err
}
return execenv.Binding{
ConversationID: b.ConversationID, BaseID: b.BaseID, Extensions: b.Layers,
Isolated: b.Isolated, UpdatedAt: b.UpdatedAt,
}, nil
}
func (s envStore) PutExecEnvBinding(b execenv.Binding) error {
return s.Store.PutExecEnvBinding(store.ExecEnvBinding{
ConversationID: b.ConversationID, BaseID: b.BaseID, Layers: b.Extensions,
Isolated: b.Isolated, UpdatedAt: b.UpdatedAt,
})
}
func (s envStore) ListExecEnvBindings() ([]execenv.Binding, error) {
rows, err := s.Store.ListExecEnvBindings()
if err != nil {
return nil, err
}
out := make([]execenv.Binding, 0, len(rows))
for _, b := range rows {
out = append(out, execenv.Binding{
ConversationID: b.ConversationID, BaseID: b.BaseID, Extensions: b.Layers,
Isolated: b.Isolated, UpdatedAt: b.UpdatedAt,
})
}
return out, nil
}
func (s envStore) InsertExecEnvAudit(owner, action, actor, target, outcome, entry string) error {
return s.Store.InsertExecEnvAudit(owner, action, actor, target, outcome, entry)
}
// wireExecEnv 打开环境根,把基础层交给执行器,并接上申请代理。
// 两者都空时不改变现状(解释器仍固定为 /usr/bin/python3,避免 linuxbrew 路径)。
func wireExecEnv(st *store.Store, opt *Options) (*execenv.Broker, error) {
if opt.ExecEnvRoot == "" {
return nil, nil
}
root, err := execenv.Open(opt.ExecEnvRoot)
if err != nil {
return nil, err
}
if bin, ok := root.PythonBin(); ok {
tools.SetExecPython(bin, root.PythonPrefix())
}
tools.SetExecShimDir(root.BinDir())
if opt.ExecDepsDir == "" {
if id, err := root.CurrentBase(); err != nil {
return nil, err
} else if id != "" {
opt.ExecDepsDir = root.LayerDir(id)
}
}
py, _ := root.PythonBin()
if py == "" {
py = "/usr/bin/python3"
}
broker := execenv.NewBroker(root, envStore{st}, execenv.Pip{Python: py})
tools.SetEnvBroker(broker)
tools.SetExecLayers(broker.LayersFor)
tools.SetEnvApproval(func(owner, conv, requestID, detail string) error {
card := &core.HITLCard{
ID: core.ID("hitl_" + requestID), RunID: "execenv", ConversationID: core.ID(conv),
Question: "批准为这个会话安装额外的 Python 包?", Kind: core.HITLKindChoice,
Detail: "exec-env:" + requestID + "\n" + detail,
Choices: []core.HITLChoice{
{ID: "approve", Label: "批准", Description: detail, Impact: "只挂到这个会话,不改变基础层", Recommended: true},
{ID: "isolate", Label: "批准并使用隔离层", Description: "与基础层版本冲突时用这份独立闭包", Impact: "这个会话不再叠基础层"},
{ID: "reject", Label: "拒绝", Description: "不安装", Impact: "会话保持现状"},
},
}
return st.SaveHITLCard(owner, card)
})
return broker, nil
}
+201
View File
@@ -0,0 +1,201 @@
//go:build unix
package app
import (
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"agent-system-backend/internal/instanceguard"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func TestSecondProcessRejectedBeforeOpen(t *testing.T) {
if os.Getenv("U8_APP_ROLE") == "hold" {
holdService(t)
return
}
dir := t.TempDir()
ready := filepath.Join(dir, "ready")
cmd := exec.Command(os.Args[0], "-test.run=TestSecondProcessRejectedBeforeOpen")
cmd.Env = append(os.Environ(), "U8_APP_ROLE=hold", "U8_APP_DIR="+dir, "U8_APP_READY="+ready)
cmd.Dir = t.TempDir()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
})
waitReady(t, ready)
_, _, err := openServiceStore(Options{DataDir: dir, Addr: ":1"})
if !errors.Is(err, instanceguard.ErrBusy) {
t.Fatalf("second open: %v", err)
}
if err := Migrate(Options{DataDir: dir}); !errors.Is(err, instanceguard.ErrBusy) {
t.Fatalf("migrate while held: %v", err)
}
cli, err := store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
run, err := cli.GetRunByID("run_u8_hold")
if err != nil {
t.Fatal(err)
}
if run.Status != core.RunWaitingHITL {
t.Fatalf("run changed while second process was refused: %s", run.Status)
}
if _, err := cli.CreateProject("owner", "short"); err != nil {
t.Fatalf("account-style write while service holds the guard: %v", err)
}
if err := cli.Close(); err != nil {
t.Fatal(err)
}
if err := cmd.Process.Kill(); err != nil {
t.Fatal(err)
}
_, _ = cmd.Process.Wait()
st, release, err := openServiceStore(Options{DataDir: dir, Addr: ":2"})
if err != nil {
t.Fatal(err)
}
defer release()
run, err = st.GetRunByID("run_u8_hold")
if err != nil {
t.Fatal(err)
}
if run.Status != core.RunWaitingHITL {
t.Fatalf("restart reconciled a run the second process never opened: %s", run.Status)
}
}
func TestCopiedBinaryStillSharesGuard(t *testing.T) {
if os.Getenv("U8_APP_ROLE") == "copy-hold" {
holdService(t)
return
}
bin, err := os.Executable()
if err != nil {
t.Fatal(err)
}
copyPath := filepath.Join(t.TempDir(), "agent-system-test-copy")
raw, err := os.ReadFile(bin)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(copyPath, raw, 0o700); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
ready := filepath.Join(dir, "ready")
cmd := exec.Command(copyPath, "-test.run=TestCopiedBinaryStillSharesGuard")
cmd.Env = append(os.Environ(), "U8_APP_ROLE=copy-hold", "U8_APP_DIR="+dir, "U8_APP_READY="+ready)
cmd.Dir = t.TempDir()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
})
waitReady(t, ready)
if _, _, err := openServiceStore(Options{DataDir: dir}); !errors.Is(err, instanceguard.ErrBusy) {
t.Fatalf("copied binary: %v", err)
}
}
func holdService(t *testing.T) {
t.Helper()
dir := os.Getenv("U8_APP_DIR")
st, _, err := openServiceStore(Options{DataDir: dir})
if err != nil {
t.Fatal(err)
}
run := &core.Run{
ID: "run_u8_hold", ConversationID: "c", ProjectID: "p", Owner: "o",
Kind: core.RunConversation, Status: core.RunWaitingHITL,
}
if err := st.CreateRun(run); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(os.Getenv("U8_APP_READY"), []byte("ready"), 0o600); err != nil {
t.Fatal(err)
}
time.Sleep(30 * time.Second)
}
func waitReady(t *testing.T, ready string) {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(ready); err == nil {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("child did not become ready")
}
func TestAdoptDoesNotCreateMissingDatabase(t *testing.T) {
dir := t.TempDir()
_, release, err := openServiceStore(Options{DataDir: dir})
if err != nil {
t.Fatal(err)
}
release()
db := filepath.Join(dir, "agent-system.db")
if err := os.Remove(db); err != nil {
t.Fatal(err)
}
_, _, err = openServiceStore(Options{DataDir: dir, AdoptInstanceGuard: true})
if !errors.Is(err, instanceguard.ErrDBMissing) {
t.Fatalf("adopt missing: %v", err)
}
if _, err := os.Stat(db); !os.IsNotExist(err) {
t.Fatalf("adopt created a database: %v", err)
}
}
func TestIdentityFailureDoesNotStick(t *testing.T) {
dir := t.TempDir()
t.Setenv("AGENT_SYSTEM_INSTANCE_ID", "wrong")
if _, _, err := openServiceStore(Options{DataDir: dir}); err == nil {
t.Fatal("missing identity row was accepted")
}
t.Setenv("AGENT_SYSTEM_INSTANCE_ID", "")
st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
marker, ok, err := st.LoadInstanceGuard()
if err != nil || ok || marker.InstanceID != "" {
t.Fatalf("failed start wrote a guard id: ok=%v id=%q err=%v", ok, marker.InstanceID, err)
}
if err := st.EnsureSystemIdentity("right", "sqlite"); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
t.Setenv("AGENT_SYSTEM_INSTANCE_ID", "right")
opened, release, err := openServiceStore(Options{DataDir: dir})
if err != nil {
t.Fatal(err)
}
release()
_ = opened
}
+489 -5
View File
@@ -5,9 +5,14 @@ import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
authzsvc "agent-system-backend/internal/authz"
"agent-system-backend/internal/authz/catalog"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/runtime"
@@ -22,7 +27,11 @@ import (
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct {
projects *projectLifecycleSlot
workObserver *projectWorkObserverSlot
directory *accountDirectorySlot
contexts *projectContextSlot
projectAudit *projectAuditSlot
regulations *regulationsSlot // EXT-20 法规提供方槽(共享指针;nil/未注册=不可用)
accountGuard func(string) error
allowImplicitLocal bool
st *store.Store
@@ -30,6 +39,7 @@ type hostcaps struct {
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
now func() time.Time
}
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
@@ -39,6 +49,7 @@ type projectAuthz struct {
fn extension.ResourceAuthorizer
holder2 string // EXT-8b 资源级持有者(首个注册者生效)
fn2 extension.ResourceAuthorizerV2
enrich func(extension.ResourceAuthzQuery) (extension.ResourceAuthzQuery, error)
}
func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
@@ -64,12 +75,19 @@ func (a *projectAuthz) setV2(pluginID string, fn extension.ResourceAuthorizerV2)
// 布尔(兼容现行行为)。查询字段由执行端受信构造。
func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, error) {
a.mu.Lock()
fn2, fn1 := a.fn2, a.fn
fn2, fn1, enrich := a.fn2, a.fn, a.enrich
a.mu.Unlock()
if fn2 != nil {
if q.Actor == "" || q.Action == "" || q.ResourceType == "" || q.ResourceID == "" {
return false, nil // 受信上下文字段缺失=不可判定=拒绝
}
if enrich != nil {
var err error
q, err = enrich(q)
if err != nil {
return false, err
}
}
return fn2(q)
}
if fn1 == nil || q.Owner == "" || q.ProjectID == "" {
@@ -191,10 +209,14 @@ func (c hostcaps) parse(ctx context.Context, filename, mime string, content []by
// actor 是本次分发主体(通常等于 owner);AdminProvider 能力经 actor 查询
// (EXT-18)。非 dispatch 上下文(EXT-11 scoped host)actor 为空——IsAdmin 恒 false。
type inprocHost struct {
caps hostcaps
pluginID string
owner string
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
caps hostcaps
pluginID string
owner string
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
conversationID core.ID // dispatch-bound, never taken from completion arguments
hostProjectID core.ID // dispatch project; rechecked against stored conversation
entrance string // dispatch entrance; model tools are "agent"
humanGrant *cmdbus.HumanGrant
}
func (h inprocHost) requireEffective() error {
@@ -305,6 +327,465 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account
return entries, nil
}
func (h inprocHost) rolePolicyActor() (store.Account, error) {
if h.actor == "" {
return store.Account{}, store.ErrAccountForbidden
}
account, err := h.caps.st.GetAccountByOwner(h.actor)
if err != nil || !account.Enabled {
return store.Account{}, store.ErrAccountForbidden
}
return account, nil
}
func (h inprocHost) CurrentRolePolicy(ctx context.Context) (extension.RolePolicySnapshot, error) {
if err := ctx.Err(); err != nil {
return extension.RolePolicySnapshot{}, err
}
actor, err := h.rolePolicyActor()
if err != nil {
return extension.RolePolicySnapshot{}, err
}
return h.RolePolicyForUser(ctx, actor.ID)
}
func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (extension.RolePolicySnapshot, error) {
if err := ctx.Err(); err != nil {
return extension.RolePolicySnapshot{}, err
}
if _, err := h.rolePolicyActor(); err != nil {
return extension.RolePolicySnapshot{}, err
}
return rolePolicySnapshotForUser(h.caps.st, userID)
}
func rolePolicySnapshotForUser(st *store.Store, userID string) (extension.RolePolicySnapshot, error) {
account, err := st.GetAccount(userID)
if err != nil {
return extension.RolePolicySnapshot{}, err
}
epoch, err := st.AuthorityEpoch()
if err != nil {
return extension.RolePolicySnapshot{}, err
}
snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion,
UserID: account.ID, Owner: account.Owner, AccountEnabled: account.Enabled,
AuthorityEpoch: epoch, Permissions: map[string]extension.RolePermission{}}
linked, err := authzsvc.NewService(st).EffectiveLinkedCapabilities(account.Owner)
if err != nil {
return extension.RolePolicySnapshot{}, err
}
snapshot.Linked = extension.LinkedCapabilities{ProjectCreate: linked.ProjectCreate, Source: linked.Source,
Actions: linked.LinkedActions, Conflicts: linked.Conflicts}
view, err := st.RoleForUser(userID)
if err != nil {
return extension.RolePolicySnapshot{}, err
}
if view.Binding == nil || view.Role == nil {
return snapshot, nil
}
snapshot.BindingID = view.Binding.BindingID
snapshot.RoleID = view.Role.RoleID
snapshot.RoleName = view.Role.Name
snapshot.RoleDescription = view.Role.Description
snapshot.RoleEnabled = view.Role.Enabled
snapshot.RoleVersion = view.Role.RoleVersion
permissions, err := st.RolePermissions(view.Role.RoleID)
if err != nil {
return extension.RolePolicySnapshot{}, err
}
for _, permission := range permissions {
snapshot.Permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}
}
return snapshot, nil
}
func (h inprocHost) RolePolicyForOwner(ctx context.Context, owner string) (extension.RolePolicySnapshot, error) {
if _, err := h.rolePolicyActor(); err != nil {
return extension.RolePolicySnapshot{}, err
}
account, err := h.caps.st.GetAccountByOwner(owner)
if err != nil {
return extension.RolePolicySnapshot{}, err
}
return h.RolePolicyForUser(ctx, account.ID)
}
func (h inprocHost) CheckRoleAction(ctx context.Context, userID, actionID string) (extension.RoleActionDecision, error) {
decision := extension.RoleActionDecision{ActionID: actionID}
if _, ok := catalog.DescriptorByID(actionID); !ok {
decision.Reason = "unknown-action"
return decision, nil
}
snapshot, err := h.RolePolicyForUser(ctx, userID)
if err != nil {
return decision, err
}
decision.RoleID = snapshot.RoleID
decision.AuthorityEpoch = snapshot.AuthorityEpoch
if !snapshot.AccountEnabled {
decision.Reason = "account-disabled"
return decision, nil
}
if snapshot.RoleID == "" {
decision.Reason = "role-unassigned"
return decision, nil
}
if !snapshot.RoleEnabled {
decision.Reason = "role-disabled"
return decision, nil
}
permission, ok := snapshot.Permissions[actionID]
if !ok {
decision.Reason = "action-unconfigured"
return decision, nil
}
decision.Allowed = permission.Allowed
decision.CapAllowed = permission.CapAllowed
if !permission.CapAllowed {
decision.Reason = "role-cap"
} else if !permission.Allowed {
decision.Reason = "role-default-deny"
}
return decision, nil
}
func (h inprocHost) CheckCurrentSystemAction(ctx context.Context, actionID string) (extension.SystemActionDecision, error) {
if err := ctx.Err(); err != nil {
return extension.SystemActionDecision{}, err
}
if _, err := h.rolePolicyActor(); err != nil {
return extension.SystemActionDecision{}, err
}
decision := authzsvc.NewService(h.caps.st).AuthorizeSystem(h.actor, actionID)
return extension.SystemActionDecision{ActionID: actionID, Allowed: decision.Allowed,
AuthorityEpoch: decision.AuthorityEpoch, Reason: decision.Reason}, nil
}
func (h inprocHost) currentCapabilityExplanation(ctx context.Context) (store.CapabilityExplanation, extension.CapabilitySubject, error) {
if err := ctx.Err(); err != nil {
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
}
if _, err := h.rolePolicyActor(); err != nil {
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
}
explanation, err := authzsvc.NewService(h.caps.st).ExplainCapabilities(h.actor)
if err != nil {
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
}
subject := extension.CapabilitySubject{SchemaVersion: extension.CapabilityExplainSchemaVersion,
UserID: explanation.UserID, Owner: explanation.Owner, AuthorityEpoch: explanation.AuthorityEpoch,
RoleID: explanation.RoleID, RoleName: explanation.RoleName, RoleEnabled: explanation.RoleEnabled}
return explanation, subject, nil
}
func capabilityActionExplains(in []store.CapabilityActionExplain) []extension.CapabilityActionExplain {
out := make([]extension.CapabilityActionExplain, 0, len(in))
for _, action := range in {
out = append(out, extension.CapabilityActionExplain(action))
}
return out
}
func (h inprocHost) CurrentCapabilityExplain(ctx context.Context) (extension.CapabilityExplain, error) {
explanation, subject, err := h.currentCapabilityExplanation(ctx)
if err != nil {
return extension.CapabilityExplain{}, err
}
capability := explanation.Capability
return extension.CapabilityExplain{CapabilitySubject: subject,
ProjectCreate: extension.CapabilityActionExplain(capability.ProjectCreate),
Linked: capabilityActionExplains(capability.Linked),
Conflicts: append([]string{}, capability.Conflicts...), MigrationNote: capability.MigrationNote}, nil
}
func (h inprocHost) CurrentMemberBasicCollaborate(ctx context.Context) (extension.MemberBasicCollaborate, error) {
explanation, subject, err := h.currentCapabilityExplanation(ctx)
if err != nil {
return extension.MemberBasicCollaborate{}, err
}
return extension.MemberBasicCollaborate{CapabilitySubject: subject,
Actions: capabilityActionExplains(explanation.MemberBasicCollaborate.Actions)}, nil
}
func (h inprocHost) CurrentDocumentQualify(ctx context.Context) (extension.DocumentQualify, error) {
explanation, subject, err := h.currentCapabilityExplanation(ctx)
if err != nil {
return extension.DocumentQualify{}, err
}
duties := make([]extension.DocumentDutyQualify, 0, len(explanation.DocumentQualify.Duties))
for _, duty := range explanation.DocumentQualify.Duties {
duties = append(duties, extension.DocumentDutyQualify(duty))
}
return extension.DocumentQualify{CapabilitySubject: subject, Duties: duties}, nil
}
func (h inprocHost) requireRoleAdminAction(actionID string) error {
if h.actor == "" || h.owner != h.actor {
return store.ErrAccountForbidden
}
decision := authzsvc.NewService(h.caps.st).AuthorizeSystem(h.actor, actionID)
if !decision.Allowed {
return store.ErrAccountForbidden
}
return nil
}
func roleAdminRecord(view store.RoleAdminView) extension.RoleAdminRecord {
out := extension.RoleAdminRecord{RoleID: view.RoleID, Name: view.Name, Description: view.Description,
Enabled: view.Enabled, RoleVersion: view.RoleVersion, BindingCount: view.BindingCount,
Permissions: map[string]extension.RolePermission{}}
for _, permission := range view.Permissions {
out.Permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}
}
return out
}
func (h inprocHost) ListRoles(ctx context.Context) ([]extension.RoleAdminRecord, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := h.requireRoleAdminAction("system.roles.view"); err != nil {
return nil, err
}
result := []extension.RoleAdminRecord{}
cursor := ""
for {
roles, next, err := h.caps.st.ListRoles("", "fuzzy", "all", "", cursor, 100)
if err != nil {
return nil, err
}
for _, role := range roles {
result = append(result, roleAdminRecord(role))
}
if next == "" {
return result, nil
}
cursor = next
}
}
// ListRoleOptions is the read-only role directory for non-admin dispatch
// actors: enabled roles only, projected as roleId+name, sorted by name then
// roleId. It never requires system.roles.view and never exposes permissions,
// bindings or counts; callers still authorize their own domain action.
func (h inprocHost) ListRoleOptions(ctx context.Context) ([]extension.RoleOption, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := h.requireEffective(); err != nil {
return nil, err
}
if _, err := h.rolePolicyActor(); err != nil {
return nil, err
}
result := []extension.RoleOption{}
cursor := ""
for {
roles, next, err := h.caps.st.ListRoles("", "fuzzy", "all", "", cursor, 100)
if err != nil {
return nil, err
}
for _, role := range roles {
if !role.Enabled {
continue
}
result = append(result, extension.RoleOption{RoleID: role.RoleID, Name: role.Name})
}
if next == "" {
sort.Slice(result, func(i, j int) bool {
if result[i].Name != result[j].Name {
return result[i].Name < result[j].Name
}
return result[i].RoleID < result[j].RoleID
})
return result, nil
}
cursor = next
}
}
var _ extension.RoleOptionsProvider = inprocHost{}
func (h inprocHost) CreateRole(ctx context.Context, name, description string) (extension.RoleAdminRecord, error) {
if err := ctx.Err(); err != nil {
return extension.RoleAdminRecord{}, err
}
if err := h.requireRoleAdminAction("system.roles.manage"); err != nil {
return extension.RoleAdminRecord{}, err
}
role, err := h.caps.st.CreateRole(h.actor, store.RoleCreate{Name: name, Description: description})
if err != nil {
return extension.RoleAdminRecord{}, err
}
view, err := h.caps.st.RoleAdmin(role.RoleID)
return roleAdminRecord(view), err
}
func (h inprocHost) SetRolePermission(ctx context.Context, roleID, actionID string, allowed, capAllowed bool, key string) (extension.RoleAdminRecord, error) {
if err := ctx.Err(); err != nil {
return extension.RoleAdminRecord{}, err
}
if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil {
return extension.RoleAdminRecord{}, err
}
view, err := h.caps.st.RoleAdmin(roleID)
if err != nil {
return extension.RoleAdminRecord{}, err
}
changes := make([]store.RolePermissionChange, 0, len(view.Permissions)+1)
found := false
for _, permission := range view.Permissions {
if permission.ActionID == actionID {
found = true
if permission.Allowed == allowed && permission.CapAllowed == capAllowed {
return roleAdminRecord(view), nil
}
permission.Allowed, permission.CapAllowed = allowed, capAllowed
}
changes = append(changes, store.RolePermissionChange{ActionID: permission.ActionID, Allowed: permission.Allowed, CapAllowed: permission.CapAllowed})
}
if !found {
changes = append(changes, store.RolePermissionChange{ActionID: actionID, Allowed: allowed, CapAllowed: capAllowed})
}
if key == "" {
return extension.RoleAdminRecord{}, store.ErrInvalidRolePolicy
}
if _, err := h.caps.st.ReplaceRolePermissions(h.actor, roleID, view.RoleVersion, changes, key+":"+strconv.FormatInt(view.RoleVersion, 10)); err != nil {
return extension.RoleAdminRecord{}, err
}
view, err = h.caps.st.RoleAdmin(roleID)
return roleAdminRecord(view), err
}
func (h inprocHost) AssignRoleByOwner(ctx context.Context, owner, roleID, key string) (extension.RoleBindingResult, error) {
if err := ctx.Err(); err != nil {
return extension.RoleBindingResult{}, err
}
if err := h.requireRoleAdminAction("system.roles.assign"); err != nil {
return extension.RoleBindingResult{}, err
}
target, err := h.caps.st.GetAccountByOwner(owner)
if err != nil {
return extension.RoleBindingResult{}, err
}
current, err := h.caps.st.RoleForUser(target.ID)
if err != nil {
return extension.RoleBindingResult{}, err
}
if current.Binding != nil && current.Binding.RoleID == roleID {
return extension.RoleBindingResult{BindingID: current.Binding.BindingID, UserID: target.ID, RoleID: roleID,
UserStateVersion: current.Binding.UserStateVersion, AuthorityEpoch: current.Binding.AuthorityEpoch}, nil
}
if key == "" {
return extension.RoleBindingResult{}, store.ErrInvalidRolePolicy
}
binding, err := h.caps.st.AssignUserRole(h.actor, target.ID, roleID, target.StateVersion, key+":"+strconv.FormatInt(target.StateVersion, 10))
if err != nil {
return extension.RoleBindingResult{}, err
}
return extension.RoleBindingResult{BindingID: binding.BindingID, UserID: binding.UserID, RoleID: binding.RoleID,
UserStateVersion: binding.UserStateVersion, AuthorityEpoch: binding.AuthorityEpoch}, nil
}
func (h inprocHost) SetRoleEnabled(ctx context.Context, roleID string, enabled bool) (extension.RoleAdminRecord, error) {
if err := ctx.Err(); err != nil {
return extension.RoleAdminRecord{}, err
}
if err := h.requireRoleAdminAction("system.roles.manage"); err != nil {
return extension.RoleAdminRecord{}, err
}
view, err := h.caps.st.RoleAdmin(roleID)
if err != nil {
return extension.RoleAdminRecord{}, err
}
if view.Enabled == enabled {
return roleAdminRecord(view), nil
}
role, err := h.caps.st.PatchRole(h.actor, roleID, view.RoleVersion, store.RolePatch{Enabled: &enabled})
if err != nil {
return extension.RoleAdminRecord{}, err
}
view, err = h.caps.st.RoleAdmin(role.RoleID)
return roleAdminRecord(view), err
}
func (h inprocHost) ImportRoleAuthority(ctx context.Context, in extension.RoleAuthorityImport, key string) (extension.RoleAuthorityImportResult, error) {
if err := ctx.Err(); err != nil {
return extension.RoleAuthorityImportResult{}, err
}
if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil {
return extension.RoleAuthorityImportResult{}, err
}
roles := make([]store.RoleAuthorityImportRole, 0, len(in.Roles))
for _, role := range in.Roles {
permissions := make([]store.RolePermissionChange, 0, len(role.Permissions))
for actionID, permission := range role.Permissions {
permissions = append(permissions, store.RolePermissionChange{ActionID: actionID,
Allowed: permission.Allowed, CapAllowed: permission.CapAllowed})
}
sort.Slice(permissions, func(i, j int) bool { return permissions[i].ActionID < permissions[j].ActionID })
roles = append(roles, store.RoleAuthorityImportRole{RoleID: role.RoleID, Name: role.Name,
Description: role.Description, Enabled: role.Enabled, RoleVersion: role.RoleVersion,
Permissions: permissions, Owners: append([]string(nil), role.Owners...)})
}
result, err := h.caps.st.ImportRoleAuthority(h.actor, store.RoleAuthorityImport{
SourceSchemaVersion: in.SourceSchemaVersion, SourceSHA256: in.SourceSHA256, Roles: roles}, key)
if err != nil {
return extension.RoleAuthorityImportResult{}, err
}
return extension.RoleAuthorityImportResult{AuthorityEpoch: result.AuthorityEpoch,
ImportedRoles: result.ImportedRoles, ImportedUsers: result.ImportedUsers}, nil
}
func (h inprocHost) ReadRoleAuthorityImport(ctx context.Context, sourceSHA string) (extension.RoleAuthorityImportProjection, error) {
if err := ctx.Err(); err != nil {
return extension.RoleAuthorityImportProjection{}, err
}
if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil {
return extension.RoleAuthorityImportProjection{}, err
}
staged, err := h.caps.st.ReadRoleAuthorityImport(h.actor, sourceSHA)
if err != nil {
return extension.RoleAuthorityImportProjection{}, err
}
roles := make([]extension.RoleAdminRecord, 0, len(staged.Roles))
bindings := map[string]string{}
for _, role := range staged.Roles {
permissions := map[string]extension.RolePermission{}
for _, permission := range role.Permissions {
permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}
}
roles = append(roles, extension.RoleAdminRecord{RoleID: role.RoleID, Name: role.Name,
Description: role.Description, Enabled: role.Enabled, RoleVersion: role.RoleVersion,
BindingCount: len(role.Owners), Permissions: permissions})
for _, owner := range role.Owners {
bindings[owner] = role.RoleID
}
}
epoch, err := h.caps.st.AuthorityEpoch()
if err != nil {
return extension.RoleAuthorityImportProjection{}, err
}
return extension.RoleAuthorityImportProjection{SourceSHA256: staged.SourceSHA256,
AuthorityEpoch: epoch, Roles: roles, Bindings: bindings}, nil
}
func (h inprocHost) ActivateRoleAuthorityImport(ctx context.Context, sourceSHA string, expectedEpoch int64) (extension.RoleAuthorityImportResult, error) {
if err := ctx.Err(); err != nil {
return extension.RoleAuthorityImportResult{}, err
}
if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil {
return extension.RoleAuthorityImportResult{}, err
}
result, err := h.caps.st.ActivateRoleAuthorityImport(h.actor, sourceSHA, expectedEpoch)
if err != nil {
return extension.RoleAuthorityImportResult{}, err
}
return extension.RoleAuthorityImportResult{AuthorityEpoch: result.AuthorityEpoch,
ImportedRoles: result.ImportedRoles, ImportedUsers: result.ImportedUsers}, nil
}
func (h inprocHost) KVPut(key, value string) error {
if err := h.requireEffective(); err != nil {
return err
@@ -323,6 +804,9 @@ func (h inprocHost) Audit(action, target, evidence string) error {
if err := h.requireEffective(); err != nil {
return err
}
if strings.HasPrefix(action, "model.context_manifest") {
return fmt.Errorf("extension: model context manifest is a reserved host receipt")
}
return h.caps.st.AppendAuditEventAccountChecked(h.owner, store.AuditEvent{
Owner: h.owner, Actor: "plugin:" + h.pluginID,
Action: action, Target: target, Evidence: evidence,
+109
View File
@@ -0,0 +1,109 @@
package app
import (
"context"
"errors"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func TestCapabilityExplainProviderExplainsBoundActorOnly(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/capability-explain.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
role, err := st.CreateRole("admin", store.RoleCreate{Name: "项目负责人"})
if err != nil {
t.Fatal(err)
}
// 收窄模型(R2):职位角色只携带单一 project.create 行;document.* /
// project.* 等其他动作由系统授权或项目成员关系承担,写到角色行被
// ReplaceRolePermissions 拒绝(ErrInvalidRolePolicy)。fixture 只设
// project.create,断言围绕这条窄权限:CreateAuthority 投影到四个
// ProjectCreateLinkedActions、MemberBasicCollaborate 全 action-unconfigured、
// DocumentQualify 三职责都 action-unconfigured。
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
[]store.RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
"explain-provider"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-provider-assign"); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: "alice"}
provider, ok := any(h).(extension.CapabilityExplainProvider)
if !ok {
t.Fatal("dispatch host missing CapabilityExplainProvider")
}
ctx := context.Background()
capability, err := provider.CurrentCapabilityExplain(ctx)
if err != nil {
t.Fatal(err)
}
if capability.SchemaVersion != extension.CapabilityExplainSchemaVersion || capability.UserID != alice.ID || capability.Owner != "alice" ||
capability.RoleID != role.RoleID || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != extension.CapabilitySourceRole ||
len(capability.Linked) != 4 || len(capability.Conflicts) != 0 {
t.Fatalf("capability=%+v", capability)
}
snapshot, err := any(h).(extension.RolePolicyProvider).CurrentRolePolicy(ctx)
if err != nil || snapshot.Linked.Source != capability.ProjectCreate.Source || len(snapshot.Linked.Actions) != len(capability.Linked) {
t.Fatalf("snapshot linked=%+v err=%v", snapshot.Linked, err)
}
member, err := provider.CurrentMemberBasicCollaborate(ctx)
if err != nil || member.UserID != alice.ID || len(member.Actions) == 0 {
t.Fatalf("member=%+v err=%v", member, err)
}
// 角色权限投影:document.read 不在职位权限(只有 project.create),因此不能
// 被 Source=CapabilitySourceRole 标记为允许;来源必须是空、Reason 必须是
// "action-unconfigured"——这条边界保护正是窄模型的保护意图。
for _, action := range member.Actions {
if action.ActionID == "document.read" && (action.Allowed || action.Source == extension.CapabilitySourceRole || action.Reason != "action-unconfigured") {
t.Fatalf("document.read=%+v", action)
}
}
document, err := provider.CurrentDocumentQualify(ctx)
if err != nil || document.UserID != alice.ID || len(document.Duties) != 3 {
t.Fatalf("document=%+v err=%v", document, err)
}
// 三职责不被职位权限(project.create)直接资格化,因此 Qualified=false、
// DefaultAllowed=false、Source 空、Reason 为 action-unconfigured;这与
// 历史上「Allowed=false CapAllowed=true 单独写入」的职责资格语义不同——
// 那条路径已被 ReplaceRolePermissions 拒绝,本测试在不依赖该历史路径的
// 前提下保留职责资格投影的边界验证。
for _, duty := range document.Duties {
if duty.ActionID == "document.review" && (duty.Qualified || duty.DefaultAllowed || duty.Source == extension.CapabilitySourceRole || duty.Reason != "action-unconfigured") {
t.Fatalf("document.review=%+v", duty)
}
}
// A scoped Host without a dispatch actor cannot explain anyone.
scoped := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice"}).(extension.CapabilityExplainProvider)
if _, err := scoped.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("scoped capability: %v", err)
}
if _, err := scoped.CurrentMemberBasicCollaborate(ctx); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("scoped member: %v", err)
}
if _, err := scoped.CurrentDocumentQualify(ctx); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("scoped document: %v", err)
}
ghost := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "ghost", actor: "ghost"}).(extension.CapabilityExplainProvider)
if _, err := ghost.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("unknown actor: %v", err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if _, err := provider.CurrentCapabilityExplain(canceled); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled: %v", err)
}
}
+104
View File
@@ -0,0 +1,104 @@
package app
import (
"fmt"
"time"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func (h inprocHost) clock() time.Time {
if h.caps.now != nil {
return h.caps.now()
}
return time.Now()
}
func humanCommand(audience string) (string, error) {
if audience == extension.HumanDecisionAudienceStructure {
return "rdm.structure.decide", nil
}
return "", fmt.Errorf("extension: human decision audience is not issued for this host")
}
func humanEntranceAllowed(entrance string) bool {
switch entrance {
case "button", "card", "api":
return true
default:
return false
}
}
// IssueHumanDecision opens a pending card for the authenticated person.
// Model-tool, chat, and slash entrances cannot open one.
func (h inprocHost) IssueHumanDecision(binding extension.HumanDecisionBinding, question string) (extension.HumanDecisionCard, error) {
if h.caps.st == nil || h.actor == "" || h.actor != binding.Actor || !humanEntranceAllowed(h.entrance) {
return extension.HumanDecisionCard{}, fmt.Errorf("extension: human decision cannot be issued on this dispatch")
}
commandID, err := humanCommand(binding.Audience)
if err != nil {
return extension.HumanDecisionCard{}, err
}
if binding.ProjectID == "" || binding.ProposalID == "" || binding.ProposalRevision < 1 || binding.PayloadHash == "" {
return extension.HumanDecisionCard{}, fmt.Errorf("extension: human decision binding is incomplete")
}
rec, err := h.caps.st.IssueHumanDecision(store.HumanDecision{
PluginID: h.pluginID, Audience: binding.Audience, CommandID: commandID,
HumanActor: binding.Actor, Owner: h.owner, ProjectID: binding.ProjectID,
ProposalID: binding.ProposalID, ProposalRevision: binding.ProposalRevision,
PayloadHash: binding.PayloadHash, Question: question, ConversationID: string(h.conversationID),
}, h.clock())
if err != nil {
return extension.HumanDecisionCard{}, err
}
return extension.HumanDecisionCard{CardID: rec.ID, Question: rec.Question, ExpiresAt: rec.ExpiresAt}, nil
}
// VerifyHumanDecision reports the persisted human response for this dispatch.
// A model tool, a forged entrance, a mismatched binding, an expired card, or
// a revoked card fails closed. The record stays answered; it is not consumed.
func (h inprocHost) VerifyHumanDecision(expected extension.HumanDecisionBinding) (extension.HumanDecisionProof, error) {
if h.caps.st == nil || h.humanGrant == nil || h.entrance == "agent" || h.actor == "" || h.actor != h.humanGrant.Actor {
return extension.HumanDecisionProof{}, fmt.Errorf("extension: this dispatch has no authenticated human decision")
}
rec, err := h.caps.st.HumanDecision(h.humanGrant.CardID)
if err != nil {
return extension.HumanDecisionProof{}, fmt.Errorf("extension: human decision card is not pending for this dispatch")
}
now := h.clock()
if rec.Status != "responded" || rec.PluginID != h.pluginID || rec.ProofID == "" || rec.ProofID != h.humanGrant.ProofID ||
rec.HumanActor != expected.Actor || rec.HumanActor != h.actor || rec.Decision != expected.Decision ||
rec.Audience != expected.Audience || rec.ProjectID != expected.ProjectID || rec.ProposalID != expected.ProposalID ||
rec.ProposalRevision != expected.ProposalRevision || rec.PayloadHash != expected.PayloadHash ||
!now.Before(rec.ExpiresAt) {
return extension.HumanDecisionProof{}, fmt.Errorf("extension: human decision does not match this dispatch")
}
return extension.HumanDecisionProof{
ProofID: rec.ProofID, Issuer: "agent-system-backend", HumanActor: rec.HumanActor,
Audience: rec.Audience, ProjectID: rec.ProjectID, ProposalID: rec.ProposalID,
Revision: rec.ProposalRevision, PayloadHash: rec.PayloadHash, Decision: rec.Decision,
IssuedAt: rec.IssuedAt, ExpiresAt: rec.ExpiresAt, ConversationID: rec.ConversationID,
HITLResponseRef: rec.ID,
}, nil
}
// RevokeHumanDecision invalidates a still-pending card. Answered proofs stay
// available until they expire; consumption is not a host concern.
func (h inprocHost) RevokeHumanDecision(cardID string) error {
if h.caps.st == nil || h.actor == "" || !humanEntranceAllowed(h.entrance) {
return fmt.Errorf("extension: human decision cannot be revoked on this dispatch")
}
return h.caps.st.RevokeHumanDecision(h.pluginID, h.actor, cardID)
}
// HumanGrantFromRecord copies the persisted response onto a dispatch.
// Callers must set it only after an authenticated response or session retry.
func HumanGrantFromRecord(rec store.HumanDecision) *cmdbus.HumanGrant {
if rec.ProofID == "" || rec.Status != "responded" {
return nil
}
return &cmdbus.HumanGrant{CardID: rec.ID, ProofID: rec.ProofID, Actor: rec.HumanActor, Decision: rec.Decision}
}
+170
View File
@@ -0,0 +1,170 @@
package app
import (
"testing"
"time"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func openDecisionStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.OpenSQLite(t.TempDir() + "/decision.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func decisionHost(st *store.Store, plugin, actor, entrance string, now time.Time, grant *cmdbus.HumanGrant) inprocHost {
return inprocHost{
caps: hostcaps{st: st, now: func() time.Time { return now }},
pluginID: plugin, owner: actor, actor: actor, entrance: entrance, humanGrant: grant,
}
}
func binding(decision, hash string, revision int) extension.HumanDecisionBinding {
return extension.HumanDecisionBinding{
Audience: extension.HumanDecisionAudienceStructure, Actor: "alice", ProjectID: "p1",
ProposalID: "prop-1", ProposalRevision: revision, PayloadHash: hash, Decision: decision,
}
}
func TestHumanDecisionAuthenticatedResponse(t *testing.T) {
st := openDecisionStore(t)
now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC)
host := decisionHost(st, "rdm", "alice", "button", now, nil)
card, err := host.IssueHumanDecision(binding("", "hash-1", 1), "确认结构变更")
if err != nil {
t.Fatal(err)
}
answered, err := st.RespondHumanDecision("alice", card.CardID, "accept", now)
if err != nil {
t.Fatal(err)
}
host.humanGrant = &cmdbus.HumanGrant{CardID: answered.ID, ProofID: answered.ProofID, Actor: "alice", Decision: "accept"}
host.entrance = "card"
proof, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1))
if err != nil {
t.Fatal(err)
}
if proof.ProofID == "" || proof.Issuer != "agent-system-backend" || proof.HumanActor != "alice" ||
proof.HITLResponseRef != card.CardID || proof.PayloadHash != "hash-1" {
t.Fatalf("proof = %+v", proof)
}
again, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1))
if err != nil || again.ProofID != proof.ProofID {
t.Fatalf("second verify consumed or changed the proof: %v %+v", err, again)
}
stored, err := st.HumanDecision(card.CardID)
if err != nil || stored.Status != "responded" {
t.Fatalf("host consumed the proof, status=%s err=%v", stored.Status, err)
}
}
func TestHumanDecisionRejectsUntrustedDispatch(t *testing.T) {
st := openDecisionStore(t)
now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC)
host := decisionHost(st, "rdm", "alice", "button", now, nil)
card, err := host.IssueHumanDecision(binding("", "hash-1", 1), "确认")
if err != nil {
t.Fatal(err)
}
answered, err := st.RespondHumanDecision("alice", card.CardID, "accept", now)
if err != nil {
t.Fatal(err)
}
grant := &cmdbus.HumanGrant{CardID: answered.ID, ProofID: answered.ProofID, Actor: "alice", Decision: "accept"}
expect := binding("accept", "hash-1", 1)
agent := decisionHost(st, "rdm", "alice", "agent", now, grant)
if _, err := agent.VerifyHumanDecision(expect); err == nil {
t.Fatal("model tool dispatch verified a human decision")
}
forged := decisionHost(st, "rdm", "alice", "card", now, nil)
if _, err := forged.VerifyHumanDecision(expect); err == nil {
t.Fatal("forged entrance verified without a host grant")
}
if _, err := decisionHost(st, "rdm", "alice", "agent", now, nil).IssueHumanDecision(binding("", "hash-2", 1), "x"); err == nil {
t.Fatal("model tool issued a human decision card")
}
if _, err := decisionHost(st, "other", "alice", "card", now, grant).VerifyHumanDecision(expect); err == nil {
t.Fatal("another plugin reused the proof")
}
other := expect
other.ProposalID = "prop-2"
if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(other); err == nil {
t.Fatal("proof authorized a second proposal")
}
wrongActor := expect
wrongActor.Actor = "bob"
if _, err := decisionHost(st, "rdm", "bob", "card", now, grant).VerifyHumanDecision(wrongActor); err == nil {
t.Fatal("wrong actor verified")
}
wrongProject := expect
wrongProject.ProjectID = "p2"
if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongProject); err == nil {
t.Fatal("wrong project verified")
}
wrongRev := expect
wrongRev.ProposalRevision = 2
if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongRev); err == nil {
t.Fatal("wrong revision verified")
}
wrongHash := expect
wrongHash.PayloadHash = "hash-changed"
if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongHash); err == nil {
t.Fatal("wrong payload hash verified")
}
if _, err := decisionHost(st, "rdm", "alice", "button", now, nil).IssueHumanDecision(
extension.HumanDecisionBinding{Audience: "other.plugin", Actor: "alice", ProjectID: "p1", ProposalID: "prop-1", ProposalRevision: 1, PayloadHash: "h"},
"x"); err == nil {
t.Fatal("foreign audience was issued")
}
}
func TestHumanDecisionCardChangeExpiryAndRevoke(t *testing.T) {
st := openDecisionStore(t)
now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC)
host := decisionHost(st, "rdm", "alice", "api", now, nil)
first, err := host.IssueHumanDecision(binding("", "hash-1", 1), "旧卡")
if err != nil {
t.Fatal(err)
}
if _, err := host.IssueHumanDecision(binding("", "hash-2", 2), "新卡"); err != nil {
t.Fatal(err)
}
if _, err := st.RespondHumanDecision("alice", first.CardID, "accept", now); err == nil {
t.Fatal("changed card still accepted the old confirmation")
}
current, err := host.IssueHumanDecision(binding("", "hash-3", 3), "待撤销")
if err != nil {
t.Fatal(err)
}
if err := host.RevokeHumanDecision(current.CardID); err != nil {
t.Fatal(err)
}
if _, err := st.RespondHumanDecision("alice", current.CardID, "accept", now); err == nil {
t.Fatal("revoked card was answered")
}
live, err := host.IssueHumanDecision(binding("", "hash-4", 4), "会过期")
if err != nil {
t.Fatal(err)
}
if _, err := st.RespondHumanDecision("alice", live.CardID, "accept", now.Add(16*time.Minute)); err == nil {
t.Fatal("expired card was answered")
}
}
func TestMissingHumanDecisionStoreFailsClosed(t *testing.T) {
host := decisionHost(nil, "rdm", "alice", "button", time.Now(), nil)
if _, err := host.IssueHumanDecision(binding("", "hash-1", 1), "x"); err == nil {
t.Fatal("missing store issued a card")
}
if _, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1)); err == nil {
t.Fatal("missing store verified a decision")
}
}
+4 -4
View File
@@ -17,11 +17,11 @@ func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) {
if err := seed.EnsureSystemIdentity("test-local-owner", "sqlite"); err != nil {
t.Fatal(err)
}
seed.Close()
bootstrap, err := BootstrapAdmin("", dir, "root", false)
bootstrap, err := seed.BootstrapFirstAdmin("root", "root", "test")
if err != nil || bootstrap == "" {
t.Fatalf("bootstrap: %v", err)
}
seed.Close()
first, _, err := CreateUserAccount("", dir, "alice", "", "", false, true, false)
if err != nil || first == "" {
t.Fatalf("local pending create: %v", err)
@@ -65,14 +65,14 @@ func TestAPIKeyCLIRequiresExistingInitializedAccount(t *testing.T) {
if err := st.EnsureSystemIdentity("test-api-key-admission", "sqlite"); err != nil {
t.Fatal(err)
}
st.Close()
if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("missing account was implicitly created: %v", err)
}
grant, err := BootstrapAdmin("", dir, "alice", true)
grant, err := st.BootstrapFirstAdmin("alice", "alice", "test")
if err != nil {
t.Fatal(err)
}
st.Close()
if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("pending account received a full API key: %v", err)
}
+390
View File
@@ -0,0 +1,390 @@
package app
import (
"context"
"errors"
"sync"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// projectAuditSlot binds the audit.read policy to its registering domain plugin.
// A different plugin cannot borrow that policy through its own Host handle.
type projectAuditSlot struct {
mu sync.RWMutex
holder string
check extension.ProjectAuditAuthorizer
verifier extension.ProjectAuditLinkVerifier
disclose extension.ProjectAuditDisclosureAuthorizer
publisherToken *auditPublisherToken
}
// This identity is available only to the publisher factory returned at
// registration. A caller-provided pluginID cannot manufacture the pointer.
type auditPublisherToken struct{ marker byte }
type projectAuditPublisherFactory struct {
caps hostcaps
pluginID string
token *auditPublisherToken
}
func (f projectAuditPublisherFactory) ForOwner(owner string) (extension.ProjectAuditPublisher, error) {
if owner == "" || f.token == nil {
return nil, extension.ErrProjectAuditDenied
}
return projectAuditPublisher{caps: f.caps, owner: owner, pluginID: f.pluginID, token: f.token}, nil
}
type projectAuditPublisher struct {
caps hostcaps
owner string
pluginID string
token *auditPublisherToken
}
func (p projectAuditPublisher) PublishProjectAuditLink(ctx context.Context, req extension.ProjectAuditLinkRequest) (extension.ProjectAuditLinkReceipt, error) {
h := inprocHost{caps: p.caps, owner: p.owner, pluginID: p.pluginID}
return h.publishProjectAuditLink(ctx, req, p.token)
}
func (s *projectAuditSlot) register(pluginID string, check extension.ProjectAuditAuthorizer) error {
if pluginID == "" || check == nil {
return extension.ErrProjectAuditUnsupported
}
s.mu.Lock()
defer s.mu.Unlock()
if s.check != nil || (s.holder != "" && s.holder != pluginID) {
return errors.New("extension: project audit authorizer already registered")
}
s.holder, s.check = pluginID, check
return nil
}
func (s *projectAuditSlot) registerVerifier(pluginID string, verify extension.ProjectAuditLinkVerifier) (*auditPublisherToken, error) {
if pluginID == "" || verify == nil {
return nil, extension.ErrProjectAuditUnsupported
}
s.mu.Lock()
defer s.mu.Unlock()
if s.verifier != nil || (s.holder != "" && s.holder != pluginID) {
return nil, errors.New("extension: project audit verifier already registered")
}
s.holder, s.verifier = pluginID, verify
s.publisherToken = &auditPublisherToken{marker: 1}
return s.publisherToken, nil
}
func (s *projectAuditSlot) registerDisclosure(pluginID string, disclose extension.ProjectAuditDisclosureAuthorizer) error {
if pluginID == "" || disclose == nil {
return extension.ErrProjectAuditUnsupported
}
s.mu.Lock()
defer s.mu.Unlock()
if s.disclose != nil || (s.holder != "" && s.holder != pluginID) {
return errors.New("extension: project audit disclosure authorizer already registered")
}
s.holder, s.disclose = pluginID, disclose
return nil
}
func (r compiledRegistry) RegisterProjectAuditAuthorizer(extension.ProjectAuditAuthorizer) error {
return errors.New("extension: declarative plugins cannot register project audit authorizers")
}
func (r pluginRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAuditAuthorizer) error {
if r.caps.projectAudit == nil {
return extension.ErrProjectAuditUnsupported
}
return r.caps.projectAudit.register(r.pluginID, check)
}
func (r recordRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAuditAuthorizer) error {
reg, ok := r.Registry.(extension.ProjectAuditRegistry)
if !ok {
return extension.ErrProjectAuditUnsupported
}
return reg.RegisterProjectAuditAuthorizer(check)
}
func (r compiledRegistry) RegisterProjectAuditLinkVerifier(extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) {
return nil, errors.New("extension: declarative plugins cannot register project audit link verifiers")
}
func (r compiledRegistry) RegisterProjectAuditDisclosureAuthorizer(extension.ProjectAuditDisclosureAuthorizer) error {
return errors.New("extension: declarative plugins cannot register project audit disclosure authorizers")
}
func (r pluginRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) {
if r.caps.projectAudit == nil {
return nil, extension.ErrProjectAuditUnsupported
}
token, err := r.caps.projectAudit.registerVerifier(r.pluginID, verify)
if err != nil {
return nil, err
}
return projectAuditPublisherFactory{caps: r.caps, pluginID: r.pluginID, token: token}, nil
}
func (r pluginRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extension.ProjectAuditDisclosureAuthorizer) error {
if r.caps.projectAudit == nil {
return extension.ErrProjectAuditUnsupported
}
return r.caps.projectAudit.registerDisclosure(r.pluginID, disclose)
}
func (r recordRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) {
reg, ok := r.Registry.(extension.ProjectAuditLinkRegistry)
if !ok {
return nil, extension.ErrProjectAuditUnsupported
}
return reg.RegisterProjectAuditLinkVerifier(verify)
}
func (r recordRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extension.ProjectAuditDisclosureAuthorizer) error {
reg, ok := r.Registry.(extension.ProjectAuditLinkRegistry)
if !ok {
return extension.ErrProjectAuditUnsupported
}
return reg.RegisterProjectAuditDisclosureAuthorizer(disclose)
}
var _ extension.ProjectAuditHost = inprocHost{}
var _ extension.ProjectAuditPublisher = projectAuditPublisher{}
var _ extension.ProjectAuditPublisherFactory = projectAuditPublisherFactory{}
func (h inprocHost) publishProjectAuditLink(ctx context.Context, req extension.ProjectAuditLinkRequest, capability *auditPublisherToken) (extension.ProjectAuditLinkReceipt, error) {
if err := ctx.Err(); err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if h.caps.projectAudit == nil || h.caps.st == nil {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditUnsupported
}
slot := h.caps.projectAudit
slot.mu.RLock()
holder, verify, registeredToken := slot.holder, slot.verifier, slot.publisherToken
slot.mu.RUnlock()
if holder != h.pluginID || verify == nil || registeredToken == nil || capability != registeredToken {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditUnsupported
}
if h.owner == "" || (h.actor != "" && h.actor != h.owner) || req.HostAuditOffset < 0 || req.ProjectID == "" || req.SourceKind != "domain-commit" || req.SourceID == "" || req.OperationID == "" || req.ResourceKind == "" || req.ResourceID == "" || req.DisclosureClass != "metadata" || req.IdempotencyKey == "" {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
// An explicit offset is only a locator. A historical collision or a forged
// offset must not turn a private command with the same operation ID into a
// published project receipt.
resolved, err := h.caps.st.CommittedAuditOffset(h.owner, string(req.ProjectID), req.OperationID)
if errors.Is(err, store.ErrNotFound) {
if req.HostAuditOffset == 0 {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditPending
}
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if errors.Is(err, store.ErrConflict) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if req.HostAuditOffset != 0 && req.HostAuditOffset != resolved {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
req.HostAuditOffset = resolved
row, projectID, err := h.caps.st.CommandAuditForLink(h.owner, req.HostAuditOffset)
if errors.Is(err, store.ErrNotFound) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if projectID != string(req.ProjectID) || row.Envelope.Actor != h.owner || row.Envelope.OperationID != req.OperationID || string(row.Envelope.State) != "committed" || row.Envelope.RunID != req.RunID {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if req.RunID != "" {
bound, err := h.caps.st.RunBoundToAudit(h.owner, projectID, row.Envelope.ConversationID, req.RunID)
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if !bound {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
}
previous, lookupErr := h.caps.st.ProjectAuditLinkByKey(h.owner, req.IdempotencyKey)
if lookupErr == nil {
if previous.HostAuditOffset != req.HostAuditOffset || previous.ProjectID != string(req.ProjectID) || previous.SourceKind != req.SourceKind || previous.SourceID != req.SourceID || previous.OperationID != req.OperationID || previous.ResourceKind != req.ResourceKind || previous.ResourceID != req.ResourceID || previous.RunID != req.RunID || previous.DisclosureClass != req.DisclosureClass {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
return extension.ProjectAuditLinkReceipt{LinkID: previous.ID, HostAuditOffset: previous.HostAuditOffset}, nil
}
if !errors.Is(lookupErr, store.ErrNotFound) {
return extension.ProjectAuditLinkReceipt{}, lookupErr
}
proof := extension.ProjectAuditLinkProof{ProjectAuditLinkRequest: req, Owner: h.owner}
ok, err := verify(ctx, proof)
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if !ok {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if err := ctx.Err(); err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
link := store.ProjectAuditLink{HostAuditOffset: req.HostAuditOffset, ProjectID: projectID, Owner: h.owner,
SourceKind: req.SourceKind, SourceID: req.SourceID, OperationID: req.OperationID, ResourceKind: req.ResourceKind,
ResourceID: req.ResourceID, RunID: req.RunID, DisclosureClass: req.DisclosureClass, IdempotencyKey: req.IdempotencyKey}
saved, err := h.caps.st.SaveProjectAuditLink(link)
if errors.Is(err, store.ErrConflict) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
return extension.ProjectAuditLinkReceipt{LinkID: saved.ID, HostAuditOffset: saved.HostAuditOffset}, nil
}
func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAuditQuery) (extension.ProjectAuditPage, error) {
if err := ctx.Err(); err != nil {
return extension.ProjectAuditPage{}, err
}
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
}
slot := h.caps.projectAudit
slot.mu.RLock()
holder, check := slot.holder, slot.check
slot.mu.RUnlock()
if holder != h.pluginID || check == nil {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
ok, err := check(h.actor, core.ID(q.ProjectID))
if err != nil {
return extension.ProjectAuditPage{}, err
}
if !ok {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
if err := ctx.Err(); err != nil {
return extension.ProjectAuditPage{}, err
}
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
}
page := extension.ProjectAuditPage{
Receipts: make([]extension.HostAuditReceipt, 0, len(rows)),
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", Export: "unsupported",
},
}
for _, row := range rows {
nameStatus := "not-recorded"
if row.ActorName != "" {
nameStatus = "recorded"
}
page.Receipts = append(page.Receipts, 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, ConversationID: row.Envelope.ConversationID,
})
}
return page, nil
}
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 || q.Export {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
slot.mu.RLock()
disclose := slot.disclose
slot.mu.RUnlock()
if disclose == nil {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
link, row, err := h.caps.st.ProjectAuditLinkedReceipt(string(q.ProjectID), q.LinkedSourceKind, q.LinkedSourceID, q.LinkedHostOffset)
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)
if err != nil {
return extension.ProjectAuditPage{}, err
}
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"
}
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", 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
}
+184
View File
@@ -0,0 +1,184 @@
package app
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"
)
func TestProjectAuditLinkSharesOnlyVerifiedCommittedMetadata(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice at action"); err != nil {
t.Fatal(err)
}
if err := st.EnsureUser("bob", "Bob"); err != nil {
t.Fatal(err)
}
p, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
otherProject, err := st.CreateProject("alice", "B")
if err != nil {
t.Fatal(err)
}
c, err := st.CreateConversation("alice", string(p.ID), "private", "")
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, ParamsDigest: "secret-digest"}); err != nil {
t.Fatal(err)
}
private, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
offset := private[0].Offset
slot := &projectAuditSlot{}
caps := hostcaps{st: st, projectAudit: slot}
reg := pluginRegistry{pluginID: "rdm", caps: caps}
auditAllowed := true
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
return auditAllowed && actor == "bob" && projectID == p.ID, nil
}); err != nil {
t.Fatal(err)
}
publisherFactory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) {
return proof.Owner == "alice" && proof.ProjectID == p.ID && proof.SourceKind == "domain-commit" && proof.SourceID == "commit-1" && proof.OperationID == "op-1" && proof.ResourceKind == "document" && proof.ResourceID == "doc-1", nil
})
if err != nil {
t.Fatal(err)
}
metadataAllowed := true
if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, resource extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) {
return extension.ProjectAuditDisclosure{Metadata: metadataAllowed && actor == "bob" && resource.ResourceID == "doc-1"}, nil
}); err != nil {
t.Fatal(err)
}
bob := inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}
q := extension.ProjectAuditQuery{ProjectID: p.ID, LinkedSourceKind: "domain-commit", LinkedSourceID: "commit-1", LinkedHostOffset: offset}
if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("unlinked private audit: %v", err)
}
alicePublisher, err := publisherFactory.ForOwner("alice") // capability retained by trusted RDM outbox
if err != nil {
t.Fatal(err)
}
if _, ok := alicePublisher.(extension.Host); ok {
t.Fatal("publisher factory exposed a full owner-scoped Host")
}
linkReq := extension.ProjectAuditLinkRequest{ProjectID: p.ID, SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
// A different compiled plugin can call the public scoped-host factory with
// "rdm" as text. That must not mint RDM's audit publication authority.
wireScopedHostFactory(caps)
t.Cleanup(func() { extension.SetScopedHostFactory(nil) })
forged, err := extension.NewScopedHost("alice", "rdm")
if err != nil {
t.Fatal(err)
}
if publisher, ok := forged.(extension.ProjectAuditPublisher); ok {
if _, err := publisher.PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("self-reported plugin ID minted audit publisher: %v", err)
}
}
if rows, err := bob.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: p.ID}); err != nil || len(rows.Receipts) != 0 {
t.Fatalf("private owner row visible without exact link: %+v %v", rows, err)
}
wrongProject := linkReq
wrongProject.ProjectID = otherProject.ID
wrongProject.HostAuditOffset = offset
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), wrongProject); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("wrong host project: %v", err)
}
uncommitted := linkReq
uncommitted.SourceID, uncommitted.IdempotencyKey = "uncommitted", "outbox-uncommitted"
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), uncommitted); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("uncommitted source: %v", err)
}
if _, err := (pluginRegistry{pluginID: "other", caps: caps}).RegisterProjectAuditLinkVerifier(func(context.Context, extension.ProjectAuditLinkProof) (bool, error) { return true, nil }); err == nil {
t.Fatal("other plugin replaced the registered verifier")
}
pending := linkReq
pending.OperationID, pending.IdempotencyKey = "op-not-yet-recorded", "outbox-pending"
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), pending); !errors.Is(err, extension.ErrProjectAuditPending) {
t.Fatalf("host receipt race must be retryable: %v", err)
}
first, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq)
if err != nil {
t.Fatal(err)
}
if first.HostAuditOffset != offset {
t.Fatalf("trusted resolved offset: got %d want %d", first.HostAuditOffset, offset)
}
retry, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq)
if err != nil || retry.LinkID != first.LinkID {
t.Fatalf("idempotent retry: first=%+v retry=%+v err=%v", first, retry, err)
}
page, err := bob.QueryProjectAudit(context.Background(), q)
if err != nil {
t.Fatal(err)
}
if len(page.Receipts) != 1 || page.Receipts[0].CommandID != "rdm.confirm" || page.Receipts[0].ActorName != "Alice at action" || page.Receipts[0].ConversationID != "" || page.Coverage.CrossMember != "verified-exact-link-only" {
t.Fatalf("linked page: %+v", page)
}
raw, err := json.Marshal(page)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "secret-digest") || strings.Contains(string(raw), string(c.ID)) {
t.Fatalf("private receipt fields leaked: %s", raw)
}
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "private.note", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
t.Fatal(err)
}
duplicates, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
privateOffset := duplicates[0].Offset
mislink := linkReq
mislink.HostAuditOffset, mislink.IdempotencyKey = privateOffset, "outbox-private"
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), mislink); !errors.Is(err, extension.ErrProjectAuditLinkConflict) {
t.Fatalf("ambiguous operation linked a private command: %v", err)
}
privateQuery := q
privateQuery.LinkedHostOffset = privateOffset
if _, err := bob.QueryProjectAudit(context.Background(), privateQuery); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("private command disclosed through ambiguous operation: %v", err)
}
metadataAllowed = false
if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("revoked object metadata: %v", err)
}
metadataAllowed, auditAllowed = true, false
if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("revoked audit.read: %v", err)
}
linkReq.ResourceID = "doc-2"
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditLinkConflict) {
t.Fatalf("same key different link: %v", err)
}
metadataAllowed, auditAllowed = true, true
if err := st.Close(); err != nil {
t.Fatal(err)
}
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq); err == nil || errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("storage outage during publish must remain retryable: %v", err)
}
if _, err := bob.QueryProjectAudit(context.Background(), q); err == nil || errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("storage outage during linked read must be explicit: %v", err)
}
}
+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)
}
}
+238
View File
@@ -0,0 +1,238 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
func TestProjectAuditHostChecksDomainReadAndProjectsStoredReceipts(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
for _, owner := range []string{"alice", "bob"} {
if err := st.EnsureUser(owner, strings.ToUpper(owner)); err != nil {
t.Fatal(err)
}
}
a, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
b, err := st.CreateProject("alice", "B")
if err != nil {
t.Fatal(err)
}
ac, err := st.CreateConversation("alice", string(a.ID), "A", "")
if err != nil {
t.Fatal(err)
}
bc, err := st.CreateConversation("alice", string(b.ID), "B", "")
if err != nil {
t.Fatal(err)
}
shared, err := st.CreateConversationInProject("bob", string(a.ID), "A shared", "")
if err != nil {
t.Fatal(err)
}
for _, item := range []struct{ owner, conversation, command string }{
{"alice", string(ac.ID), "a.command"},
{"alice", string(bc.ID), "b.command"},
{"bob", string(shared.ID), "shared.command"},
} {
if err := st.AppendAudit(item.owner, agentcommand.AuditEnvelope{Time: time.Now(), Actor: item.owner, CommandID: item.command, ConversationID: item.conversation, State: agentcommand.StateCommitted, ParamsDigest: "sensitive-digest"}); err != nil {
t.Fatal(err)
}
}
slot := &projectAuditSlot{}
caps := hostcaps{st: st, projectAudit: slot}
reg := pluginRegistry{pluginID: "rdm", caps: caps}
allowed := true
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
return allowed && actor == "bob" && projectID == a.ID, nil
}); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "rdm"}
page, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(page.Receipts) != 1 || page.Receipts[0].CommandID != "shared.command" {
t.Fatalf("A page: %+v", page)
}
if page.Coverage.CommandAudit != "actor-owned-project-bound-only" || page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.RunRelations != "unsupported" || page.Coverage.ModelReceipts != "unsupported" || page.Coverage.CutoffConsistency != "committed-prefix" {
t.Fatalf("coverage: %+v", page.Coverage)
}
if page.Receipts[0].ActorName != "BOB" {
t.Fatalf("actor names: %+v", page.Receipts)
}
raw, err := json.Marshal(page)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") || strings.Contains(string(raw), "payload") {
t.Fatalf("raw audit payload leaked: %s", raw)
}
if _, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: b.ID}); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("project B: %v", err)
}
if _, err := (inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "other"}).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("other plugin: %v", err)
}
if _, err := (inprocHost{caps: caps, owner: "bob", pluginID: "rdm"}).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID}); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("unbound actor: %v", err)
}
allowed = false
if _, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID, BeforeOffset: page.NextOffset, CutoffOffset: page.CutoffOffset}); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("revoked audit.read: %v", err)
}
}
// A direct explicit ID bypasses the v39 default allocator. This must never
// yield a committed-prefix page that permanently skips the late lower row.
func TestProjectAuditPostgresExplicitLowIDCannotHideAcrossPages(t *testing.T) {
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN unset: isolated PostgreSQL check NOT_RUN")
}
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { admin.Close() })
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
t.Fatal(err)
}
schema := "w9_" + strings.ToLower(string(store.NewID("audit")))
if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`) })
st, err := store.OpenPostgres(store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
conversation, err := st.CreateConversation("alice", string(project.ID), "work", "")
if err != nil {
t.Fatal(err)
}
for _, command := range []string{"first", "second"} {
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: command, ConversationID: string(conversation.ID), State: agentcommand.StateCommitted}); err != nil {
t.Fatal(err)
}
}
insert := `INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id)
VALUES ($1,$2,'','','committed','',$3,'alice',$4,'Alice',$5) RETURNING id`
gap, err := st.DB().Begin()
if err != nil {
t.Fatal(err)
}
defer gap.Rollback()
var gapID int64
if err := gap.QueryRow(insert, time.Now().UTC().Format(time.RFC3339Nano), "rolled-back-gap", string(conversation.ID), `{}`, string(project.ID)).Scan(&gapID); err != nil {
t.Fatal(err)
}
if gapID != 3 {
t.Fatalf("rollback gap ID=%d want=3", gapID)
}
if err := gap.Rollback(); err != nil {
t.Fatal(err)
}
a, err := st.DB().Begin()
if err != nil {
t.Fatal(err)
}
defer a.Rollback()
env := agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "explicit-late", ConversationID: string(conversation.ID), State: agentcommand.StateCommitted}
raw, err := json.Marshal(env)
if err != nil {
t.Fatal(err)
}
_, err = a.Exec(`INSERT INTO command_audit(id,time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id)
VALUES (3,$1,$2,'','','committed','',$3,'alice',$4,'Alice',$5)`, env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(conversation.ID), string(raw), string(project.ID))
if err != nil {
// Rejecting the online explicit ID is also a safe outcome.
t.Logf("online explicit low ID rejected: %v", err)
return
}
slot := &projectAuditSlot{}
caps := hostcaps{st: st, projectAudit: slot}
reg := pluginRegistry{pluginID: "rdm", caps: caps}
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
return actor == "alice" && projectID == project.ID, nil
}); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: caps, owner: "alice", actor: "alice", pluginID: "rdm"}
committed := make(chan error, 1)
started := make(chan struct{})
go func() {
close(started)
committed <- st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "implicit-high", ConversationID: string(conversation.ID), State: agentcommand.StateCommitted})
}()
<-started
var first extension.ProjectAuditPage
highCommittedEarly := false
select {
case err := <-committed:
if err != nil {
t.Fatal(err)
}
highCommittedEarly = true
case <-time.After(500 * time.Millisecond):
}
if highCommittedEarly {
first, err = h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: project.ID, Limit: 2})
if err != nil {
t.Fatal(err)
}
if len(first.Receipts) != 2 || first.Receipts[0].SourceOffset != 4 || first.Receipts[1].SourceOffset != 2 || first.CutoffOffset != 4 || first.NextOffset != 2 || first.Coverage.CutoffConsistency != "committed-prefix" {
t.Fatalf("counterexample first page changed: %+v", first)
}
}
if err := a.Commit(); err != nil {
t.Fatal(err)
}
if !highCommittedEarly {
if err := <-committed; err != nil {
t.Fatal(err)
}
first, err = h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: project.ID, Limit: 2})
if err != nil {
t.Fatal(err)
}
}
second, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: project.ID, BeforeOffset: first.NextOffset, CutoffOffset: first.CutoffOffset, Limit: 2})
if err != nil {
t.Fatal(err)
}
seen := map[int64]bool{}
for _, receipt := range append(first.Receipts, second.Receipts...) {
seen[receipt.SourceOffset] = true
}
if !seen[3] || len(seen) != 4 {
t.Fatalf("committed-prefix omitted explicit low ID 3: early=%v first=%+v second=%+v", highCommittedEarly, first, second)
}
}
+366
View File
@@ -0,0 +1,366 @@
package app
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"sync"
"time"
"agent-system-backend/internal/projectcontext"
"agent-system-backend/internal/runtime"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
type projectContextSlot struct {
mu sync.RWMutex
holder string
provider extension.ProjectContextProvider
}
func (s *projectContextSlot) active() bool {
if s == nil {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.provider != nil
}
func requireProjectContextProvider(required bool, slot *projectContextSlot) error {
if required && !slot.active() {
return errors.New("extension: required project context provider not assembled")
}
return nil
}
func (r compiledRegistry) RegisterProjectContextProvider(extension.ProjectContextProvider) error {
return errors.New("extension: declarative plugins cannot register project context providers")
}
func (r pluginRegistry) RegisterProjectContextProvider(p extension.ProjectContextProvider) error {
if r.caps.contexts == nil || r.pluginID == "" || p == nil {
return errors.New("extension: project context provider unavailable")
}
slot := r.caps.contexts
slot.mu.Lock()
defer slot.mu.Unlock()
if slot.provider != nil {
return errors.New("extension: project context provider already registered")
}
slot.holder, slot.provider = r.pluginID, p
return nil
}
func (r recordRegistry) RegisterProjectContextProvider(p extension.ProjectContextProvider) error {
reg, ok := r.Registry.(extension.ProjectContextRegistry)
if !ok {
return errors.New("extension: project context registration unavailable")
}
return reg.RegisterProjectContextProvider(p)
}
func (s *projectContextSlot) resolve(ctx context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
if s == nil {
return extension.ProjectContextBundle{}, errors.New("extension: project context provider missing")
}
s.mu.RLock()
p, holder := s.provider, s.holder
s.mu.RUnlock()
if p == nil {
return extension.ProjectContextBundle{}, errors.New("extension: project context provider missing")
}
if req.Actor == "" || req.ProjectID == "" {
return extension.ProjectContextBundle{}, errors.New("extension: trusted project scope missing")
}
req.PluginID = holder
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
b, err := p.Resolve(bounded, req)
if err != nil {
return extension.ProjectContextBundle{}, err
}
if err := bounded.Err(); err != nil {
return extension.ProjectContextBundle{}, err
}
if b.ProjectID != req.ProjectID || b.Status != extension.ProjectContextReady {
return extension.ProjectContextBundle{}, fmt.Errorf("extension: project context unavailable: %s", b.Status)
}
return b, nil
}
func (s *projectContextSlot) readEvidence(ctx context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) {
if s == nil || req.Scope.Actor == "" || req.Scope.ProjectID == "" || req.Reference.ID == "" {
return extension.ProjectEvidence{}, errors.New("extension: trusted evidence scope missing")
}
s.mu.RLock()
p, holder := s.provider, s.holder
s.mu.RUnlock()
if p == nil {
return extension.ProjectEvidence{}, errors.New("extension: project context provider missing")
}
req.Scope.PluginID = holder
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
evidence, err := p.ReadEvidence(bounded, req)
if err != nil {
return extension.ProjectEvidence{}, err
}
if err := bounded.Err(); err != nil {
return extension.ProjectEvidence{}, err
}
if evidence.Reference != req.Reference || len([]rune(evidence.Text)) > req.Scope.BudgetChars {
return extension.ProjectEvidence{}, errors.New("extension: project evidence response mismatches reference or budget")
}
return evidence, nil
}
func (s *projectContextSlot) recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
// 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
s.mu.RUnlock()
rechecker, ok := p.(extension.ProjectContextSourceRechecker)
if !ok {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project source rechecker unavailable")
}
req.Scope.PluginID = holder
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
result, err := rechecker.Recheck(bounded, req)
if err != nil {
return extension.ProjectContextSourceRecheckResult{}, err
}
if err := bounded.Err(); err != nil {
return extension.ProjectContextSourceRecheckResult{}, err
}
if result.Bundle.ProjectID != req.Scope.ProjectID || result.Bundle.Status != extension.ProjectContextReady {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project sources no longer authorized")
}
return result, nil
}
const workContextBudgetChars = 12000
// TaskCreateForWork fixes the project binding while the authenticated command
// dispatch still owns its conversation. Legacy TaskCreate has no such binding.
func (h inprocHost) TaskCreateForWork(kind, title string) (string, error) {
if h.actor == "" || h.actor != h.owner || h.pluginID == "" || h.conversationID == "" || h.caps.st == nil || !h.caps.contexts.active() {
return "", errors.New("extension: trusted project work dispatch required")
}
conv, err := h.caps.st.GetConversation(h.owner, string(h.conversationID))
if err != nil {
return "", err
}
if conv.ProjectID == "" || (h.hostProjectID != "" && h.hostProjectID != conv.ProjectID) || h.caps.projects == nil || h.caps.projects.work() == nil {
return "", errors.New("extension: trusted project work scope missing")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
allowed, err := h.caps.projects.work()(ctx, h.actor, conv.ProjectID)
if err != nil || !allowed {
return "", errors.New("extension: project work denied")
}
task, err := h.caps.st.CreateWorkTask(h.owner, h.pluginID, string(conv.ProjectID), string(conv.ID), kind, title)
return task.ID, err
}
// completeForWork is the dispatch-scoped completion path. The conversation is
// reloaded so a plugin's goal/target and a stale dispatch project cannot select
// another project's context. Work authorization is checked on every call.
func (h inprocHost) CompleteForWork(ctx context.Context, req extension.WorkCompletionRequest) (extension.WorkCompletionResult, error) {
if strings.TrimSpace(req.Goal) == "" {
return extension.WorkCompletionResult{}, errors.New("extension: work goal required")
}
if h.caps.st == nil {
return extension.WorkCompletionResult{}, errors.New("extension: trusted work store missing")
}
actor, convID, targetID := h.actor, h.conversationID, string(h.conversationID)
var boundProjectID core.ID
if actor == "" {
if req.TaskID == "" || convID != "" {
return extension.WorkCompletionResult{}, errors.New("extension: persisted work task required for scoped host")
}
binding, err := h.caps.st.WorkTaskScope(h.owner, h.pluginID, req.TaskID)
if err != nil {
return extension.WorkCompletionResult{}, err
}
if binding.Status == "done" || binding.Status == "completed" || binding.Status == "failed" || binding.Status == "cancelled" {
return extension.WorkCompletionResult{}, errors.New("extension: work task already terminal")
}
actor, convID, boundProjectID, targetID = binding.Owner, core.ID(binding.ConversationID), core.ID(binding.ProjectID), binding.TaskID
} else if actor != h.owner || convID == "" || req.TaskID != "" {
return extension.WorkCompletionResult{}, errors.New("extension: trusted work dispatch required")
}
conv, err := h.caps.st.GetConversation(h.owner, string(convID))
if err != nil {
return extension.WorkCompletionResult{}, err
}
if conv.ProjectID == "" || (h.hostProjectID != "" && h.hostProjectID != conv.ProjectID) || (boundProjectID != "" && boundProjectID != conv.ProjectID) {
return extension.WorkCompletionResult{}, errors.New("extension: trusted project scope mismatch")
}
if h.caps.projects == nil || h.caps.projects.work() == nil {
return extension.WorkCompletionResult{}, errors.New("extension: project work authorization missing")
}
allowed, err := h.caps.projects.work()(ctx, actor, conv.ProjectID)
if err != nil {
return extension.WorkCompletionResult{}, err
}
if !allowed {
return extension.WorkCompletionResult{}, errors.New("extension: project work denied")
}
requested := extension.ProjectContextRequest{
Actor: actor, ProjectID: conv.ProjectID, ConversationID: conv.ID, TaskID: core.ID(req.TaskID),
Goal: req.Goal, TargetID: req.TargetID, BudgetChars: workContextBudgetChars,
}
bundle, err := h.caps.contexts.resolve(ctx, requested)
if err != nil {
return extension.WorkCompletionResult{}, err
}
evidenceText, usedEvidence, err := h.readWorkEvidence(ctx, requested, bundle, req.EvidenceRefs)
if err != nil {
return extension.WorkCompletionResult{}, err
}
prompt, manifest, err := assembleWorkCompletionWithEvidence(req.Goal, bundle, workContextBudgetChars, evidenceText, usedEvidence)
if err != nil {
return extension.WorkCompletionResult{}, err
}
manifest.ProjectID = conv.ProjectID
manifest.TaskID = req.TaskID
route := runtime.ResolveChatModel(core.AgentProfile{}, h.caps.set.Effective(h.owner, ""))
manifest.Model = route.Model
if route.Model == "" || route.Base == "" {
return extension.WorkCompletionResult{}, errors.New("llm route not configured")
}
// Resolve can be slow. A member may be revoked while the provider is
// assembling summaries, so check the live work policy again at send time.
allowed, err = h.caps.projects.work()(ctx, actor, conv.ProjectID)
if err != nil {
return extension.WorkCompletionResult{}, err
}
if !allowed {
return extension.WorkCompletionResult{}, errors.New("extension: project work denied")
}
current, err := h.caps.contexts.resolve(ctx, requested)
if err != nil {
return extension.WorkCompletionResult{}, err
}
if err := projectcontext.ValidateCurrent(manifest, current); err != nil {
return extension.WorkCompletionResult{}, err
}
for _, used := range manifest.ReadEvidence {
found := false
for _, ref := range current.Evidence {
if ref.ID != used.ReferenceID || ref.SourceID != used.SourceID || ref.Revision != used.Revision || ref.Hash != used.SourceHash {
continue
}
scope := requested
scope.BudgetChars = 8000
now, err := h.caps.contexts.readEvidence(ctx, extension.ProjectEvidenceRequest{Scope: scope, Reference: ref})
if err != nil {
return extension.WorkCompletionResult{}, err
}
if fmt.Sprintf("%x", sha256.Sum256([]byte(now.Text))) == used.InputSHA256 {
found = true
}
break
}
if !found {
return extension.WorkCompletionResult{}, errors.New("extension: requested work evidence changed before model send")
}
}
attemptID, err := projectcontext.NewAttemptID()
if err != nil {
return extension.WorkCompletionResult{}, err
}
manifest.AttemptID, manifest.Delivery = attemptID, "attempted"
if err := h.caps.st.AppendContextManifest(h.owner, targetID, manifest); err != nil {
return extension.WorkCompletionResult{}, err
}
text, err := completeChat(ctx, route.Base, route.Key, route.Model, prompt)
if err != nil {
manifest.Delivery = "unknown"
_ = h.caps.st.AppendContextManifest(h.owner, targetID, manifest)
return extension.WorkCompletionResult{}, err
}
manifest.Delivery = "responded"
if err := h.caps.st.AppendContextManifest(h.owner, targetID, manifest); err != nil {
return extension.WorkCompletionResult{}, err
}
return extension.WorkCompletionResult{Text: text, Manifest: manifest}, nil
}
func assembleWorkCompletion(goal string, bundle extension.ProjectContextBundle, budget int) (string, extension.ProjectContextManifest, error) {
return assembleWorkCompletionWithEvidence(goal, bundle, budget, "", nil)
}
func assembleWorkCompletionWithEvidence(goal string, bundle extension.ProjectContextBundle, budget int, evidenceText string, used []extension.ProjectContextManifestEntry) (string, extension.ProjectContextManifest, error) {
prefix := "Work goal:\n" + goal + "\n\n"
ref, m, err := projectcontext.Assemble(bundle, budget-len([]rune(prefix))-len([]rune(evidenceText)))
if err != nil {
return "", m, err
}
prompt := prefix + ref + evidenceText
m.BudgetChars = budget
m.ReadEvidence = used
m.InputSHA256 = fmt.Sprintf("%x", sha256.Sum256([]byte(prompt)))
return prompt, m, nil
}
func (h inprocHost) readWorkEvidence(ctx context.Context, scope extension.ProjectContextRequest, bundle extension.ProjectContextBundle, ids []string) (string, []extension.ProjectContextManifestEntry, error) {
if len(ids) > 4 {
return "", nil, errors.New("extension: too many work evidence references")
}
var b strings.Builder
var used []extension.ProjectContextManifestEntry
seen := make(map[string]bool, len(ids))
for _, id := range ids {
if id == "" || seen[id] {
return "", nil, errors.New("extension: invalid or duplicate work evidence reference")
}
seen[id] = true
var selected extension.ProjectEvidenceRef
for _, ref := range bundle.Evidence {
if ref.ID == id {
selected = ref
break
}
}
if selected.ID == "" {
return "", nil, errors.New("extension: work evidence reference not in current bundle")
}
readScope := scope
readScope.BudgetChars = 8000
evidence, err := h.caps.contexts.readEvidence(ctx, extension.ProjectEvidenceRequest{Scope: readScope, Reference: selected})
if err != nil {
return "", nil, err
}
b.WriteString("\n[Evidence ")
b.WriteString(selected.ID)
b.WriteString(" source ")
b.WriteString(selected.SourceID)
b.WriteString(" revision ")
b.WriteString(selected.Revision)
b.WriteString("]\n")
b.WriteString(evidence.Text)
used = append(used, extension.ProjectContextManifestEntry{ReferenceID: selected.ID, SourceID: selected.SourceID, Revision: selected.Revision, SourceHash: selected.Hash,
InputSHA256: fmt.Sprintf("%x", sha256.Sum256([]byte(evidence.Text)))})
}
return b.String(), used, nil
}
+420
View File
@@ -0,0 +1,420 @@
package app
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
type testProjectContextProvider struct {
resolve func(extension.ProjectContextRequest) (extension.ProjectContextBundle, error)
read func(extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error)
}
type testSourceRecheckProvider struct {
testProjectContextProvider
called *bool
}
func (p testSourceRecheckProvider) Recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
if _, ok := ctx.Deadline(); !ok || req.Scope.PluginID != "rdm" || req.Scope.Actor != "alice" || req.Scope.ProjectID != "p1" || req.Scope.Goal != "" {
return extension.ProjectContextSourceRecheckResult{}, fmt.Errorf("untrusted or unbounded recheck scope")
}
*p.called = true
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.Scope.ProjectID}}, nil
}
func TestProjectSourceRecheckUsesSameRegisteredProviderAndDeadline(t *testing.T) {
called := false
slot := &projectContextSlot{}
provider := testSourceRecheckProvider{called: &called}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: slot}}).RegisterProjectContextProvider(provider); err != nil {
t.Fatal(err)
}
if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}}); err != nil || !called {
t.Fatalf("registered source rechecker unavailable: called=%v err=%v", called, err)
}
if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1", Goal: "OLD_RESTRICTED_BODY"}}); err == nil {
t.Fatal("raw Goal entered source recheck provider")
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := slot.recheck(cancelled, extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}}); err == nil {
t.Fatal("cancelled source recheck returned authorized context")
}
legacy := &projectContextSlot{}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: legacy}}).RegisterProjectContextProvider(testProjectContextProvider{}); err != nil {
t.Fatal(err)
}
if _, err := legacy.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}}); err == nil {
t.Fatal("provider without optional rechecker silently accepted old sources")
}
}
type deadlineContextProvider struct{ sawDeadline *bool }
func (p deadlineContextProvider) Resolve(ctx context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
_, *p.sawDeadline = ctx.Deadline()
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.ProjectID}, nil
}
func (p deadlineContextProvider) ReadEvidence(ctx context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) {
_, *p.sawDeadline = ctx.Deadline()
return extension.ProjectEvidence{Reference: req.Reference, Text: "detail"}, nil
}
func TestProjectContextProviderCallsHaveBoundedDeadline(t *testing.T) {
seen := false
slot := &projectContextSlot{}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: slot}}).RegisterProjectContextProvider(deadlineContextProvider{sawDeadline: &seen}); err != nil {
t.Fatal(err)
}
if _, err := slot.resolve(context.Background(), extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}); err != nil || !seen {
t.Fatalf("Resolve missing bounded context: seen=%v err=%v", seen, err)
}
seen = false
if _, err := slot.readEvidence(context.Background(), extension.ProjectEvidenceRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1", BudgetChars: 100}, Reference: extension.ProjectEvidenceRef{ID: "r1"}}); err != nil || !seen {
t.Fatalf("ReadEvidence missing bounded context: seen=%v err=%v", seen, err)
}
}
func (p testProjectContextProvider) Resolve(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
return p.resolve(req)
}
func (p testProjectContextProvider) ReadEvidence(_ context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) {
if p.read != nil {
return p.read(req)
}
return extension.ProjectEvidence{}, nil
}
func TestProjectContextRegistryOptionalAndSingleOwner(t *testing.T) {
slot := &projectContextSlot{}
reg := recordRegistry{Registry: pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: slot}}}
optional, ok := any(reg).(extension.ProjectContextRegistry)
if !ok {
t.Fatal("record registry hides optional project context registration")
}
provider := testProjectContextProvider{resolve: func(extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
return extension.ProjectContextBundle{Status: extension.ProjectContextReady}, nil
}}
if err := optional.RegisterProjectContextProvider(provider); err != nil {
t.Fatal(err)
}
if err := optional.RegisterProjectContextProvider(provider); err == nil {
t.Fatal("duplicate provider replaced the first provider")
}
if err := (compiledRegistry{}).RegisterProjectContextProvider(provider); err == nil {
t.Fatal("declarative provider accepted")
}
}
func TestRequiredProjectContextFailsWhenProviderNotAssembled(t *testing.T) {
slot := &projectContextSlot{}
if err := requireProjectContextProvider(true, slot); err == nil {
t.Fatal("required project context silently degraded")
}
if err := requireProjectContextProvider(false, slot); err != nil {
t.Fatalf("optional legacy path blocked: %v", err)
}
provider := testProjectContextProvider{resolve: func(extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
return extension.ProjectContextBundle{}, nil
}}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: slot}}).RegisterProjectContextProvider(provider); err != nil {
t.Fatal(err)
}
if err := requireProjectContextProvider(true, slot); err != nil {
t.Fatalf("registered provider rejected: %v", err)
}
}
func TestCompleteForWorkUsesStoredScopeAndActualInputManifest(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.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", "Project")
if err != nil {
t.Fatal(err)
}
other, err := st.CreateProject("alice", "Other")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("alice", p.ID, "Work", "")
if err != nil {
t.Fatal(err)
}
var sent string
modelCalls := 0
model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
modelCalls++
var payload struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Error(err)
}
for _, m := range payload.Messages {
sent += m.Content
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
}))
defer model.Close()
set := settings.NewService(st)
set.SetSystemOverride(map[string]any{"models": map[string]any{"chat": map[string]any{"baseUrl": model.URL, "model": "test-model"}}})
projects := &projectLifecycleSlot{}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: projects}}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
contexts := &projectContextSlot{}
called := 0
oversized := false
revokeDuringResolve := false
revokeDocDuringResolve := false
authEpoch := "duty-1"
provider := testProjectContextProvider{resolve: func(req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
called++
if req.Actor != "alice" || req.ProjectID != core.ID(p.ID) || req.ConversationID != core.ID(conv.ID) || req.PluginID != "rdm" {
t.Errorf("untrusted scope: %+v", req)
}
item := extension.ProjectContextItem{SourceID: "decision-1", Revision: "r3", Hash: "source-hash", Text: "Authorized summary", Required: true}
if oversized {
item.Text = strings.Repeat("x", workContextBudgetChars+1)
}
if revokeDuringResolve {
projects.mu.Lock()
projects.provider.Work = func(context.Context, string, core.ID) (bool, error) { return false, nil }
projects.mu.Unlock()
}
epoch := authEpoch
if revokeDocDuringResolve {
authEpoch = "duty-2"
revokeDocDuringResolve = false
}
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: core.ID(p.ID), Version: "v7", AuthorizationEpoch: epoch, Items: []extension.ProjectContextItem{item}}, nil
}}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: contexts}}).RegisterProjectContextProvider(provider); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st, set: set, projects: projects, contexts: contexts}, pluginID: "rdm", owner: "alice", actor: "alice", conversationID: conv.ID, hostProjectID: p.ID}
work := any(h).(extension.WorkCompletionHost)
result, err := work.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Draft report"})
if err != nil {
t.Fatal(err)
}
if result.Text != "done" || !strings.Contains(sent, "Authorized summary") || !strings.Contains(sent, "Draft report") || result.Manifest.InputSHA256 == "" || len(result.Manifest.Included) != 1 {
t.Fatalf("input or manifest mismatch: result=%+v sent=%q", result, sent)
}
if want := fmt.Sprintf("%x", sha256.Sum256([]byte(sent))); result.Manifest.InputSHA256 != want {
t.Fatalf("manifest input hash %q differs from actual request content hash %q", result.Manifest.InputSHA256, want)
}
if got, want := result.Manifest.Included[0].InputSHA256, fmt.Sprintf("%x", sha256.Sum256([]byte("Authorized summary"))); got != want || result.Manifest.Included[0].SourceHash != "source-hash" {
t.Fatalf("manifest does not distinguish source hash and actual text hash: %+v", result.Manifest.Included[0])
}
if called != 2 {
t.Fatalf("provider called %d times, want initial and pre-send reads", called)
}
stored, err := st.ListContextManifests("alice", string(conv.ID))
if err != nil || len(stored) != 2 || stored[0].Delivery != "attempted" || stored[1].Delivery != "responded" || stored[0].AttemptID != stored[1].AttemptID || stored[1].InputSHA256 != result.Manifest.InputSHA256 {
t.Fatalf("work completion manifest not persisted: got=%+v err=%v", stored, err)
}
h.hostProjectID = other.ID
if _, err := h.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Draft report"}); err == nil || called != 2 {
t.Fatal("forged project scope reached provider")
}
h.hostProjectID = p.ID
h.conversationID = ""
if _, err := h.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Draft report"}); err == nil || called != 2 {
t.Fatal("scoped host without dispatch scope reached provider")
}
h.conversationID = conv.ID
projects.mu.Lock()
projects.provider.Work = func(context.Context, string, core.ID) (bool, error) { return false, nil }
projects.mu.Unlock()
if _, err := h.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Draft report"}); err == nil || called != 2 {
t.Fatal("revoked project work reached provider")
}
projects.mu.Lock()
projects.provider.Work = func(context.Context, string, core.ID) (bool, error) { return true, nil }
projects.mu.Unlock()
oversized = true
if _, err := h.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Draft report"}); err == nil || modelCalls != 1 {
t.Fatal("required context was dropped and model still called")
}
oversized = false
revokeDuringResolve = true
if _, err := h.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Draft report"}); err == nil || modelCalls != 1 {
t.Fatal("revocation during context resolution reached model")
}
projects.mu.Lock()
projects.provider.Work = func(context.Context, string, core.ID) (bool, error) { return true, nil }
projects.mu.Unlock()
revokeDuringResolve = false
revokeDocDuringResolve = true
if _, err := h.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Draft report"}); err == nil || modelCalls != 1 {
t.Fatal("document duty revoked while project membership remains reached model")
}
taskHost := any(h).(extension.WorkTaskHost)
taskID, err := taskHost.TaskCreateForWork("compose", "Draft report")
if err != nil {
t.Fatal(err)
}
scoped := inprocHost{caps: h.caps, pluginID: "rdm", owner: "alice"}
if _, err := scoped.TaskCreateForWork("compose", "Forged"); err == nil {
t.Fatal("scoped host created a work task without dispatch scope")
}
if _, err := scoped.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Resume report", TaskID: taskID}); err != nil || modelCalls != 2 {
t.Fatalf("persisted plugin task did not restore trusted work scope: err=%v calls=%d", err, modelCalls)
}
otherPlugin := inprocHost{caps: h.caps, pluginID: "other", owner: "alice"}
if _, err := otherPlugin.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Steal task", TaskID: taskID}); err == nil || modelCalls != 2 {
t.Fatal("other plugin reused task binding")
}
otherOwner := inprocHost{caps: h.caps, pluginID: "rdm", owner: "bob"}
if _, err := otherOwner.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Steal task", TaskID: taskID}); err == nil || modelCalls != 2 {
t.Fatal("other owner reused task binding")
}
legacyTaskID, err := h.TaskCreate("legacy", "Unbound", string(conv.ID))
if err != nil {
t.Fatal(err)
}
if _, err := scoped.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Old task", TaskID: legacyTaskID}); err == nil || modelCalls != 2 {
t.Fatal("old unbound task was treated as trusted work scope")
}
if _, err := st.DB().Exec(`UPDATE conversations SET project_id=? WHERE id=?`, other.ID, conv.ID); err != nil {
t.Fatal(err)
}
if _, err := scoped.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Moved project", TaskID: taskID}); err == nil || modelCalls != 2 {
t.Fatal("task silently followed a conversation to another project")
}
}
func TestWorkContextBudgetKeepsRequiredEvidenceFirst(t *testing.T) {
bundle := extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "project-1", Items: []extension.ProjectContextItem{
{SourceID: "optional", Revision: "r1", Hash: "h1", Text: "12345678"},
{SourceID: "required", Revision: "r1", Hash: "h2", Text: "must keep", Required: true},
}}
prompt, manifest, err := assembleWorkCompletion("goal", bundle, 140)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(prompt, "must keep") || strings.Contains(prompt, "12345678") {
t.Fatalf("required evidence lost to optional evidence: %q", prompt)
}
if len([]rune(prompt)) > 140 {
t.Fatalf("final prompt exceeded the budget: %d", len([]rune(prompt)))
}
if len(manifest.Included) != 1 || manifest.Included[0].SourceID != "required" || len(manifest.Dropped) != 1 || manifest.Dropped[0].SourceID != "optional" {
t.Fatalf("manifest does not match selected input: %+v", manifest)
}
}
func TestCompleteForWorkReadsOnlyCurrentBundleEvidence(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "evidence.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
p, err := st.CreateProject("alice", "P")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("alice", p.ID, "C", "")
if err != nil {
t.Fatal(err)
}
var sent []string
model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
var input string
for _, m := range body.Messages {
input += m.Content
}
sent = append(sent, input)
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
}))
defer model.Close()
set := settings.NewService(st)
set.SetSystemOverride(map[string]any{"models": map[string]any{"chat": map[string]any{"baseUrl": model.URL, "model": "test-model"}}})
projects := &projectLifecycleSlot{}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: projects}}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
contexts := &projectContextSlot{}
ref := extension.ProjectEvidenceRef{ID: "ref-1", SourceID: "doc-1", Revision: "r2", Hash: "source-hash"}
readCalls := 0
readText, authEpoch := "current restricted detail", "duty-1"
provider := testProjectContextProvider{
resolve: func(req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.ProjectID, AuthorizationEpoch: authEpoch, PublicationEpoch: "pub-1", Evidence: []extension.ProjectEvidenceRef{ref}}, nil
},
read: func(req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) {
readCalls++
if req.Reference != ref || req.Scope.ProjectID != core.ID(p.ID) {
t.Errorf("untrusted evidence scope: %+v", req)
}
return extension.ProjectEvidence{Reference: req.Reference, Text: readText}, nil
},
}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: contexts}}).RegisterProjectContextProvider(provider); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st, set: set, projects: projects, contexts: contexts}, pluginID: "rdm", owner: "alice", actor: "alice", conversationID: conv.ID, hostProjectID: p.ID}
result, err := h.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Write", EvidenceRefs: []string{"ref-1"}})
if err != nil || len(sent) != 1 || !strings.Contains(sent[0], "current restricted detail") || len(result.Manifest.ReadEvidence) != 1 || readCalls != 2 {
t.Fatalf("requested evidence did not reach model under reauthorization: err=%v sent=%q manifest=%+v reads=%d", err, sent, result.Manifest, readCalls)
}
if _, err := h.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Write", EvidenceRefs: []string{"arbitrary-path"}}); err == nil || readCalls != 2 {
t.Fatal("arbitrary evidence reference reached provider")
}
readText, authEpoch = "new public detail", "duty-2"
if _, err := h.CompleteForWork(context.Background(), extension.WorkCompletionRequest{Goal: "Write", EvidenceRefs: []string{"ref-1"}}); err != nil || len(sent) != 2 || strings.Contains(sent[1], "current restricted detail") || !strings.Contains(sent[1], "new public detail") {
t.Fatalf("same Host reused old evidence after duty change: err=%v sent=%q", err, sent)
}
}
func TestPluginAuditCannotForgeOrCorruptContextManifest(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "manifest.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
good := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "attempt-1", Delivery: "attempted", InputSHA256: "sha-good"}
if err := st.AppendContextManifest("alice", "run-1", good); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "malicious", owner: "alice"}
if err := h.Audit("model.context_manifest", "run-1", `{"Delivery":"responded","InputSHA256":"forged"}`); err == nil {
t.Fatal("plugin forged a trusted model manifest action")
}
if err := st.AppendAuditEvent(store.AuditEvent{Owner: "alice", Action: "model.context_manifest", Target: "run-1", Evidence: "not-json"}); err != nil {
t.Fatal(err)
}
got, err := st.ListContextManifests("alice", "run-1")
if err != nil || len(got) != 1 || got[0].InputSHA256 != "sha-good" {
t.Fatalf("untrusted audit row corrupted manifest reader: got=%+v err=%v", got, err)
}
}
+136
View File
@@ -0,0 +1,136 @@
package app
import (
"context"
"errors"
"testing"
"time"
"agent-system-backend/pkg/extension"
)
type scriptedRecheckProvider struct {
testProjectContextProvider
recheck func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error)
}
func (p scriptedRecheckProvider) Recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return p.recheck(ctx, req)
}
func registeredRecheckSlot(t *testing.T, recheck func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error)) *projectContextSlot {
t.Helper()
slot := &projectContextSlot{}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: slot}}).RegisterProjectContextProvider(scriptedRecheckProvider{recheck: recheck}); err != nil {
t.Fatal(err)
}
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) {
deadline, ok := ctx.Deadline()
if !ok {
return extension.ProjectContextSourceRecheckResult{}, errors.New("unbounded recheck")
}
remaining = time.Until(deadline)
select {
case <-ctx.Done():
case <-time.After(10 * time.Second):
}
// A late success must not authorize old machine context.
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.Scope.ProjectID}}, nil
})
started := time.Now()
_, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}})
if err == nil {
t.Fatal("source recheck past its deadline returned authorized context")
}
if remaining <= 0 || remaining > 3*time.Second {
t.Fatalf("source recheck deadline is not bounded by 3s: %v", remaining)
}
if elapsed := time.Since(started); elapsed > 5*time.Second {
t.Fatalf("source recheck outlived its 3s ceiling: %v", elapsed)
}
}
func TestProjectSourceRecheckRejectsProviderRefusalAndForeignBundles(t *testing.T) {
scope := extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}
answers := map[string]func(extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error){
"refused": func(extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return extension.ProjectContextSourceRecheckResult{}, errors.New("provider refused")
},
"denied": func(req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextDenied, ProjectID: req.Scope.ProjectID}}, nil
},
"incomplete": func(req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextIncomplete, ProjectID: req.Scope.ProjectID}}, nil
},
"other project": func(extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "p2"}}, nil
},
}
for name, answer := range answers {
slot := registeredRecheckSlot(t, func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return answer(req)
})
if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: scope}); err == nil {
t.Fatalf("%s source recheck accepted", name)
}
}
var sawPlugin string
slot := registeredRecheckSlot(t, func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
sawPlugin = req.Scope.PluginID
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.Scope.ProjectID}}, nil
})
forged := scope
forged.PluginID = "forged-plugin"
if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: forged}); err != nil || sawPlugin != "rdm" {
t.Fatalf("recheck scope plugin was not the registered holder: plugin=%q err=%v", sawPlugin, err)
}
for _, missing := range []extension.ProjectContextRequest{{ProjectID: "p1"}, {Actor: "alice"}} {
if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: missing}); err == nil {
t.Fatalf("recheck without trusted actor/project accepted: %+v", missing)
}
}
}
+381
View File
@@ -0,0 +1,381 @@
package app
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"sync"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
type projectWorkObserverSlot struct {
mu sync.RWMutex
provider *extension.ProjectWorkObserver
}
func (s *projectWorkObserverSlot) get() *extension.ProjectWorkObserver {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.provider == nil {
return nil
}
copy := *s.provider
return &copy
}
func (s *projectWorkObserverSlot) active() bool { return s.get() != nil }
func wireProjectWorkGate(mgr *runmgr.Manager, st *store.Store, caps hostcaps, slot *projectWorkObserverSlot,
work func(context.Context, string, core.ID) (bool, error),
) {
provider := slot.get()
if provider == nil || work == nil {
return
}
verify := func(ctx context.Context, row store.ProjectWorkObservationRow) error {
switch row.SourceKind {
case "retrieval-hit":
return verifyProjectKnowledgeObservation(ctx, caps, row)
case "tool-result", "visible-analysis":
return verifyProjectTransientObservation(ctx, st, row)
default:
return fmt.Errorf("project work source %s: unsupported run source kind %q", row.EventID, row.SourceKind)
}
}
mgr.SetProjectWorkGate(
func(ctx context.Context, runID, action string) (extension.ProjectWorkDecision, error) {
return preflightProjectRun(ctx, st, slot, work, verify, runID, action)
},
provider.Respond,
)
}
func (r compiledRegistry) RegisterProjectWorkObserver(extension.ProjectWorkObserver) error {
return errors.New("extension: declarative plugins cannot register project work observers")
}
func (r pluginRegistry) RegisterProjectWorkObserver(p extension.ProjectWorkObserver) error {
if r.pluginID != "rdm" || r.caps.workObserver == nil {
return errors.New("extension: project work observer requires trusted RDM registration")
}
if p.Observe == nil || p.Preflight == nil || p.Respond == nil {
return errors.New("extension: project work observer requires all callbacks")
}
s := r.caps.workObserver
s.mu.Lock()
defer s.mu.Unlock()
if s.provider != nil {
return errors.New("extension: project work observer already registered")
}
copy := p
s.provider = &copy
return nil
}
func (r recordRegistry) RegisterProjectWorkObserver(p extension.ProjectWorkObserver) error {
reg, ok := r.Registry.(extension.ProjectWorkObservationRegistry)
if !ok {
return errors.New("extension: project work observation registration unavailable")
}
return reg.RegisterProjectWorkObserver(p)
}
// deliverProjectWorkObservations replays only committed source parts, rechecking
// their owner and current project work authority before disclosing text to RDM.
// An Observe ACK is an intake receipt, not a preflight or publication decision.
// Failures leave the row pending for a later idempotent retry.
func deliverProjectWorkObservations(ctx context.Context, st *store.Store, slot *projectWorkObserverSlot,
work func(context.Context, string, core.ID) (bool, error), limit int, verify ...func(context.Context, store.ProjectWorkObservationRow) error,
) (int, error) {
provider := slot.get()
if provider == nil || work == nil {
return 0, errors.New("extension: trusted project work observer unavailable")
}
rows, err := st.PendingProjectWorkObservations(limit)
if err != nil {
return 0, err
}
return deliverProjectWorkRows(ctx, st, provider, work, rows, verify...)
}
func deliverProjectWorkRows(ctx context.Context, st *store.Store, provider *extension.ProjectWorkObserver,
work func(context.Context, string, core.ID) (bool, error), rows []store.ProjectWorkObservationRow,
verify ...func(context.Context, store.ProjectWorkObservationRow) error,
) (int, error) {
completed := 0
for _, row := range rows {
if row.State != "pending" {
continue
}
if row.SourceKind != "user-message" && row.SourceKind != "assistant-message" {
if len(verify) != 1 || verify[0] == nil {
return completed, fmt.Errorf("project work source %s: current resource status verifier unavailable", row.EventID)
}
if err := verify[0](ctx, row); err != nil {
return completed, err
}
}
if err := ctx.Err(); err != nil {
return completed, err
}
conv, err := st.GetConversation(row.Owner, row.ConversationID)
if err != nil || string(conv.ProjectID) != row.ProjectID {
return completed, fmt.Errorf("project work source %s: current conversation binding unavailable", row.EventID)
}
allowed, err := work(ctx, row.Owner, core.ID(row.ProjectID))
if err != nil || !allowed {
return completed, fmt.Errorf("project work source %s: current project authorization denied", row.EventID)
}
account, err := st.GetAccountByOwner(row.Owner)
authorizationEpoch := ""
if err == nil && account.Enabled {
authorizationEpoch = fmt.Sprintf("account:%d:project:%s", account.AuthVersion, row.ProjectID)
} else if row.Owner == store.OwnerDefault {
// Auth-off local mode has no enabled account model. The project-work
// callback above is still authoritative for this process.
authorizationEpoch = "auth-off-local:project:" + row.ProjectID
} else {
return completed, fmt.Errorf("project work source %s: current account authorization unavailable", row.EventID)
}
observation := extension.WorkObservation{
EventID: row.EventID, Phase: "source-committed", Actor: row.Owner, Owner: row.Owner,
ProjectID: core.ID(row.ProjectID), ConversationID: core.ID(row.ConversationID), RunID: core.ID(row.RunID),
SourceKind: row.SourceKind, SourceID: row.SourceID, SourceVersion: row.SourceVersion,
SourceSHA256: row.SourceSHA256, PartIndex: row.PartIndex, SpanStart: row.SpanStart, SpanEnd: row.SpanEnd,
Text: row.Text, Scope: row.Scope, Delivery: row.Delivery, DeliveryReason: row.Reason, Metadata: row.Metadata,
AuthorizationEpoch: authorizationEpoch, ObservedAt: row.ObservedAt,
}
result, err := provider.Observe(ctx, observation)
if err != nil {
return completed, err
}
switch result.State {
case extension.WorkObservationPending:
continue
case extension.WorkObservationAccepted, extension.WorkObservationPartial, extension.WorkObservationDenied:
if err := st.FinishProjectWorkObservation(row.EventID, result.ReceiptID, string(result.State)); err != nil {
return completed, err
}
completed++
default:
return completed, fmt.Errorf("project work source %s: invalid observer result %q", row.EventID, result.State)
}
}
return completed, nil
}
func verifyProjectKnowledgeObservation(_ context.Context, caps hostcaps, row store.ProjectWorkObservationRow) error {
if row.SourceKind != "retrieval-hit" || caps.st == nil {
return errors.New("project work source: unsupported resource verification")
}
doc, err := caps.st.ProjectWorkKnowledgeSource(row.SourceID)
if err != nil {
return errors.New("project work source: current knowledge source unavailable")
}
hash := sha256.Sum256([]byte(doc.Chunk))
actualHash := fmt.Sprintf("%x", hash)
if actualHash != row.SourceVersion || actualHash != row.SourceSHA256 || doc.Chunk != row.Text ||
(row.Metadata["ref"] != "" && row.Metadata["ref"] != doc.Ref) ||
(row.Metadata["source"] != "" && row.Metadata["source"] != doc.Source) ||
(doc.ProjectID != "" && string(doc.ProjectID) != row.ProjectID) {
return errors.New("project work source: knowledge version or scope changed")
}
if caps.authz != nil && caps.authz.hasV2() {
allowed, err := caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: row.Owner, Action: "model-context", ResourceType: "knowledge", ResourceID: doc.ID,
Owner: string(doc.Owner), ProjectID: doc.ProjectID, Source: doc.Source,
})
if err != nil || !allowed {
return errors.New("project work source: current knowledge authorization denied")
}
} else if string(doc.Owner) != row.Owner {
return errors.New("project work source: shared knowledge authorization unavailable")
}
return nil
}
// A transient tool result has no external source authority to recheck. Its
// committed private outbox bytes and stored run are the only reliable source;
// RDM must keep it a candidate until a separately verifiable evidence source
// or human confirmation supports a formal project claim.
func verifyProjectTransientObservation(_ context.Context, st *store.Store, row store.ProjectWorkObservationRow) error {
toolSource := row.SourceKind == "tool-result" && row.Metadata["sourceStatus"] == "transient" && row.Metadata["toolName"] != ""
visibleAnalysis := row.SourceKind == "visible-analysis" && row.Metadata["stage"] == "assistant-final" && row.Metadata["partType"] != ""
if (!toolSource && !visibleAnalysis) || row.RunID == "" {
return errors.New("project work source: transient result lacks trusted provenance")
}
run, err := st.GetRunByID(core.ID(row.RunID))
if err != nil || string(run.Owner) != row.Owner || string(run.ProjectID) != row.ProjectID || string(run.ConversationID) != row.ConversationID {
return errors.New("project work source: transient run binding unavailable")
}
return nil
}
// preflightProjectMessage delivers this committed message's pending parts
// inline, then asks RDM to prove that every exact source part was analyzed for
// the pending action. A durable intake ACK alone never permits the action.
func preflightProjectMessage(ctx context.Context, st *store.Store, slot *projectWorkObserverSlot,
work func(context.Context, string, core.ID) (bool, error), messageID, action, expectedOwner, expectedConversation, expectedProject, targetID, targetVersion string,
) (extension.ProjectWorkDecision, error) {
pending := extension.ProjectWorkDecision{State: extension.ProjectWorkPending, Reason: "project source awaiting analysis"}
provider := slot.get()
if provider == nil || work == nil {
return pending, errors.New("extension: trusted project work observer unavailable")
}
rows, err := st.ProjectMessageObservations(messageID)
if err != nil {
return pending, err
}
if len(rows) == 0 {
return pending, errors.New("extension: committed project message has no source manifest")
}
if _, err := deliverProjectWorkRows(ctx, st, provider, work, rows); err != nil {
return pending, err
}
rows, err = st.ProjectMessageObservations(messageID)
if err != nil {
return pending, err
}
manifest := make([]extension.ProjectWorkSource, 0, len(rows))
for _, row := range rows {
if row.State != "accepted" || row.Delivery != "complete" {
return pending, nil
}
manifest = append(manifest, extension.ProjectWorkSource{
EventID: row.EventID, SourceKind: row.SourceKind, SourceID: row.SourceID,
SourceVersion: row.SourceVersion, SourceSHA256: row.SourceSHA256, PartIndex: row.PartIndex,
})
}
first := rows[0]
if first.Owner != expectedOwner || first.ConversationID != expectedConversation || first.ProjectID != expectedProject {
return pending, errors.New("extension: source message is not bound to this actor, conversation, and project")
}
for _, row := range rows[1:] {
if row.Owner != first.Owner || row.ProjectID != first.ProjectID || row.ConversationID != first.ConversationID {
return pending, errors.New("extension: mixed project source manifest")
}
}
conv, err := st.GetConversation(first.Owner, first.ConversationID)
if err != nil || string(conv.ProjectID) != first.ProjectID {
return pending, errors.New("extension: project binding changed before preflight")
}
allowed, err := work(ctx, first.Owner, conv.ProjectID)
if err != nil || !allowed {
return pending, errors.New("extension: current project work denied before preflight")
}
raw, err := json.Marshal(manifest)
if err != nil {
return pending, err
}
watermark := fmt.Sprintf("%x", sha256.Sum256(raw))
decision, err := provider.Preflight(ctx, extension.ProjectWorkAction{
Actor: first.Owner, Owner: first.Owner, ProjectID: conv.ProjectID,
ConversationID: conv.ID, RunID: core.ID(first.RunID), Action: action, TargetID: targetID, TargetVersion: targetVersion,
Manifest: manifest, SourceWatermark: watermark,
})
if err != nil {
return pending, err
}
switch decision.State {
case extension.ProjectWorkProceed:
if decision.DecisionID == "" || decision.SourceWatermark != watermark {
return pending, errors.New("extension: project preflight decision did not bind current sources")
}
case extension.ProjectWorkClarify, extension.ProjectWorkPending, extension.ProjectWorkDenied:
// A non-proceed decision is a real result, never an implied approval.
default:
return pending, errors.New("extension: invalid project preflight decision")
}
return decision, nil
}
// preflightProjectRun binds all committed user, retrieval, tool, and visible
// analysis parts of one run before a later model or business action consumes
// them. Missing/partial parts keep the action pending. The domain provider
// decides whether the complete source set contains a blocking risk.
func preflightProjectRun(ctx context.Context, st *store.Store, slot *projectWorkObserverSlot,
work func(context.Context, string, core.ID) (bool, error), verify func(context.Context, store.ProjectWorkObservationRow) error,
runID, action string,
) (extension.ProjectWorkDecision, error) {
pending := extension.ProjectWorkDecision{State: extension.ProjectWorkPending, Reason: "run sources awaiting analysis"}
provider := slot.get()
if provider == nil || work == nil || verify == nil {
return pending, errors.New("extension: trusted project run observer unavailable")
}
run, err := st.GetRunByID(core.ID(runID))
if err != nil || run.ProjectID == "" {
return pending, errors.New("extension: trusted project run unavailable")
}
rows, err := st.ProjectRunObservations(runID)
if err != nil {
return pending, err
}
if len(rows) == 0 {
return pending, errors.New("extension: project run has no trusted source manifest")
}
if _, err := deliverProjectWorkRows(ctx, st, provider, work, rows, verify); err != nil {
return pending, err
}
rows, err = st.ProjectRunObservations(runID)
if err != nil {
return pending, err
}
manifest := make([]extension.ProjectWorkSource, 0, len(rows))
for _, row := range rows {
if row.State != "accepted" || row.Delivery != "complete" {
return pending, nil
}
if row.RunID != runID || row.Owner != string(run.Owner) || row.ProjectID != string(run.ProjectID) || row.ConversationID != string(run.ConversationID) {
return pending, errors.New("extension: mixed run source manifest")
}
if row.SourceKind != "user-message" && row.SourceKind != "assistant-message" {
if err := verify(ctx, row); err != nil {
return pending, err
}
}
manifest = append(manifest, extension.ProjectWorkSource{
EventID: row.EventID, SourceKind: row.SourceKind, SourceID: row.SourceID,
SourceVersion: row.SourceVersion, SourceSHA256: row.SourceSHA256, PartIndex: row.PartIndex,
})
}
conv, err := st.GetConversation(string(run.Owner), string(run.ConversationID))
if err != nil || conv.ProjectID != run.ProjectID {
return pending, errors.New("extension: run project binding changed before preflight")
}
allowed, err := work(ctx, string(run.Owner), run.ProjectID)
if err != nil || !allowed {
return pending, errors.New("extension: current run project work denied")
}
raw, err := json.Marshal(manifest)
if err != nil {
return pending, err
}
watermark := fmt.Sprintf("%x", sha256.Sum256(raw))
decision, err := provider.Preflight(ctx, extension.ProjectWorkAction{
Actor: string(run.Owner), Owner: string(run.Owner), ProjectID: run.ProjectID,
ConversationID: run.ConversationID, RunID: run.ID, Action: action,
Manifest: manifest, SourceWatermark: watermark,
})
if err != nil {
return pending, err
}
switch decision.State {
case extension.ProjectWorkProceed:
if decision.DecisionID == "" || decision.SourceWatermark != watermark {
return pending, errors.New("extension: run preflight decision did not bind current sources")
}
case extension.ProjectWorkClarify, extension.ProjectWorkPending, extension.ProjectWorkDenied:
default:
return pending, errors.New("extension: invalid project run preflight decision")
}
return decision, nil
}
@@ -0,0 +1,332 @@
package app
import (
"context"
"crypto/sha256"
"fmt"
"path/filepath"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
func workObserverForTest() extension.ProjectWorkObserver {
return extension.ProjectWorkObserver{
Observe: func(context.Context, extension.WorkObservation) (extension.WorkObservationResult, error) {
return extension.WorkObservationResult{State: extension.WorkObservationPending}, nil
},
Preflight: func(context.Context, extension.ProjectWorkAction) (extension.ProjectWorkDecision, error) {
return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil
},
Respond: func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil
},
}
}
func TestProjectWorkDeliveryRechecksCurrentAuthorityAndACKsOnce(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "work.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "P")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("alice", string(project.ID), "Work", "")
if err != nil {
t.Fatal(err)
}
msg := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "要求效率和成本都低于人工"}}}
if err := st.AppendMessageWithRequestAccountChecked("alice", false, &msg, "req-1"); err != nil {
t.Fatal(err)
}
called := 0
slot := &projectWorkObserverSlot{}
observer := workObserverForTest()
observer.Observe = func(_ context.Context, source extension.WorkObservation) (extension.WorkObservationResult, error) {
called++
if source.Owner != "alice" || source.Actor != "alice" || source.ProjectID != project.ID || source.SourceID != string(msg.ID) || source.Text != msg.Parts[0].Text || source.Scope != "private" {
t.Fatalf("provider received forged or wrong source: %+v", source)
}
return extension.WorkObservationResult{State: extension.WorkObservationAccepted, ReceiptID: "rdm-receipt-1"}, nil
}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{workObserver: slot}}).RegisterProjectWorkObserver(observer); err != nil {
t.Fatal(err)
}
denied := func(context.Context, string, core.ID) (bool, error) { return false, nil }
if _, err := deliverProjectWorkObservations(context.Background(), st, slot, denied, 10); err == nil || called != 0 {
t.Fatalf("revoked source was disclosed: calls=%d err=%v", called, err)
}
allowed := func(_ context.Context, actor string, id core.ID) (bool, error) {
return actor == "alice" && id == project.ID, nil
}
if delivered, err := deliverProjectWorkObservations(context.Background(), st, slot, allowed, 10); err != nil || delivered != 1 || called != 1 {
t.Fatalf("authorized source delivery failed: delivered=%d calls=%d err=%v", delivered, called, err)
}
if delivered, err := deliverProjectWorkObservations(context.Background(), st, slot, allowed, 10); err != nil || delivered != 0 || called != 1 {
t.Fatalf("delivered source was replayed without need: delivered=%d calls=%d err=%v", delivered, called, err)
}
}
func TestProjectMessagePreflightRequiresAnalysisAndExactManifest(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "preflight.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "P")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("alice", string(project.ID), "Work", "")
if err != nil {
t.Fatal(err)
}
msg := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "要求效率和成本都低于人工"}}}
if err := st.AppendMessageWithRequestAccountChecked("alice", false, &msg, "req-1"); err != nil {
t.Fatal(err)
}
ready, observations, decisions := false, 0, 0
observer := workObserverForTest()
observer.Observe = func(context.Context, extension.WorkObservation) (extension.WorkObservationResult, error) {
observations++
return extension.WorkObservationResult{State: extension.WorkObservationAccepted, ReceiptID: "intake-1"}, nil
}
observer.Preflight = func(_ context.Context, action extension.ProjectWorkAction) (extension.ProjectWorkDecision, error) {
decisions++
if action.Actor != "alice" || action.ProjectID != project.ID || action.ConversationID != conv.ID || action.RunID != "" || action.Action != "conversation.run" || len(action.Manifest) != 1 {
t.Fatalf("preflight did not receive exact trusted source manifest: %+v", action)
}
if !ready {
return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil
}
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "decision-1", SourceWatermark: action.SourceWatermark}, nil
}
slot := &projectWorkObserverSlot{provider: &observer}
work := func(_ context.Context, actor string, id core.ID) (bool, error) {
return actor == "alice" && id == project.ID, nil
}
decision, err := preflightProjectMessage(context.Background(), st, slot, work, string(msg.ID), "conversation.run", "alice", string(conv.ID), string(project.ID), "conversation.run", "v1")
if err != nil || decision.State != extension.ProjectWorkPending || observations != 1 || decisions != 1 {
t.Fatalf("durable intake was confused with completed analysis: %+v observations=%d decisions=%d err=%v", decision, observations, decisions, err)
}
ready = true
decision, err = preflightProjectMessage(context.Background(), st, slot, work, string(msg.ID), "conversation.run", "alice", string(conv.ID), string(project.ID), "conversation.run", "v1")
if err != nil || decision.State != extension.ProjectWorkProceed || observations != 1 || decisions != 2 {
t.Fatalf("same source did not proceed after domain analysis: %+v observations=%d decisions=%d err=%v", decision, observations, decisions, err)
}
if _, err := st.DB().Exec(`UPDATE messages SET parts=? WHERE id=?`, `[{"type":"text","text":"changed"}]`, msg.ID); err != nil {
t.Fatal(err)
}
decision, err = preflightProjectMessage(context.Background(), st, slot, work, string(msg.ID), "conversation.run", "alice", string(conv.ID), string(project.ID), "conversation.run", "v1")
if err != nil || decision.State != extension.ProjectWorkPending || decisions != 2 {
t.Fatalf("source changed after ACK but stale decision still passed: %+v decisions=%d err=%v", decision, decisions, err)
}
partial := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "file-1"}}}}
if err := st.AppendMessageWithRequestAccountChecked("alice", false, &partial, "req-2"); err != nil {
t.Fatal(err)
}
decision, err = preflightProjectMessage(context.Background(), st, slot, work, string(partial.ID), "conversation.run", "alice", string(conv.ID), string(project.ID), "conversation.run", "v1")
if err != nil || decision.State != extension.ProjectWorkPending || decisions != 2 {
t.Fatalf("partial attachment reached project action preflight: %+v decisions=%d err=%v", decision, decisions, err)
}
}
func TestRetrievalObservationRechecksCurrentSourceBeforeDisclosure(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "source.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "P")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("alice", string(project.ID), "Work", "")
if err != nil {
t.Fatal(err)
}
run := core.Run{ConversationID: conv.ID, ProjectID: project.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking}
if err := st.CreateRun(&run); err != nil {
t.Fatal(err)
}
doc := core.KnowledgeDoc{ID: "knowledge-1", Owner: "alice", ProjectID: project.ID, Source: "upload", Ref: "https://example.test/a", Chunk: "actual source"}
if err := st.AddKnowledge(doc); err != nil {
t.Fatal(err)
}
observation := store.RunWorkObservationInput{RunID: string(run.ID), SourceKind: "retrieval-hit", SourceID: string(doc.ID),
SourceVersion: fmt.Sprintf("%x", sha256.Sum256([]byte(doc.Chunk))), Text: doc.Chunk, Delivery: "complete",
Metadata: map[string]string{"ref": doc.Ref, "source": doc.Source}}
if _, err := st.AppendRunWorkObservation(observation); err != nil {
t.Fatal(err)
}
called := 0
provider := workObserverForTest()
provider.Observe = func(context.Context, extension.WorkObservation) (extension.WorkObservationResult, error) {
called++
return extension.WorkObservationResult{State: extension.WorkObservationAccepted, ReceiptID: "rdm-1"}, nil
}
slot := &projectWorkObserverSlot{provider: &provider}
work := func(context.Context, string, core.ID) (bool, error) { return true, nil }
verify := func(ctx context.Context, row store.ProjectWorkObservationRow) error {
return verifyProjectKnowledgeObservation(ctx, hostcaps{st: st}, row)
}
if n, err := deliverProjectWorkObservations(context.Background(), st, slot, work, 10, verify); err != nil || n != 1 || called != 1 {
t.Fatalf("current owned source was not delivered: n=%d calls=%d err=%v", n, called, err)
}
if _, err := st.AppendRunWorkObservation(observation); err != nil {
t.Fatal(err)
}
if _, err := st.DB().Exec(`DELETE FROM knowledge WHERE id=?`, doc.ID); err != nil {
t.Fatal(err)
}
if n, err := deliverProjectWorkObservations(context.Background(), st, slot, work, 10, verify); err == nil || n != 0 || called != 1 {
t.Fatalf("deleted source text crossed into observer: n=%d calls=%d err=%v", n, called, err)
}
}
func TestTransientToolSourceKeepsCurrentRunAndCandidateStatus(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "tool.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "P")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("alice", string(project.ID), "Work", "")
if err != nil {
t.Fatal(err)
}
run := core.Run{ConversationID: conv.ID, ProjectID: project.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking}
if err := st.CreateRun(&run); err != nil {
t.Fatal(err)
}
_, err = st.AppendRunWorkObservation(store.RunWorkObservationInput{RunID: string(run.ID), SourceKind: "tool-result", SourceID: "call-1", SourceVersion: "v1",
Text: "实际工具返回", Delivery: "complete", Metadata: map[string]string{"sourceStatus": "transient", "toolName": "lookup"}})
if err != nil {
t.Fatal(err)
}
rows, err := st.PendingProjectWorkObservations(10)
if err != nil || len(rows) != 1 {
t.Fatalf("tool source missing: %+v %v", rows, err)
}
if err := verifyProjectTransientObservation(context.Background(), st, rows[0]); err != nil {
t.Fatalf("committed private tool source rejected: %v", err)
}
if _, err := st.DB().Exec(`DELETE FROM runs WHERE id=?`, run.ID); err != nil {
t.Fatal(err)
}
if err := verifyProjectTransientObservation(context.Background(), st, rows[0]); err == nil {
t.Fatal("deleted run still authorized its transient source")
}
}
func TestRunPreflightWaitsForAllCommittedSourceParts(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "run-preflight.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "P")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("alice", string(project.ID), "Work", "")
if err != nil {
t.Fatal(err)
}
run := core.Run{ConversationID: conv.ID, ProjectID: project.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking}
if err := st.CreateRun(&run); err != nil {
t.Fatal(err)
}
msg := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "检查成本和效率"}}}
if err := st.AppendMessageWithRequestAccountChecked("alice", false, &msg, "req"); err != nil {
t.Fatal(err)
}
if err := st.SetMessageRunIDAccountChecked("alice", string(msg.ID), string(run.ID), false); err != nil {
t.Fatal(err)
}
if _, err := st.AppendRunWorkObservation(store.RunWorkObservationInput{RunID: string(run.ID), SourceKind: "tool-result", SourceID: "call-1",
SourceVersion: "v1", Text: "工具中的测量结果", Delivery: "complete", Metadata: map[string]string{"sourceStatus": "transient", "toolName": "measure"}}); err != nil {
t.Fatal(err)
}
ready, preflights, observed, verified := false, 0, 0, 0
provider := workObserverForTest()
provider.Observe = func(_ context.Context, observation extension.WorkObservation) (extension.WorkObservationResult, error) {
observed++
if observation.AuthorizationEpoch == "" {
t.Fatal("observation omitted the current authorization epoch")
}
return extension.WorkObservationResult{State: extension.WorkObservationAccepted, ReceiptID: fmt.Sprintf("receipt-%d", observed)}, nil
}
provider.Preflight = func(_ context.Context, action extension.ProjectWorkAction) (extension.ProjectWorkDecision, error) {
preflights++
if action.RunID != run.ID || action.ProjectID != project.ID || len(action.Manifest) != 2 {
t.Fatalf("run manifest omitted a committed source: %+v", action)
}
if !ready {
return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil
}
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "approved-risk-check", SourceWatermark: action.SourceWatermark}, nil
}
slot := &projectWorkObserverSlot{provider: &provider}
work := func(context.Context, string, core.ID) (bool, error) { return true, nil }
verify := func(ctx context.Context, row store.ProjectWorkObservationRow) error {
verified++
return verifyProjectTransientObservation(ctx, st, row)
}
decision, err := preflightProjectRun(context.Background(), st, slot, work, verify, string(run.ID), "model.consume.tool")
if err != nil || decision.State != extension.ProjectWorkPending || observed != 2 || preflights != 1 || verified != 2 {
t.Fatalf("intake ACK was mistaken for finished risk analysis: %+v observed=%d preflights=%d err=%v", decision, observed, preflights, err)
}
ready = true
decision, err = preflightProjectRun(context.Background(), st, slot, work, verify, string(run.ID), "model.consume.tool")
if err != nil || decision.State != extension.ProjectWorkProceed || observed != 2 || preflights != 2 || verified != 3 {
t.Fatalf("fully analyzed run was not released once: %+v observed=%d verified=%d preflights=%d err=%v", decision, observed, verified, preflights, err)
}
}
func TestProjectWorkObservationRegistrationIsTrustedAndUnique(t *testing.T) {
slot := &projectWorkObserverSlot{}
trusted := pluginRegistry{pluginID: "rdm", caps: hostcaps{workObserver: slot}}
other := pluginRegistry{pluginID: "other", caps: hostcaps{workObserver: slot}}
if err := other.RegisterProjectWorkObserver(workObserverForTest()); err == nil {
t.Fatal("untrusted plugin registered project observer")
}
if err := (compiledRegistry{}).RegisterProjectWorkObserver(workObserverForTest()); err == nil {
t.Fatal("declarative plugin registered live observer callback")
}
if err := trusted.RegisterProjectWorkObserver(extension.ProjectWorkObserver{}); err == nil {
t.Fatal("partial observer registered")
}
if err := trusted.RegisterProjectWorkObserver(workObserverForTest()); err != nil {
t.Fatal(err)
}
if err := trusted.RegisterProjectWorkObserver(workObserverForTest()); err == nil {
t.Fatal("second observer replaced the trusted provider")
}
if got := slot.get(); got == nil || got.Observe == nil || got.Preflight == nil || got.Respond == nil {
t.Fatal("registered observer not available to host")
}
}
+220
View File
@@ -0,0 +1,220 @@
package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"sync"
"unicode/utf8"
"agent-system-backend/pkg/extension"
)
// EXT-20:共享法规检索与网络证据。提供方由受信插件 kbreg 进程内注册;基座只
// 填写调用方身份、做查询最小化校验并审计,不改写提供方返回的状态。
const (
regulationsPluginID = "kbreg"
maxWebQueryRunes = 256
auditRegulationsSearch = "regulations.search"
auditRegulationsEvidence = "regulations.evidence"
auditWebSearch = "web.search"
auditWebCapture = "web.capture"
regulationsAuditInvalid = "invalid"
regulationsAuditError = "error"
regulationsAuditOK = "ok"
)
var (
errRegulationsSchema = errors.New("extension: unsupported regulations schema version")
errWebQueryInvalid = errors.New("extension: web search query must be 1-256 characters")
)
type regulationsSlot struct {
mu sync.RWMutex
provider extension.RegulationsProvider
}
func (s *regulationsSlot) register(pluginID string, p extension.RegulationsProvider) error {
if p == nil || pluginID != regulationsPluginID {
return errors.New("extension: regulations provider requires trusted kbreg registration")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.provider != nil {
return errors.New("extension: regulations provider already registered")
}
s.provider = p
return nil
}
func (s *regulationsSlot) get() extension.RegulationsProvider {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.provider
}
// regulationsAudit is the only content written to the audit trail: never the
// query text, excerpts or captured page text.
type regulationsAudit struct {
Revision string `json:"revision,omitempty"`
Count int `json:"count"`
State string `json:"state"`
QuerySHA256 string `json:"querySha256,omitempty"`
}
func regulationsQueryDigest(query string) string {
if query == "" {
return ""
}
sum := sha256.Sum256([]byte(query))
return hex.EncodeToString(sum[:])
}
// regulations resolves the provider and the trusted caller. Without an audit
// store the capability is unavailable: calls must never run unaudited.
func (h inprocHost) regulations() (extension.RegulationsProvider, extension.RegulationsCaller, error) {
if err := h.requireEffective(); err != nil {
return nil, extension.RegulationsCaller{}, err
}
p := h.caps.regulations.get()
if p == nil || h.caps.st == nil {
return nil, extension.RegulationsCaller{}, extension.ErrRegulationsUnavailable
}
return p, extension.RegulationsCaller{PluginID: h.pluginID, Actor: h.actor, Owner: h.owner}, nil
}
// auditRegulations appends one event per call. A call error takes precedence;
// otherwise an audit failure withholds the result.
func (h inprocHost) auditRegulations(action string, rec regulationsAudit, callErr error) error {
if callErr != nil && rec.State == "" {
rec.State = regulationsAuditError
}
evidence, err := json.Marshal(rec)
if err == nil {
err = h.Audit(action, regulationsPluginID, string(evidence))
}
if callErr != nil {
return callErr
}
return err
}
func (h inprocHost) SearchRegulations(ctx context.Context, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) {
p, caller, err := h.regulations()
if err != nil {
return extension.RegulationsSearchResult{}, err
}
rec := regulationsAudit{Revision: req.CorpusRevision, QuerySHA256: regulationsQueryDigest(req.Query)}
if req.SchemaVersion != extension.RegulationsSchemaVersion {
rec.State = regulationsAuditInvalid
return extension.RegulationsSearchResult{}, h.auditRegulations(auditRegulationsSearch, rec, errRegulationsSchema)
}
res, err := p.SearchRegulations(ctx, caller, req)
if err == nil {
rec.Revision, rec.Count, rec.State = res.CorpusRevision, len(res.Hits), res.State
}
if err := h.auditRegulations(auditRegulationsSearch, rec, err); err != nil {
return extension.RegulationsSearchResult{}, err
}
return res, nil
}
func (h inprocHost) ResolveRegulationEvidence(ctx context.Context, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) {
p, caller, err := h.regulations()
if err != nil {
return extension.RegulationEvidence{}, err
}
rec := regulationsAudit{Revision: req.CorpusRevision}
if req.SchemaVersion != extension.RegulationsSchemaVersion {
rec.State = regulationsAuditInvalid
return extension.RegulationEvidence{}, h.auditRegulations(auditRegulationsEvidence, rec, errRegulationsSchema)
}
ev, err := p.ResolveRegulationEvidence(ctx, caller, req)
if err == nil {
rec.Count, rec.State = 1, regulationsAuditOK
if ev.CorpusRevision != "" {
rec.Revision = ev.CorpusRevision
}
}
if err := h.auditRegulations(auditRegulationsEvidence, rec, err); err != nil {
return extension.RegulationEvidence{}, err
}
return ev, nil
}
// webQueryExcluded reports whether query contains any non-empty exclusion
// term, case-insensitively.
func webQueryExcluded(query string, exclusions []string) bool {
lower := strings.ToLower(query)
for _, term := range exclusions {
term = strings.ToLower(strings.TrimSpace(term))
if term != "" && strings.Contains(lower, term) {
return true
}
}
return false
}
func (h inprocHost) SearchWeb(ctx context.Context, req extension.WebSearchRequest) (extension.WebSearchResult, error) {
p, caller, err := h.regulations()
if err != nil {
return extension.WebSearchResult{}, err
}
req.Query = strings.TrimSpace(req.Query)
rec := regulationsAudit{QuerySHA256: regulationsQueryDigest(req.Query)}
if req.SchemaVersion != extension.RegulationsSchemaVersion {
rec.State = regulationsAuditInvalid
return extension.WebSearchResult{}, h.auditRegulations(auditWebSearch, rec, errRegulationsSchema)
}
if req.Query == "" || !utf8.ValidString(req.Query) || utf8.RuneCountInString(req.Query) > maxWebQueryRunes {
rec.State = regulationsAuditInvalid
return extension.WebSearchResult{}, h.auditRegulations(auditWebSearch, rec, errWebQueryInvalid)
}
if webQueryExcluded(req.Query, req.Exclusions) {
res := extension.WebSearchResult{State: extension.WebSearchStateRejected, Reason: "query contains an excluded term"}
rec.State = res.State
if err := h.auditRegulations(auditWebSearch, rec, nil); err != nil {
return extension.WebSearchResult{}, err
}
return res, nil
}
res, err := p.SearchWeb(ctx, caller, req)
if err == nil {
rec.Count, rec.State = len(res.Results), res.State
}
if err := h.auditRegulations(auditWebSearch, rec, err); err != nil {
return extension.WebSearchResult{}, err
}
return res, nil
}
func (h inprocHost) CaptureWeb(ctx context.Context, req extension.WebCaptureRequest) (extension.WebCapture, error) {
p, caller, err := h.regulations()
if err != nil {
return extension.WebCapture{}, err
}
rec := regulationsAudit{QuerySHA256: regulationsQueryDigest(strings.TrimSpace(req.SearchQuery))}
if req.SchemaVersion != extension.RegulationsSchemaVersion {
rec.State = regulationsAuditInvalid
return extension.WebCapture{}, h.auditRegulations(auditWebCapture, rec, errRegulationsSchema)
}
capture, err := p.CaptureWeb(ctx, caller, req)
if err == nil {
rec.Count, rec.State = len(capture.Candidates), capture.State
}
if err := h.auditRegulations(auditWebCapture, rec, err); err != nil {
return extension.WebCapture{}, err
}
return capture, nil
}
var _ extension.RegulationsHost = inprocHost{}
+134
View File
@@ -0,0 +1,134 @@
package app
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
// compatRegulationsPlugin stands in for the external kbreg repository: it uses
// only pkg/extension types and registers through the optional Registrar.
type compatRegulationsPlugin struct{ seen []extension.RegulationsCaller }
func (p *compatRegulationsPlugin) Register(reg extension.Registry) error {
registrar, ok := reg.(extension.RegulationsRegistrar)
if !ok {
return errors.New("regulations registrar unavailable")
}
return registrar.RegisterRegulationsProvider(p)
}
func (p *compatRegulationsPlugin) SearchRegulations(_ context.Context, c extension.RegulationsCaller, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) {
p.seen = append(p.seen, c)
return extension.RegulationsSearchResult{CorpusRevision: "rev-compat", State: extension.RegulationsStateOK,
Hits: []extension.RegulationHit{{RegulationID: "r", EditionID: "e", NodeID: "n", Disclosure: extension.RegulationDisclosureExcerpt}}}, nil
}
func (p *compatRegulationsPlugin) ResolveRegulationEvidence(_ context.Context, c extension.RegulationsCaller, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) {
p.seen = append(p.seen, c)
return extension.RegulationEvidence{CorpusRevision: req.CorpusRevision, NodeID: req.NodeID, Disclosure: extension.RegulationDisclosureFull, HashVerified: true}, nil
}
func (p *compatRegulationsPlugin) SearchWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebSearchRequest) (extension.WebSearchResult, error) {
p.seen = append(p.seen, c)
return extension.WebSearchResult{Provider: "compat", SentQuery: req.Query, State: extension.WebSearchStateEmpty}, nil
}
func (p *compatRegulationsPlugin) CaptureWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebCaptureRequest) (extension.WebCapture, error) {
p.seen = append(p.seen, c)
return extension.WebCapture{FinalURL: req.URL, State: extension.WebCaptureStateFailed, Reason: "offline"}, nil
}
// compatRegulationsConsumer is a second compiled plugin that reaches the
// provider only through the dispatch Host type assertion.
func compatRegulationsConsumer(reg extension.Registry) error {
return reg.AddCommandHandler(json.RawMessage(`{"id":"consumer.regs","title":"regs"}`),
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
regs, ok := h.(extension.RegulationsHost)
if !ok {
return nil, errors.New("regulations host capability missing")
}
search, err := regs.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "注册检验"})
if err != nil {
return nil, err
}
web, err := regs.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "注册检验 指导原则"})
if err != nil {
return nil, err
}
return map[string]any{"revision": search.CorpusRevision, "webState": web.State}, nil
})
}
func newRegulationsCompatApp(t *testing.T, plugins ...extension.InProc) (*cmdbus.Bus, *store.Store, hostcaps) {
t.Helper()
extension.ResetCompiled()
t.Cleanup(extension.ResetCompiled)
for _, p := range plugins {
extension.RegisterCompiled(p)
}
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "compat.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
caps := hostcaps{st: st, regulations: &regulationsSlot{}}
applyCompiled("", bus, caps)
return bus, st, caps
}
func TestRegulationsCompiledPluginCompatibility(t *testing.T) {
provider := &compatRegulationsPlugin{}
impostor := &compatRegulationsPlugin{}
bus, st, caps := newRegulationsCompatApp(t,
extension.InProc{ID: "impostor", Register: impostor.Register},
extension.InProc{ID: "kbreg", Register: provider.Register},
extension.InProc{ID: "consumer", Register: compatRegulationsConsumer},
)
if caps.regulations.get() != extension.RegulationsProvider(provider) {
t.Fatal("compiled kbreg provider was not the registered provider")
}
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "consumer.regs", Owner: "alice", Actor: "alice", Entrance: "button"})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("dispatch = %+v", resp.Receipt)
}
payload, _ := resp.Payload.(map[string]any)
if payload["revision"] != "rev-compat" || payload["webState"] != extension.WebSearchStateEmpty {
t.Fatalf("payload = %+v", resp.Payload)
}
want := extension.RegulationsCaller{PluginID: "consumer", Actor: "alice", Owner: "alice"}
if len(provider.seen) != 2 || provider.seen[0] != want || provider.seen[1] != want || len(impostor.seen) != 0 {
t.Fatalf("provider callers = %+v impostor = %+v", provider.seen, impostor.seen)
}
evs, err := st.ListAuditEvents("alice", 20)
if err != nil {
t.Fatal(err)
}
actions := map[string]bool{}
for _, ev := range evs {
actions[ev.Action] = true
}
if !actions[auditRegulationsSearch] || !actions[auditWebSearch] {
t.Fatalf("host audit missing: %+v", evs)
}
}
func TestRegulationsCompiledConsumerWithoutProvider(t *testing.T) {
bus, _, _ := newRegulationsCompatApp(t, extension.InProc{ID: "consumer", Register: compatRegulationsConsumer})
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "consumer.regs", Owner: "alice", Actor: "alice", Entrance: "button"})
if resp.Receipt.State == agentcommand.StateCommitted {
t.Fatalf("consumer succeeded without a regulations provider: %+v", resp.Payload)
}
}
+347
View File
@@ -0,0 +1,347 @@
package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
type fakeRegulationsProvider struct {
callers []extension.RegulationsCaller
webQueries []string
search extension.RegulationsSearchResult
evidence extension.RegulationEvidence
web extension.WebSearchResult
capture extension.WebCapture
err error
}
func (f *fakeRegulationsProvider) SearchRegulations(_ context.Context, c extension.RegulationsCaller, _ extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) {
f.callers = append(f.callers, c)
return f.search, f.err
}
func (f *fakeRegulationsProvider) ResolveRegulationEvidence(_ context.Context, c extension.RegulationsCaller, _ extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) {
f.callers = append(f.callers, c)
return f.evidence, f.err
}
func (f *fakeRegulationsProvider) SearchWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebSearchRequest) (extension.WebSearchResult, error) {
f.callers = append(f.callers, c)
f.webQueries = append(f.webQueries, req.Query)
return f.web, f.err
}
func (f *fakeRegulationsProvider) CaptureWeb(_ context.Context, c extension.RegulationsCaller, _ extension.WebCaptureRequest) (extension.WebCapture, error) {
f.callers = append(f.callers, c)
return f.capture, f.err
}
func newRegulationsTestHost(t *testing.T) (inprocHost, *store.Store) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "regulations.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
caps := hostcaps{st: st, regulations: &regulationsSlot{}}
return inprocHost{caps: caps, pluginID: "rdm", owner: "alice", actor: "alice"}, st
}
func registerFakeRegulations(t *testing.T, h inprocHost) *fakeRegulationsProvider {
t.Helper()
p := &fakeRegulationsProvider{}
if err := (pluginRegistry{pluginID: "kbreg", caps: h.caps}).RegisterRegulationsProvider(p); err != nil {
t.Fatal(err)
}
return p
}
const regV = extension.RegulationsSchemaVersion
func callAllRegulations(h inprocHost, version int) []error {
ctx := context.Background()
_, e1 := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: version, Query: "医疗器械 临床评价"})
_, e2 := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: version, CorpusRevision: "rev-1", NodeID: "n1"})
_, e3 := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: version, Query: "临床评价 指导原则"})
_, e4 := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: version, URL: "https://www.nmpa.gov.cn/x", SearchQuery: "临床评价 指导原则"})
return []error{e1, e2, e3, e4}
}
func TestRegulationsUnavailableWithoutProvider(t *testing.T) {
h, st := newRegulationsTestHost(t)
for i, err := range callAllRegulations(h, regV) {
if !errors.Is(err, extension.ErrRegulationsUnavailable) {
t.Fatalf("method %d without provider: %v", i, err)
}
}
h.caps.regulations = nil
for i, err := range callAllRegulations(h, regV) {
if !errors.Is(err, extension.ErrRegulationsUnavailable) {
t.Fatalf("method %d without slot: %v", i, err)
}
}
if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 0 {
t.Fatalf("unavailable calls audited: %+v %v", evs, err)
}
}
func TestRegulationsRegistrationOnlyTrustedKbregOnce(t *testing.T) {
h, _ := newRegulationsTestHost(t)
first, second := &fakeRegulationsProvider{}, &fakeRegulationsProvider{}
if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterRegulationsProvider(first); err == nil {
t.Fatal("non-kbreg plugin registered a regulations provider")
}
kbreg := pluginRegistry{pluginID: "kbreg", caps: h.caps}
if err := kbreg.RegisterRegulationsProvider(nil); err == nil {
t.Fatal("nil provider registered")
}
if err := kbreg.RegisterRegulationsProvider(first); err != nil {
t.Fatal(err)
}
if err := kbreg.RegisterRegulationsProvider(second); err == nil {
t.Fatal("duplicate registration accepted")
}
if h.caps.regulations.get() != first {
t.Fatal("duplicate registration replaced the first provider")
}
if err := (pluginRegistry{pluginID: "kbreg"}).RegisterRegulationsProvider(first); err == nil {
t.Fatal("registration without a host slot accepted")
}
}
func TestRegulationsDeclarativeRegistrationRejected(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := &fakeRegulationsProvider{}
if err := (compiledRegistry{}).RegisterRegulationsProvider(p); err == nil {
t.Fatal("declarative registry accepted a regulations provider")
}
if err := (recordRegistry{Registry: pluginRegistry{pluginID: "kbreg", caps: h.caps}}).RegisterRegulationsProvider(p); err != nil {
t.Fatalf("record registry did not forward kbreg registration: %v", err)
}
if h.caps.regulations.get() != p {
t.Fatal("forwarded registration did not fill the slot")
}
}
func TestRegulationsCallerFilledFromDispatchContext(t *testing.T) {
h, _ := newRegulationsTestHost(t)
h.actor = "alice-session"
p := registerFakeRegulations(t, h)
p.web.State = extension.WebSearchStateOK
for i, err := range callAllRegulations(h, regV) {
if err != nil {
t.Fatalf("method %d: %v", i, err)
}
}
want := extension.RegulationsCaller{PluginID: "rdm", Actor: "alice-session", Owner: "alice"}
if len(p.callers) != 4 {
t.Fatalf("provider calls = %d", len(p.callers))
}
for i, got := range p.callers {
if got != want {
t.Fatalf("call %d caller = %+v, want %+v", i, got, want)
}
}
}
func TestRegulationsRejectsOtherSchemaVersions(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
for _, version := range []int{0, regV + 1} {
for i, err := range callAllRegulations(h, version) {
if !errors.Is(err, errRegulationsSchema) {
t.Fatalf("schema %d method %d: %v", version, i, err)
}
}
}
if len(p.callers) != 0 {
t.Fatalf("provider reached with unsupported schema: %d calls", len(p.callers))
}
}
func TestRegulationsWebQueryBounds(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
ctx := context.Background()
for _, q := range []string{"", " \t\n", strings.Repeat("法", maxWebQueryRunes+1)} {
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: q}); !errors.Is(err, errWebQueryInvalid) {
t.Fatalf("query of %d runes accepted: %v", len([]rune(q)), err)
}
}
if len(p.callers) != 0 {
t.Fatal("provider reached with an invalid query")
}
maxQuery := strings.Repeat("法", maxWebQueryRunes)
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: " " + maxQuery + " "}); err != nil {
t.Fatalf("256-rune query rejected: %v", err)
}
if len(p.webQueries) != 1 || p.webQueries[0] != maxQuery {
t.Fatalf("provider query was not trimmed: %q", p.webQueries)
}
}
func TestRegulationsExclusionRejectedBeforeProvider(t *testing.T) {
h, st := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
ctx := context.Background()
query := "Project-Falcon 临床评价 指导原则"
res, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: query,
Exclusions: []string{"", " ", "project-falcon"}})
if err != nil || res.State != extension.WebSearchStateRejected || res.SentQuery != "" || len(res.Results) != 0 {
t.Fatalf("excluded query result = %+v %v", res, err)
}
if len(p.callers) != 0 {
t.Fatal("provider received a query containing an excluded term")
}
evs, err := st.ListAuditEvents("alice", 10)
if err != nil || len(evs) != 1 || evs[0].Action != auditWebSearch {
t.Fatalf("rejection audit: %+v %v", evs, err)
}
if strings.Contains(strings.ToLower(evs[0].Evidence), "falcon") || !strings.Contains(evs[0].Evidence, `"state":"rejected"`) {
t.Fatalf("rejection audit evidence = %s", evs[0].Evidence)
}
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: query, Exclusions: []string{"", " "}}); err != nil {
t.Fatal(err)
}
if len(p.callers) != 1 {
t.Fatal("blank exclusions blocked an allowed query")
}
}
func TestRegulationsProviderStatesPassThroughUnchanged(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
at := time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-7", State: extension.RegulationsStateDegraded,
DegradedReasons: []string{"vector_index_unavailable"},
Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1",
Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights"},
{RegulationID: "r2", EditionID: "e2", NodeID: "n2", Disclosure: extension.RegulationDisclosureMetadataOnly}}}
p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-7", NodeID: "n1",
Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", HashVerified: false}
p.web = extension.WebSearchResult{Provider: "fake", SentQuery: "q", SearchedAt: at,
State: extension.WebSearchStateRateLimited, Reason: "quota"}
p.capture = extension.WebCapture{CaptureID: "c1", FetchedAt: at, Retention: extension.WebRetentionMetadataOnly,
State: extension.WebCaptureStateRejected, Reason: "robots"}
ctx := context.Background()
search, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "q"})
if err != nil || !reflect.DeepEqual(search, p.search) {
t.Fatalf("search = %+v %v", search, err)
}
evidence, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-7", NodeID: "n1"})
if err != nil || !reflect.DeepEqual(evidence, p.evidence) {
t.Fatalf("evidence = %+v %v", evidence, err)
}
web, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: "q"})
if err != nil || !reflect.DeepEqual(web, p.web) {
t.Fatalf("web = %+v %v", web, err)
}
p.web.State = extension.WebSearchStateRejected
web, err = h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: "q"})
if err != nil || web.State != extension.WebSearchStateRejected {
t.Fatalf("provider rejection rewritten: %+v %v", web, err)
}
capture, err := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: regV, URL: "https://example.org"})
if err != nil || !reflect.DeepEqual(capture, p.capture) {
t.Fatalf("capture = %+v %v", capture, err)
}
p.err = errors.New("provider failure")
if _, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "q"}); !errors.Is(err, p.err) {
t.Fatalf("provider failure wrapped as success: %v", err)
}
}
func TestRegulationsAuditEvidenceOmitsQueryAndText(t *testing.T) {
h, st := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
const (
secretQuery = "第十七条 secret-query-text"
secretExcerpt = "SECRET-EXCERPT 原文片段"
secretPage = "SECRET-PAGE 网页全文"
)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK,
Hits: []extension.RegulationHit{{NodeID: "n1", Excerpt: secretExcerpt, Disclosure: extension.RegulationDisclosureExcerpt}}}
p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-9", NodeID: "n1", Excerpt: secretExcerpt,
Disclosure: extension.RegulationDisclosureFull, HashVerified: true}
p.web = extension.WebSearchResult{SentQuery: secretQuery, State: extension.WebSearchStateOK,
Results: []extension.WebSearchHit{{Rank: 1, URL: "https://example.org", Snippet: secretExcerpt}}}
p.capture = extension.WebCapture{State: extension.WebCaptureStateCaptured, Retention: extension.WebRetentionFull,
Text: secretPage, Candidates: []extension.WebClauseCandidate{{Excerpt: secretExcerpt}}}
ctx := context.Background()
if _, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: secretQuery}); err != nil {
t.Fatal(err)
}
if _, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-9", NodeID: "n1"}); err != nil {
t.Fatal(err)
}
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: " " + secretQuery + " "}); err != nil {
t.Fatal(err)
}
if _, err := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: regV, URL: "https://example.org", SearchQuery: secretQuery}); err != nil {
t.Fatal(err)
}
evs, err := st.ListAuditEvents("alice", 10)
if err != nil || len(evs) != 4 {
t.Fatalf("audit events = %+v %v", evs, err)
}
sum := sha256.Sum256([]byte(secretQuery))
digest := hex.EncodeToString(sum[:])
want := map[string]regulationsAudit{
auditRegulationsSearch: {Revision: "rev-9", Count: 1, State: "ok", QuerySHA256: digest},
auditRegulationsEvidence: {Revision: "rev-9", Count: 1, State: "ok"},
auditWebSearch: {Count: 1, State: "ok", QuerySHA256: digest},
auditWebCapture: {Count: 1, State: "captured", QuerySHA256: digest},
}
for _, ev := range evs {
for _, secret := range []string{"secret-query-text", "第十七条", "SECRET-EXCERPT", "原文片段", "SECRET-PAGE", "网页全文"} {
if strings.Contains(ev.Evidence, secret) || strings.Contains(ev.Target, secret) {
t.Fatalf("%s audit leaks %q: %s", ev.Action, secret, ev.Evidence)
}
}
var fields map[string]any
if err := json.Unmarshal([]byte(ev.Evidence), &fields); err != nil {
t.Fatal(err)
}
for k := range fields {
if k != "revision" && k != "count" && k != "state" && k != "querySha256" {
t.Fatalf("%s audit has unexpected field %q", ev.Action, k)
}
}
var got regulationsAudit
if err := json.Unmarshal([]byte(ev.Evidence), &got); err != nil {
t.Fatal(err)
}
if w, ok := want[ev.Action]; !ok || got != w || ev.Owner != "alice" {
t.Fatalf("%s audit = %+v owner=%s, want %+v", ev.Action, got, ev.Owner, w)
}
delete(want, ev.Action)
}
}
func TestRegulationsRequireEffectiveAccount(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
h.caps.accountGuard = func(string) error { return store.ErrAccountForbidden }
for i, err := range callAllRegulations(h, regV) {
if !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("method %d ignored the account guard: %v", i, err)
}
}
if len(p.callers) != 0 {
t.Fatal("provider reached for an ineffective account")
}
}
+268
View File
@@ -0,0 +1,268 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"agent-system-backend/internal/tools"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// EXT-20 内置对话工具:模型经工具注册表只读检索共享法规知识库。执行走
// inprocHost 的审计路径(有效账号校验 + regulations.* 审计),调用方身份为
// 运行属主本人与固定的内置工具调用方 ID。
const (
regulationsToolCallerID = "agent-tools"
regulationsToolTimeout = 60 * time.Second
regulationsSearchDefaultLimit = 5
regulationsSearchMaxLimit = 10
toolRegulationsSearch = "regulations_search"
toolRegulationEvidence = "regulation_evidence"
toolKnowledgeSearch = "knowledge_search"
knowledgeRegulationsFallbackPrefix = "项目资料无命中;以下为法规知识库命中(可用 regulation_evidence 取原文):\n"
)
func registerRegulationsTools(reg *tools.Registry, caps hostcaps) error {
if err := registerRegulationsSearchTools(reg, caps); err != nil {
return err
}
return wrapKnowledgeSearchWithRegulations(reg, caps)
}
// wrapKnowledgeSearchWithRegulations 让 knowledge_search 在项目资料无命中时回退
// 法规知识库;未注册 knowledge_search 时不做任何事。
func wrapKnowledgeSearchWithRegulations(reg *tools.Registry, caps hostcaps) error {
desc, inner, ok := reg.Get(toolKnowledgeSearch)
if !ok {
return nil
}
return reg.Register(desc, func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) {
out, err := inner(rc, args)
if err != nil || out != tools.KnowledgeSearchNoHits {
return out, err
}
if fallback, ok := knowledgeRegulationsFallback(caps, rc, args); ok {
return knowledgeRegulationsFallbackPrefix + fallback, nil
}
return out, nil
})
}
func knowledgeRegulationsFallback(caps hostcaps, rc tools.ToolExecCtx, args json.RawMessage) (string, bool) {
var a struct {
Query string `json:"query"`
}
if json.Unmarshal(args, &a) != nil {
return "", false
}
q, err := json.Marshal(map[string]string{"query": a.Query})
if err != nil {
return "", false
}
res, err := regulationsSearchTool(caps, rc, q)
if err != nil {
return "", false
}
var parsed regulationsToolSearchOutput
if json.Unmarshal([]byte(res), &parsed) != nil || parsed.State == extension.RegulationsStateUnavailable || len(parsed.Hits) == 0 {
return "", false
}
return res, true
}
func registerRegulationsSearchTools(reg *tools.Registry, caps hostcaps) error {
if err := reg.Register(core.ToolDesc{
Name: toolRegulationsSearch,
Description: "检索已发布的医疗器械法规、标准(GB/YY/YY/T 等)、指导原则、通告等共享法规知识库;用户提到标准号、法规名称、条款或监管要求时优先调用本工具,再考虑网络搜索;命中后可用 regulation_evidence 取条款原文以精确引用",
Source: core.SourceBuiltin, ReadOnly: true,
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"query":{"type":"string","description":"检索词"},` +
`"docNo":{"type":"string","description":"标准号/文号,如 GB 9706.1-2020"},` +
`"clauseNo":{"type":"string","description":"条款号,如 7.2.1"},` +
`"kinds":{"type":"array","items":{"type":"string"},"description":"文献类别过滤"},` +
`"currentOnly":{"type":"boolean","description":"仅现行有效版本"},` +
`"limit":{"type":"integer","minimum":1,"maximum":10,"description":"返回条数,默认 5"}},` +
`"required":["query"]}`),
}, func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) {
return regulationsSearchTool(caps, rc, args)
}); err != nil {
return err
}
return reg.Register(core.ToolDesc{
Name: toolRegulationEvidence,
Description: "按 regulations_search 返回的 corpusRevision 与 nodeId 取条款原文区间与哈希,用于精确引用",
Source: core.SourceBuiltin, ReadOnly: true,
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"corpusRevision":{"type":"string","description":"regulations_search 返回的语料修订号"},` +
`"nodeId":{"type":"string","description":"regulations_search 命中的 nodeId"},` +
`"start":{"type":"integer","description":"原文区间起点(可选)"},` +
`"end":{"type":"integer","description":"原文区间终点(可选)"}},` +
`"required":["corpusRevision","nodeId"]}`),
}, func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) {
return regulationEvidenceTool(caps, rc, args)
})
}
type regulationsToolHit struct {
RegulationID string `json:"regulationId"`
EditionID string `json:"editionId"`
NodeID string `json:"nodeId"`
DocNo string `json:"docNo,omitempty"`
Title string `json:"title,omitempty"`
EditionLabel string `json:"editionLabel,omitempty"`
LegalStatus string `json:"legalStatus,omitempty"`
ClauseNo string `json:"clauseNo,omitempty"`
HeadingPath []string `json:"headingPath,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
Disclosure string `json:"disclosure"`
WithheldReason string `json:"withheldReason,omitempty"`
PageStart int `json:"pageStart,omitempty"`
PageEnd int `json:"pageEnd,omitempty"`
IsSummary bool `json:"isSummary,omitempty"`
MatchKinds []string `json:"matchKinds,omitempty"`
}
type regulationsToolSearchOutput struct {
CorpusRevision string `json:"corpusRevision"`
State string `json:"state"`
DegradedReasons []string `json:"degradedReasons,omitempty"`
Hits []regulationsToolHit `json:"hits"`
}
type regulationsToolUnavailable struct {
State string `json:"state"`
Reason string `json:"reason"`
}
func regulationsSearchTool(caps hostcaps, rc tools.ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
Query string `json:"query"`
DocNo string `json:"docNo"`
ClauseNo string `json:"clauseNo"`
Kinds []string `json:"kinds"`
CurrentOnly bool `json:"currentOnly"`
Limit int `json:"limit"`
}
if len(args) > 0 {
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("%s: bad args: %w", toolRegulationsSearch, err)
}
}
a.Query = strings.TrimSpace(a.Query)
if a.Query == "" {
return "", fmt.Errorf("%s: query is required", toolRegulationsSearch)
}
limit := a.Limit
if limit <= 0 {
limit = regulationsSearchDefaultLimit
} else if limit > regulationsSearchMaxLimit {
limit = regulationsSearchMaxLimit
}
h, err := regulationsToolHost(caps, rc, toolRegulationsSearch)
if err != nil {
return "", err
}
ctx, cancel := regulationsToolContext(rc)
defer cancel()
res, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{
SchemaVersion: extension.RegulationsSchemaVersion,
Query: a.Query,
Kinds: a.Kinds,
CurrentOnly: a.CurrentOnly,
DocNo: strings.TrimSpace(a.DocNo),
ClauseNo: strings.TrimSpace(a.ClauseNo),
Limit: limit,
})
if err != nil {
return regulationsToolError(toolRegulationsSearch, err)
}
out := regulationsToolSearchOutput{CorpusRevision: res.CorpusRevision, State: res.State,
DegradedReasons: res.DegradedReasons, Hits: make([]regulationsToolHit, 0, len(res.Hits))}
for _, hit := range res.Hits {
out.Hits = append(out.Hits, regulationsToolHit{
RegulationID: hit.RegulationID, EditionID: hit.EditionID, NodeID: hit.NodeID,
DocNo: hit.DocNo, Title: hit.Title, EditionLabel: hit.EditionLabel, LegalStatus: hit.LegalStatus,
ClauseNo: hit.ClauseNo, HeadingPath: hit.HeadingPath, Excerpt: hit.Excerpt,
Disclosure: hit.Disclosure, WithheldReason: hit.WithheldReason,
PageStart: hit.PageStart, PageEnd: hit.PageEnd, IsSummary: hit.IsSummary, MatchKinds: hit.MatchKinds,
})
}
return regulationsToolJSON(out)
}
func regulationEvidenceTool(caps hostcaps, rc tools.ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
CorpusRevision string `json:"corpusRevision"`
NodeID string `json:"nodeId"`
Start int `json:"start"`
End int `json:"end"`
}
if len(args) > 0 {
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("%s: bad args: %w", toolRegulationEvidence, err)
}
}
a.CorpusRevision, a.NodeID = strings.TrimSpace(a.CorpusRevision), strings.TrimSpace(a.NodeID)
if a.CorpusRevision == "" || a.NodeID == "" {
return "", fmt.Errorf("%s: corpusRevision and nodeId are required", toolRegulationEvidence)
}
h, err := regulationsToolHost(caps, rc, toolRegulationEvidence)
if err != nil {
return "", err
}
ctx, cancel := regulationsToolContext(rc)
defer cancel()
ev, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{
SchemaVersion: extension.RegulationsSchemaVersion,
CorpusRevision: a.CorpusRevision,
NodeID: a.NodeID,
Start: a.Start,
End: a.End,
})
if err != nil {
return regulationsToolError(toolRegulationEvidence, err)
}
return regulationsToolJSON(ev)
}
// regulationsToolHost binds the audited host to the run owner, who is both the
// owner and the actor of an agent tool call.
func regulationsToolHost(caps hostcaps, rc tools.ToolExecCtx, name string) (inprocHost, error) {
owner := rc.OwnerID()
if owner == "" {
return inprocHost{}, fmt.Errorf("%s: run owner is required", name)
}
return inprocHost{caps: caps, pluginID: regulationsToolCallerID, owner: owner, actor: owner}, nil
}
func regulationsToolContext(rc tools.ToolExecCtx) (context.Context, context.CancelFunc) {
parent := context.Background()
if c, ok := rc.(interface{ Context() context.Context }); ok && c.Context() != nil {
parent = c.Context()
}
return context.WithTimeout(parent, regulationsToolTimeout)
}
func regulationsToolError(name string, err error) (string, error) {
if errors.Is(err, extension.ErrRegulationsUnavailable) {
return regulationsToolJSON(regulationsToolUnavailable{State: extension.RegulationsStateUnavailable,
Reason: "shared regulations knowledge base is not available"})
}
return "", fmt.Errorf("%s: %w", name, err)
}
func regulationsToolJSON(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
+318
View File
@@ -0,0 +1,318 @@
package app
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"agent-system-backend/internal/tools"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
type regulationsToolCtx struct{ owner string }
func (c regulationsToolCtx) ProjectID() string { return "" }
func (c regulationsToolCtx) ConversationID() string { return "" }
func (c regulationsToolCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) {
return nil, nil
}
func (c regulationsToolCtx) WriteArtifact(string, string, string, []byte) (core.Artifact, error) {
return core.Artifact{}, errors.New("read-only")
}
func (c regulationsToolCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil }
func (c regulationsToolCtx) PeerConversationMessages(string, int) ([]core.Message, error) {
return nil, nil
}
func (c regulationsToolCtx) OwnerID() string { return c.owner }
func (c regulationsToolCtx) WorkspaceRoot() string { return "" }
func (c regulationsToolCtx) SettingBool(_ string, d bool) bool { return d }
type recordingRegulationsProvider struct {
*fakeRegulationsProvider
searches []extension.RegulationsSearchRequest
evidences []extension.RegulationEvidenceRequest
}
func (p *recordingRegulationsProvider) SearchRegulations(ctx context.Context, c extension.RegulationsCaller, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) {
p.searches = append(p.searches, req)
return p.fakeRegulationsProvider.SearchRegulations(ctx, c, req)
}
func (p *recordingRegulationsProvider) ResolveRegulationEvidence(ctx context.Context, c extension.RegulationsCaller, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) {
p.evidences = append(p.evidences, req)
return p.fakeRegulationsProvider.ResolveRegulationEvidence(ctx, c, req)
}
func newRegulationsToolsRegistry(t *testing.T, h inprocHost) *tools.Registry {
t.Helper()
reg := tools.NewRegistry()
if err := registerRegulationsTools(reg, h.caps); err != nil {
t.Fatal(err)
}
return reg
}
func registerRecordingRegulations(t *testing.T, h inprocHost) *recordingRegulationsProvider {
t.Helper()
p := &recordingRegulationsProvider{fakeRegulationsProvider: &fakeRegulationsProvider{}}
if err := (pluginRegistry{pluginID: "kbreg", caps: h.caps}).RegisterRegulationsProvider(p); err != nil {
t.Fatal(err)
}
return p
}
func callRegulationsTool(reg *tools.Registry, name, args string) core.ToolCallResult {
return reg.Execute(regulationsToolCtx{owner: "alice"}, core.ToolCallRequest{CallID: "c1", Name: name, Arguments: json.RawMessage(args)})
}
func TestRegulationsToolsRegisteredAsReadOnlyBuiltins(t *testing.T) {
h, _ := newRegulationsTestHost(t)
reg := newRegulationsToolsRegistry(t, h)
for _, name := range []string{toolRegulationsSearch, toolRegulationEvidence} {
desc, _, ok := reg.Get(name)
if !ok || desc.Source != core.SourceBuiltin || !desc.ReadOnly || desc.Edits || desc.Hidden {
t.Fatalf("%s desc = %+v ok=%v", name, desc, ok)
}
var schema struct {
Type string `json:"type"`
Required []string `json:"required"`
Props map[string]any `json:"properties"`
}
if err := json.Unmarshal(desc.Parameters, &schema); err != nil || schema.Type != "object" || len(schema.Required) == 0 {
t.Fatalf("%s schema = %s %v", name, desc.Parameters, err)
}
for _, req := range schema.Required {
if _, ok := schema.Props[req]; !ok {
t.Fatalf("%s required %q not declared", name, req)
}
}
}
}
func TestRegulationsSearchToolMapsHitsAndClampsLimit(t *testing.T) {
h, st := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-3", State: extension.RegulationsStateDegraded,
DegradedReasons: []string{"vector_index_unavailable"},
Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020",
Title: "医用电气设备", EditionLabel: "2020", LegalStatus: "current", ClauseNo: "7.2.1",
HeadingPath: []string{"7", "7.2"}, Excerpt: "条款片段", ExcerptStart: 3, NodeSHA256: "abc",
Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", PageStart: 12, PageEnd: 13,
IsSummary: true, MatchKinds: []string{"docNo", "text"}, Score: 0.9}}}
reg := newRegulationsToolsRegistry(t, h)
res := callRegulationsTool(reg, toolRegulationsSearch,
`{"query":" 电气安全 ","docNo":"GB 9706.1-2020","clauseNo":"7.2.1","kinds":["standard"],"currentOnly":true,"limit":50}`)
if res.Status != "ok" {
t.Fatalf("search result = %+v", res)
}
want := regulationsToolSearchOutput{CorpusRevision: "rev-3", State: extension.RegulationsStateDegraded,
DegradedReasons: []string{"vector_index_unavailable"},
Hits: []regulationsToolHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020",
Title: "医用电气设备", EditionLabel: "2020", LegalStatus: "current", ClauseNo: "7.2.1",
HeadingPath: []string{"7", "7.2"}, Excerpt: "条款片段",
Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", PageStart: 12, PageEnd: 13,
IsSummary: true, MatchKinds: []string{"docNo", "text"}}}}
var got regulationsToolSearchOutput
if err := json.Unmarshal([]byte(res.Output), &got); err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("search output = %s %v", res.Output, err)
}
if strings.Contains(res.Output, "score") || strings.Contains(res.Output, "nodeSha256") {
t.Fatalf("search output leaks unlisted fields: %s", res.Output)
}
wantReq := extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "电气安全", Kinds: []string{"standard"},
CurrentOnly: true, DocNo: "GB 9706.1-2020", ClauseNo: "7.2.1", Limit: regulationsSearchMaxLimit}
if len(p.searches) != 1 || !reflect.DeepEqual(p.searches[0], wantReq) {
t.Fatalf("provider request = %+v", p.searches)
}
wantCaller := extension.RegulationsCaller{PluginID: regulationsToolCallerID, Actor: "alice", Owner: "alice"}
if len(p.callers) != 1 || p.callers[0] != wantCaller {
t.Fatalf("caller = %+v", p.callers)
}
callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q"}`)
callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q","limit":-3}`)
callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q","limit":7}`)
for i, want := range []int{regulationsSearchDefaultLimit, regulationsSearchDefaultLimit, 7} {
if got := p.searches[i+1].Limit; got != want {
t.Fatalf("limit case %d = %d, want %d", i, got, want)
}
}
evs, err := st.ListAuditEvents("alice", 10)
if err != nil || len(evs) != 4 {
t.Fatalf("audit events = %+v %v", evs, err)
}
for _, ev := range evs {
if ev.Action != auditRegulationsSearch || ev.Actor != "plugin:"+regulationsToolCallerID || strings.Contains(ev.Evidence, "电气安全") {
t.Fatalf("search audit = %+v", ev)
}
}
}
func TestRegulationEvidenceToolMapsFields(t *testing.T) {
h, st := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-3", RegulationID: "r1", EditionID: "e1", NodeID: "n1",
ClauseNo: "7.2.1", Excerpt: "原文", Start: 4, End: 10, NodeSHA256: "abc", SourceAssetSHA256: "def",
PageStart: 12, PageEnd: 12, Disclosure: extension.RegulationDisclosureFull, HashVerified: true}
reg := newRegulationsToolsRegistry(t, h)
res := callRegulationsTool(reg, toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":"n1","start":4,"end":10}`)
if res.Status != "ok" {
t.Fatalf("evidence result = %+v", res)
}
var got extension.RegulationEvidence
if err := json.Unmarshal([]byte(res.Output), &got); err != nil || got != p.evidence {
t.Fatalf("evidence output = %s %v", res.Output, err)
}
wantReq := extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-3", NodeID: "n1", Start: 4, End: 10}
if len(p.evidences) != 1 || p.evidences[0] != wantReq {
t.Fatalf("provider request = %+v", p.evidences)
}
if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 1 || evs[0].Action != auditRegulationsEvidence {
t.Fatalf("evidence audit = %+v %v", evs, err)
}
}
func TestRegulationsToolsRejectMissingArgs(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
reg := newRegulationsToolsRegistry(t, h)
for _, c := range []struct{ name, args string }{
{toolRegulationsSearch, `{}`},
{toolRegulationsSearch, `{"query":" "}`},
{toolRegulationEvidence, `{"corpusRevision":"rev-3"}`},
{toolRegulationEvidence, `{"nodeId":"n1"}`},
{toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":" "}`},
} {
if res := callRegulationsTool(reg, c.name, c.args); res.Status != "error" {
t.Fatalf("%s %s accepted: %+v", c.name, c.args, res)
}
}
if len(p.callers) != 0 {
t.Fatal("provider reached with missing arguments")
}
}
func TestRegulationsToolsReportUnavailable(t *testing.T) {
h, st := newRegulationsTestHost(t)
reg := newRegulationsToolsRegistry(t, h)
assertUnavailable := func(label string) {
t.Helper()
for _, c := range []struct{ name, args string }{
{toolRegulationsSearch, `{"query":"q"}`},
{toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":"n1"}`},
} {
res := callRegulationsTool(reg, c.name, c.args)
var out regulationsToolUnavailable
if res.Status != "ok" || json.Unmarshal([]byte(res.Output), &out) != nil ||
out.State != extension.RegulationsStateUnavailable || out.Reason == "" {
t.Fatalf("%s %s = %+v", label, c.name, res)
}
}
}
assertUnavailable("no provider")
if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 0 {
t.Fatalf("unavailable calls audited: %+v %v", evs, err)
}
p := registerRecordingRegulations(t, h)
p.err = extension.ErrRegulationsUnavailable
assertUnavailable("provider unavailable")
p.err = errors.New("provider failure")
if res := callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q"}`); res.Status != "error" {
t.Fatalf("provider failure reported as success: %+v", res)
}
}
type knowledgeHitsCtx struct {
regulationsToolCtx
hits []core.RetrievalHit
}
func (c knowledgeHitsCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) {
return c.hits, nil
}
func newKnowledgeFallbackRegistry(t *testing.T, h inprocHost) *tools.Registry {
t.Helper()
reg := tools.NewRegistry()
if err := reg.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
before, _, _ := reg.Get(toolKnowledgeSearch)
if err := registerRegulationsTools(reg, h.caps); err != nil {
t.Fatal(err)
}
if after, _, _ := reg.Get(toolKnowledgeSearch); !reflect.DeepEqual(after, before) {
t.Fatalf("knowledge_search desc changed: %+v", after)
}
return reg
}
func callKnowledgeSearch(reg *tools.Registry, hits []core.RetrievalHit) core.ToolCallResult {
rc := knowledgeHitsCtx{regulationsToolCtx: regulationsToolCtx{owner: "alice"}, hits: hits}
return reg.Execute(rc, core.ToolCallRequest{CallID: "c1", Name: toolKnowledgeSearch, Arguments: json.RawMessage(`{"query":"电气安全"}`)})
}
func TestKnowledgeSearchFallsBackToRegulationsHits(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK,
Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020",
Excerpt: "法规条款片段", Disclosure: extension.RegulationDisclosureFull}}}
reg := newKnowledgeFallbackRegistry(t, h)
res := callKnowledgeSearch(reg, nil)
if res.Status != "ok" || !strings.HasPrefix(res.Output, knowledgeRegulationsFallbackPrefix) ||
!strings.Contains(res.Output, "法规条款片段") || !strings.Contains(res.Output, "GB 9706.1-2020") {
t.Fatalf("fallback result = %+v", res)
}
if len(p.searches) != 1 || p.searches[0].Query != "电气安全" {
t.Fatalf("regulations request = %+v", p.searches)
}
}
func TestKnowledgeSearchKeepsNoHitsWhenRegulationsEmpty(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK}
reg := newKnowledgeFallbackRegistry(t, h)
if res := callKnowledgeSearch(reg, nil); res.Status != "ok" || res.Output != tools.KnowledgeSearchNoHits {
t.Fatalf("empty regulations result = %+v", res)
}
if len(p.searches) != 1 {
t.Fatalf("regulations not consulted: %+v", p.searches)
}
}
func TestKnowledgeSearchKeepsNoHitsWhenRegulationsUnavailable(t *testing.T) {
h, _ := newRegulationsTestHost(t)
reg := newKnowledgeFallbackRegistry(t, h)
if res := callKnowledgeSearch(reg, nil); res.Status != "ok" || res.Output != tools.KnowledgeSearchNoHits {
t.Fatalf("no provider result = %+v", res)
}
p := registerRecordingRegulations(t, h)
for _, err := range []error{extension.ErrRegulationsUnavailable, errors.New("provider failure")} {
p.err = err
if res := callKnowledgeSearch(reg, nil); res.Status != "ok" || res.Output != tools.KnowledgeSearchNoHits {
t.Fatalf("provider %v result = %+v", err, res)
}
}
}
func TestKnowledgeSearchProjectHitSkipsRegulations(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerRecordingRegulations(t, h)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK,
Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", Excerpt: "法规条款片段"}}}
reg := newKnowledgeFallbackRegistry(t, h)
hits := []core.RetrievalHit{{Doc: core.KnowledgeDoc{Title: "项目说明书", Source: "upload", Chunk: "项目片段"}, Score: 0.8}}
res := callKnowledgeSearch(reg, hits)
if res.Status != "ok" || !strings.Contains(res.Output, "项目片段") ||
strings.Contains(res.Output, knowledgeRegulationsFallbackPrefix) || strings.Contains(res.Output, "法规条款片段") {
t.Fatalf("project hit result = %+v", res)
}
if len(p.searches) != 0 {
t.Fatalf("regulations consulted on project hit: %+v", p.searches)
}
}
+16
View File
@@ -71,3 +71,19 @@ func TestCompiledRegistryRejectsV2(t *testing.T) {
t.Fatal("声明式扩展不得注册资源级授权回调(fail-closed)")
}
}
func TestAuthorizeResourceEnrichesEveryInProcessQueryWithCurrentRolePolicy(t *testing.T) {
snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion,
Owner: "alice", AccountEnabled: true, RoleID: "role_1", RoleEnabled: true, AuthorityEpoch: 7}
a := projectAuthz{enrich: func(q extension.ResourceAuthzQuery) (extension.ResourceAuthzQuery, error) {
q.AuthorityEpoch = snapshot.AuthorityEpoch
q.RolePolicy = &snapshot
return q, nil
}}
a.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
return q.AuthorityEpoch == 7 && q.RolePolicy != nil && q.RolePolicy.Owner == q.Actor, nil
})
if ok, err := a.AuthorizeResource(extension.ResourceAuthzQuery{Actor: "alice", Action: "search", ResourceType: "knowledge", ResourceID: "k1"}); err != nil || !ok {
t.Fatalf("enriched query ok=%v err=%v", ok, err)
}
}
+215
View File
@@ -0,0 +1,215 @@
package app
import (
"context"
"errors"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/role-provider.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
role, err := st.CreateRole("admin", store.RoleCreate{Name: "软件工程师", Description: "负责软件研发"})
if err != nil {
t.Fatal(err)
}
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
[]store.RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "provider-policy")
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "provider-binding"); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "admin", actor: "admin"}
provider, ok := any(h).(extension.RolePolicyProvider)
if !ok {
t.Fatal("dispatch host missing RolePolicyProvider")
}
snapshot, err := provider.RolePolicyForUser(context.Background(), alice.ID)
if err != nil {
t.Fatal(err)
}
byOwner, err := provider.RolePolicyForOwner(context.Background(), "alice")
if err != nil || byOwner.UserID != alice.ID || byOwner.RoleID != role.RoleID {
t.Fatalf("owner policy=%+v err=%v", byOwner, err)
}
if snapshot.SchemaVersion != extension.RolePolicySchemaVersion || snapshot.UserID != alice.ID || !snapshot.AccountEnabled ||
snapshot.RoleID != role.RoleID || snapshot.RoleName != "软件工程师" || snapshot.RoleDescription != "负责软件研发" ||
!snapshot.RoleEnabled || snapshot.RoleVersion != receipt.RoleVersion || snapshot.AuthorityEpoch < receipt.AuthorityEpoch {
t.Fatalf("snapshot=%+v", snapshot)
}
permission := snapshot.Permissions["project.create"]
if len(snapshot.Permissions) != 1 || !permission.Allowed || !permission.CapAllowed {
t.Fatalf("permissions=%+v", snapshot.Permissions)
}
decision, err := provider.CheckRoleAction(context.Background(), alice.ID, "project.create")
if err != nil || !decision.Allowed || !decision.CapAllowed || decision.RoleID != role.RoleID {
t.Fatalf("decision=%+v err=%v", decision, err)
}
legacy, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read")
if err != nil || legacy.Allowed || legacy.Reason != "action-unconfigured" {
t.Fatalf("non-project role action=%+v err=%v", legacy, err)
}
systemProvider, ok := any(h).(extension.SystemActionProvider)
if !ok {
t.Fatal("dispatch host missing SystemActionProvider")
}
systemDecision, err := systemProvider.CheckCurrentSystemAction(context.Background(), "system.permissions.manage")
if err != nil || !systemDecision.Allowed || systemDecision.AuthorityEpoch < 1 {
t.Fatalf("system decision=%+v err=%v", systemDecision, err)
}
updated, err := st.ReplaceRolePermissions("admin", role.RoleID, snapshot.RoleVersion,
[]store.RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}, "provider-policy-update")
if err != nil {
t.Fatal(err)
}
refreshed, err := provider.RolePolicyForUser(context.Background(), alice.ID)
if err != nil || refreshed.AuthorityEpoch != updated.AuthorityEpoch || refreshed.RoleVersion != updated.RoleVersion {
t.Fatalf("refreshed=%+v receipt=%+v err=%v", refreshed, updated, err)
}
if denied := refreshed.Permissions["project.create"]; len(refreshed.Permissions) != 1 || denied.Allowed || denied.CapAllowed {
t.Fatalf("refreshed permissions=%+v", refreshed.Permissions)
}
revoked, err := provider.CheckRoleAction(context.Background(), alice.ID, "project.create")
if err != nil || revoked.Allowed || revoked.CapAllowed {
t.Fatalf("revoked decision=%+v err=%v", revoked, err)
}
}
func TestRoleProviderRejectsUntrustedScopedHost(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/role-provider.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: ""}
provider := any(h).(extension.RolePolicyProvider)
if _, err := provider.RolePolicyForUser(context.Background(), alice.ID); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("empty actor provider: %v", err)
}
}
func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/role-admin.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "admin", actor: "admin"}
admin, ok := any(h).(extension.RolePolicyAdmin)
if !ok {
t.Fatal("dispatch host missing RolePolicyAdmin")
}
role, err := admin.CreateRole(context.Background(), "软件工程师", "负责软件研发")
if err != nil {
t.Fatal(err)
}
if _, err := admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "legacy-action"); !errors.Is(err, store.ErrInvalidRolePolicy) {
t.Fatalf("non-project role action accepted: %v", err)
}
role, err = admin.SetRolePermission(context.Background(), role.RoleID, "project.create", true, true, "project-access")
access := role.Permissions["project.create"]
if err != nil || len(role.Permissions) != 1 || !access.Allowed || !access.CapAllowed {
t.Fatalf("role=%+v err=%v", role, err)
}
binding, err := admin.AssignRoleByOwner(context.Background(), "alice", role.RoleID, "legacy-assign")
if err != nil || binding.RoleID != role.RoleID {
t.Fatalf("binding=%+v err=%v", binding, err)
}
roles, err := admin.ListRoles(context.Background())
if err != nil || len(roles) != 1 || roles[0].BindingCount != 1 {
t.Fatalf("roles=%+v err=%v", roles, err)
}
}
// 非管理员分发主体(项目创建者)只读已启用职位:roleId+name,按 name→roleId 排序,
// 不含权限/绑定/计数;空 actor(scoped host)与已停用账号一律拒绝。
func TestRoleOptionsExposeEnabledRolesToNonAdminActor(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/role-options.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, err := st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
beta, err := st.CreateRole("admin", store.RoleCreate{Name: "Beta 职位", Description: "停用前"})
if err != nil {
t.Fatal(err)
}
alpha, err := st.CreateRole("admin", store.RoleCreate{Name: "Alpha 职位"})
if err != nil {
t.Fatal(err)
}
retired, err := st.CreateRole("admin", store.RoleCreate{Name: "Gamma 职位"})
if err != nil {
t.Fatal(err)
}
disabled := false
if _, err := st.PatchRole("admin", retired.RoleID, retired.RoleVersion, store.RolePatch{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: "alice"}
options, ok := any(h).(extension.RoleOptionsProvider)
if !ok {
t.Fatal("dispatch host missing RoleOptionsProvider")
}
// 管理面仍然拒绝非管理员:只读目录不放大权限。
if _, err := any(h).(extension.RolePolicyAdmin).ListRoles(context.Background()); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("non-admin ListRoles: %v", err)
}
got, err := options.ListRoleOptions(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0] != (extension.RoleOption{RoleID: alpha.RoleID, Name: "Alpha 职位"}) ||
got[1] != (extension.RoleOption{RoleID: beta.RoleID, Name: "Beta 职位"}) {
t.Fatalf("options=%+v", got)
}
scoped := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice"}
if _, err := any(scoped).(extension.RoleOptionsProvider).ListRoleOptions(context.Background()); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("empty actor options: %v", err)
}
if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); err != nil {
t.Fatal(err)
}
if _, err := options.ListRoleOptions(context.Background()); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled actor options: %v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := options.ListRoleOptions(cancelled); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled context: %v", err)
}
}
+181
View File
@@ -0,0 +1,181 @@
package app
// 临时会话的内部命令分发门禁(U2):组合根在命令总线的项目预检入口上拒绝
// “基座项目是临时会话”的正式项目命令,宿主与插件处理器共用这一条分发面。
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func newScratchGateStore(t *testing.T) (*store.Store, core.Project, core.Conversation) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "scratch-gate.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
t.Fatal(err)
}
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
return st, project, conversation
}
// TestScratchCommandHostGateDecidesOwnership 门禁本身:属主的临时会话拒绝,
// 普通项目与无基座项目的分发不受影响;非属主按存储判定(NotFound)。
func TestScratchCommandHostGateDecidesOwnership(t *testing.T) {
st, project, _ := newScratchGateStore(t)
if err := rejectScratchCommandHost(st, cmdbus.Request{
ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(project.ID),
}); !errors.Is(err, ErrScratchCommandHost) {
t.Fatalf("scratch host project accepted a formal command: %v", err)
}
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
if err := rejectScratchCommandHost(st, cmdbus.Request{
ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(normal.ID),
}); err != nil {
t.Fatalf("normal host project refused: %v", err)
}
if err := rejectScratchCommandHost(st, cmdbus.Request{ID: "rdm.reqs.add", Owner: "alice"}); err != nil {
t.Fatalf("hostless dispatch refused: %v", err)
}
// 非属主不能把自己的“属主身份”带进临时会话。
if err := rejectScratchCommandHost(st, cmdbus.Request{
ID: "rdm.reqs.add", Owner: "bob", HostProjectID: string(project.ID),
}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("non-owner scratch dispatch = %v", err)
}
}
// TestScratchCommandHostGateOnBusPreflight 门禁在真实分发面上生效:正式项目命令
// 在临时会话宿主上被拒,普通项目宿主不受影响(失败原因来自后续的会话来源校验,
// 不是临时会话门禁)。
func TestScratchCommandHostGateOnBusPreflight(t *testing.T) {
st, project, conversation := newScratchGateStore(t)
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
normalConv, err := st.CreateConversation("alice", string(normal.ID), "会话", "")
if err != nil {
t.Fatal(err)
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error {
if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" {
return errors.New("formal project command requires an authenticated conversation source")
}
if err := rejectScratchCommandHost(st, req); err != nil {
return err
}
conv, err := st.GetConversation(req.Owner, req.ConversationID)
if err != nil || string(conv.ProjectID) != req.HostProjectID {
return errors.New("formal project command conversation binding changed")
}
return errors.New("source analysis did not commit a proceed decision")
})
if err := bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"},
func(context.Context, cmdbus.Request) (any, error) { return map[string]any{"ok": true}, nil }); err != nil {
t.Fatal(err)
}
scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice",
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
})
if scratchResp.Receipt.State != agentcommand.StateRejected ||
!strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) {
t.Fatalf("scratch host dispatch = %+v (want %s)", scratchResp.Receipt, ErrScratchCommandHost)
}
normalResp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice",
ConversationID: string(normalConv.ID), AnchorMessageID: "msg-1", HostProjectID: string(normal.ID),
})
if normalResp.Receipt.State != agentcommand.StateRejected ||
strings.Contains(normalResp.Receipt.Reason, ErrScratchCommandHost.Error()) {
t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v", normalResp.Receipt)
}
}
// TestScratchCommandHostGateOnBusDispatch 基座项目门禁挂在命令总线上(组合根的
// 真实接线):不经 HTTP 403、也不经正式命令预检——聊天意图路由与 system.intent
// 再分发走的就是这条面——rdm.* 在临时会话宿主上被拒且处理器不会被调用;普通项目
// 宿主与无宿主分发不受这条门禁影响。
func TestScratchCommandHostGateOnBusDispatch(t *testing.T) {
st, project, conversation := newScratchGateStore(t)
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
handled, preflights := 0, 0
bus.SetHostProjectGuard(func(_ context.Context, req cmdbus.Request) error {
return scratchCommandHostGuard(st, req)
})
bus.SetProjectWorkPreflight(func(context.Context, cmdbus.Request) error { preflights++; return nil })
for _, spec := range []agentcommand.CommandSpec{
{ID: "rdm.reqs.add", Title: "登记需求"},
{ID: "demo.ping", Title: "会话命令"},
} {
id := spec.ID
if err := bus.RegisterPluginCommand(spec,
func(context.Context, cmdbus.Request) (any, error) { handled++; return map[string]any{"id": id}, nil }); err != nil {
t.Fatal(err)
}
}
scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
})
if scratchResp.Receipt.State != agentcommand.StateRejected ||
!strings.HasPrefix(scratchResp.Receipt.Reason, "host project: ") ||
!strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) {
t.Fatalf("scratch host dispatch = %+v (want host project: %s)", scratchResp.Receipt, ErrScratchCommandHost)
}
if handled != 0 || preflights != 0 {
t.Fatalf("scratch host reached the command handler %d times / preflight %d times", handled, preflights)
}
// 同一宿主上的会话类命令照旧可用(临时会话不是禁用一切命令)。
if resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "demo.ping", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
}); resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("conversation command on a scratch host = %+v", resp.Receipt)
}
// 正面:普通项目宿主不被这条门禁拒绝;无宿主的分发也不走门禁。
for _, req := range []cmdbus.Request{
{ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: string(normal.ID)},
{ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice"},
} {
if err := scratchCommandHostGuard(st, req); err != nil {
t.Fatalf("host guard refused %+v: %v", req, err)
}
}
resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: string(normal.ID),
})
if resp.Receipt.State != agentcommand.StateCommitted || strings.Contains(resp.Receipt.Reason, ErrScratchCommandHost.Error()) || handled != 2 {
t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v (handler calls %d)", resp.Receipt, handled)
}
}
+111
View File
@@ -0,0 +1,111 @@
package app
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"agent-system-backend/internal/blobstore"
"agent-system-backend/internal/instanceguard"
"agent-system-backend/internal/store"
)
// openServiceStore 打开活动服务使用的存储。
// SQLite 档先取活动服务锁,再打开(打开即迁移),然后核对守卫标记。
// 返回的函数先关存储再放锁。
func openServiceStore(opt Options) (*store.Store, func(), error) {
nop := func() {}
if opt.StorageDSN != "" {
artifacts := opt.ArtifactsDir
if artifacts == "" {
artifacts = filepath.Join(opt.DataDir, "artifacts")
}
// 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享);
// 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。
blobs, berr := blobstore.NewS3FromEnv()
if berr != nil {
return nil, nop, fmt.Errorf("blobstore s3: %w", berr)
}
if blobs != nil {
log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix)
}
st, err := store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs)
if err != nil && opt.Tier == "team" {
// team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。
return nil, nop, fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err)
}
if err != nil {
return nil, nop, fmt.Errorf("open store: %w", err)
}
if err := verifyConfiguredIdentity(st, "pg"); err != nil {
_ = st.Close()
return nil, nop, err
}
return st, func() { _ = st.Close() }, nil
}
lease, err := instanceguard.Acquire(opt.DataDir)
if err != nil {
return nil, nop, fmt.Errorf("instance guard: %w", err)
}
releaseLease := func() { lease.Release() }
if err := lease.AutomaticRefusal(); err != nil {
adoptable := opt.AdoptInstanceGuard && (errors.Is(err, instanceguard.ErrDBReplaced) || errors.Is(err, instanceguard.ErrLockCorrupt))
if !adoptable {
releaseLease()
return nil, nop, fmt.Errorf("instance guard: %w", err)
}
}
st, err := store.OpenSQLite(lease.DBPath())
if err != nil {
releaseLease()
return nil, nop, fmt.Errorf("open store: %w", err)
}
if err := verifyConfiguredIdentity(st, "sqlite"); err != nil {
_ = st.Close()
releaseLease()
return nil, nop, err
}
if err := instanceguard.Bind(st, lease, instanceguard.BindOptions{
Adopt: opt.AdoptInstanceGuard,
InstanceID: strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")),
}); err != nil {
_ = st.Close()
releaseLease()
return nil, nop, fmt.Errorf("instance guard: %w", err)
}
return st, func() {
_ = st.Close()
lease.Release()
}, nil
}
// verifyConfiguredIdentity U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
func verifyConfiguredIdentity(st *store.Store, defaultRef string) error {
sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID"))
if sysID == "" {
return nil
}
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
if dbref == "" {
dbref = defaultRef
}
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
return fmt.Errorf("system identity: %w", err)
}
return nil
}
// Migrate 执行显式迁移。SQLite 必须先取得活动服务锁,有服务占用则拒绝。
func Migrate(opt Options) error {
_, release, err := openServiceStore(opt)
if err != nil {
return err
}
release()
return nil
}
+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
}
}
+58
View File
@@ -0,0 +1,58 @@
package app
import (
"errors"
"fmt"
"log"
"agent-system-backend/internal/config"
"agent-system-backend/internal/server"
"agent-system-backend/internal/store"
)
// ErrCLIAdminAppointment rejects every local CLI attempt to create an
// administrator (R4, R5): only the server-side super administrator appoints.
var ErrCLIAdminAppointment = errors.New("administrator appointment is reserved to the super administrator; no CLI path may create an administrator")
// ErrCLIBootstrapAdminDisabled rejects the retired local bootstrap-admin path:
// only service startup establishes the super administrator (KTD4).
var ErrCLIBootstrapAdminDisabled = errors.New("bootstrap-admin is disabled; the super administrator is established at service startup from SUPER_ADMIN_OWNER and SUPER_ADMIN_PASSWORD")
// ValidateSuperAdmin is the startup gate for R1: with authentication enabled,
// missing, partial or policy-violating configuration refuses startup. The
// unauthenticated embedded mode neither requires nor uses the credentials.
func ValidateSuperAdmin(authOn bool, sa config.SuperAdmin) error {
if !authOn {
return nil
}
if err := sa.Validate(server.ValidatePassword); err != nil {
return fmt.Errorf("super admin: %w", err)
}
return nil
}
// BootstrapSuperAdmin establishes the configured super administrator in
// persistent account state (KTD4). Identity conflicts stop startup; logs carry
// the owner only, never the password or its hash.
func BootstrapSuperAdmin(st *store.Store, authOn bool, sa config.SuperAdmin) error {
if !authOn {
return nil
}
if err := ValidateSuperAdmin(authOn, sa); err != nil {
return err
}
hash, err := store.HashPassword(sa.Password())
if err != nil {
return fmt.Errorf("super admin: hash password: %w", err)
}
out, err := st.BootstrapSuperAdmin(sa.Owner, hash)
if err != nil {
return fmt.Errorf("super admin %q: %w", sa.Owner, err)
}
if out.Created {
log.Printf("super admin: established account %q", sa.Owner)
} else {
log.Printf("super admin: account %q already established", sa.Owner)
}
return nil
}
+129
View File
@@ -0,0 +1,129 @@
package app
import (
"bytes"
"errors"
"log"
"os"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/config"
"agent-system-backend/internal/store"
)
const appSuperAdminPassword = "Sup3r-Secret-Value"
func openSuperAdminStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func captureLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
prev, flags := log.Writer(), log.Flags()
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) })
return &buf
}
func TestValidateSuperAdminStartupGate(t *testing.T) {
cases := []struct {
name string
sa config.SuperAdmin
}{
{"both absent", config.SuperAdmin{}},
{"owner only", config.NewSuperAdmin("root", "")},
{"password only", config.NewSuperAdmin("", appSuperAdminPassword)},
{"invalid password", config.NewSuperAdmin("root", "short")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateSuperAdmin(true, tc.sa)
if !errors.Is(err, config.ErrSuperAdminConfig) {
t.Fatalf("authentication-enabled startup accepted %s: %v", tc.name, err)
}
if err := ValidateSuperAdmin(false, tc.sa); err != nil {
t.Fatalf("unauthenticated mode must not require credentials: %v", err)
}
})
}
if err := ValidateSuperAdmin(true, config.NewSuperAdmin("root", appSuperAdminPassword)); err != nil {
t.Fatal(err)
}
}
func TestBootstrapSuperAdminStartup(t *testing.T) {
st := openSuperAdminStore(t)
logs := captureLog(t)
sa := config.NewSuperAdmin("root", appSuperAdminPassword)
for i := 0; i < 2; i++ {
if err := BootstrapSuperAdmin(st, true, sa); err != nil {
t.Fatalf("bootstrap %d: %v", i, err)
}
}
account, err := st.GetAccountByOwner("root")
if err != nil {
t.Fatal(err)
}
if !account.IsSuperAdmin || !account.IsAdmin {
t.Fatalf("startup did not establish super admin: %+v", account)
}
if strings.Contains(logs.String(), appSuperAdminPassword) || strings.Contains(logs.String(), "argon2") {
t.Fatalf("startup log leaks credential material: %s", logs.String())
}
if !strings.Contains(logs.String(), "established account") || !strings.Contains(logs.String(), "already established") {
t.Fatalf("startup log lacks bootstrap outcome: %s", logs.String())
}
}
func TestBootstrapSuperAdminStartupFailsClosed(t *testing.T) {
st := openSuperAdminStore(t)
if err := st.CreateAccount("root", "", "", false); err != nil {
t.Fatal(err)
}
err := BootstrapSuperAdmin(st, true, config.NewSuperAdmin("root", appSuperAdminPassword))
if !errors.Is(err, store.ErrSuperAdminConflict) {
t.Fatalf("conflicting owner did not stop startup: %v", err)
}
if strings.Contains(err.Error(), appSuperAdminPassword) {
t.Fatalf("startup error leaks password: %v", err)
}
if err := BootstrapSuperAdmin(st, true, config.NewSuperAdmin("root", "short")); !errors.Is(err, config.ErrSuperAdminConfig) {
t.Fatalf("invalid password reached storage: %v", err)
}
}
func TestBootstrapSuperAdminSkippedWithoutAuthentication(t *testing.T) {
st := openSuperAdminStore(t)
if err := BootstrapSuperAdmin(st, false, config.SuperAdmin{}); err != nil {
t.Fatal(err)
}
users, err := st.ListUsersAdmin()
if err != nil {
t.Fatal(err)
}
if len(users) != 0 {
t.Fatalf("unauthenticated mode created accounts: %d", len(users))
}
}
func TestCLIAccountPathsCannotAppointAdministrators(t *testing.T) {
dir := t.TempDir()
if _, _, err := CreateUserAccount("", dir, "mallory", "", "", true, true, false); !errors.Is(err, ErrCLIAdminAppointment) {
t.Fatalf("CLI create --admin: %v", err)
}
if _, err := BootstrapAdmin("", dir, "mallory", false); !errors.Is(err, ErrCLIBootstrapAdminDisabled) {
t.Fatalf("CLI bootstrap-admin: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "agent-system.db")); !os.IsNotExist(err) {
t.Fatalf("rejected CLI paths touched storage: %v", err)
}
}
+115
View File
@@ -0,0 +1,115 @@
package app
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
func TestSurfaceButtonDispatchUsesAuthenticatedActor(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice at action"); 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)
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Now().UTC() })
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.submit", Title: "Submit"}, "", func(context.Context, cmdbus.Request) (any, error) { return "ok", nil }); err != nil {
t.Fatal(err)
}
result, err := dispatchSurfaceAction(context.Background(), bus, surfacesvc.ActionDispatch{CommandID: "demo.submit", Owner: "alice", ConversationID: string(c.ID)})
if err != nil {
t.Fatal(err)
}
if result.State != "committed" {
t.Fatalf("state: %s", result.State)
}
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Envelope.Actor != "alice" || rows[0].ActorName != "Alice at action" {
t.Fatalf("button audit actor: %+v", rows)
}
}
func TestCompiledHandlerReceivesHostOperationID(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
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)
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Now().UTC() })
slot := &projectAuditSlot{}
caps := hostcaps{st: st, projectAudit: slot}
reg := pluginRegistry{compiledRegistry: compiledRegistry{bus: bus}, pluginID: "rdm", caps: caps}
var seen string
if err := reg.AddCommandHandler(json.RawMessage(`{"id":"rdm.confirm","title":"Confirm"}`), func(_ context.Context, _ extension.Host, req extension.Request) (any, error) {
seen = req.OperationID
return "domain-committed", nil
}); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice", ConversationID: string(c.ID)})
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
if seen == "" || resp.Receipt.OperationID != seen || len(rows) != 1 || rows[0].Envelope.OperationID != seen {
t.Fatalf("handler/domain correlation: seen=%q receipt=%+v rows=%+v", seen, resp.Receipt, rows)
}
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, project core.ID) (bool, error) { return actor == "bob" && project == p.ID, nil }); err != nil {
t.Fatal(err)
}
publisherFactory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) {
return proof.Owner == "alice" && proof.ProjectID == p.ID && proof.OperationID == seen && proof.SourceID == "domain-commit-1" && proof.ResourceID == "doc-1", nil
})
if err != nil {
t.Fatal(err)
}
if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, r extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) {
return extension.ProjectAuditDisclosure{Metadata: actor == "bob" && r.ResourceID == "doc-1"}, nil
}); err != nil {
t.Fatal(err)
}
publisher, err := publisherFactory.ForOwner("alice")
if err != nil {
t.Fatal(err)
}
linked, err := publisher.PublishProjectAuditLink(context.Background(), extension.ProjectAuditLinkRequest{ProjectID: p.ID, OperationID: seen, SourceKind: "domain-commit", SourceID: "domain-commit-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"})
if err != nil || linked.HostAuditOffset != rows[0].Offset {
t.Fatalf("publish by trusted operation: %+v %v", linked, err)
}
bob := inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "rdm"}
page, err := bob.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: p.ID, LinkedSourceKind: "domain-commit", LinkedSourceID: "domain-commit-1", LinkedHostOffset: linked.HostAuditOffset})
if err != nil || len(page.Receipts) != 1 || page.Receipts[0].OperationID != seen {
t.Fatalf("linked read: %+v %v", page, err)
}
}
+29
View File
@@ -0,0 +1,29 @@
package app
import (
"context"
"fmt"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/surfacesvc"
)
// dispatchSurfaceAction receives Owner from SurfaceService's authenticated
// caller. The button is acting for that user, so both Actor and Owner must be
// populated before the command bus writes its audit envelope.
func dispatchSurfaceAction(ctx context.Context, bus *cmdbus.Bus, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
if cmdbus.RequiresProjectWorkDecision(d.CommandID) {
return surfacesvc.ActionOutcome{}, fmt.Errorf("project formal action requires a committed conversation source preflight")
}
resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params,
Entrance: "button", Actor: d.Owner, Owner: d.Owner, ConversationID: d.ConversationID})
state := "committed"
if resp.Receipt.State != "" {
state = string(resp.Receipt.State)
}
cardID := ""
if resp.Card != nil {
cardID = string(resp.Card.ID)
}
return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
}
+96
View File
@@ -0,0 +1,96 @@
package app
import (
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func TestTaskStartupReconcilesQueueBeforeServing(t *testing.T) {
if os.Getenv("TASK_STARTUP_CHILD") == "1" {
if err := Run(Options{Addr: os.Getenv("TASK_STARTUP_ADDR"), DataDir: os.Getenv("TASK_STARTUP_DATA"), PluginsDir: filepath.Join(os.Getenv("TASK_STARTUP_DATA"), "plugins"), ForceMock: true, InstanceID: "startup-test", DisableExecTools: true, DisableConvCardMirror: true}); err != nil {
t.Fatal(err)
}
return
}
data := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(data, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
p, err := st.CreateProject(store.OwnerDefault, "startup")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation(store.OwnerDefault, p.ID, "startup", "")
if err != nil {
t.Fatal(err)
}
card := core.ActivityCard{ID: "card-startup", ProjectID: p.ID, ConversationID: conv.ID, Kind: core.CardKindProgress, Status: core.CardActive, Title: "整理"}
card.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "task-startup", AttemptID: "attempt-startup", CardID: card.ID, ProjectID: p.ID, ConversationID: conv.ID, OwnerInstanceID: "startup-test", Revision: 1, StreamScope: "conversation:" + conv.ID, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: "整理"}
if _, err = st.CommitTaskProjection(store.TaskProjectionWrite{Owner: conv.Owner, Card: card}); err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
ln.Close()
cmd := exec.Command(os.Args[0], "-test.run=^TestTaskStartupReconcilesQueueBeforeServing$")
cmd.Env = append(os.Environ(), "TASK_STARTUP_CHILD=1", "TASK_STARTUP_ADDR="+addr, "TASK_STARTUP_DATA="+data)
logfile, err := os.Create(filepath.Join(t.TempDir(), "startup.log"))
if err != nil {
t.Fatal(err)
}
defer logfile.Close()
cmd.Stdout, cmd.Stderr = logfile, logfile
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = cmd.Process.Signal(syscall.SIGTERM)
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-done:
case <-time.After(5 * time.Second):
_ = cmd.Process.Kill()
<-done
}
})
client := &http.Client{Timeout: time.Second}
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
resp, err := client.Get("http://" + addr + "/api/conversations/" + conv.ID + "/cards")
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
got, err := st.GetCard(conv.Owner, card.ID)
if err != nil || got.Task == nil || got.Task.ExecutionState != "interrupted" || got.Task.RunID != "" {
t.Fatalf("service became ready with orphan queue: %+v, %v", got.Task, err)
}
if got.Task.Conclusion != "排队任务未恢复,未开始执行" {
t.Fatal(got.Task.Conclusion)
}
projectEvents, err := st.EventsAfter("project:"+p.ID, 0, 100)
if err != nil || len(projectEvents) != 0 {
t.Fatalf("recovery ignored disabled card mirroring: %v %v", projectEvents, err)
}
return
}
}
time.Sleep(20 * time.Millisecond)
}
b, _ := os.ReadFile(logfile.Name())
t.Fatalf("startup did not become ready: %s", b)
}
+23
View File
@@ -0,0 +1,23 @@
package app
import (
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
)
const tavilySecretService = "tools.tavily"
type activeSecretSource interface {
ActiveSecret(owner, service string) (string, bool)
}
// tavilyKeyPool 解析 Tavily 密钥池:默认属主受管密钥 → 系统行受管密钥
// (owner=settings.OwnerSystemRow)→ 设置明文 tools.tavily.apiKeys。
func tavilyKeyPool(secrets activeSecretSource, plaintext string) []string {
for _, owner := range []string{store.OwnerDefault, settings.OwnerSystemRow} {
if active, ok := secrets.ActiveSecret(owner, tavilySecretService); ok {
return splitKeyPool(active)
}
}
return splitKeyPool(plaintext)
}
+53
View File
@@ -0,0 +1,53 @@
package app
import (
"path/filepath"
"reflect"
"testing"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
)
func newTavilyKeysStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "tavily.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestTavilyKeyPoolFallsBackToSystemRowSecret(t *testing.T) {
st := newTavilyKeysStore(t)
if _, err := st.AddSecret(settings.OwnerSystemRow, tavilySecretService, "system", "fake-sys-a, fake-sys-b"); err != nil {
t.Fatal(err)
}
if got, want := tavilyKeyPool(st, "fake-plain"), []string{"fake-sys-a", "fake-sys-b"}; !reflect.DeepEqual(got, want) {
t.Fatalf("system-row key pool = %v, want %v", got, want)
}
}
func TestTavilyKeyPoolPrefersOwnerRowSecret(t *testing.T) {
st := newTavilyKeysStore(t)
if _, err := st.AddSecret(settings.OwnerSystemRow, tavilySecretService, "system", "fake-sys"); err != nil {
t.Fatal(err)
}
if _, err := st.AddSecret(store.OwnerDefault, tavilySecretService, "owner", "fake-owner"); err != nil {
t.Fatal(err)
}
if got, want := tavilyKeyPool(st, "fake-plain"), []string{"fake-owner"}; !reflect.DeepEqual(got, want) {
t.Fatalf("owner-row key pool = %v, want %v", got, want)
}
}
func TestTavilyKeyPoolFallsBackToPlaintextSetting(t *testing.T) {
st := newTavilyKeysStore(t)
if got, want := tavilyKeyPool(st, " fake-plain-a ,, fake-plain-b"), []string{"fake-plain-a", "fake-plain-b"}; !reflect.DeepEqual(got, want) {
t.Fatalf("plaintext key pool = %v, want %v", got, want)
}
if got := tavilyKeyPool(st, ""); len(got) != 0 {
t.Fatalf("empty key pool = %v", got)
}
}
+98
View File
@@ -0,0 +1,98 @@
// Package catalog owns the trusted action catalog used by system, role, and
// project authorization. Callers cannot invent an action or its scope.
package catalog
type Scope string
const (
ScopeSystem Scope = "system"
ScopeProject Scope = "project"
)
// ProjectAccessAction is the only action a position role carries (R2). Every
// other catalog action comes from system grants or project membership; role
// rows naming them are retained for audit but never authorize anything.
const ProjectAccessAction = "project.create"
type Descriptor struct {
ActionID string `json:"actionId"`
Label string `json:"label"`
Description string `json:"description"`
Group string `json:"group"`
Scope Scope `json:"scope"`
Protected bool `json:"protected"`
}
var descriptors = []Descriptor{
{ActionID: "system.settings.read", Label: "查看系统设置", Description: "查看系统范围的受管配置", Group: "系统设置", Scope: ScopeSystem, Protected: true},
{ActionID: "system.settings.write", Label: "修改系统设置", Description: "修改系统范围的受管配置", Group: "系统设置", Scope: ScopeSystem, Protected: true},
{ActionID: "system.users.view", Label: "查看用户", Description: "查看账号目录及获准资料", Group: "用户管理", Scope: ScopeSystem, Protected: true},
{ActionID: "system.users.create", Label: "创建用户", Description: "创建普通账号", Group: "用户管理", Scope: ScopeSystem, Protected: true},
{ActionID: "system.users.edit", Label: "编辑用户", Description: "编辑账号的非凭据资料", Group: "用户管理", Scope: ScopeSystem, Protected: true},
{ActionID: "system.users.disable", Label: "停用用户", Description: "停用或重新启用账号", Group: "用户管理", Scope: ScopeSystem, Protected: true},
{ActionID: "system.roles.view", Label: "查看角色", Description: "查看职位角色及绑定人数", Group: "角色管理", Scope: ScopeSystem, Protected: true},
{ActionID: "system.roles.manage", Label: "管理角色", Description: "创建、编辑和停用职位角色", Group: "角色管理", Scope: ScopeSystem, Protected: true},
{ActionID: "system.roles.assign", Label: "分配角色", Description: "替换用户唯一生效职位角色", Group: "角色管理", Scope: ScopeSystem, Protected: true},
{ActionID: "system.permissions.view", Label: "查看系统权限", Description: "查看系统授权、角色默认与上限", Group: "权限管理", Scope: ScopeSystem, Protected: true},
{ActionID: "system.permissions.manage", Label: "管理系统权限", Description: "配置明确系统授权与平台上限", Group: "权限管理", Scope: ScopeSystem, Protected: true},
{ActionID: "project.create", Label: "创建项目", Description: "创建新的项目", Group: "项目入口", Scope: ScopeSystem},
{ActionID: "project.settings", Label: "项目设置", Description: "修改本项目设置", Group: "项目管理", Scope: ScopeProject},
{ActionID: "project.archive", Label: "归档项目", Description: "归档或恢复本人创建的项目", Group: "项目管理", Scope: ScopeProject},
{ActionID: "project.stage.advance", Label: "推进项目阶段", Description: "推进本人创建项目的阶段", Group: "项目管理", Scope: ScopeProject},
{ActionID: "project.members.manage", Label: "管理项目成员", Description: "维护本项目成员", Group: "项目管理", Scope: ScopeProject},
{ActionID: "project.permissions.manage", Label: "管理项目权限", Description: "维护本项目成员权限覆盖", Group: "项目管理", Scope: ScopeProject},
{ActionID: "document.duties.assign", Label: "分配文档三权", Description: "分配本项目文档编制、审核和批准职责", Group: "文档职责", Scope: ScopeProject},
{ActionID: "document.read", Label: "读取文档", Description: "读取获准的项目文档", Group: "文档动作", Scope: ScopeProject},
{ActionID: "document.author", Label: "编制文档", Description: "履行已分配的文档编制职责", Group: "文档动作", Scope: ScopeProject},
{ActionID: "document.review", Label: "审核文档", Description: "履行已分配的文档审核职责", Group: "文档动作", Scope: ScopeProject},
{ActionID: "document.approve", Label: "批准文档", Description: "履行已分配的文档批准职责", Group: "文档动作", Scope: ScopeProject},
{ActionID: "document.publish", Label: "发布文档", Description: "发布已完成审签的项目文档", Group: "文档动作", Scope: ScopeProject},
{ActionID: "trace.edit", Label: "编辑追溯网", Description: "编辑本项目追溯关系", Group: "项目内容", Scope: ScopeProject},
{ActionID: "knowledge.publish", Label: "发布项目知识", Description: "发布或撤销本项目知识", Group: "项目内容", Scope: ScopeProject},
{ActionID: "project.audit.read", Label: "读取业务审计", Description: "读取获准项目的业务审计", Group: "项目内容", Scope: ScopeProject},
}
var descriptorByID = func() map[string]Descriptor {
out := make(map[string]Descriptor, len(descriptors))
for _, descriptor := range descriptors {
out[descriptor.ActionID] = descriptor
}
return out
}()
func DescriptorByID(id string) (Descriptor, bool) {
descriptor, ok := descriptorByID[id]
return descriptor, ok
}
func Catalog() []Descriptor {
out := make([]Descriptor, len(descriptors))
copy(out, descriptors)
return out
}
// ProjectCreateAction is the system action whose holder manages the projects
// they create. Enabling it links ProjectCreateLinkedActions for those projects.
const ProjectCreateAction = "project.create"
var projectCreateLinkedActions = []string{
"project.settings",
"project.members.manage",
"project.permissions.manage",
"document.duties.assign",
}
// ProjectCreateLinkedActions returns the own-project management capabilities
// that a project creator receives together with project.create.
func ProjectCreateLinkedActions() []string {
return append([]string(nil), projectCreateLinkedActions...)
}
func IsProjectCreateLinkedAction(id string) bool {
for _, linked := range projectCreateLinkedActions {
if linked == id {
return true
}
}
return false
}
+72
View File
@@ -0,0 +1,72 @@
package catalog
import "testing"
func TestCatalogRejectsUnknownAndProtectsManagementActions(t *testing.T) {
if _, ok := DescriptorByID("invented.action"); ok {
t.Fatal("unknown action registered")
}
got, ok := DescriptorByID("system.roles.manage")
if !ok || got.Scope != ScopeSystem || !got.Protected {
t.Fatalf("descriptor=%+v", got)
}
create, ok := DescriptorByID("project.create")
if !ok || create.Scope != ScopeSystem || create.Protected {
t.Fatalf("project.create=%+v", create)
}
}
func TestCatalogProjectAccessIsTheOnlyRoleAction(t *testing.T) {
access, ok := DescriptorByID(ProjectAccessAction)
if !ok || access.ActionID != "project.create" || access.Scope != ScopeSystem || access.Protected {
t.Fatalf("project access descriptor=%+v ok=%v", access, ok)
}
}
func TestProjectCreateLinkedActionsAreCatalogedProjectActions(t *testing.T) {
linked := ProjectCreateLinkedActions()
if len(linked) != 4 {
t.Fatalf("linked=%v", linked)
}
for _, actionID := range linked {
descriptor, ok := DescriptorByID(actionID)
if !ok || descriptor.Scope != ScopeProject || descriptor.Protected || !IsProjectCreateLinkedAction(actionID) {
t.Fatalf("linked action %s descriptor=%+v ok=%v", actionID, descriptor, ok)
}
}
if IsProjectCreateLinkedAction(ProjectCreateAction) || IsProjectCreateLinkedAction("document.read") {
t.Fatal("unlinked action reported as linked")
}
linked[0] = "mutated"
if ProjectCreateLinkedActions()[0] == "mutated" {
t.Fatal("linked actions exposed mutable storage")
}
}
func TestCatalogReturnsIndependentCopies(t *testing.T) {
first := Catalog()
if len(first) == 0 {
t.Fatal("empty catalog")
}
first[0].Label = "mutated"
second := Catalog()
if second[0].Label == "mutated" {
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")
}
}
}
+114
View File
@@ -0,0 +1,114 @@
package authz
import (
"agent-system-backend/internal/authz/catalog"
"agent-system-backend/internal/store"
)
type Descriptor = catalog.Descriptor
type Scope = catalog.Scope
const (
ScopeSystem = catalog.ScopeSystem
ScopeProject = catalog.ScopeProject
)
func Catalog() []Descriptor { return catalog.Catalog() }
type Decision struct {
Allowed bool `json:"allowed"`
ActionID string `json:"actionId"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
AuthorityEpoch int64 `json:"authorityEpoch"`
}
type Service struct {
store *store.Store
}
func NewService(st *store.Store) *Service { return &Service{store: st} }
func (s *Service) AuthorizeSystem(actor, actionID string) Decision {
decision := Decision{ActionID: actionID}
descriptor, ok := catalog.DescriptorByID(actionID)
if !ok || descriptor.Scope != catalog.ScopeSystem || s == nil || s.store == nil {
decision.Reason = "unknown-system-action"
return decision
}
epoch, err := s.store.AuthorityEpoch()
if err != nil {
decision.Reason = "authority-unavailable"
return decision
}
decision.AuthorityEpoch = epoch
account, err := s.store.GetAccountByOwner(actor)
if err != nil || !account.Enabled {
decision.Reason = "account-unavailable"
return decision
}
// Project access comes only from the actor's role (R2): neither is_admin
// nor a system grant confers it.
if actionID == catalog.ProjectAccessAction {
roleAllowed, _, err := s.store.RolePermissionAllowed(actor, actionID)
if err != nil {
decision.Reason = "authority-unavailable"
return decision
}
if roleAllowed {
decision.Allowed, decision.Source = true, "role"
return decision
}
decision.Reason = "not-granted"
return decision
}
// is_admin remains the narrow bootstrap/root recovery source. Ordinary
// delegated management uses explicit system_grants.
if account.IsAdmin {
decision.Allowed, decision.Source = true, "bootstrap-admin"
return decision
}
allowed, err := s.store.SystemGrantAllowed(actor, actionID)
if err != nil {
decision.Reason = "authority-unavailable"
return decision
}
if allowed {
decision.Allowed, decision.Source = true, "system-grant"
return decision
}
decision.Reason = "not-granted"
return decision
}
// EffectiveLinkedCapabilities returns the own-project management actions that
// the actor's project.create links. HTTP and command readers share this call
// so the link is computed once per actor from the Backend authority.
func (s *Service) EffectiveLinkedCapabilities(actor string) (store.CapabilityAssociation, error) {
if s == nil || s.store == nil {
return store.CapabilityAssociation{LinkedActions: []string{}}, store.ErrAccountForbidden
}
return s.store.LinkedCapabilities(actor)
}
// ExplainCapabilities explains only the actor's own creator, member and
// document-duty capabilities; HTTP and Host providers share this call.
func (s *Service) ExplainCapabilities(actor string) (store.CapabilityExplanation, error) {
if s == nil || s.store == nil {
return store.CapabilityExplanation{}, store.ErrAccountForbidden
}
return s.store.ExplainCapabilities(actor)
}
func (s *Service) EffectiveSystemActions(actor string) []string {
actions := []string{}
for _, descriptor := range catalog.Catalog() {
if descriptor.Scope != catalog.ScopeSystem {
continue
}
if decision := s.AuthorizeSystem(actor, descriptor.ActionID); decision.Allowed {
actions = append(actions, descriptor.ActionID)
}
}
return actions
}
+30
View File
@@ -32,6 +32,7 @@ func (s *Service) ResolveByRefAccountChecked(actor, ref, actionID, answer string
return nil
}
card.Status = core.CardResolved
recordAnswer(&card, actionID, answer)
if err := s.UpdateVersionedAccountChecked(actor, card, version, allowImplicitDefault); errors.Is(err, store.ErrConflict) {
return nil
} else if err != nil {
@@ -51,6 +52,9 @@ func (s *Service) AppendAccountChecked(actor, id, text string, allowImplicitDefa
if err != nil {
return err
}
if card.Task != nil {
return ErrNoAction
}
if text != "" {
if n := len(card.Body); n > 0 && card.Body[n-1].Type == core.UIMarkdown {
card.Body[n-1].Markdown += text
@@ -101,6 +105,14 @@ func (s *Service) CreateAccountChecked(actor string, card core.ActivityCard, ref
if err := s.requireAccountCheckedEmitter(); err != nil {
return card, err
}
if card.Task != nil {
return card, store.ErrConflict
}
if card.ID != "" {
if existing, err := s.st.GetCard(actor, card.ID); err == nil && existing.Task != nil {
return card, store.ErrConflict
}
}
if card.ID == "" {
card.ID = store.NewID("card")
}
@@ -119,6 +131,20 @@ func (s *Service) updateVersionedAccountChecked(actor string, card core.Activity
if err := s.requireAccountCheckedEmitter(); err != nil {
return card, err
}
if card.Task == nil {
if current, err := s.st.GetCard(actor, card.ID); err == nil && current.Task != nil {
return card, store.ErrConflict
}
}
if card.Task != nil {
// Task cards keep their revision-checked projection and its in-transaction
// card events; the account fence covers that same transaction.
if err := s.commitProjection(store.TaskProjectionWrite{Owner: actor, Card: card, ExpectedCardVersion: expectedVersion,
ExpectedTaskRevision: card.Task.Revision, Channels: s.taskChannels(card), AccountActor: actor, AllowImplicitDefault: allowImplicitDefault}); err != nil {
return card, err
}
return s.Get(actor, card.ID)
}
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", expectedVersion, allowImplicitDefault); err != nil {
return card, err
@@ -157,6 +183,9 @@ func (s *Service) SetStatusAccountChecked(actor, id string, status core.CardStat
if err != nil {
return card, err
}
if card.Task != nil {
return card, nil
}
return card, s.emitCardAccountChecked(actor, card, core.EvCardStatus,
core.CardStatusPayload{CardID: card.ID, Status: status, Tone: card.Tone, Note: note})
}
@@ -188,6 +217,7 @@ func (s *Service) ActionAccountChecked(actor, id, actionID, freeText string, all
}
}
card.Status = core.CardResolved
recordAnswer(&card, actionID, freeText)
card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault)
if err != nil {
return card, "", err
+71 -3
View File
@@ -33,6 +33,7 @@ var (
const cardAppendWindow = 250 * time.Millisecond
type Service struct {
TaskCommitter func(store.TaskProjectionWrite) ([]core.StreamEvent, error)
st *store.Store
emit Emitter
accountCheckedEmit AccountCheckedEmitter
@@ -119,6 +120,14 @@ func (s *Service) emitCard(c core.ActivityCard, typ core.StreamEventType, payloa
// (HITL 投影卡 = "hitl:<id>",to_project 留档副本 = "to_project:<原卡ID>");
// ConversationID 为空的项目级卡发布到项目流。
func (s *Service) Create(owner string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
if card.Task != nil {
return card, store.ErrConflict
}
if card.ID != "" {
if existing, err := s.st.GetCard(owner, card.ID); err == nil && existing.Task != nil {
return card, store.ErrConflict
}
}
if card.ID == "" {
card.ID = store.NewID("card")
}
@@ -135,16 +144,53 @@ func (s *Service) Create(owner string, card core.ActivityCard, ref string) (core
}
// Get 读取单卡。
func (s *Service) Get(owner, id string) (core.ActivityCard, error) { return s.st.GetCard(owner, id) }
func (s *Service) Get(owner, id string) (core.ActivityCard, error) {
c, err := s.st.GetCard(owner, id)
if err == nil {
c = s.present(owner, c)
}
return c, err
}
// List 按会话列出(updated_at 降序;置顶与待办排序是前端关注点)。
func (s *Service) List(owner, conversationID string) ([]core.ActivityCard, error) {
return s.st.ListCards(owner, conversationID)
cards, err := s.st.ListCards(owner, conversationID)
if err == nil {
cards = s.PresentCards(owner, cards)
}
return cards, err
}
// PresentCards applies the read-time projections to cards read elsewhere
// (for example a cursor snapshot), in place.
func (s *Service) PresentCards(owner string, cards []core.ActivityCard) []core.ActivityCard {
for i := range cards {
cards[i] = s.present(owner, cards[i])
}
return cards
}
// present applies every read-time projection: resolved HITL answers and
// task source visibility.
func (s *Service) present(owner string, c core.ActivityCard) core.ActivityCard {
return s.VisibleTaskCard(owner, s.presentResolved(owner, c))
}
// Update 保存整卡变更(流式 Body 增量等)并发布 card.updated;ref 保持原值。
// 系统镜像路径(同会话 run 的顺序行为)沿用盲写;用户态裁决走 UpdateVersioned。
func (s *Service) Update(owner string, card core.ActivityCard) error {
if card.Task == nil {
if old, e := s.st.GetCard(owner, card.ID); e == nil && old.Task != nil {
return store.ErrConflict
}
}
if card.Task != nil {
_, ver, err := s.st.GetCardVersion(owner, card.ID)
if err != nil {
return err
}
return s.UpdateVersioned(owner, card, ver)
}
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCard(owner, &card, ""); err != nil {
return err
@@ -155,6 +201,14 @@ func (s *Service) Update(owner string, card core.ActivityCard) error {
// UpdateVersioned 乐观并发保存(U3,KTD7):版本不匹配透传 store.ErrConflict,
// HTTP 层映射 409;客户端重读卡后重试。
func (s *Service) UpdateVersioned(owner string, card core.ActivityCard, expectedVersion int) error {
if card.Task == nil {
if current, err := s.st.GetCard(owner, card.ID); err == nil && current.Task != nil {
return store.ErrConflict
}
}
if card.Task != nil {
return s.commitProjection(store.TaskProjectionWrite{Owner: owner, Card: card, ExpectedCardVersion: expectedVersion, ExpectedTaskRevision: card.Task.Revision, Channels: s.taskChannels(card)})
}
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCardVersioned(owner, &card, "", expectedVersion); err != nil {
return err
@@ -171,6 +225,9 @@ func (s *Service) Append(owner, id, text string) error {
if err != nil {
return err
}
if c.Task != nil {
return ErrNoAction
}
if text != "" {
if n := len(c.Body); n > 0 && c.Body[n-1].Type == core.UIMarkdown {
c.Body[n-1].Markdown += text
@@ -211,6 +268,9 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
if err := s.UpdateVersioned(owner, c, ver); err != nil {
return c, err
}
if c.Task != nil {
return s.Get(owner, c.ID)
}
return c, s.emitCard(c, core.EvCardStatus, core.CardStatusPayload{CardID: c.ID, Status: status, Tone: c.Tone, Note: note})
}
@@ -221,7 +281,10 @@ func (s *Service) SetPinned(owner, id string, pinned bool) (core.ActivityCard, e
return c, err
}
c.Pinned = pinned
return c, s.UpdateVersioned(owner, c, ver)
if e := s.UpdateVersioned(owner, c, ver); e != nil {
return c, e
}
return s.Get(owner, c.ID)
}
// Dismiss 关闭卡(非确认语义,留痕)。
@@ -256,6 +319,7 @@ func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCar
return c, "", ErrNoAction
}
c.Status = core.CardResolved
recordAnswer(&c, actionID, freeText)
if err := s.UpdateVersioned(owner, c, ver); err != nil {
return c, "", err
}
@@ -275,6 +339,9 @@ func (s *Service) ToProject(owner, id string) (core.ActivityCard, error) {
if c.ConversationID == "" {
return c, errors.New("card: already project-level")
}
if c.Task != nil {
return c, errors.New("card: keep the original task reference in its conversation")
}
if ref, rerr := s.st.CardRef(owner, c.ID); rerr == nil {
if strings.HasPrefix(ref, "hitl:") || strings.HasPrefix(ref, "cmd:") {
return c, errors.New("card: flow-bound card cannot be archived to project")
@@ -317,6 +384,7 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
return nil // 已决(双现任一侧先到)
}
c.Status = core.CardResolved
recordAnswer(&c, actionID, answer)
if err := s.UpdateVersioned(owner, c, ver); err != nil {
if errors.Is(err, store.ErrConflict) {
return nil // 双现任一侧先到(版本竞争败者):不重试,卡以先到侧为准
+59
View File
@@ -0,0 +1,59 @@
package cardsvc
import (
core "agent-system-core"
"strings"
)
func recordAnswer(card *core.ActivityCard, actionID, answer string) {
label := actionID
for _, action := range card.Actions {
if action.ID == actionID {
label = action.Label
break
}
}
body := []core.UINode{}
for _, node := range card.Body {
if node.Type == core.UIMarkdown && (strings.Contains(node.Markdown, "可在输入框自由输入") || strings.Contains(node.Markdown, "自由输入回应")) {
continue
}
body = append(body, node)
}
pairs := [][2]string{}
if label != "" {
pairs = append(pairs, [2]string{"你的选择", label})
}
if strings.TrimSpace(answer) != "" && answer != label {
pairs = append(pairs, [2]string{"补充说明", answer})
}
if len(pairs) > 0 {
body = append(body, core.UINode{Type: core.UIKV, KV: pairs})
}
card.Body = body
}
func (s *Service) presentResolved(owner string, card core.ActivityCard) core.ActivityCard {
if card.Status != core.CardResolved {
return card
}
// Already-recorded answers survive unchanged; old projections are repaired
// from their owner-scoped persisted HITL resolution without changing history.
for _, node := range card.Body {
for _, pair := range node.KV {
if pair[0] == "你的选择" || pair[0] == "补充说明" {
return card
}
}
}
ref, err := s.st.CardRef(owner, card.ID)
if err != nil || !strings.HasPrefix(ref, "hitl:") {
return card
}
resolution, err := s.st.HITLResolution(owner, strings.TrimPrefix(ref, "hitl:"))
if err != nil {
return card
}
recordAnswer(&card, "", resolution.Answer)
return card
}
+41
View File
@@ -0,0 +1,41 @@
package cardsvc
import (
"agent-system-backend/internal/store"
core "agent-system-core"
"strings"
"testing"
)
func TestHistoricalResolvedChoiceDisplaysPersistedAnswer(t *testing.T) {
st, svc, _ := openTest(t)
hitl := core.HITLCard{ID: "old-choice", ConversationID: "cnv_1"}
if err := st.SaveHITLCard(store.OwnerDefault, &hitl); err != nil {
t.Fatal(err)
}
if err := st.ResolveHITLCard(store.OwnerDefault, hitl.ID, core.HITLResolvedPayload{CardID: hitl.ID, Answer: "生成摘要", Kind: "choice"}); err != nil {
t.Fatal(err)
}
card, err := svc.Create(store.OwnerDefault, core.ActivityCard{Kind: core.CardKindConfirm, Status: core.CardResolved, Body: []core.UINode{{Type: core.UIMarkdown, Markdown: "_可在输入框自由输入回应。_"}}}, "hitl:"+hitl.ID)
if err != nil {
t.Fatal(err)
}
got, err := svc.Get(store.OwnerDefault, card.ID)
if err != nil {
t.Fatal(err)
}
found := false
for _, node := range got.Body {
if strings.Contains(node.Markdown, "输入框") {
t.Fatal("stale input hint retained")
}
for _, pair := range node.KV {
if pair[1] == "生成摘要" {
found = true
}
}
}
if !found {
t.Fatal("stored choice answer missing")
}
}
+63
View File
@@ -0,0 +1,63 @@
package cardsvc
import (
"agent-system-backend/internal/store"
core "agent-system-core"
"time"
)
func taskCardState(task core.TaskSnapshot) (core.CardStatus, string) {
switch task.ExecutionState {
case "completed":
return core.CardDone, "ok"
case "failed", "interrupted":
return core.CardFailed, "danger"
case "awaiting_user":
return core.CardActionRequired, "warn"
default:
return core.CardActive, "info"
}
}
func (s *Service) commitProjection(w store.TaskProjectionWrite) error {
if s.TaskCommitter != nil {
_, err := s.TaskCommitter(w)
return err
}
_, err := s.st.CommitTaskProjection(w)
return err
}
func (s *Service) taskChannels(card core.ActivityCard) []string {
channels := []string{card.ConversationID}
if s.MirrorConvCards && card.ProjectID != "" {
channels = append(channels, string(core.ProjectChannelKey(card.ProjectID)))
}
return channels
}
func (s *Service) CommitTask(owner string, next core.TaskSnapshot, expectedRevision int64) error {
return s.CommitTaskSources(owner, next, expectedRevision, nil)
}
func (s *Service) CommitTaskSources(owner string, next core.TaskSnapshot, expectedRevision int64, witnesses []store.ArtifactWitness) error {
c, version, err := s.st.GetCardVersion(owner, next.CardID)
if err != nil {
return err
}
c.Task = &next
c.SourceRunID = next.RunID
c.Body = next.SummaryBody()
c.Status, c.Tone = taskCardState(next)
c.UpdatedAt = time.Now().UTC()
return s.commitProjection(store.TaskProjectionWrite{Owner: owner, Card: c, ExpectedCardVersion: version, ExpectedTaskRevision: expectedRevision, SourceWitnesses: witnesses, Channels: s.taskChannels(c)})
}
func (s *Service) CreateTask(owner string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
if card.Task == nil {
return card, store.ErrConflict
}
card.Status, card.Tone = taskCardState(*card.Task)
card.Body = card.Task.SummaryBody()
card.CreatedAt = time.Now().UTC()
card.UpdatedAt = card.CreatedAt
if err := s.commitProjection(store.TaskProjectionWrite{Owner: owner, Card: card, Ref: ref, Channels: s.taskChannels(card)}); err != nil {
return card, err
}
return s.st.GetCard(owner, card.ID)
}
+63
View File
@@ -0,0 +1,63 @@
package cardsvc
import (
core "agent-system-core"
"testing"
)
func TestTaskProjectionPreservesUserReadingPreferences(t *testing.T) {
st, svc, _ := openTest(t)
c := core.ActivityCard{ID: "card-task", ProjectID: "p1", ConversationID: "c1", Title: "任务", Kind: core.CardKindProgress}
c.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "t1", AttemptID: "a1", CardID: c.ID, ProjectID: "p1", ConversationID: "c1", OwnerInstanceID: "i1", StreamScope: "conversation:c1", Revision: 1, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: "任务"}
if _, e := svc.CreateTask("o", c, ""); e != nil {
t.Fatal(e)
}
current, e := st.GetCard("o", c.ID)
if e != nil {
t.Fatal("task card not saved", e)
}
current.Pinned = true
current.Status = core.CardDismissed
if e = st.SaveCard("o", &current, ""); e != nil {
t.Fatal(e)
}
next := *current.Task
next.Revision = 2
next.ExecutionState = "completed"
next.Conclusion = "有用成果"
if e = svc.CommitTask("o", next, 1); e != nil {
t.Fatal(e)
}
got, e := st.GetCard("o", c.ID)
if e != nil || !got.Pinned || got.Status != core.CardDismissed || got.Task.ExecutionState != "completed" {
t.Fatal("work update stole user state", got, e)
}
}
func TestTaskProjectionUserPinHasAtomicEventWatermark(t *testing.T) {
st, svc, _ := openTest(t)
c := core.ActivityCard{ID: "card-pin", ProjectID: "p1", ConversationID: "c1", Title: "任务", Kind: core.CardKindProgress}
c.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "t1", AttemptID: "a1", CardID: c.ID, ProjectID: "p1", ConversationID: "c1", OwnerInstanceID: "i1", StreamScope: "conversation:c1", Revision: 1, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: "任务"}
if _, e := svc.CreateTask("o", c, ""); e != nil {
t.Fatal(e)
}
if _, e := svc.SetPinned("o", c.ID, true); e != nil {
t.Fatal(e)
}
snapshot, e := st.CardsAtCursor("o", "c1")
if e != nil || snapshot.ReplaySeq != 2 || len(snapshot.Cards) != 1 || !snapshot.Cards[0].Pinned {
t.Fatal("pin not atomically replayable", snapshot, e)
}
}
func TestTaskProjectionCannotCreateSecondExecutionOwner(t *testing.T) {
_, svc, _ := openTest(t)
c := core.ActivityCard{ID: "card-archive", ProjectID: "p1", ConversationID: "c1", Title: "任务", Kind: core.CardKindProgress}
c.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "t1", AttemptID: "a1", CardID: c.ID, ProjectID: "p1", ConversationID: "c1", OwnerInstanceID: "i1", StreamScope: "conversation:c1", Revision: 1, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: "任务"}
if _, e := svc.CreateTask("o", c, ""); e != nil {
t.Fatal(e)
}
if _, e := svc.ToProject("o", c.ID); e == nil {
t.Fatal("task was copied with a second execution owner")
}
}
+106
View File
@@ -0,0 +1,106 @@
package cardsvc
import (
core "agent-system-core"
"crypto/sha256"
"encoding/hex"
)
// Task source authorization is conservative: same owner and same project.
// The host rechecks it on every fresh read and stream replay, without an ACL cache.
func (s *Service) taskSourceState(owner, project string, src core.TaskSourceRef) string {
art, e := s.st.GetArtifact(owner, src.ArtifactID)
if e != nil || art.ProjectID != project {
return "unavailable"
}
data, e := s.st.ArtifactContent(owner, src.ArtifactID)
if e != nil {
return "unavailable"
}
sum := sha256.Sum256(data)
if hex.EncodeToString(sum[:]) != src.ContentHash {
return "changed"
}
return "available"
}
func (s *Service) VisibleTaskCard(owner string, c core.ActivityCard) core.ActivityCard {
if c.Task == nil {
return c
}
task := *c.Task
denied := false
changed := false
if _, e := s.st.GetCard(owner, c.ID); e != nil {
denied = true
}
refs := append([]core.TaskSourceRef(nil), task.Inputs...)
for _, f := range task.Findings {
refs = append(refs, f.Sources...)
}
for _, src := range refs {
switch s.taskSourceState(owner, c.ProjectID, src) {
case "unavailable":
denied = true
case "changed":
changed = true
}
}
// Result refs must also be owned and accessible before exposing generated names.
for _, ref := range task.ResultRefs {
if ref.ArtifactID != "" {
a, e := s.st.GetArtifact(owner, ref.ArtifactID)
if e != nil || a.ProjectID != c.ProjectID {
denied = true
}
}
}
if !denied && !changed {
return c
}
task.Goal = ""
task.Focus = ""
task.Coverage = ""
task.Remaining = ""
task.NextStep = ""
task.Conclusion = ""
task.CheckpointKeys = nil
task.Limitations = nil
task.Phase.Label = ""
task.Actions = nil
task.ResultRefs = nil
if denied {
c.Title = "后台任务"
task.Title = c.Title
task.Inputs = nil
task.Findings = nil
task.Conclusion = "相关来源当前不可用,结果暂不展示"
task.Limitations = []string{"相关来源当前不可用,请核实访问权限与文件状态"}
task.Remaining = "请核实材料访问权限与文件是否仍可读取"
} else {
task.Findings = append([]core.TaskFinding(nil), task.Findings...)
for i := range task.Findings {
f := &task.Findings[i]
f.Status = "superseded"
f.Sources = append([]core.TaskSourceRef(nil), f.Sources...)
for j := range f.Sources {
f.Sources[j].Availability = s.taskSourceState(owner, c.ProjectID, f.Sources[j])
}
}
task.Conclusion = "依据文件已更新,当前结论需要重新核验"
task.Limitations = []string{"依据文件已更新,历史发现不能直接作为当前结论"}
task.Remaining = "展开历史记录可查看处理时的合法引用;当前文件与当时版本可能不同"
}
task.OutcomeCompleteness = "partial"
c.Task = &task
c.Actions = nil
c.Body = task.SummaryBody()
// The durable work result and its follow-up choice derive from the same
// sources, so they follow the task conclusion's visibility.
if c.Work != nil {
work := *c.Work
work.Result = ""
work.Decision = nil
c.Work = &work
}
return c
}
+41
View File
@@ -0,0 +1,41 @@
package cardsvc
import (
core "agent-system-core"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
)
func TestTaskVisibilityHidesAllDerivedTextOnSourceRevocation(t *testing.T) {
st, svc, _ := openTest(t)
data := []byte("private input")
art := core.Artifact{Owner: "o", ProjectID: "p", Name: "private name"}
if e := st.CreateArtifact(&art, data); e != nil {
t.Fatal(e)
}
sum := sha256.Sum256(data)
src := core.TaskSourceRef{ID: "s", ArtifactID: art.ID, ContentHash: hex.EncodeToString(sum[:]), Label: art.Name, Quote: string(data)}
c := core.ActivityCard{ID: "ct", ProjectID: "p", ConversationID: "c", Title: "private title", Kind: core.CardKindProgress}
c.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "t", AttemptID: "a", CardID: c.ID, ProjectID: "p", ConversationID: "c", OwnerInstanceID: "i", Revision: 1, StreamScope: "conversation:c", ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: c.Title, Focus: "private focus", Coverage: "private coverage", Remaining: "private remaining", NextStep: "private advice", Conclusion: "private conclusion", Inputs: []core.TaskSourceRef{src}, Findings: []core.TaskFinding{{ID: "f", Revision: 1, Status: "active", Kind: "material_record", Text: "private finding", Sources: []core.TaskSourceRef{src}}}}
if _, e := svc.CreateTask("o", c, ""); e != nil {
t.Fatal(e)
}
if e := st.UpdateArtifactContent("o", art.ID, []byte("updated")); e != nil {
t.Fatal(e)
}
changed := svc.VisibleTaskCard("o", c)
if changed.Task.Findings[0].Status != "superseded" || changed.Task.Findings[0].Sources[0].Availability != "changed" {
t.Fatal("old evidence masquerades as current")
}
if e := st.DeleteArtifact("o", art.ID); e != nil {
t.Fatal(e)
}
hidden := svc.VisibleTaskCard("o", c)
raw, _ := json.Marshal(hidden)
if strings.Contains(string(raw), "private") {
t.Fatal("derived information leaked", string(raw))
}
}
@@ -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)
}
}
+267 -30
View File
@@ -5,6 +5,7 @@ package cmdbus
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -15,7 +16,7 @@ import (
"time"
agentcommand "agent-command"
"agent-system-core"
core "agent-system-core"
)
// AuditStore 审计落盘端口(store 实现;v7 建表、v18 加 owner 列)。
@@ -25,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)
@@ -34,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 {
@@ -96,9 +167,49 @@ type Request struct {
Actor string
Owner string
ConversationID string
OperationID string // overwritten by the trusted bus before handler execution
AnchorMessageID string // 确认卡锚定的发起消息(内联渲染)
Confirmed bool // 危险命令经确认卡放行后的执行
HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文)
// HumanGrant is set only by the authenticated session path or the human
// decision resolver. Client JSON and agent tool dispatch leave it nil.
HumanGrant *HumanGrant
}
// RequiresProjectWorkDecision identifies formal RDM writes that must be bound
// to an analyzed committed-message manifest before dispatch. Read views and
// the separate exact-card structure issue/decision path are excluded.
func RequiresProjectWorkDecision(id string) bool {
for _, prefix := range []string{
"rdm.reqs.", "rdm.review.", "rdm.draft.", "rdm.cnode.", "rdm.cedge.",
"rdm.conclusion.", "rdm.section.", "rdm.stage.", "rdm.change.",
} {
if strings.HasPrefix(id, prefix) {
for _, read := range []string{"rdm.reqs.list", "rdm.reqs.exports", "rdm.reqs.underived", "rdm.review.status", "rdm.review.doc.status", "rdm.draft.proposals", "rdm.conclusion.list", "rdm.change.list"} {
if id == read {
return false
}
}
return true
}
}
switch id {
case "rdm.structure.propose", "rdm.structure.revise", "rdm.docs.instantiate", "rdm.docs.delivery.approve",
"rdm.doc.snapshot", "rdm.ingest", "rdm.upload.confirm", "rdm.knowledge.publish", "rdm.trace.confirm",
"rdm.conflicts.resolve":
return true
default:
return false
}
}
// HumanGrant ties this dispatch to one persisted human response.
// It is not a secret token and is not read from command params.
type HumanGrant struct {
CardID string
ProofID string
Actor string
Decision string
}
// Response 分发结果:回执 + 处理负载 + 可能的确认卡。
@@ -115,20 +226,37 @@ type pending struct {
}
type Bus struct {
reg *agentcommand.Registry
audit AuditStore
cards CardPort
now func() time.Time
handlers map[string]Handler
slash map[string]string // "/x" → command id
pendMu sync.Mutex
pending map[string]pending
pendingStore PendingStore
intents *IntentStore // G2-2:意图声明表(自然语言路由)
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
accountGuard EffectiveAccountGuard // A1: optional command execution precheck
strictAccountWrites bool
allowImplicitLocal bool
reg *agentcommand.Registry
audit AuditStore
cards CardPort
now func() time.Time
handlers map[string]Handler
slash map[string]string // "/x" → command id
pendMu sync.Mutex
pending map[string]pending
pendingStore PendingStore
intents *IntentStore // G2-2:意图声明表(自然语言路由)
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
accountGuard EffectiveAccountGuard // A1: optional command execution precheck
strictAccountWrites bool
allowImplicitLocal bool
projectWorkPreflight func(context.Context, Request) error
hostProjectGuard func(context.Context, Request) error
// operationQuery operationID 对账查询端口(nil = 不提供,见 OperationQuery)。
operationQuery OperationQuery
}
func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) {
b.projectWorkPreflight = fn
}
// SetHostProjectGuard installs the composition-root check applied to every
// dispatch that carries a base project, before the project-work preflight and
// before any handler runs. It is the single bus-level place where host-project
// capability gates live, so every entrance (slash, exec, chat intent routing,
// system.intent re-dispatch, agent tool) is covered by one decision.
func (b *Bus) SetHostProjectGuard(fn func(context.Context, Request) error) {
b.hostProjectGuard = fn
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
@@ -154,6 +282,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 }
@@ -336,6 +491,21 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
if err := validateParams(spec.ParamsSchema, req.Params); err != nil {
return receipt(agentcommand.StateRejected, "", "params: "+err.Error())
}
// 基座项目门禁:所有带 HostProjectID 的分发都在这里过一道,命令种类与宿主
// 能力的匹配面由组合根决定(nil 时保持原行为)。
if req.HostProjectID != "" && b.hostProjectGuard != nil {
if err := b.hostProjectGuard(ctx, req); err != nil {
return receipt(agentcommand.StateRejected, "", "host project: "+err.Error())
}
}
if RequiresProjectWorkDecision(req.ID) {
if b.projectWorkPreflight == nil {
return receipt(agentcommand.StateRejected, "", "project source preflight unavailable")
}
if err := b.projectWorkPreflight(ctx, req); err != nil {
return receipt(agentcommand.StateRejected, "", "project source preflight: "+err.Error())
}
}
// 高危收口:非确认入口先落内联确认卡(committed 语义 = 卡已创建;确认后二次分发执行)。
if spec.Danger == agentcommand.DangerHigh && !req.Confirmed {
if b.cards == nil || req.ConversationID == "" {
@@ -351,10 +521,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}
@@ -369,12 +541,36 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil {
return b.accountRejected(req.ID, start)
}
// The domain handler can persist this trusted ID with its transaction/outbox.
// Entropy, not the clock or caller Params, distinguishes concurrent operations.
// 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")
}
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)
}
op := fmt.Sprintf("op_%s_%d", strings.ReplaceAll(strings.TrimPrefix(req.ID, ""), ".", "_"), b.now().UnixNano())
return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, op, "", start), Payload: payload}
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),其余视为取消。
@@ -395,7 +591,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
p, ok := b.pending[cardID]
b.pendMu.Unlock()
if ok && p.req.Owner != "" && p.req.Owner != owner {
return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", 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)
@@ -405,7 +601,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
if found {
var req Request
if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil {
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", 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
@@ -416,11 +612,11 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
ok = false
}
if !ok {
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", 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)
@@ -436,15 +632,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.
@@ -467,7 +677,7 @@ func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error {
func (b *Bus) accountRejected(commandID string, start time.Time) Response {
return Response{Receipt: agentcommand.ExecReceipt{
CommandID: commandID, State: agentcommand.StateRejected,
Reason: "effective account unavailable", Time: start.UTC(),
Reason: "effective account unavailable", SideEffects: agentcommand.SideEffectsNone, Time: start.UTC(),
}}
}
@@ -494,10 +704,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 {
+316 -1
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
})
@@ -62,6 +63,7 @@ func newTestBus(a AuditStore, c CardPort) *Bus {
func TestDispatchFourEntrancesSameAudit(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
b.SetProjectWorkPreflight(func(context.Context, Request) error { return nil })
for _, ent := range []string{"button", "slash", "chat", "card", "api", "weird-unknown"} {
resp := b.Dispatch(context.Background(), Request{ID: "demo.ping", Entrance: ent})
if resp.Receipt.State != agentcommand.StateCommitted {
@@ -87,6 +89,73 @@ func TestDispatchFourEntrancesSameAudit(t *testing.T) {
}
}
func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 123).UTC() })
var seen string
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.confirm", Title: "Confirm"}, "", func(_ context.Context, req Request) (any, error) {
seen = req.OperationID
return "committed-domain-event", nil
}); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice", Params: map[string]any{"operationId": "forged-client-id"}})
if seen == "" || seen == "forged-client-id" || resp.Receipt.OperationID != seen || len(audit.logs) != 1 || audit.logs[0].OperationID != seen {
t.Fatalf("operation correlation missing: handler=%q receipt=%+v audit=%+v", seen, resp.Receipt, audit.logs)
}
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.fail", Title: "Fail"}, "", func(_ context.Context, req Request) (any, error) {
if req.OperationID == "" {
t.Fatal("handler must receive a tentative host operation ID")
}
// 领域事务回滚 = 确定未执行:声明 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("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)
}
}
func TestOperationIDsRemainUniqueWithFixedClockAndConcurrentDispatch(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 123).UTC() })
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.confirm", Title: "Confirm"}, "", func(_ context.Context, req Request) (any, error) {
return req.OperationID, nil
}); err != nil {
t.Fatal(err)
}
const count = 64
var wg sync.WaitGroup
ids := make([]string, count)
for i := range ids {
wg.Add(1)
go func(i int) {
defer wg.Done()
resp := bus.Dispatch(context.Background(), Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice"})
ids[i] = resp.Receipt.OperationID
}(i)
}
wg.Wait()
seen := map[string]bool{}
for _, id := range ids {
if id == "" || seen[id] {
t.Fatalf("fixed clock produced a missing or duplicate trusted operation ID: %q", id)
}
seen[id] = true
}
if len(audit.logs) != count {
t.Fatalf("audit rows=%d want=%d", len(audit.logs), count)
}
}
func TestDispatchUnknownAndBadParams(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
@@ -217,6 +286,7 @@ func TestValidateParamsScalars(t *testing.T) {
func TestRegisterPluginCommandDispatch(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
b.SetProjectWorkPreflight(func(context.Context, Request) error { return nil })
err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.ingest", Title: "入库"}, func(ctx context.Context, req Request) (any, error) {
return map[string]any{"echo": req.Params["ref"], "owner": req.Owner}, nil
})
@@ -280,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)
}
}
+94
View File
@@ -0,0 +1,94 @@
package cmdbus
// 基座项目门禁在命令总线上的行为(临时会话隔离,U2):SetHostProjectGuard 装
// 的门禁对所有带 HostProjectID 的分发生效,且在 handler 与项目工作预检之前。
import (
"context"
"errors"
"strings"
"testing"
"time"
agentcommand "agent-command"
)
// TestHostProjectGuardRefusesBeforeHandler 基座项目门禁:带宿主的请求先过门禁,
// 拒绝时 handler 与预检都不该被调用;同一个命令换一个普通宿主项目照旧执行。
func TestHostProjectGuardRefusesBeforeHandler(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
handled, preflights := 0, 0
bus.SetProjectWorkPreflight(func(context.Context, Request) error { preflights++; return nil })
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, "",
func(context.Context, Request) (any, error) { handled++; return map[string]any{"ok": true}, nil }); err != nil {
t.Fatal(err)
}
bus.SetHostProjectGuard(func(_ context.Context, req Request) error {
if strings.HasPrefix(req.ID, "rdm.") || RequiresProjectWorkDecision(req.ID) {
if req.HostProjectID == "prj_scratch" {
return errors.New("not available in scratch sessions")
}
}
return nil
})
resp := bus.Dispatch(context.Background(), Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: "cnv_scratch", HostProjectID: "prj_scratch",
})
if resp.Receipt.State != agentcommand.StateRejected {
t.Fatalf("scratch host dispatch state = %s, want rejected", resp.Receipt.State)
}
if !strings.HasPrefix(resp.Receipt.Reason, "host project: ") ||
!strings.Contains(resp.Receipt.Reason, "not available in scratch sessions") {
t.Fatalf("scratch host rejection reason = %q", resp.Receipt.Reason)
}
if handled != 0 || preflights != 0 {
t.Fatalf("guard ran late: handler=%d preflight=%d", handled, preflights)
}
// 正面:同一命令在普通项目宿主上不被这条门禁拒绝。
ok := bus.Dispatch(context.Background(), Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: "cnv_normal", HostProjectID: "prj_normal",
})
if ok.Receipt.State != agentcommand.StateCommitted || handled != 1 || preflights != 1 {
t.Fatalf("normal host dispatch = %+v handler=%d preflight=%d", ok.Receipt, handled, preflights)
}
}
// TestHostProjectGuardSkipsHostlessAndUnrelatedCommands 门禁只作用于带宿主的
// 请求与非项目命令;会话类命令在同一个临时会话宿主上照旧可用。
func TestHostProjectGuardSkipsHostlessAndUnrelatedCommands(t *testing.T) {
bus := New(&memAudit{}, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "",
func(context.Context, Request) (any, error) { return map[string]any{"pong": true}, nil }); err != nil {
t.Fatal(err)
}
guarded := 0
bus.SetHostProjectGuard(func(_ context.Context, req Request) error {
guarded++
if req.HostProjectID == "prj_scratch" && strings.HasPrefix(req.ID, "rdm.") {
return errors.New("not available in scratch sessions")
}
return nil
})
resp := bus.Dispatch(context.Background(), Request{
ID: "demo.ping", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: "prj_scratch",
})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("conversation command on a scratch host = %+v", resp.Receipt)
}
if guarded != 1 {
t.Fatalf("host guard calls = %d, want 1", guarded)
}
hostless := bus.Dispatch(context.Background(), Request{ID: "demo.ping", Entrance: "api", Actor: "alice", Owner: "alice"})
if hostless.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("hostless dispatch = %+v", hostless.Receipt)
}
if guarded != 1 {
t.Fatalf("host guard ran for a hostless dispatch: %d", guarded)
}
}
+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)
}
}
@@ -0,0 +1,16 @@
package cmdbus
import "testing"
func TestFormalRDMWritesRequireProjectWorkDecision(t *testing.T) {
for _, id := range []string{"rdm.reqs.add", "rdm.docs.instantiate", "rdm.docs.delivery.approve", "rdm.doc.snapshot", "rdm.conflicts.resolve", "rdm.review.doc.approve", "rdm.structure.propose", "rdm.trace.confirm"} {
if !RequiresProjectWorkDecision(id) {
t.Errorf("formal write %s bypassed project source preflight", id)
}
}
for _, id := range []string{"rdm.inspect.tree", "rdm.reqs.list", "rdm.structure.proposals", "rdm.structure.issue", "rdm.structure.decide"} {
if RequiresProjectWorkDecision(id) {
t.Errorf("read or separately proven command %s was misclassified", id)
}
}
}
+1
View File
@@ -67,6 +67,7 @@ func TestIntentDeclareMatchRoute(t *testing.T) {
t.Fatalf("匹配 = %+v ok=%v", in, ok)
}
// 路由:匹配到的命令经总线 chat 入口分发成功。
bus.SetProjectWorkPreflight(func(context.Context, Request) error { return nil })
resp := bus.Dispatch(context.Background(), Request{ID: in.Command, Params: in.Params, Entrance: "chat", Owner: "alice"})
if resp.Receipt.State != "committed" {
t.Fatalf("路由分发 = %+v", resp.Receipt)
+10 -1
View File
@@ -36,6 +36,8 @@ type Config struct {
ExecDepsDir string `json:"execDepsDir"`
// ExecWorkspace sandbox 档工作区模式:空=直写;overlay=冻结基底+upper。
ExecWorkspace string `json:"execWorkspace"`
// ExecEnvRoot 分层 Python 环境根。空 = 不启用申请代理。
ExecEnvRoot string `json:"execEnvRoot"`
// Tier 部署档(协作存储方案 KTD1):空=auto(DSN→PG,否则 SQLite);
// local=强制 SQLite(不允许 DSN);team=强制 PG(缺 DSN/未开认证拒绝启动,
// PG 故障拒绝就绪,绝不回落 SQLite)。
@@ -69,6 +71,7 @@ var envKey = map[string]string{
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
"execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR",
"execWorkspace": "AGENT_SYSTEM_EXEC_WORKSPACE",
"execEnvRoot": "AGENT_SYSTEM_EXEC_ENV_ROOT",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
@@ -213,6 +216,8 @@ func loadFile(path string) (Config, map[string]bool, error) {
dst = &c.ExecDepsDir
case "execWorkspace":
dst = &c.ExecWorkspace
case "execEnvRoot":
dst = &c.ExecEnvRoot
case "tier":
dst = &c.Tier
case "pgMaxOpen":
@@ -260,6 +265,8 @@ func stringField(c Config, f string) string {
return c.ExecDepsDir
case "execWorkspace":
return c.ExecWorkspace
case "execEnvRoot":
return c.ExecEnvRoot
case "tier":
return c.Tier
}
@@ -355,6 +362,8 @@ func setString(c *Config, f, v string) {
c.ExecDepsDir = v
case "execWorkspace":
c.ExecWorkspace = v
case "execEnvRoot":
c.ExecEnvRoot = v
case "tier":
c.Tier = v
}
@@ -386,7 +395,7 @@ func (r Resolved) Explain(w io.Writer, file string) {
fmt.Fprintln(w, "config file: (none)")
}
fmt.Fprintln(w, "field value source")
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} {
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "execDepsDir", "execWorkspace", "execEnvRoot", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} {
var v string
if isBoolField(f) {
v = fmt.Sprintf("%t", boolField(r.Config, f))
+153
View File
@@ -0,0 +1,153 @@
package config
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"regexp"
"strings"
"unicode"
)
// 超级管理员启动凭据(R1):进程环境优先,未设置的键从受保护的 dotenv 文件补齐。
const (
EnvSuperAdminOwner = "SUPER_ADMIN_OWNER"
EnvSuperAdminPassword = "SUPER_ADMIN_PASSWORD"
EnvFileKey = "AGENT_SYSTEM_ENV_FILE"
)
// ErrSuperAdminConfig 标记缺失、不完整、非法或不可读的超级管理员配置;
// 错误文本只含键名与文件路径,绝不含取值。
var ErrSuperAdminConfig = errors.New("super admin config")
// SuperAdmin 持有启动期超级管理员身份与口令。口令只在进程内存中,
// 格式化输出与 JSON 序列化一律脱敏。
type SuperAdmin struct {
Owner string
password string
}
// NewSuperAdmin 供测试与组合根直接构造(不做校验,校验见 Validate)。
func NewSuperAdmin(owner, password string) SuperAdmin {
return SuperAdmin{Owner: owner, password: password}
}
// Password 返回配置口令;调用方不得记录或回传。
func (s SuperAdmin) Password() string { return s.password }
func (s SuperAdmin) String() string {
if s.password == "" {
return fmt.Sprintf("SuperAdmin{owner=%q password=<unset>}", s.Owner)
}
return fmt.Sprintf("SuperAdmin{owner=%q password=<redacted>}", s.Owner)
}
func (s SuperAdmin) GoString() string { return s.String() }
func (s SuperAdmin) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`{"owner":%q,"passwordConfigured":%t}`, s.Owner, s.password != "")), nil
}
// Validate 检查身份与口令的结构有效性;passwordPolicy 为认证边界的口令策略
// (由调用方注入,避免在配置层复制策略)。
func (s SuperAdmin) Validate(passwordPolicy func(string) error) error {
switch {
case s.Owner == "" && s.password == "":
return fmt.Errorf("%w: %s and %s are required when authentication is enabled", ErrSuperAdminConfig, EnvSuperAdminOwner, EnvSuperAdminPassword)
case s.Owner == "":
return fmt.Errorf("%w: %s is missing (partial configuration)", ErrSuperAdminConfig, EnvSuperAdminOwner)
case s.password == "":
return fmt.Errorf("%w: %s is missing (partial configuration)", ErrSuperAdminConfig, EnvSuperAdminPassword)
}
if strings.TrimSpace(s.Owner) != s.Owner {
return fmt.Errorf("%w: %s must not have surrounding whitespace", ErrSuperAdminConfig, EnvSuperAdminOwner)
}
for _, r := range s.Owner {
if unicode.IsControl(r) {
return fmt.Errorf("%w: %s contains control characters", ErrSuperAdminConfig, EnvSuperAdminOwner)
}
}
if passwordPolicy != nil {
if err := passwordPolicy(s.password); err != nil {
return fmt.Errorf("%w: %s violates password policy: %v", ErrSuperAdminConfig, EnvSuperAdminPassword, err)
}
}
return nil
}
// LoadSuperAdmin 读取超级管理员凭据:envLookup 中非空的键优先;未设置或空的键从
// envFile(为空时取 AGENT_SYSTEM_ENV_FILE)补齐。文件必须存在且不得对组/其他
// 用户开放任何权限。未开认证时不读取也不要求(嵌入式单用户档不暴露账号管理)。
func LoadSuperAdmin(envLookup func(string) (string, bool), envFile string, authEnabled bool) (SuperAdmin, error) {
if !authEnabled {
return SuperAdmin{}, nil
}
owner, _ := envLookup(EnvSuperAdminOwner)
password, _ := envLookup(EnvSuperAdminPassword)
ownerSet, passwordSet := owner != "", password != ""
if envFile == "" {
envFile, _ = envLookup(EnvFileKey)
}
if (!ownerSet || !passwordSet) && envFile != "" {
values, err := ReadEnvFile(envFile)
if err != nil {
return SuperAdmin{}, err
}
if !ownerSet {
owner = values[EnvSuperAdminOwner]
}
if !passwordSet {
password = values[EnvSuperAdminPassword]
}
}
return SuperAdmin{Owner: owner, password: password}, nil
}
var envFileLine = regexp.MustCompile(`^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$`)
// ReadEnvFile 按 svc.sh 使用的 dotenv 格式安全解析:
// KEY=VALUE、# 注释、export 前缀、剥成对引号;非法行忽略;不执行内容。
// 文件对组/其他用户有任何权限位即拒绝(受保护 .env 约定)。
func ReadEnvFile(path string) (map[string]string, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("%w: env file: %v", ErrSuperAdminConfig, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%w: env file %s is not a regular file", ErrSuperAdminConfig, path)
}
if perm := info.Mode().Perm(); perm&0o077 != 0 {
return nil, fmt.Errorf("%w: env file %s is accessible by group/other (mode %04o; chmod 600)", ErrSuperAdminConfig, path, perm)
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("%w: env file: %v", ErrSuperAdminConfig, err)
}
values := map[string]string{}
scanner := bufio.NewScanner(bytes.NewReader(raw))
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSuffix(scanner.Text(), "\r")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
m := envFileLine.FindStringSubmatch(line)
if m == nil {
continue
}
values[m[1]] = unquoteEnvValue(strings.TrimLeft(m[2], " \t"))
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("%w: env file %s: unreadable line", ErrSuperAdminConfig, path)
}
return values, nil
}
func unquoteEnvValue(v string) string {
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
return v[1 : len(v)-1]
}
return v
}
+157
View File
@@ -0,0 +1,157 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
const testSuperPassword = "Sup3r-Secret-Value"
func lookupMap(m map[string]string) func(string) (string, bool) {
return func(k string) (string, bool) {
v, ok := m[k]
return v, ok
}
}
func testPolicy(pw string) error {
if len(pw) < 8 || len(pw) > 256 {
return errors.New("password must be 8-256 characters")
}
return nil
}
func writeEnvFile(t *testing.T, body string, perm os.FileMode) string {
t.Helper()
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(body), perm); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, perm); err != nil {
t.Fatal(err)
}
return path
}
func assertNoSecret(t *testing.T, err error, secret string) {
t.Helper()
if err != nil && secret != "" && strings.Contains(err.Error(), secret) {
t.Fatalf("error leaks secret: %v", err)
}
}
func TestSuperAdminValidateFailsClosed(t *testing.T) {
cases := []struct {
name, owner, password, want string
}{
{"both absent", "", "", "SUPER_ADMIN_OWNER and SUPER_ADMIN_PASSWORD are required"},
{"owner only", "root", "", "SUPER_ADMIN_PASSWORD is missing"},
{"password only", "", testSuperPassword, "SUPER_ADMIN_OWNER is missing"},
{"short password", "root", "short", "SUPER_ADMIN_PASSWORD violates password policy"},
{"owner whitespace", " root", testSuperPassword, "surrounding whitespace"},
{"owner control", "ro\x01ot", testSuperPassword, "control characters"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sa, err := LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: tc.owner, EnvSuperAdminPassword: tc.password}), "", true)
if err != nil {
t.Fatal(err)
}
err = sa.Validate(testPolicy)
if !errors.Is(err, ErrSuperAdminConfig) || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err=%v want %q", err, tc.want)
}
assertNoSecret(t, err, tc.password)
})
}
}
func TestSuperAdminLoadFromProcessEnvironment(t *testing.T) {
sa, err := LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: "root", EnvSuperAdminPassword: testSuperPassword}), "", true)
if err != nil {
t.Fatal(err)
}
if sa.Owner != "root" || sa.Password() != testSuperPassword {
t.Fatalf("got owner=%q", sa.Owner)
}
if err := sa.Validate(testPolicy); err != nil {
t.Fatal(err)
}
}
func TestSuperAdminEnvFileFillsUnsetKeysOnly(t *testing.T) {
path := writeEnvFile(t, "# comment\nexport SUPER_ADMIN_OWNER=\"file-root\"\nSUPER_ADMIN_PASSWORD='"+testSuperPassword+"'\r\nnot a line\n", 0o600)
sa, err := LoadSuperAdmin(lookupMap(map[string]string{}), path, true)
if err != nil {
t.Fatal(err)
}
if sa.Owner != "file-root" || sa.Password() != testSuperPassword {
t.Fatalf("file values not loaded: owner=%q", sa.Owner)
}
sa, err = LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: "env-root", EnvSuperAdminPassword: ""}), path, true)
if err != nil {
t.Fatal(err)
}
if sa.Owner != "env-root" || sa.Password() != testSuperPassword {
t.Fatalf("process environment must win, empty must fall back: owner=%q", sa.Owner)
}
sa, err = LoadSuperAdmin(lookupMap(map[string]string{EnvFileKey: path}), "", true)
if err != nil || sa.Owner != "file-root" {
t.Fatalf("AGENT_SYSTEM_ENV_FILE not honored: owner=%q err=%v", sa.Owner, err)
}
}
func TestSuperAdminEnvFilePartialFailsValidation(t *testing.T) {
path := writeEnvFile(t, "SUPER_ADMIN_OWNER=root\n", 0o600)
sa, err := LoadSuperAdmin(lookupMap(map[string]string{}), path, true)
if err != nil {
t.Fatal(err)
}
if err := sa.Validate(testPolicy); err == nil || !strings.Contains(err.Error(), "SUPER_ADMIN_PASSWORD is missing") {
t.Fatalf("partial file accepted: %v", err)
}
}
func TestSuperAdminEnvFileMustBeProtected(t *testing.T) {
path := writeEnvFile(t, "SUPER_ADMIN_OWNER=root\nSUPER_ADMIN_PASSWORD="+testSuperPassword+"\n", 0o644)
_, err := LoadSuperAdmin(lookupMap(map[string]string{}), path, true)
if !errors.Is(err, ErrSuperAdminConfig) || !strings.Contains(err.Error(), "group/other") {
t.Fatalf("world-readable env file accepted: %v", err)
}
assertNoSecret(t, err, testSuperPassword)
_, err = LoadSuperAdmin(lookupMap(map[string]string{}), filepath.Join(t.TempDir(), "missing.env"), true)
if !errors.Is(err, ErrSuperAdminConfig) {
t.Fatalf("missing env file accepted: %v", err)
}
}
func TestSuperAdminNotRequiredWithoutAuthentication(t *testing.T) {
sa, err := LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: "root"}), filepath.Join(t.TempDir(), "missing.env"), false)
if err != nil {
t.Fatalf("auth-disabled mode must not read or require credentials: %v", err)
}
if sa.Owner != "" || sa.Password() != "" {
t.Fatalf("auth-disabled mode loaded credentials: owner=%q", sa.Owner)
}
}
func TestSuperAdminRedactsPassword(t *testing.T) {
sa := NewSuperAdmin("root", testSuperPassword)
for _, out := range []string{fmt.Sprint(sa), fmt.Sprintf("%v %+v %#v %s", sa, sa, sa, sa), fmt.Sprint(&sa)} {
if strings.Contains(out, testSuperPassword) {
t.Fatalf("formatted output leaks password: %s", out)
}
}
raw, err := json.Marshal(struct{ SuperAdmin SuperAdmin }{sa})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), testSuperPassword) || !strings.Contains(string(raw), `"passwordConfigured":true`) {
t.Fatalf("json leaks password or lost status: %s", raw)
}
}
+36 -1
View File
@@ -4,12 +4,14 @@ package ctxasm
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
"unicode/utf8"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/vision"
core "agent-system-core"
)
@@ -134,6 +136,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
// 此处与整段组装共用同一实现,保证两条路径的当前轮语义一致。
func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, rep *Report) agentllm.Message {
var sb strings.Builder
var images []agentllm.Image
for _, p := range currentUser.Parts {
if p.Type == core.PartText {
sb.WriteString(p.Text)
@@ -146,6 +149,22 @@ func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, atta
}
sb.WriteString("\n\n[附件](用户上传文档的解析全文,按原文顺序保留标题/列表/表格;超长时文末标注截断。请依据文档内容完成任务,输出完整不省略)\n")
for _, doc := range attachments {
if pics := vision.ImagesFromDoc(doc); len(pics) > 0 {
kept, notes := vision.Admit(images, pics)
images = append(images, kept...)
if len(kept) > 0 {
fmt.Fprintf(&sb, "### %s\n[图片 %s,图像已随本消息发送]\n", doc.Meta.Filename, doc.Meta.Filename)
} else {
fmt.Fprintf(&sb, "### %s\n[图片 %s,本轮未发送图像]\n", doc.Meta.Filename, doc.Meta.Filename)
}
for _, note := range notes {
fmt.Fprintf(&sb, "%s\n", note)
if rep != nil {
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s:%s", doc.Meta.Filename, note))
}
}
continue
}
text := doc.PlainText()
if runes := []rune(text); len(runes) > limit {
text = string(runes[:limit]) + "…(附件超长截断)"
@@ -168,7 +187,7 @@ func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, atta
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
}
}
return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()}
return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String(), Images: images}
}
// maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。
@@ -435,6 +454,10 @@ func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int,
if a == nil {
return ""
}
name := a.Name
if isImageArtifact(a.Kind, name) {
return fmt.Sprintf("此前上传的图片:%s。\n", name)
}
if resolve == nil {
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
}
@@ -454,6 +477,18 @@ func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int,
return fmt.Sprintf("此前已生成文件 %s,内容如下:\n%s\n", art.Name, text)
}
func isImageArtifact(kind, name string) bool {
if kind == "image" {
return true
}
switch strings.ToLower(filepath.Ext(name)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg":
return true
default:
return false
}
}
// extractiveSummary 把窗口外历史压成短摘要(不调用模型)。summarizeBeyond 打开时注入。
func extractiveSummary(dropped []core.Message) string {
var lines []string
+44
View File
@@ -203,3 +203,47 @@ func contains(items []string, want string) bool {
}
return false
}
func TestCurrentImageAttachmentAndHistoryPlaceholder(t *testing.T) {
png := []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x50,
0x0f, 0x00, 0x04, 0x85, 0x01, 0x80, 0x84, 0xa9, 0x8c, 0x21, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
doc := core.ParsedDocument{
Meta: core.DocMeta{Filename: "chart.png", MIME: "image/png"},
Blocks: []core.DocBlock{{
Type: core.BlockImageRef,
Ref: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
}},
}
msg := BuildCurrentUser(core.EffectiveSettings{}, fakeUser("看图"), []core.ParsedDocument{doc}, nil, &Report{})
if len(msg.Images) != 1 || msg.Images[0].MIME != "image/png" || msg.Images[0].Data == "" {
t.Fatalf("images %+v", msg.Images)
}
if !strings.Contains(msg.Content, "图像已随本消息发送") || strings.Contains(msg.Content, string(png)) {
t.Fatalf("content %q", msg.Content)
}
prev := core.Message{ID: "old", Role: core.RoleUser, Parts: []core.Part{
{Type: core.PartText, Text: "上一张"},
{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "a1", Name: "chart.png", Kind: "image"}},
}}
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, []core.Message{prev}, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
return core.Artifact{Name: "chart.png", MIME: "image/png"}, png, true
}, AssembleOptions{})
var hist string
for _, m := range msgs {
if strings.Contains(m.Content, "上一张") {
hist = m.Content
if len(m.Images) != 0 {
t.Fatal("history must not resend image bytes")
}
}
}
if !strings.Contains(hist, "此前上传的图片:chart.png") {
t.Fatalf("history placeholder missing: %q", hist)
}
}

Some files were not shown because too many files have changed in this diff Show More