Compare commits

...
300 Commits
Author SHA1 Message Date
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 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 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 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 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 2716b9e607 Merge pull request 'Add U3 account directory, lifecycle and self-recovery' (#198) from feature/iam-u3-account-foundation into main
ci / vet-and-test (push) Successful in 1m2s
2026-10-14 15:04:54 +00:00
weichen 6dadde47b0 Merge remote-tracking branch 'origin/main' into feature/iam-u3-account-foundation
ci / vet-and-test (pull_request) Successful in 1m2s
2026-10-14 14:56:33 +00:00
weichen ead5ade30e Merge pull request 'Vendor four permissive popular skills into the recipe library' (#200) from feature/vendor-popular-skills into main
ci / vet-and-test (push) Successful in 53s
2026-10-14 14:55:21 +00:00
weichen d53b20ecd7 Verify disabled process replay across independent stores
ci / vet-and-test (pull_request) Successful in 59s
Why: The previous endpoint regression used an SQLite-only server fixture even when isolated PostgreSQL was configured.

Evidence: The revised HTTP test passed with dialect=sqlite and dialect=postgres; it creates data through one Store and reads through another before and after disabling the local account.

Impact: U3 effective-state checks for the new process replay route now have a direct two-store PostgreSQL regression without changing production behavior.

Refs: IAM U3 affected Stage 7 review; Backend PR #198
2026-10-14 14:52:05 +00:00
weichen 9010e5b5f7 Merge authoritative main into the vendor-skills branch
ci / vet-and-test (pull_request) Successful in 52s
2026-10-14 14:48:53 +00:00
weichen 97d9c2b0c7 Guard process event replay for disabled local accounts
ci / vet-and-test (pull_request) Successful in 1m4s
Why: Merging run-process visibility introduced a read endpoint that bypassed U3 effective-account checks when authentication was off.

Evidence: A new regression first returned HTTP 200 for disabled local; with entry and final response checks it passed, and the event pagination test passed ten repeated runs after anchoring to its appended event sequence.

Impact: Explicitly disabled local accounts cannot read stored process events; missing implicit local remains compatible.

Refs: IAM U3 Stage 8 integration review; Backend PR #198; main PR #199
2026-10-14 14:46:24 +00:00
weichen e0ef580f73 Merge remote-tracking branch 'origin/main' into feature/iam-u3-account-foundation 2026-10-14 14:41:39 +00:00
weichen 98cfa3cf4f Merge pull request 'Expose persisted run events for process review' (#199) from feature/work-companion-visibility into main
ci / vet-and-test (push) Successful in 46s
2026-10-14 14:39:39 +00:00
weichen 121b4d60e7 Allow private project replay test to reach shared event
ci / vet-and-test (pull_request) Successful in 56s
Why: The SSE test canceled after 200 ms while filtering more than a thousand private events; CI and race builds stopped before the shared frame.

Evidence: The failure reproduced under go test -race with HTTP 200, no private leak, and no shared frame; after a two-second deadline the race case passed three times and the Server package passed.

Impact: The privacy assertion is unchanged and now reports which condition failed, while slower replay still reaches the event under test.

Refs: IAM U3 Stage 7; Backend PR #198; CI run 3170
2026-10-14 14:23:39 +00:00
weichen bb5419182c Include child collaboration status in run history
ci / vet-and-test (pull_request) Successful in 1m12s
Why: Spawned agent progress disappeared when the parent reply settled.

Evidence: go test ./...; run-event route test covers child status and raw-output exclusion.

Impact: Return child progress and result events in parent history without exposing child raw tool streams.

Refs: Agent-System-Dev feature/work-companion-visibility UI1
2026-10-14 14:17:25 +00:00
weichen e6c3d0d876 Vendor four permissive popular skills into the recipe library
ci / vet-and-test (pull_request) Successful in 46s
Why: The user asked to bring proven external skills for document
beautification and content enrichment into the system. Surveying GitHub,
skills.sh and ClawHub found the popular document-editing skills are
proprietary (Anthropic document skills forbid extraction and derivatives),
so the compliant picks are the permissively-licensed leaders: frontend-
design (Apache-2.0, 985.7K installs), canvas-design (Apache-2.0, official
example skill with bundled OFL fonts), copywriting and copy-editing (MIT,
54.7K stars).

Evidence: Seed-time open-spec validation passes for all four vendored
SKILL.md frontmatters (dogfooded by the existing seed tests); anthropic
skill bodies verified byte-identical to the pinned upstream snapshot
(dbd4588f); marketing skills trimmed only of unvendored sibling pointers
and eval artifacts, recorded in per-skill UPSTREAM.md with pinned SHAs;
new provenance test guards license files.

Impact: Built-in skills grow from four to eight and the binary embeds
about 6 MB of font assets; prompt library base.deliverables v9 exposes
the entry points. House-style stays authoritative for Office themes; the
new skills add visual direction, poster/infographic rendering and
copywriting craft. Seeds upgrade existing deployments on next start.

Refs: Dev task feature/vendor-popular-skills
2026-10-14 14:14:36 +00:00
weichen 26305cacf9 Assert account directory cursor fidelity in migration
ci / vet-and-test (pull_request) Failing after 46s
Why: The U3 migration must replace the target schema seed with the source directory revision and cursor key.

Evidence: Isolated PostgreSQL TestMigrateEndToEndAndIdempotent passed with field-by-field source and target comparison.

Impact: Migration regressions that lose directory cursor identity now fail the existing end-to-end test.

Refs: IAM U3 Stage 7; docs/proposals/2026-10-14-iam-u3-account-contract-and-plan.md §10
2026-10-14 14:09:01 +00:00
weichen b8af4341ab Expose persisted run events for process review
Why: Completed replies currently drop access to streamed thinking and tool output.

Evidence: go test ./...; isolated run-event pagination and conversation binding test.

Impact: Add owner-scoped paginated event reading for a single conversation run.

Refs: Agent-System-Dev feature/work-companion-visibility UI1
2026-10-14 14:07:28 +00:00
weichen b080e98388 Exclude seeded account revision from migration target occupancy
Why: U3 seeds a directory revision in every new target database, which made an empty PostgreSQL target look occupied to the migration guard.

Evidence: Isolated PostgreSQL cmd/storage-migrate integration tests passed after the previously failing cases reproduced the error.

Impact: A fresh target remains eligible for migration; existing business rows and foreign migration identities still block it.

Refs: IAM U3 Stage 7; docs/proposals/2026-10-14-iam-u3-account-contract-and-plan.md §10
2026-10-14 14:03:53 +00:00
weichen d2f9b16d1a Merge remote-tracking branch 'origin/main' into feature/iam-u3-account-foundation 2026-10-14 08:37:04 +00:00
weichen 607c38d7e6 Merge pull request 'Reject structurally broken OOXML at artifact registration' (#197) from feature/pptx-register-structural-gate into main
ci / vet-and-test (push) Successful in 3m24s
2026-10-14 08:36:18 +00:00
weichen 7492feab42 Fence delegated project projection by its real operator
Why: RDM delegated rename rebuilt the creator Host and could commit a Backend project rename after the delegated operator was disabled.

Evidence: SQLite and isolated PostgreSQL targeted checks cover operator/creator disable order, two processes, sorted locks, auth-off local compatibility, and HTTP/command Host wiring; exact committed-head replay remains pending.

Impact: Adds a request-bound single-project extension capability and checks operator plus creator in the final Backend transaction. Global ScopedHost cannot derive delegated authority; RDM cross-database atomicity remains outside the promise.

Refs: Approved Documents delegated-projection amendment 5783940, U3 A1-A4 candidate adcd8fe, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 08:17:31 +00:00
weichen c8f8076579 Make artifact backfill fixture a structurally valid docx
ci / vet-and-test (pull_request) Successful in 1m16s
Why: The backfill contract test used a fake PK-magic docx as its fixture;
the new registration structural gate correctly refuses that shape, so the
contract was no longer exercised.

Evidence: The three affected runmgr tests pass locally with -count=1;
the full combined suite receipt follows on the task card.

Impact: Test fixture only; the gate behavior under test is unchanged.

Refs: Dev task feature/pptx-register-structural-gate
2026-10-14 07:42:28 +00:00
weichen aea948fd4d Reject structurally broken OOXML at artifact registration
Why: An agent bypassing the housestyle kit hand-assembled OOXML with
zipfile; the resulting pptx lacked masters, theme and docProps. Text
extraction succeeded and every existing check passed, so a package that
Office refuses to open reached the user as a deliverable.

Evidence: Focused Go suite covers a valid minimal pptx, the incident-
shaped package, nine chain-break variants and register-level integration;
the housestyle python suite adds BrokenPptxTests for masterless, kit-made
and unopenable files. Full combined suite receipt follows on the task
card before review.

Impact: register_artifact now runs a pure-Go OPC structural check for
docx/xlsx/pptx and refuses broken packages with a kit rebuild hint;
lint_pptx gains structure-broken assertions (master/theme/layout chain,
open-failure fallback) that template mode cannot downgrade; prompts
forbid hand-assembled OOXML and specify the missing-library fallback.

Refs: Dev task feature/pptx-register-structural-gate
2026-10-14 07:36:21 +00:00
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +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 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 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 4053c4d038 Preserve API key CLI output compatibility
Why: U3 owner activation grants require protected files, while the merged U7 API-key command retains its existing optional file output.

Evidence: SELF go test ./cmd/agent-system ./internal/app -count=1.

Impact: Keep owner-grant delivery strict without changing API-key CLI defaults.

Refs: IAM U3 r1; U7 PR #185
2026-10-14 05:35:44 +00:00
weichen ff6b44bc32 Integrate U3 account contract with serialized account transactions
Why: U7 account audit and identity gates advanced on main while U3 account initialization and recovery were in progress.

Evidence: SELF merge-tree checks passed with go test ./... and isolated PostgreSQL store/server tests; exact-head replay follows.

Impact: Preserve pending-account grants, proof-linked recovery, protected CLI delivery, account serialization, and atomic audit.

Refs: IAM U3 r1; U7 PR #185
2026-10-14 05:27:42 +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 3e01614939 Require password lineage and make recovery retries metadata-only
Why: Historical full sessions could reach self-profile actions, and a lost recovery response had no safe idempotent retry contract.

Evidence: SELF receipts /tmp/iam-u3-full-password-profile.json, /tmp/iam-u3-recovery-idempotent-sqlite.json, /tmp/iam-u3-recovery-idempotent-pg.json, /tmp/iam-u3-account-sse-pass.json, /tmp/iam-u3-backend-idempotent-all.json, and /tmp/iam-u3-backend-idempotent-pg.json.

Impact: Profile actions require a password-authenticated full cookie; duplicate owner confirmation or rotation returns status without plaintext; an active SSE stream stops before its next business frame after disable.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 04:58:31 +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 7229abe50d Fence final run settlement against account disable
Why: A disable could commit after the run manager checked account state but before its final business message was stored.

Evidence: SELF receipts /tmp/iam-u3-final-settle-sqlite.json, /tmp/iam-u3-final-settle-pg.json, /tmp/iam-u3-backend-settle-all.json, /tmp/iam-u3-backend-settle-pg.json, and /tmp/iam-u3-http-two-instance-pg.json.

Impact: The final run transaction locks and rechecks the account, rejects post-disable business output, and permits only an account-denial failure record. Auth-off implicit local ownership remains explicit.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 04:48:53 +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 b9c0a344b6 Implement owner-confirmed account recovery and account controls
Why: Administrator-issued password and login-key paths could take over target accounts, while old credentials and plugin handles ignored current account state.

Evidence: SELF receipts /tmp/iam-u3-backend-all-replay.json, /tmp/iam-u3-backend-final-pg-core.json, /tmp/iam-u3-backend-build.json, and focused HTTP/concurrency receipts in /tmp/iam-u3-*.json.

Impact: Adds trusted pending-account initialization and reissue, preexisting owner-session proof, proposal/rotation/grant transactions, server-side admin search, current Host guards, and legacy HTTP/CLI closure. Production cutover and downstream pins remain pending.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 04:20:48 +00:00
weichen 8e80e2618e Merge pull request 'Serialize account recovery and session admission' (#185) from feature/u7-account-tx-audit into main
ci / vet-and-test (push) Successful in 49s
2026-10-14 04:14:47 +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 a4e33f192f Serialize account credential and session changes
ci / vet-and-test (pull_request) Successful in 49s
Why: Concurrent password changes, recovery key reissue, and session admission could accept retired credentials or omit rejected audits.

Evidence: Local Go vet and full tests passed; real PostgreSQL store suite, deterministic interleavings, and two-process contention passed with SELF receipts.

Impact: Account writes and session admission serialize per owner; stale sessions fail closed; checked rejections carry an audit row.

Refs: Agent-System-Dev #486; Agent-System-Backend #185; merge queue Issue #518
2026-10-14 04:08:14 +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 76e7cc7c18 Deliver API keys through exclusive private files
Why: API-key issuance shares the account maintenance no-create and credential-output contract; the old CLI printed its key and lacked --out-file.

Evidence: The new subprocess test failed before the flag existed, then passed for private output and pre-mutation collision refusal; current go vet and all Go tests pass.

Impact: apikey create accepts protected --dsn-file and --out-file, preserves stdout default, and never overwrites an existing destination.

Refs: Backend PR #185; Dev PR #486; U7 KTD14
2026-10-14 03:47:11 +00:00
weichen 571e2bd28a Verify every maintenance entry refuses an absent database
Why: The safe open requirement covers user, apikey and identity verify, including default CLI paths.

Evidence: All three paths use private missing directories; previous full Go and PostgreSQL receipts cover the implementation, with a focused recheck before push.

Impact: Test coverage only; no runtime behavior or migration changes.

Refs: Backend PR #185; Dev PR #486
2026-10-14 03:42:57 +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 6dbe025ec1 Integrate atomic account maintenance with current authentication
Why: Deliver U7 account transactions and audit without losing main auth_version, restricted-session, project or migration behavior.

Evidence: SELF go vet and all Go tests passed; isolated PostgreSQL store tests, 20 repeated two-process admission rounds, and real SIGTERM/SIGKILL persistence passed. Original failures remain in receipts; exact-head CI and independent Review follow.

Impact: Adds schema v31, noncreating account CLI opens, preflight credential output, same-transaction audit, and bootstrap admission guards. No live database touched.

Refs: Backend PR #185; Dev PR #486; original U7 r2 account specification; 2026-10-14 Root takeover
2026-10-14 03:42:03 +00:00
weichen 2fb505c0eb Add scoped account directory provider
Why: The old account lister exposed a global, always-valid directory without a domain locator or field authorization.

Evidence: SELF receipts /tmp/iam-u3-u3-backend-sqlite.json and /tmp/iam-u3-u3-backend-pg.json.

Impact: Adds versioned optional query and exact lookup, trusted RDM scope registration, visible-field cursor binding, and restricts legacy EXT-19 to effective system administrators. RDM consumption remains a separate U3-5 unit.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 03:29:26 +00:00
weichen 8dfc223fb6 Enforce current account state at credential and HTTP boundaries
Why: Disabled accounts and mixed credentials could continue to authenticate, while web creation accepted passwords and administrator privileges.

Evidence: SELF receipts /tmp/iam-u3-u2-store-sqlite-green.json, /tmp/iam-u3-u2-store-pg-regression.json, and /tmp/iam-u3-u2-server-sqlite-green.json.

Impact: Adds versioned enable/disable, revokes existing handles, protects the last operable manager, and exposes pending-account/profile APIs. Recovery proposal and legacy-entry closure remain in U3-4.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 03:21:30 +00:00
weichen d2efea2845 Guard active runs with current account state
Why: Disabling an account must stop in-flight work as well as new HTTP requests.

Evidence: SELF receipts /tmp/iam-u3-u2-runtime-regression.json and /tmp/iam-u3-u2-app-compile.json.

Impact: Run start, tools, HITL, streaming output, child runs and final settlement recheck the account independently of project authorization.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 03:20:41 +00:00
weichen 6c0289faf7 Add versioned account profiles and credential provenance
Why: preserve identity while supporting optional profiles and controlled account lifecycle.

Evidence: SELF complete SQLite store tests and real PostgreSQL account-model regressions; red receipts retained.

Impact: additive account schema with a security compatibility floor; no production migration executed.

Refs: IAM U3-1; approved Documents c236d3099dd3e20f7bdd35caf162e0be9a7d52eb
2026-10-14 02:57:31 +00:00
weichen 5d5fc9891c Merge pull request 'Enable private member work in shared projects' (#195) from feature/project-member-access into main
ci / vet-and-test (push) Successful in 45s
2026-10-13 11:52:27 +00:00
weichen d26f3cf2e8 Protect private project event replay and live delivery
ci / vet-and-test (pull_request) Successful in 49s
Why:
Legacy project streams replayed upload filenames and parser metadata to newly
authorized members even though the underlying cards remained owner-only.

Evidence:
SELF real HTTP red regressions exposed legacy replay, spoofed sharing claims,
private-history pagination, and live delivery after membership removal. The
affected packages, uncached full Backend suite, isolated PostgreSQL Store suite,
and agent-system build pass after applying the event boundary.

Impact:
Registered lifecycle streams resolve trusted resource ownership and require an
internal publication proof for cross-owner delivery. Event and proof commit in
one transaction; v30 adds an additive SQLite/PostgreSQL sidecar without granting
access to historical events. Seen and visible cursors remain separate, revoked
members leave live streams, and unregistered base behavior remains compatible.
The storage migration tool preserves the new proof rows.

Refs:
project-member-access-implementation-plan.md Task 1 / R5; authorized legacy SSE
privacy follow-up; project-member-composed-legacy-project-history.json
2026-10-13 11:12:47 +00:00
weichen d2f96e79bb Admit private member uploads and preserve masked metadata edits
Why:
Nested unchanged credential masks left empty parents that looked like managed
subtree overwrites. Shared members also hit an owner-only upload guard, while
upload ingestion cards could disclose private filenames and parser metadata.

Evidence:
SELF independent nested-mask probe, HTTP nested/mixed writeback, member upload,
project card privacy and authorization-change regressions failed before each fix
and passed after it. Affected settings/server suite passed; exact final-object
build and independent replay remain delivery gates owned by the task coordinator.

Impact:
Prune only parents emptied by ignored masks; explicit empty/real managed writes
stay rejected. Registered uploads check live work and trusted project existence,
keep originals owned by the actor, and recheck before persistence. Suppress only
upload project cards when no private stream exists; explicit structure cards and
unregistered upload behavior remain available.

Refs:
Dev project-member-access plan r1 Task 1/R5; independent nested mask P2;
2026-10-13 composed member-upload and private project-stream acceptance findings
2026-10-13 10:23:37 +00:00
weichen c4435d1415 Protect shared project metadata credentials and managed settings
Why:
Legacy project metadata can contain credentials outside the settings scope table.
Sharing raw projection settings exposed them, and generic project PATCH bypassed
the system-managed path guard already enforced by project settings endpoints.

Evidence:
SELF member Host regression failed before the fix. The fixed 4f72aaf server
baseline overlay reproduced HTTP metadata leakage and managed PATCH returning 200.
SELF shared Host-to-HTTP, immutable mask, dotted/schema/fallback, public mutation
and atomic managed-write regressions passed; uncached full suite, isolated PG
Store suite and static build passed. Independent delta replay remains a gate.

Impact:
Registered lifecycle metadata returns detached credential-masked views while
preserving service/configuration keys and persisted data. Generic project PATCH
shares the existing managed validator before rename or settings mutation; allowed
metadata key shapes and raw owner-only management lookups remain available.

Refs:
Dev project-member-access plan r1 Task 1 and R5; approved hard secret boundary;
2026-10-13 shared project metadata review follow-up
2026-10-13 09:46:20 +00:00
weichen 4f72aaf3d8 Recheck resumed project work and isolate publication replacement
Why:
Membership could be revoked while a tool waited for confirmation or a queued
HITL reply waited for consumption. Publication replacement also crossed an
empty project scope or another plugin's source namespace for the same Ref.

Evidence:
SELF replay of all four independent review reproductions failed before the
fix and passed unchanged afterward. Committed regressions cover direct and
queued HITL, yes/session confirmation, direct artifact writes, protected vectors,
exact empty project scopes, literal wildcard IDs and unregistered compatibility.
SELF uncached go test ./..., isolated PostgreSQL Store tests and static build passed.

Impact:
Recheck trusted live owner/project authorization when HITL resumes and at write
boundaries. Registered publishers replace only exact owner/project/ref/plugin
rows and vectors; ordinary unregistered publication semantics are preserved.

Refs:
Dev project-member-access implementation plan r1 Task 1; independent Backend
review findings against b9bea5bee0
2026-10-13 09:04:06 +00:00
weichen b9bea5bee0 Filter private knowledge before applying shared result limits
Why:
Higher-ranked private rows could exhaust SQL candidate limits and hide an
otherwise authorized published slice from a member's current-project search.

Evidence:
SELF TestAuthorizedKnowledgeFiltersBeforeTopK failed with no shared hits before
the change and passed after it. SELF go test ./... and the isolated PostgreSQL
Store suite passed; independent replay and current-HEAD CI remain owner gates.

Impact:
Scan the resource-authorized shared fulltext path until enough allowed results
are found. Preserve ordinary legacy retrieval candidate limits and semantics.

Refs:
Dev project-member-access plan r1 Task 1; R5 published knowledge availability
2026-10-13 08:47:19 +00:00
weichen 7cf8f02399 Add project lifecycle delegation and private member work
Why:
Shared project reads previously doubled as write permission, project mutations
bypassed the domain lifecycle, and coarse retrieval shared private resources.

Evidence:
SELF red-before-implementation logs cover projection, lifecycle, private work,
resource filtering, canonical retrieval scope, publication source and namespace.
SELF go test ./..., isolated PostgreSQL go test ./internal/store -count=1,
and CGO_ENABLED=0 go build ./cmd/agent-system passed; task owner owns replay/CI.

Impact:
Add optional actor-scoped projection/lifecycle capabilities; registered providers
control public lifecycle and live work. Preserve owned conversation history and
ordinary unregistered behavior. Authorize knowledge by stored ID/source, map
current host scopes through trusted service metadata, and filter artifact lists.

Refs:
Dev project-member-access implementation plan r1 Task 1; approved design r0.1;
2026-10-13 trusted publication Source hardening ruling
2026-10-13 08:43:02 +00:00
weichen 15566f9a33 Merge pull request 'Bump embedded core pin to TS mirror fix 5773e74' (#194) from chore/bump-core-pin into main
ci / vet-and-test (push) Successful in 51s
2026-10-13 07:12:20 +00:00
weichen 4e285bdd8d Bump embedded core pin to TS mirror fix 5773e74
ci / vet-and-test (pull_request) Successful in 1m3s
Why: 三层 pin 全等——Core 权威 TS 镜像补受管 scope 后,Backend 嵌套 pin 同步。
Evidence: go build 通过;组合校验随 Dev CI。
Impact: gitlink 一枚。
2026-10-13 07:07:47 +00:00
weichen 31a6b326c6 Merge pull request 'Add account name passthrough and EXT-19 account search' (#191) from feature/rdm-increment-account-search into main
ci / vet-and-test (push) Successful in 49s
2026-10-13 06:26:51 +00:00
weichen baaafe99d3 Merge origin/main into feature/rdm-increment-account-search
ci / vet-and-test (pull_request) Successful in 47s
2026-10-13 06:21:41 +00:00
weichen cfb8308895 Merge pull request 'Close the account lifecycle leftovers: atomicity, bootstrap lock, redeem guard, credential delivery' (#192) from feature/svc-account-hardening into main
ci / vet-and-test (push) Successful in 47s
2026-10-13 06:07:33 +00:00
weichen bc1e0a36f7 Close the account lifecycle leftovers: atomicity, bootstrap lock, redeem guard, credential delivery
ci / vet-and-test (pull_request) Successful in 51s
Why: The login recovery contract leftovers and KTD12/KTD14 require:
concurrent same-name account creation must not overwrite the winner,
first-admin bootstrap must serialize across processes, the redeem
endpoint backoff must be shared across instances, revoked API keys
must close established SSE streams, and CLI credentials need an
env-DSN path plus a non-interactive 0600 file delivery mode.

Evidence: go test ./... green; new tests cover 16-way concurrent
create (1 win/15 conflict, winner credentials intact), 8-way
concurrent bootstrap (single admin, refuse takeover after), redeem
guard shared across two store handles on one DB, SSE stream closed
after key revocation within the recheck window, and HTTP 429 after
the failure cap (SELF worktree run; CI is the GATE, PG via test-pg).

Impact: Migration v29 (redeem_attempts); CreateAccount becomes a
single-row INSERT with unique-violation conflict; BootstrapFirstAdmin
advisory-locks (PG) / rechecks (SQLite) and commits admin+key+audit in
one transaction; SSE revalidation covers API-key streams; redeem
backoff moves to the shared DB; CLI gains AGENT_SYSTEM_DSN fallback
and --out file delivery for create/reset-password/issue-login-key/
bootstrap-admin.

Refs: docs/svc-deploy/2026-10-13-login-recovery-contract.md leftovers;
proposal r2 KTD12/KTD14/R5/R11.
2026-10-13 06:05:45 +00:00
weichen fe977c8e52 Record account search surface entries
ci / vet-and-test (pull_request) Successful in 50s
Why: AccountSearcher 属有意的公开契约扩展,表面快照随行。
Evidence: go test ./... 全绿(TestSurfaceSnapshot 通过)。
2026-10-13 06:05:10 +00:00
weichen e3b6cca8bc Add account name passthrough and EXT-19 account search
Why: P3 人员选择器需要按账号或姓名查找成员并区分同名——EXT-19 目录此前只有 owner+有效态;扩展申请四要素记入 Dev 任务卡(授权管理读面下发姓名,联系方式仍不下发;停用能力经 facts F6 核实为未实现,Valid 维持存在性口径)。
Evidence: go test ./... 全绿;TestInprocHostSearchAccounts 覆盖姓名/账号子串、大小写不敏感、空 query 首页、ListAccounts 姓名回归;facts F5/F6 登记随 Dev PR。
Impact: AccountEntry 加 Name 可选字段;新增可选接口 AccountSearcher 与 inprocHost 实现;既有消费者不受影响(字段可选、接口可选)。
Refs: 提案 r1 P3;Dev 任务卡 feature/rdm-increment-settings-duties
2026-10-13 06:04:26 +00:00
weichen 741da4a0b6 Merge pull request 'Seed the mock startup option into the system settings scope' (#190) from fix/mock-startup-system-scope into main
ci / vet-and-test (push) Successful in 1m13s
2026-10-13 04:46:44 +00:00
weichen 0af1ebc915 Seed the mock startup option into the system settings scope
ci / vet-and-test (pull_request) Successful in 48s
Why: Since the system-managed settings change (#188), runtime.* writes
at the user scope are rejected, so the mock startup flag failed with
"system-managed settings require admin" and the serve process never
became healthy - the Dev multi-instance integration regression.

Evidence: go build ok; internal/app and internal/settings suites green
(SELF); the Dev composite CI on the follow-up PR re-runs
make test-multi-instance against this fix (GATE).

Impact: The mock startup flag now seeds runtime.mock via MergeSystem
(trusted operator input, effective for every tenant through the
Effective merge chain); no HTTP surface changes.

Refs: Dev PR #508; backend #188.
2026-10-13 04:45:12 +00:00
weichen 349a47de6f Merge pull request 'Close invalid sessions SSE streams at heartbeat recheck' (#189) from fix/sse-session-invalidation into main
ci / vet-and-test (pull_request) Successful in 48s
ci / vet-and-test (push) Successful in 1m10s
2026-10-13 04:35:26 +00:00
weichen 8b73afeef6 Close invalid sessions' SSE streams at heartbeat recheck
ci / vet-and-test (pull_request) Successful in 55s
Why: The 2026-10-13 login recovery contract (Dev docs/svc-deploy/
2026-10-13-login-recovery-contract.md, plan item 6.7) requires that
established SSE connections stop receiving private events once a
password write destroys their session.

Evidence: go test ./... green; TestSSESessionInvalidationClosesStream
streams over a real HTTP server, invalidates the session via
AdminResetUserPassword, and asserts the stream terminates with the
session-expired marker within the recheck window (SELF, worktree run).

Impact: Session-authenticated event streams revalidate at a 15s
heartbeat cadence (package var, shortened in tests) and terminate on
invalidation; API-key streams are unchanged (key revocation is separate).
EventSource reconnects are rejected by the auth middleware.

Refs: Dev PR #504; login recovery contract section 6.7.
2026-10-13 04:33:17 +00:00
weichen 76c75ce50c Merge pull request 'Enforce system-managed settings scope with admin system settings API' (#188) from feature/rdm-increment-settings-duties into main
ci / vet-and-test (push) Successful in 58s
2026-10-13 04:29:25 +00:00
weichen 496c784213 Merge origin/main into feature/rdm-increment-settings-duties
ci / vet-and-test (pull_request) Successful in 1m2s
2026-10-13 04:22:36 +00:00
weichen 386afcd4a6 Point core pin at merged main 0f94e4b0
ci / vet-and-test (pull_request) Successful in 42s
Why: Core PR #30 合入后按 pin 级联惯例把嵌套 pin 对准组件 main 合入提交。
Evidence: go test ./internal/settings/ ./internal/server/ 全绿。
Impact: 仅 gitlink 指针调整(同一能力内容,9150e6f 为其祖先)。
2026-10-13 04:20:07 +00:00
weichen 716fe9541c Enforce system-managed settings scope with admin system settings API
Why: P2——模型、密钥、Tavily、遥测与运行基础设施属系统受管项;user/project 层须整笔拒绝(含嵌套、点路径与子树覆盖),生效链剥除存量覆盖,管理员经新系统面统一配置。
Evidence: go test ./... 全绿;settings 单测覆盖嵌套/点路径/祖先标量拒绝、系统层生效、读面剥除;server e2e 覆盖 400/系统面读写/掩码;认证档 admin 门测试(普通 403、admin 200、密钥不泄漏)。
Impact: settings.go 受管标记与 mergeScope 拦截、Effective 增系统层、server 新增 GET/PUT /api/settings/system(无认证档本机即管理员)、SecretLookup 系统行回退、插件 schema 受管注入;测试基建 mock seed 与三处用例迁到系统层;svc.sh 播种改走系统面(仅 admin,无管理员时明确跳过)。Core gitlink 升至 9150e6f。
Refs: modules/documents/docs/proposals/2026-10-13-rdm-settings-members-duties-perms-increment.md P2
2026-10-13 04:05:15 +00:00
weichen f103540d60 Merge pull request 'Harden login-key recovery flow with auth-version transactions' (#187) from feature/svc-login-recovery-flow into main
ci / vet-and-test (push) Successful in 46s
2026-10-13 04:03:05 +00:00
weichen 467718a519 Harden login-key recovery flow with auth-version transactions
ci / vet-and-test (pull_request) Successful in 52s
Why: The login recovery contract (2026-10-13) requires admin-issued
one-time keys, in-transaction revalidation of restricted sessions, a
login race guard, and audited password writes; the previous
implementation validated sessions outside the change-password
transaction and let stale password verifications create sessions.

Evidence: go test ./... green (SQLite); store tests switch to
openTestStore so the CI test-pg job covers real PostgreSQL, including
100-way concurrent redeem single-winner and auth-version race cases
(SELF, local run).

Impact: New migration v28 (users/sessions.auth_version,
account_events). New admin endpoints POST/GET
/api/admin/users/{owner}/login-keys and DELETE
.../login-keys/{id}. Login binds session creation to the verified
auth version and creates restricted sessions for must-change accounts;
redeem gains per-IP backoff; cookies gain Secure under TLS; active
change-password and admin/CLI resets become single transactions that
bump auth_version, revoke keys and sessions, and write audit events.

Refs: modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md r2; Dev task feature/svc-login-recovery-flow
2026-10-13 03:36:38 +00:00
weichen dc736e339d Merge pull request 'Fall back to local artifacts when the blob store is a typed nil' (#186) from fix/u7-k4-artifact-upload into main
ci / vet-and-test (push) Successful in 43s
Why: an unset object-storage endpoint passed a typed nil into the store and artifact upload panicked.
Evidence: GATE vet-and-test run 2783 job 4031 success on 46073d30a5. Independent review approved with no blockers.
Impact: unconfigured stores fall back to the local artifacts directory. Configured S3 is unchanged.
Refs: #186
2026-10-13 01:53:02 +00:00
weichenandCursor 46073d30a5 Fall back to local artifacts when the blob store is a typed nil
ci / vet-and-test (pull_request) Successful in 46s
Why: an unset S3 client passed through the Store interface is not a nil interface, so uploads panic instead of using the local artifact directory.
Evidence: TestIsNil and TestFinishOpenTypedNilS3UsesLocalArtifacts failed before this change and pass after it. The PostgreSQL case skips unless AGENT_PG_TEST_DSN is set.
Impact: only an unset blob store falls back; a constructed S3 or local store is unchanged.
Refs: internal/blobstore/blobstore.go internal/store/store.go
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 01:22:57 +00:00
weichenandCursor 9754531cc9 Merge origin/main into the account transaction branch
ci / vet-and-test (pull_request) Successful in 51s
Why: identity open and the unknown-subcommand check landed on main, and this branch still has to refuse account migration while advertising capabilities.
Evidence: go test ./internal/store ./internal/app ./cmd/agent-system ./pkg/identity passed after the merge.
Impact: identity verify uses the no-migrate opener and returns the schema version. Account open still does not migrate. v28 is registered as readable by older artifacts.
Refs: cmd/agent-system/main.go, internal/app/app.go, internal/store/store.go, internal/store/migrate_compat.go
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 15:49:42 +00:00
weichen c6fb13016a Merge pull request 'Open existing databases without migrating' (#184) from feature/d1-identity-open into main
ci / vet-and-test (push) Successful in 47s
Why: identity probes must open an existing database without creating or migrating it, and DSN must stay out of argv.
Evidence: GATE ci / vet-and-test success on 0f192a4813, run 2625 job 3782.
Impact: version --json carries schema target and compat floor; unknown top-level commands exit 2.
Refs: #184
2026-10-12 15:05:04 +00:00
weichenandCursor 0f192a4813 Merge origin/main into feature/d1-identity-open
ci / vet-and-test (pull_request) Successful in 57s
Why: main gained AGENT_SYSTEM_DSN fallback in #182 after this branch
was cut. Both changes touch identity DSN parsing.
Evidence: go test ./pkg/identity/ ./internal/store/ ./cmd/agent-system/ for the identity, schema, and unknown-subcommand cases passed after the resolution.
Impact: keeps open-existing identity mode and the 0600 dsn file. Environment
DSN still wins when --dsn is absent, via ResolveDSN.
Refs: Backend #182; recovery plan r3.1 D1

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:55:56 +00:00
weichenandCursor 545a75b54e Prove a password still verifies after the database is reopened
ci / vet-and-test (pull_request) Successful in 49s
Why: a commit that only checks the password in the same process does not show the hash was durable.
Evidence: SELF go test TestPasswordSurvivesReopen.
Impact: test only.
Refs: docs/svc-deploy/u7-account-tx-audit.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:52:21 +00:00
weichenandCursor f13e8b1ab7 Commit account changes and audits in one transaction
Why: creating an account and its activation key, and changing a password, were separate commits, so a crash could leave a user without a key or a new password with a still-valid login key.
Evidence: SELF go test ./internal/store including TestConcurrentCreateOneWinner, TestPasswordChangeIsAtomic, TestOpenExistingDoesNotMigrateOrCreate, and TestBootstrapRefusesExistingBusiness. PostgreSQL cases are NOT_RUN locally.
Impact: strict insert returns ErrConflict instead of overwriting. Account CLI opens an existing database without migrating. --dsn and --password still work and warn. capabilities --json does not touch the database.
Refs: docs/svc-deploy/u7-account-tx-audit.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:49:40 +00:00
weichenandCursor 5c26a86331 Publish schema target and compatibility floor
ci / vet-and-test (pull_request) Successful in 47s
Why: a rollback can only be safe when the candidate says which schema it
will migrate to and whether older binaries can still read that schema.
A new migration that forgets to register that fact must fail the test.
Evidence: go test ./internal/store/ ./pkg/identity/ -run 'TestMigrationCompat|TestCapabilities' passed.
Impact: version --json and identity capabilities include schema.target and
schema.compatFloor. Every migration version is listed in
migrationBreaksOldReaders. Current versions do not break old readers, so
the floor stays 0.
Refs: recovery plan r3.1 D2

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:11:07 +00:00
weichen 41e7eea0c3 Merge pull request 'Fall back to AGENT_SYSTEM_DSN for identity CLI DSN delivery' (#182) from feature/identity-dsn-env into main
ci / vet-and-test (push) Successful in 41s
2026-10-12 14:05:30 +00:00
weichenandCursor 69ea7dcc14 Open identity commands on an existing database only
Why: identity ensure/verify went through the normal store open, which
creates a database and runs migrations. A deploy gate that calls the
candidate before stopping the old service could migrate production
ahead of the switch, and a missing database was turned into an empty one.
Evidence: go test ./pkg/identity/ ./internal/store/ ./cmd/agent-system/ -run 'TestCapabilities|TestEnsure|TestVerify|TestDSN|TestUsage|TestSystemIdentity|TestOpenIdentity|TestUnknownSubcommand' passed in this worktree.
Impact: identity CLI refuses a missing file or a missing agent_system_identity table, does not migrate, reads DSN from the environment or a 0600 file, and version --json plus identity capabilities emit one capability document. Unknown top-level commands exit 2.
Refs: recovery plan r3.1 D1; Dev feature/svc-d1-identity-flow

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:04:41 +00:00
weichen 58b4c640d5 Fall back to AGENT_SYSTEM_DSN for identity CLI DSN delivery
ci / vet-and-test (pull_request) Successful in 44s
Why: svc.sh delivers database credentials via environment per G1-7
(command lines are visible to every process on the host), but identity
ensure/verify only accepted --dsn on the command line for pg-bound
instances.
Evidence: go test ./pkg/identity/... ok; build ok.
Impact: --dsn remains highest priority, then AGENT_SYSTEM_DSN, then
embedded SQLite; no storage changes.
Refs: Dev U3 (real-combination CI gates); G1-7 credential discipline.
2026-10-12 14:04:07 +00:00
weichen 0191be52cd Merge pull request 'Add EXT-19 account lister host capability' (#181) from feature/ext19-account-lister into main
ci / vet-and-test (push) Successful in 43s
2026-10-12 13:47:26 +00:00
weichen bff6820e5f Add EXT-19 account lister host capability
ci / vet-and-test (pull_request) Successful in 42s
Why: 方案 r1 U3/KTD2——RDM 成员管理需最小账号目录(owner+有效态+分页);此前无公开能力,下游只能盲加成员字符串。
Evidence: 红(AccountLister 未定义编译红);绿:surface 棘轮 2.11.0 + TestInprocHostListAccounts(排序/游标/limit 钳制/真实 store);go test ./... 全绿。
Impact: 公开面 AccountEntry{Owner,Valid}+AccountLister(游标分页,limit 1-200 缺省 50);inprocHost 实现(users 表,无姓名/联系方式下发);既有 EXT-8b 版本断言放宽为下限。
Refs: 方案 r1 U3/KTD2;Dev#477/#180 identity CLI
2026-10-12 13:46:15 +00:00
weichen f718d36d23 Add red test for EXT-19 account lister surface
Why: 方案 r1 U3/KTD2——最小账号目录(owner+有效态+分页)是 RDM 成员管理的公开前置。
Evidence: go vet 编译红(AccountLister/AccountEntry/SurfaceVersion 2.11.0 未定义)。
Impact: 仅测试。
Refs: 方案 r1 U3/KTD2;Dev#477 identity CLI
2026-10-12 13:43:24 +00:00
weichen acabb85e2a Merge pull request 'Publicize identity CLI as pkg/identity for downstream reuse' (#180) from feature/identity-cli-public into main
ci / vet-and-test (push) Successful in 43s
2026-10-12 13:11:27 +00:00
weichen 5623000d51 Publicize identity CLI as pkg/identity for downstream reuse
ci / vet-and-test (pull_request) Successful in 43s
Why: runIdentity lived in cmd/agent-system package main, so downstream
combination binaries (Agent-System-RDM) cannot reuse it and their unknown
subcommands fall into the serve path; the 2026-10-12 rdm1/rdmcand batch
update failures traced back to that capability gap.
Evidence: go test ./pkg/identity/... ok; CGO_ENABLED=0 go build ok;
behavioral probes (usage rc=2, capabilities v1, ensure/verify roundtrip)
recorded in the PR.
Impact: identity semantics unchanged for the base binary; adds
identity capabilities (v1) and usage-on-unknown contract shared by all
products; no storage changes.
Refs: Dev PR #474 (svc.sh candidate gate); user root-cause report
2026-10-12 U1 (shared identity interface).
2026-10-12 13:10:13 +00:00
weichen d1f46c5646 Merge pull request 'Map background card failures to CardFailed with L1 body' (#177) from feature/fast-slow-display-u1 into main
ci / vet-and-test (push) Successful in 49s
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/177
2026-10-12 12:16:45 +00:00
weichen 830fea3d3b Merge pull request 'Consult resource-level authorizer on all shared read paths' (#179) from feature/ext8-read-consumption into main
ci / vet-and-test (push) Successful in 47s
2026-10-12 12:09:51 +00:00
weichen 19d4e1ef0d Consult resource-level authorizer on all shared read paths
ci / vet-and-test (pull_request) Successful in 46s
Why: 方案 r1 U9 第二切片——EXT-8b 契约落地后,读面(下载/清单/建会话/SSE 订阅/检索过滤/动作矩阵 read 回落)仍走 V1 owner×project 布尔,资源粒度与受信六要素未消费。
Evidence: 红测(SetResourceAuthorizerV2 未定义编译红,head 见任务收据);绿测三件:六要素查询断言(download=artifact×ID×Owner=alice×ProjectID、list/read=project×pid)、V2 拒绝压过 V1 放行(404 不泄露)、V2 缺席回落 V1(现行兼容);go test ./... 全绿。
Impact: Server 增 ProjectAuthzV2 槽与 SetResourceAuthorizerV2;authorizeSharedResource(六要素受信构造、字段缺失/出错一律 false)替换六处读路径的 V1 直调(artifactContent/getArtifact/sharedArtifactGet=listArtifacts/createConversation/SSE subscribe/检索 allow-fn/authorizeProjectAction read 回落);app.go 组合根注入 authzSlot.AuthorizeResource。V2 未注册时全部回落 V1,RDM 现网行为不变。
Refs: Backend#178;方案 r1 U9/KTD2
2026-10-12 12:08:14 +00:00
weichen 430caa6296 Add red tests for EXT-8b read-path consumption
Why: U9 第二切片——server 读面须按资源级六要素咨询 V2(红:SetResourceAuthorizerV2 未定义)。
Evidence: go vet 编译红(槽位/消费缺失);断言下载/list/建会话三路径查询要素、V2 拒绝压过 V1、V2 缺席回落 V1。
Impact: 仅测试。
Refs: Backend#178;方案 r1 U9
2026-10-12 12:04:43 +00:00
weichen bbe977351d Merge pull request 'Add EXT-8b resource-level authorization contract' (#178) from feature/ext8-resource-authz-v2 into main
ci / vet-and-test (push) Successful in 45s
2026-10-12 11:45:35 +00:00
weichen 0270b40197 Add EXT-8b resource-level authorization contract
ci / vet-and-test (pull_request) Successful in 45s
Why: 方案 r1 U9 首切片——V1(owner×project 布尔)无法表达资源粒度判定,读面(下载/列表/检索/模型上下文)需要执行端按资源咨询下游。
Evidence: 红:resource_authz_v2_test.go 编译红(ResourceAuthzQuery/V2 未定义);绿:pkg/extension surface 棘轮(2.10.0)+ internal/app 槽位测试(V2 咨询/V1 回落/字段缺失拒/error fail-closed/首注册者生效/compiled 拒绝);go test ./... 全绿(收据 /tmp/be-v2-test.txt)。
Impact: 公开面新增 ResourceAuthzQuery/ResourceAuthorizerV2/Registry.RegisterResourceAuthorizerV2;projectAuthz 增 V2 槽(AuthorizeResource:V2 优先、V1 兼容回落、受信字段缺失=拒);compiledRegistry fail-closed 拒绝声明式注册;读路径消费接线(server 5 处 authorizeSharedProject 升级)留待下一切片。
Refs: 方案 r1 U9/KTD2;RDM EXT-8 现有消费者(hostcaps/projectAuthz)
2026-10-12 11:44:00 +00:00
weichenandCursor 4d48abaa8c Map background card failures to CardFailed with L1 body
ci / vet-and-test (pull_request) Successful in 55s
Why: Stop showing raw task text as progress and stop lighting the
done lamp on failed background runs (U1 whitelist).
Evidence: go test ./internal/runmgr/ -count=1
Impact: create/promote/finish card bodies; material queue status text.
Refs: fast-slow-channel-display-u1-plan W2; Core ba289e7

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 07:43:31 +00:00
weichen 5e8ada727c Wait on CardDone and materialActive release in lifecycle test (#176)
ci / vet-and-test (push) Successful in 1m16s
2026-10-12 06:32:43 +00:00
weichenandCursor c963347e52 Wait on CardDone and materialActive release in lifecycle test
ci / vet-and-test (pull_request) Successful in 46s
Why: TestMaterialIntakeAttachmentOnlyLifecycle flaked when waiting on
systemTexts; deliverNotices runs before defer materialKeyRelease, so
done-dedup still saw the in-memory key and returned pending.
Evidence: go test ./internal/runmgr/ -run TestMaterialIntakeAttachmentOnlyLifecycle -count=20
Impact: Aligns the wait with materialExisting gates; no production change.
Refs: fix/material-intake-lifecycle-flake

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 06:31:43 +00:00
weichen dcc180dcad Merge pull request 'Add agent_system_identity DB marker (U1 I3)' (#170) from feature/recovery-u1-db-identity into main
ci / vet-and-test (push) Successful in 48s
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/170
2026-10-12 05:35:11 +00:00
weichenandCursor e7b70c24f8 Renumber agent_system_identity to v27 after login_keys took v26
ci / vet-and-test (pull_request) Successful in 42s
Why: main #171 claimed migration v26; rebase conflict required
bumping the U1 I3 table and completing copyOrder for both new tables.
Evidence: go test ./internal/store/ ./cmd/storage-migrate/ -run
'SystemIdentity|CopyOrder'
Impact: migrate v27; copyOrder += login_keys + agent_system_identity.
Refs: Backend PR #170; conflict with main 1d9e8f4

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 05:04:16 +00:00
weichenandCursor dcd5445d61 Include agent_system_identity in storage-migrate copyOrder
Why: TestCopyOrderCoversAllFixtureTables fails when new schema tables
are omitted from the migrate tool inventory.
Evidence: go test ./cmd/storage-migrate/ -run CopyOrder
Impact: copyOrder gains agent_system_identity (no FK deps).
Refs: U1 I3; Backend PR #170

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 05:03:45 +00:00
weichenandCursor e046bbd15c Add agent_system_identity marker with ensure/verify CLI
Why: U1 I3 needs a DB-side instance identity anchor matching
manifest.instance_id so wrong/missing DB blocks startup (I4).
Evidence: go test ./internal/store/ ./internal/app/; CLI smoke
ensure/verify on SQLite temp dir.
Impact: migration v26; store Ensure/Verify; identity subcommand;
serve checks AGENT_SYSTEM_INSTANCE_ID when set.
Refs: docs/recovery/u1-identity-manifest.md §5.2; Dev feature/recovery-u1-db-identity

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 05:03:45 +00:00
weichen 1d9e8f4a79 Merge pull request 'Add one-time login keys, restricted sessions, and OpenExisting' (#171) from feature/svc-deploy-u4-login-key into main
ci / vet-and-test (push) Failing after 43s
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/171
2026-10-12 05:00:47 +00:00
weichen 52d75fd931 Merge pull request 'Flatten surface action data into command params' (#174) from fix/surface-action-flatten-data into main
ci / vet-and-test (push) Successful in 47s
2026-10-12 04:16:41 +00:00
weichenandCursor ed443ed3f6 Flatten surface action data into command params
ci / vet-and-test (pull_request) Successful in 47s
Why: system.intent ParamsSchema requires top-level text, but
SubmitAction only nested values under data, so button intent always
rejected missing text during prod acceptance.
Evidence: go test ./internal/surfacesvc/ -run TestSubmitActionFlattensDataIntoParams
Impact: B-class intent and other schema-required params work via actions;
submit still receives nested data.
Refs: GenUI prod deploy acceptance; fix track

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 04:16:34 +00:00
weichen c7b30d2109 Merge pull request 'Guard nil confirm card in surface action dispatcher' (#173) from fix/surface-action-nil-card into main
ci / vet-and-test (push) Successful in 43s
2026-10-12 04:08:58 +00:00
weichenandCursor 4352f368cb Guard nil confirm card in surface action dispatcher
ci / vet-and-test (pull_request) Successful in 44s
Why: Committed button actions with no HITL card panicked on
resp.Card.ID during prod acceptance (nil pointer).
Evidence: go test ./internal/app/ ./internal/surfacesvc/; prod panic stack
Impact: B-class help/intent and C-class submit return without crash.
Refs: GenUI prod deploy acceptance

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 04:08:47 +00:00
weichen cb386023d0 Merge pull request 'Keep auto-present reminders synchronous to avoid run races' (#172) from feature/genui-auto-trigger-optin into main
ci / vet-and-test (push) Failing after 58s
2026-10-12 03:57:11 +00:00
weichenandCursor 9bd0aae9d8 Keep auto-present reminders synchronous to avoid run races
ci / vet-and-test (pull_request) Successful in 56s
Why: Async notice-run delivery raced with test teardown and in-flight
conversation runs (database closed / material lifecycle flakes).
Evidence: go test ./internal/runmgr/ -run Material
Impact: Reminders append as system messages only; material path still
inlines into startNoticeRun.
Refs: Dev PR #425 CI test-pg failure

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:56:59 +00:00
weichenandCursor 47463a9623 Align admin account e2e with must-change password gate
ci / vet-and-test (pull_request) Failing after 1m25s
Why: U4 middleware restricts must_change sessions to me/password/logout;
the legacy e2e expected admin create before first password change.
Evidence: go test ./internal/store/ ./internal/server/ ./internal/app/
Impact: TestAdminAccountFlow matches set-password + reloginRequired.
Refs: docs/svc-deploy/u4-one-time-login-key.md §5–6

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:54:59 +00:00
weichen b6ea1a5910 Merge pull request 'Drop runmgr import of genui to satisfy package boundaries' (#169) from feature/genui-auto-trigger-optin into main
ci / vet-and-test (push) Successful in 1m1s
2026-10-12 03:48:28 +00:00
weichenandCursor 41df7ae56f Drop runmgr import of genui to satisfy package boundaries
ci / vet-and-test (pull_request) Successful in 1m10s
Why: lint-boundaries forbids backend/runmgr -> backend/genui; keep the
auto trigger as an injected interface with local reminder text.
Evidence: bash scripts/lint-boundaries.sh
Impact: No behavior change; wiring still via app.SetGenUIAuto(genui.NewAuto).
Refs: architecture boundaries

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:48:10 +00:00
weichen 9b69117bea Merge pull request 'Add Generative UI auto-present opt-in with budget and cooldown' (#168) from feature/genui-auto-trigger-optin into main
ci / vet-and-test (push) Failing after 1m4s
2026-10-12 03:45:03 +00:00
weichenandCursor 6ff7fa9bd7 Add Generative UI auto-present opt-in with budget and cooldown
ci / vet-and-test (pull_request) Successful in 1m4s
Why: Known-boundary follow-up: owner opt-in auto page suggestions on
semantic task events without self-trigger from surface revisions.
Evidence: go test ./internal/genui/ ./internal/runmgr/ ./internal/settings/ ./internal/app/
Impact: Default-off settings key; material/plan/todo/document hooks nudge present.
Refs: docs/proposals/2026-10-12-generative-ui-auto-trigger.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:44:47 +00:00
weichenandCursor 1ba7225e83 Add one-time login keys, restricted sessions, and OpenExisting
ci / vet-and-test (pull_request) Failing after 56s
Why: passwordless activation/recovery must redeem only into a
set-password session; account ops must not create empty stores.
Evidence: SELF go test store/server login-key and OpenExisting cases.
Impact: auth middleware gates; CLI user create defaults to activate key.
Refs: docs/svc-deploy/u4-one-time-login-key.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:44:16 +00:00
weichen d1b5a94d15 Merge pull request 'Whitelist B-class surface actions for help and intent' (#167) from feature/genui-bclass-help-intent into main
ci / vet-and-test (push) Successful in 1m3s
2026-10-12 03:23:57 +00:00
weichen ba619747c5 Merge authoritative main into the B-class branch
ci / vet-and-test (pull_request) Successful in 1m12s
2026-10-12 03:23:43 +00:00
weichenandCursor 5e0077dc11 Whitelist B-class surface actions for help and intent
ci / vet-and-test (pull_request) Successful in 48s
Why: Generative UI U5 left the B-class action allowlist empty; owner
authorized first entries builtin.help and system.intent.
Evidence: go test ./internal/genui/ ./internal/surfacesvc/ ./internal/server/
Impact: Surface buttons may bind help/intent; button-entrance intent
matches only and does not nest-dispatch write commands.
Refs: docs/proposals/2026-10-12-generative-ui-bclass-help-intent.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:18:57 +00:00
weichen b160515905 Merge pull request 'Unwrap item-style spec wrappers, pair bare table cells, let subagents present pages' (#166) from feature/genui-spec-repair into main
ci / vet-and-test (push) Successful in 55s
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/166
2026-10-12 03:09:35 +00:00
weichen 9ec9f9a010 Merge pull request 'Harden cross-instance HITL/cancel command paths and add disk-full rejection tests (U7)' (#164) from feature/u7-load-fault-release into main
ci / vet-and-test (push) Successful in 59s
2026-10-12 03:09:03 +00:00
weichen 58598df095 Merge remote-tracking branch 'origin/main' into feature/u7-load-fault-release
ci / vet-and-test (pull_request) Successful in 51s
2026-10-12 03:07:33 +00:00
weichen 3d850a282f Unwrap item-style spec wrappers, pair bare table cells, let subagents present pages
ci / vet-and-test (pull_request) Successful in 1m4s
Why: MiniMax-M3 emits XML-RPC-style {"item": ...} single-key wrappers on
array fields (kv/cells/items), bare scalar table cells, and page
submission delegated to the general sub-agent had no generative-ui
tool - observed live on dev (events seq 1395/1396/2171) where every
present with kv/table/list nodes failed invalid-spec and the delegated
retry reported the tool missing.

Evidence: red test reproducing the exact production error
(.nodes.1.kv of type [][2]string) goes green after the repair; full
genui/prompts/runtime/runmgr suites pass (SELF); mismatched-length
cells stay untouched by a dedicated guard test.

Impact: generative-ui pages with kv/table/list nodes save successfully
again; the general sub-agent can submit pages (tool resolves only when
the plugin exposes it, so absent plugins are unaffected).

Refs: dev task card feature/genui-spec-repair; dev conversation
激活验证-1012.
2026-10-12 03:03:26 +00:00
weichen a80d3c3359 Merge pull request 'Declare admin-zone sections from compiled plugins (surface 2.9.0, WP3-I3)' (#165) from feature/adminzone-plugin-sections into main
ci / vet-and-test (push) Successful in 46s
2026-10-12 02:39:56 +00:00
weichen 1719b5f668 Merge authoritative main into the admin-zone branch
ci / vet-and-test (pull_request) Successful in 49s
2026-10-12 02:37:57 +00:00
weichen 62bfc6068a Declare admin-zone sections from compiled plugins (surface 2.9.0)
ci / vet-and-test (pull_request) Successful in 46s
Why: the admin zone could not host plugin-declared partitions - KTD4
requires the RDM audit partition to be reachable by RDM domain admins and
base superadmins without hardcoding RDM into the base AdminZone.
Evidence: go test ./internal/app ./internal/server ./pkg/extension green
incl. new admin-zone recording/endpoint tests; surface snapshot test
green after surface.txt update; CI (Dev composite) is authoritative.
Impact: extension surface 2.8.0 -> 2.9.0 (Registry.AddAdminZone +
AdminZoneSpec, fail-closed on invalid specs); applyCompiled records
declarations into the plugin summary; new authenticated GET
/api/admin-zone/sections returns the flattened, ordered declaration list.
Entry visibility stays metadata-only - data capability remains enforced
by each section's own command surface (fail-closed).
Refs: WP3-I3/KTD4; task card feature-adminzone-plugin-sections.
2026-10-12 02:28:51 +00:00
weichen 0a7413903d Harden cross-instance HITL/cancel command paths and add disk-full rejection tests (U7)
ci / vet-and-test (pull_request) Successful in 48s
Why: two-process HTTP acceptance (Dev tests/integration/multi-instance, added in
the matching Dev-side change) exposed three real cross-instance defects that the
U5 library-level tests bypassed via direct Manager calls, plus the U7 fault
matrix needs explicit disk-full rejection evidence.
Evidence: go test ./... green on SQLite and real PG (AGENT_PG_TEST_DSN);
new regressions TestCrossInstanceResolveDoesNotSettleCard (runmgr, PG-gated),
TestUploadRejectsCleanlyWhenObjectDirUnwritable and
TestArtifactOverwriteRejectsCleanlyWhenObjectDirUnwritable (server).
Impact: ResolveHITL no longer settles the card when the run executes on another
instance (command-bus fallback stays available); cancelRun enqueues the command
with the real conversation id and no longer reports a false local success;
object-store failures now provably leave zero DB/object residue.
Refs: collaboration storage proposal U7 (sections 3/5/7); task card
feature/u7-load-fault-release.
2026-10-12 02:28:48 +00:00
weichen 696ef15168 Merge pull request 'Add project-level surface aggregation and task-phase guidance (U6)' (#163) from feature/generative-ui-u6 into main
ci / vet-and-test (push) Successful in 53s
2026-10-12 02:12:59 +00:00
weichen add1fbb045 Add project-level surface aggregation and task-phase guidance (U6)
ci / vet-and-test (pull_request) Successful in 54s
Why: U6 extends pages from conversations into the task/project
workspace - surfaces aggregate across conversations under the owner's
project scope (project resolved authoritatively from the conversation
row, never self-reported by the model), and the catalog guidance now
organizes pages by task phase (requirements/execution/acceptance)
while stating that task facts come only from real task records.

Evidence: build/vet clean; surfacesvc suite green incl. the new
project-aggregation test (owner isolation, cross-owner zero, history
retained post-disable); genui/store suites green.

Impact: New endpoint GET /api/projects/{id}/surfaces; Submit resolves
project from conversation; catalog text guidance. Existing endpoints
unchanged.

Refs: proposal r2 U6; Dev task feature/generative-ui-u6
2026-10-12 02:12:42 +00:00
weichen d835ad7466 Merge pull request 'List compiled plugins in /api/plugins (WP3-I4)' (#162) from feature/plugins-compiled-listing into main
ci / vet-and-test (push) Successful in 45s
2026-10-12 01:59:21 +00:00
weichen 9aa51e5250 List compiled plugins in /api/plugins
ci / vet-and-test (pull_request) Successful in 47s
Why: compiled-in plugins (generative-ui, rdm) never appeared in the admin
plugins list: GET /api/plugins only scanned the directory host, so binary-
level capabilities were invisible to operators (user-reported gap).
Evidence: go test ./internal/app/ ./internal/server/ green locally incl.
new TestApplyCompiledRecordsSummary and TestListPluginsMergesCompiled;
CI (make test) is authoritative.
Impact: applyCompiled records each plugin's registered commands/intents/
skills into a summary handed to the server; /api/plugins merges synthesized
inproc entries (backend.mode=inproc, apiVersion=extension surface version,
provides from the registration record). Directory entries win on id
collision. No public extension-surface change.
Refs: WP3-I4; task card feature-plugins-compiled-listing.
2026-10-12 01:44:56 +00:00
weichen e49f165d0b Merge pull request 'Material-intake guaranteed loop: deterministic spawn, choice chain, queue, cardflow on (Dev task feature/material-intake-flow)' (#161) from feature/material-intake-flow into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 17:47:56 +00:00
weichen 409390fff1 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Successful in 42s
2026-10-11 17:37:51 +00:00
weichen 4c637d4071 Merge pull request 'Repair object-shaped children maps into arrays in page specs' (#160) from fix/genui-children-repair into main
ci / vet-and-test (push) Successful in 49s
2026-10-11 16:57:51 +00:00
weichen 432b916cba Repair object-shaped children maps into arrays in page specs
ci / vet-and-test (pull_request) Successful in 52s
Why: Live E2E on the dev instance - the model keyed section children
as an object map (children.item.*), the strict decode rejected it, and
the page fell back to a file artifact; the guidance fix made the model
call present, this closes the remaining shape gap.

Evidence: genui suite green incl. the reproduction case; post-deploy
E2E rerun recorded in the Dev task.

Impact: Repair strategy only; validation unchanged.

Refs: Dev task fix/genui-page-visibility (E2E follow-up)
2026-10-11 16:57:48 +00:00
weichen 16ed7b281b Add background queue for system tasks and flip ui.cardflow default on
Why: at the 3-per-conversation cap the guaranteed material task was dropped silently, and long deliverables stayed in chat because cardflow defaulted off (user ruling 2026-10-11).
Evidence: new queue lifecycle test (queued card, hard-reject for model path, FIFO start on release, card promote) passes; full go test ./... exit 0 (local, SELF).
Impact: queueable system spawns create a queued progress card at capacity and start FIFO when a slot frees (memory-only queue; restart limitation noted); model delegate_task still hard-rejects; ui.cardflow default true (EffectNewRun).
Refs: Dev task .task/feature-material-intake-flow.md I3.
2026-10-11 16:56:59 +00:00
weichen 42867aabf4 Add deliverable-choice chain: confirm_choice tool, notice contract, backstops
Why: after material organization completes, weak models report and end the turn without offering the user a choice of formal deliverables, and even after a user picks one the model may close the turn without starting the background task.
Evidence: new tests (notice-choice chain, deliverable backstop, free-input path, invalid args, notice contract) pass; full go test ./... exit 0 (local, SELF).
Impact: new confirm_choice tool issues a HITL choice card and routes the answer into a hard delegate instruction; material notices instruct the model to report then offer choices; stop hook adds two one-shot backstops (choice not offered in material notice runs; choice made but no background delegation).
Refs: Dev task .task/feature-material-intake-flow.md I2.
2026-10-11 16:53:53 +00:00
weichen 02858f486b Add deterministic material-intake background path for attachment sends
Why: the dual-speed entry hint is advisory only; real runs (MiniMax, 3 attachments, no text) end with a clarifying question and zero delegation, so background cards and result backfill never start.
Evidence: new unit tests in runmgr/material_test.go and runtime/material_flow_test.go all pass; full go test ./... exit 0 (local, SELF).
Impact: attachment-carrying runs now guarantee a material-organization background task (pre-spawn on attachment-only/heavy sends, stop-hook backstop otherwise when the model did not delegate); subagent receives inline parsed text with budget plus knowledge_search guidance; content-key dedup prevents duplicate spawns (in-flight, completed, resend); model-authored delegate_task gains a materials appendix; settings runtime.materialIntake (default on) and runtime.materialIntakeChars added.
Refs: Dev task .task/feature-material-intake-flow.md I1; user ruling 2026-10-11.
2026-10-11 16:45:49 +00:00
weichen faae3d3901 Merge pull request 'Add model identity and in-database vector top-k via pgvector (U6 slice 3)' (#159) from feature/collab-storage-u6-vector into main
ci / vet-and-test (push) Successful in 10m52s
2026-10-11 16:31:25 +00:00
weichen 9b33df6625 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Failing after 10m28s
2026-10-11 16:31:15 +00:00
weichen 5d5cef9fcd Add model identity and in-database vector top-k via pgvector (U6 slice 3)
ci / vet-and-test (pull_request) Successful in 56s
Why: plan U6 vector slice (KTD11 correctness step) - same-dimension vectors from different models must never mix, and PG tiers should run filtered exact top-k in the database instead of scanning every row in-process.

Evidence: full suite green on SQLite and real PostgreSQL 18.6 with pgvector (35 packages each tier) - pgvector_test covers model-identity and dimension isolation, update/delete atomicity (BLOB and vec_pg swap together), shared top-5 with score monotonicity, Recall@10 = exact-scan parity, deny-all predicate leakage; storage-migrate tests green after excluding the derived vec_pg column.

Impact: v25 migration adds model/content_hash columns and (PG, best-effort DO block) the pgvector extension plus vec_pg column - absence degrades to in-process scan; UpsertKnowledgeVectorExt dual-writes BLOB (cross-tier authority) and vec_pg; SearchKnowledgeVectorsModel/SharedModel filter by model identity and use SQL exact top-k (ORDER BY vec_pg <=> q) with automatic degradation; embedders expose Model() and the rag pipeline threads it through indexing and both retrieve paths; storage-migrate excludes vec_pg as a derived column.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U6 slice 3, KTD11; R9.
2026-10-11 16:31:12 +00:00
weichen 1fa03a3cbb Merge pull request 'Add AdminProvider capability and HostProjectID context (EXT-18)' (#158) from feature/base-ext-admin-project into main
ci / vet-and-test (push) Successful in 54s
2026-10-11 16:23:31 +00:00
weichen 4ae125a3ef Add AdminProvider capability and HostProjectID context (EXT-18)
ci / vet-and-test (pull_request) Successful in 57s
Why: RDM KTD4 dual-layer admin requires base isAdmin visibility (superuser slot); KTD5 host-project binding needs trusted current-project context.

Evidence: SELF 4 ext18 tests pass + surface snapshot regenerated + full pkg/extension + internal/app suites green.

Impact: extension.Request gains HostProjectID; optional AdminProvider interface (EXT-17 pattern); inprocHost implements IsAdmin via GetUserAuthState; three dispatch sites set HostProjectID from conversation.

Refs: RDM docs/proposals U1 base extension requests
2026-10-11 16:23:15 +00:00
weichen 25fbea8522 Merge pull request 'Add same-transaction outbox, S3-compatible blobstore, and real-process acceptance (U5 remainder)' (#157) from feature/collab-storage-u5-outbox into main
ci / vet-and-test (push) Successful in 1m4s
2026-10-11 15:58:18 +00:00
weichen e1dc93d147 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Successful in 1m10s
2026-10-11 15:58:03 +00:00
weichen eb4796cf61 Add same-transaction outbox, S3-compatible blobstore, and real-process acceptance (U5 remainder)
ci / vet-and-test (pull_request) Successful in 46s
Why: plan U5 remainder (KTD5/KTD9 + exit criteria) - commands must commit atomically with business writes, artifacts need a shared object store for multi-instance, and the two-real-process acceptance matrix must run in CI.

Evidence: full suite green on SQLite and real PostgreSQL 18.6 (35 packages each tier) - outbox tx test (rollback removes the command, commit makes both visible, idempotent re-enqueue), S3 store tests (SigV4-signed round trip, 404 sentinel, idempotent delete, presigned GET, env fallback), real-process acceptance (testbinary self-spawn of two serve processes with per-process identity and command consumers against one schema: same-conversation contention, cross-instance cancel command claimed exactly once, stale-owner settle fenced with run unchanged).

Impact: EnqueueCommandInTx for in-transaction command outbox and AppendEvents now notifies in the same transaction (KTD6 front half); blobstore.S3 (SigV4, presign, prefix isolation) injected from BLOBSTORE_S3_* env when configured, local fallback otherwise; realproc_test provides the process-level multi-instance acceptance gate (PG-env gated, runs in CI test-pg job).

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U5 KTD5/KTD9, section 5 exit criteria; R4/R7/R10.
2026-10-11 15:57:12 +00:00
weichen 1df183b104 Merge pull request 'Guide the model to present pages for page-like deliverables' (#156) from fix/genui-page-visibility into main
ci / vet-and-test (push) Successful in 47s
2026-10-11 15:56:08 +00:00
weichen 820aa4432e Guide the model to present pages for page-like deliverables
ci / vet-and-test (pull_request) Successful in 47s
Why: Field evidence from the dev instance - the present tool was
projected and enabled but never called, because every resident prompt
module steers 'make me a report' toward file artifacts; the tool
description carried only the component catalog, not when to use it.

Evidence: genui suite green (catalog assertion extended implicitly by
existing shape test); end-to-end model behavior verified post-deploy
in the Dev task.

Impact: Tool description text only (self-gating: description appears
only when the activation gate projects the tool).

Refs: Dev task fix/genui-page-visibility
EOF
)
2026-10-11 15:44:33 +00:00
weichen eb12f102f2 Merge pull request 'Add the surface action pipeline: ledger, guards, dispatch, submit (U5)' (#155) from feature/generative-ui-u5 into main
ci / vet-and-test (push) Successful in 1m6s
2026-10-11 14:24:34 +00:00
weichen f5eb27df86 Add the surface action pipeline: ledger, guards, dispatch, submit (U5)
ci / vet-and-test (pull_request) Successful in 1m10s
Why: The approved U5 scope needs the action half of the loop: an
idempotent ledger (migration v24), a single-choke SubmitAction (plugin
activation, current-revision, action whitelist, params-hash
idempotency), dispatch through the command bus so DangerHigh lands on
the existing inline confirm card with no Confirmed passthrough (the
endpoint does not even bind the field), the button-only
generative-ui.submit storing form data in the plugin KV ledger, and
catalog discipline rejecting unknown action ids at present time.

Evidence: go build/vet clean; full go test ./... green; surfacesvc/
genui/store suites also green under AGENT_PG_TEST_DSN; red matrix
covers disabled/stale-revision/cross-owner/unknown-action/idempotent
same-and-different-params/danger-high-await-confirm and the endpoint
forged-confirmed case.

Impact: Additive migration v24 + new endpoint/command; existing
endpoints and command semantics untouched (B-class whitelist starts
empty per the approved scope).

Refs: U5 action-scope r2 (approved, Documents #71); UI #46/#47;
Frontend #56; Dev task feature/generative-ui-u5
2026-10-11 14:24:31 +00:00
weichen 44d45e1092 Merge pull request 'Re-pin agent-system-core to 89f3906 to match Dev core' (#154) from feature/core-repin-89f3906 into main
ci / vet-and-test (push) Successful in 1m3s
2026-10-11 13:54:32 +00:00
weichen ec9d5f78fe Re-pin agent-system-core to 89f3906 to match Dev core
ci / vet-and-test (pull_request) Successful in 1m7s
Why: PR #152 pinned the bundled core back to 7a98a7e citing "Dev core is
at 7a98a7e", but Dev origin/main pins modules/core at 89f3906; the
misalignment breaks Dev check-pins for any tree that materializes
recorded pointers (exposed by a fresh task worktree running make test).

Evidence: go build ./... and internal/server + internal/store tests
green on the repinned tree.

Impact: restores modules/backend/deps/agent-system-core == Dev
modules/core (89f3906), unblocking Dev gitlink bumps.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:52:34 +00:00
weichen 51ad3ce115 Merge pull request 'Fall back to session cookie on stale Bearer key and report authVia' (#153) from feature/admin-auth-gate-fix into main
ci / vet-and-test (push) Successful in 47s
2026-10-11 13:48:25 +00:00
weichen 6681c3b47c Fall back to session cookie on stale Bearer key and report authVia
ci / vet-and-test (pull_request) Successful in 44s
Why: a browser holding a revoked API key in localStorage sent the Bearer
header on every request; key precedence 401'd all APIs despite a valid
login session, locking admins out of user management. /api/me also
reported mustChangePassword for key principals, who cannot change
passwords (session-owned), dead-ending the forced-change gate.

Evidence: new TestStaleKeySessionFallback covers fallback and authVia;
TestAuthMiddleware/TestAdminAccountFlow unchanged and green; full
go test ./... green.

Impact: invalid key falls back to a valid session cookie before 401
(cookie-less programmatic clients unaffected); /api/me gains
authVia ("key"|"session"|""), letting the frontend keep key holders
out of the change-password gate.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:45:52 +00:00
weichen 9a1c911e53 Merge pull request 'Pin agent-system-core to 7a98a7e (align with Dev core)' (#152) from feature/u5-core-pin into main
ci / vet-and-test (push) Failing after 0s
2026-10-11 12:30:58 +00:00
weichen bfeeefab94 Pin agent-system-core to 7a98a7e (align with Dev core)
ci / vet-and-test (pull_request) Failing after 1s
Why: Dev modules/core is at 7a98a7e7 (core PR #26); backend's internal pin lagged at 89f3906 after a parallel merge, failing Dev check-pins.

Evidence: build and store/runmgr suites green with the aligned pin; check-pins green after this merge.

Impact: gitlink bump only.

Refs: core PR #26/#28 lineage.
2026-10-11 12:30:49 +00:00
weichen 840757240b Merge pull request 'Add execution ownership leases with epoch fencing and persistent run commands (U5)' (#151) from feature/collab-storage-u5-ownership into main
ci / vet-and-test (push) Successful in 1m5s
2026-10-11 12:26:35 +00:00
weichen 9422bf9c28 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Failing after 1m11s
2026-10-11 12:26:21 +00:00
weichen 603f1c0fa4 Add execution ownership leases with epoch fencing and persistent run commands (U5)
ci / vet-and-test (pull_request) Successful in 57s
Why: plan U5/KTD4+KTD5 - before a second instance may serve writes, conversation execution must have a single valid owner (DB lease, epoch increments on takeover), stale owners must not settle (in-transaction fence check), and cancel/HITL must reach the executing instance via a persistent command inbox.

Evidence: full suite green twice-relevant on SQLite and real PostgreSQL 18.6 (35 packages each tier) - lease tests (claim/contention/re-claim/renew fencing/expiry takeover/monotonic epoch, command inbox idempotent enqueue, atomic concurrent claim) and multi-instance tests (same-conversation contention rejected, stale-owner settle fenced with run unchanged, cross-instance cancel consumed exactly once by the owner instance, non-owner does not consume).

Impact: v23 migration (conversation_executions + run_commands); ClaimConversation stacks a DB lease on PG tiers (15min, release on conversation release); settle path fenced (fenced runs emit run.fenced, no terminal write); StartCommandConsumer polls the inbox for commands on locally-owned executions (cancel routes to local cascade or DB-level close-out; HITL revalidates gate and rejects expired explicitly); server cancel/HITL fall back to command enqueue with 202 when the run is not local; app wires instance identity (host:addr on PG tiers) and starts the consumer.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U5 KTD4/KTD5; R4/R10.
2026-10-11 12:25:31 +00:00
weichen 9f7b3c5405 Merge pull request 'Pin agent-system-core to 89f3906 (surface TS types and event union)' (#150) from feature/generative-ui-u4-coredep into main
ci / vet-and-test (push) Successful in 1m3s
2026-10-11 12:22:45 +00:00
weichen fadce52dce Pin agent-system-core to 89f3906 (surface TS types and event union)
ci / vet-and-test (pull_request) Successful in 1m2s
Why: Core #27/#28 landed the surface snapshot types and the
surface.updated union entry; the backend dep copy must match Dev's
modules/core pin or check-pins fails.

Evidence: scripts/check-pins.sh + combined make test in the Dev
workspace after re-pinning.

Impact: Gitlink bump only.

Refs: Core PR #27/#28; Dev task feature/generative-ui-u4
2026-10-11 12:22:41 +00:00
weichen 6abf112765 Merge pull request 'Add the generative-ui plugin, capability gating, and surface projection' (#149) from feature/generative-ui-u3 into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 10:38:27 +00:00
weichen 367dc207b3 Add the generative-ui plugin, capability gating, and surface projection
ci / vet-and-test (pull_request) Successful in 42s
Why: Generative UI U3 - the agent needs a real submission path and the
next turn needs to see prior pages: a built-in in-process plugin
registers generative-ui.present (exposeAsTool) carrying the catalog
(prompt=validation single source) with a schema-repair strategy
measured against real MiniMax-M3 deviations; the command bus gains an
owner-scoped tool gate so a disabled plugin stops injecting its tool
(execution guard remains in surfacesvc); ctxasm projects saved surfaces
back into context so the model can revise existing structure; doc-refs
are authorized against the owner's real artifacts.

Evidence: unit suites green (genui guards/epoch/doc-ref, gate fail-closed,
projection content); full go test ./... green; cmdbus/ctxasm also green
under AGENT_PG_TEST_DSN; real-model closed loop (env-gated
TestRealModelSurfaceLoop) passes: task->spec->save->projection->revise
with revision 1->2 and structure change, CNY 0.08 total.

Impact: New internal/genui package; additive cmdbus/ctxasm/runmgr/
runtime hooks; no storage or contract changes (U2 layer untouched).

Refs: Documents proposal r2 U3; Core PR #25/#26; Backend #143; Dev task
feature/generative-ui-u3
2026-10-11 10:38:15 +00:00
weichen 2bfebe378c Merge pull request 'Pin agent-system-core to the node-id fields merge (align with Dev core)' (#148) from feature/u6-core-pin into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 09:33:28 +00:00
weichen f8db2225bf Pin agent-system-core to the node-id fields merge (align with Dev core)
ci / vet-and-test (pull_request) Successful in 41s
Why: Dev top-level modules/core advanced to 7a98a7e7 (core PR #26, optional node id/source TS contract fields); check-pins requires the backend internal pin to match.

Evidence: go build and store/pgbus suites green with the bumped pin; check-pins green in the Dev worktree after this merge.

Impact: gitlink bump only (additive optional contract fields).

Refs: core PR #26.
2026-10-11 09:33:19 +00:00
weichen 8c113a70b8 Merge pull request 'Catch up event streams by cursor with real reconnect and periodic rescue (U6 slice 1)' (#147) from feature/collab-storage-u6-events into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 09:30:30 +00:00
weichen fc75847580 Catch up event streams by cursor with real reconnect and periodic rescue (U6 slice 1)
ci / vet-and-test (pull_request) Successful in 43s
Why: plan section 2/KTD6 found the listener fetched only the last event of a batch (intermediate events invisible to live subscribers) and kept waiting on a dead connection after a drop (no actual reconnect).

Evidence: pgbus suite green twice against real PostgreSQL - batch of 64 all visible with continuous seq, 1200 events across pages, connection-kill reconnect recovers the outage window, no-notify events delivered by the periodic rescue; full backend suite green on both tiers.

Impact: Listener tracks per-conversation cursors (first-seen catch-up from 0 is harmless under SSE seq dedup), pages via EventsAfter to the notified watermark, rebuilds the connection with backoff and resumes known streams, and a rescue ticker covers lost notifications; Notifier contract unchanged.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U6 slice 1, KTD6; R5.
2026-10-11 09:30:29 +00:00
weichen 9ee87dcf2d Merge pull request 'Pin agent-system-core to 7a98a7e (TS contract authority for node ids)' (#146) from feature/generative-ui-u2-coredep into main
ci / vet-and-test (push) Successful in 39s
2026-10-11 08:46:50 +00:00
weichen b302446986 Merge remote-tracking branch 'origin/main' into feature/generative-ui-u2-coredep
ci / vet-and-test (pull_request) Successful in 40s
2026-10-11 08:46:43 +00:00
weichen 5191efd891 Pin agent-system-core to 7a98a7e (TS contract authority for node ids)
ci / vet-and-test (pull_request) Successful in 39s
Why: Core PR #26 made ts/types.ts carry the node id/source fields; the
backend dep copy must match Dev's modules/core pin or check-pins fails.

Evidence: scripts/check-pins.sh in the Dev workspace; combined make
test rerun after re-pinning.

Impact: Gitlink bump only.

Refs: Core PR #26; Dev task feature/generative-ui-u2
2026-10-11 08:46:19 +00:00
weichen a466838440 Merge pull request 'Cover the surface tables in storage-migrate copy list' (#145) from feature/storage-migrate-new-tables into main
ci / vet-and-test (push) Successful in 37s
2026-10-11 08:33:01 +00:00
weichen 1d5672e4fe Cover the surface tables in storage-migrate's copy list
ci / vet-and-test (pull_request) Successful in 40s
Why: the copy-order coverage guard caught that backend PR #143's new tables (surfaces, surface_revisions, plugin_activation) were absent from the migration list - they would have been silently skipped on real migrations.

Evidence: TestCopyOrderCoversAllFixtureTables failed on the merged main and passes with the addition; full migrator suite green on SQLite and real PostgreSQL.

Impact: copyOrder list only; ordered after their referenced owners/projects.

Refs: backend PR #143; Dev U4 task.
2026-10-11 08:32:45 +00:00
weichen e3b1e0310e Merge pull request 'Add the storage-migrate tool: inventory, backup, and identity-governed migration (U4)' (#144) from feature/collab-storage-u4-migrator into main
ci / vet-and-test (push) Failing after 39s
2026-10-11 08:30:58 +00:00
weichen 40be71a3f1 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Failing after 41s
2026-10-11 08:30:53 +00:00
weichen 093b86fe7e Add the storage-migrate tool: inventory, backup, and identity-governed migration (U4)
ci / vet-and-test (pull_request) Successful in 38s
Why: plan U4/R7 - the SQLite-to-PostgreSQL path needs a managed entry that never replays SQLite dumps as PG SQL, fixes migration identity against a frozen snapshot, refuses foreign or non-empty targets, verifies per-table counts and ordered checksums, and keeps secrets out of reports (KTD14).

Evidence: integration tests against real PostgreSQL green twice (end-to-end with bool/NULL/Unicode/BLOB/sequence assertions, idempotent rerun, interrupted-table resume, foreign-data and wrong-snapshot refusals, unknown-owner block); SQLite unit tests for inventory/backup/coverage; full backend suite green on both tiers.

Impact: new cmd/storage-migrate binary (make build now emits bin/storage-migrate); store gains a DB() accessor documented for ops tooling; no server or runtime behavior change.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U4 and section 6; docs/guides/team-storage.md section 10.
2026-10-11 08:30:01 +00:00
weichen d79514302f Merge pull request 'Add the Surface store, activation guard, and EXT-17 host capability' (#143) from feature/generative-ui-u2 into main
ci / vet-and-test (push) Successful in 40s
2026-10-11 08:27:02 +00:00
weichen 70983e19fe Merge remote-tracking branch 'origin/main' into feature/generative-ui-u2
ci / vet-and-test (pull_request) Successful in 40s
2026-10-11 08:26:55 +00:00
weichen 75829fa442 Add the Surface store, activation guard, and EXT-17 host capability
ci / vet-and-test (pull_request) Successful in 40s
Why: Generative UI proposal U2 needs the host side of the contract:
owner-isolated surface storage with revision CAS, an execution-level
enable/disable ledger (default-off, epoch bumped on every transition so
in-flight submits after a disable are rejected), a single-choke-point
service that validates specs (strict decode + catalog rules) before
saving, event publish with pending recovery, the optional SurfaceHost
extension capability, and direct API endpoints that share the same
guard (no bypass by entrance).

Evidence: go build/vet clean; full go test ./... passes (SQLite);
surface store tests also pass under AGENT_PG_TEST_DSN (PostgreSQL);
surface.txt regenerated via the AST snapshot test.

Impact: Additive migration v22 (new tables only); existing endpoints and
plugin_grants semantics untouched; old consumers unaffected. U3+ (data
binding, actions, frontend) deliberately out of scope.

Refs: Documents proposal 2026-10-10-generative-ui-plugin.md (r2 U2);
Core PR #25; Dev task feature/generative-ui-u2
2026-10-11 08:26:24 +00:00
weichen c8d66ca3cd Merge pull request 'Make notice redelivery iterative instead of self-recursive' (#142) from feature/notice-delivery-recursion-fix into main
ci / vet-and-test (push) Successful in 39s
2026-10-11 08:12:14 +00:00
weichen 1ef1fdeac9 Make notice redelivery iterative instead of self-recursive
ci / vet-and-test (pull_request) Successful in 42s
Why: deliverNotices re-invoked itself synchronously from a defer after a failed startNoticeRun requeued the batch - a persistently failing delivery (busy conversation, missing row, slow DB) recursed without bound and overflowed the stack (real-PG CI job 2352, internal/runmgr).

Evidence: regression test TestNoticeDeliveryFailureIsBounded (missing conversation row) hangs on the old code and passes with the fix; full backend suite green on SQLite and against real PostgreSQL 18.6 + Redis; clean-HEAD reproduction isolated an unrelated count=2 admin-login artifact.

Impact: failed batches now return after requeue (retried on the next enqueue or restart); notices arriving during delivery are picked up by a bounded loop tail instead of defer recursion; no behavior change on the success path.

Refs: Dev PR #333; plan R8 real-database gate.
2026-10-11 08:12:03 +00:00
weichen 05c3447ebb Merge pull request 'Add shared execution budgets and the action-authz matrix (U3 slice 2)' (#141) from feature/collab-storage-u3-budget-authz into main
ci / vet-and-test (push) Successful in 38s
2026-10-11 05:18:20 +00:00
weichen f84733d128 Add shared execution budgets and the action-authz matrix (U3 slice 2)
ci / vet-and-test (pull_request) Successful in 43s
Why: plan U3 remainder (R8/KTD13) - execution admission must be bounded and shared (leases in the database, parents yield while waiting so budgets cannot deadlock parent-child), and project authorization must distinguish read/edit/approve/execute/admin with default-deny for unconfigured actions.

Evidence: full suite green on SQLite and against real PostgreSQL 18.6 + Redis (isolated instance); new tests - admission_pg_test (enforcement, per-user, concurrent no-oversell), runmgr admission tests (saturated-budget sync spawn progresses, background children share the budget, bounded queue 429), server action-matrix tests (default deny, legacy adapter read-only, action-aware authoritative + fail-closed).

Impact: admission_leases (v21) with atomic lease accounting under an advisory lock; runtime.execBudgetGlobal/PerUser/execQueueMax settings (all zero = off, historical behavior preserved); conversation turns, background and sync subagents share the pool, parents yield around synchronous children and HITL waits; queue-full rejects 429 before writing; server gains ProjectAction matrix + SetProjectActionAuthorizer with legacy EXT-8 read fallback.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U3 R8/KTD13; docs/guides/team-storage.md section 9.
2026-10-11 05:18:12 +00:00
weichen 3def525902 Merge pull request 'Close request atomicity: locked message append, card versions, atomic rate limit, recovery holds (U3 slice 1)' (#140) from feature/collab-storage-u3-atomicity into main
ci / vet-and-test (push) Successful in 40s
2026-10-11 04:38:02 +00:00
weichen 5cd67be982 Close request atomicity: locked message append, card versions, atomic rate limit, recovery holds (U3 slice 1)
ci / vet-and-test (pull_request) Successful in 41s
Why: the approved plan U3 (KTD3/KTD7/KTD10 plus minimal recovery isolation) requires concurrent submits to not silently overwrite, unknown-side-effect runs to never auto-resume, and the shared rate limiter to degrade conservatively instead of failing open.

Evidence: full backend suite green twice-relevant paths against real PostgreSQL 18.6 and Redis (isolated instance) and on SQLite; new tests - card_version_test (concurrent single winner), recovery_hold_test + recovery_holds_test (skip/release/fail-closed CRUD), ratelimit atomic-window and degraded-fallback tests.

Impact: AppendMessageWithRequest now allocates seq under the conversation advisory lock in one transaction; activity_cards gain version (v19) with SaveCardVersioned/GetCardVersion/CardVersion and cardsvc user mutations (status/pin/action/resolve-by-ref) return 409-mapped conflicts while system mirrors keep blind writes; recovery_holds (v20) with admin list/release endpoints and fail-closed resume skip; Redis limiter uses one Lua INCR+PEXPIRE script and falls back to a per-instance limit with a Degraded counter.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U3 KTD3/KTD7/KTD10; R3/R6/R7/R8.
2026-10-11 04:37:50 +00:00
weichen f480c5b520 Merge pull request 'Retry transient SQLITE_BUSY at migration Begin and race on a materialized db' (#139) from feature/collab-storage-pg-tier-fix2 into main
ci / vet-and-test (push) Successful in 40s
2026-10-11 04:09:00 +00:00
weichen 6f20e6cc03 Retry transient SQLITE_BUSY at migration Begin and race on a materialized db
ci / vet-and-test (pull_request) Successful in 39s
Why: the connection-open phase (DSN pragmas incl. journal_mode on first open) can return SQLITE_BUSY immediately - the busy handler does not cover that phase - so four racing migrators failed in 0.03s on slow CI disks regardless of the 30s timeout (jobs 2293/2285).

Evidence: local -count=30 green (fast disk) vs CI deterministic failure at 0.03s (no timeout wait occurred); fix verified with -count=5 locally plus full store suite.

Impact: bounded retry (5 attempts, backoff) on Begin BUSY only - statements inside the IMMEDIATE transaction remain lock-protected; the racing test now materializes the file/WAL first, matching the KTD8 scenario (concurrent migrators on a provisioned database).

Refs: Dev PR #325; plan KTD8.
2026-10-11 04:08:50 +00:00
weichen efbd7b1d04 Merge pull request 'Raise the SQLite busy timeout to 30s for serialized migrators' (#138) from feature/collab-storage-pg-tier-fix1 into main
ci / vet-and-test (push) Failing after 40s
2026-10-11 03:42:55 +00:00
weichen 6f698d0ba5 Raise the SQLite busy timeout to 30s for serialized migrators
ci / vet-and-test (pull_request) Successful in 42s
Why: with _txlock=immediate the whole migration pass holds the write lock; four racing migrators on a slow CI disk exceeded the 5s busy timeout (job 2285: SQLITE_BUSY in TestMigrateRacingSQLite) although the local tier passes.

Evidence: go test -count=3 on the migration atomicity suite green locally; CI failure log cited in the Dev PR.

Impact: local SQLite tier only - lock contention waits up to 30s instead of erroring; no schema or API change.

Refs: Dev PR #325; plan KTD8.
2026-10-11 03:42:47 +00:00
weichen 2497508271 Merge pull request 'Add the team PG tier, atomic migrations and the connection budget (U2)' (#137) from feature/collab-storage-pg-tier into main
ci / vet-and-test (push) Successful in 41s
2026-10-11 03:11:12 +00:00
weichen 8633711b1d Add the team PG tier, atomic migrations and the connection budget (U2)
ci / vet-and-test (pull_request) Successful in 38s
Why: the approved collaboration plan U2 (KTD1/KTD8/KTD12) requires an explicit fail-closed team deployment tier, DDL-and-ledger-atomic migrations serialized across migrators, and a configurable PG connection budget with sanitized effective values.

Evidence: real PostgreSQL 18.6 + pgvector + Redis 8.10.2 local gate - full backend suite green twice with AGENT_PG_TEST_DSN/AGENT_REDIS_TEST_ADDR (isolated svc.sh instance collabtest:8897, redis 6399); SQLite suite green without env.

Impact: config tier+pool keys and validation; store PoolOptions + statement_timeout DSN param + single-transaction migration with PG advisory lock and SQLite _txlock=immediate + unknown-schema-version refusal; app tier guard fail-closed (team requires DSN+auth, no SQLite fallback); CLI flags and explain rows; tests for tier/pool parsing, atomicity fault-injection, racing migrators, old-schema upgrade; fixed pre-existing real-PG test defects (blind &search_path= appends in runmgr/pgbus helpers, pg_trgm per-schema install race, exhausted seq-conflict retries).

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U2, KTD1/KTD8/KTD12; R2/R3/R7.
2026-10-11 03:10:55 +00:00
weichen ea4651b1f8 Merge pull request 'Remove the empty -ws shell directory after each apply' (#136) from feature/agent-s0-workspace-overlay into main
ci / vet-and-test (push) Successful in 39s
2026-10-11 02:20:47 +00:00
weichen bd4b3bca94 Remove the empty -ws shell directory after each apply
ci / vet-and-test (pull_request) Successful in 40s
Why: relaxAndRemove cleared the per-exec id directory but left the -ws
parent behind, so every conversation accumulated an empty husk - the
live probe caught it in the workspace listing.

Evidence: go test ./... green; TestOverlayCleansShellDir pins the
behavior with a real bwrap roundtrip.

Impact: One extra os.Remove of the (now empty) parent; failures are
best-effort as before.

Refs: Dev task feature/agent-s0-workspace-overlay (fix-up of #135).
2026-10-11 02:18:01 +00:00
weichen 0c34b3595e Merge pull request 'Add the S0 task workspace overlay: frozen base, private upper, CAS apply' (#135) from feature/agent-s0-workspace-overlay into main
ci / vet-and-test (push) Successful in 41s
2026-10-11 02:14:36 +00:00
weichen 01fd3b6fd4 Add the S0 task workspace overlay: frozen base, private upper, CAS apply
ci / vet-and-test (pull_request) Successful in 43s
Why: The sandbox tier wrote the session directory directly, so a buggy
or runaway script could leave the workspace half-mutated and nothing
bounded what a timeout had already written. The plan's first landing
shape (section 4.4) swaps the session bind for a frozen-base overlay:
each exec freezes the session dir (hardlink clone + sha256 baseline),
runs on a private upper, and only a clean exit promotes the change set
back via CAS (baseline hash match -> atomic replace/remove; externally
changed targets double-store as *.conflict-* instead of clobbering).
Timeouts and kills discard the upper entirely - half-written state
never lands.

Evidence: go test ./... green including bwrap integration roundtrips
(TestSandboxWorkspaceOverlayRoundtrip: add/modify/delete through a real
overlayfs upper with whiteout handling and ws cleanup;
TestSandboxWorkspaceOverlayTimeoutDiscards: partial writes discarded on
timeout) and unit anchors for freeze manifest (.agentx excluded), CAS
add/modify, conflict double-store, and the change summary.

Impact: Opt-in via --exec-workspace overlay / AGENT_SYSTEM_EXEC_WORKSPACE
(only with --exec-backend sandbox; fail-closed on unknown modes). The
model sees a one-line change summary appended to the tool output, so
discards and conflict files are visible. Default behavior is unchanged.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md section
4.4; Dev task feature/agent-s0-workspace-overlay.
2026-10-11 02:04:05 +00:00
weichen eda90ed310 Merge pull request 'Log R4 reminder injection for observable evidence' (#134) from feature/r4-injection-log into main
ci / vet-and-test (push) Successful in 55s
2026-10-11 01:24:40 +00:00
weichen 5ebd1bb525 Log R4 reminder injection for observable evidence
ci / vet-and-test (pull_request) Successful in 56s
Why: Reminders are attached to the request copy and never reach the
session journal, so a transcript grep proves nothing about whether the
R4 trigger fired - the first verification attempt was invalid evidence.
The dual-speed nudge already logs for exactly this reason.

Evidence: go test ./internal/runtime green; the follow-up live probe
must show the [runtime] r4 escalation reminder injected line in the
instance log.

Impact: Two log lines, nothing else.

Refs: Dev task feature/agent-e4-escalation-ledger (third fix-up).
2026-10-11 01:22:49 +00:00
weichen 6647d4879c Merge pull request 'Switch the R4 failure trigger to a sliding window' (#133) from feature/agent-e4-escalation-ledger into main
ci / vet-and-test (push) Successful in 42s
2026-10-10 18:44:09 +00:00
weichen 6709325c38 Switch the R4 failure trigger to a sliding window
ci / vet-and-test (pull_request) Successful in 40s
Why: The consecutive-failure counter never fired in the live suite (0
reminders despite 20 failures) because real failures interleave with
successes - each success reset the streak. The pattern to catch is
"spinning overall", not "N in a row".

Evidence: go test ./... green; the anchor test now covers scattered
failures (3 in the last 6 outcomes escalates, 2 does not, once per run);
the next live run must show the reminder actually appearing in the
transcript.

Impact: recentOutcomes ring (last 6) replaces consecToolFailures; the
zero-hit search trigger keeps its own threshold (2); message wording
matches the window semantics.

Refs: Dev task feature/agent-e4-escalation-ledger (second fix-up).
2026-10-10 18:43:03 +00:00
weichen ebec49db3d Merge pull request 'Restrict the source tag to material reads; never tag the model's own files' (#132) from feature/agent-e4-escalation-ledger into main
ci / vet-and-test (push) Successful in 43s
2026-10-10 18:22:26 +00:00
weichen 03b4fdabaf Restrict the source tag to material reads; never tag the model's own files
ci / vet-and-test (pull_request) Successful in 42s
Why: The first E4 live comparison regressed badly (toolFailures 10->24,
toolCalls 63->166) and the instance transcript shows the loop: tagging
kernel read_file/grep results put a trailing [src_N] line on the
model's own workspace files, the model treated it as external
tampering, and re-read/re-edit cycles cascaded into the read-before-
write and file-changed guards.

Why: The ledger only ever needed SOURCES - searches always, and
workspace reads only under inputs/ (the upload material area). Grep
cannot distinguish materials from self-produced files, so it stays
untagged.

Evidence: go test ./... green; new anchor TestEvidenceLedger
OnlyTagsMaterialReads pins the inputs/-only rule; the re-run live
comparison follows on the Dev side.

Impact: evidenceTools loses read_file/grep; isEvidenceRead gates
workspace reads to inputs/; everything else (search tags, escalation
reminders, prompts) unchanged.

Refs: Dev task feature/agent-e4-escalation-ledger (fix-up of #131).
2026-10-10 18:11:24 +00:00
weichen d116eda0bc Merge pull request 'Add R4 escalation reminders and the structured source-id evidence ledger (E4)' (#131) from feature/agent-e4-escalation-ledger into main
ci / vet-and-test (push) Successful in 41s
2026-10-10 17:50:21 +00:00
weichen 21b8461525 Add R4 escalation reminders and the structured source-id evidence ledger (E4)
ci / vet-and-test (pull_request) Successful in 43s
Why: The plan's E4 remainder under the MiniMax-only constraint: the
harness must detect the model going in circles and inject a
change-of-strategy instruction (R4 - the model cannot be trusted to
notice its own repeat-fail loop), and number sourcing needs addressable
evidence: tool results carry [src_N|tool] tags so "found in materials"
becomes "found in source src_N" for both the model and the fact-check
notice.

Why: Failure counting lives in one place (onEvent end, keyed by the
slot status) because tool execution is parallel; the ledger and tag
append are serialized by the same mutex, and escalation reminders fire
once per condition streak - following the dual-speed nudge finding that
MiniMax ignores vague reminders but follows concrete instructions.

Evidence: go test ./... green; new anchors TestEscalationOnConsecutive
ToolFailures, TestEscalationOnZeroHitSearches, TestEvidenceLedger
TagsSource, TestEvidenceLedgerSkipsNonEvidenceTools. Live before/
after comparison runs on the Dev side per ruling 6.

Impact: knowledge/web/tavily searches, fs_read_file and the workspace
read_file/grep results now end with a source tag and enter the run
ledger (2k-char head); two consecutive tool failures or two empty
search results inject a concrete change-of-strategy reminder;
base.deliverables v7 and agent-sub-research v3 explain the tags;
fact-check notice points at src results. Non-evidence tools and
failures are unchanged.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E4,
R4); Dev task feature/agent-e4-escalation-ledger.
2026-10-10 17:41:38 +00:00
weichen bd687d27d4 Merge pull request 'Add estimate-marking awareness and a second fact-check round (E3)' (#130) from feature/agent-e3-evidence-gate into main
ci / vet-and-test (push) Successful in 45s
2026-10-10 16:10:42 +00:00
weichen 06f5835211 Add estimate-marking awareness and a second fact-check round (E3)
ci / vet-and-test (pull_request) Successful in 41s
Why: The E1 live comparison was the only metric that got worse
(unverifiedFacts 7 -> 12): the gate gave one correction round and then
shipped bare numbers. The design (E3) calls for number sourcing with a
mechanically checkable marking escape: numbers labeled as
targets/assumptions are honest planning quantities, not fabricated
facts, so the gate must let them pass - and the model must be told that
marking is the sanctioned way out.

Why: The marking decision is clause-scoped, not a fixed window - a
window wrongly blessed "3,500" because a neighboring clause said
"target 85%" (caught by the existing test). The second round only
fires when the unverified set actually changed, because the
continuation budget is shared across all final gates and a repeat
block on identical tokens starves the downstream gates (also caught by
an existing test).

Evidence: go test ./... green; new anchors TestMarkedAsEstimate,
TestUnverifiedFactsMarksPass, TestFactCheckGateRounds; the live eval
gains an unverifiedUnmarkedFacts field sharing the same marking rule.

Impact: base.deliverables v6 adds the hard number-sourcing rule;
research/explore/general sub-agent profiles v+1 require citations for
key numbers; fact gate: marked-in-clause numbers pass, round 2
escalates to "last interception" with the marking instruction, then
passes through with the check line. Eval report keeps the strict
series and adds the E3-series field.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E3,
D4); Dev task feature/agent-e3-evidence-gate.
2026-10-10 16:08:19 +00:00
weichen 017b40f761 Merge pull request 'Bump the agent-squad pin (E1 chain)' (#129) from feature/bump-squad into main
ci / vet-and-test (push) Successful in 42s
2026-10-10 10:12:30 +00:00
weichen 288e4763a3 Merge authoritative main into the bump branch
ci / vet-and-test (pull_request) Successful in 40s
2026-10-10 10:11:07 +00:00
weichen 5714c739de Merge pull request 'Wire the dual-speed lane into the real run path: entry hint, escalating nudge, think-stripped cards' (#128) from feature/dual-speed-real-path into main
ci / vet-and-test (push) Successful in 41s
2026-10-10 10:09:51 +00:00
weichen 7ef6ef86cd Bump the agent-squad pin for the E1 chain
ci / vet-and-test (pull_request) Successful in 42s
2026-10-10 10:09:01 +00:00
weichen c449a176f9 Restore the E1 agent-native kernel pin c348441 over the stale submodule state captured by the merge
ci / vet-and-test (pull_request) Successful in 43s
2026-10-10 10:06:55 +00:00
weichen 8355794e45 Merge remote-tracking branch 'origin/main' into feature/dual-speed-real-path
# Conflicts:
#	internal/runtime/llm_direct.go
#	internal/runtime/llm_test.go
2026-10-10 10:05:38 +00:00
weichen b3f885f215 Strip inline think blocks from subagent summaries and card bodies
ci / vet-and-test (pull_request) Successful in 43s
Why: the round-6 real-model dual-speed run showed MiniMax writing its
reasoning as inline <think> blocks; finishChild's partsText fed them into
the background notice turn and the inbox result card verbatim.
Evidence: new runmgr test covers closed-block stripping with body preserved
and conservative handling of unclosed blocks; go test ./... 32 pkgs green.
Impact: partsText removes closed <think>...</think> spans before summary,
notice, and card assembly; unclosed blocks are kept as-is.
Refs: Dev task feature/dual-speed-real-path.
2026-10-10 10:01:15 +00:00
weichen 3f2c7c7033 Nudge long-running turns onto the dual-speed path via the kernel reminder hook
Why: the dual-speed slow lane shipped as an observable capability plus soft
prompt guidance, but on the real (non-mock) path models routinely grind
through heavy document tasks synchronously for minutes with zero user-visible
output - the pattern only engages when the model happens to think of it.
Evidence: five new unit tests over an injected clock cover fire-after-threshold,
quiet-before-threshold, disabled-by-setting, and the replied/delegated guard
paths (reminders observed in the captured kernel requests); full go test
./... 32 pkgs green. Real-model run recorded in the Dev task card.
Impact: runDirect tracks run start, user-reply, and background-delegation
state; reminders() injects a one-shot dual-speed nudge (SR-B10) once elapsed
time crosses runtime.dualSpeedNudgeSeconds (default 75s, 0 disables) while
the user has neither a reply nor a background lane. New setting registered.
Refs: Dev task feature/dual-speed-real-path; SR-B10 (RDM requirements/01 V2.1).
2026-10-10 09:11:18 +00:00
weichen eef60ff0b9 Merge pull request 'Enable parallel tool execution, fix delegate truncation, sandbox deps layer (E1)' (#127) from feature/agent-e1-parallel-fixes into main
ci / vet-and-test (push) Successful in 46s
2026-10-10 09:04:57 +00:00
weichen 4df81ef1da Enable parallel tool execution, fix delegate truncation, sandbox deps layer
ci / vet-and-test (pull_request) Successful in 43s
Why: Real runs were fully serial: the direct path never set the kernel's
parallel tool mode, delegate_task handed the parent only the child's
160-char summary (the full text was dropped), MiniMax streamed its
reasoning inside content <think> blocks, and the EXT-16 sandbox hid
~/.local so python-docx/openpyxl/pptx could not even import. All four
were measured defects (F1/F2/F3/F5) in the delegation/routing proposal.

Why: Serial-only mutual exclusion is not enough for order-sensitive
tools - the card-stream test failed with out-of-order appends - so the
ordering moved into the kernel (SerialTools partition, Agent-Native
#44); process events pair by CallID (#42) and tool results write back
through the injected call context (#43).

Evidence: go test ./... green, including new regression anchors:
TestDelegateToolReturnsFullText, TestToolEventsPairByCallID,
TestToolExecModeAndSerialSet, TestOpenLLMClientReasoningSplitAuto,
TestSandboxArgvDepsLayer, TestExecEnvJoinsPythonPath.

Impact: Direct runs now execute independent same-turn calls
(searches, reads, delegate_task, review_findings) concurrently by
default (runtime.parallelTools=false restores sequential); card flow,
plans, workflows, exec primitives and file edits keep model-declared
order. delegate_task returns the capped full child text. MiniMax-*
models get reasoning_split=true so thinking streams via
reasoning_content. --exec-deps-dir/AGENT_SYSTEM_EXEC_DEPS_DIR mounts a
read-only dependency layer into the sandbox and merges PYTHONPATH.
Bumps deps/agent-native to c348441.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E1,
F1-F5); Agent-Native #42/#43/#44; Dev task feature/agent-e1-parallel-fixes.
2026-10-10 09:02:10 +00:00
weichen c6912b63e7 Merge pull request 'Keep the final reply clean when checks continue or the verifier is skipped' (#126) from feature/final-reply-pollution-fix into main
ci / vet-and-test (push) Successful in 43s
2026-10-10 07:56:41 +00:00
weichen 18aa99cb95 Keep the final reply clean when checks continue or the verifier is skipped
ci / vet-and-test (pull_request) Successful in 1m1s
Why: real run run_b520bf2e3037 showed the user-facing reply being displaced by
internal bookkeeping - the fact-check correction round's closing text plus the
same-source verifier skip notice became the visible answer while the actual
summary was folded into the process timeline (frontend splits at the last
process part). Same tail pollution reproduced on every same-model run since.
Evidence: go test ./... -count=1 green (32 pkgs); new regression tests cover
the demotion of the pre-check summary and the skip notice becoming a check
part; three old-contract tests updated to assert notices land as process
checks instead of final text.
Impact: stop() fact-check branch now demotes the standing summary (aligned
with the gate and cap branches) and asks the model for a fresh full reply;
verifier errors record as process checks (pass-state for same-source skip,
fail-state for real errors) instead of StopDecision.Notice text; the cap
notice likewise becomes a failed check line.
Refs: Dev task feature/final-reply-pollution-fix; diagnosis in
.task/feature-dual-speed-tasks.md lineage and session analysis 2026-10-10.
2026-10-10 07:48:03 +00:00
weichen d2bb936784 Merge pull request 'Make background subagents observable as the slow lane of dual-speed tasks' (#125) from feature/dual-speed-tasks into main
ci / vet-and-test (push) Successful in 44s
2026-10-10 06:52:50 +00:00
weichen 43087a79f8 Make background subagents observable as the slow lane of dual-speed tasks
ci / vet-and-test (pull_request) Successful in 45s
Why: SR-B10 (RDM requirements/01 V2.1, ruling J-03) defines the task-level
dual-speed pattern: a fast lane gives immediate feedback while deep work runs
in background with queryable progress and results posted back to the inbox.
The base system had the background spawn/notice machinery but nothing the
user could see while it ran or collect afterwards, so heavy tasks just made
the user wait on a synchronous turn.
Evidence: go test ./... -count=1 green (32 pkgs); new runmgr tests cover the
card lifecycle on success (progress card -> done/ok with result summary and
artifact line, card.created/updated/status events) and on failure
(done/danger with the error), plus the cards==nil no-op path.
Impact: spawnBackground now registers an inbox progress card linked to the
child run and rewrites it with the result on completion; the delegate_task
description and base-method working prompt teach the fast-feedback-first
pattern; a #background mock demo exercises the whole path end to end.
Refs: RDM docs/requirements/01 UR-27/SR-B10; .tmp 03-V2 §任务快慢双速通用模式;
Dev task feature/dual-speed-tasks.
2026-10-10 06:40:00 +00:00
566 changed files with 69879 additions and 1640 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,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
+30
View File
@@ -0,0 +1,30 @@
# feature/regulations-kb-ext20 任务卡
任务主记录:本文件随 PR 描述维护。
状态:已完成(合入前闭合);owner:weichen。PR:https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/212
## 基线与授权
- 权威仓: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 节一致,无阻塞项。
- 影响与剩余项:新增可选能力,未注册提供方时返回不可用,既有行为不变。
+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;放弃路径注明原因>。
+67
View File
@@ -0,0 +1,67 @@
# 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
enabled: true
antigravity:
adapter: antigravity
executable: agy
model: auto
enabled: true
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 复用)。
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
)
func runCapabilities(args []string) int {
fs := flag.NewFlagSet("capabilities", flag.ExitOnError)
asJSON := fs.Bool("json", false, "以 JSON 打印能力,不打开数据库")
_ = fs.Parse(args)
caps := map[string]any{
"accountTransaction": true,
"accountAudit": true,
"accountOpsOpen": true,
"passwordStdin": true,
"dsnFile": true,
"outFile": true,
}
if !*asJSON {
fmt.Fprintln(os.Stderr, "用法:agent-system capabilities --json")
return 2
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(caps); err != nil {
fmt.Fprintln(os.Stderr, "capabilities:", err)
return 1
}
return 0
}
func resolveAccountDSN(flagDSN, dsnFile string) (string, error) {
if flagDSN != "" {
fmt.Fprintln(os.Stderr, "warning: --dsn is deprecated; use AGENT_SYSTEM_DSN or --dsn-file")
}
if dsnFile != "" {
info, err := os.Stat(dsnFile)
if err != nil {
return "", err
}
if info.Mode().Perm() != 0o600 {
return "", fmt.Errorf("dsn file %s must be mode 0600", dsnFile)
}
raw, err := os.ReadFile(dsnFile)
if err != nil {
return "", err
}
return string(trimNewline(raw)), nil
}
if env := os.Getenv("AGENT_SYSTEM_DSN"); env != "" {
return env, nil
}
return flagDSN, nil
}
func resolveAccountPassword(flagPassword string, fromStdin bool) (string, error) {
if flagPassword != "" {
fmt.Fprintln(os.Stderr, "warning: --password is deprecated; use --password-stdin")
}
if !fromStdin {
return flagPassword, nil
}
raw, err := io.ReadAll(os.Stdin)
if err != nil {
return "", err
}
return string(trimNewline(raw)), nil
}
func writeExclusiveSecret(path, body string) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = io.WriteString(f, body+"\n")
return err
}
func trimNewline(raw []byte) []byte {
for len(raw) > 0 && (raw[len(raw)-1] == '\n' || raw[len(raw)-1] == '\r') {
raw = raw[:len(raw)-1]
}
return raw
}
+166
View File
@@ -0,0 +1,166 @@
package main
import (
"agent-system-backend/internal/store"
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
)
func accountCLI(t *testing.T, args ...string) (int, string) {
t.Helper()
out, err := os.CreateTemp(t.TempDir(), "capture-")
if err != nil {
t.Fatal(err)
}
defer out.Close()
oldOut, oldErr := os.Stdout, os.Stderr
os.Stdout = out
os.Stderr = out
defer func() { os.Stdout = oldOut; os.Stderr = oldErr }()
rc := runUser(args)
out.Seek(0, 0)
raw, err := io.ReadAll(out)
if err != nil {
t.Fatal(err)
}
return rc, string(raw)
}
func cliStore(t *testing.T) (string, *store.Store) {
t.Helper()
t.Setenv("AGENT_SYSTEM_DSN", "")
dir := t.TempDir()
s, e := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { s.Close() })
if e := s.EnsureSystemIdentity("fixture", "sqlite"); e != nil {
t.Fatal(e)
}
return dir, s
}
func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) {
for _, sub := range []string{"create", "initialize-account", "bootstrap-admin"} {
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)
}
}
out := filepath.Join(t.TempDir(), "secret")
args := []string{sub, "--data", dir, "--owner", "ada", "--out-file", out}
if sub == "initialize-account" {
a, e := s.CreatePendingAccount("root", "ada", store.AccountProfile{Name: "Ada"})
if e != nil {
t.Fatal(e)
}
args = append(args, "--user-id", a.ID, "--expected-state-version", "1", "--expected-generation", "0")
}
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")
}
})
}
}
func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) {
for _, kind := range []string{"existing", "conflicting"} {
t.Run(kind, func(t *testing.T) {
dir, s := cliStore(t)
out := filepath.Join(t.TempDir(), "secret")
args := []string{"create", "--data", dir, "--owner", "ada", "--out-file", out}
if kind == "existing" {
if e := os.WriteFile(out, []byte("existing"), 0600); e != nil {
t.Fatal(e)
}
} else {
args = append(args, "--out", filepath.Join(t.TempDir(), "json-secret"))
}
rc, _ := accountCLI(t, args...)
if rc == 0 {
t.Fatal("unsafe output selection succeeded")
}
if _, e := s.GetUser("ada"); e == nil {
t.Fatal("output refusal happened after account mutation")
}
})
}
}
func TestAPIKeyCreateUsesExclusivePrivateOutput(t *testing.T) {
if dir := os.Getenv("U7_APIKEY_CHILD_DIR"); dir != "" {
rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture", "--out-file", os.Getenv("U7_APIKEY_CHILD_OUT")})
if rc != 0 {
t.Fatal("apikey create rejected its private output destination")
}
return
}
for _, existing := range []bool{false, true} {
name := "new"
if existing {
name = "existing"
}
t.Run(name, func(t *testing.T) {
dir, st := cliStore(t)
if err := st.CreateAccount("ada", "Ada", "", false); err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "credential")
if existing {
if err := os.WriteFile(out, []byte("preserve"), 0600); err != nil {
t.Fatal(err)
}
}
cmd := exec.Command(os.Args[0], "-test.run=^TestAPIKeyCreateUsesExclusivePrivateOutput$")
cmd.Env = append(os.Environ(), "U7_APIKEY_CHILD_DIR="+dir, "U7_APIKEY_CHILD_OUT="+out)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if existing {
if err == nil {
t.Fatal("existing output was overwritten or mutation succeeded")
}
raw, readErr := os.ReadFile(out)
if readErr != nil || string(raw) != "preserve" {
t.Fatal("existing output was modified")
}
keys, listErr := st.ListAPIKeys("ada")
if listErr != nil || len(keys) != 0 {
t.Fatal("mutation happened before output preflight")
}
return
}
if err != nil {
t.Fatal("new private output was rejected")
}
info, statErr := os.Stat(out)
if statErr != nil || info.Mode().Perm() != 0600 {
t.Fatal("credential output missing or not private")
}
raw, readErr := os.ReadFile(out)
if readErr != nil || len(raw) == 0 {
t.Fatal("credential output empty")
}
if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(raw)) {
t.Fatal("credential was written to stdout")
}
keys, listErr := st.ListAPIKeys("ada")
if listErr != nil || len(keys) != 1 {
t.Fatal("successful issue was not committed")
}
})
}
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"os"
"os/exec"
"strings"
"testing"
)
func TestUnknownSubcommandExits(t *testing.T) {
if os.Getenv("DISPATCH_CHILD") == "1" {
os.Args = []string{"agent-system", "not-a-command"}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestUnknownSubcommandExits")
cmd.Env = append(os.Environ(), "DISPATCH_CHILD=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("未知子命令应非零退出:%s", out)
}
if !strings.Contains(string(out), "未知子命令") {
t.Fatalf("输出:%s", out)
}
if strings.Contains(string(out), "agent-system starting") || strings.Contains(string(out), "serving") {
t.Fatalf("未知子命令落入了服务路径:%s", out)
}
}
+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
}
+375 -30
View File
@@ -9,17 +9,29 @@ import (
"flag"
"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"
)
var version = "0.1.0-dev"
func main() {
if len(os.Args) > 1 {
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
switch os.Args[1] {
case "version", "--version":
if len(os.Args) > 2 && os.Args[2] == "--json" {
if err := identity.WriteDocument(os.Stdout, "agent-system", version); err != nil {
fmt.Fprintln(os.Stderr, "version:", err)
os.Exit(1)
}
return
}
fmt.Println("agent-system", version)
return
case "serve":
@@ -30,6 +42,18 @@ func main() {
os.Exit(runConfig(os.Args[2:]))
case "user":
os.Exit(runUser(os.Args[2:]))
case "identity":
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|env|migrate> [参数]")
os.Exit(2)
}
}
fs := flag.NewFlagSet("serve", flag.ExitOnError)
@@ -46,24 +70,88 @@ func main() {
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
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 活动服务守卫")
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")
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,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace, ExecEnvRoot: *execEnvRoot,
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
}, cliSet)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
if msgs := resolved.TierProblems(); len(msgs) > 0 {
for _, msg := range msgs {
fmt.Fprintln(os.Stderr, "agent-system: tier guard:", msg)
}
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,
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,
PGPool: store.PoolOptions{
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
QueryTimeoutSec: resolved.PGQueryTimeoutSec,
},
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
@@ -82,6 +170,24 @@ 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":
name = "pgMaxIdle"
case "pg-conn-max-idle-sec":
name = "pgConnMaxIdleSec"
case "pg-conn-max-lifetime-sec":
name = "pgConnMaxLifetimeSec"
case "pg-query-timeout-sec":
name = "pgQueryTimeoutSec"
}
set[name] = true
})
@@ -108,12 +214,23 @@ func runConfig(args []string) int {
allowedHosts := fs.String("allowed-hosts", "", "")
noExec := fs.Bool("disable-exec-tools", false, "")
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, "")
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "")
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "")
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "")
fs.Parse(args[1:])
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,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace, ExecEnvRoot: *execEnvRoot,
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
}, visited(fs))
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
@@ -134,22 +251,47 @@ func runAPIKey(args []string) int {
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
dsnFile := fs.String("dsn-file", "", "0600权限的PostgreSQL DSN文件")
owner := fs.String("owner", "", "租户名(owner)")
name := fs.String("name", "default", "密钥名称")
outFile := fs.String("out-file", "", "把明文密钥独占写入该文件(0600)")
fs.Parse(args[1:])
if *owner == "" {
fmt.Fprintln(os.Stderr, "apikey: --owner 必填")
return 2
}
if sub != "create" && *outFile != "" {
fmt.Fprintln(os.Stderr, "apikey: 此子命令不生成密钥")
return 2
}
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey:", err)
return 1
}
*dsn = resolvedDSN
switch sub {
case "create":
delivery, err := reserveCredentialOutput(*outFile, "")
if err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
defer delivery.abort()
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
// 明文仅此一次输出;库中只存 SHA-256 摘要。
fmt.Println(key)
if delivery.file == nil {
fmt.Println(key) // Existing API-key CLI compatibility; owner grants require a file.
return 0
}
if err := delivery.deliver("api-key", *owner, key, nil); err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
return 0
case "list":
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
@@ -178,55 +320,222 @@ func runAPIKey(args []string) int {
}
}
// runUser:用户账号管理子命令(管理员发放制,R2)——create / reset-password / list。
// 密码未显式给定时生成随机值,明文仅此一次输出;创建/重置后首次登录强制改密。
// deliverCredential writes only to a new private file. The secret never goes
// to stdout or an existing path; a JSON payload may include expiresAt.
func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error {
if outPath == "" {
return fmt.Errorf("protected credential delivery path required")
}
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
if expiresAt != nil {
payload["expiresAt"] = expiresAt
}
buf, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
if err := writeExclusiveSecret(outPath, string(buf)); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath)
return nil
}
type credentialOutput struct {
path string
file *os.File
json bool
mutated bool
}
// Reserve the destination before changing the account. O_EXCL also rejects an
// existing symlink, so an existing secret can never be overwritten.
func reserveCredentialOutput(rawPath, jsonPath string) (*credentialOutput, error) {
if rawPath != "" && jsonPath != "" {
return nil, fmt.Errorf("--out and --out-file are mutually exclusive")
}
out := &credentialOutput{}
out.path, out.json = rawPath, false
if jsonPath != "" {
out.path, out.json = jsonPath, true
}
if out.path == "" {
return out, nil
}
f, err := os.OpenFile(out.path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return nil, err
}
out.file = f
return out, nil
}
func (out *credentialOutput) abort() {
if out == nil || out.file == nil {
return
}
info, err := out.file.Stat()
_ = out.file.Close()
if out.mutated || err != nil {
return
}
pathInfo, pathErr := os.Stat(out.path)
if pathErr == nil && os.SameFile(info, pathInfo) {
_ = os.Remove(out.path)
}
}
func (out *credentialOutput) deliver(kind, owner, plaintext string, expiresAt any) error {
if out.file == nil {
return fmt.Errorf("protected credential delivery path required")
}
out.mutated = true // Keep the private recovery artifact if delivery fails after DB commit.
var body []byte
if out.json {
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
if expiresAt != nil {
payload["expiresAt"] = expiresAt
}
var err error
body, err = json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
} else {
body = []byte(plaintext)
}
body = append(body, '\n')
if _, err := out.file.Write(body); err != nil {
return err
}
if err := out.file.Sync(); err != nil {
return err
}
if err := out.file.Close(); err != nil {
return err
}
out.file = nil
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", out.path)
return nil
}
// runUser:本地 owner 账号初始化、续办和只读/撤销操作。
func runUser(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list> [--owner x --name y --admin --password p]")
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|initialize-account|list|revoke-login-key|bootstrap-admin> [flags]")
return 2
}
sub := args[0]
fs := flag.NewFlagSet("user "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档;已弃用,优先 AGENT_SYSTEM_DSN 或 --dsn-file)")
dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件")
owner := fs.String("owner", "", "用户名(owner)")
userID := fs.String("user-id", "", "待初始化账号稳定ID")
expectedStateVersion := fs.Int64("expected-state-version", 0, "待初始化账号状态版本")
expectedGeneration := fs.Int64("expected-generation", -1, "待初始化账号代数")
name := fs.String("name", "", "显示名")
password := fs.String("password", "", "密码(空 = 随机生成并打印一次)")
admin := fs.Bool("admin", false, "管理员(可管理用户与密钥)")
password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)")
passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码")
outFile := fs.String("out-file", "", "把明文凭据独占写入该文件(0600)")
instanceID := fs.String("instance-id", "", "严格核验库内服务身份")
admin := fs.Bool("admin", false, "管理员")
role := fs.String("role", "user", "角色 user|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)")
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")
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
}
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
return 1
}
resolvedPassword, err := resolveAccountPassword(*password, *passwordStdin)
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
return 1
}
if *instanceID != "" {
_ = os.Setenv("AGENT_SYSTEM_INSTANCE_ID", *instanceID)
}
*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")
return 2
}
if !credentialAction && (*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 == "" {
fmt.Fprintln(os.Stderr, "user create: --owner 必填")
return 2
}
pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, *admin)
isAdmin := *admin
switch *role {
case "user":
case "admin":
isAdmin = true
default:
fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role)
return 2
}
pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
if err != nil {
fmt.Fprintln(os.Stderr, "user create:", err)
return 1
}
fmt.Println(pw)
if *password == "" {
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
if err := delivery.deliver("login-key", *owner, pw, nil); err != nil {
fmt.Fprintln(os.Stderr, "user create:", err)
return 1
}
fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID)
return 0
case "initialize-account":
if *userID == "" || *expectedStateVersion < 1 || *expectedGeneration < 0 {
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)
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
}
return 0
case "reset-password":
if *owner == "" {
fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填")
return 2
}
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password)
if err != nil {
fmt.Fprintln(os.Stderr, "user reset-password:", err)
return 1
}
fmt.Println(pw)
if *password == "" {
fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。")
}
return 0
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED")
return 2
case "list":
users, err := app.ListUserAccounts(*dsn, *data)
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
if err != nil {
fmt.Fprintln(os.Stderr, "user list:", err)
return 1
@@ -234,8 +543,44 @@ func runUser(args []string) int {
out, _ := json.MarshalIndent(users, "", " ")
fmt.Println(string(out))
return 0
case "issue-login-key":
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED")
return 2
case "revoke-login-key":
if *keyID == "" {
fmt.Fprintln(os.Stderr, "user revoke-login-key: --id 必填")
return 2
}
if err := app.RevokeUserLoginKey(*dsn, *data, *keyID, *openExisting); err != nil {
fmt.Fprintln(os.Stderr, "user revoke-login-key:", err)
return 1
}
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
}
}
// runIdentity:U1 I3 服务实例库内身份(manifest.instance_id ↔ agent_system_identity)。
// 与 serve --instance(runs 进程标识)无关。实现在 pkg/identity(公共化:下游
// 组合仓共用同一 CLI,未知子命令 usage 退出、不落服务路径——2026-10-12 裁定)。
func runIdentity(args []string) int {
return identity.Run(args)
}
+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
}
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) {
data := t.TempDir()
for _, args := range [][]string{
{"reset-password", "--data", data, "--owner", "victim", "--password", "attacker-password"},
{"issue-login-key", "--data", data, "--owner", "victim", "--purpose", "recover", "--revoke-all-credentials"},
} {
if code := runUser(args); code != 2 {
t.Fatalf("legacy command did not reject before storage: %v code=%d", args[0], code)
}
}
out := filepath.Join(data, "delivery.json")
if code := runUser([]string{"create", "--data", data, "--owner", "victim", "--password", "attacker-password", "--out", out}); code != 2 {
t.Fatalf("password-bearing local create accepted: %d", 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 TestCredentialDeliveryCreatesExclusive0600File(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "credential.json")
if err := os.WriteFile(out, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := deliverCredential(out, "activation", "alice", "secret", nil); err == nil {
t.Fatal("existing permissive file was overwritten")
}
bytes, err := os.ReadFile(out)
if err != nil || string(bytes) != "old" {
t.Fatalf("existing file changed: %q %v", bytes, err)
}
out = filepath.Join(dir, "new.json")
if err := deliverCredential(out, "activation", "alice", "secret", nil); err != nil {
t.Fatal(err)
}
info, err := os.Stat(out)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("delivery mode: %v %v", info, err)
}
bytes, err = os.ReadFile(out)
if err != nil || !strings.Contains(string(bytes), `"secret": "secret"`) {
t.Fatalf("delivery payload: %v", err)
}
}
+378
View File
@@ -0,0 +1,378 @@
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"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)
}
}
// U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例):
// 夹具 → 受管迁移 → 校验和验证 → 幂等重跑 → 断点续传 → 非空目标拒绝 →
// 未知 owner 阻塞。每用例独立 PG schema(与 store 测试同隔离约定)。
func pgSchemaDSN(t *testing.T) (string, func()) {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真库集成由 CI 的必跑 job 覆盖")
}
schema := "mig_" + strings.ToLower(store.NewID("t")) + "_" + fmt.Sprint(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("preinstall pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("create schema: %v", err)
}
cleanup := func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
}
return store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), cleanup
}
func TestMigrateEndToEndAndIdempotent(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
src, err := sql.Open("sqlite", fixture)
if err != nil {
t.Fatal(err)
}
defer src.Close()
var sourcePublic, sourceStaff, sourcePrivate, sourceSecret string
var sourceKeyVersion int64
if err := src.QueryRow(`SELECT public_revision,staff_no_revision,private_revision,cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan(
&sourcePublic, &sourceStaff, &sourcePrivate, &sourceKeyVersion, &sourceSecret,
); err != nil {
t.Fatal(err)
}
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
t.Fatalf("首次迁移:%v", err)
}
// 幂等重跑(同身份):表级 done 对账,零重复行。
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
t.Fatalf("幂等重跑:%v", err)
}
// 目标侧关键断言:布尔映射、NULL 保留、Unicode、BLOB、SERIAL 序列推进、
// 复合主键事件序。
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
db := st.DB()
var targetPublic, targetStaff, targetPrivate, targetSecret string
var targetKeyVersion int64
if err := db.QueryRow(`SELECT public_revision,staff_no_revision,private_revision,cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan(
&targetPublic, &targetStaff, &targetPrivate, &targetKeyVersion, &targetSecret,
); err != nil {
t.Fatal(err)
}
if targetPublic != sourcePublic || targetStaff != sourceStaff || targetPrivate != sourcePrivate || targetKeyVersion != sourceKeyVersion || targetSecret != sourceSecret {
t.Fatal("迁移后的目录版本或游标密钥与源库不一致")
}
var pinned, archived bool
if err := db.QueryRow(`SELECT pinned, archived FROM conversations WHERE id='c1'`).Scan(&pinned, &archived); err != nil || !pinned || archived {
t.Fatalf("布尔映射:pinned=%v archived=%v err=%v", pinned, archived, err)
}
var title, note string
if err := db.QueryRow(`SELECT title, session_note FROM conversations WHERE id='c1'`).Scan(&title, &note); err != nil || !strings.Contains(note, "注") {
t.Fatalf("Unicode:title=%q note=%q err=%v", title, note, err)
}
var vec []byte
if err := db.QueryRow(`SELECT vec FROM knowledge_vectors WHERE doc_id='k1'`).Scan(&vec); err != nil || len(vec) != 12 {
t.Fatalf("BLOB 向量:len=%d err=%v", len(vec), err)
}
var ended sql.NullString
if err := db.QueryRow(`SELECT ended_at FROM runs WHERE id='r1'`).Scan(&ended); err != nil || ended.Valid {
t.Fatalf("NULL 保留:ended=%v err=%v", ended, err)
}
var seqMax int
if err := db.QueryRow(`SELECT MAX(seq) FROM events WHERE conversation_id='c1'`).Scan(&seqMax); err != nil || seqMax != 2 {
t.Fatalf("事件复合主键序:max=%d err=%v", seqMax, err)
}
// 复制的显式 id 之后,应用侧插入不撞序列(command_audit.id=7 已复制)。
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") {
t.Fatalf("报告泄密:err=%v", err)
}
}
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()
fixture := buildFixture(t)
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
t.Fatal(err)
}
// 模拟断点:messages 已 done 后被部分回退(行删一半、状态回 pending),
// 重跑只重做该表,终态校验仍全绿。
db, err := sql.Open("pgx", target)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DELETE FROM messages WHERE id='m3'`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE migration_table_state SET state='pending' WHERE migration_id='mig-resume' AND table_name='messages'`); err != nil {
t.Fatal(err)
}
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
t.Fatalf("断点续跑:%v", err)
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
t.Fatalf("续跑后 messages=%d:重做该表应恢复 3 行", n)
}
}
func TestMigrateRefusesForeignDataAndWrongSnapshot(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
// 非空目标(人工数据、无迁移身份):拒绝。
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
st.Close()
db, err := sql.Open("pgx", target)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES ('foreign', '外来', '{}', '2026-10-11T00:00:00Z', '2026-10-11T00:00:00Z', 'alice')`); err != nil {
t.Fatal(err)
}
err = cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-x"})
if err == nil || !strings.Contains(err.Error(), "非空目标") {
t.Fatalf("非空目标应拒绝:%v", err)
}
if _, err := db.Exec(`DELETE FROM projects WHERE id='foreign'`); err != nil {
t.Fatal(err)
}
// 同 migration_id 换快照:拒绝(仅同一冻结快照可续传)。
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-snap"}); err != nil {
t.Fatal(err)
}
other := buildFixture(t) // 不同文件 ⇒ 不同 snapshot_id
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-snap"})
if err == nil || !strings.Contains(err.Error(), "快照不同") {
t.Fatalf("换快照应拒绝:%v", err)
}
// 其他 migration_id 已存在:拒绝。
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-other"})
if err == nil || !strings.Contains(err.Error(), "其他迁移记录") {
t.Fatalf("他身份应拒绝:%v", err)
}
}
func TestMigrateBlocksUnknownOwner(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
// 在夹具里造一个 users 之外的 owner 行(直接写源库)。
srcPath := fixture
st, err := store.OpenSQLite(srcPath) // 普通打开(夹具可写)
if err != nil {
t.Fatal(err)
}
if _, err := st.DB().Exec(`INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
('k_ghost', 'p1', 'upload', '幽灵', 'g.md', '…', '2026-10-11T01:00:00Z', 'ghost-user')`); err != nil {
t.Fatal(err)
}
st.Close()
err = cmdMigrate([]string{"--source", srcPath, "--target", target, "--migration-id", "mig-ghost"})
if err == nil || !strings.Contains(err.Error(), "未知 owner") {
t.Fatalf("未知 owner 应阻塞:%v", err)
}
}
File diff suppressed because it is too large Load Diff
+325
View File
@@ -0,0 +1,325 @@
package main
import (
"bytes"
"database/sql"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
)
// U4 迁移工具测试:夹具覆盖全部当前 schema 的边值(布尔/空值/时间/JSON/
// Unicode/二进制向量/秘密字段),以及 inventory/backup 的行为面。
// 真库端到端(幂等/续传/非空拒/未知 owner)在 integration_test.go(PG 门控)。
// buildFixture:建一个全 schema 的 SQLite 库并填充边值行,返回路径。
func buildFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "fixture.db")
st, err := store.OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
seedEdgeRows(t, st)
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 布尔)与单租户默认身份。
mustExec(t, st, `INSERT INTO users (id, owner, name, created_at, password_hash, password_set_at, must_change_password, is_admin) VALUES
('u1', 'alice', '爱丽丝', '2026-10-11T01:00:00Z', 'sha256:…', '2026-10-11T01:00:00Z', false, true),
('u2', 'bob', 'Bob 😊', '2026-10-11T01:00:00Z', '', '', true, false)`)
// 项目与会话(布尔 pinned/archived/allow_cross_read/cross_private、空 JSON)。
mustExec(t, st, `INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES
('p1', '项目一', '{}', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice'),
('p2', 'Emoji 项目 🚀', '{"a":null,"b":[1,2,"三"]}', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '')`)
mustExec(t, st, `INSERT INTO conversations (id, project_id, title, profile_id, created_at, updated_at, owner, pinned, archived, allow_cross_read, cross_private, permission_mode, session_note) VALUES
('c1', 'p1', '会话', '', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice', true, false, false, true, '', 'note—注'),
('c2', 'p2', '', '', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '', false, true, true, false, 'strict', '')`)
// 消息(request_id 幂等、parts JSON 含空对象/空数组/Unicode)与 seq 顺序。
mustExec(t, st, `INSERT INTO messages (id, conversation_id, run_id, role, parts, seq, created_at, request_id) VALUES
('m1', 'c1', '', 'user', '[{"type":"text","text":"你好"}]', 1, '2026-10-11T01:00:01Z', 'req-1'),
('m2', 'c1', '', 'assistant', '[{"type":"kv","kv":[]},{"type":"text","text":""}]', 2, '2026-10-11T01:00:02Z', ''),
('m3', 'c2', '', 'system', '[]', 1, '2026-10-11T02:00:01Z', 'req-2')`)
// runs(NULL ended_at/usage/error)。
mustExec(t, st, `INSERT INTO runs (id, parent_run_id, conversation_id, project_id, owner, kind, profile_id, status, phase, agent_name, created_at, instance_id) VALUES
('r1', '', 'c1', 'p1', 'alice', 'conversation', '', 'completed', '', '', '2026-10-11T01:00:00Z', 'host:1'),
('r2', 'r1', 'c1', 'p1', 'alice', 'subagent', '', 'running', 'thinking', '检索', '2026-10-11T01:00:03Z', 'host:2')`)
// events(复合主键 (conversation_id, seq))。
mustExec(t, st, `INSERT INTO events (conversation_id, seq, run_id, ts, v, type, payload) VALUES
('c1', 1, 'r1', '2026-10-11T01:00:01Z', 1, 'run.phase', '{"phase":"thinking"}'),
('c1', 2, 'r1', '2026-10-11T01:00:02Z', 1, 'message.appended', NULL),
('c2', 1, '', '2026-10-11T02:00:01Z', 1, 'x', '{}')`)
// 工件(size 0 与大值)。
mustExec(t, st, `INSERT INTO artifacts (id, project_id, name, kind, mime, size, source_run, content_path, created_at, updated_at, owner) VALUES
('a1', 'p1', '报告.md', 'doc', 'text/markdown', 0, 'r1', 'art/a1', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice'),
('a2', 'p2', '数据.xlsx', 'doc', '', 2147483647, '', '', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '')`)
// HITL 卡(resolved=0、resolution NULL)。
mustExec(t, st, `INSERT INTO hitl_cards (id, run_id, conversation_id, payload, created_at, resolved, resolution, owner) VALUES
('h1', 'r2', 'c1', '{"q":"继续?"}', '2026-10-11T01:00:04Z', 0, NULL, 'alice')`)
// 知识 + 向量(BLOB 二进制:float32 little-endian)。
mustExec(t, st, `INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
('k1', 'p1', 'upload', '标题', 'doc.md', '正文内容…', '2026-10-11T01:00:00Z', 'alice')`)
mustExec(t, st, `INSERT INTO knowledge_vectors (doc_id, owner, ref, dim, vec, created_at) VALUES
('k1', 'alice', 'doc.md', 3, x'0000803F0000003F000080BF', '2026-10-11T01:00:00Z')`)
// 活动卡(pinned 布尔)。
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, 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}')`)
// 秘密字段(值不外泄:迁移按行复制,报告无正文)。
mustExec(t, st, `INSERT INTO secret_keys (id, owner, service, label, value, active, created_at) VALUES
('sk1', 'alice', 'openai', 'main', 'sk-secret-value-不打印', 1, '2026-10-11T01:00:00Z')`)
// 恢复隔离(业务态,应随迁)。
mustExec(t, st, `INSERT INTO recovery_holds (run_id, owner, conversation_id, reason, created_at) VALUES
('rh1', 'alice', 'c1', '副作用未知', '2026-10-11T01:00:00Z')`)
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// mustExec:夹具直接走底层连接写行(业务代码不得如此;见 store.DB 注释)。
func mustExec(t *testing.T, st *store.Store, q string) {
t.Helper()
if _, err := st.DB().Exec(q); err != nil {
t.Fatal(err)
}
}
// ---- inventory ----
func TestInventoryReport(t *testing.T) {
path := buildFixture(t)
db, err := openSourceRO(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
rep, err := inventoryReport(db, path, "")
if err != nil {
t.Fatal(err)
}
if rep["source_schema_version"].(int) < 20 {
t.Fatalf("schema version: %v", rep["source_schema_version"])
}
counts := rep["table_counts"].(map[string]int64)
if counts["messages"] != 3 || counts["events"] != 3 || counts["users"] != 2 {
t.Fatalf("counts: %v", counts)
}
b, _ := json.Marshal(rep)
if strings.Contains(string(b), "sk-secret-value") {
t.Fatal("inventory 报告不得包含秘密值")
}
}
func TestInventoryMissingFile(t *testing.T) {
if _, err := openSourceRO(filepath.Join(t.TempDir(), "nope.db")); err == nil {
t.Fatal("缺文件应报错")
}
}
// ---- backup ----
func TestBackupVerifiesAndSnapshotsConsistent(t *testing.T) {
path := buildFixture(t)
out := filepath.Join(t.TempDir(), "snap.db")
if err := cmdBackup([]string{"--source", path, "--out", out}); err != nil {
t.Fatal(err)
}
snap, err := openSourceRO(out)
if err != nil {
t.Fatal(err)
}
defer snap.Close()
var n int
if err := snap.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
t.Fatalf("snap messages: %d %v", n, err)
}
// 已存在的输出拒绝覆盖。
if err := cmdBackup([]string{"--source", path, "--out", out}); err == nil {
t.Fatal("已存在快照应拒绝")
}
}
// ---- 夹具即文档:确认全部业务表在 copyOrder 内(防新表漏迁) ----
func TestCopyOrderCoversAllFixtureTables(t *testing.T) {
path := buildFixture(t)
db, err := openSourceRO(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
known := map[string]bool{}
for _, t := range copyOrder {
known[t] = 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"} {
known[rt] = true
}
// FTS5 虚表影子表:SQLite 内部结构,PG 侧由 v4 生成列/索引从 knowledge
// 行自动重建,不随迁。
for _, shadow := range []string{"knowledge_fts", "knowledge_fts_data", "knowledge_fts_idx", "knowledge_fts_docsize", "knowledge_fts_config"} {
known[shadow] = true
}
var missing []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
if !known[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
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")
}
Vendored Submodule
+1
@@ -0,0 +1,120 @@
# IAM U3 A1: account write fence inventory
Status: stage 4–5 implementation inventory; the fixed candidate is the commit containing this file, and stage 6 human review remains pending. The route table records the **4053c4d baseline finding** and the intended final sink; its `Now` column is historical, not a claim about the amended candidate. Scope follows the approved Documents A1–A4 amendment `fb3a8214df963697ebd745ad11eb852ed9444fe3` and approved delegated-actor amendment `5783940e4c434102cbca8c22bd282d87b28fa840`.
The 66 non-GET `/api` registrations were enumerated from `internal/server/server.go`. Paths below omit `/api`. `A` means `s.owner(r)` (auth-off: implicit `store.OwnerDefault`); `B` means body owner, `K` redeemed-key owner, `S` current password-session owner. Target resource owner never substitutes for actor. In the historical `Now` column, `T` means the baseline already had a final Store check; `M` means a Backend DB write lacked one; `E` means an external or process-local effect; `—` means no business mutation or permitted credential cleanup. `D` means a short Store transaction with actor account lock and enabled read **before** resource/scope/conversation locks; `F` means stage immutable bytes, then `D` for the visible DB reference; `X` means last effective-state check before external action plus cancellation; `C` means `D` at each command handler's actual sink; `P` means `D` for Backend projection and `X` at the separate RDM callback. `Vnn` identifies the route for traceability; verification uses representative concurrency cases and complete source-to-sink review, rather than one duplicate test per route.
| # | Route → handler (source line) | Actor | Final sink | Now | Required fence | Verify |
|---:|---|---|---|---|---|---|
| 01 | `POST /auth/login` → `login` (`accounts.go:148`) | B | `CreateSessionVerified` | T | retain account/version transaction check | V01 |
| 02 | `POST /auth/login-key/redeem` → `redeemLoginKey` (`accounts.go:106`) | K | `RedeemLoginKey`; failed-attempt security counter | T | retain account/grant transaction check | V02 |
| 03 | `POST /auth/recovery-grants/redeem` → `redeemLoginKey` (`accounts.go:106`) | K | same as 02 | T | same as 02 | V03 |
| 04 | `POST /auth/logout` → `logout` (`accounts.go:202`) | A | `DeleteSession` credential cleanup | — | permit cleanup after disable | V04 |
| 05 | `POST /auth/password` → `changePassword` (`accounts.go:214`) | S | checked setup/forced/active password Store transaction | T | retain session/account recheck | V05 |
| 06 | `POST /admin/users` → `createAdminUser` (`accounts.go:321`) | A | `CreatePendingAccount` | T | retain actor enabled/admin transaction check | V06 |
| 07 | `PATCH /me/profile` → `patchMeProfile` (`accounts.go:394`) | S | `PatchAccountProfile` | T | retain actor/target transaction check | V07 |
| 08 | `PATCH /admin/users/{userId}/profile` → `patchAdminAccountProfile` (`accounts.go:419`) | A | `PatchAccountProfile` | T | retain actor/target transaction check | V08 |
| 09 | `PUT /admin/users/{userId}/state` → `setAdminAccountState` (`accounts.go:444`) | A | `SetAccountEnabled` | T | retain shared account lock and in-tx actor check | V09 |
| 10 | `POST /admin/users/{userId}/recovery-proposals` → `createAdminRecoveryProposal` (`recovery.go:7`) | A | `CreateRecoveryProposal` | T | retain actor/target transaction check | V10 |
| 11 | `DELETE /admin/users/{userId}/recovery-proposals/{id}` → `revokeAdminRecoveryProposal` (`recovery.go:47`) | A | `RevokeRecoveryProposal` | T | retain actor/target transaction check | V11 |
| 12 | `POST /auth/recovery-proposals/{id}/confirm` → `confirmRecoveryProposal` (`recovery.go:81`) | S | `ConfirmRecoveryProposal` | T | retain owner/session proof in transaction | V12 |
| 13 | `POST /auth/recovery-proposals/{id}/rotate-grant` → `rotateRecoveryGrant` (`recovery.go:105`) | S | `RotateRecoveryGrant` | T | retain owner/session proof in transaction | V13 |
| 14 | `POST /admin/users/{owner}/password` → `resetAdminUserPassword` (`accounts.go:468`) | A | 410 response only | — | no business sink | V14 |
| 15 | `POST /admin/users/{owner}/login-keys` → `issueAdminLoginKey` (`accounts.go:478`) | A | 410 response only | — | no business sink | V15 |
| 16 | `DELETE /admin/users/{owner}/login-keys/{id}` → `revokeAdminLoginKey` (`accounts.go:494`) | A | `RevokeLoginKeyCascade` | M | D: lock/recheck admin actor too, then target/grant | V16 |
| 17 | `POST /projects` → `createProject` (`server.go:713`) | A | `CreateProject` or RDM `Create` + Host projection | M/E | P | V17 |
| 18 | `PATCH /projects/{id}` → `patchProject` (`server.go:734`) | A | RDM `Rename` or `RenameProject`; `UpdateProjectSettings` | M/E | P; D for every settings write | V18 |
| 19 | `DELETE /projects/{id}` → `deleteProject` (`server.go:812`) | A | RDM `Archive` or `DeleteProject` | M/E | P | V19 |
| 20 | `POST /projects/{id}/conversations` → `createConversation` (`server.go:875`) | A | `CreateConversation[InProject]`; RDM `Work` precheck | M | D; keep domain work check | V20 |
| 21 | `PATCH /conversations/{id}` → `patchConversation` (`server.go:914`) | A | update, permission, note, flags: separate Store writes | M | D at each final write | V21 |
| 22 | `DELETE /conversations/{id}` → `deleteConversation` (`server.go:950`) | A | `DeleteConversation` | M | D | V22 |
| 23 | `POST /conversations/{id}/messages` → `postMessage` (`server.go:956`) | A | message, run, message-run link; optional Bus dispatch | M/E | D per write; C for command; actor lock before conversation lock | V23 |
| 24 | `POST /conversations/{id}/rewind` → `rewindFiles` (`rewind.go:27`) | A | checkpoint files, then `AppendMessage` | M/E | X for rewind, D for message | V24 |
| 25 | `POST /conversations/{id}/regenerate` → `regenerateMessage` (`borrow.go:159`) | A | message flags/exclusion, regen marker, rewind files, new run | M/E | D per write, X for rewind | V25 |
| 26 | `POST /messages/{id}/exclude` → `excludeMessage` (`borrow.go:122`) | A | `ExcludeRunGroup(messageID)` | M | D; derive conversation owner in transaction | V26 |
| 27 | `POST /messages/{id}/variant` → `selectVariant` (`borrow.go:146`) | A | `SelectVariant(messageID)` | M | D; derive conversation owner in transaction | V27 |
| 28 | `POST /secrets` → `createSecret` (`borrow.go:21`) | A | `AddSecret` | M | D | V28 |
| 29 | `POST /secrets/{id}/activate` → `activateSecret` (`borrow.go:39`) | A | `ActivateSecret` | M | D | V29 |
| 30 | `PATCH /secrets/{id}` → `renameSecret` (`borrow.go:43`) | A | `RenameSecret` | M | D | V30 |
| 31 | `DELETE /secrets/{id}` → `deleteSecret` (`borrow.go:54`) | A | `DeleteSecret` | M | D | V31 |
| 32 | `POST /context-entries` → `saveContextEntry` (`borrow.go:64`) | A | `SaveContextEntry` | M | D | V32 |
| 33 | `DELETE /context-entries/{id}` → `deleteContextEntry` (`borrow.go:78`) | A | `DeleteContextEntry` | M | D | V33 |
| 34 | `POST /prompt-commands` → `saveUserPromptCommand` (`borrow.go:87`) | A | `SaveUserCommand`, then process Bus register | M/E | D; X before Bus mutation | V34 |
| 35 | `DELETE /prompt-commands/{id}` → `deleteUserPromptCommand` (`borrow.go:102`) | A | `DeleteUserCommand`, then Bus unregister | M/E | D; X before Bus mutation | V35 |
| 36 | `POST /runs/{id}/cancel` → `cancelRun` (`server.go:1278`) | A | PG `EnqueueCommand` or local cancel handle | M/E | D for enqueue with in-tx run owner; X locally | V36 |
| 37 | `POST /hitl/{id}/resolve` → `resolveHITL` (`server.go:1314`) | A | local HITL/card/event writes or PG `EnqueueCommand` | M | D at each Store sink, including queue | V37 |
| 38 | `POST /cards/{id}/actions/{actionId}` → `cardAction` (`server.go:1401`) | A | Cards action or Bus confirm, then receipt message | M/E | D for card/message; C for confirmed handler | V38 |
| 39 | `POST /cards/{id}/pin` → `cardPin` (`server.go:1453`) | A | card version write | M | D | V39 |
| 40 | `POST /cards/{id}/dismiss` → `cardDismiss` (`server.go:1462`) | A | card version/event write | M | D | V40 |
| 41 | `POST /cards/{id}/to_project` → `cardToProject` (`server.go:1386`) | A | project card copy and original dismissal | M | D at both writes | V41 |
| 42 | `POST /projects/{id}/uploads` → `upload` (`server.go:1469`) | A | Blob + artifact row, card, knowledge/vector index | M/E | F; D at later card/index writes | V42 |
| 43 | `PUT /artifacts/{id}/content` → `putArtifactContent` (`server.go:1651`) | A | **in-place Blob overwrite before SQL UPDATE** | M/E | F using new immutable blob ref, guarded DB pointer swap | V43 |
| 44 | `DELETE /artifacts/{id}` → `deleteArtifact` (`server.go:1656`) | A | metadata, index/vector rows, Blob deletion | M/E | D for visible rows; X for post-commit GC | V44 |
| 45 | `POST /profiles` → `saveProfile` (`server.go:1667`) | A | profile Store row | M | D | V45 |
| 46 | `DELETE /profiles/{id}` → `deleteProfile` (`server.go:1683`) | A | profile Store row | M | D | V46 |
| 47 | `POST /prompts` → `savePromptModule` (`server.go:2023`) | A | prompt Store row | M | D | V47 |
| 48 | `DELETE /prompts/{id}` → `deletePromptModule` (`server.go:2044`) | A | prompt Store row | M | D | V48 |
| 49 | `PUT /settings/user` → `putSettingsUser` (`server.go:1717`) | A | settings transaction, then separate snapshot row | M | D for settings and snapshot | V49 |
| 50 | `PUT /settings/project/{id}` → `putSettingsProject` (`server.go:1726`) | A | settings transaction, then separate snapshot row | M | D for settings and snapshot | V50 |
| 51 | `PUT /settings/system` → `putSettingsSystem` (`server.go:1782`) | A | system settings and snapshot under synthetic owner | M | D with **request actor**, not `OwnerSystemRow` | V51 |
| 52 | `POST /tools` → `createUserTool` (`server.go:1815`) | A | process Registry register; future HTTP tool execution | E | X for registration and each tool execution | V52 |
| 53 | `DELETE /tools/{name}` → `deleteUserTool` (`server.go:1834`) | A | process Registry unregister | E | X | V53 |
| 54 | `POST /plugins/{id}/enabled` → `setPluginEnabled` (`platform_api.go:132`) | A | `SetPluginEnabled` upsert | M | D | V54 |
| 55 | `POST /plugins/{id}/activation` → `setSurfaceActivation` (`platform_api.go:251`) | A | activation transaction, separate audit row | M | D for both writes | V55 |
| 56 | `POST /surfaces` → `submitSurface` (`platform_api.go:194`) | A | snapshot transaction, then event/pending row | M | D for snapshot/event writes | V56 |
| 57 | `POST /surfaces/{id}/actions` → `submitSurfaceAction` (`platform_api.go:271`) | A | action insert, Bus dispatch, action-state update | M/E | D for both action writes; C for dispatch | V57 |
| 58 | `POST /plugins/reload` → `reloadPlugins` (`server.go:1908`) | A | process registry/sidecar changes and `UpsertPlugin` | M/E | X for process changes; D for DB writes | V58 |
| 59 | `POST /commands/{id}` → `execCommand` (`server.go:1211`) | A | Bus audit/card/pending or arbitrary command handler/RDM | M/E | C, X for external handler | V59 |
| 60 | `POST /projects/{id}/knowledge` → `addKnowledge` (`server.go:2055`) | A | `AddKnowledge` batch transaction | M | D; in-tx project/owner check | V60 |
| 61 | `POST /tasks` → `createBackgroundTask` (`platform_api.go:16`) | A | task insert | M | D | V61 |
| 62 | `POST /tasks/{id}` → `updateBackgroundTask` (`platform_api.go:52`) | A | task update | M | D | V62 |
| 63 | `POST /memory` → `putMemory` (`platform_api.go:69`) | A | memory upsert | M | D | V63 |
| 64 | `POST /audit` → `postAudit` (`platform_api.go:112`) | A | client-origin business audit row | M | D; security-denial audit is a separate exception | V64 |
| 65 | `POST /admin/backup` → `backup` (`server.go:2224`) | A | directory + SQLite backup file | E | X and explicit admin authorization | V65 |
| 66 | `POST /admin/recovery-holds/{runId}/release` → `releaseRecoveryHold` (`accounts.go:526`) | A | recovery-hold UPDATE | M | D with actor admin check and run/hold owner lookup | V66 |
The route count is not a full write count. `GET /api/llm/budget` → `llmBudget` (`platform_api.go`) calls `AccountTokens(owner, 0, budget)`; the amended Store returns a read-only total when tokens are zero. **V67** therefore has no business write. Ordinary authenticated GETs can refresh session `last_seen_at` as credential maintenance; session/API-key in-flight revocation remains a separate impact analysis.
## Host and run manager exits
| ID | Entry and actor | Final sink | Now → required fence | Verify |
|---|---|---|---|---|
| H01 | `inprocHost.KVPut`, host owner (`hostcaps.go:295`) | `PutPluginKV` | M → D | VH01 |
| H02 | `inprocHost.Audit`, host owner; actor label is `plugin:<id>` (`hostcaps.go:309`) | `AppendAuditEvent` | M → D using host owner | VH02 |
| H03 | `KnowledgeWrite/Publish/Delete`, host owner (`hostcaps.go:350-424`) | knowledge rows, vector rows; publish deletes then writes | M/E → D at every DB sink; X before embedding/remote model | VH03 |
| H04 | `TaskCreate/Update`, host owner (`hostcaps.go:427-443`) | background-task rows | M → D | VH04 |
| H05 | `EnsureProject/RenameOwnedProject`, host owner (`project_lifecycle.go:125-135`) | Backend projection rows, RDM callback upstream | M/E → actor D before project-name lock; X at callback | VH05 |
| H06 | `SurfaceSave`, host owner (`hostcaps.go:481`) | `surfacesvc.Submit` snapshot/event | M → D at Store sinks | VH06 |
| H07 | `Complete`, host owner (`hostcaps.go:319`) | external LLM request | E → X with context cancellation | VH07 |
| H08 | scoped Host reconstructed from owner/plugin ID (`hostcaps.go:103`) | same H01-H07 methods | M/E → same final sink fences; early `requireEffective` alone is insufficient | VH08 |
| G01 | `startClaimedRun`/subagent spawn, run owner (`runmgr.go:1443,1924`) | `CreateRun`, `UpdateRun` and admission rows | M → D for business state; keep failure-only cleanup explicit | VG01 |
| G02 | `Emit`, `AskHITL`, cards, plan/todos (`runmgr.go:676-935`) | events, HITL/card, plan/todo rows | M → D at each final Store write | VG02 |
| G03 | `WriteArtifact`, run owner (`runmgr.go:968`) | Blob, artifact, event, knowledge/vector rows | M/E → F then D for each later write | VG03 |
| G04 | `ExecuteTool`, run owner (`runmgr.go:1080-1167`) | Registry HTTP/command/filesystem side effects, output artifacts | E/M → X before external call; C/D for command and output sinks | VG04 |
| G05 | `stageInputs`, checkpoint recording/rewind and subagent transcript (`inputs.go:17-90`, `rewind.go:37-99`, `subagent_transcript.go:46-84`) | workspace files | E → X before each effect and cancellation; no cross-system atomic claim | VG05 |
| G06 | `ResolveHITL`, run/card owner (`runmgr.go:1780-1860`) | HITL/card/event and possible run writes | M → D | VG06 |
| G07 | background notice delivery and subagent completion (`runmgr.go:1982-2041,2444-2455`) | run/message/event plus transcript files | M/E → D for business rows, X for files | VG07 |
| G08 | `settleRunWithFence`, run owner (`runmgr.go:1700-1722`) | final run + assistant message | T for enabled check in `store/recovery.go:184-220`; retain narrow failure-state exception | VG08 |
| G09 | `OnUsage`, run owner (`app.go:668-680`) | usage and budget audit rows | M → D or documented accounting-only exception | VG09 |
At the 4053c4d baseline, Host/runmgr effective-account queries were early checks, Bus confirmation and Surface writes had separate sinks, and artifact content could be overwritten in place before a checked SQL update. These are the reasons for the sink-level mapping below; file paths and old line numbers in the historical tables are navigational rather than final-SHA evidence.
## Amended candidate mapping and limits
This section supersedes the historical `Now` column when checking the amended candidate. V01–V15 retain their existing account/credential transaction or response-only behavior; V16 uses actor-checked login-key revocation. V17–V23 route project, conversation, message, run creation and link writes through account-checked Store entries. V24–V35 use guarded file/message and borrow/secret/context/command entries; V25 prepares the message exclusion and regeneration marker in one checked transaction, then consumes the marker only with successful run settlement. V36–V44 guard command queue, HITL, card, surface/event, artifact metadata and index writes. Artifact content updates switch immutable blob references in the checked transaction. V45–V51 guard profile, prompt, settings and snapshot writes, using the **request actor** for system settings. V52–V59 combine last checks for process/HTTP/tool effects with guarded plugin, surface, card, command pending and audit DB writes. V60–V66 use guarded knowledge, task, memory, audit and hold updates. V65 refuses whole-database backup under authenticated mode because administrator status does not grant access to other owners' business content; auth-off backup has a last check. V67 is read-only at zero tokens.
VH01–VH04/VH06/VH08 use the Host owner and final Store account checks for Backend-owned rows. H05 delegated rename now derives a **single-project** projection from the current authenticated lifecycle or command Host; its final Store transaction locks the real operator and creator in stable order, checks both effective states and verifies the persisted project owner. The global `NewScopedHost` cannot derive this delegated capability; its original creator-owned methods remain. VH07 checks immediately before its external model call and passes the caller context. VG01–VG09 use checked run, admission/lease, event, HITL, card, artifact, index, notice, command and settle sinks. File staging, transcript, tool HTTP/search/code execution and overlay writeback check just before effects and propagate cancellation. Failed/cancelled/interrupted run sealing, lease release, notice deletion and failed-start marker deletion are narrowly scoped safety cleanup; positive token usage is accounting for model work already incurred.
The H05/V17–V19 late-write blocker was reproduced in the earlier candidate and repaired under the separately approved Documents `5783940` plan. Tests cover creator/operator effective state, a disable between early check and final write on SQLite/PG, a two-process PG disable, opposite lock order progress, and both HTTP PATCH and no-conversation command Host sources. The RDM database authorization/intent commit is still separate from Backend's account/project transaction; a permission withdrawal or cross-database failure is handled by the existing I1 failure intent and recovery path rather than a zero-window assertion. Completion and stage 6 claims require exact-HEAD receipts and human review.
The account lock closes the **account-enabled versus Backend-owned business write** race on the same database. It does not prove that a session or API key revoked after request authentication cannot complete a write while that account remains enabled; that is a distinct credential-version fence and outside this amendment's promise. RDM commits in its own database and external requests, processes and files cannot share the Backend transaction. The last check can race with those effects, and a multi-file operation can leave earlier files committed when a later check fails. No cross-database or external zero-window claim is made.
**Sink discovery still required:** Backend source alone cannot enumerate every dynamically registered Bus handler, plugin sidecar command, or the RDM callback's independent database writes. V17–V19, V23, V38, V57–V59 and VH05 must record the actual composed handlers and their final sinks in the tested composition; an unknown or unguarded handler blocks the A1 completion claim. The external HTTP tool destination and filesystem actions depend on runtime configuration, so VG04/VG05 must verify the actual enabled tool set and cancellation points.
## Common implementation and verification gate
1. Build one short Store write-transaction primitive: `Begin` → PG actor advisory lock (`account:<owner>`) → `SELECT enabled ... FOR UPDATE` → existing resource/scope/conversation/admission locks → resource authorization and write → commit. The actor lock must precede all other PG locks, including project-name locks; participating account owners use sorted order, then bootstrap lock where required. `SetAccountEnabled` already takes the account lock (`store/account_state.go:20-45`); `lockAccountUsers` supplies sorted PG account locking (`store/account_model.go:115-145`). SQLite uses `_txlock=immediate` (`store/store.go:188-199`). In auth-off mode, only a missing `OwnerDefault` row is implicitly enabled; an existing disabled row rejects (`app/account_guard.go:10-23`). No DB connection or transaction spans request body decoding, embedding, tool execution, RDM callback, or full HTTP lifetime.
2. Make methods with only resource IDs derive owner inside the guarded transaction. Pass actor separately for system settings (`OwnerSystemRow`), delegated Host calls, commands, queue commands, audit and background jobs. Preserve project/duty authorization as a separate condition; account enabled does not grant resource access. For files, stage invisible immutable bytes and switch the visible DB reference in `D`; failed transaction deletes staged bytes, post-commit old-blob GC may continue. RDM's independent database keeps the explicit cross-database window from the amendment.
3. Review **every** V/VH/VG route against its final sink on the fixed candidate SHA. Use deterministic pause-and-disable tests for representative project, message/run, artifact, settings, command/surface and Host/runmgr paths on SQLite and isolated PostgreSQL. Test account lock ordering with two PG connections. Verify the existing credential routes' in-transaction ordering. For external effects, verify the last check and cancellation and state the remaining check-to-effect window. Record exact command, tested SHA and output in the Dev task card; local SELF is not independent REPLAY or CI GATE.
Stage 6 stops if any Backend-owned business write lacks a final actor-enabled transaction fence, if artifact content remains visibly mutable after disable via in-place overwrite, or if an external/RDM effect is described as same-DB atomic. Session/API-key in-flight revocation is a separate amendment impact analysis; enabled checks do not prove credential-revocation fencing.
+17
View File
@@ -17,6 +17,8 @@ import (
"strings"
"testing"
"time"
"agent-system-backend/internal/runtime"
)
// upload 上传一个材料文件,返回 artifact id。
@@ -377,6 +379,18 @@ func unverifiedFacts(artifactText, materialText string) []string {
return out
}
// unmarkedFacts 从未核实清单里剔除已按「目标/预计/假设」标注的(与后端
// runtime.MarkedAsEstimate 同一口径,标注词邻域 ±32 字符)。
func unmarkedFacts(tokens []string, artifactText string) []string {
var out []string
for _, tok := range tokens {
if !runtime.MarkedAsEstimate(artifactText, tok) {
out = append(out, tok)
}
}
return out
}
// finalVsArtifact 终轮回复里声称的章节(「一、…」)必须能在工件里找到。
func finalVsArtifact(turn *turnResult, artifactText string) []string {
if turn == nil || turn.FinalText == "" || strings.TrimSpace(artifactText) == "" {
@@ -430,6 +444,9 @@ func renderMarkdown(rep *report) string {
}
if len(tk.UnverifiedFacts) > 0 {
fmt.Fprintf(&b, "\n未核实事实(工件里出现、材料里找不到):%s\n", strings.Join(tk.UnverifiedFacts, "、"))
if len(tk.UnverifiedUnmarkedFacts) > 0 {
fmt.Fprintf(&b, "其中未标注(无出处且无目标/预计/假设标注,E3 闸门口径):%s\n", strings.Join(tk.UnverifiedUnmarkedFacts, "、"))
}
}
if len(tk.FinalVsArtifact) > 0 {
fmt.Fprintf(&b, "\n终轮声称但工件不存在的章节:%s\n", strings.Join(tk.FinalVsArtifact, ";"))
+4
View File
@@ -252,6 +252,9 @@ type taskResult struct {
Turns []turnResult `json:"turns"`
Total turnMetrics `json:"total"`
UnverifiedFacts []string `json:"unverifiedFacts,omitempty"`
// UnverifiedUnmarkedFacts:无出处且未按「目标/预计/假设」标注的子集
// (E3 口径:严格口径 unverifiedFacts 保留原义以维持历史可比性)。
UnverifiedUnmarkedFacts []string `json:"unverifiedUnmarkedFacts,omitempty"`
FinalVsArtifact []string `json:"finalVsArtifact,omitempty"`
}
@@ -307,6 +310,7 @@ func runTask(t *testing.T, c *client, projectID string, tk task, materialsDir st
artText += downloadArtifactText(t, c, projectID, name)
}
tr.UnverifiedFacts = unverifiedFacts(artText, materialText)
tr.UnverifiedUnmarkedFacts = unmarkedFacts(tr.UnverifiedFacts, artText)
tr.FinalVsArtifact = finalVsArtifact(last, artText)
}
}
+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=
+407
View File
@@ -0,0 +1,407 @@
package app
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"sort"
"strings"
"sync"
"unicode"
"unicode/utf8"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
type accountDirectorySlot struct {
mu sync.RWMutex
pluginID string
resolver extension.AccountDirectoryScopeResolver
secret [32]byte
keyVersion int
}
func newAccountDirectorySlot(st *store.Store) (*accountDirectorySlot, error) {
key, err := st.GetAccountCursorKey()
if err != nil {
return nil, err
}
return &accountDirectorySlot{secret: key.Secret, keyVersion: key.Version}, nil
}
func (s *accountDirectorySlot) register(pluginID string, fn extension.AccountDirectoryScopeResolver) error {
if fn == nil || pluginID != "rdm" {
return store.ErrAccountForbidden
}
s.mu.Lock()
defer s.mu.Unlock()
if s.resolver != nil {
return errors.New("account directory resolver already registered")
}
s.pluginID, s.resolver = pluginID, fn
return nil
}
type directoryPolicy struct {
allowed map[string]bool
match []string
candidates map[string]bool
owners map[string]bool
rows []store.DirectoryAccountRow
all bool
status string
version string
}
type directoryCursor struct {
KeyVersion int `json:"k"`
Version string `json:"v"`
Owner string `json:"o"`
UserID string `json:"u"`
}
type directoryVisibleRow struct {
UserID string
Owner string
Enabled bool
Name string
StaffNo string
}
func knownDirectoryField(field string) bool {
return field == "owner" || field == "name" || field == "staffNo"
}
func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery, loadRows bool) (directoryPolicy, error) {
var p directoryPolicy
if err := h.requireEffective(); err != nil {
return p, err
}
if h.actor == "" || h.caps.st == nil || h.caps.directory == nil || q.SchemaVersion != extension.AccountDirectorySchemaVersion {
return p, store.ErrAccountForbidden
}
actor, err := h.caps.st.GetUserAuthState(h.actor)
if err != nil || !actor.Enabled {
return p, store.ErrAccountForbidden
}
if q.Mode != extension.AccountDirectoryModeExact && q.Mode != extension.AccountDirectoryModeFuzzy {
return p, store.ErrInvalidAccountProfile
}
if !utf8.ValidString(q.Query) || utf8.RuneCountInString(q.Query) > 128 {
return p, store.ErrInvalidAccountProfile
}
for _, r := range q.Query {
if unicode.IsControl(r) {
return p, store.ErrInvalidAccountProfile
}
}
p.status = q.Status
if p.status == "" {
p.status = "enabled"
}
if p.status != "enabled" && p.status != "disabled" && p.status != "all" {
return p, store.ErrInvalidAccountProfile
}
var scope extension.AccountDirectoryScope
switch q.Purpose {
case extension.AccountDirectoryPurposeSystemUsers:
if !actor.IsAdmin || q.ProjectID != "" || q.DocumentID != "" {
return p, store.ErrAccountForbidden
}
scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"},
MatchFields: []string{"owner", "name", "staffNo"}, AllCandidates: true, ScopeVersion: "system.users.v1"}
case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeDocumentDuties:
if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") ||
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || p.status != "enabled" {
return p, store.ErrAccountForbidden
}
h.caps.directory.mu.RLock()
resolver, pluginID := h.caps.directory.resolver, h.caps.directory.pluginID
h.caps.directory.mu.RUnlock()
if resolver == nil || pluginID != h.pluginID {
return p, store.ErrAccountForbidden
}
scope, err = resolver(ctx, extension.AccountDirectoryScopeRequest{
Actor: h.actor, PluginID: h.pluginID, Purpose: q.Purpose, ProjectID: q.ProjectID, DocumentID: q.DocumentID,
})
if err != nil {
return p, store.ErrAccountForbidden
}
if q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && scope.AllCandidates {
return p, store.ErrAccountForbidden
}
default:
return p, store.ErrAccountForbidden
}
if scope.ScopeVersion == "" {
return p, store.ErrAccountForbidden
}
p.allowed = map[string]bool{"owner": true}
for _, field := range scope.AllowedFields {
if !knownDirectoryField(field) {
return p, store.ErrAccountForbidden
}
p.allowed[field] = true
}
matchAllowed := map[string]bool{}
for _, field := range scope.MatchFields {
if !knownDirectoryField(field) || !p.allowed[field] {
return p, store.ErrAccountForbidden
}
matchAllowed[field] = true
}
requested := q.SearchFields
if len(requested) == 0 {
requested = []string{"owner", "name"}
}
for _, field := range requested {
if !matchAllowed[field] {
return p, store.ErrAccountForbidden
}
p.match = append(p.match, field)
}
sort.Strings(p.match)
p.candidates = make(map[string]bool, len(scope.CandidateIDs))
p.owners = make(map[string]bool, len(scope.CandidateOwners))
for _, id := range scope.CandidateIDs {
if id == "" {
return p, store.ErrAccountForbidden
}
p.candidates[id] = true
}
for _, owner := range scope.CandidateOwners {
if owner == "" {
return p, store.ErrAccountForbidden
}
p.owners[owner] = true
}
p.all = scope.AllCandidates
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
p.all = true
}
visible := make([]directoryVisibleRow, 0)
if loadRows {
rows, err := h.caps.st.ListDirectoryAccountRows()
if err != nil {
return p, err
}
for _, row := range rows {
if !directoryRowAllowed(row, p) {
continue
}
p.rows = append(p.rows, row)
item := directoryVisibleRow{UserID: row.UserID, Owner: row.Owner, Enabled: row.Enabled}
if p.allowed["name"] {
item.Name = row.Name
}
if p.allowed["staffNo"] {
item.StaffNo = row.StaffNo
}
visible = append(visible, item)
}
}
ids := make([]string, 0, len(p.candidates))
for id := range p.candidates {
ids = append(ids, id)
}
sort.Strings(ids)
owners := make([]string, 0, len(p.owners))
for owner := range p.owners {
owners = append(owners, owner)
}
sort.Strings(owners)
allowed := make([]string, 0, len(p.allowed))
for field := range p.allowed {
allowed = append(allowed, field)
}
sort.Strings(allowed)
fingerprint := struct {
Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Scope string
Match, Allowed, IDs, Owners []string
Visible []directoryVisibleRow
All bool
}{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID,
Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status,
Scope: scope.ScopeVersion, Match: p.match, Allowed: allowed, IDs: ids, Owners: owners, Visible: visible, All: p.all}
encoded, err := json.Marshal(fingerprint)
if err != nil {
return p, err
}
digest := sha256.Sum256(encoded)
p.version = hex.EncodeToString(digest[:])
return p, nil
}
func (s *accountDirectorySlot) encodeCursor(c directoryCursor) (string, error) {
c.KeyVersion = s.keyVersion
data, err := json.Marshal(c)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, s.secret[:])
_, _ = mac.Write([]byte("iam-directory-cursor-v1\x00"))
_, _ = mac.Write(data)
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
func (s *accountDirectorySlot) decodeCursor(token, version string) (directoryCursor, error) {
var c directoryCursor
payload, signature, found := strings.Cut(token, ".")
if !found {
return c, store.ErrConflict
}
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil || len(data) > 1024 {
return c, store.ErrConflict
}
provided, err := base64.RawURLEncoding.DecodeString(signature)
if err != nil {
return c, store.ErrConflict
}
mac := hmac.New(sha256.New, s.secret[:])
_, _ = mac.Write([]byte("iam-directory-cursor-v1\x00"))
_, _ = mac.Write(data)
if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &c) != nil || c.KeyVersion != s.keyVersion || c.Version != version || c.Owner == "" || c.UserID == "" {
return directoryCursor{}, store.ErrConflict
}
return c, nil
}
func directoryEntry(row store.DirectoryAccountRow, p directoryPolicy) extension.AccountDirectoryEntry {
entry := extension.AccountDirectoryEntry{UserID: row.UserID, Owner: row.Owner, Valid: row.Enabled}
if p.allowed["name"] {
entry.Name = row.Name
}
if p.allowed["staffNo"] {
entry.StaffNo = row.StaffNo
}
return entry
}
func directoryMatches(row store.DirectoryAccountRow, fields []string, mode, query string) bool {
for _, field := range fields {
value := row.Owner
switch field {
case "name":
value = row.Name
case "staffNo":
value = row.StaffNo
}
if mode == extension.AccountDirectoryModeExact && strings.EqualFold(value, query) {
return true
}
if mode == extension.AccountDirectoryModeFuzzy && strings.Contains(strings.ToLower(value), strings.ToLower(query)) {
return true
}
}
return false
}
func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool {
if !p.all && !p.candidates[row.UserID] && !p.owners[row.Owner] {
return false
}
return p.status == "all" || (p.status == "enabled" && row.Enabled) || (p.status == "disabled" && !row.Enabled)
}
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 != "")
if err != nil {
return extension.AccountDirectoryPage{}, err
}
maxLimit := 50
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
maxLimit = 100
}
if q.Limit == 0 {
q.Limit = 20
}
if q.Limit < 1 || q.Limit > maxLimit {
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
}
page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version}
if query == "" {
if q.Cursor != "" {
return extension.AccountDirectoryPage{}, store.ErrConflict
}
return page, nil
}
if q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 {
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
}
var after directoryCursor
if q.Cursor != "" {
after, err = h.caps.directory.decodeCursor(q.Cursor, p.version)
if err != nil {
return extension.AccountDirectoryPage{}, err
}
}
matched := make([]store.DirectoryAccountRow, 0, q.Limit+1)
for _, row := range p.rows {
if !directoryMatches(row, p.match, q.Mode, query) {
continue
}
if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) {
continue
}
matched = append(matched, row)
if len(matched) > q.Limit {
break
}
}
more := len(matched) > q.Limit
if more {
matched = matched[:q.Limit]
}
for _, row := range matched {
page.Entries = append(page.Entries, directoryEntry(row, p))
}
if more {
last := matched[len(matched)-1]
page.NextCursor, err = h.caps.directory.encodeCursor(directoryCursor{Version: p.version, Owner: last.Owner, UserID: last.UserID})
if err != nil {
return extension.AccountDirectoryPage{}, err
}
}
return page, nil
}
func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery, userID string) (extension.AccountDirectoryLookup, error) {
if userID == "" || q.Cursor != "" {
return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile
}
p, err := h.directoryPolicy(ctx, q, true)
if err != nil {
return extension.AccountDirectoryLookup{}, err
}
for _, row := range p.rows {
if row.UserID == userID {
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
}
}
return extension.AccountDirectoryLookup{}, store.ErrNotFound
}
func (h inprocHost) LookupAccountDirectoryOwner(ctx context.Context, q extension.AccountDirectoryQuery, owner string) (extension.AccountDirectoryLookup, error) {
if owner == "" || q.Cursor != "" {
return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile
}
p, err := h.directoryPolicy(ctx, q, true)
if err != nil {
return extension.AccountDirectoryLookup{}, err
}
for _, row := range p.rows {
if row.Owner == owner {
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
}
}
return extension.AccountDirectoryLookup{}, store.ErrNotFound
}
var _ extension.AccountDirectoryProvider = inprocHost{}
+221
View File
@@ -0,0 +1,221 @@
package app
import (
"context"
"database/sql"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func openDirectoryTestStore(t *testing.T) *store.Store {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "directory.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
schema := "t_" + strings.ToLower(store.NewID("dir"))
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`)
_ = admin.Close()
})
separator := "?"
if strings.Contains(dsn, "?") {
separator = "&"
}
st, err := store.OpenPostgres(dsn+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
st := openDirectoryTestStore(t)
t.Logf("directory provider dialect=%s", st.Dialect())
if err := st.CreateAccount("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)
}
outsider, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "同名", StaffNo: "hidden-x"})
if err != nil {
t.Fatal(err)
}
slot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"}
query := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
Purpose: extension.AccountDirectoryPurposeProjectMembers, ProjectID: "project-1",
Mode: extension.AccountDirectoryModeFuzzy, Query: "同名", Limit: 1}
if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil {
t.Fatal("domain directory ran without trusted resolver")
}
if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterAccountDirectoryScopeResolver(func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
CandidateIDs: []string{alice.ID, bob.ID}, ScopeVersion: "scope-1"}, nil
}); err != nil {
t.Fatal(err)
}
page, err := h.QueryAccountDirectory(context.Background(), query)
if err != nil || len(page.Entries) != 1 || page.Entries[0].UserID != alice.ID || page.Entries[0].StaffNo != "" || page.NextCursor == "" {
t.Fatalf("first scoped page: %+v %v", page, err)
}
query.Cursor = page.NextCursor
otherSlot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
otherSlot.pluginID, otherSlot.resolver = slot.pluginID, slot.resolver
otherHost := h
otherHost.caps.directory = otherSlot
second, err := otherHost.QueryAccountDirectory(context.Background(), query)
if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" {
t.Fatalf("second scoped page on another host: %+v %v", second, err)
}
query.Cursor = ""
query.Query = "hidden-x"
if found, err := h.QueryAccountDirectory(context.Background(), query); err != nil || len(found.Entries) != 0 {
t.Fatalf("hidden staff number matched: %+v %v", found, err)
}
query.SearchFields = []string{"staffNo"}
if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil {
t.Fatal("hidden staff number accepted as search field")
}
query.SearchFields = nil
query.Query = "同名"
if hit, err := h.LookupAccountDirectoryOwner(context.Background(), query, "alice"); err != nil || hit.Entry.UserID != alice.ID {
t.Fatalf("explicit owner lookup failed to resolve stable ID: %+v %v", hit, err)
}
if _, err := h.LookupAccountDirectoryOwner(context.Background(), query, "outsider"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("owner lookup leaked outsider: %v", err)
}
if _, err := h.LookupAccountDirectory(context.Background(), query, "not-in-scope"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("exact lookup leaked outsider: %v", err)
}
page, err = h.QueryAccountDirectory(context.Background(), query)
if err != nil {
t.Fatal(err)
}
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "private-edit"}); err != nil {
t.Fatal(err)
}
query.Cursor = page.NextCursor
if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil {
t.Fatalf("private edit invalidated candidate cursor: %v", err)
}
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+1, map[string]string{"staffNo": "still-hidden"}); err != nil {
t.Fatal(err)
}
if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil {
t.Fatalf("unavailable field invalidated candidate cursor: %v", err)
}
if _, err := st.PatchAccountProfile("admin", outsider.ID, outsider.ProfileVersion, map[string]string{"name": "out-of-scope-change"}); err != nil {
t.Fatal(err)
}
if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil {
t.Fatalf("out-of-scope edit invalidated candidate cursor: %v", err)
}
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+2, map[string]string{"name": "changed"}); err != nil {
t.Fatal(err)
}
if _, err := h.QueryAccountDirectory(context.Background(), query); !errors.Is(err, store.ErrConflict) {
t.Fatalf("visible edit did not invalidate cursor: %v", err)
}
}
func TestPGDirectoryCursorContinuesInAnotherProcess(t *testing.T) {
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
if baseDSN == "" {
t.Skip("isolated PostgreSQL test DSN required")
}
if os.Getenv("IAM_U3_DIRECTORY_CHILD") == "1" {
schema := os.Getenv("IAM_U3_DIRECTORY_SCHEMA")
separator := "?"
if strings.Contains(baseDSN, "?") {
separator = "&"
}
st, err := store.OpenExistingPostgres(baseDSN+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
slot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st, directory: slot}, actor: "admin", owner: "admin"}
q := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
Purpose: extension.AccountDirectoryPurposeSystemUsers, Mode: extension.AccountDirectoryModeFuzzy,
Query: "same", Limit: 1, Cursor: os.Getenv("IAM_U3_DIRECTORY_CURSOR")}
page, err := h.QueryAccountDirectory(context.Background(), q)
if err != nil || len(page.Entries) != 1 || page.Entries[0].Owner != "beta" {
t.Fatalf("cross-process page: %+v %v", page, err)
}
return
}
st := openDirectoryTestStore(t)
if err := st.EnsureSystemIdentity("u3-directory-cross-process", "pg"); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
for _, owner := range []string{"alpha", "beta"} {
if _, err := st.CreatePendingAccount("admin", owner, store.AccountProfile{Name: "same"}); err != nil {
t.Fatal(err)
}
}
slot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st, directory: slot}, actor: "admin", owner: "admin"}
q := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
Purpose: extension.AccountDirectoryPurposeSystemUsers, Mode: extension.AccountDirectoryModeFuzzy,
Query: "same", Limit: 1}
page, err := h.QueryAccountDirectory(context.Background(), q)
if err != nil || len(page.Entries) != 1 || page.Entries[0].Owner != "alpha" || page.NextCursor == "" {
t.Fatalf("initial page: %+v %v", page, err)
}
var schema string
if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestPGDirectoryCursorContinuesInAnotherProcess$")
cmd.Env = append(os.Environ(), "IAM_U3_DIRECTORY_CHILD=1", "IAM_U3_DIRECTORY_SCHEMA="+schema,
"IAM_U3_DIRECTORY_CURSOR="+page.NextCursor)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("cross-process cursor failed: %v: %s", err, strings.ReplaceAll(string(out), baseDSN, "[REDACTED]"))
}
}
+24
View File
@@ -0,0 +1,24 @@
package app
import (
"context"
"errors"
"agent-system-backend/internal/store"
)
// accountEffectiveAuthorization is shared by runmgr and in-process Host
// capabilities. The implicit local singleton is the only auth-off missing-row
// exception; an existing disabled local account remains denied.
func accountEffectiveAuthorization(st *store.Store, authOn bool) func(context.Context, string) (bool, error) {
return func(_ context.Context, owner string) (bool, error) {
account, err := st.GetAccountByOwner(owner)
if errors.Is(err, store.ErrNotFound) && !authOn && owner == store.OwnerDefault {
return true, nil
}
if err != nil {
return false, err
}
return account.Enabled, nil
}
}
+117
View File
@@ -0,0 +1,117 @@
package app
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
)
func TestInprocHostRechecksEffectiveAccountOnEveryCapability(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/host.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.CreateAccount("alice", "", "", false); err != nil {
t.Fatal(err)
}
check := accountEffectiveAuthorization(st, true)
h := inprocHost{caps: hostcaps{st: st, accountGuard: func(owner string) error {
valid, err := check(context.Background(), owner)
if err != nil {
return err
}
if !valid {
return store.ErrAccountForbidden
}
return nil
}}, owner: "alice", actor: "alice", pluginID: "rdm"}
if err := h.KVPut("before", "allowed"); err != nil {
t.Fatal(err)
}
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, "alice"); err != nil {
t.Fatal(err)
}
if err := h.KVPut("after", "denied"); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled host wrote KV: %v", err)
}
if _, _, err := h.KVGet("before"); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled host read KV: %v", err)
}
if _, err := h.TaskCreate("test", "denied", ""); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled host created task: %v", err)
}
if _, err := h.ProjectNameAvailable("denied", ""); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled host used project capability: %v", err)
}
if err := h.Audit("action", "target", ""); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled host wrote audit: %v", err)
}
if _, err := h.Complete(context.Background(), "denied"); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled host reached model completion: %v", err)
}
if _, err := h.KnowledgeSearch(context.Background(), "", "denied", 1); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled host read knowledge: %v", err)
}
}
func TestHostCompletionChecksAgainAfterModelSettingsRead(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/host-complete.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
requests := 0
model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"late"}}]}`))
}))
defer model.Close()
set := settings.NewService(st)
set.SetSystemOverride(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "test", "baseUrl": model.URL}}})
checks := 0
h := inprocHost{caps: hostcaps{st: st, set: set, accountGuard: func(string) error {
checks++
if checks >= 3 {
return store.ErrAccountForbidden
}
return nil
}}, owner: "alice", actor: "alice", pluginID: "rdm"}
if _, err := h.Complete(context.Background(), "late"); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("completion after disable: %v", err)
}
if checks != 3 || requests != 0 {
t.Fatalf("late completion reached model: checks=%d requests=%d", checks, requests)
}
}
func TestImplicitLocalAccountExceptionIsAuthOffOnly(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/local.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
allowed, err := accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault)
if err != nil || !allowed {
t.Fatalf("implicit local denied: %v %v", allowed, err)
}
allowed, err = accountEffectiveAuthorization(st, true)(context.Background(), store.OwnerDefault)
if !errors.Is(err, store.ErrNotFound) || allowed {
t.Fatalf("auth-on missing account allowed: %v %v", allowed, err)
}
if err := st.EnsureUser(store.OwnerDefault, ""); err != nil {
t.Fatal(err)
}
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, store.OwnerDefault); err != nil {
t.Fatal(err)
}
allowed, err = accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault)
if err != nil || allowed {
t.Fatalf("disabled existing local account allowed: %v %v", allowed, err)
}
}
+126
View File
@@ -0,0 +1,126 @@
package app
// EXT-19 消费测试:inprocHost.ListAccounts——最小目录(owner/valid)、游标分页、
// limit 钳制(1-200,缺省 50)。数据来自 users 表(真实 store,非桩)。
import (
"testing"
"agent-system-backend/internal/store"
)
func TestInprocHostListAccounts(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(dir + "/test.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
for _, o := range []string{"carol", "alice", "bob", "dave"} {
if err := st.EnsureUser(o, ""); err != nil {
t.Fatal(err)
}
if _, err := st.CreateAPIKey(o, "e2e"); err != nil {
t.Fatal(err)
}
}
if _, err := st.DB().Exec(`UPDATE users SET is_admin=TRUE WHERE owner='alice'`); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, owner: "alice", actor: "alice"}
if _, _, err := (inprocHost{caps: hostcaps{st: st}}).ListAccounts("", 2); err == nil {
t.Fatal("legacy directory allowed a host without a trusted dispatch actor")
}
// 首页(limit=2):alice/bob,next=bob。
page, next, err := h.ListAccounts("", 2)
if err != nil {
t.Fatal(err)
}
if len(page) != 2 || page[0].Owner != "alice" || page[1].Owner != "bob" {
t.Fatalf("首页不符: %+v", page)
}
for _, e := range page {
if !e.Valid {
t.Fatalf("现有账号应 valid: %+v", e)
}
}
if next != "bob" {
t.Fatalf("next 应为 bob, got %q", next)
}
// 次页:carol/dave,next=""(末页)。
page2, next2, _ := h.ListAccounts(next, 2)
if len(page2) != 2 || page2[0].Owner != "carol" || page2[1].Owner != "dave" || next2 != "" {
t.Fatalf("次页不符: %+v next=%q", page2, next2)
}
// limit 钳制:0 → 50 缺省;超 200 → 200(不炸)。
if p, _, _ := h.ListAccounts("", 0); len(p) != 4 {
t.Fatalf("limit=0 应取缺省(4 账号全量), got %d", len(p))
}
if _, _, err := h.ListAccounts("", 999); err != nil {
t.Fatalf("limit 超限应钳制不报错: %v", err)
}
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
t.Fatal(err)
}
if _, _, err := h.ListAccounts("", 2); err == nil {
t.Fatal("disabled administrator retained legacy directory access")
}
}
// TestInprocHostSearchAccounts:EXT-19 扩展 SearchAccounts——账号/姓名子串
// 检索、大小写不敏感、空 query 首页、limit 钳制(P3,2026-10-13)。
func TestInprocHostSearchAccounts(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(dir + "/test.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
hash, err := store.HashPassword("init-pass-01")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "王小明", hash, true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("bob", "李大明", hash, false); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("carol", "", hash, false); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, owner: "alice", actor: "alice"}
if _, err := (inprocHost{caps: hostcaps{st: st}, owner: "bob", actor: "bob"}).SearchAccounts("alice", 50); err == nil {
t.Fatal("ordinary actor gained legacy system directory search")
}
// 姓名子串(同名区分场景):两个"大明/小明"都以"明"命中。
got, err := h.SearchAccounts("明", 50)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Owner != "alice" || got[1].Owner != "bob" {
t.Fatalf("姓名子串命中不符: %+v", got)
}
if got[0].Name != "王小明" {
t.Fatalf("目录行应携带姓名: %+v", got[0])
}
// 账号子串、大小写不敏感。
got, _ = h.SearchAccounts("AL", 50)
if len(got) != 1 || got[0].Owner != "alice" {
t.Fatalf("账号子串应命中 alice: %+v", got)
}
// 未命中。
if got, _ = h.SearchAccounts("不存在", 50); len(got) != 0 {
t.Fatalf("未命中应为空: %+v", got)
}
// 空 query=首页候选(owner 升序);姓名为空的账号 Name 为空串。
if got, _ = h.SearchAccounts("", 2); len(got) != 2 || got[0].Owner != "alice" || got[1].Name != "李大明" {
t.Fatalf("空 query 首页不符: %+v", got)
}
// ListAccounts 回归:分页行同样携带姓名。
page, _, _ := h.ListAccounts("", 2)
if page[0].Name != "王小明" {
t.Fatalf("ListAccounts 应携带姓名: %+v", page[0])
}
}
+58
View File
@@ -0,0 +1,58 @@
package app
import (
"agent-system-backend/internal/store"
"os"
"path/filepath"
"testing"
)
func TestAccountMaintenanceDefaultsToNonCreatingOpen(t *testing.T) {
t.Setenv("AGENT_SYSTEM_DSN", "")
for _, tc := range []struct {
name string
run func(string) error
}{
{"user", func(dir string) error {
_, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false)
return err
}},
{"apikey", func(dir string) error {
_, err := CreateAPIKey("", dir, "ada", "test")
return err
}},
{"identity verify", func(dir string) error {
_, err := VerifySystemIdentity("", dir, "fixture", "sqlite")
return err
}},
} {
t.Run(tc.name, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "absent")
if err := tc.run(dir); err == nil {
t.Error("maintenance created a missing database")
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Error("failed maintenance created a directory")
}
})
}
}
func TestAccountMaintenanceCannotImplicitlyMigrate(t *testing.T) {
t.Setenv("AGENT_SYSTEM_DSN", "")
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
if err := st.EnsureSystemIdentity("test", "sqlite"); err != nil {
t.Fatal(err)
}
// Roll back the newest additive migration in this private fixture only.
if err := st.MigrateDown(store.SchemaTarget() - 1); err != nil {
t.Fatal(err)
}
st.Close()
if _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false); err == nil {
t.Error("account command implicitly migrated an older schema")
}
}
+511 -117
View File
@@ -5,6 +5,7 @@ package app
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
@@ -34,6 +35,7 @@ import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/config"
"agent-system-backend/internal/genui"
"agent-system-backend/internal/hostapi"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsem"
@@ -50,22 +52,25 @@ import (
"agent-system-backend/internal/skills"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
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
@@ -79,6 +84,70 @@ type Options struct {
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
// 硬关闭 DisableExecTools 优先级高于后端选择。
ExecBackend string
// 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
// Tier 部署档(协作存储 KTD1):空=auto(DSN→PG 否则 SQLite);local=SQLite;
// team=PG fail-closed(缺 DSN/未认证拒绝启动;PG 故障拒绝就绪,不回落 SQLite)。
Tier string
// PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。
PGPool store.PoolOptions
// AdoptInstanceGuard 在数据库被替换或锁文件被重建后,显式重写守卫记录。
AdoptInstanceGuard bool
}
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
// 本地档退化为 local(单实例语义)。
func instanceTag(opt Options) string {
if opt.Tier == "team" || opt.StorageDSN != "" {
return hostTag() + ":" + opt.Addr
}
return "local"
}
func hostTag() string {
h, err := os.Hostname()
if err != nil || h == "" {
return "host"
}
return h
}
// tierName 输出部署档名(诊断统一拼写:空 = auto)。
func tierName(opt Options) string {
if opt.Tier == "" {
return "auto"
}
return opt.Tier
}
// TierRefusal 部署档门禁(KTD1):返回阻止启动的问题(空 = 放行)。
// team 档 fail-closed:缺 DSN、未开认证拒绝;PG 故障的拒绝发生在其后的
// OpenPostgres(Run 对 team 档的错误显式声明不回落 SQLite)。
func TierRefusal(opt Options) []string {
return config.Resolved{Config: config.Config{
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, Tier: opt.Tier,
PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
}}.TierProblems()
}
// latestVersion 返回已应用迁移的最高版本(0 = 空账本)。
func latestVersion(versions []int) int {
max := 0
for _, v := range versions {
if v > max {
max = v
}
}
return max
}
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
@@ -87,32 +156,42 @@ func Run(opt Options) error {
Service: "agent-system", Module: "app", Level: logsys.LevelInfo,
Sinks: []logsys.Sink{&sink.Console{Out: os.Stderr, Format: "json"}},
})
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir)
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir, "tier", tierName(opt))
resolved := config.Resolved{Config: config.Config{
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, InsecureListen: opt.InsecureListen,
Tier: opt.Tier, PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
}}
if msg := resolved.ListenRefusal(); msg != "" {
return fmt.Errorf("listen guard: %s", msg)
}
// 部署档门禁(KTD1 fail-closed):team 档缺 DSN/未开认证直接拒绝启动;
// local 档带 DSN 是配置错误。此处与 config.TierProblems 同规则(纵深防御,
// 服务入口不依赖 CLI 是否校验)。
if msgs := TierRefusal(opt); len(msgs) > 0 {
return fmt.Errorf("tier guard: %s", strings.Join(msgs, ";"))
}
if opt.Tier == "team" && !opt.DisableExecTools && opt.ExecBackend == "" {
log.Printf("warning: team 档未配置执行隔离(disableExecTools 或 execBackend);向真实多用户开放前必须补齐(方案 KTD1)")
}
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")
}
st, err = store.OpenPostgres(opt.StorageDSN, artifacts)
} else {
st, err = store.OpenSQLite(filepath.Join(opt.DataDir, "agent-system.db"))
}
st, releaseStore, err := openServiceStore(opt)
if err != nil {
return fmt.Errorf("open store: %w", err)
return err
}
defer releaseStore()
pool := st.Pool()
if versions, verr := st.MigrationStatus(); verr == nil {
log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)",
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, pool.ConnMaxIdleTimeSec,
pool.ConnMaxLifetimeSec, latestVersion(versions))
} else {
log.Printf("storage tier: %s (dialect=%s pool=%d/%d schema=? migration status unavailable: %v)",
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, verr)
}
defer st.Close()
log.Printf("storage tier: %s", st.Dialect())
b := bus.New()
reg := tools.NewRegistry()
@@ -138,6 +217,28 @@ 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)
if tools.ExecBackendName() == "sandbox" {
log.Printf("exec tools: workspace=overlay (frozen base + private upper + CAS apply)")
} else {
log.Printf("exec tools: workspace=overlay ignored (backend is not sandbox)")
}
} else if opt.ExecWorkspace != "" {
return fmt.Errorf("exec workspace: unknown mode %q (overlay)", opt.ExecWorkspace)
}
if err := tools.RegisterExecTools(reg); err != nil {
return fmt.Errorf("exec tools: %w", err)
}
@@ -167,11 +268,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
}
return st.ActiveSecret(owner, service)
if v, ok := st.ActiveSecret(owner, service); ok {
return v, "owner", true
}
// P2 受管:密钥统一迁入 secrets 表后,系统层受管密钥挂在系统行
//(owner=settings.OwnerSystemRow);租户未单独配置时回退系统密钥。
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)
@@ -250,6 +359,7 @@ func Run(opt Options) error {
return nil
},
}
cfg.Stream = runtime.GuardProjectStream(rc, "", cfg.Client.Stream)
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
tavilyNow = active
@@ -270,7 +380,17 @@ func Run(opt Options) error {
if mgr := mcpManagerFor(rawMCP, strings.TrimSpace(effNow.GetString("tools.mcp.overrides", ""))); mgr != nil {
cfg.MCP = mgr
}
cfg.CodeOrchestration = effNow.GetBool("runtime.codeOrchestration", false)
// 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 {
@@ -308,7 +428,10 @@ func Run(opt Options) error {
return fmt.Errorf("default profile: %w", err)
}
if opt.ForceMock {
if err := setSvc.MergeUser(store.OwnerDefault, map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
// runtime.* 自 #188 起为系统受管项:启动选项属受信操作员输入,
// 播种走系统层(对所有租户生效);用户层写入会被受管拦截拒绝,
// 导致 serve --mock 直接启动失败(multi-instance 集成回归)。
if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
return err
}
}
@@ -317,6 +440,18 @@ 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)
instanceID := opt.InstanceID
if instanceID == "" {
host, err := os.Hostname()
@@ -362,6 +497,9 @@ func Run(opt Options) error {
logger.Error("plugin load failed", perr, "plugin", id, "code", string(errcode.SysUnavailable))
}
}
// P2 受管:插件声明里带 scope=system 的设置项同样纳入受管路径
//(user/project 写入拒绝、生效链剥除)。
setSvc.SetExtraSchema(pluginHost.SettingsSchema())
// 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。
pipe := rag.NewPipeline(st, setSvc, nil, nil)
@@ -388,19 +526,131 @@ func Run(opt Options) error {
// fs 预留能力沙箱根:<DataDir>/workspaces/<owner>/<conversationID>/。
mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces"))
// 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。
mgr.ResumeInterruptedChildren()
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)
cardSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked)
cardSvc.SetProjectSharedEmitter(mgr.EmitProjectSharedCardEvent)
surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent)
surfaceSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked)
go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。
if n, err := surfaceSvc.RecoverPendingEventsAccountChecked(context.Background(), !authOn); err == nil && n > 0 {
log.Printf("surface: recovered %d pending events", n)
}
}()
mgr.SetCards(cardSvc)
// 执行身份与命令消费(协作存储 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 实测调整)。
mgr.SetExecutionBudgetFn(func() store.ExecutionBudget {
eff := setSvc.Effective(store.OwnerDefault, "")
return store.ExecutionBudget{
Global: int(eff.GetNumber("runtime.execBudgetGlobal", 0)),
PerUser: int(eff.GetNumber("runtime.execBudgetPerUser", 0)),
}
}, int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.execQueueMax", 200)))
// 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。
bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() })
bus.SetEffectiveAccountGuard(func(ctx context.Context, actor string) error {
valid, err := effectiveAccount(ctx, actor)
if err != nil {
return err
}
if !valid {
return store.ErrAccountForbidden
}
return nil
})
bus.RequireAccountCheckedWrites(!authOn)
// U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发
//(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。
surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
return dispatchSurfaceAction(ctx, bus, d)
})
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
bus.SetToolGate(func(owner, commandID string) bool {
if !strings.HasPrefix(commandID, genui.PluginID+".") {
return true
}
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
return err == nil && row.Enabled
})
// 自动建议页面 opt-in:settings ∧ activation;预算/冷却在 genui.Auto。
mgr.SetGenUIAuto(genui.NewAuto(
func(owner string) bool {
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
return err == nil && row.Enabled
},
func(owner string) bool {
return setSvc.Effective(owner, "").GetBool(genui.SettingAutoPresent, false)
},
func(owner, kind, detail string) {
_ = st.AppendAuditEvent(store.AuditEvent{Owner: owner, Action: kind, Evidence: detail})
},
))
bus.SetPendingStore(st)
pluginHost.OnCommands = bus.RegisterPluginCommands
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
authzSlot := &projectAuthz{}
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot}
projectSlot := &projectLifecycleSlot{}
workObserverSlot := &projectWorkObserverSlot{}
directorySlot, err := newAccountDirectorySlot(st)
if err != nil {
return err
}
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 {
return err
}
if !valid {
return store.ErrAccountForbidden
}
return nil
}}
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")
}
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 {
@@ -412,7 +662,26 @@ func Run(opt Options) error {
mgr.SetCommandSource(busAdapter{bus: bus})
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
wireScopedHostFactory(caps)
applyCompiled(skillsRoot, bus, caps)
compiledSummary := applyCompiled(skillsRoot, bus, caps)
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)
})
if authzSlot.hasV2() {
mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool {
ok, err := authzSlot.AuthorizeResource(extension.ResourceAuthzQuery{Actor: actor, Action: "model-context", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
return err == nil && ok
})
}
mgr.OnUsage = func(owner string, tokens int) {
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
status, aerr := st.AccountTokens(owner, tokens, budget)
@@ -426,6 +695,9 @@ func Run(opt Options) error {
}
}
hostSrv.Dispatch = func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error {
if err := caps.accountGuard(owner); err != nil {
return err
}
resp := bus.Dispatch(ctx, cmdbus.Request{ID: commandID, Params: params, Entrance: "api", Actor: pluginID, Owner: owner})
if resp.Receipt.State == "rejected" || resp.Receipt.State == "unknown" {
return fmt.Errorf("%s", resp.Receipt.Reason)
@@ -437,26 +709,22 @@ func Run(opt Options) error {
}
// EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。
hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
own, err := caps.searchDefault(ctx, owner, text)
if err != nil {
return nil, err
}
shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool {
ok, aerr := authzSlot.Authorize(owner, projectID)
return aerr == nil && ok
})
if err != nil {
return nil, err
}
return append(own, shared...), nil
return caps.searchDefault(ctx, owner, text)
}
hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) {
return caps.complete(ctx, owner, prompt)
}
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
srv.ExecEnv = broker
projectSlot.wire(srv, caps)
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
srv.SetProjectAuthorizer(authzSlot.Authorize)
// EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。
if authzSlot.hasV2() {
srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource)
}
// EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。
srv.DisableUploadIndexing = opt.DisableUploadIndexing
// EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。
@@ -465,6 +733,9 @@ func Run(opt Options) error {
srv.Workflows = wfReg
mgr.SetWorkflows(wfReg)
srv.Cards = cardSvc
srv.Surfaces = surfaceSvc
// WP3-I4:编译插件摘要进 /api/plugins 管理清单(注册面即清单面)。
srv.CompiledPlugins = compiledSummary
srv.Bus = bus
srv.Gate = gate
srv.Prompts = promptSvc
@@ -488,7 +759,6 @@ func Run(opt Options) error {
return int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.rateLimitPerMin", 0))
})
// 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。
authOn := resolved.AuthEnabled()
srv.Auth = server.NewAuthenticator(st, authOn)
if authOn {
log.Printf("auth: API key required (create with: agent-system apikey create --owner <name>)")
@@ -533,48 +803,105 @@ func Run(opt Options) error {
return nil
}
// CreateUserAccount CLI 建号(管理员发放制):未给密码则生成随机 12 位并返回明文(仅此一次输出)。
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin bool) (string, error) {
if password == "" {
password = server.RandomPassword()
func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error {
if storageDSN == "" {
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
}
hash, err := store.HashPassword(password)
if storageDSN != "" {
var isOwner bool
err := st.DB().QueryRow(`SELECT current_user = pg_get_userbyid(c.relowner) FROM pg_class c WHERE c.oid='users'::regclass`).Scan(&isOwner)
if err != nil || !isOwner {
return store.ErrAccountForbidden
}
return nil
}
info, err := os.Stat(filepath.Join(dataDir, "agent-system.db"))
if err != nil {
return "", err
return err
}
if err := withStore(storageDSN, dataDir, func(st *store.Store) error {
return st.CreateAccount(owner, name, hash, isAdmin)
}); err != nil {
return "", err
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || (os.Geteuid() != 0 && stat.Uid != uint32(os.Geteuid())) {
return store.ErrAccountForbidden
}
return password, nil
return nil
}
// ResetUserPassword CLI 重置密码:must_change=1(首登强制改密),吊销全部会话。
func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, error) {
if password == "" {
password = server.RandomPassword()
// CreateUserAccount is the local owner-only, audited pending-account path.
// Password-bearing creation is rejected before storage is opened.
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, string, error) {
if password != "" || generatePassword {
return "", "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only")
}
hash, err := store.HashPassword(password)
if err != nil {
return "", err
}
if err := withStore(storageDSN, dataDir, func(st *store.Store) error {
if err := st.SetUserPassword(owner, hash); err != nil {
var out store.AccountOutcome
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
return err
}
st.DeleteUserSessions(owner, "")
return nil
}); err != nil {
return "", err
}
return password, nil
_, next, err := st.CreateLocalPendingAccountWithAudit(owner, name, isAdmin, accountInstanceID())
out = next
return err
})
return out.Plain, out.OperationID, err
}
// IssueUserLoginKey is retained only as a rejecting compatibility entry.
func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) {
return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery")
}
// InitializeAccount resumes a specific pending account. The operator identity
// comes from the local OS UID; the target is fixed by stable user ID and CAS.
func InitializeAccount(storageDSN, dataDir, userID string, expectedStateVersion, expectedGeneration int64) (string, string, error) {
var plain, owner string
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
return err
}
account, err := st.GetAccount(userID)
if err != nil {
return err
}
out, err := st.InitializePendingAccountWithAudit(userID, expectedStateVersion, expectedGeneration, accountInstanceID())
plain, owner = out.Plain, account.Owner
return err
})
return plain, owner, err
}
// RevokeUserLoginKey CLI 撤销未使用登录 Key。
func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error {
return withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
return st.RevokeLoginKey(id)
})
}
// BootstrapAdmin 首位管理员引导:单事务串行(KTD12——PG 咨询锁/SQLite 插入后复查),
// 建号、激活 Key 与审计共同提交;库内已有用户即拒绝接管。
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
}
// ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、
// 作废未用登录 Key、吊销全部会话并审计。
func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) {
return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password")
}
// ListUserAccounts CLI 用户列表。
func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) {
func ListUserAccounts(storageDSN, dataDir string, openExisting bool) ([]store.AdminUser, error) {
var out []store.AdminUser
err := withStore(storageDSN, dataDir, func(st *store.Store) error {
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
var e error
out, e = st.ListUsersAdmin()
return e
@@ -583,13 +910,12 @@ func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) {
}
func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
var st *store.Store
var err error
if storageDSN != "" {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
}
return withStoreMode(storageDSN, dataDir, false, fn)
}
// withIdentityStore 只打开既有库:不建文件、不建目录、不迁移。
func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
st, err := store.OpenIdentity(storageDSN, dataDir)
if err != nil {
return err
}
@@ -597,55 +923,119 @@ func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
return fn(st)
}
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) error) error {
var st *store.Store
var err error
if storageDSN != "" {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
// DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。
if storageDSN == "" {
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
}
// Account maintenance never initializes or migrates storage; the retained
// CLI compatibility flag cannot opt this operation into service initialization.
if storageDSN != "" {
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return "", err
return err
}
defer st.Close()
return st.CreateAPIKey(owner, name)
if id := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); id != "" {
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
if dbref == "" {
if storageDSN != "" {
dbref = "pg"
} else {
dbref = "sqlite"
}
}
if err := st.VerifySystemIdentity(id, dbref); err != nil {
return err
}
}
return fn(st)
}
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
var plain string
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
return err
}
account, err := st.GetAccountByOwner(owner)
if err != nil {
return err
}
if !account.Enabled || account.InitializationState == store.InitializationPending {
return store.ErrAccountForbidden
}
p, e := st.CreateAPIKey(owner, name)
plain = p
return e
})
return plain, err
}
// ListAPIKeys 列出租户密钥(摘要视图)。
func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) {
var st *store.Store
var err error
if storageDSN != "" {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return nil, err
}
defer st.Close()
if _, err := st.GetUser(owner); err != nil {
return nil, fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
}
return st.ListAPIKeys(owner)
var out []store.APIKeyInfo
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
if _, err := st.GetUser(owner); err != nil {
return fmt.Errorf("owner %q 不存在(先建立账号)", owner)
}
var e error
out, e = st.ListAPIKeys(owner)
return e
})
return out, err
}
// RevokeAPIKey 撤销指定名称密钥。
func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
var st *store.Store
var err error
if storageDSN != "" {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return false, err
}
defer st.Close()
return st.RevokeAPIKeyByName(owner, name)
var ok bool
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
var e error
ok, e = st.RevokeAPIKeyByName(owner, name)
return e
})
return ok, err
}
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
// 只打开既有库,不建库、不迁移。
func EnsureSystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
return withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
return st.EnsureSystemIdentity(instanceID, dbref)
})
}
// VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。
// 只打开既有库,不建库、不迁移。schemaVersion 取身份行上的值。
func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, error) {
var schemaVersion int
err := withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
if err := st.VerifySystemIdentity(instanceID, dbref); err != nil {
return err
}
row, err := st.GetSystemIdentity()
if err != nil {
return err
}
schemaVersion = row.SchemaVersion
return nil
})
return schemaVersion, err
}
func cliActor() string {
return fmt.Sprintf("%d", os.Getuid())
}
func accountInstanceID() string {
return strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID"))
}
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
@@ -726,7 +1116,11 @@ func splitKeyPool(s string) []string {
type busAdapter struct{ bus *cmdbus.Bus }
func (a busAdapter) CommandTools() []runmgr.CommandTool {
src := a.bus.CommandTools()
return a.CommandToolsFor("")
}
func (a busAdapter) CommandToolsFor(owner string) []runmgr.CommandTool {
src := a.bus.CommandToolsFor(owner)
out := make([]runmgr.CommandTool, len(src))
for i, ct := range src {
out[i] = runmgr.CommandTool{ID: ct.ID, Description: ct.Description, Params: ct.Params, Risk: ct.Risk}
+147 -5
View File
@@ -11,6 +11,7 @@ import (
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/server"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
@@ -26,6 +27,29 @@ type pluginRegistry struct {
compiledRegistry
pluginID string
caps hostcaps
// summary 是本插件的注册面摘要(WP3-I4):AddAdminZone 声明记录于此,
// server 经 CompiledPlugins 供给分区清单端点(WP3-I3)。
summary *server.CompiledPlugin
}
// AddAdminZone 记录管理区分区声明(WP3-I3):声明即展示元数据,权威记录
// 就是注册期摘要;规格非法(非对象、id/title 缺失)fail-closed 拒绝,
// 使 register() 整体中止(与命令注册同一失败语义)。
func (r pluginRegistry) AddAdminZone(spec json.RawMessage) error {
if r.summary == nil {
return errors.New("extension: " + r.pluginID + ": admin zone outside applyCompiled")
}
var s extension.AdminZoneSpec
if err := json.Unmarshal(spec, &s); err != nil {
return fmt.Errorf("extension: admin zone spec: %w", err)
}
if s.ID == "" || s.Title == "" {
return fmt.Errorf("extension: %s: admin zone requires id and title", r.pluginID)
}
r.summary.AdminZones = append(r.summary.AdminZones, server.AdminZoneSection{
ID: s.ID, Title: s.Title, Icon: s.Icon, Desc: s.Desc, Order: s.Order,
})
return nil
}
func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
@@ -40,10 +64,13 @@ 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 := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner}
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,
})
})
}
@@ -82,10 +109,37 @@ func (r compiledRegistry) AddCommand(spec json.RawMessage) error {
}
// RegisterResourceAuthorizer 声明式扩展无宿主进程内代码,不能提供回调(fail-closed)。
// RegisterResourceAuthorizerV2 声明式扩展同样不能提供资源级回调(fail-closed)。
func (r compiledRegistry) RegisterResourceAuthorizerV2(extension.ResourceAuthorizerV2) error {
return errors.New("extension: declarative plugins cannot register resource authorizers")
}
func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorizer) error {
return errors.New("extension: declarative plugins cannot register resource authorizers")
}
func (r compiledRegistry) RegisterAccountDirectoryScopeResolver(extension.AccountDirectoryScopeResolver) error {
return errors.New("extension: declarative plugins cannot register account directory authorization")
}
func (r pluginRegistry) RegisterAccountDirectoryScopeResolver(fn extension.AccountDirectoryScopeResolver) error {
if r.pluginID != "rdm" || r.caps.directory == nil {
return errors.New("extension: account directory resolver requires trusted RDM registration")
}
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 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
@@ -99,22 +153,110 @@ func (r pluginRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthoriz
return nil
}
// RegisterResourceAuthorizerV2 进程内扩展:填充 EXT-8b 资源级适配器槽(首注册生效)。
func (r pluginRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error {
if fn == nil {
return errors.New("extension: " + r.pluginID + ": nil resource authorizer v2")
}
r.caps.authz.setV2(r.pluginID, fn)
return nil
}
func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error {
log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload))
return nil
}
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) {
// recordRegistry 包装 per-plugin 注册表,把成功注册的声明资产名记入摘要
// (WP3-I4:注册面即清单面——编译插件的管理条目不另设声明口)。
type recordRegistry struct {
extension.Registry
sum *server.CompiledPlugin
}
func (r recordRegistry) RegisterAccountDirectoryScopeResolver(fn extension.AccountDirectoryScopeResolver) error {
registrar, ok := r.Registry.(extension.AccountDirectoryRegistrar)
if !ok {
return errors.New("extension: account directory registration unavailable")
}
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
}
if id := specID(spec); id != "" {
r.sum.Commands = append(r.sum.Commands, id)
}
return nil
}
func (r recordRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
if err := r.Registry.AddCommandHandler(spec, h); err != nil {
return err
}
if id := specID(spec); id != "" {
r.sum.Commands = append(r.sum.Commands, id)
}
return nil
}
func (r recordRegistry) AddIntent(spec json.RawMessage) error {
if err := r.Registry.AddIntent(spec); err != nil {
return err
}
if id := specID(spec); id != "" {
r.sum.Intents = append(r.sum.Intents, id)
}
return nil
}
func (r recordRegistry) AddSkill(name, markdown string) error {
if err := r.Registry.AddSkill(name, markdown); err != nil {
return err
}
r.sum.Skills = append(r.sum.Skills, name)
return nil
}
// specID 宽松提取 spec 顶层 id:仅服务清单摘要展示,解析失败返回空串
// 不记录,不影响注册结果本身。
func specID(spec json.RawMessage) string {
var s struct {
ID string `json:"id"`
}
if err := json.Unmarshal(spec, &s); err != nil {
return ""
}
return s.ID
}
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) []server.CompiledPlugin {
base := compiledRegistry{skillsRoot: skillsRoot, bus: bus}
var out []server.CompiledPlugin
for _, e := range extension.Compiled() {
if e.Register == nil {
continue
}
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps}
if err := e.Register(reg); err != nil {
sum := server.CompiledPlugin{ID: e.ID}
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps, summary: &sum}
// Register 失败的插件保留已成功注册部分(与命令总线一致:部分注册
// 生效),摘要如实反映实际注册面。
if err := e.Register(recordRegistry{Registry: reg, sum: &sum}); err != nil {
log.Printf("extension: %s: %v", e.ID, err)
}
out = append(out, sum)
}
return out
}
func mergeMCPServers(raw string, extra []core.PluginMCPServer) string {
+92
View File
@@ -0,0 +1,92 @@
package app
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
// WP3-I4:applyCompiled 的注册面摘要——成功注册的命令/意图/技能记入
// CompiledPlugin,失败注册不记录,Register 整体失败保留已成功部分。
func TestApplyCompiledRecordsSummary(t *testing.T) {
extension.ResetCompiled()
t.Cleanup(extension.ResetCompiled)
extension.RegisterCompiled(extension.InProc{ID: "demo", Register: func(reg extension.Registry) error {
if err := reg.AddCommandHandler(json.RawMessage(`{"id":"demo.one","title":"一"}`), func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
return "ok", nil
}); err != nil {
return err
}
// 重复 id 注册失败:总线拒绝,摘要不记录。
if err := reg.AddCommand(json.RawMessage(`{"id":"demo.one","title":"重复"}`)); err == nil {
t.Fatal("duplicate command should fail")
}
if err := reg.AddIntent(json.RawMessage(`{"id":"demo.go","phrases":["演示"],"command":"demo.one"}`)); err != nil {
return err
}
_ = reg.AddSkill("demo-skill", "# 演示\n技能正文")
// WP3-I3:管理区分区声明记入摘要;缺 id/title 的非法规格 fail-closed。
if err := reg.AddAdminZone(json.RawMessage(`{"id":"demo.audit","title":"演示审计","icon":"search","order":20}`)); err != nil {
return err
}
if err := reg.AddAdminZone(json.RawMessage(`{"title":"缺 id"}`)); err == nil {
t.Fatal("admin zone without id should fail")
}
return nil
}})
extension.RegisterCompiled(extension.InProc{ID: "broken", Register: func(reg extension.Registry) error {
if err := reg.AddCommand(json.RawMessage(`{"id":"broken.cmd","title":"先成功"}`)); err != nil {
return err
}
return reg.AddCommandHandler(json.RawMessage(`not-json`), nil)
}})
// 无 Register 的登记不产生条目。
extension.RegisterCompiled(extension.InProc{ID: "empty"})
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
sums := applyCompiled(t.TempDir(), bus, hostcaps{st: st, set: settings.NewService(st)})
if len(sums) != 2 {
t.Fatalf("want 2 summaries, got %d: %+v", len(sums), sums)
}
if sums[0].ID != "demo" {
t.Fatalf("sums[0] = %+v", sums[0])
}
if len(sums[0].Commands) != 1 || sums[0].Commands[0] != "demo.one" {
t.Fatalf("demo commands = %v", sums[0].Commands)
}
if len(sums[0].Intents) != 1 || sums[0].Intents[0] != "demo.go" {
t.Fatalf("demo intents = %v", sums[0].Intents)
}
if len(sums[0].Skills) != 1 || sums[0].Skills[0] != "demo-skill" {
t.Fatalf("demo skills = %v", sums[0].Skills)
}
// WP3-I3:合法声明记入,非法规格(缺 id)被拒后摘要不受污染。
if len(sums[0].AdminZones) != 1 {
t.Fatalf("demo admin zones = %+v", sums[0].AdminZones)
}
if z := sums[0].AdminZones[0]; z.ID != "demo.audit" || z.Title != "演示审计" || z.Icon != "search" || z.Order != 20 {
t.Fatalf("demo admin zone = %+v", z)
}
// broken:Register 报错但先成功的命令仍在总线,摘要如实保留。
if sums[1].ID != "broken" || len(sums[1].Commands) != 1 || sums[1].Commands[0] != "broken.cmd" {
t.Fatalf("broken summary = %+v", sums[1])
}
// 摘要与总线一致:记录的命令真实可分发(不是空头衔)。
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "demo.one", Owner: "alice", Entrance: "test"})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("demo.one dispatch = %s (%s)", resp.Receipt.State, resp.Receipt.Reason)
}
}
@@ -0,0 +1,122 @@
package app
import (
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// requestBoundHost is created only for a current lifecycle or command request.
// A reconstructed global ScopedHost is a plain inprocHost and cannot expose
// the delegated projection method by type assertion.
type requestBoundHost struct{ inprocHost }
// ProjectProjectionForCreator only derives from a live request Host of the
// registered RDM provider. The operator is inherited from that Host, never
// accepted as a caller-supplied field. RDM's project.settings decision remains
// its own domain authority; this capability fences the Backend projection.
func (bound requestBoundHost) ProjectProjectionForCreator(creator, projectID string) (extension.ProjectHost, error) {
h := bound.inprocHost
if h.actor == "" || h.actor != h.owner || h.pluginID != "rdm" ||
h.caps.projects == nil || h.caps.st == nil || creator == "" || projectID == "" {
return nil, store.ErrAccountForbidden
}
slot := h.caps.projects
slot.mu.Lock()
registered := slot.provider != nil && slot.holder == h.pluginID
slot.mu.Unlock()
if !registered {
return nil, store.ErrAccountForbidden
}
if err := h.requireEffective(); err != nil {
return nil, err
}
if h.caps.accountGuard != nil {
if err := h.caps.accountGuard(creator); err != nil {
return nil, err
}
}
project, err := h.caps.st.ProjectMetadata(projectID)
if err != nil {
return nil, err
}
if string(project.Owner) != creator || string(project.ID) != projectID {
return nil, store.ErrNotFound
}
return delegatedProjectProjection{caps: h.caps, pluginID: h.pluginID,
actor: h.actor, creator: creator, projectID: projectID}, nil
}
// delegatedProjectProjection exposes only ProjectHost, and every method is
// confined to the bound project. It cannot be cast to the broader Host.
type delegatedProjectProjection struct {
caps hostcaps
pluginID string
actor string
creator string
projectID string
}
var _ extension.ProjectHost = delegatedProjectProjection{}
func (p delegatedProjectProjection) Owner() string { return p.creator }
func (p delegatedProjectProjection) check() error {
if p.caps.accountGuard == nil {
return nil
}
if err := p.caps.accountGuard(p.actor); err != nil {
return err
}
return p.caps.accountGuard(p.creator)
}
func (p delegatedProjectProjection) target(id string) error {
if id == "" || id != p.projectID {
return store.ErrNotFound
}
return nil
}
func (p delegatedProjectProjection) OwnedProjects() ([]core.Project, error) {
return nil, store.ErrAccountForbidden
}
func (p delegatedProjectProjection) OwnedProject(string) (core.Project, error) {
return core.Project{}, store.ErrAccountForbidden
}
func (p delegatedProjectProjection) EnsureProject(string, string) (core.Project, error) {
return core.Project{}, store.ErrAccountForbidden
}
func (p delegatedProjectProjection) ProjectMetadata(id string) (core.Project, error) {
if err := p.target(id); err != nil {
return core.Project{}, err
}
if err := p.check(); err != nil {
return core.Project{}, err
}
view := inprocHost{caps: p.caps, pluginID: p.pluginID, owner: p.creator, actor: p.actor}
return view.ProjectMetadata(id)
}
func (p delegatedProjectProjection) ProjectNameAvailable(name, exceptID string) (bool, error) {
if err := p.target(exceptID); err != nil {
return false, err
}
if err := p.check(); err != nil {
return false, err
}
return p.caps.st.ProjectNameAvailable(name, exceptID)
}
func (p delegatedProjectProjection) RenameOwnedProject(id, name string) (core.Project, error) {
if err := p.target(id); err != nil {
return core.Project{}, err
}
if err := p.check(); err != nil {
return core.Project{}, err
}
return p.caps.st.RenameOwnedProjectAccountChecked(p.actor, p.creator, p.projectID, name, p.caps.allowImplicitLocal)
}
@@ -0,0 +1,108 @@
package app
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/server"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
func TestDelegatedProjectionIsBoundAtHTTPAndCommandEntries(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/entries.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
for _, owner := range []string{"alice", "bob"} {
if err := st.EnsureUser(owner, ""); err != nil {
t.Fatal(err)
}
}
httpProject, err := st.CreateProject("alice", "HTTP original")
if err != nil {
t.Fatal(err)
}
commandProject, err := st.CreateProject("alice", "Command original")
if err != nil {
t.Fatal(err)
}
check := func(owner string) error {
state, err := st.GetUserAuthState(owner)
if err != nil {
return err
}
if !state.Enabled {
return store.ErrAccountForbidden
}
return nil
}
projectFromRequest := func(h extension.Host, id, name string) (core.Project, error) {
bound, ok := h.(extension.DelegatedProjectProjectionHost)
if !ok {
return core.Project{}, errors.New("request Host lost operator-bound projection")
}
projection, err := bound.ProjectProjectionForCreator("alice", id)
if err != nil {
return core.Project{}, err
}
return projection.RenameOwnedProject(id, name)
}
slot := &projectLifecycleSlot{}
caps := hostcaps{st: st, set: settings.NewService(st), projects: slot, accountGuard: check}
provider := completeLifecycle()
provider.Rename = func(_ context.Context, h extension.Host, id, name string) (core.Project, error) {
return projectFromRequest(h, id, name)
}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
t.Fatal(err)
}
srv := server.New(st, nil, nil, nil, caps.set, nil)
srv.Auth = server.NewAuthenticator(st, true)
slot.wire(srv, caps)
key, err := st.CreateAPIKey("bob", "delegate-entry")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPatch, "/api/projects/"+string(httpProject.ID), strings.NewReader(`{"name":"via HTTP"}`))
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HTTP lifecycle rename: status=%d body=%s", rec.Code, rec.Body.String())
}
bus := cmdbus.New(st, nil, nil)
bus.SetEffectiveAccountGuard(func(_ context.Context, owner string) error { return check(owner) })
bus.RequireAccountCheckedWrites(false)
reg := pluginRegistry{pluginID: "rdm", caps: caps, compiledRegistry: compiledRegistry{bus: bus}}
err = reg.AddCommandHandler(json.RawMessage(`{"id":"rdm.fixture.rename","title":"Rename"}`),
func(_ context.Context, h extension.Host, req extension.Request) (any, error) {
id, _ := req.Params["id"].(string)
name, _ := req.Params["name"].(string)
return projectFromRequest(h, id, name)
})
if err != nil {
t.Fatal(err)
}
result := bus.Dispatch(context.Background(), cmdbus.Request{ID: "rdm.fixture.rename", Owner: "bob", Actor: "bob",
Params: map[string]any{"id": string(commandProject.ID), "name": "via command"}})
if result.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("command rename: %+v", result.Receipt)
}
for _, tc := range []struct{ id, want string }{{string(httpProject.ID), "via HTTP"}, {string(commandProject.ID), "via command"}} {
project, err := st.ProjectMetadata(tc.id)
if err != nil || project.Name != tc.want {
t.Fatalf("entry %s did not reach bound projection: %+v %v", tc.id, project, err)
}
}
}
@@ -0,0 +1,135 @@
package app
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"agent-system-backend/internal/store"
)
// Two processes share one isolated PostgreSQL schema. The child has already
// accepted a stale early account read when the parent commits disable; only
// the final Backend transaction can stop the physical rename.
func TestPGDelegatedProjectionDisableAcrossProcesses(t *testing.T) {
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
if baseDSN == "" {
t.Skip("isolated PostgreSQL DSN required")
}
separator := "?"
if strings.Contains(baseDSN, "?") {
separator = "&"
}
if os.Getenv("IAM_U3_DELEGATE_CHILD") == "1" {
dsn := baseDSN + separator + "search_path=" + os.Getenv("IAM_U3_DELEGATE_SCHEMA") + ",public"
st, err := store.OpenExistingPostgres(dsn, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
var bobChecks atomic.Int32
check := func(owner string) error {
state, err := st.GetUserAuthState(owner)
if err != nil {
return err
}
if !state.Enabled {
return store.ErrAccountForbidden
}
if owner == "bob" && bobChecks.Add(1) == 2 {
if err := os.WriteFile(os.Getenv("IAM_U3_DELEGATE_READY"), []byte("ready"), 0o600); err != nil {
return err
}
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(os.Getenv("IAM_U3_DELEGATE_RELEASE")); err == nil {
break
}
if time.Now().After(deadline) {
return errors.New("parent did not release paused child")
}
time.Sleep(10 * time.Millisecond)
}
}
return nil
}
slot := &projectLifecycleSlot{}
caps := hostcaps{st: st, projects: slot, accountGuard: check}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
host := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}}
projection, err := host.ProjectProjectionForCreator("alice", os.Getenv("IAM_U3_DELEGATE_PROJECT"))
if err != nil {
t.Fatal(err)
}
if _, err := projection.RenameOwnedProject(os.Getenv("IAM_U3_DELEGATE_PROJECT"), "late"); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("late physical rename was not fenced: %v", err)
}
return
}
st := openDirectoryTestStore(t)
if err := st.EnsureSystemIdentity("u3-delegate-two-process", "pg"); err != nil {
t.Fatal(err)
}
for _, account := range []struct {
owner string
admin bool
}{{"admin", true}, {"alice", false}, {"bob", false}} {
if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil {
t.Fatal(err)
}
}
project, err := st.CreateProject("alice", "before")
if err != nil {
t.Fatal(err)
}
var schema string
if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
t.Fatal(err)
}
barrier := t.TempDir()
ready, release := filepath.Join(barrier, "ready"), filepath.Join(barrier, "release")
cmd := exec.Command(os.Args[0], "-test.run=^TestPGDelegatedProjectionDisableAcrossProcesses$")
cmd.Env = append(os.Environ(), "IAM_U3_DELEGATE_CHILD=1", "IAM_U3_DELEGATE_SCHEMA="+schema,
"IAM_U3_DELEGATE_PROJECT="+string(project.ID), "IAM_U3_DELEGATE_READY="+ready, "IAM_U3_DELEGATE_RELEASE="+release)
var output bytes.Buffer
cmd.Stdout, cmd.Stderr = &output, &output
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(ready); err == nil {
break
}
if time.Now().After(deadline) {
_ = cmd.Process.Kill()
t.Fatalf("child did not reach stale early check: %s", strings.ReplaceAll(output.String(), baseDSN, "[REDACTED]"))
}
time.Sleep(10 * time.Millisecond)
}
bob, err := st.GetAccountByOwner("bob")
if err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(release, []byte("release"), 0o600); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("child failed: %v: %s", err, strings.ReplaceAll(output.String(), baseDSN, "[REDACTED]"))
}
got, err := st.ProjectMetadata(string(project.ID))
if err != nil || got.Name != "before" {
t.Fatalf("disabled operator's project rename persisted: %+v %v", got, 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
}
+62
View File
@@ -0,0 +1,62 @@
package app
// EXT-18 AdminProvider 正负例:admin 主体 → true;非 admin / 空 actor(scoped
// host)→ false;store 查询失败 → false(fail-closed)。
import (
"testing"
"agent-system-backend/internal/store"
)
func TestExt18AdminProviderPositive(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: "boss"}
if !h.IsAdmin() {
t.Fatal("admin 主体应返回 true")
}
}
func TestExt18AdminProviderNegative(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("user", "User", "pw", false); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "user", actor: "user"}
if h.IsAdmin() {
t.Fatal("非 admin 主体应返回 false")
}
}
func TestExt18AdminProviderEmptyActor(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: ""} // scoped host(无 dispatch)
if h.IsAdmin() {
t.Fatal("空 actor(非 dispatch 上下文)恒 false")
}
}
func TestExt18RequestHostProjectIDField(t *testing.T) {
// 编译期断言:Request 有 HostProjectID 字段。
_ = ""
var r struct{ HostProjectID string }
r.HostProjectID = "proj-1"
if r.HostProjectID != "proj-1" {
t.Fatal("字段赋值失败")
}
}
+3
View File
@@ -23,6 +23,9 @@ func newExt9Host(t *testing.T) (interface {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
set := settings.NewService(st)
pipe := rag.NewPipeline(st, set, nil, nil)
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set}, pluginID: "rdm", owner: "alice"}
+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
}
+329 -32
View File
@@ -4,13 +4,18 @@ import (
"agent-system-backend/pkg/extension"
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
core "agent-system-core"
)
@@ -18,17 +23,29 @@ import (
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct {
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
projects *projectLifecycleSlot
workObserver *projectWorkObserverSlot
directory *accountDirectorySlot
contexts *projectContextSlot
projectAudit *projectAuditSlot
regulations *regulationsSlot // EXT-20 法规提供方槽(共享指针;nil/未注册=不可用)
accountGuard func(string) error
allowImplicitLocal bool
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
now func() time.Time
}
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
type projectAuthz struct {
mu sync.Mutex
holder string // 持有者 pluginID(首个注册者生效)
fn extension.ResourceAuthorizer
mu sync.Mutex
holder string // 持有者 pluginID(首个注册者生效)
fn extension.ResourceAuthorizer
holder2 string // EXT-8b 资源级持有者(首个注册者生效)
fn2 extension.ResourceAuthorizerV2
}
func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
@@ -40,7 +57,41 @@ func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
a.holder, a.fn = pluginID, fn
}
func (a *projectAuthz) setV2(pluginID string, fn extension.ResourceAuthorizerV2) {
a.mu.Lock()
defer a.mu.Unlock()
if a.fn2 != nil {
return
}
a.holder2, a.fn2 = pluginID, fn
}
// AuthorizeResource EXT-8b 资源级判定(U9):V2 注册时按查询六要素咨询(含
// owner 自有命中与共享读取,fail-closed);未注册回落 V1 的 owner×project
// 布尔(兼容现行行为)。查询字段由执行端受信构造。
func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, error) {
a.mu.Lock()
fn2, fn1 := a.fn2, a.fn
a.mu.Unlock()
if fn2 != nil {
if q.Actor == "" || q.Action == "" || q.ResourceType == "" || q.ResourceID == "" {
return false, nil // 受信上下文字段缺失=不可判定=拒绝
}
return fn2(q)
}
if fn1 == nil || q.Owner == "" || q.ProjectID == "" {
return false, nil
}
return fn1(q.Actor, q.ProjectID)
}
// Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。
func (a *projectAuthz) hasV2() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.fn2 != nil
}
func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) {
a.mu.Lock()
fn := a.fn
@@ -68,7 +119,25 @@ type sharedRetriever interface {
}
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
if c.accountGuard != nil {
if err := c.accountGuard(owner); err != nil {
return nil, err
}
}
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
if c.projects != nil && projectID != "" {
id, err := c.projects.resolve(ctx, c, owner, projectID)
if err != nil {
return nil, err
}
q.ProjectID = id
}
if c.authz != nil && c.authz.hasV2() {
return c.pipe.RetrieveAuthorized(ctx, core.ID(owner), q, func(d core.KnowledgeDoc) bool {
ok, err := c.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
return err == nil && ok
})
}
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
if c.authz != nil {
@@ -89,10 +158,23 @@ func (c hostcaps) searchDefault(ctx context.Context, owner, text string) ([]core
// complete 经基座 LLM 路由补全(owner 的模型配置生效;未配置报错)。
func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, error) {
if c.accountGuard != nil {
if err := c.accountGuard(owner); err != nil {
return "", err
}
}
route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, ""))
if route.Model == "" || route.Base == "" {
return "", fmt.Errorf("llm route not configured")
}
if err := ctx.Err(); err != nil {
return "", err
}
if c.accountGuard != nil {
if err := c.accountGuard(owner); err != nil {
return "", err
}
}
return completeChat(ctx, route.Base, route.Key, route.Model, prompt)
}
@@ -113,42 +195,189 @@ func (c hostcaps) parse(ctx context.Context, filename, mime string, content []by
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
// actor 是本次分发主体(通常等于 owner);AdminProvider 能力经 actor 查询
// (EXT-18)。非 dispatch 上下文(EXT-11 scoped host)actor 为空——IsAdmin 恒 false。
type inprocHost struct {
caps hostcaps
pluginID string
owner string
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 {
if h.caps.accountGuard != nil {
return h.caps.accountGuard(h.owner)
}
return nil
}
func (h inprocHost) PluginID() string { return h.pluginID }
func (h inprocHost) Owner() string { return h.owner }
// IsAdmin 实现 EXT-18 AdminProvider:查询本次分发主体的基座平台管理角色。
// actor 为空(非 dispatch 上下文)恒 false;store 查询失败按非管理员处理
// (fail-closed——不因存储故障放行超管面)。
func (h inprocHost) IsAdmin() bool {
if h.actor == "" {
return false
}
state, err := h.caps.st.GetUserAuthState(h.actor)
if err != nil {
return false
}
return state.Enabled && state.IsAdmin
}
func (h inprocHost) legacySystemDirectoryAuthorized() bool {
if h.requireEffective() != nil {
return false
}
if h.actor == "" {
return false
}
state, err := h.caps.st.GetUserAuthState(h.actor)
return err == nil && state.Enabled && state.IsAdmin
}
// ListAccounts 保留 EXT-19 旧签名,仅供有受信分发主体的系统管理上下文;
// 项目和文档候选必须走带 locator 的 AccountDirectoryProvider。
func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) {
if !h.legacySystemDirectoryAuthorized() {
return nil, "", store.ErrAccountForbidden
}
if limit <= 0 || limit > 200 {
limit = 50
}
users, err := h.caps.st.ListUsersAdmin()
if err != nil {
return nil, "", err
}
entries := make([]extension.AccountEntry, 0, len(users))
for _, u := range users {
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: u.Enabled})
}
// 排序+游标(owner 升序;cursor 为上一页末 owner,空=首页)。
for i := 0; i < len(entries); i++ {
for j := i + 1; j < len(entries); j++ {
if entries[j].Owner < entries[i].Owner {
entries[i], entries[j] = entries[j], entries[i]
}
}
}
start := 0
if cursor != "" {
for start < len(entries) && entries[start].Owner <= cursor {
start++
}
}
end := start + limit
if end > len(entries) {
end = len(entries)
}
next := ""
if end < len(entries) {
next = entries[end-1].Owner
}
return entries[start:end], next, nil
}
// SearchAccounts 实现 EXT-19 扩展 AccountSearcher(P3,2026-10-13):账号或
// 姓名子串检索(大小写不敏感;空 query = 首页候选)。结果按 owner 升序,
// 有效态口径与 ListAccounts 一致。
func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.AccountEntry, error) {
if !h.legacySystemDirectoryAuthorized() {
return nil, store.ErrAccountForbidden
}
if limit <= 0 || limit > 200 {
limit = 50
}
users, err := h.caps.st.ListUsersAdmin()
if err != nil {
return nil, err
}
q := strings.ToLower(strings.TrimSpace(query))
entries := make([]extension.AccountEntry, 0, len(users))
for _, u := range users {
if q != "" &&
!strings.Contains(strings.ToLower(u.Owner), q) &&
!strings.Contains(strings.ToLower(u.Name), q) {
continue
}
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: u.Enabled})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Owner < entries[j].Owner })
if len(entries) > limit {
entries = entries[:limit]
}
return entries, nil
}
func (h inprocHost) KVPut(key, value string) error {
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
if err := h.requireEffective(); err != nil {
return err
}
return h.caps.st.PutPluginKVAccountChecked(h.owner, h.pluginID, key, value, h.caps.allowImplicitLocal)
}
func (h inprocHost) KVGet(key string) (string, bool, error) {
if err := h.requireEffective(); err != nil {
return "", false, err
}
return h.caps.st.GetPluginKV(h.pluginID, h.owner, key)
}
func (h inprocHost) Audit(action, target, evidence string) error {
return h.caps.st.AppendAuditEvent(store.AuditEvent{
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,
})
}, h.caps.allowImplicitLocal)
}
func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) {
if err := h.requireEffective(); err != nil {
return "", err
}
return h.caps.complete(ctx, h.owner, prompt)
}
func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
if err := h.requireEffective(); err != nil {
return nil, err
}
return h.caps.search(ctx, h.owner, projectID, text, topK)
}
// knowledgeSource preserves ordinary host Source semantics. A lifecycle provider
// introduces shared publication provenance, so plugins may only assert their own
// namespace; another plugin cannot impersonate an RDM published row.
func (h inprocHost) knowledgeSource(source string) error {
if h.caps.projects == nil || !h.caps.projects.active() {
return nil
}
prefix := "plugin:" + h.pluginID
if source == prefix || strings.HasPrefix(source, prefix+":") {
return nil
}
return fmt.Errorf("extension: knowledge source must belong to plugin %q", h.pluginID)
}
// KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
// (embedding 失败降级纯全文,不阻断写入)。
func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
if err := h.requireEffective(); err != nil {
return err
}
if len(docs) == 0 {
return nil
}
@@ -158,54 +387,61 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
if docs[i].Source == "" {
docs[i].Source = src
}
if err := h.knowledgeSource(docs[i].Source); err != nil {
return err
}
}
if !embed {
return h.caps.st.AddKnowledge(docs...)
return h.caps.st.AddKnowledgeAccountChecked(h.owner, h.caps.allowImplicitLocal, docs...)
}
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
return h.caps.pipe.IndexChunksAccountChecked(ctx, core.ID(h.owner), docs, h.caps.allowImplicitLocal)
}
// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner
// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite
// 语义(Owner/Source 强制同彼)。重试产生相同结果。
// KnowledgePublish replaces refs and their vectors within the publication scope.
// A registered lifecycle scopes replacement to the exact owner/project/plugin;
// ordinary unregistered Hosts retain the legacy cross-source ref semantics.
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
if err := h.requireEffective(); err != nil {
return err
}
if len(docs) == 0 {
return nil
}
refs := map[string]bool{}
for i := range docs {
docs[i].Owner = core.ID(h.owner)
if docs[i].Source == "" {
docs[i].Source = "plugin:" + h.pluginID
}
if docs[i].Ref != "" {
refs[docs[i].Ref] = true
}
}
for ref := range refs {
if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil {
return err
}
if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil {
if err := h.knowledgeSource(docs[i].Source); err != nil {
return err
}
}
return h.KnowledgeWrite(ctx, docs, embed)
scoped := h.caps.projects != nil && h.caps.projects.active()
if embed {
return h.caps.pipe.PublishChunksAccountChecked(ctx, core.ID(h.owner), h.pluginID, scoped, docs, h.caps.allowImplicitLocal)
}
return h.caps.st.ReplaceKnowledgeAccountChecked(ctx, h.owner, h.pluginID, scoped, h.caps.allowImplicitLocal, docs...)
}
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
// 或他人来源拒绝(fail-closed);删行+向量,幂等。
func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error {
if err := h.requireEffective(); err != nil {
return err
}
if sourcePrefix != "plugin:"+h.pluginID {
return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID)
}
_, err := h.caps.st.DeleteKnowledgeBySource(ctx, h.owner, sourcePrefix)
_, err := h.caps.st.DeleteKnowledgeBySourceAccountChecked(ctx, h.owner, sourcePrefix, h.caps.allowImplicitLocal)
return err
}
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) {
t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID)
if err := h.requireEffective(); err != nil {
return "", err
}
t, err := h.caps.st.CreateBackgroundTaskAccountChecked(h.owner, kind, title, conversationID, h.caps.allowImplicitLocal)
if err != nil {
return "", err
}
@@ -213,20 +449,81 @@ func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, erro
}
func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText string) error {
return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText)
if err := h.requireEffective(); err != nil {
return err
}
return h.caps.st.UpdateBackgroundTaskAccountChecked(h.owner, taskID, status, progress, errText, h.caps.allowImplicitLocal)
}
// ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。
func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) {
if err := h.requireEffective(); err != nil {
return core.Artifact{}, err
}
return h.caps.st.GetArtifact(h.owner, artifactID)
}
func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
if err := h.requireEffective(); err != nil {
return nil, err
}
return h.caps.st.ArtifactContent(h.owner, artifactID)
}
// ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储,
// owner 作用域由调用方经 ArtifactContent 取字节时保证。
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
if err := h.requireEffective(); err != nil {
return core.ParsedDocument{}, err
}
return h.caps.parse(ctx, filename, mime, content)
}
// ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ----
func (h inprocHost) surface() (*surfacesvc.Service, error) {
if err := h.requireEffective(); err != nil {
return nil, err
}
if h.caps.surfaces == nil {
return nil, surfacesvc.ErrUnavailable
}
return h.caps.surfaces, nil
}
func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
svc, err := h.surface()
if err != nil {
return core.SurfaceSnapshot{}, err
}
return svc.SubmitAccountChecked(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, SourceRunID: req.SourceRunID,
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
ContextVersion: req.ContextVersion, SemanticText: req.SemanticText, Spec: req.Spec,
}, h.caps.allowImplicitLocal)
}
func (h inprocHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
svc, err := h.surface()
if err != nil {
return core.SurfaceSnapshot{}, err
}
return svc.Get(ctx, h.owner, surfaceID)
}
func (h inprocHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
svc, err := h.surface()
if err != nil {
return nil, err
}
return svc.List(ctx, h.owner, conversationID)
}
func (h inprocHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
svc, err := h.surface()
if err != nil {
return false, 0, err
}
row, err := svc.Activation(ctx, h.owner, h.pluginID)
return row.Enabled, row.Epoch, 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")
}
}
+6
View File
@@ -78,6 +78,9 @@ func TestInprocHostOwnerScope(t *testing.T) {
bus, st := newInprocTestApp(t, nil) // embedder nil:embed=true 仍只落全文(IndexChunks 降级路径)
for _, owner := range []string{"alice", "bob"} {
if err := st.EnsureUser(owner, ""); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "tst.record", Params: map[string]any{"ref": owner + "-ref"}, Owner: owner, Actor: "ui", Entrance: "chat",
})
@@ -157,6 +160,9 @@ func TestInprocHostKnowledgeWriteEmbedHash(t *testing.T) {
},
})
bus, st := newInprocTestApp(t, func() string { return "hash" })
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "tst.embed", Params: map[string]any{"text": "Hash 向量内容"}, Owner: "alice", Entrance: "api",
@@ -0,0 +1,180 @@
package app
import (
"agent-system-backend/internal/rag"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
"testing"
)
func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
for _, owner := range []string{"alice", "bob"} {
if err := st.EnsureUser(owner, ""); err != nil {
t.Fatal(err)
}
}
pipe := rag.NewPipeline(st, settings.NewService(st), nil, nil)
auth := &projectAuthz{}
alice := inprocHost{owner: "alice", pluginID: "rdm", caps: hostcaps{st: st, pipe: pipe, authz: auth}}
first := []core.KnowledgeDoc{{ID: "slice-1", ProjectID: "domain-1", Ref: "notes.md", Chunk: "sharedneedle one"}}
second := []core.KnowledgeDoc{{ID: "slice-2", ProjectID: "domain-2", Ref: "notes.md", Chunk: "sharedneedle two"}}
for _, ds := range [][]core.KnowledgeDoc{first, second, second} {
if err := alice.KnowledgePublish(context.Background(), ds, false); err != nil {
t.Fatal(err)
}
}
own, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "sharedneedle"})
if err != nil || len(own) != 2 {
t.Fatalf("cross project publish replaced first: %+v %v", own, err)
}
auth.set("rdm", func(string, core.ID) (bool, error) { return true, nil })
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "project" || (q.ResourceType == "knowledge" && q.ResourceID == "slice-1" && q.ProjectID == "domain-1" && q.Actor == "bob" && q.Owner == "alice"), nil
})
bob := inprocHost{owner: "bob", pluginID: "rdm", caps: alice.caps}
hits, err := bob.KnowledgeSearch(context.Background(), "", "sharedneedle", 5)
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-1" {
t.Fatalf("host V2=%+v %v", hits, err)
}
}
func TestHostKnowledgeResolvesTrustedProjectScope(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
t.Fatal(err)
}
slot := &projectLifecycleSlot{}
provider := completeLifecycle()
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
if h.Owner() != "bob" {
t.Fatalf("wrong scope actor %q", h.Owner())
}
return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}}, nil
}
caps := hostcaps{st: st, pipe: rag.NewPipeline(st, settings.NewService(st), nil, nil), authz: &projectAuthz{}, projects: slot}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
t.Fatal(err)
}
caps.authz.setV2("rdm", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
h := inprocHost{owner: "bob", pluginID: "rdm", caps: caps}
hits, err := h.KnowledgeSearch(context.Background(), "host-current", "scopeneedle", 5)
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
t.Fatalf("host scope: %+v %v", hits, err)
}
}
func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
slot := &projectLifecycleSlot{}
caps := hostcaps{st: st, projects: slot}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
for _, publish := range []bool{false, true} {
docs := []core.KnowledgeDoc{{ID: "claimed-published", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "ref", Chunk: "spoofneedle"}}
var err error
if publish {
err = h.KnowledgePublish(context.Background(), docs, false)
} else {
err = h.KnowledgeWrite(context.Background(), docs, false)
}
if err == nil {
t.Fatalf("foreign namespace accepted publish=%v", publish)
}
}
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "spoofneedle"}); err != nil || len(hits) != 0 {
t.Fatalf("foreign source wrote data: %+v %v", hits, err)
}
// Existing ordinary Hosts preserve their documented caller-provided Source.
h.caps.projects = nil
if err := h.KnowledgeWrite(context.Background(), []core.KnowledgeDoc{{Source: "manual", Chunk: "ordinaryneedle"}}, false); err != nil {
t.Fatalf("ordinary source semantics changed: %v", err)
}
}
func TestProjectPublishPreservesForeignPluginAndScope(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
slot := &projectLifecycleSlot{}
caps := hostcaps{st: st, projects: slot}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
original := core.KnowledgeDoc{ID: "rdm-published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "notes.md", Chunk: "originalneedle"}
if err := st.AddKnowledge(original); err != nil {
t.Fatal(err)
}
if err := st.UpsertKnowledgeVector(context.Background(), "alice", original.ID, original.Ref, []float32{1, 0}); err != nil {
t.Fatal(err)
}
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
replacement := []core.KnowledgeDoc{{ID: "other", ProjectID: "domain", Source: "plugin:other", Ref: "notes.md", Chunk: "replacementneedle"}}
for i := 0; i < 2; i++ {
if err := h.KnowledgePublish(context.Background(), replacement, false); err != nil {
t.Fatal(err)
}
}
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle"}); err != nil || len(hits) != 1 {
t.Fatalf("foreign plugin deleted RDM publication: %+v %v", hits, err)
}
if hits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{1, 0}, 5, core.RetrievalQuery{ProjectID: "domain"}); err != nil || len(hits) != 1 || hits[0].Doc.ID != original.ID {
t.Fatalf("foreign plugin deleted RDM vector: %+v %v", hits, err)
}
h.pluginID = "rdm"
if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "unscoped", Source: "plugin:rdm", Ref: "notes.md", Chunk: "privateneedle"}}, false); err != nil {
t.Fatal(err)
}
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle", ProjectID: "domain"}); err != nil || len(hits) != 1 {
t.Fatalf("unscoped publication deleted scoped RDM data: %+v %v", hits, err)
}
}
func TestUnregisteredPublishRetainsLegacyRefScope(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "legacy", Owner: "alice", ProjectID: "old-project", Source: "manual", Ref: "same", Chunk: "legacyneedle"}); err != nil {
t.Fatal(err)
}
h := inprocHost{owner: "alice", pluginID: "other", caps: hostcaps{st: st}}
if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "replacement", Source: "manual", Ref: "same", Chunk: "replacementneedle"}}, false); err != nil {
t.Fatal(err)
}
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "legacyneedle"}); err != nil || len(hits) != 0 {
t.Fatalf("unregistered legacy scope changed: %+v %v", hits, err)
}
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "replacementneedle"}); err != nil || len(hits) != 1 {
t.Fatalf("unregistered replacement missing: %+v %v", hits, err)
}
}
+98
View File
@@ -0,0 +1,98 @@
package app
import (
"errors"
"path/filepath"
"testing"
"agent-system-backend/internal/store"
)
func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) {
dir := t.TempDir()
seed, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
if err := seed.EnsureSystemIdentity("test-local-owner", "sqlite"); err != nil {
t.Fatal(err)
}
seed.Close()
bootstrap, err := BootstrapAdmin("", dir, "root", false)
if err != nil || bootstrap == "" {
t.Fatalf("bootstrap: %v", err)
}
first, _, err := CreateUserAccount("", dir, "alice", "", "", false, true, false)
if err != nil || first == "" {
t.Fatalf("local pending create: %v", err)
}
st, err := store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
st.Close()
second, owner, err := InitializeAccount("", dir, account.ID, account.StateVersion, account.InitializationGeneration)
if err != nil || owner != "alice" || second == first {
t.Fatalf("owner reissue: owner=%s err=%v", owner, err)
}
st, err = store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
current, err := st.GetAccount(account.ID)
if err != nil || current.Owner != account.Owner || current.ID != account.ID || current.InitializationGeneration != account.InitializationGeneration+1 {
t.Fatalf("reissue changed account identity: %+v %v", current, err)
}
if _, _, err := st.RedeemLoginKey(first); err == nil {
t.Fatal("old activation grant survived reissue")
}
if _, _, err := st.RedeemLoginKey(second); err != nil {
t.Fatalf("new activation grant failed: %v", err)
}
}
func TestAPIKeyCLIRequiresExistingInitializedAccount(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
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)
if err != nil {
t.Fatal(err)
}
if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("pending account received a full API key: %v", err)
}
st, err = store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
token, _, err := st.RedeemLoginKey(grant)
if err != nil {
t.Fatal(err)
}
hash, err := store.HashPassword("trusted-owner-password")
if err != nil {
t.Fatal(err)
}
if _, err := st.CompleteSetPasswordChecked(token, hash); err != nil {
t.Fatal(err)
}
if key, err := CreateAPIKey("", dir, "alice", "integration"); err != nil || key == "" {
t.Fatalf("initialized account could not receive its API key: %v", err)
}
}
+336
View File
@@ -0,0 +1,336 @@
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
}
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 {
return h.queryLinkedProjectAudit(ctx, q, slot)
}
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",
},
}
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 {
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 err != nil {
return extension.ProjectAuditPage{}, err
}
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
}
nameStatus := "not-recorded"
if row.ActorName != "" {
nameStatus = "recorded"
}
return extension.ProjectAuditPage{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}},
Coverage: extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported"}}, nil
}
+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)
}
}
+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)
}
}
+355
View File
@@ -0,0 +1,355 @@
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) {
if s == nil || req.Scope.Actor == "" || req.Scope.ProjectID == "" || req.Scope.Goal != "" {
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope missing")
}
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)
}
}
+151
View File
@@ -0,0 +1,151 @@
package app
import (
"agent-system-backend/internal/projectscope"
"agent-system-backend/internal/server"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
"errors"
"sync"
)
type projectLifecycleSlot struct {
mu sync.Mutex
holder string
provider *extension.ProjectLifecycle
}
func (r compiledRegistry) RegisterProjectLifecycle(extension.ProjectLifecycle) error {
return errors.New("extension: declarative plugins cannot register project lifecycle")
}
func (r pluginRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error {
if r.caps.projects == nil || r.pluginID == "" {
return errors.New("extension: project lifecycle slot unavailable")
}
if p.List == nil || p.Create == nil || p.Rename == nil || p.Archive == nil || p.Work == nil {
return errors.New("extension: project lifecycle requires all callbacks")
}
slot := r.caps.projects
slot.mu.Lock()
defer slot.mu.Unlock()
if slot.provider != nil {
return errors.New("extension: project lifecycle already registered")
}
slot.holder, slot.provider = r.pluginID, &p
return nil
}
func (r recordRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error {
reg, ok := r.Registry.(extension.ProjectLifecycleRegistry)
if !ok {
return errors.New("extension: project lifecycle registration unavailable")
}
return reg.RegisterProjectLifecycle(p)
}
func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) {
slot.mu.Lock()
defer slot.mu.Unlock()
if slot.provider == nil {
return
}
holder := slot.holder
srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host {
return requestBoundHost{inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}}
})
}
func (slot *projectLifecycleSlot) active() bool {
slot.mu.Lock()
defer slot.mu.Unlock()
return slot.provider != nil
}
func (slot *projectLifecycleSlot) work() func(context.Context, string, core.ID) (bool, error) {
slot.mu.Lock()
defer slot.mu.Unlock()
if slot.provider == nil {
return nil
}
return slot.provider.Work
}
func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, owner string, id core.ID) (core.ID, error) {
slot.mu.Lock()
if slot.provider == nil {
slot.mu.Unlock()
return id, nil
}
p, holder := *slot.provider, slot.holder
slot.mu.Unlock()
return projectscope.Resolve(ctx, inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}, p, id)
}
var _ extension.ProjectHost = inprocHost{}
func (h inprocHost) OwnedProjects() ([]core.Project, error) {
if err := h.requireEffective(); err != nil {
return nil, err
}
ps, err := h.caps.st.ListProjects(h.owner)
for i := range ps {
ps[i].Owner = h.owner
}
return ps, err
}
func (h inprocHost) OwnedProject(id string) (core.Project, error) {
if err := h.requireEffective(); err != nil {
return core.Project{}, err
}
return h.caps.st.GetProject(h.owner, id)
}
func (h inprocHost) ProjectMetadata(id string) (core.Project, error) {
if err := h.requireEffective(); err != nil {
return core.Project{}, err
}
p, err := h.caps.st.ProjectMetadata(id)
if err != nil {
return core.Project{}, err
}
if h.caps.projects != nil && h.caps.projects.active() {
p.Settings = h.caps.set.MaskedProjectMetadata(p.Settings)
}
if p.Owner == h.owner {
return p, nil
}
if h.caps.authz == nil {
return core.Project{}, store.ErrNotFound
}
ok, err := h.caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: h.owner, Action: "read", ResourceType: "project", ResourceID: core.ID(id), Owner: p.Owner, ProjectID: core.ID(id)})
if err != nil || !ok {
return core.Project{}, store.ErrNotFound
}
return p, nil
}
func (h inprocHost) EnsureProject(id, name string) (core.Project, error) {
if err := h.requireEffective(); err != nil {
return core.Project{}, err
}
if h.actor != "" && h.actor != h.owner {
return core.Project{}, store.ErrAccountForbidden
}
// A creator-scoped Host is the approved I1 projection capability. RDM
// authorizes a delegated operator before invoking it; cross-database
// commit fencing remains outside U3's guarantee.
return h.caps.st.EnsureProjectAccountChecked(h.owner, h.owner, id, name, h.caps.allowImplicitLocal)
}
func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) {
if err := h.requireEffective(); err != nil {
return core.Project{}, err
}
if h.actor != "" && h.actor != h.owner {
return core.Project{}, store.ErrAccountForbidden
}
return h.caps.st.RenameOwnedProjectAccountChecked(h.owner, h.owner, id, name, h.caps.allowImplicitLocal)
}
func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) {
if err := h.requireEffective(); err != nil {
return false, err
}
return h.caps.st.ProjectNameAvailable(name, exceptID)
}
+353
View File
@@ -0,0 +1,353 @@
package app
import (
"agent-system-backend/internal/server"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"bytes"
"context"
"encoding/json"
"errors"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
func completeLifecycle() extension.ProjectLifecycle {
return extension.ProjectLifecycle{
List: func(context.Context, extension.Host) ([]core.Project, error) { return nil, nil },
Create: func(context.Context, extension.Host, string) (core.Project, error) { return core.Project{}, nil },
Rename: func(context.Context, extension.Host, string, string) (core.Project, error) {
return core.Project{}, nil
},
Archive: func(context.Context, extension.Host, string) error { return nil },
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
}
}
func TestProjectLifecycleOptionalRegistration(t *testing.T) {
slot := &projectLifecycleSlot{}
reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}}
if err := reg.RegisterProjectLifecycle(extension.ProjectLifecycle{}); err == nil {
t.Fatal("incomplete registration accepted")
}
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err == nil {
t.Fatal("duplicate registration accepted")
}
if err := (compiledRegistry{}).RegisterProjectLifecycle(completeLifecycle()); err == nil {
t.Fatal("declarative callback accepted")
}
rr := recordRegistry{Registry: pluginRegistry{pluginID: "other", caps: hostcaps{projects: &projectLifecycleSlot{}}}}
optional, ok := any(rr).(extension.ProjectLifecycleRegistry)
if !ok {
t.Fatal("record registry hides optional registration")
}
if err := optional.RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
}
func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
for _, owner := range []string{"alice", "bob"} {
if err := st.EnsureUser(owner, ""); err != nil {
t.Fatal(err)
}
}
alice := inprocHost{owner: "alice", caps: hostcaps{st: st}}
p, err := alice.EnsureProject("stable", "Shared")
if err != nil {
t.Fatal(err)
}
auth := &projectAuthz{}
auth.set("rdm", func(owner string, id core.ID) (bool, error) { return owner == "bob" && id == core.ID(p.ID), nil })
bob := inprocHost{owner: "bob", caps: hostcaps{st: st, authz: auth}}
if _, err := bob.ProjectMetadata(p.ID); err != nil {
t.Fatal(err)
}
if _, err := bob.OwnedProject(p.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("owned lookup=%v", err)
}
if _, err := bob.RenameOwnedProject(p.ID, "Other"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("shared rename=%v", err)
}
if _, err := bob.EnsureProject(p.ID, p.Name); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("shared ensure=%v", err)
}
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { return false, nil })
if _, err := bob.ProjectMetadata(p.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("V2 denial ignored=%v", err)
}
}
func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
p, err := st.CreateProject("alice", "Shared")
if err != nil {
t.Fatal(err)
}
raw := map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-nested-secret"}}, "models.embedding.apiKey": "fixture-flat-secret", "legacy.token": "fixture-legacy-secret", "plugins.private": map[string]any{"credential": "fixture-schema-secret"}, "display": map[string]any{"caption": "Shared configuration"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
t.Fatal(err)
}
svc := settings.NewService(st)
svc.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}})
slot := &projectLifecycleSlot{}
auth := &projectAuthz{}
caps := hostcaps{st: st, set: svc, projects: slot, authz: auth}
provider := completeLifecycle()
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
project, err := h.(extension.ProjectHost).ProjectMetadata(p.ID)
return []core.Project{project}, err
}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
t.Fatal(err)
}
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
return q.Actor == "bob" && q.ResourceType == "project" && q.ResourceID == p.ID, nil
})
for _, actor := range []string{"bob", "alice"} {
h := inprocHost{owner: actor, caps: caps}
view, err := h.ProjectMetadata(p.ID)
if err != nil {
t.Fatal(err)
}
encoded, _ := json.Marshal(view.Settings)
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
if bytes.Contains(encoded, []byte(value)) {
t.Fatalf("%s metadata exposed a fixture credential", actor)
}
}
if view.Settings["rdm.projectId"] != "canonical" || view.Settings["rdm.state"] != "ready" || view.Settings["display"].(map[string]any)["caption"] != "Shared configuration" {
t.Fatal("allowed service/configuration metadata changed")
}
}
// Exercise the real registry -> actor-scoped Host -> public HTTP list chain.
srv := server.New(st, nil, nil, nil, svc, nil)
srv.Auth = server.NewAuthenticator(st, true)
slot.wire(srv, caps)
if err := st.EnsureUser("bob", ""); err != nil {
t.Fatal(err)
}
key, err := st.CreateAPIKey("bob", "test")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest("GET", "/api/projects", nil)
request.Header.Set("Authorization", "Bearer "+key)
response := httptest.NewRecorder()
srv.Handler().ServeHTTP(response, request)
if response.Code != 200 {
t.Fatalf("member list=%d", response.Code)
}
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
if bytes.Contains(response.Body.Bytes(), []byte(value)) {
t.Fatal("actor-scoped lifecycle list exposed a fixture credential")
}
}
if !bytes.Contains(response.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) {
t.Fatal("actor-scoped list lost canonical metadata")
}
owned, err := (inprocHost{owner: "alice", caps: caps}).OwnedProject(p.ID)
if err != nil {
t.Fatal(err)
}
before, _ := json.Marshal(raw)
after, _ := json.Marshal(owned.Settings)
if !bytes.Equal(before, after) {
t.Fatal("metadata read mutated persisted owner settings")
}
}
func TestRequestBoundDelegatedProjectionFencesRealOperator(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/delegated-project.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
for _, account := range []struct {
owner string
admin bool
}{{"admin", true}, {"alice", false}, {"bob", false}} {
if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil {
t.Fatal(err)
}
}
project, err := st.CreateProject("alice", "original")
if err != nil {
t.Fatal(err)
}
guard := accountEffectiveAuthorization(st, true)
check := func(owner string) error {
valid, err := guard(context.Background(), owner)
if err != nil {
return err
}
if !valid {
return store.ErrAccountForbidden
}
return nil
}
slot := &projectLifecycleSlot{}
caps := hostcaps{st: st, projects: slot, accountGuard: check}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
request := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}}
delegator, ok := any(request).(interface {
ProjectProjectionForCreator(creator, projectID string) (extension.ProjectHost, error)
})
if !ok {
t.Fatal("request-bound delegated projection capability missing")
}
projection, err := delegator.ProjectProjectionForCreator("alice", string(project.ID))
if err != nil {
t.Fatal(err)
}
if projection.Owner() != "alice" {
t.Fatalf("trusted projection owner=%s", projection.Owner())
}
if _, ok := projection.(extension.Host); ok {
t.Fatal("single-project projection exposed broad creator Host capabilities")
}
if _, err := projection.RenameOwnedProject(string(project.ID), "delegated"); err != nil {
t.Fatalf("active delegated rename: %v", err)
}
if _, err := projection.RenameOwnedProject("another-project", "hijack"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("projection escaped bound project: %v", err)
}
if _, err := projection.EnsureProject("another-project", "hijack"); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("projection created another project: %v", err)
}
bob, err := st.GetAccountByOwner("bob")
if err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
t.Fatal(err)
}
if _, err := projection.RenameOwnedProject(string(project.ID), "late"); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled real operator renamed creator project: %v", err)
}
got, err := st.ProjectMetadata(string(project.ID))
if err != nil || got.Name != "delegated" {
t.Fatalf("late rename changed project: %+v %v", got, err)
}
}
func TestDelegatedProjectionRejectsDisableBetweenEarlyCheckAndWrite(t *testing.T) {
var st, other *store.Store
var err error
if baseDSN := os.Getenv("AGENT_PG_TEST_DSN"); baseDSN != "" {
st = openDirectoryTestStore(t)
if err := st.EnsureSystemIdentity("u3-delegated-projection", "pg"); err != nil {
t.Fatal(err)
}
var schema string
if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
t.Fatal(err)
}
separator := "?"
if strings.Contains(baseDSN, "?") {
separator = "&"
}
other, err = store.OpenExistingPostgres(baseDSN+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "other-artifacts"))
} else {
db := filepath.Join(t.TempDir(), "shared.db")
st, err = store.OpenSQLite(db)
if err == nil {
other, err = store.OpenExistingSQLite(db)
}
if st != nil {
defer st.Close()
}
}
if err != nil {
t.Fatal(err)
}
defer other.Close()
for _, account := range []struct {
owner string
admin bool
}{{"admin", true}, {"alice", false}, {"bob", false}} {
if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil {
t.Fatal(err)
}
}
project, err := st.CreateProject("alice", "before")
if err != nil {
t.Fatal(err)
}
entered, release := make(chan struct{}), make(chan struct{})
var bobChecks atomic.Int32
check := func(owner string) error {
state, err := st.GetUserAuthState(owner)
if err != nil {
return err
}
if !state.Enabled {
return store.ErrAccountForbidden
}
if owner == "bob" && bobChecks.Add(1) == 2 {
close(entered)
<-release
}
return nil
}
slot := &projectLifecycleSlot{}
caps := hostcaps{st: st, projects: slot, accountGuard: check}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
host := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}}
projection, err := host.ProjectProjectionForCreator("alice", string(project.ID))
if err != nil {
t.Fatal(err)
}
written := make(chan error, 1)
go func() {
_, err := projection.RenameOwnedProject(string(project.ID), "late")
written <- err
}()
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("rename did not reach paused early account check")
}
bob, err := st.GetAccountByOwner("bob")
if err != nil {
t.Fatal(err)
}
if _, err := other.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
t.Fatal(err)
}
close(release)
select {
case err := <-written:
if !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("late delegated rename committed: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("late rename did not return")
}
got, err := st.ProjectMetadata(string(project.ID))
if err != nil || got.Name != "before" {
t.Fatalf("project changed after disable: %+v %v", got, err)
}
}
+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")
}
}
+73
View File
@@ -0,0 +1,73 @@
package app
// EXT-8b 资源级授权槽位测试(U9 首切片):
// 1. V2 未注册 → AuthorizeResource 回落 V1(owner×project 兼容现行);
// 2. V2 注册 → 按查询六要素咨询;受信字段缺失(actor/action/type/id 空)=拒绝;
// 3. V2 返回 error = 拒绝(fail-closed);
// 4. 首注册者生效(后来者忽略);
// 5. compiledRegistry 的 V2 注册被拒(声明式无宿主回调)。
import (
"errors"
"testing"
"agent-system-backend/pkg/extension"
)
func TestAuthorizeResourceV2FallbackAndQuery(t *testing.T) {
var a projectAuthz
// 未注册任何适配器 → 拒绝。
if ok, err := a.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1",
Owner: "u2", ProjectID: "p1"}); ok || err != nil {
t.Fatalf("未注册应拒绝, got %v %v", ok, err)
}
// 仅 V1:回落 owner×project 布尔。
a.set("p1", func(owner string, projectID string) (bool, error) { return true, nil })
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1",
Owner: "u2", ProjectID: "p1"}); !ok {
t.Fatal("V1 允许时回落应放行")
}
// V1 路径要求 owner/project 非空。
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1"}); ok {
t.Fatal("V1 回落在无项目私域应拒绝")
}
// 注册 V2 → 按六要素咨询。
a.setV2("p1", func(q extension.ResourceAuthzQuery) (bool, error) {
return q.Actor == "u1" && q.Action == "download" && q.ResourceType == "artifact" &&
q.ResourceID == "a1" && q.Owner == "u2" && q.ProjectID == "p1", nil
})
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1",
Owner: "u2", ProjectID: "p1"}); !ok {
t.Fatal("V2 六要素齐备应放行")
}
// 六要素缺一(action 空)=拒绝(不可判定)。
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u1", ResourceType: "artifact", ResourceID: "a1", Owner: "u2", ProjectID: "p1"}); ok {
t.Fatal("受信字段缺失应拒绝")
}
// V2 出错=拒绝(fail-closed)。
a2 := projectAuthz{}
a2.setV2("p1", func(extension.ResourceAuthzQuery) (bool, error) { return false, errors.New("boom") })
if ok, err := a2.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u", Action: "read", ResourceType: "x", ResourceID: "1"}); ok || err == nil {
t.Fatal("V2 error 应拒绝")
}
// 首注册者生效。
a3 := projectAuthz{}
a3.setV2("first", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
a3.setV2("second", func(extension.ResourceAuthzQuery) (bool, error) { return false, nil })
if ok, _ := a3.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u", Action: "read", ResourceType: "x", ResourceID: "1"}); !ok {
t.Fatal("首个 V2 注册者应生效")
}
}
func TestCompiledRegistryRejectsV2(t *testing.T) {
r := compiledRegistry{}
if err := r.RegisterResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return true, nil }); err == nil {
t.Fatal("声明式扩展不得注册资源级授权回调(fail-closed)")
}
}
+6
View File
@@ -24,6 +24,9 @@ func TestScopedHostFactory(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
set := settings.NewService(st)
pipe := rag.NewPipeline(st, set, nil, nil)
caps := hostcaps{st: st, pipe: pipe, set: set}
@@ -39,6 +42,9 @@ func TestScopedHostFactory(t *testing.T) {
if h.Owner() != "alice" || h.PluginID() != "rdm" {
t.Fatalf("Owner/PluginID = %s/%s, want alice/rdm", h.Owner(), h.PluginID())
}
if _, ok := h.(extension.DelegatedProjectProjectionHost); ok {
t.Fatal("globally reconstructed Host exposed request-bound projection authority")
}
// KV 往返落 owner 作用域。
if err := h.KVPut("k1", "v1"); err != nil {
t.Fatal(err)
+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
}
+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
}
+31
View File
@@ -0,0 +1,31 @@
package app
import (
"strings"
"testing"
)
// 协作存储方案 U2(KTD1/R2):服务入口的部署档门禁。
// team 档缺 DSN / 未开认证必须拒绝启动——不创建 SQLite、不进入装配。
func TestTierRefusalTeam(t *testing.T) {
msgs := TierRefusal(Options{Tier: "team", Addr: "127.0.0.1:8788"})
if len(msgs) != 2 {
t.Fatalf("team without dsn/auth: %v", msgs)
}
joined := strings.Join(msgs, ";")
if !strings.Contains(joined, "PostgreSQL DSN") || !strings.Contains(joined, "认证") {
t.Fatalf("refusal wording: %v", msgs)
}
msgs = TierRefusal(Options{Tier: "team", Addr: "127.0.0.1:8788", StorageDSN: "postgres://u:p@h/db", AuthMode: "on"})
if len(msgs) != 0 {
t.Fatalf("team with dsn+auth should pass: %v", msgs)
}
msgs = TierRefusal(Options{Tier: "local", Addr: "127.0.0.1:8788", StorageDSN: "postgres://u:p@h/db"})
if len(msgs) != 1 || !strings.Contains(msgs[0], "local 档不使用") {
t.Fatalf("local with dsn: %v", msgs)
}
msgs = TierRefusal(Options{Addr: "127.0.0.1:8788"})
if len(msgs) != 0 {
t.Fatalf("auto default: %v", msgs)
}
}
+22
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
)
@@ -19,6 +20,21 @@ type Store interface {
Delete(ref string) error
}
// IsNil 判断对象存储是否未配置。nil 接口,以及放进接口后的带类型空指针
// (例如未设置端点时的 (*S3)(nil)),都算未配置。已构造的实现返回 false。
func IsNil(s Store) bool {
if s == nil {
return true
}
v := reflect.ValueOf(s)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return v.IsNil()
default:
return false
}
}
// Local 是本地目录实现(data/artifacts;多实例时把目录指向共享卷即可)。
type Local struct {
dir string
@@ -32,6 +48,12 @@ func NewLocal(dir string) (*Local, error) {
return &Local{dir: dir}, nil
}
// OpenLocalExisting 绑定既有目录,不 MkdirAll。目录缺失时仍返回可用句柄
// (账户运维只碰 DB;Put 时若目录不存在会失败)。
func OpenLocalExisting(dir string) (*Local, error) {
return &Local{dir: dir}, nil
}
// validID 只接受纯文件名形态的对象 id:非空、不含路径分隔符、非 . / ..。
// Put 的 id 可能是上层透传的用户输入(如工件路径参数),先在此挡住路径穿越。
func validID(id string) bool {
+29
View File
@@ -0,0 +1,29 @@
package blobstore
import "testing"
func TestIsNil(t *testing.T) {
var unset Store
if !IsNil(unset) {
t.Fatal("nil interface must count as unset")
}
var typed *S3
var asStore Store = typed
if !IsNil(asStore) {
t.Fatal("typed nil *S3 must count as unset")
}
local, err := NewLocal(t.TempDir())
if err != nil {
t.Fatal(err)
}
if IsNil(local) {
t.Fatal("local store must count as configured")
}
s3, err := NewS3("http://127.0.0.1:9/bucket", "us-east-1", "access", "secret", "art/")
if err != nil {
t.Fatal(err)
}
if IsNil(s3) {
t.Fatal("configured S3 must count as configured")
}
}
+121
View File
@@ -0,0 +1,121 @@
package blobstore
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// U5/KTD9:S3 兼容对象存储实现——针对嵌入式测试服务器验证
// 签名请求、Put/Get/Delete 语义与预签名 URL 生成。
// (真实 S3 兼容服务的端到端验收随 U7 压测环境执行;此处锁定协议正确性。)
func newTestS3(t *testing.T, base string) *S3 {
t.Helper()
s, err := NewS3(base, "us-east-1", "AKIDEXAMPLE", "secret", "art/")
if err != nil {
t.Fatal(err)
}
return s
}
func TestS3PutGetDeleteRoundTrip(t *testing.T) {
var gotPath, gotMethod, gotAuth string
var gotBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod, gotAuth = r.URL.Path, r.Method, r.Header.Get("Authorization")
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
gotBody = buf[:n]
switch r.Method {
case http.MethodPut:
w.WriteHeader(200)
case http.MethodGet:
if strings.Contains(gotPath, "obj404") {
w.WriteHeader(404)
return
}
w.Write([]byte("payload"))
case http.MethodDelete:
w.WriteHeader(204)
}
}))
defer srv.Close()
s := newTestS3(t, srv.URL+"/bucket")
ref, err := s.Put("doc1", []byte("payload"))
if err != nil {
t.Fatal(err)
}
if ref != "art/doc1" {
t.Fatalf("ref=%s(对象键应带前缀)", ref)
}
if gotPath != "/bucket/art/doc1" || gotMethod != http.MethodPut || string(gotBody) != "payload" {
t.Fatalf("put 请求:%s %s body=%q", gotMethod, gotPath, gotBody)
}
if len(gotAuth) == 0 || gotAuth[:17] != "AWS4-HMAC-SHA256 " {
t.Fatalf("Authorization 头:%q", gotAuth)
}
got, err := s.Get(ref)
if err != nil || string(got) != "payload" {
t.Fatalf("get: %q err=%v", got, err)
}
if err := s.Delete(ref); err != nil {
t.Fatalf("delete: %v", err)
}
// 404 → 哨兵错误(服务端对 art/obj404 的 GET 返回 404)。
if _, err := s.Get("art/obj404"); err == nil || err.Error() != ErrObjectNotFound.Error() {
t.Fatalf("404 应 ErrObjectNotFound:%v", err)
}
// Delete 幂等(404 视为成功)。
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv2.Close()
s2 := newTestS3(t, srv2.URL+"/b")
if err := s2.Delete("art/none"); err != nil {
t.Fatalf("delete 404 应幂等:%v", err)
}
}
func TestS3PresignGet(t *testing.T) {
s := newTestS3(t, "https://s3.example.com/bucket")
u, err := s.PresignGet("art/doc1", 15*time.Minute)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"X-Amz-Algorithm=AWS4-HMAC-SHA256", "X-Amz-Signature=", "X-Amz-Expires=900", "/bucket/art/doc1"} {
if !contains(u, want) {
t.Fatalf("预签名 URL 缺 %s:%s", want, u)
}
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}
func TestNewS3FromEnvFallback(t *testing.T) {
os.Unsetenv("BLOBSTORE_S3_ENDPOINT")
s, err := NewS3FromEnv()
if s != nil || err != nil {
t.Fatalf("未配置端点应回落 (nil,nil):s=%v err=%v", s, err)
}
t.Setenv("BLOBSTORE_S3_ENDPOINT", "http://x/b")
t.Setenv("BLOBSTORE_S3_ACCESS_KEY", "a")
t.Setenv("BLOBSTORE_S3_SECRET_KEY", "b")
s, err = NewS3FromEnv()
if err != nil || s == nil {
t.Fatalf("已配置应构造:s=%v err=%v", s, err)
}
}
+242
View File
@@ -0,0 +1,242 @@
package blobstore
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// S3 是 S3 兼容对象存储实现(协作存储 U5/KTD9:多实例共享工件)。
// 认证:AWS Signature V4(静态 AccessKey/SecretKey;凭据只经环境或受保护
// 配置注入,引用形态为对象键,不含凭据)。
//
// 语义(KTD9):
// - Put:先完整写对象再由上层提交引用(失败孤儿可清理);
// - Get:按对象键读取;
// - Delete:立即删除(标记/延迟 GC 由桶生命周期策略承担)。
type S3 struct {
Base string // 端点(含 bucket 路径,如 https://s3.example.com/bucket)
Region string
Access string
Secret string
Prefix string // 对象键前缀(租户/应用隔离)
client *http.Client
nowFunc func() time.Time
}
// NewS3 从显式参数构造;nil client 用默认。
func NewS3(base, region, access, secret, prefix string) (*S3, error) {
if base == "" || access == "" || secret == "" {
return nil, errors.New("blobstore: s3 requires base/access/secret")
}
if !strings.HasSuffix(base, "/") {
base += "/"
}
return &S3{Base: base, Region: region, Access: access, Secret: secret, Prefix: prefix,
client: &http.Client{Timeout: 30 * time.Second}, nowFunc: time.Now}, nil
}
// NewS3FromEnv 按环境构造:BLOBSTORE_S3_ENDPOINT/REGION/ACCESS_KEY/SECRET_KEY/PREFIX。
// 未配置端点返回 (nil, nil)(调用方回落本地档)。
func NewS3FromEnv() (*S3, error) {
if os.Getenv("BLOBSTORE_S3_ENDPOINT") == "" {
return nil, nil
}
return NewS3(os.Getenv("BLOBSTORE_S3_ENDPOINT"),
envOr("BLOBSTORE_S3_REGION", "us-east-1"),
os.Getenv("BLOBSTORE_S3_ACCESS_KEY"),
os.Getenv("BLOBSTORE_S3_SECRET_KEY"),
os.Getenv("BLOBSTORE_S3_PREFIX"))
}
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func (s *S3) key(id string) (string, error) {
if !validID(id) {
return "", fmt.Errorf("blobstore: invalid object id %q", id)
}
return s.Prefix + id, nil
}
// Put 上传对象;ref=对象键。
func (s *S3) Put(id string, content []byte) (string, error) {
key, err := s.key(id)
if err != nil {
return "", err
}
resp, err := s.do(http.MethodPut, key, content, "")
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return "", fmt.Errorf("blobstore: s3 put %s: %s %s", key, resp.Status, body)
}
return key, nil
}
// Get 下载对象。
func (s *S3) Get(ref string) ([]byte, error) {
resp, err := s.do(http.MethodGet, ref, nil, "")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrObjectNotFound
}
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("blobstore: s3 get %s: %s", ref, resp.Status)
}
return io.ReadAll(resp.Body)
}
// ErrObjectNotFound 对象不存在(与本地档 ErrNotExist 对齐的哨兵)。
var ErrObjectNotFound = errors.New("blobstore: object not found")
// Delete 删除对象(幂等:404 视为成功)。
func (s *S3) Delete(ref string) error {
resp, err := s.do(http.MethodDelete, ref, nil, "")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 == 2 || resp.StatusCode == http.StatusNotFound {
return nil
}
return fmt.Errorf("blobstore: s3 delete %s: %s", ref, resp.Status)
}
// do 签名并执行请求(AWS SigV4,service=s3)。
func (s *S3) do(method, key string, body []byte, contentType string) (*http.Response, error) {
u, err := url.Parse(s.Base + key)
if err != nil {
return nil, err
}
now := s.nowFunc().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
payloadHash := sha256.Sum256(body)
payloadHex := hex.EncodeToString(payloadHash[:])
req, err := http.NewRequest(method, u.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", payloadHex)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
// 规范请求与签名(SigV4 单 chunk)。
canonicalURI := "/" + strings.TrimPrefix(u.Path, "/")
canonicalQuery := u.RawQuery
var ch bytes.Buffer
ch.WriteString(method + "\n" + canonicalURI + "\n" + canonicalQuery + "\n")
ch.WriteString("host:" + u.Host + "\n")
ch.WriteString("x-amz-content-sha256:" + payloadHex + "\n")
ch.WriteString("x-amz-date:" + amzDate + "\n")
ch.WriteString("\nhost;x-amz-content-sha256;x-amz-date\n" + payloadHex)
cr := ch.Bytes()
scope := dateStamp + "/" + s.Region + "/s3/aws4_request"
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sha256sum(cr))
kDate := hmacSHA256([]byte("AWS4"+s.Secret), dateStamp)
kRegion := hmacSHA256(kDate, s.Region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+s.Access+"/"+scope+
", SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="+sig)
return s.client.Do(req)
}
func sha256sum(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
func hmacSHA256(key []byte, data string) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(data))
return m.Sum(nil)
}
// PresignGet 生成限时 GET 预签名 URL(下载入口;有效期由调用方指定)。
// 用于把大对象下载直接指向对象存储,应用实例不做数据中继。
func (s *S3) PresignGet(ref string, ttl time.Duration) (string, error) {
u, err := url.Parse(s.Base + ref)
if err != nil {
return "", err
}
now := s.nowFunc().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
expiry := int64(ttl / time.Second)
payloadHex := hex.EncodeToString(sha256sum(nil))
q := u.Query()
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
q.Set("X-Amz-Credential", s.Access+"/"+dateStamp+"/"+s.Region+"/s3/aws4_request")
q.Set("X-Amz-Date", amzDate)
q.Set("X-Amz-Expires", fmt.Sprint(expiry))
q.Set("X-Amz-SignedHeaders", "host")
u.RawQuery = q.Encode()
canonicalQuery := canonicalQueryEncode(u)
var ch bytes.Buffer
ch.WriteString("GET\n/" + strings.TrimPrefix(u.Path, "/") + "\n" + canonicalQuery + "\n")
ch.WriteString("host:" + u.Host + "\n")
ch.WriteString("x-amz-content-sha256:" + payloadHex + "\n")
ch.WriteString("x-amz-date:" + amzDate + "\n")
ch.WriteString("\nhost;x-amz-content-sha256;x-amz-date\n" + payloadHex)
scope := dateStamp + "/" + s.Region + "/s3/aws4_request"
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sha256sum(ch.Bytes()))
kDate := hmacSHA256([]byte("AWS4"+s.Secret), dateStamp)
kRegion := hmacSHA256(kDate, s.Region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
return u.String() + "&X-Amz-Signature=" + sig, nil
}
// canonicalQueryEncode 按 AWS 规则重排查询串(键排序 + URL 编码)。
func canonicalQueryEncode(u *url.URL) string {
q := u.Query()
var keys []string
for k := range q {
keys = append(keys, k)
}
// 排序(简单实现足够:键集固定且互不冲突)
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[j] < keys[i] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
var sb strings.Builder
for i, k := range keys {
if i > 0 {
sb.WriteString("&")
}
sb.WriteString(url.QueryEscape(k) + "=" + url.QueryEscape(q[k][0]))
}
return sb.String()
}
var _ Store = (*S3)(nil)
+221
View File
@@ -0,0 +1,221 @@
package cardsvc
import (
"agent-system-backend/internal/store"
core "agent-system-core"
"errors"
)
func (s *Service) requireAccountCheckedEmitter() error {
if s.emit != nil && s.accountCheckedEmit == nil {
return ErrGuardedEmitterRequired
}
return nil
}
func (s *Service) ResolveByRefAccountChecked(actor, ref, actionID, answer string, allowImplicitDefault bool) error {
if err := s.requireAccountCheckedEmitter(); err != nil {
return err
}
id, err := s.st.CardIDByRef(actor, ref)
if errors.Is(err, store.ErrNotFound) {
return nil
}
if err != nil {
return err
}
card, version, err := s.st.GetCardVersion(actor, id)
if err != nil {
return err
}
if card.Status.Terminal() {
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 {
return err
}
return s.emitCardAccountChecked(actor, card, core.EvCardResolved,
core.CardResolvedPayload{CardID: card.ID, ActionID: actionID, Answer: answer})
}
// AppendAccountChecked preserves stream-card throttling while fencing its
// durable body update against account disable.
func (s *Service) AppendAccountChecked(actor, id, text string, allowImplicitDefault bool) error {
if err := s.requireAccountCheckedEmitter(); err != nil {
return err
}
card, version, err := s.st.GetCardVersion(actor, id)
if err != nil {
return err
}
if text != "" {
if n := len(card.Body); n > 0 && card.Body[n-1].Type == core.UIMarkdown {
card.Body[n-1].Markdown += text
} else {
card.Body = append(card.Body, core.UINode{Type: core.UIMarkdown, Markdown: text})
}
}
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", version, allowImplicitDefault); err != nil {
return err
}
if card.ConversationID == "" && card.ProjectID == "" {
return nil
}
s.emitMu.Lock()
last, seen := s.lastEmit[id]
emit := !seen || s.now().Sub(last) >= cardAppendWindow
if emit {
s.lastEmit[id] = s.now()
}
s.emitMu.Unlock()
if !emit {
return nil
}
return s.emitCardAccountChecked(actor, card, core.EvCardUpdated, core.CardPayload{Card: card})
}
func (s *Service) emitCardAccountChecked(actor string, card core.ActivityCard, typ core.StreamEventType, payload any) error {
if s.accountCheckedEmit == nil || (card.ConversationID == "" && card.ProjectID == "") {
return nil
}
if card.ConversationID != "" {
if err := s.accountCheckedEmit(actor, card.ConversationID, card.SourceRunID, typ, payload); err != nil {
return err
}
if !s.MirrorConvCards {
return nil
}
}
if card.ProjectID != "" {
return s.accountCheckedEmit(actor, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, typ, payload)
}
return nil
}
// CreateAccountChecked creates a card under the actor's final Store fence.
func (s *Service) CreateAccountChecked(actor string, card core.ActivityCard, ref string, allowImplicitDefault bool) (core.ActivityCard, error) {
if err := s.requireAccountCheckedEmitter(); err != nil {
return card, err
}
if card.ID == "" {
card.ID = store.NewID("card")
}
now := s.now().UTC()
card.CreatedAt, card.UpdatedAt = now, now
if card.Status == "" {
card.Status = core.CardActive
}
if err := s.st.SaveCardAccountChecked(actor, &card, ref, allowImplicitDefault); err != nil {
return card, err
}
return card, s.emitCardAccountChecked(actor, card, core.EvCardCreated, core.CardPayload{Card: card})
}
func (s *Service) updateVersionedAccountChecked(actor string, card core.ActivityCard, expectedVersion int, allowImplicitDefault bool) (core.ActivityCard, error) {
if err := s.requireAccountCheckedEmitter(); err != nil {
return card, err
}
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", expectedVersion, allowImplicitDefault); err != nil {
return card, err
}
return card, s.emitCardAccountChecked(actor, card, core.EvCardUpdated, core.CardPayload{Card: card})
}
func (s *Service) UpdateVersionedAccountChecked(actor string, card core.ActivityCard, expectedVersion int, allowImplicitDefault bool) error {
_, err := s.updateVersionedAccountChecked(actor, card, expectedVersion, allowImplicitDefault)
return err
}
func (s *Service) SetPinnedAccountChecked(actor, id string, pinned, allowImplicitDefault bool) (core.ActivityCard, error) {
card, version, err := s.st.GetCardVersion(actor, id)
if err != nil {
return card, err
}
card.Pinned = pinned
card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault)
if err != nil {
return card, err
}
return card, nil
}
func (s *Service) SetStatusAccountChecked(actor, id string, status core.CardStatus, tone, note string, allowImplicitDefault bool) (core.ActivityCard, error) {
card, version, err := s.st.GetCardVersion(actor, id)
if err != nil {
return card, err
}
card.Status = status
if tone != "" {
card.Tone = tone
}
card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault)
if err != nil {
return card, err
}
return card, s.emitCardAccountChecked(actor, card, core.EvCardStatus,
core.CardStatusPayload{CardID: card.ID, Status: status, Tone: card.Tone, Note: note})
}
func (s *Service) DismissAccountChecked(actor, id string, allowImplicitDefault bool) (core.ActivityCard, error) {
return s.SetStatusAccountChecked(actor, id, core.CardDismissed, "", "dismissed by user", allowImplicitDefault)
}
func (s *Service) ActionAccountChecked(actor, id, actionID, freeText string, allowImplicitDefault bool) (core.ActivityCard, string, error) {
card, version, err := s.st.GetCardVersion(actor, id)
if err != nil {
return card, "", err
}
if card.Status != core.CardActionRequired {
return card, "", ErrNotAwaiting
}
label, found := actionID, false
for _, action := range card.Actions {
if action.ID == actionID {
label, found = action.Label, true
break
}
}
if !found {
if actionID == "" && freeText != "" {
label = freeText
} else {
return card, "", ErrNoAction
}
}
card.Status = core.CardResolved
recordAnswer(&card, actionID, freeText)
card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault)
if err != nil {
return card, "", err
}
err = s.emitCardAccountChecked(actor, card, core.EvCardResolved,
core.CardResolvedPayload{CardID: card.ID, ActionID: actionID, Answer: freeText})
return card, label, err
}
// ToProjectAccountChecked performs the copy and dismissal in one transaction.
// Events are published after commit through the actor-aware event sink.
func (s *Service) ToProjectAccountChecked(actor, id string, allowImplicitDefault bool) (core.ActivityCard, error) {
if err := s.requireAccountCheckedEmitter(); err != nil {
return core.ActivityCard{}, err
}
archived, origin, created, err := s.st.ArchiveCardToProjectAccountChecked(actor, id, s.now().UTC(), allowImplicitDefault)
if err != nil || !created {
return archived, err
}
if err := s.emitCardAccountChecked(actor, archived, core.EvCardCreated, core.CardPayload{Card: archived}); err != nil {
return archived, err
}
if err := s.emitCardAccountChecked(actor, origin, core.EvCardUpdated, core.CardPayload{Card: origin}); err != nil {
return archived, err
}
err = s.emitCardAccountChecked(actor, origin, core.EvCardStatus,
core.CardStatusPayload{CardID: origin.ID, Status: core.CardDismissed, Tone: origin.Tone, Note: "已留档到项目"})
return archived, err
}
+109
View File
@@ -0,0 +1,109 @@
package cardsvc
import (
"errors"
"testing"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func TestAccountCheckedCardActionsUseActorEventSinkAndRespectDisable(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/cards.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("admin", "", "", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "", "", false); err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "cards")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("alice", project.ID, "cards", "")
if err != nil {
t.Fatal(err)
}
legacyCalls := 0
svc := New(st, func(string, string, core.StreamEventType, any) error {
legacyCalls++
return nil
})
type emitted struct {
actor, channel string
typ core.StreamEventType
}
var guarded []emitted
svc.SetAccountCheckedEmitter(func(actor, channel, runID string, typ core.StreamEventType, payload any) error {
guarded = append(guarded, emitted{actor, channel, typ})
return nil
})
base := core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Action",
Status: core.CardActionRequired, Actions: []core.UIAction{{ID: "yes", Label: "Approve"}}}
action, err := svc.CreateAccountChecked("alice", base, "", false)
if err != nil {
t.Fatal(err)
}
if _, label, err := svc.ActionAccountChecked("alice", action.ID, "yes", "", false); err != nil || label != "Approve" {
t.Fatalf("guarded action: label=%q err=%v", label, err)
}
pending, err := svc.CreateAccountChecked("alice", base, "", false)
if err != nil {
t.Fatal(err)
}
pin, err := svc.CreateAccountChecked("alice", core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Pin"}, "", false)
if err != nil {
t.Fatal(err)
}
if _, err := svc.SetPinnedAccountChecked("alice", pin.ID, true, false); err != nil {
t.Fatal(err)
}
if _, err := svc.DismissAccountChecked("alice", pin.ID, false); err != nil {
t.Fatal(err)
}
archive, err := svc.CreateAccountChecked("alice", core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Archive"}, "", false)
if err != nil {
t.Fatal(err)
}
archived, err := svc.ToProjectAccountChecked("alice", archive.ID, false)
if err != nil || archived.ConversationID != "" {
t.Fatalf("guarded archive: %+v %v", archived, err)
}
if legacyCalls != 0 {
t.Fatalf("guarded card operations used unguarded emitter %d times", legacyCalls)
}
if len(guarded) == 0 {
t.Fatal("guarded operations published no events")
}
for _, event := range guarded {
if event.actor != "alice" {
t.Fatalf("event actor lost: %+v", event)
}
}
before := len(guarded)
account, err := st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
for _, mutate := range []func() error{
func() error { _, err := svc.CreateAccountChecked("alice", base, "", false); return err },
func() error { _, _, err := svc.ActionAccountChecked("alice", pending.ID, "yes", "", false); return err },
func() error { _, err := svc.SetPinnedAccountChecked("alice", pin.ID, false, false); return err },
func() error { _, err := svc.DismissAccountChecked("alice", pin.ID, false); return err },
func() error { _, err := svc.ToProjectAccountChecked("alice", archive.ID, false); return err },
} {
if err := mutate(); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled actor mutated card: %v", err)
}
}
if len(guarded) != before {
t.Fatalf("disabled actor published %d events", len(guarded)-before)
}
}
+88 -15
View File
@@ -17,18 +17,26 @@ import (
// 获得持久化、seq 排序、断线重放与终态对账(Stream 契约)。
type Emitter func(conversationID, runID string, typ core.StreamEventType, payload any) error
// AccountCheckedEmitter persists an actor-attributed event through its own
// final account-checked Store transaction. The card write and event emission
// are separate commits; the application wires this to its guarded event sink.
type AccountCheckedEmitter func(actor, conversationID, runID string, typ core.StreamEventType, payload any) error
var (
ErrNotFound = store.ErrNotFound // 语义对齐存储层:跨租户/不存在一律 NotFound
ErrNoAction = errors.New("card: action not found")
ErrNotAwaiting = errors.New("card: not awaiting action")
ErrNotFound = store.ErrNotFound // 语义对齐存储层:跨租户/不存在一律 NotFound
ErrNoAction = errors.New("card: action not found")
ErrNotAwaiting = errors.New("card: not awaiting action")
ErrGuardedEmitterRequired = errors.New("card: account-checked emitter required")
)
// cardAppendWindow 是流式追加(Append)的 card.updated 发布节流窗口(D45)。
const cardAppendWindow = 250 * time.Millisecond
type Service struct {
st *store.Store
emit Emitter
st *store.Store
emit Emitter
accountCheckedEmit AccountCheckedEmitter
projectSharedEmit ProjectSharedEmitter
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
MirrorConvCards bool
@@ -41,6 +49,46 @@ func New(st *store.Store, emit Emitter) *Service {
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true}
}
func (s *Service) SetAccountCheckedEmitter(emit AccountCheckedEmitter) {
s.accountCheckedEmit = emit
}
// ProjectSharedEmitter is an internal trusted publication adapter. It is never
// bound to model arguments or exposed as a mandatory extension capability.
type ProjectSharedEmitter func(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error
func (s *Service) SetProjectSharedEmitter(emit ProjectSharedEmitter) { s.projectSharedEmit = emit }
// PublishProjectCard explicitly publishes a project-level structural snapshot.
// Ordinary Create/Update remain private unless this entry issues a new proof.
func (s *Service) PublishProjectCard(owner, producer string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
if s.projectSharedEmit == nil || owner == "" || producer == "" || card.ProjectID == "" || card.ConversationID != "" {
return card, errors.New("card: trusted project publisher required")
}
if card.ID != "" {
current, err := s.st.GetCard(owner, card.ID)
if err != nil {
return card, err
}
if current.ProjectID != card.ProjectID || current.ConversationID != "" {
return card, store.ErrNotFound
}
} else {
card.ID = store.NewID("card")
}
now := s.now().UTC()
card.CreatedAt = now
card.UpdatedAt = now
if card.Status == "" {
card.Status = core.CardActive
}
if err := s.st.SaveCard(owner, &card, ref); err != nil {
return card, err
}
err := s.projectSharedEmit(owner, producer, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, core.EvCardCreated, core.CardPayload{Card: card})
return card, err
}
// emitCard 按卡归属发布卡片事件(D50):
// - 会话卡 → 会话流;同时镜像项目流(伪会话键 "project:<projectId>")——
// 项目收件箱聚合视图(项目级卡 ∪ 各会话未决卡)的实时性依赖它;卡片事件
@@ -87,14 +135,24 @@ 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)
return s.presentResolved(owner, 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 {
for i := range cards {
cards[i] = s.presentResolved(owner, cards[i])
}
}
return cards, err
}
// Update 保存整卡变更(流式 Body 增量等)并发布 card.updated;ref 保持原值。
// 系统镜像路径(同会话 run 的顺序行为)沿用盲写;用户态裁决走 UpdateVersioned。
func (s *Service) Update(owner string, card core.ActivityCard) error {
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCard(owner, &card, ""); err != nil {
@@ -103,6 +161,16 @@ func (s *Service) Update(owner string, card core.ActivityCard) error {
return s.emitCard(card, core.EvCardUpdated, core.CardPayload{Card: card})
}
// UpdateVersioned 乐观并发保存(U3,KTD7):版本不匹配透传 store.ErrConflict,
// HTTP 层映射 409;客户端重读卡后重试。
func (s *Service) UpdateVersioned(owner string, card core.ActivityCard, expectedVersion int) error {
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCardVersioned(owner, &card, "", expectedVersion); err != nil {
return err
}
return s.emitCard(card, core.EvCardUpdated, core.CardPayload{Card: card})
}
// Append 流式追加正文(card_append 工具;D45):文本并入 body 尾部 markdown
// 节点(尾节点非 markdown 则新增),逐次持久化;card.updated 按 cardAppendWindow
// 节流——窗口内的追加只落库不发布(投影语义:事件携带整卡,后续事件自然覆盖
@@ -141,7 +209,7 @@ func (s *Service) Append(owner, id, text string) error {
// SetStatus 状态迁移:更新并发布 card.status。
func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return c, err
}
@@ -149,7 +217,7 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
if tone != "" {
c.Tone = tone
}
if err := s.Update(owner, c); err != nil {
if err := s.UpdateVersioned(owner, c, ver); err != nil {
return c, err
}
return c, s.emitCard(c, core.EvCardStatus, core.CardStatusPayload{CardID: c.ID, Status: status, Tone: c.Tone, Note: note})
@@ -157,12 +225,12 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
// SetPinned 置顶/取消(收件箱"不沉没"语义)。
func (s *Service) SetPinned(owner, id string, pinned bool) (core.ActivityCard, error) {
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return c, err
}
c.Pinned = pinned
return c, s.Update(owner, c)
return c, s.UpdateVersioned(owner, c, ver)
}
// Dismiss 关闭卡(非确认语义,留痕)。
@@ -173,7 +241,7 @@ func (s *Service) Dismiss(owner, id string) (core.ActivityCard, error) {
// Action 处理卡上按钮点击:仅 action_required 卡可裁决;结果 resolved 并发布
// card.resolved(ActionID 即按钮语义)。
func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCard, string, error) {
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return c, "", err
}
@@ -197,7 +265,8 @@ func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCar
return c, "", ErrNoAction
}
c.Status = core.CardResolved
if err := s.Update(owner, c); err != nil {
recordAnswer(&c, actionID, freeText)
if err := s.UpdateVersioned(owner, c, ver); err != nil {
return c, "", err
}
return c, label, s.emitCard(c, core.EvCardResolved, core.CardResolvedPayload{CardID: c.ID, ActionID: actionID, Answer: freeText})
@@ -250,7 +319,7 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
}
return err
}
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return err
}
@@ -258,7 +327,11 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
return nil // 已决(双现任一侧先到)
}
c.Status = core.CardResolved
if err := s.Update(owner, c); err != nil {
recordAnswer(&c, actionID, answer)
if err := s.UpdateVersioned(owner, c, ver); err != nil {
if errors.Is(err, store.ErrConflict) {
return nil // 双现任一侧先到(版本竞争败者):不重试,卡以先到侧为准
}
return err
}
if c.ConversationID != "" && s.emit != 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")
}
}
+169
View File
@@ -0,0 +1,169 @@
package cmdbus
import (
"context"
"errors"
"strings"
"testing"
agentcommand "agent-command"
)
func TestEffectiveAccountGuardRejectsBeforeDispatchEffects(t *testing.T) {
for _, tc := range []struct {
name string
req Request
err error
want string
}{
{"disabled handler", Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}, errors.New("disabled"), "alice"},
{"failed lookup", Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}, errors.New("database unavailable"), "alice"},
{"plugin actor uses account owner", Request{ID: "demo.ping", Owner: "alice", Actor: "plugin:rdm"}, errors.New("disabled"), "alice"},
{"actor fallback", Request{ID: "demo.ping", Actor: "alice"}, errors.New("disabled"), "alice"},
} {
t.Run(tc.name, func(t *testing.T) {
audit, cards := &memAudit{}, &memCards{}
b := newTestBus(audit, cards)
called := 0
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
called++
if actor != tc.want {
t.Fatalf("guard actor = %q, want %q", actor, tc.want)
}
return tc.err
})
resp := b.Dispatch(context.Background(), tc.req)
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "effective account") {
t.Fatalf("guard rejection: %+v", resp.Receipt)
}
if called != 1 || len(audit.logs) != 0 || len(cards.created) != 0 {
t.Fatalf("guard calls=%d audits=%d cards=%d", called, len(audit.logs), len(cards.created))
}
})
}
}
func TestEffectiveAccountGuardRejectsBeforePendingConfirmAndRechecks(t *testing.T) {
audit, cards := &memAudit{}, &memCards{}
b := newTestBus(audit, cards)
allowed := false
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
if actor != "alice" {
t.Fatalf("guard actor = %q", actor)
}
if !allowed {
return errors.New("disabled")
}
return nil
})
req := Request{ID: "demo.risky", Owner: "alice", Actor: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}}
if r := b.Dispatch(context.Background(), req); r.Receipt.State != agentcommand.StateRejected {
t.Fatalf("disabled dispatch: %+v", r.Receipt)
}
if len(cards.created) != 0 || len(b.pending) != 0 || len(audit.logs) != 0 {
t.Fatal("disabled dispatch created confirmation effects")
}
allowed = true
r := b.Dispatch(context.Background(), req)
if r.Receipt.State != agentcommand.StateAccepted || len(cards.created) != 1 || len(b.pending) != 1 {
t.Fatalf("enabled dispatch: %+v", r.Receipt)
}
allowed = false
before := len(audit.logs)
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected {
t.Fatalf("disabled confirm: %+v", r.Receipt)
}
if len(b.pending) != 1 || len(audit.logs) != before {
t.Fatal("disabled confirm consumed pending request or wrote audit")
}
allowed = true
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("re-enabled confirm: %+v", r.Receipt)
}
}
func TestEffectiveAccountGuardStopsConfirmedHandlerAfterStateChanges(t *testing.T) {
b := New(&memAudit{}, &memCards{}, nil)
executed := 0
if err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.effect", Title: "Effect"}, func(context.Context, Request) (any, error) {
executed++
return "done", nil
}); err != nil {
t.Fatal(err)
}
checks := 0
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
checks++
if actor != "alice" {
t.Fatalf("guard actor = %q", actor)
}
if checks == 4 {
return errors.New("disabled after confirmation started")
}
return nil
})
// Confirm passes its own check, then Dispatch must check once more before
// invoking the handler. A direct confirmed request takes the same path.
if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.high", Title: "High", Danger: agentcommand.DangerHigh}, "", func(context.Context, Request) (any, error) {
executed++
return "done", nil
}); err != nil {
t.Fatal(err)
}
r := b.Dispatch(context.Background(), Request{ID: "demo.high", Owner: "alice", ConversationID: "cnv_1"})
if r.Receipt.State != agentcommand.StateAccepted {
t.Fatalf("initial dispatch: %+v", r.Receipt)
}
// Dispatch checks again before creating the confirmation card. Confirm's
// check passes; its nested Dispatch catches the fourth-state change.
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected {
t.Fatalf("confirm after disable: %+v", r.Receipt)
}
if executed != 0 || len(b.pending) != 0 {
t.Fatalf("executed=%d pending=%d", executed, len(b.pending))
}
// Direct confirmed execution also passes through the same guard.
checks = 3
if r := b.Dispatch(context.Background(), Request{ID: "demo.effect", Owner: "alice", Confirmed: true}); r.Receipt.State != agentcommand.StateRejected || executed != 0 {
t.Fatalf("direct confirmed dispatch: %+v executed=%d", r.Receipt, executed)
}
}
type trackingPendingStore struct {
owner, payload string
takes int
}
func (p *trackingPendingStore) SavePendingConfirm(_ string, owner, payload string) error {
p.owner, p.payload = owner, payload
return nil
}
func (p *trackingPendingStore) TakePendingConfirm(string) (string, string, bool, error) {
p.takes++
return p.owner, p.payload, p.payload != "", nil
}
func TestEffectiveAccountGuardDoesNotConsumeStoredPendingOnDeniedConfirm(t *testing.T) {
b := newTestBus(&memAudit{}, &memCards{})
pendingStorage := &trackingPendingStore{}
b.SetPendingStore(pendingStorage)
allowed := true
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
if actor != "alice" {
t.Fatalf("guard actor = %q", actor)
}
if !allowed {
return errors.New("database unavailable")
}
return nil
})
r := b.Dispatch(context.Background(), Request{ID: "demo.risky", Owner: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}})
if r.Receipt.State != agentcommand.StateAccepted || pendingStorage.payload == "" {
t.Fatalf("pending setup: %+v", r.Receipt)
}
b.pending = map[string]pending{} // force the restored path
allowed = false
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected || pendingStorage.takes != 0 {
t.Fatalf("denied confirm: %+v takes=%d", r.Receipt, pendingStorage.takes)
}
}
@@ -0,0 +1,18 @@
package cmdbus
import (
"context"
"testing"
agentcommand "agent-command"
)
func TestStrictAccountWritesRejectLegacyBusPorts(t *testing.T) {
audit, cards := &memAudit{}, &memCards{}
b := newTestBus(audit, cards)
b.RequireAccountCheckedWrites(false)
resp := b.Dispatch(context.Background(), Request{ID: "demo.risky", Owner: "alice", Actor: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}})
if resp.Receipt.State != agentcommand.StateUnknown || len(cards.created) != 0 || len(audit.logs) != 0 || len(b.pending) != 0 {
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))
}
}
+268 -37
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 列)。
@@ -45,12 +46,35 @@ type CommandTool struct {
}
// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。
// ToolGate 是 owner 级工具注入闸(U3:禁用即不注入能力):返回 false 时该命令
// 不投影给该 owner 的模型。组合根按需装配(如 generative-ui 按 activation 台账)。
type ToolGate func(owner, commandID string) bool
// EffectiveAccountGuard checks the current account state before a command can
// create a confirmation or invoke a handler. The composition root supplies the
// check; nil keeps standalone and auth-off fixtures unchanged. The actor is
// derived only from trusted Request identity fields, never Params. This is an
// early check, not an atomic fence across handler databases or external calls.
type EffectiveAccountGuard func(ctx context.Context, actor string) error
// SetToolGate 装配 owner 级注入闸(组合根;启动期一次)。
func (b *Bus) SetToolGate(g ToolGate) { b.toolGate = g }
func (b *Bus) CommandTools() []CommandTool {
return b.CommandToolsFor("")
}
// CommandToolsFor 按 owner 过滤工具投影(gate 为 nil 时不过滤;owner 为空时
// gated 命令一律不投影——fail-closed)。
func (b *Bus) CommandToolsFor(owner string) []CommandTool {
out := []CommandTool{}
for _, r := range b.reg.List() {
if !r.Enabled || r.Spec.ExposeAsTool == nil {
continue
}
if b.toolGate != nil && !b.toolGate(owner, r.Spec.ID) {
continue
}
risk := r.Spec.ExposeAsTool.Risk
if risk == "" {
risk = "low"
@@ -73,8 +97,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 分发结果:回执 + 处理负载 + 可能的确认卡。
@@ -91,16 +156,25 @@ 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:意图声明表(自然语言路由)
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
}
func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) {
b.projectWorkPreflight = fn
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
@@ -126,6 +200,70 @@ func (b *Bus) Registry() *agentcommand.Registry { return b.reg }
// SetPendingStore 注入确认挂起态的持久化实现(nil = 仅进程内)。
func (b *Bus) SetPendingStore(st PendingStore) { b.pendingStore = st }
// 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 }
// RequireAccountCheckedWrites makes every Bus-owned durable sink use a final
// account transaction. The composition root enables this after wiring Store
// and Cards; standalone in-memory fixtures retain their legacy ports.
func (b *Bus) RequireAccountCheckedWrites(allowImplicitLocal bool) {
b.strictAccountWrites = true
b.allowImplicitLocal = allowImplicitLocal
}
func (b *Bus) createConfirmCard(actor string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
if !b.strictAccountWrites {
return b.cards.Create(actor, card, ref)
}
checked, ok := b.cards.(interface {
CreateAccountChecked(string, core.ActivityCard, string, bool) (core.ActivityCard, error)
})
if !ok {
return core.ActivityCard{}, fmt.Errorf("cmdbus: account-checked card port required")
}
return checked.CreateAccountChecked(actor, card, ref, b.allowImplicitLocal)
}
func (b *Bus) setConfirmCardStatus(actor, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
if !b.strictAccountWrites {
return b.cards.SetStatus(actor, id, status, tone, note)
}
checked, ok := b.cards.(interface {
SetStatusAccountChecked(string, string, core.CardStatus, string, string, bool) (core.ActivityCard, error)
})
if !ok {
return core.ActivityCard{}, fmt.Errorf("cmdbus: account-checked card port required")
}
return checked.SetStatusAccountChecked(actor, id, status, tone, note, b.allowImplicitLocal)
}
func (b *Bus) savePending(cardID, owner, payload string) error {
if !b.strictAccountWrites {
return b.pendingStore.SavePendingConfirm(cardID, owner, payload)
}
checked, ok := b.pendingStore.(interface {
SavePendingConfirmAccountChecked(string, string, string, bool) error
})
if !ok {
return fmt.Errorf("cmdbus: account-checked pending store required")
}
return checked.SavePendingConfirmAccountChecked(cardID, owner, payload, b.allowImplicitLocal)
}
func (b *Bus) takePending(cardID, owner string) (string, string, bool, error) {
if !b.strictAccountWrites {
return b.pendingStore.TakePendingConfirm(cardID)
}
checked, ok := b.pendingStore.(interface {
TakePendingConfirmAccountChecked(string, string, bool) (string, string, bool, error)
})
if !ok {
return "", "", false, fmt.Errorf("cmdbus: account-checked pending store required")
}
return checked.TakePendingConfirmAccountChecked(cardID, owner, b.allowImplicitLocal)
}
// RegisterBuiltin 登记内置命令并绑定宿主处理器与 `/` 别名。
func (b *Bus) RegisterBuiltin(spec agentcommand.CommandSpec, slash string, h Handler) error {
spec.Binding = agentcommand.Binding{Kind: agentcommand.BindBuiltin}
@@ -224,10 +362,16 @@ func (b *Bus) HasCommand(id string) bool {
// List 四入口共享的命令清单(`/` 菜单、按钮、卡片 action 提示用)。
func (b *Bus) List() []agentcommand.Registered { return b.reg.List() }
// Dispatch 统一分发:校验→危险收口→执行→审计。任何路径都产出回执与审计(入口对称)。
// Dispatch 统一分发:账号预检→校验→危险收口→执行→审计。账号预检拒绝只返回回执,
// 避免停用账号或检查故障时继续写入审计;其余路径维持四入口审计对称。
func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
start := b.now()
req.Entrance = agentcommand.CanonicalEntrance(req.Entrance)
if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil {
// A rejected account cannot create a card, pending row, audit row, or
// invoke a handler. A failed read is also a denial.
return b.accountRejected(req.ID, start)
}
receipt := func(state agentcommand.ResultState, op, reason string) Response {
return Response{Receipt: b.auditReceipt(req, state, op, reason, start)}
}
@@ -238,22 +382,37 @@ 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())
}
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 == "" {
return receipt(agentcommand.StateRejected, "", "high-danger command requires conversation context for inline confirm")
}
card, err := b.cards.Create(req.Owner, confirmCard(spec, req), "cmd:"+spec.ID)
if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil {
return b.accountRejected(req.ID, start)
}
card, err := b.createConfirmCard(accountActor(req), confirmCard(spec, req), "cmd:"+spec.ID)
if err != nil {
return receipt(agentcommand.StateUnknown, "", "confirm card: "+err.Error())
}
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())
}
} else if b.strictAccountWrites {
return receipt(agentcommand.StateUnknown, "", "confirm pending: account-checked store required")
}
b.pendMu.Lock()
b.pending[card.ID] = pending{req: req, cardID: card.ID}
b.pendMu.Unlock()
if b.pendingStore != nil {
raw, _ := json.Marshal(req)
_ = b.pendingStore.SavePendingConfirm(card.ID, req.Owner, string(raw))
}
r := b.auditReceipt(req, agentcommand.StateAccepted, card.ID, "awaiting explicit confirm", start)
return Response{Receipt: r, Card: &card}
}
@@ -261,59 +420,118 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
if !ok {
return receipt(agentcommand.StateRejected, "", "no host handler bound for "+req.ID)
}
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 failure still writes only a rejected audit receipt without an ID.
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())
}
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}
}
// Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。
func (b *Bus) Confirm(owner, cardID, actionID string) Response {
return b.ConfirmContext(context.Background(), owner, cardID, actionID)
}
// ConfirmContext carries the request cancellation through the confirmed
// handler and repeats the account check before consuming durable state.
func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string) Response {
// The caller supplies the authenticated card owner. Check before even
// consuming a persisted pending request; Dispatch checks the stored
// request again immediately before executing its handler.
if err := b.checkEffectiveAccount(ctx, owner); err != nil {
return b.accountRejected("unknown", b.now())
}
b.pendMu.Lock()
p, ok := b.pending[cardID]
if ok && actionID == "confirm" {
delete(b.pending, cardID)
}
b.pendMu.Unlock()
if ok && actionID == "confirm" && b.pendingStore != nil {
_, _, _, _ = b.pendingStore.TakePendingConfirm(cardID)
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()}}
}
if !ok && b.pendingStore != nil {
_, payload, found, err := b.pendingStore.TakePendingConfirm(cardID)
if err == nil && found {
var req Request
if json.Unmarshal([]byte(payload), &req) == nil {
p = pending{req: req, cardID: cardID}
ok = true
}
if b.pendingStore != nil {
storedOwner, payload, found, err := b.takePending(cardID, owner)
if err != nil {
return b.accountRejected("unknown", b.now())
}
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()}}
}
p = pending{req: req, cardID: cardID}
ok = true
} else if b.strictAccountWrites {
ok = false
}
} else if b.strictAccountWrites {
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()}}
}
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()}}
}
b.pendMu.Lock()
delete(b.pending, cardID)
b.pendMu.Unlock()
req.Confirmed = true
req.Entrance = "card"
if actionID != "confirm" {
if b.cards != nil {
_, _ = b.cards.SetStatus(owner, cardID, core.CardResolved, "muted", "cancelled")
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "muted", "cancelled")
}
start := b.now()
return Response{Receipt: b.auditReceipt(req, agentcommand.StateRejected, "", "cancelled at confirm card", start)}
}
resp := b.Dispatch(context.Background(), req)
resp := b.Dispatch(ctx, req)
if b.cards != nil {
if resp.Receipt.State == agentcommand.StateCommitted {
_, _ = b.cards.SetStatus(owner, cardID, core.CardResolved, "ok", "confirmed")
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "ok", "confirmed")
} else {
_, _ = b.cards.SetStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason)
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason)
}
}
return resp
}
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.
if req.Owner != "" {
return req.Owner
}
return req.Actor
}
func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error {
if err := ctx.Err(); err != nil {
return err
}
if b.accountGuard == nil {
return nil
}
return b.accountGuard(ctx, actor)
}
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(),
}}
}
func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reason string, start time.Time) agentcommand.ExecReceipt {
env := agentcommand.AuditEnvelope{
Time: start.UTC(), Actor: req.Actor, CommandID: req.ID, Entrance: agentcommand.CanonicalEntrance(req.Entrance),
@@ -323,7 +541,20 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas
if _, src, err := b.reg.Resolve(req.ID); err == nil {
env.Source = src
}
if err := b.audit.AppendAudit(req.Owner, env); err != nil {
var err error
if b.strictAccountWrites {
checked, ok := b.audit.(interface {
AppendAuditAccountChecked(string, agentcommand.AuditEnvelope, bool) error
})
if !ok {
err = fmt.Errorf("cmdbus: account-checked audit store required")
} else {
err = checked.AppendAuditAccountChecked(accountActor(req), env, b.allowImplicitLocal)
}
} else {
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()}
}
+62
View File
@@ -62,6 +62,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 +88,66 @@ 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")
}
return nil, errors.New("domain transaction rejected")
}); err != nil {
t.Fatal(err)
}
failed := bus.Dispatch(context.Background(), Request{ID: "rdm.fail", Actor: "alice", Owner: "alice"})
if failed.Receipt.State != agentcommand.StateRejected || failed.Receipt.OperationID != "" || len(audit.logs) != 2 || audit.logs[1].OperationID != "" {
t.Fatalf("failed handler was marked committed: %+v audits=%+v", failed.Receipt, audit.logs)
}
}
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 +278,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
})
+28
View File
@@ -6,6 +6,7 @@ package cmdbus
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
@@ -72,3 +73,30 @@ func TestCommandToolDispatchEnvelope(t *testing.T) {
type nopAudit struct{}
func (nopAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { return nil }
// U3:owner 级注入闸——gate 拒绝的命令不投影(禁用即不注入能力);fail-closed
// (owner 为空且 gate 判否时不投影)。
func TestCommandToolsForGate(t *testing.T) {
bus := New(nil, nil, func() time.Time { return time.Now().UTC() })
if err := bus.RegisterPluginCommands([]json.RawMessage{
[]byte(`{"id":"generative-ui.present","title":"生成页面","description":"d","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
[]byte(`{"id":"other.echo","title":"回声","description":"e","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
}); err != nil {
t.Fatal(err)
}
bus.SetToolGate(func(owner, id string) bool {
return !strings.HasPrefix(id, "generative-ui.")
})
on := bus.CommandToolsFor("alice")
for _, ct := range on {
if strings.HasPrefix(ct.ID, "generative-ui.") {
t.Fatalf("gated command leaked for enabled owner: %+v", ct)
}
}
if len(on) != 1 {
t.Fatalf("expected only other.echo, got %+v", on)
}
if n := len(bus.CommandToolsFor("")); n != 1 {
t.Fatalf("empty owner must be fail-closed on gated commands, got %d", n)
}
}
@@ -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)
+241 -24
View File
@@ -11,6 +11,7 @@ import (
"io"
"net"
"os"
"strconv"
"strings"
)
@@ -31,6 +32,23 @@ type Config struct {
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
// 隔离档)。DisableExecTools 优先级高于后端选择。
ExecBackend string `json:"execBackend"`
// ExecDepsDir sandbox 档只读依赖层目录(空 = 不挂载)。
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)。
Tier string `json:"tier"`
// PG 连接预算(KTD12):零值走默认(16/8/300s/不限/不限);负值非法。
// PGQueryTimeoutSec>0 时以 statement_timeout 附着到 PG DSN。
PGMaxOpen int `json:"pgMaxOpen"`
PGMaxIdle int `json:"pgMaxIdle"`
PGConnMaxIdleSec int `json:"pgConnMaxIdleSec"`
PGConnMaxLifetimeSec int `json:"pgConnMaxLifetimeSec"`
PGQueryTimeoutSec int `json:"pgQueryTimeoutSec"`
}
var defaults = Config{
@@ -42,18 +60,27 @@ var defaults = Config{
// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。
var envKey = map[string]string{
"addr": "AGENT_SYSTEM_ADDR",
"data": "AGENT_SYSTEM_DATA",
"plugins": "AGENT_SYSTEM_PLUGINS",
"dsn": "AGENT_SYSTEM_DSN",
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
"auth": "AGENT_SYSTEM_AUTH",
"mock": "AGENT_SYSTEM_MOCK",
"instance": "AGENT_SYSTEM_INSTANCE",
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
"addr": "AGENT_SYSTEM_ADDR",
"data": "AGENT_SYSTEM_DATA",
"plugins": "AGENT_SYSTEM_PLUGINS",
"dsn": "AGENT_SYSTEM_DSN",
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
"auth": "AGENT_SYSTEM_AUTH",
"mock": "AGENT_SYSTEM_MOCK",
"instance": "AGENT_SYSTEM_INSTANCE",
"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",
"tier": "AGENT_SYSTEM_TIER",
"pgMaxOpen": "AGENT_SYSTEM_PG_MAX_OPEN",
"pgMaxIdle": "AGENT_SYSTEM_PG_MAX_IDLE",
"pgConnMaxIdleSec": "AGENT_SYSTEM_PG_CONN_MAX_IDLE_SEC",
"pgConnMaxLifetimeSec": "AGENT_SYSTEM_PG_CONN_MAX_LIFETIME_SEC",
"pgQueryTimeoutSec": "AGENT_SYSTEM_PG_QUERY_TIMEOUT_SEC",
}
// Source 配置来源层级(数值越大优先级越高)。
@@ -103,11 +130,7 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
}
// 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。
for f := range present {
if isBoolField(f) {
setBool(&cfg, f, boolField(fc, f))
} else {
setString(&cfg, f, stringField(fc, f))
}
setField(&cfg, f, fc)
src[f] = SrcFile
}
}
@@ -120,6 +143,12 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
return Resolved{}, err
}
setBool(&cfg, f, b)
} else if isIntField(f) {
n, err := parseInt(key, v)
if err != nil {
return Resolved{}, err
}
setInt(&cfg, f, n)
} else {
setString(&cfg, f, v)
}
@@ -129,15 +158,22 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
// CLI 层:仅显式传入。
for f := range cliSet {
src[f] = SrcCLI
if isBoolField(f) {
setBool(&cfg, f, boolField(cli, f))
} else {
setString(&cfg, f, stringField(cli, f))
}
setField(&cfg, f, cli)
}
return Resolved{Config: cfg, sources: src}, nil
}
// setField 从同类型快照覆盖单个字段(文件/CLI 层复用;字段类型由字段名决定)。
func setField(dst *Config, f string, src Config) {
if isBoolField(f) {
setBool(dst, f, boolField(src, f))
} else if isIntField(f) {
setInt(dst, f, intField(src, f))
} else {
setString(dst, f, stringField(src, f))
}
}
func loadFile(path string) (Config, map[string]bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
@@ -176,6 +212,24 @@ func loadFile(path string) (Config, map[string]bool, error) {
dst = &c.DisableExecTools
case "execBackend":
dst = &c.ExecBackend
case "execDepsDir":
dst = &c.ExecDepsDir
case "execWorkspace":
dst = &c.ExecWorkspace
case "execEnvRoot":
dst = &c.ExecEnvRoot
case "tier":
dst = &c.Tier
case "pgMaxOpen":
dst = &c.PGMaxOpen
case "pgMaxIdle":
dst = &c.PGMaxIdle
case "pgConnMaxIdleSec":
dst = &c.PGConnMaxIdleSec
case "pgConnMaxLifetimeSec":
dst = &c.PGConnMaxLifetimeSec
case "pgQueryTimeoutSec":
dst = &c.PGQueryTimeoutSec
default:
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
}
@@ -207,6 +261,14 @@ func stringField(c Config, f string) string {
return c.AllowedHostsRaw
case "execBackend":
return c.ExecBackend
case "execDepsDir":
return c.ExecDepsDir
case "execWorkspace":
return c.ExecWorkspace
case "execEnvRoot":
return c.ExecEnvRoot
case "tier":
return c.Tier
}
return ""
}
@@ -215,6 +277,45 @@ func isBoolField(f string) bool {
return f == "mock" || f == "insecureListen" || f == "disableExecTools"
}
func isIntField(f string) bool {
switch f {
case "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec":
return true
}
return false
}
func intField(c Config, f string) int {
switch f {
case "pgMaxOpen":
return c.PGMaxOpen
case "pgMaxIdle":
return c.PGMaxIdle
case "pgConnMaxIdleSec":
return c.PGConnMaxIdleSec
case "pgConnMaxLifetimeSec":
return c.PGConnMaxLifetimeSec
case "pgQueryTimeoutSec":
return c.PGQueryTimeoutSec
}
return 0
}
func setInt(c *Config, f string, v int) {
switch f {
case "pgMaxOpen":
c.PGMaxOpen = v
case "pgMaxIdle":
c.PGMaxIdle = v
case "pgConnMaxIdleSec":
c.PGConnMaxIdleSec = v
case "pgConnMaxLifetimeSec":
c.PGConnMaxLifetimeSec = v
case "pgQueryTimeoutSec":
c.PGQueryTimeoutSec = v
}
}
func boolField(c Config, f string) bool {
switch f {
case "insecureListen":
@@ -257,6 +358,14 @@ func setString(c *Config, f, v string) {
c.AllowedHostsRaw = v
case "execBackend":
c.ExecBackend = v
case "execDepsDir":
c.ExecDepsDir = v
case "execWorkspace":
c.ExecWorkspace = v
case "execEnvRoot":
c.ExecEnvRoot = v
case "tier":
c.Tier = v
}
}
@@ -270,6 +379,14 @@ func parseBool(name, v string) (bool, error) {
return false, fmt.Errorf("env %s: invalid bool %q", name, v)
}
func parseInt(name, v string) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, fmt.Errorf("env %s: invalid int %q", name, v)
}
return n, nil
}
// Explain 只读诊断:逐字段打印解析值与来源层级,DSN 脱敏。
func (r Resolved) Explain(w io.Writer, file string) {
if file != "" {
@@ -278,16 +395,32 @@ 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"} {
v := stringField(r.Config, f)
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))
} else if isIntField(f) {
n := intField(r.Config, f)
if d := intDefault(f); n == 0 && d != 0 {
v = fmt.Sprintf("%d (默认)", d)
} else {
v = fmt.Sprintf("%d", n)
}
} else {
v = stringField(r.Config, f)
}
if f == "dsn" {
v = redact(v)
}
fmt.Fprintf(w, "%-16s %-56s %s\n", f, v, r.sources[f])
}
if msgs := r.TierProblems(); len(msgs) > 0 {
for _, msg := range msgs {
fmt.Fprintf(w, "tier guard: 拒绝启动(%s)\n", msg)
}
} else {
fmt.Fprintln(w, "tier guard: 放行")
}
if msg := r.ListenRefusal(); msg != "" {
fmt.Fprintf(w, "listen guard: 拒绝启动(%s)\n", msg)
} else if r.InsecureListen && !IsLoopbackListen(r.Addr) && !r.AuthEnabled() {
@@ -302,6 +435,90 @@ func (r Resolved) AuthEnabled() bool {
return r.AuthMode == "on" || (r.AuthMode != "off" && r.StorageDSN != "")
}
// intDefault 返回 int 字段的默认值(0 表示零值合法、无默认替换)。
func intDefault(f string) int {
switch f {
case "pgMaxOpen":
return DefaultPGMaxOpen
case "pgMaxIdle":
return DefaultPGMaxIdle
case "pgConnMaxIdleSec":
return DefaultPGConnMaxIdleSec
}
return 0
}
// 连接预算默认值(KTD12;与 store 层默认保持一致,两处同时修改时以测试对齐)。
const (
DefaultPGMaxOpen = 16
DefaultPGMaxIdle = 8
DefaultPGConnMaxIdleSec = 300
)
// TierProblems 部署档语义校验:返回阻止启动的问题列表(空 = 放行)。
// team 档 fail-closed:缺 DSN、未开认证直接拒绝;local 档不允许 DSN(KTD1)。
// 执行隔离在 team 档是启动警告而非拒绝(本增量先可见、U3/U5 再硬化)。
func (r Resolved) TierProblems() []string {
var msgs []string
switch r.Tier {
case "", "local", "team":
default:
msgs = append(msgs, fmt.Sprintf("未知部署档 %q(local | team)", r.Tier))
}
if r.Tier == "team" {
if r.StorageDSN == "" {
msgs = append(msgs, "team 档需要 PostgreSQL DSN(dsn / AGENT_SYSTEM_DSN);不会回落 SQLite")
}
if !r.AuthEnabled() {
msgs = append(msgs, "team 档必须开启认证(auth=on)")
}
}
if r.Tier == "local" && r.StorageDSN != "" {
msgs = append(msgs, "local 档不使用 PostgreSQL DSN(改用 team 档,或去掉 dsn)")
}
if v := r.PGMaxOpen; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgMaxOpen 不能为负(当前 %d)", v))
}
if v := r.PGMaxIdle; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgMaxIdle 不能为负(当前 %d)", v))
}
if v := r.PGConnMaxIdleSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgConnMaxIdleSec 不能为负(当前 %d)", v))
}
if v := r.PGConnMaxLifetimeSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgConnMaxLifetimeSec 不能为负(当前 %d)", v))
}
if v := r.PGQueryTimeoutSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgQueryTimeoutSec 不能为负(当前 %d)", v))
}
if idle, open := r.EffectivePGMaxIdle(), r.EffectivePGMaxOpen(); idle > open {
msgs = append(msgs, fmt.Sprintf("pgMaxIdle(%d) 大于 pgMaxOpen(%d)", idle, open))
}
return msgs
}
// EffectivePGMaxOpen/Idle/ConnMaxIdleSec 返回连接预算有效值(零值落默认,KTD12)。
func (r Resolved) EffectivePGMaxOpen() int {
if r.PGMaxOpen > 0 {
return r.PGMaxOpen
}
return DefaultPGMaxOpen
}
func (r Resolved) EffectivePGMaxIdle() int {
if r.PGMaxIdle > 0 {
return r.PGMaxIdle
}
return DefaultPGMaxIdle
}
func (r Resolved) EffectivePGConnMaxIdleSec() int {
if r.PGConnMaxIdleSec > 0 {
return r.PGConnMaxIdleSec
}
return DefaultPGConnMaxIdleSec
}
// AllowedHosts 把逗号分隔的主机名拆开,忽略空段。
func (r Resolved) AllowedHosts() []string {
var out []string
+133
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -220,3 +221,135 @@ func TestListenGuard(t *testing.T) {
t.Fatalf("auth on should pass: %+v %q", authed.Config, authed.ListenRefusal())
}
}
// ---- 协作存储方案 U2:部署档(KTD1)与连接预算(KTD12)----
// team 档 fail-closed:缺 DSN、未开认证都拒绝;DSN+auth=on 放行。
func TestTierTeamFailClosed(t *testing.T) {
r, err := Load("", noEnv, Config{Tier: "team"}, map[string]bool{"tier": true})
if err != nil {
t.Fatal(err)
}
msgs := r.TierProblems()
if len(msgs) != 2 {
t.Fatalf("team without dsn/auth should refuse with 2 problems, got %v", msgs)
}
joined := msgs[0] + msgs[1]
if !strings.Contains(joined, "PostgreSQL DSN") || !strings.Contains(joined, "认证") {
t.Fatalf("refusal messages wrong: %v", msgs)
}
// 补上 DSN(auto 认证随 DSN 开启)→ 放行。
r, err = Load("", noEnv, Config{Tier: "team", StorageDSN: "postgres://u:p@h/db"}, map[string]bool{"tier": true, "dsn": true})
if err != nil {
t.Fatal(err)
}
if msgs = r.TierProblems(); len(msgs) != 0 {
t.Fatalf("team with dsn should pass, got %v", msgs)
}
// 显式关认证仍然拒绝。
r, err = Load("", noEnv, Config{Tier: "team", StorageDSN: "postgres://u:p@h/db", AuthMode: "off"}, map[string]bool{"tier": true, "dsn": true, "auth": true})
if err != nil {
t.Fatal(err)
}
if msgs = r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "认证") {
t.Fatalf("team with auth=off should refuse, got %v", msgs)
}
}
// local 档不允许 DSN;未知档名拒绝。
func TestTierLocalAndUnknown(t *testing.T) {
r, err := Load("", noEnv, Config{Tier: "local", StorageDSN: "postgres://u:p@h/db"}, map[string]bool{"tier": true, "dsn": true})
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "local 档不使用") {
t.Fatalf("local with dsn should refuse, got %v", msgs)
}
r, err = Load("", noEnv, Config{Tier: "cluster"}, map[string]bool{"tier": true})
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "未知部署档") {
t.Fatalf("unknown tier should refuse, got %v", msgs)
}
// auto(空)档维持现状语义:无 DSN 不拒绝。
r, err = Load("", noEnv, Config{}, nil)
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 0 {
t.Fatalf("auto default should pass, got %v", msgs)
}
}
// 连接预算四源解析与环境变量整数校验;MaxIdle>MaxOpen 拒绝。
func TestPGPoolConfigLayers(t *testing.T) {
env := func(k string) (string, bool) {
switch k {
case "AGENT_SYSTEM_PG_MAX_OPEN":
return "7", true
case "AGENT_SYSTEM_PG_MAX_IDLE":
return "9", true
}
return "", false
}
f := fileWith(t, `{"pgMaxOpen":5}`)
r, err := Load(f, env, Config{PGMaxOpen: 3, PGMaxIdle: 2}, map[string]bool{"pgMaxIdle": true})
if err != nil {
t.Fatal(err)
}
// 文件 5 被 env 7 覆盖(CLI 值 3 未显式传入不参与);pgMaxIdle CLI 显式 2 胜。
if r.PGMaxOpen != 7 || r.SourceOf("pgMaxOpen") != SrcEnv {
t.Fatalf("pgMaxOpen = %d (%v), want env 7", r.PGMaxOpen, r.SourceOf("pgMaxOpen"))
}
if r.PGMaxIdle != 2 || r.SourceOf("pgMaxIdle") != SrcCLI {
t.Fatalf("pgMaxIdle = %d (%v), want CLI 2", r.PGMaxIdle, r.SourceOf("pgMaxIdle"))
}
if msgs := r.TierProblems(); len(msgs) != 0 {
t.Fatalf("2/2 pool valid, got %v", msgs)
}
// 有效值:零值落默认。
r, err = Load("", noEnv, Config{}, nil)
if err != nil {
t.Fatal(err)
}
if r.EffectivePGMaxOpen() != DefaultPGMaxOpen || r.EffectivePGMaxIdle() != DefaultPGMaxIdle || r.EffectivePGConnMaxIdleSec() != DefaultPGConnMaxIdleSec {
t.Fatalf("effective defaults wrong: %+v", r.Config)
}
// idle > open 拒绝。
r, err = Load("", noEnv, Config{PGMaxOpen: 2, PGMaxIdle: 3}, map[string]bool{"pgMaxOpen": true, "pgMaxIdle": true})
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "pgMaxIdle(3) 大于 pgMaxOpen(2)") {
t.Fatalf("idle>open should refuse, got %v", msgs)
}
// 非法整数环境变量报错定位到变量。
bad := func(k string) (string, bool) {
if k == "AGENT_SYSTEM_PG_MAX_OPEN" {
return "many", true
}
return "", false
}
if _, err := Load("", bad, Config{}, nil); err == nil || !strings.Contains(err.Error(), "AGENT_SYSTEM_PG_MAX_OPEN") {
t.Fatalf("invalid int env should fail with var name, got %v", err)
}
}
// explain 输出包含部署档与预算行,且 tier guard 结论可见(脱敏诊断)。
func TestExplainTierAndPool(t *testing.T) {
r, err := Load("", noEnv, Config{Tier: "team"}, map[string]bool{"tier": true})
if err != nil {
t.Fatal(err)
}
var b bytes.Buffer
r.Explain(&b, "")
out := b.String()
for _, want := range []string{"tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec", "tier guard: 拒绝启动"} {
if !strings.Contains(out, want) {
t.Fatalf("explain missing %q:\n%s", want, out)
}
}
}
+89 -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"
)
@@ -41,6 +43,9 @@ type AssembleOptions struct {
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
ThinkReplay bool
// Surfaces 是当前会话最近保存的生成页面(U3/R6):以语义投影并入上下文,
// 使下一轮模型能解释并修改已有结构(修改走 generative-ui.present 重提)。
Surfaces []core.SurfaceSnapshot
}
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
@@ -58,6 +63,17 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: prof.SystemPrompt})
rep.Items = append(rep.Items, "system(画像自定义段)")
}
if len(opts.Surfaces) > 0 {
var b strings.Builder
b.WriteString("[本会话已保存的生成页面(结构摘要;正文以预览区为准)]\n")
for _, snap := range opts.Surfaces {
b.WriteString(projectSurface(snap))
b.WriteString("\n")
}
b.WriteString("用户要求调整页面时,按结构摘要理解现状并用 generative-ui.present 提交修订(带 surfaceId 与 baseRevision)。")
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
rep.Items = append(rep.Items, fmt.Sprintf("surface(%d)", len(opts.Surfaces)))
}
maxHist := prof.Context.MaxHistoryMessages
if maxHist <= 0 {
@@ -120,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)
@@ -132,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]) + "…(附件超长截断)"
@@ -154,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。
@@ -421,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)
}
@@ -440,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
@@ -499,3 +548,42 @@ func InjectMemory(sys []agentllm.Message, entries []core.MemoryEntry) []agentllm
out = append(out, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
return out
}
// projectSurface 是 ctxasm 侧的结构摘要投影(与 surfacesvc.ProjectSurface 同义;
// 独立实现避免 ctxasm→surfacesvc 依赖边)。
func projectSurface(snap core.SurfaceSnapshot) string {
var b strings.Builder
fmt.Fprintf(&b, "- 页面 %s(修订 %d)%q", string(snap.Identity.SurfaceID), snap.Revision, snap.Spec.Title)
if snap.SemanticText != "" {
fmt.Fprintf(&b, ":%s", snap.SemanticText)
}
b.WriteString("\n 结构:")
var walk func(n core.UINode)
first := true
walk = func(n core.UINode) {
label := string(n.Type)
if n.Title != "" {
label += "(\"" + n.Title + "\")"
}
if n.ID != "" {
label += "#" + n.ID
}
if len(n.Source) > 0 {
label += "[" + strings.Join(n.Source, ",") + "]"
}
if first {
b.WriteString(label)
first = false
} else {
b.WriteString(" > " + label)
}
for _, c := range n.Children {
walk(c)
}
}
for _, n := range snap.Spec.Nodes {
walk(n)
}
b.WriteString("\n 修订此页:present 带 surfaceId=" + string(snap.Identity.SurfaceID) + " 与 baseRevision=" + fmt.Sprint(snap.Revision) + "。")
return b.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)
}
}
+52
View File
@@ -0,0 +1,52 @@
package ctxasm
// U3/R6:已保存页面的语义投影并入上下文——下一轮模型能看到结构、来源与修订
// 入口(present + baseRevision),可据此解释并修改已有 table/compare/tabs。
import (
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
func surfaceSnap() core.SurfaceSnapshot {
return core.SurfaceSnapshot{
Identity: core.SurfaceIdentity{SurfaceID: "surf_x", Owner: "alice", PluginID: "generative-ui", ConversationID: "c1"},
Revision: 2,
Spec: core.SurfaceSpec{Title: "选型对比", Nodes: []core.UINode{
{Type: core.UISection, ID: "n1", Title: "概览", Children: []core.UINode{
{Type: core.UIMarkdown, ID: "n1a", Source: []string{"doc:a"}, Markdown: "结论"},
}},
{Type: core.UITable, ID: "n2", Source: []string{"doc:a", "doc:b"}},
}},
SemanticText: "三工具对比页",
}
}
func TestSurfaceProjectionInContext(t *testing.T) {
msgs, rep := AssembleInput(core.AgentProfile{}, nil, nil,
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改一下"}}},
nil, nil, nil, nil, nil, nil, AssembleOptions{Surfaces: []core.SurfaceSnapshot{surfaceSnap()}})
joined := make([]string, len(msgs))
for i, m := range msgs {
joined[i] = m.Content
}
all := strings.Join(joined, "\n---\n")
for _, need := range []string{"surf_x", "修订 2", "选型对比", "section(\"概览\")#n1", "markdown#n1a[doc:a]", "table#n2", "baseRevision=2"} {
if !strings.Contains(all, need) {
t.Fatalf("projection missing %q in assembled context:\n%s", need, all)
}
}
found := false
for _, it := range rep.Items {
if strings.HasPrefix(it, "surface(") {
found = true
}
}
if !found {
t.Fatalf("report should count surface injection: %v", rep.Items)
}
_ = agentllm.RoleSystem
}
+814
View File
@@ -0,0 +1,814 @@
package docgen
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"strconv"
"strings"
"agent-document-renderer/model"
)
const (
comparisonExceptionTitle = "RDR-T10-CMP-001"
comparisonExceptionCorporate = "RDR-T10-CMP-002"
comparisonExceptionAnchors = "RDR-T10-CMP-003"
comparisonExceptionSafeLinks = "RDR-T10-CMP-004"
)
type comparisonBlock struct {
Kind string `json:"kind"`
Level int `json:"level,omitempty"`
Text []string `json:"text,omitempty"`
Rows [][]string `json:"rows,omitempty"`
}
type comparisonList struct {
Depth int `json:"depth"`
Ordered bool `json:"ordered"`
Start int `json:"start,omitempty"`
Items []comparisonListItem `json:"items"`
}
type comparisonListItem struct {
Text string `json:"text"`
Checked string `json:"checked,omitempty"`
}
type comparisonTask struct {
Text string `json:"text"`
Checked string `json:"checked"`
}
type comparisonSemantics struct {
Lists []comparisonList `json:"lists"`
Tasks []comparisonTask `json:"tasks"`
ThematicBreaks int `json:"thematicBreaks"`
TableAlignments [][][]string `json:"tableAlignments"`
}
type docxNumbering struct {
Level int `json:"level"`
Format string `json:"format"`
Start int `json:"start"`
}
type docxComparison struct {
Paragraphs []string `json:"paragraphs"`
TOC []string `json:"toc"`
PageSize map[string]string `json:"pageSize"`
Margins map[string]string `json:"margins"`
Headers int `json:"headers"`
Footers int `json:"footers"`
Bookmarks int `json:"bookmarks"`
ExternalLinks int `json:"externalLinks"`
ForbiddenObject bool `json:"forbiddenObject"`
Semantics map[string]bool `json:"semantics"`
Numbering []docxNumbering `json:"numbering"`
Tasks []comparisonTask `json:"tasks"`
ThematicRules int `json:"thematicRules"`
TableAlignments [][][]string `json:"tableAlignments"`
}
func compareLegacyAndShared(title string, source []byte, canonical model.CanonicalDocument, legacy, shared []byte) ComparisonReceipt {
receipt := ComparisonReceipt{Enabled: true, LegacySHA256: digestBytes(legacy), SharedSHA256: digestBytes(shared)}
legacyStructure := legacyComparisonStructure(string(source))
sharedStructure := canonicalComparisonStructure(canonical)
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("canonical-structure", legacyStructure, sharedStructure, ""))
legacySemantics := sourceComparisonSemantics(string(source))
sharedSemantics := canonicalComparisonSemantics(canonical)
receipt.Dimensions = append(receipt.Dimensions,
compareExactDimension("list-structure", legacySemantics.Lists, sharedSemantics.Lists, ""),
compareExactDimension("task-state", legacySemantics.Tasks, sharedSemantics.Tasks, ""),
compareExactDimension("thematic-break", legacySemantics.ThematicBreaks, sharedSemantics.ThematicBreaks, ""),
compareExactDimension("table-alignment", legacySemantics.TableAlignments, sharedSemantics.TableAlignments, ""),
)
legacyDOCX, legacyErr := inspectComparisonDOCX(legacy)
sharedDOCX, sharedErr := inspectComparisonDOCX(shared)
if legacyErr != nil || sharedErr != nil {
detail := fmt.Sprintf("legacy=%v shared=%v", legacyErr, sharedErr)
for _, name := range []string{
"visible-text", "table-of-contents", "page-layout", "header-footer", "node-bookmarks", "safe-links", "security-objects", "word-semantics",
"word-numbering", "word-task-state", "word-thematic-rule", "word-table-alignment",
} {
receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: name, Status: "unassessed", Detail: detail})
}
return finishComparison(receipt)
}
legacyParagraphs := append([]string(nil), legacyDOCX.Paragraphs...)
if len(legacyParagraphs) >= 2 && legacyParagraphs[0] == normalizeComparisonText(title) && legacyParagraphs[1] == normalizeComparisonText(title) {
withoutDuplicate := append([]string(nil), legacyParagraphs[1:]...)
if equalJSON(withoutDuplicate, sharedDOCX.Paragraphs) {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs,
comparisonExceptionTitle, "legacy synthetic Title duplicated the first canonical heading"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, ""))
}
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, ""))
}
if len(legacyDOCX.TOC) == 0 && len(sharedDOCX.TOC) > 0 {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("table-of-contents", legacyDOCX.TOC, sharedDOCX.TOC,
comparisonExceptionCorporate, "published organization default declares a table of contents"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("table-of-contents", legacyDOCX.TOC, sharedDOCX.TOC, ""))
}
if equalJSON(legacyDOCX.PageSize, sharedDOCX.PageSize) {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("page-size", legacyDOCX.PageSize, sharedDOCX.PageSize, ""))
} else {
receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: "page-size", Status: "accidental", LegacyHash: digestJSON(legacyDOCX.PageSize), SharedHash: digestJSON(sharedDOCX.PageSize), Detail: "page size changed"})
}
if equalJSON(legacyDOCX.Margins, sharedDOCX.Margins) {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("page-margins", legacyDOCX.Margins, sharedDOCX.Margins, ""))
} else {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("page-margins", legacyDOCX.Margins, sharedDOCX.Margins,
comparisonExceptionCorporate, "published organization default owns page margins"))
}
if legacyDOCX.Headers == sharedDOCX.Headers && legacyDOCX.Footers == sharedDOCX.Footers {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("header-footer", []int{legacyDOCX.Headers, legacyDOCX.Footers}, []int{sharedDOCX.Headers, sharedDOCX.Footers}, ""))
} else {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("header-footer", []int{legacyDOCX.Headers, legacyDOCX.Footers}, []int{sharedDOCX.Headers, sharedDOCX.Footers},
comparisonExceptionCorporate, "published organization default adds approved header/footer rules"))
}
if legacyDOCX.Bookmarks == sharedDOCX.Bookmarks {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, ""))
} else if legacyDOCX.Bookmarks == 0 && sharedDOCX.Bookmarks > 0 {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks,
comparisonExceptionAnchors, "shared output anchors canonical node IDs"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, ""))
}
if legacyDOCX.ExternalLinks == sharedDOCX.ExternalLinks {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, ""))
} else if legacyDOCX.ExternalLinks > 0 && sharedDOCX.ExternalLinks == 0 {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks,
comparisonExceptionSafeLinks, "shared links are inert labels with no external relationship or target leakage"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, ""))
}
receipt.Dimensions = append(receipt.Dimensions,
compareExactDimension("security-objects", legacyDOCX.ForbiddenObject, sharedDOCX.ForbiddenObject, ""),
compareExactDimension("word-semantics", legacyDOCX.Semantics, sharedDOCX.Semantics, ""),
compareExactDimension("word-numbering", legacyDOCX.Numbering, sharedDOCX.Numbering, ""),
compareExactDimension("word-task-state", legacyDOCX.Tasks, sharedDOCX.Tasks, ""),
compareExactDimension("word-thematic-rule", legacyDOCX.ThematicRules, sharedDOCX.ThematicRules, ""),
compareExactDimension("word-table-alignment", legacyDOCX.TableAlignments, sharedDOCX.TableAlignments, ""),
)
return finishComparison(receipt)
}
func finishComparison(receipt ComparisonReceipt) ComparisonReceipt {
for _, dimension := range receipt.Dimensions {
switch dimension.Status {
case "intentional":
receipt.IntentionalDifferences = append(receipt.IntentionalDifferences, dimension.Name+": "+dimension.Detail)
case "accidental":
receipt.AccidentalDifferences = append(receipt.AccidentalDifferences, dimension.Name+": "+dimension.Detail)
case "unassessed":
receipt.Unassessed = append(receipt.Unassessed, dimension.Name+": "+dimension.Detail)
}
}
switch {
case len(receipt.Unassessed) > 0:
receipt.Assessment = "unassessed"
case len(receipt.AccidentalDifferences) > 0:
receipt.Assessment = "differences"
default:
receipt.Assessment = "assessed"
}
return receipt
}
func compareExactDimension(name string, legacy, shared any, detail string) ComparisonDimension {
status := "match"
if !equalJSON(legacy, shared) {
status = "accidental"
if detail == "" {
detail = "normalized values differ"
}
}
return ComparisonDimension{Name: name, Status: status, LegacyHash: digestJSON(legacy), SharedHash: digestJSON(shared), Detail: detail}
}
func intentionalDimension(name string, legacy, shared any, exceptionID, detail string) ComparisonDimension {
return ComparisonDimension{Name: name, Status: "intentional", LegacyHash: digestJSON(legacy), SharedHash: digestJSON(shared), ExceptionID: exceptionID, Detail: detail}
}
func legacyComparisonStructure(source string) []comparisonBlock {
document := parseMarkdown(source)
blocks := make([]comparisonBlock, 0, len(document.blocks))
for _, block := range document.blocks {
entry := comparisonBlock{Level: block.level}
switch block.kind {
case bHeading:
entry.Kind, entry.Text = "heading", []string{visibleLegacyRuns(document.parseInline(strings.Join(block.lines, "\n")))}
case bPara:
entry.Kind, entry.Text = "paragraph", []string{visibleLegacyRuns(document.parseInline(strings.Join(block.lines, "\n")))}
case bQuote:
entry.Kind, entry.Text = "quote", []string{normalizeComparisonText(strings.Join(block.lines, "\n"))}
case bCode:
entry.Kind, entry.Text = "code-block", []string{strings.Join(block.lines, "\n") + "\n"}
case bList:
entry.Kind = "list"
for _, item := range block.items {
entry.Text = append(entry.Text, visibleLegacyRuns(document.parseInline(item.text)))
}
case bTable:
entry.Kind = "table"
for _, row := range block.rows {
visible := make([]string, len(row))
for index := range row {
visible[index] = visibleLegacyRuns(document.parseInline(row[index]))
}
entry.Rows = append(entry.Rows, visible)
}
case bHR:
entry.Kind = "thematic-break"
}
blocks = append(blocks, entry)
}
return blocks
}
func canonicalComparisonStructure(document model.CanonicalDocument) []comparisonBlock {
blocks := make([]comparisonBlock, 0, len(document.Root.Children))
for _, node := range document.Root.Children {
entry := comparisonBlock{Kind: string(node.Kind)}
if node.Kind == model.NodeHeading {
fmt.Sscanf(node.Semantic, "heading%d", &entry.Level)
}
switch node.Kind {
case model.NodeHeading, model.NodeParagraph, model.NodeQuote, model.NodeCodeBlock, model.NodeCallout:
entry.Text = []string{normalizeComparisonNodeText(node)}
case model.NodeList:
for _, item := range node.Children {
entry.Text = append(entry.Text, normalizeComparisonNodeText(item))
}
case model.NodeTable:
for _, row := range node.Children {
values := make([]string, len(row.Children))
for index := range row.Children {
values[index] = normalizeComparisonNodeText(row.Children[index])
}
entry.Rows = append(entry.Rows, values)
}
case model.NodeThematicBreak:
default:
continue
}
blocks = append(blocks, entry)
}
return blocks
}
func normalizeComparisonNodeText(node model.Node) string {
if node.Kind == model.NodeCodeBlock {
return node.Text
}
return normalizeComparisonText(node.Text)
}
func visibleLegacyRuns(runs []run) string {
var value strings.Builder
for _, run := range runs {
value.WriteString(run.text)
}
return normalizeComparisonText(value.String())
}
func normalizeComparisonText(value string) string { return strings.Join(strings.Fields(value), " ") }
func sourceComparisonSemantics(source string) comparisonSemantics {
document := parseMarkdown(source)
result := comparisonSemantics{}
for _, block := range document.blocks {
switch block.kind {
case bHR:
result.ThematicBreaks++
case bTable:
rows := make([][]string, len(block.rows))
for rowIndex, row := range block.rows {
rows[rowIndex] = make([]string, len(row))
for cellIndex := range row {
alignment := "left"
if cellIndex < len(block.align) && block.align[cellIndex] != "" {
alignment = block.align[cellIndex]
}
rows[rowIndex][cellIndex] = alignment
}
}
result.TableAlignments = append(result.TableAlignments, rows)
}
}
type openList struct {
indent int
ordered bool
index int
}
var stack []openList
inFence := false
fence := ""
for _, rawLine := range strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n") {
trimmed := strings.TrimSpace(rawLine)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
marker := trimmed[:3]
if !inFence {
inFence, fence = true, marker
} else if marker == fence {
inFence, fence = false, ""
}
stack = nil
continue
}
if inFence {
continue
}
line, found := parseComparisonListLine(rawLine)
if !found {
stack = nil
continue
}
for len(stack) > 0 && stack[len(stack)-1].indent > line.indent {
stack = stack[:len(stack)-1]
}
if len(stack) > 0 && stack[len(stack)-1].indent == line.indent && stack[len(stack)-1].ordered != line.ordered {
stack = stack[:len(stack)-1]
}
if len(stack) == 0 || stack[len(stack)-1].indent != line.indent {
start := 0
if line.ordered {
start = line.start
}
result.Lists = append(result.Lists, comparisonList{Depth: len(stack), Ordered: line.ordered, Start: start})
stack = append(stack, openList{indent: line.indent, ordered: line.ordered, index: len(result.Lists) - 1})
}
list := &result.Lists[stack[len(stack)-1].index]
text := normalizeComparisonText(visibleLegacyRuns(document.parseInline(line.text)))
list.Items = append(list.Items, comparisonListItem{Text: text, Checked: line.checked})
if line.checked != "" {
result.Tasks = append(result.Tasks, comparisonTask{Text: text, Checked: line.checked})
}
}
return result
}
type comparisonListLine struct {
indent int
ordered bool
start int
checked string
text string
}
func parseComparisonListLine(raw string) (comparisonListLine, bool) {
line := strings.TrimRight(raw, " \t")
indent := 0
for indent < len(line) && line[indent] == ' ' {
indent++
}
if indent < len(line) && line[indent] == '\t' {
return comparisonListLine{}, false
}
rest := line[indent:]
parsed := comparisonListLine{indent: indent, start: 1}
for _, marker := range []string{"- ", "* ", "+ "} {
if strings.HasPrefix(rest, marker) {
parsed.text = strings.TrimSpace(strings.TrimPrefix(rest, marker))
if len(parsed.text) >= 3 && parsed.text[0] == '[' && parsed.text[2] == ']' && (parsed.text[1] == ' ' || parsed.text[1] == 'x' || parsed.text[1] == 'X') {
parsed.checked = strconv.FormatBool(parsed.text[1] == 'x' || parsed.text[1] == 'X')
parsed.text = strings.TrimSpace(parsed.text[3:])
}
return parsed, true
}
}
digits := 0
for digits < len(rest) && rest[digits] >= '0' && rest[digits] <= '9' {
digits++
}
if digits == 0 || digits > 9 || digits+1 >= len(rest) || (rest[digits] != '.' && rest[digits] != ')') || rest[digits+1] != ' ' {
return comparisonListLine{}, false
}
value, err := strconv.Atoi(rest[:digits])
if err != nil {
return comparisonListLine{}, false
}
parsed.start = value
parsed.ordered = true
parsed.text = strings.TrimSpace(rest[digits+2:])
return parsed, true
}
func canonicalComparisonSemantics(document model.CanonicalDocument) comparisonSemantics {
result := comparisonSemantics{}
var addList func(model.Node, int)
addList = func(list model.Node, depth int) {
ordered := nodePropertyValue(list, "ordered") == "true"
start := 0
if ordered {
start = 1
if value := nodePropertyValue(list, "start"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
start = parsed
}
}
}
summary := comparisonList{Depth: depth, Ordered: ordered, Start: start}
for _, item := range list.Children {
checked := nodePropertyValue(item, "checked")
text := normalizeComparisonNodeText(item)
summary.Items = append(summary.Items, comparisonListItem{Text: text, Checked: checked})
if checked != "" {
result.Tasks = append(result.Tasks, comparisonTask{Text: text, Checked: checked})
}
}
result.Lists = append(result.Lists, summary)
for _, item := range list.Children {
for _, child := range item.Children {
if child.Kind == model.NodeList {
addList(child, depth+1)
}
}
}
}
for _, node := range document.Root.Children {
switch node.Kind {
case model.NodeList:
addList(node, 0)
case model.NodeThematicBreak:
result.ThematicBreaks++
case model.NodeTable:
rows := make([][]string, len(node.Children))
for rowIndex, row := range node.Children {
rows[rowIndex] = make([]string, len(row.Children))
for cellIndex, cell := range row.Children {
alignment := nodePropertyValue(cell, "alignment")
if alignment == "" {
alignment = "left"
}
rows[rowIndex][cellIndex] = alignment
}
}
result.TableAlignments = append(result.TableAlignments, rows)
}
}
return result
}
func nodePropertyValue(node model.Node, name string) string {
for _, property := range node.Properties {
if property.Name == name {
return property.Value
}
}
return ""
}
func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return docxComparison{}, err
}
parts := make(map[string][]byte, len(reader.File))
for _, file := range reader.File {
stream, err := file.Open()
if err != nil {
return docxComparison{}, err
}
content, err := io.ReadAll(stream)
stream.Close()
if err != nil {
return docxComparison{}, err
}
parts[file.Name] = content
}
document, found := parts["word/document.xml"]
if !found {
return docxComparison{}, errors.New("word/document.xml missing")
}
result := docxComparison{PageSize: map[string]string{}, Margins: map[string]string{}, Semantics: map[string]bool{}}
decoder := xml.NewDecoder(bytes.NewReader(document))
var paragraph strings.Builder
type rawNumberedParagraph struct {
Level int
NumID string
Text string
}
var numbered []rawNumberedParagraph
inParagraph, inText, inCell, inParagraphBorder := false, false, false, false
paragraphLevel, paragraphNumID, cellAlignment := 0, "", ""
paragraphRule := false
// A TOC is its field's paragraphs plus an immediately preceding caption
// paragraph excluded from the outline (outlineLvl 9).
inInstruction, paragraphTOC, paragraphCaption := false, false, false
fieldDepth, tocFieldDepth := 0, 0
var pendingCaption []string
var table [][]string
var row []string
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return docxComparison{}, err
}
switch value := token.(type) {
case xml.StartElement:
switch value.Name.Local {
case "p":
inParagraph = true
paragraph.Reset()
paragraphLevel, paragraphNumID, paragraphRule = 0, "", false
paragraphTOC, paragraphCaption = tocFieldDepth > 0, false
case "t":
if inParagraph {
inText = true
}
case "outlineLvl":
if inParagraph && xmlAttributeValue(value.Attr, "val") == "9" {
paragraphCaption = true
}
case "fldChar":
switch xmlAttributeValue(value.Attr, "fldCharType") {
case "begin":
fieldDepth++
case "end":
if fieldDepth == tocFieldDepth {
tocFieldDepth = 0
}
fieldDepth = max(fieldDepth-1, 0)
}
case "instrText":
inInstruction = true
case "numId":
if inParagraph {
paragraphNumID = xmlAttributeValue(value.Attr, "val")
}
case "ilvl":
if inParagraph {
paragraphLevel, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val"))
}
case "pBdr":
if inParagraph {
inParagraphBorder = true
}
case "bottom":
if inParagraphBorder {
paragraphRule = true
}
case "tbl":
table = nil
case "tr":
row = nil
case "tc":
inCell, cellAlignment = true, ""
case "jc":
if inCell {
cellAlignment = xmlAttributeValue(value.Attr, "val")
}
case "pgSz":
copyAttributes(result.PageSize, value.Attr, "w", "h", "orient")
case "pgMar":
copyAttributes(result.Margins, value.Attr, "top", "right", "bottom", "left", "header", "footer", "gutter")
case "bookmarkStart":
result.Bookmarks++
}
case xml.CharData:
if inText {
paragraph.Write(value)
}
if inInstruction && tocFieldDepth == 0 && fieldDepth > 0 {
if fields := strings.Fields(string(value)); len(fields) > 0 && strings.EqualFold(fields[0], "TOC") {
tocFieldDepth, paragraphTOC = fieldDepth, inParagraph
}
}
case xml.EndElement:
switch value.Name.Local {
case "t":
inText = false
case "instrText":
inInstruction = false
case "pBdr":
inParagraphBorder = false
case "p":
text := normalizeComparisonText(paragraph.String())
if paragraphTOC {
result.TOC = append(result.TOC, pendingCaption...)
pendingCaption = nil
if text != "" {
result.TOC = append(result.TOC, text)
}
inParagraph = false
break
}
result.Paragraphs = append(result.Paragraphs, pendingCaption...)
pendingCaption = nil
if text != "" && paragraphCaption {
pendingCaption = []string{text}
} else if text != "" {
result.Paragraphs = append(result.Paragraphs, text)
}
if paragraphNumID != "" {
numbered = append(numbered, rawNumberedParagraph{Level: paragraphLevel, NumID: paragraphNumID, Text: text})
switch {
case strings.HasPrefix(text, "☑"):
result.Tasks = append(result.Tasks, comparisonTask{Text: normalizeComparisonText(strings.TrimSpace(strings.TrimPrefix(text, "☑"))), Checked: "true"})
case strings.HasPrefix(text, "☐"):
result.Tasks = append(result.Tasks, comparisonTask{Text: normalizeComparisonText(strings.TrimSpace(strings.TrimPrefix(text, "☐"))), Checked: "false"})
}
}
if paragraphRule {
result.ThematicRules++
}
inParagraph = false
case "tc":
if cellAlignment == "" {
cellAlignment = "left"
}
row = append(row, cellAlignment)
inCell = false
case "tr":
table = append(table, row)
case "tbl":
result.TableAlignments = append(result.TableAlignments, table)
}
}
}
result.Paragraphs = append(result.Paragraphs, pendingCaption...)
if len(numbered) > 0 {
numbering, found := parts["word/numbering.xml"]
if !found {
return docxComparison{}, errors.New("word/numbering.xml missing")
}
definitions, instances, err := inspectDOCXNumbering(numbering)
if err != nil {
return docxComparison{}, err
}
for _, paragraph := range numbered {
instance, found := instances[paragraph.NumID]
if !found {
return docxComparison{}, fmt.Errorf("numbering instance %s missing", paragraph.NumID)
}
level := definitions[instance.AbstractID][paragraph.Level]
start := level.Start
if start == 0 {
start = 1
}
if override, found := instance.Overrides[paragraph.Level]; found {
start = override
}
result.Numbering = append(result.Numbering, docxNumbering{Level: paragraph.Level, Format: level.Format, Start: start})
}
}
for name, content := range parts {
lower := strings.ToLower(name)
if strings.HasPrefix(lower, "word/header") && strings.HasSuffix(lower, ".xml") {
result.Headers++
}
if strings.HasPrefix(lower, "word/footer") && strings.HasSuffix(lower, ".xml") {
result.Footers++
}
if strings.HasSuffix(lower, ".rels") {
result.ExternalLinks += strings.Count(strings.ToLower(string(content)), `targetmode="external"`)
}
if strings.Contains(lower, "vba") || strings.Contains(lower, "activex") || strings.Contains(lower, "embeddings") {
result.ForbiddenObject = true
}
}
xmlText := string(document)
result.Semantics = map[string]bool{
"bold": strings.Contains(xmlText, "<w:b"), "italic": strings.Contains(xmlText, "<w:i"),
"strike": strings.Contains(xmlText, "<w:strike"), "inline-code": strings.Contains(xmlText, "inlineCode") || strings.Contains(xmlText, "Consolas"),
"link-label": strings.Contains(xmlText, `w:val="link"`) || strings.Contains(xmlText, `w:val="Hyperlink"`),
"quote": strings.Contains(strings.ToLower(xmlText), `w:val="quote"`), "code-block": strings.Contains(strings.ToLower(xmlText), `w:val="code`),
"list": strings.Contains(xmlText, "<w:numPr>"), "table": strings.Contains(xmlText, "<w:tbl>"),
}
return result, nil
}
type docxNumberingLevel struct {
Format string
Start int
}
type docxNumberingInstance struct {
AbstractID string
Overrides map[int]int
}
func inspectDOCXNumbering(raw []byte) (map[string]map[int]docxNumberingLevel, map[string]docxNumberingInstance, error) {
definitions := map[string]map[int]docxNumberingLevel{}
instances := map[string]docxNumberingInstance{}
decoder := xml.NewDecoder(bytes.NewReader(raw))
currentAbstract, currentInstance := "", ""
currentLevel, currentOverride := -1, -1
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
return definitions, instances, nil
}
if err != nil {
return nil, nil, err
}
switch value := token.(type) {
case xml.StartElement:
switch value.Name.Local {
case "abstractNum":
currentAbstract = xmlAttributeValue(value.Attr, "abstractNumId")
definitions[currentAbstract] = map[int]docxNumberingLevel{}
case "lvl":
if currentAbstract != "" {
currentLevel, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "ilvl"))
}
case "start":
if currentAbstract != "" && currentLevel >= 0 {
level := definitions[currentAbstract][currentLevel]
level.Start, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val"))
definitions[currentAbstract][currentLevel] = level
}
case "numFmt":
if currentAbstract != "" && currentLevel >= 0 {
level := definitions[currentAbstract][currentLevel]
level.Format = xmlAttributeValue(value.Attr, "val")
definitions[currentAbstract][currentLevel] = level
}
case "num":
currentInstance = xmlAttributeValue(value.Attr, "numId")
instances[currentInstance] = docxNumberingInstance{Overrides: map[int]int{}}
case "abstractNumId":
if currentInstance != "" {
instance := instances[currentInstance]
instance.AbstractID = xmlAttributeValue(value.Attr, "val")
instances[currentInstance] = instance
}
case "lvlOverride":
if currentInstance != "" {
currentOverride, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "ilvl"))
}
case "startOverride":
if currentInstance != "" && currentOverride >= 0 {
instance := instances[currentInstance]
instance.Overrides[currentOverride], _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val"))
instances[currentInstance] = instance
}
}
case xml.EndElement:
switch value.Name.Local {
case "lvl":
currentLevel = -1
case "abstractNum":
currentAbstract = ""
case "lvlOverride":
currentOverride = -1
case "num":
currentInstance = ""
}
}
}
}
func xmlAttributeValue(attributes []xml.Attr, name string) string {
for _, attribute := range attributes {
if attribute.Name.Local == name {
return attribute.Value
}
}
return ""
}
func copyAttributes(target map[string]string, attributes []xml.Attr, names ...string) {
wanted := make(map[string]bool, len(names))
for _, name := range names {
wanted[name] = true
}
for _, attribute := range attributes {
if wanted[attribute.Name.Local] {
target[attribute.Name.Local] = attribute.Value
}
}
}
func equalJSON(left, right any) bool { return bytes.Equal(canonicalJSON(left), canonicalJSON(right)) }
func digestJSON(value any) string { return digestBytes(canonicalJSON(value)) }
func canonicalJSON(value any) []byte {
raw, _ := json.Marshal(value)
return raw
}
func digestBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}
+4 -2
View File
@@ -18,8 +18,10 @@ import (
"strings"
)
// Build 生成 docx 字节。title 为文档标题;为空时取首个标题块,再无则不带 Title 段。
func Build(title, markdown string) ([]byte, error) {
// legacyBuild is retained only for an explicitly enabled migration comparison.
// Production callers must use Generate and never fall back here after a shared
// renderer failure.
func legacyBuild(title, markdown string) ([]byte, error) {
doc := parseMarkdown(markdown)
title = strings.TrimSpace(title)
if title == "" {
+23 -18
View File
@@ -9,42 +9,47 @@ import (
"testing"
)
// 样例正文:会议纪要形态(标题/表格/列表/任务项/引用/链接/删除线/分隔线)。
// 样例正文:会议纪要形态,也是 legacy/shared 唯一完整语义奇偶性夹具。
// 行内代码用 \x60(反引号)拼接——raw string 里不能直接写字面反引号。
const sampleMinutes = "# 研发周会纪要\n" + `
## 一、基本信息
| 项目 | 内容 |
|---|---|
| 时间 | 2026-09-30 14:00 |
| 地点 | 3F 会议室 |
| 主持人 | 陈威 |
正文含 **粗体**、*强调*、~~删除~~、` + "\x60inline\x60" + ` 和 [安全标签](https://example.com/private-target)。
## 二、讨论要点
- **架构**:docx 生成走后端转换,见 [方案](https://example.com/plan)
- 排版要求:标题层级、表格、列表
- 二级要点:交付物卡片
- **架构**:共享渲染器
- 排版要求
1. 有序子项
- 深层无序子项
- [x] 已完成:预览装配
- [ ] 待办:样式打磨
> 会议结论:按周五节点交付。
` + "\x60\x60\x60go" + `
fmt.Println("backtick")
` + "\x60\x60\x60" + `
~~~go
fmt.Println("tilde")
~~~~
## 三、决议
1. 采用 write_docx 工具
2. 对话内出交付物卡片
` + "\x60write_docx\x60 与 ~~write_artifact~~ 的分工见上文。" + `
3. 第三项
4. 第四项
---
后续:周报合并。
| 左 | 中 | 右 | 默认 |
| :--- | :---: | ---: | --- |
| L | C | R | D |
`
func buildSample(t *testing.T) []byte {
t.Helper()
b, err := Build("", sampleMinutes)
b, err := legacyBuild("", sampleMinutes)
if err != nil {
t.Fatalf("Build: %v", err)
}
@@ -166,13 +171,13 @@ func TestBuildHyperlinkRels(t *testing.T) {
rc.Close()
}
}
if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/plan") {
if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/private-target") {
t.Errorf("超链接 relationship 缺失: %s", rels)
}
}
func TestBuildEscapesXML(t *testing.T) {
b, err := Build("标题<a>&'", "包含 <标签> & \"引号\" 的正文")
b, err := legacyBuild("标题<a>&'", "包含 <标签> & \"引号\" 的正文")
if err != nil {
t.Fatalf("Build: %v", err)
}
@@ -202,7 +207,7 @@ func TestBuildEscapesXML(t *testing.T) {
func TestBuildPlainTextFallback(t *testing.T) {
// 无任何 Markdown 结构的纯文本:按段落输出,不 panic、产物良构。
b, err := Build("", "第一行\n第二行\n\n第二段")
b, err := legacyBuild("", "第一行\n第二行\n\n第二段")
if err != nil {
t.Fatalf("Build: %v", err)
}
@@ -0,0 +1,39 @@
package main
import (
"bytes"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func main() {
check := flag.Bool("check", false, "fail when the generated identity differs from the checked-out renderer")
flag.Parse()
repository := gitOutput("rev-parse", "--show-toplevel")
revision := gitOutput("-C", filepath.Join(repository, "deps", "agent-document-renderer"), "rev-parse", "HEAD")
content := []byte(fmt.Sprintf("// Code generated from deps/agent-document-renderer; DO NOT EDIT.\n\npackage docgen\n\nconst rendererRevision = %q\n\nconst RendererIdentity = \"agent-document-renderer@\" + rendererRevision\n", revision))
target := filepath.Join(repository, "internal", "docgen", "renderer_identity_generated.go")
if *check {
current, err := os.ReadFile(target)
if err != nil || !bytes.Equal(current, content) {
fmt.Fprintf(os.Stderr, "renderer identity is stale for %s\n", revision)
os.Exit(1)
}
return
}
if err := os.WriteFile(target, content, 0o644); err != nil {
panic(err)
}
}
func gitOutput(arguments ...string) string {
output, err := exec.Command("git", arguments...).Output()
if err != nil {
panic(err)
}
return strings.TrimSpace(string(output))
}
+40
View File
@@ -0,0 +1,40 @@
package docgen
import (
"time"
"agent-document-renderer/render"
)
type OperationRequest struct {
OperationID string `json:"operationId"`
DocumentID string `json:"documentId"`
Title string `json:"name"`
DocumentType string `json:"documentType,omitempty"`
Language string `json:"language,omitempty"`
Markdown []byte `json:"-"`
Mode render.Mode `json:"mode"`
Controlled bool `json:"controlled"`
ProjectDocument bool `json:"projectDocument"`
}
type ReceiptArtifact struct {
Role string `json:"role"`
ID string `json:"id"`
Name string `json:"name"`
MIME string `json:"mime"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type OperationReceipt struct {
OperationID string `json:"operationId"`
Outcome string `json:"outcome"`
State string `json:"state"`
FrozenAt time.Time `json:"frozenAt"`
DeliveryKey string `json:"deliveryKey,omitempty"`
Renderer string `json:"rendererIdentity"`
CacheHit bool `json:"cacheHit,omitempty"`
Artifacts []ReceiptArtifact `json:"artifacts,omitempty"`
Error string `json:"error,omitempty"`
}
+579
View File
@@ -0,0 +1,579 @@
package docgen
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
markdownrenderer "agent-document-renderer/markdown"
"agent-document-renderer/model"
"agent-document-renderer/profile"
"agent-document-renderer/profiles"
"agent-document-renderer/render"
"agent-document-renderer/word"
)
const (
WorkspaceProjectID = "workspace"
ManifestSchemaVersion = "backend-document-delivery/v1"
AdapterIdentity = RendererIdentity + "/word-adapter-v1"
defaultProfileID = "company.default"
defaultRenderCapacity = 4
)
var renderCapacity = make(chan struct{}, defaultRenderCapacity)
var (
ErrRDMRequired = errors.New("docgen: controlled or project document requires RDM")
ErrProfileMismatch = errors.New("docgen: organization-default profile mismatch")
ErrResourceLimit = errors.New("docgen: resource limit exceeded")
)
type Artifact struct {
Name string
MIME string
SHA256 string
Bytes []byte
}
type Request struct {
DocumentID string
Title string
DocumentType string
Language string
Markdown []byte
Mode render.Mode
FrozenAt time.Time
RequestedBy string
ProfileID string
ProfileVersion string
Controlled bool
ProjectDocument bool
LegacySource []byte
Limits Limits
}
type Limits struct {
MaxMarkdownBytes int
MaxNodes int
MaxDepth int
}
type DiagnosticError struct {
Stage string
Code string
Diagnostics []render.Diagnostic
Err error
}
func (failure *DiagnosticError) Error() string {
if failure == nil {
return "docgen: diagnostic failure"
}
return fmt.Sprintf("docgen: %s: %s: %v", failure.Stage, failure.Code, failure.Err)
}
func (failure *DiagnosticError) Unwrap() error {
if failure == nil {
return nil
}
return failure.Err
}
type ComparisonDimension struct {
Name string `json:"name"`
Status string `json:"status"`
LegacyHash string `json:"legacyHash,omitempty"`
SharedHash string `json:"sharedHash,omitempty"`
ExceptionID string `json:"exceptionId,omitempty"`
Detail string `json:"detail,omitempty"`
}
type ComparisonReceipt struct {
Enabled bool `json:"enabled"`
Assessment string `json:"assessment,omitempty"`
LegacySHA256 string `json:"legacySha256,omitempty"`
SharedSHA256 string `json:"sharedSha256,omitempty"`
Dimensions []ComparisonDimension `json:"dimensions,omitempty"`
IntentionalDifferences []string `json:"intentionalDifferences,omitempty"`
AccidentalDifferences []string `json:"accidentalDifferences,omitempty"`
Unassessed []string `json:"unassessed,omitempty"`
}
type RenderOptions struct {
MigrationComparison bool
}
type ManifestArtifact struct {
Name string `json:"name"`
MIME string `json:"mime"`
Size int `json:"size"`
SHA256 string `json:"sha256"`
}
type ProfileReceipt struct {
ProfileID string `json:"profileId"`
Version string `json:"version"`
SnapshotHash string `json:"snapshotHash"`
Candidate bool `json:"candidate"`
ReviewStatus string `json:"reviewStatus"`
PublicationAllowed bool `json:"publicationAllowed"`
Unverified []string `json:"unverified"`
ProfileSHA256 string `json:"profileSha256"`
AssetManifestSHA256 string `json:"assetManifestSha256"`
ReviewSHA256 string `json:"reviewSha256"`
}
type Manifest struct {
SchemaVersion string `json:"schemaVersion"`
RendererIdentity string `json:"rendererIdentity"`
AdapterIdentity string `json:"adapterIdentity"`
SelectionSource string `json:"selectionSource"`
Profile ProfileReceipt `json:"profile"`
DocumentRootID string `json:"documentRootId"`
Artifacts []ManifestArtifact `json:"artifacts"`
Renderer render.Manifest `json:"renderer"`
Diagnostics []render.Diagnostic `json:"diagnostics,omitempty"`
NodeAnchors map[string]string `json:"nodeAnchors"`
Comparison ComparisonReceipt `json:"comparison"`
}
type Delivery struct {
Document model.CanonicalDocument
Profile ProfileReceipt
Renderer render.Manifest
Markdown Artifact
DOCX Artifact
Manifest Artifact
Archive Artifact
Comparison ComparisonReceipt
}
type PreparedDocument struct {
Document model.CanonicalDocument
Markdown Artifact
CanonicalHash string
}
// Prepare canonicalizes and validates authoritative Markdown without loading a
// profile or invoking the Word adapter. Durable callers persist its Markdown
// artifact before any rendering work.
func Prepare(ctx context.Context, request Request) (PreparedDocument, error) {
if err := contextErr(ctx); err != nil {
return PreparedDocument{}, err
}
if request.Controlled || request.ProjectDocument || request.Mode == render.ModeReview || request.Mode == render.ModeApproved {
return PreparedDocument{}, ErrRDMRequired
}
if request.Mode != render.ModeWorking && request.Mode != render.ModePreview {
return PreparedDocument{}, fmt.Errorf("docgen: unsupported ordinary workspace mode %q", request.Mode)
}
limits, err := normalizeLimits(request.Limits)
if err != nil {
return PreparedDocument{}, err
}
if len(request.Markdown) > limits.MaxMarkdownBytes {
return PreparedDocument{}, fmt.Errorf("%w: Markdown is %d bytes, limit is %d", ErrResourceLimit, len(request.Markdown), limits.MaxMarkdownBytes)
}
materialized := bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---"))
var parsed markdownrenderer.Result
if materialized {
parsed, err = parseMarkdownContext(ctx, request.Markdown, false, limits)
if err != nil {
return PreparedDocument{}, classifyParseError(err)
}
if err := validateWorkspaceRoute(request, parsed.Metadata); err != nil {
return PreparedDocument{}, err
}
if _, err := profileVersionFor(request, &parsed.Metadata); err != nil {
return PreparedDocument{}, err
}
} else {
profileVersion, err := profileVersionFor(request, nil)
if err != nil {
return PreparedDocument{}, err
}
source, err := workspaceMarkdown(request, defaultProfileID, profileVersion)
if err != nil {
return PreparedDocument{}, err
}
if len(source) > limits.MaxMarkdownBytes {
return PreparedDocument{}, fmt.Errorf("%w: normalized Markdown source is %d bytes, limit is %d", ErrResourceLimit, len(source), limits.MaxMarkdownBytes)
}
parsed, err = parseMarkdownContext(ctx, source, true, limits)
if err != nil {
return PreparedDocument{}, classifyParseError(err)
}
}
if len(parsed.Markdown) > limits.MaxMarkdownBytes {
return PreparedDocument{}, fmt.Errorf("%w: materialized Markdown is %d bytes, limit is %d", ErrResourceLimit, len(parsed.Markdown), limits.MaxMarkdownBytes)
}
canonicalHash, err := parsed.Document.HashContext(ctx)
if err != nil {
return PreparedDocument{}, err
}
return PreparedDocument{
Document: parsed.Document, CanonicalHash: canonicalHash,
Markdown: artifact(safeBaseName(request.Title)+".md", "text/markdown; charset=utf-8", parsed.Markdown),
}, nil
}
type generatorDependencies struct {
loadProfile func(context.Context, string) (profiles.Bundle, error)
newRenderer func(*profile.AssetReader) render.Renderer
legacyBuild func(string, string) ([]byte, error)
}
func defaultGeneratorDependencies() generatorDependencies {
return generatorDependencies{
loadProfile: profiles.LoadCompanyDefaultVersionContext,
newRenderer: func(assets *profile.AssetReader) render.Renderer { return word.NewAdapter(assets) },
legacyBuild: legacyBuild,
}
}
func Generate(ctx context.Context, request Request) (Delivery, error) {
return generate(ctx, request, defaultGeneratorDependencies())
}
func generate(ctx context.Context, request Request, dependencies generatorDependencies) (Delivery, error) {
return generateWithOptions(ctx, request, RenderOptions{}, dependencies)
}
func GenerateWithOptions(ctx context.Context, request Request, options RenderOptions) (Delivery, error) {
return generateWithOptions(ctx, request, options, defaultGeneratorDependencies())
}
func generateWithOptions(ctx context.Context, request Request, options RenderOptions, dependencies generatorDependencies) (Delivery, error) {
if err := contextErr(ctx); err != nil {
return Delivery{}, err
}
if request.Controlled || request.ProjectDocument || request.Mode == render.ModeReview || request.Mode == render.ModeApproved {
return Delivery{}, ErrRDMRequired
}
if request.Mode != render.ModeWorking && request.Mode != render.ModePreview {
return Delivery{}, fmt.Errorf("docgen: unsupported ordinary workspace mode %q", request.Mode)
}
limits, err := normalizeLimits(request.Limits)
if err != nil {
return Delivery{}, err
}
if len(request.Markdown) > limits.MaxMarkdownBytes {
return Delivery{}, fmt.Errorf("%w: Markdown is %d bytes, limit is %d", ErrResourceLimit, len(request.Markdown), limits.MaxMarkdownBytes)
}
var parsed markdownrenderer.Result
var boundMetadata *markdownrenderer.Metadata
materialized := bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---"))
if materialized {
parsed, err = parseMarkdownContext(ctx, request.Markdown, false, limits)
if err != nil {
return Delivery{}, classifyParseError(err)
}
if err := validateWorkspaceRoute(request, parsed.Metadata); err != nil {
return Delivery{}, err
}
boundMetadata = &parsed.Metadata
}
boundVersion, err := profileVersionFor(request, boundMetadata)
if err != nil {
return Delivery{}, err
}
bundle, err := dependencies.loadProfile(ctx, boundVersion)
if err != nil {
return Delivery{}, fmt.Errorf("docgen: load company default %s: %w", boundVersion, err)
}
profileID, profileVersion := bundle.Snapshot.ProfileIdentity()
if profileID != defaultProfileID || profileVersion != boundVersion {
return Delivery{}, fmt.Errorf("%w: loaded %s@%s, bound %s@%s", ErrProfileMismatch,
profileID, profileVersion, defaultProfileID, boundVersion)
}
if !materialized {
source, sourceErr := workspaceMarkdown(request, profileID, profileVersion)
if sourceErr != nil {
return Delivery{}, sourceErr
}
if len(source) > limits.MaxMarkdownBytes {
return Delivery{}, fmt.Errorf("%w: normalized Markdown source is %d bytes, limit is %d", ErrResourceLimit, len(source), limits.MaxMarkdownBytes)
}
parsed, err = parseMarkdownContext(ctx, source, true, limits)
if err != nil {
return Delivery{}, classifyParseError(err)
}
}
if len(parsed.Markdown) > limits.MaxMarkdownBytes {
return Delivery{}, fmt.Errorf("%w: materialized Markdown is %d bytes, limit is %d", ErrResourceLimit, len(parsed.Markdown), limits.MaxMarkdownBytes)
}
if err := validateWorkspaceMetadata(request, parsed.Metadata, profileID, profileVersion); err != nil {
return Delivery{}, err
}
if err := contextErr(ctx); err != nil {
return Delivery{}, err
}
if err := acquireRenderCapacity(ctx); err != nil {
return Delivery{}, err
}
result, err := dependencies.newRenderer(bundle.Assets).Render(ctx, render.Request{
Document: parsed.Document,
Profile: bundle.Snapshot,
Mode: request.Mode,
FrozenAt: request.FrozenAt,
RequestedBy: request.RequestedBy,
})
releaseRenderCapacity()
if err != nil {
code := "renderer_failed"
if strings.Contains(err.Error(), "asset") || strings.Contains(err.Error(), "hash") {
code = "asset_mismatch"
}
return Delivery{}, diagnosticFailure("render", code, err, result.Diagnostics)
}
base := safeBaseName(request.Title)
delivery := Delivery{
Document: parsed.Document, Renderer: result.Manifest,
Profile: ProfileReceipt{
ProfileID: profileID, Version: profileVersion, SnapshotHash: bundle.Snapshot.SnapshotHash(),
Candidate: bundle.Snapshot.State() != profile.StatePublished, ReviewStatus: bundle.Review.Status, PublicationAllowed: bundle.Review.PublicationAllowed,
Unverified: append([]string(nil), bundle.Review.Unverified...), ProfileSHA256: bundle.Review.ProfileSHA256,
AssetManifestSHA256: bundle.Review.AssetManifestSHA256, ReviewSHA256: bundle.Review.ReviewSHA256,
},
Markdown: artifact(base+".md", "text/markdown; charset=utf-8", parsed.Markdown),
DOCX: artifact(base+".docx", result.MIME, result.Artifact),
}
if options.MigrationComparison {
comparisonSource := request.LegacySource
if len(comparisonSource) == 0 {
comparisonSource = request.Markdown
}
legacy, err := dependencies.legacyBuild(request.Title, string(comparisonSource))
if err != nil {
return Delivery{}, fmt.Errorf("docgen: legacy migration comparison: %w", err)
}
delivery.Comparison = compareLegacyAndShared(request.Title, comparisonSource, parsed.Document, legacy, delivery.DOCX.Bytes)
}
manifest := Manifest{
SchemaVersion: ManifestSchemaVersion, RendererIdentity: RendererIdentity, AdapterIdentity: AdapterIdentity,
SelectionSource: "organization-default",
Profile: delivery.Profile,
DocumentRootID: parsed.Document.Root.ID,
Artifacts: []ManifestArtifact{
manifestArtifact(delivery.Markdown), manifestArtifact(delivery.DOCX),
},
Renderer: result.Manifest, Diagnostics: append([]render.Diagnostic(nil), result.Diagnostics...),
NodeAnchors: cloneStringMap(result.NodeAnchors), Comparison: delivery.Comparison,
}
manifestRaw, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return Delivery{}, fmt.Errorf("docgen: marshal manifest: %w", err)
}
manifestRaw = append(manifestRaw, '\n')
delivery.Manifest = artifact(base+".manifest.json", "application/json", manifestRaw)
archiveRaw, err := buildArchive(delivery.Markdown, delivery.DOCX, delivery.Manifest)
if err != nil {
return Delivery{}, err
}
delivery.Archive = artifact(base+".zip", "application/zip", archiveRaw)
return delivery, nil
}
func acquireRenderCapacity(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case renderCapacity <- struct{}{}:
return nil
}
}
func releaseRenderCapacity() { <-renderCapacity }
func parseMarkdownContext(ctx context.Context, source []byte, firstImport bool, limits Limits) (markdownrenderer.Result, error) {
return markdownrenderer.ParseContext(ctx, source, markdownrenderer.Options{
Controlled: true, FirstImport: firstImport, MaxBytes: limits.MaxMarkdownBytes,
MaxNodes: limits.MaxNodes, MaxDepth: limits.MaxDepth,
})
}
func classifyParseError(err error) error {
if errors.Is(err, markdownrenderer.ErrDocumentLimit) {
return fmt.Errorf("%w: %v", ErrResourceLimit, err)
}
code := "markdown_invalid"
switch {
case errors.Is(err, markdownrenderer.ErrUnknownMetadata):
code = "unknown_metadata"
case errors.Is(err, markdownrenderer.ErrHiddenHTML):
code = "unsafe_markdown"
case errors.Is(err, markdownrenderer.ErrUnsafeDirective) && strings.Contains(err.Error(), "unknown directive"):
code = "unknown_directive"
case errors.Is(err, markdownrenderer.ErrUnsafeDirective):
code = "unsafe_markdown"
}
return diagnosticFailure("parse", code, err, nil)
}
func diagnosticFailure(stage, code string, err error, diagnostics []render.Diagnostic) error {
cloned := append([]render.Diagnostic(nil), diagnostics...)
if len(cloned) == 0 {
cloned = []render.Diagnostic{{Code: code, Severity: render.SeverityError, Message: err.Error()}}
}
return &DiagnosticError{Stage: stage, Code: code, Diagnostics: cloned, Err: err}
}
func normalizeLimits(input Limits) (Limits, error) {
if input.MaxMarkdownBytes < 0 || input.MaxNodes < 0 || input.MaxDepth < 0 {
return Limits{}, fmt.Errorf("%w: limits cannot be negative", ErrResourceLimit)
}
if input.MaxMarkdownBytes == 0 {
input.MaxMarkdownBytes = 2 << 20
}
if input.MaxNodes == 0 {
input.MaxNodes = 10_000
}
if input.MaxDepth == 0 {
input.MaxDepth = 64
}
return input, nil
}
func workspaceMarkdown(request Request, profileID, profileVersion string) ([]byte, error) {
if strings.TrimSpace(request.DocumentID) == "" || strings.TrimSpace(request.Title) == "" ||
strings.TrimSpace(request.DocumentType) == "" || strings.TrimSpace(request.Language) == "" {
return nil, errors.New("docgen: document ID, title, document type, and language are required")
}
if request.Mode == "" || request.FrozenAt.IsZero() || strings.TrimSpace(request.RequestedBy) == "" {
return nil, errors.New("docgen: mode, frozen time, and requester are required")
}
quote := func(value string) string {
raw, _ := json.Marshal(value)
return string(raw)
}
var header strings.Builder
header.WriteString("---\n")
fmt.Fprintf(&header, "documentId: %s\n", quote(request.DocumentID))
fmt.Fprintf(&header, "projectId: %s\n", quote(WorkspaceProjectID))
fmt.Fprintf(&header, "documentType: %s\n", quote(request.DocumentType))
fmt.Fprintf(&header, "title: %s\n", quote(request.Title))
fmt.Fprintf(&header, "language: %s\n", quote(request.Language))
fmt.Fprintf(&header, "profileId: %s\n", quote(profileID))
fmt.Fprintf(&header, "profileVersion: %s\n", quote(profileVersion))
header.WriteString("controlled: false\n---\n\n")
header.Write(bytes.TrimSpace(request.Markdown))
header.WriteByte('\n')
return []byte(header.String()), nil
}
func validateWorkspaceRoute(request Request, metadata markdownrenderer.Metadata) error {
if metadata.Controlled || metadata.ProjectID != WorkspaceProjectID {
return ErrRDMRequired
}
if metadata.DocumentID != request.DocumentID || metadata.DocumentType != request.DocumentType ||
metadata.Title != request.Title || metadata.Language != request.Language {
return errors.New("docgen: persisted Markdown metadata does not match the frozen request")
}
return nil
}
// profileVersionFor returns the company-default version a document renders
// with. Published versions are immutable, so a version the persisted Markdown
// or the request already binds is kept; only a new, unbound document takes the
// latest published version. metadata is nil until Markdown is materialized.
func profileVersionFor(request Request, metadata *markdownrenderer.Metadata) (string, error) {
requested := request.ProfileID != "" || request.ProfileVersion != ""
if requested && !publishedProfileVersion(request.ProfileID, request.ProfileVersion) {
return "", fmt.Errorf("%w: requested %s@%s is not a published %s version", ErrProfileMismatch,
request.ProfileID, request.ProfileVersion, defaultProfileID)
}
if metadata == nil {
if requested {
return request.ProfileVersion, nil
}
return profiles.LatestCompanyDefaultVersion, nil
}
if !publishedProfileVersion(metadata.ProfileID, metadata.ProfileVersion) {
return "", fmt.Errorf("%w: Markdown binds %s@%s, which is not a published %s version", ErrProfileMismatch,
metadata.ProfileID, metadata.ProfileVersion, defaultProfileID)
}
if requested && request.ProfileVersion != metadata.ProfileVersion {
return "", fmt.Errorf("%w: requested %s@%s, Markdown binds %s@%s", ErrProfileMismatch,
request.ProfileID, request.ProfileVersion, metadata.ProfileID, metadata.ProfileVersion)
}
return metadata.ProfileVersion, nil
}
func publishedProfileVersion(profileID, profileVersion string) bool {
return profileID == defaultProfileID && slices.Contains(profiles.CompanyDefaultVersions(), profileVersion)
}
func validateWorkspaceMetadata(request Request, metadata markdownrenderer.Metadata, profileID, profileVersion string) error {
if err := validateWorkspaceRoute(request, metadata); err != nil {
return err
}
if metadata.ProfileID != profileID || metadata.ProfileVersion != profileVersion {
return fmt.Errorf("%w: Markdown binds %s@%s, available %s@%s", ErrProfileMismatch,
metadata.ProfileID, metadata.ProfileVersion, profileID, profileVersion)
}
return nil
}
func artifact(name, mime string, raw []byte) Artifact {
sum := sha256.Sum256(raw)
// Callers transfer ownership of freshly produced buffers to the delivery.
return Artifact{Name: name, MIME: mime, SHA256: hex.EncodeToString(sum[:]), Bytes: raw}
}
func manifestArtifact(value Artifact) ManifestArtifact {
return ManifestArtifact{Name: value.Name, MIME: value.MIME, Size: len(value.Bytes), SHA256: value.SHA256}
}
func buildArchive(artifacts ...Artifact) ([]byte, error) {
items := append([]Artifact(nil), artifacts...)
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
var buffer bytes.Buffer
archive := zip.NewWriter(&buffer)
for _, item := range items {
header := &zip.FileHeader{Name: item.Name, Method: zip.Deflate}
header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC))
header.SetMode(0o644)
writer, err := archive.CreateHeader(header)
if err != nil {
return nil, fmt.Errorf("docgen: create archive entry %s: %w", item.Name, err)
}
if _, err := writer.Write(item.Bytes); err != nil {
return nil, fmt.Errorf("docgen: write archive entry %s: %w", item.Name, err)
}
}
if err := archive.Close(); err != nil {
return nil, fmt.Errorf("docgen: close delivery archive: %w", err)
}
return buffer.Bytes(), nil
}
func cloneStringMap(source map[string]string) map[string]string {
clone := make(map[string]string, len(source))
for key, value := range source {
clone[key] = value
}
return clone
}
func safeBaseName(title string) string {
return strings.TrimSuffix(SuggestName(title), ".docx")
}
func contextErr(ctx context.Context) error {
select {
case <-ctx.Done():
return fmt.Errorf("docgen: render canceled: %w", ctx.Err())
default:
return nil
}
}

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