Compare commits

...
Author SHA1 Message Date
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 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 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 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 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 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 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 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
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
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
322 changed files with 30794 additions and 962 deletions
+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)
}
}
+273 -27
View File
@@ -9,18 +9,28 @@ 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/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":
@@ -31,6 +41,14 @@ 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:]))
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> [参数]")
os.Exit(2)
}
}
fs := flag.NewFlagSet("serve", flag.ExitOnError)
@@ -178,22 +196,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)
@@ -222,55 +265,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
@@ -278,8 +488,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)
}
+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)
}
}
+22
View File
@@ -45,6 +45,18 @@ 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)
@@ -61,6 +73,16 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) {
}
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)
+24 -3
View File
@@ -281,16 +281,23 @@ func cmdBackup(args []string) error {
// copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。
// admission_leases 是运行态执行许可(迁移无意义),明确排除。
var copyOrder = []string{
"projects", "users", "profiles", "settings", "plugins",
"projects", "users", "account_directory_revision", "profiles", "settings", "plugins",
"conversations", "runs", "messages", "events", "artifacts",
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
"activity_cards", "command_audit", "prompt_modules",
"activity_cards", "project_event_shares", "command_audit", "prompt_modules",
"sessions", "api_keys",
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks",
"memory_entries", "audit_events", "pending_confirms", "llm_usage_daily",
"secret_keys", "settings_snapshots", "regen_pending", "context_entries",
"user_commands", "pending_notices", "recovery_holds",
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
// v26 login_keys(#171 合入 main 时漏登记;本 PR 一并补齐)
"login_keys",
// U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚)
"agent_system_identity",
"account_events",
"redeem_attempts",
"recovery_proposals", "recovery_proposal_proofs",
}
const controlDDL = `
@@ -444,6 +451,11 @@ func guardIdentity(db *sql.DB, migrationID, snapID string, srcVersion int) error
func businessRows(db *sql.DB) (int64, error) {
var total int64
for _, t := range copyOrder {
// Schema setup seeds this singleton before a migration starts. It is
// replaced from the source during copying and is not target business data.
if t == "account_directory_revision" {
continue
}
var n int64
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
if strings.Contains(err.Error(), "does not exist") {
@@ -701,6 +713,14 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
return err
}
defer tx.Rollback()
// The destination migrator creates a fresh singleton directory revision at
// schema startup. Replace it with the source's opaque revision so existing
// cursors/visible-change history remain tied to the migrated account data.
if table == "account_directory_revision" {
if _, err := tx.Exec(`DELETE FROM account_directory_revision`); err != nil {
return err
}
}
stmt, err := tx.Prepare(fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, colList, placeholders))
if err != nil {
return err
@@ -778,7 +798,8 @@ func advanceSequences(db *sql.DB, table string) error {
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
func checkOwners(db *sql.DB, src *sql.DB) error {
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "command_audit"}
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards",
"project_event_shares", "command_audit", "sessions", "api_keys", "login_keys", "account_events", "recovery_proposals"}
for _, t := range ownerTables {
if !sqliteHasTable(src, t) {
continue
@@ -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.
+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")
}
}
+289 -90
View File
@@ -55,6 +55,7 @@ import (
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
@@ -199,6 +200,21 @@ func Run(opt Options) error {
return fmt.Errorf("open store: %w", err)
}
defer st.Close()
// U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" {
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
if dbref == "" {
if opt.StorageDSN != "" {
dbref = "pg"
} else {
dbref = "sqlite"
}
}
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
return fmt.Errorf("system identity: %w", err)
}
}
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)",
@@ -280,7 +296,12 @@ func Run(opt Options) error {
if owner == "" {
owner = store.OwnerDefault
}
return st.ActiveSecret(owner, service)
if v, ok := st.ActiveSecret(owner, service); ok {
return v, true
}
// P2 受管:密钥统一迁入 secrets 表后,系统层受管密钥挂在系统行
//(owner=settings.OwnerSystemRow);租户未单独配置时回退系统密钥。
return st.ActiveSecret(settings.OwnerSystemRow, service)
}
rag.SecretLookup = func(service, fallback string) string {
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
@@ -417,7 +438,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
}
}
@@ -426,6 +450,9 @@ 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{})
authOn := resolved.AuthEnabled()
effectiveAccount := accountEffectiveAuthorization(st, authOn)
mgr.SetAccountEffectiveAuthorization(effectiveAccount, !authOn)
instanceID := opt.InstanceID
if instanceID == "" {
host, err := os.Hostname()
@@ -471,6 +498,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)
@@ -497,13 +527,25 @@ 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.RecoverPendingEvents(context.Background()); err == nil && n > 0 {
if n, err := surfaceSvc.RecoverPendingEventsAccountChecked(context.Background(), !authOn); err == nil && n > 0 {
log.Printf("surface: recovered %d pending events", n)
}
}()
@@ -526,6 +568,17 @@ func Run(opt Options) error {
// 命令总线(提案 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) {
@@ -535,7 +588,11 @@ func Run(opt Options) error {
if resp.Receipt.State != "" {
state = string(resp.Receipt.State)
}
return surfacesvc.ActionOutcome{State: state, CardID: resp.Card.ID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
cardID := ""
if resp.Card != nil {
cardID = string(resp.Card.ID)
}
return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
})
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
@@ -564,7 +621,22 @@ func Run(opt Options) error {
pluginHost.OnCommands = bus.RegisterPluginCommands
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
authzSlot := &projectAuthz{}
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
projectSlot := &projectLifecycleSlot{}
directorySlot, err := newAccountDirectorySlot(st)
if err != nil {
return err
}
caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn,
accountGuard: func(owner string) error {
valid, err := effectiveAccount(context.Background(), owner)
if err != nil {
return err
}
if !valid {
return store.ErrAccountForbidden
}
return nil
}}
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
@@ -577,6 +649,16 @@ func Run(opt Options) error {
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
wireScopedHostFactory(caps)
compiledSummary := applyCompiled(skillsRoot, bus, caps)
mgr.SetProjectWorkAuthorization(projectSlot.work())
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)
@@ -590,6 +672,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)
@@ -601,26 +686,21 @@ 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)
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 在上方已建)。
@@ -655,7 +735,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>)")
@@ -700,48 +779,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
@@ -750,13 +886,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
}
@@ -764,55 +899,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 的整型缓存读(限流值热读取免打库)。
+34 -1
View File
@@ -64,7 +64,7 @@ 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, actor: req.Actor}
host := requestBoundHost{inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor}}
return h(ctx, host, extension.Request{
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
@@ -107,10 +107,26 @@ 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)
}
// RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
@@ -124,6 +140,15 @@ 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
@@ -136,6 +161,14 @@ type recordRegistry struct {
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) AddCommand(spec json.RawMessage) error {
if err := r.Registry.AddCommand(spec); err != nil {
return err
@@ -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)
}
}
+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"}
+251 -33
View File
@@ -4,6 +4,8 @@ import (
"agent-system-backend/pkg/extension"
"context"
"fmt"
"sort"
"strings"
"sync"
"agent-system-backend/internal/ingest"
@@ -19,18 +21,24 @@ 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 适配器槽(共享指针;首个注册者生效)
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
projects *projectLifecycleSlot
directory *accountDirectorySlot
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 = 未装配,明确拒绝)
}
// 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) {
@@ -42,7 +50,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
@@ -70,7 +112,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 {
@@ -91,10 +151,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)
}
@@ -124,6 +197,13 @@ type inprocHost struct {
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
}
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 }
@@ -138,36 +218,152 @@ func (h inprocHost) IsAdmin() bool {
if err != nil {
return false
}
return state.IsAdmin
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
}
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
}
@@ -177,54 +373,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
}
@@ -232,27 +435,42 @@ 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
}
@@ -264,11 +482,11 @@ func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRe
if err != nil {
return core.SurfaceSnapshot{}, err
}
return svc.Submit(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{
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) {
+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)
}
}
+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)
}
}
+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)
+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")
}
}
+219
View File
@@ -0,0 +1,219 @@
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
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
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)
}
}
+53 -5
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>")——
// 项目收件箱聚合视图(项目级卡 ∪ 各会话未决卡)的实时性依赖它;卡片事件
+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))
}
}
+187 -35
View File
@@ -49,6 +49,13 @@ type CommandTool struct {
// 不投影给该 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 }
@@ -108,17 +115,20 @@ type pending struct {
}
type Bus struct {
reg *agentcommand.Registry
audit AuditStore
cards CardPort
now func() time.Time
handlers map[string]Handler
slash map[string]string // "/x" → command id
pendMu sync.Mutex
pending map[string]pending
pendingStore PendingStore
intents *IntentStore // G2-2:意图声明表(自然语言路由)
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
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
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
@@ -144,6 +154,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}
@@ -242,10 +316,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)}
}
@@ -261,17 +341,24 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
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}
}
@@ -279,6 +366,9 @@ 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)
}
payload, err := h(ctx, req)
if err != nil {
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
@@ -289,49 +379,98 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
// 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),
@@ -341,7 +480,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()}
}
+5 -1
View File
@@ -222,7 +222,11 @@ func (r *fakeRegistry) Note(kind, name string, p json.RawMessage) error { return
func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
return nil
}
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
func (r *fakeRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error {
return nil
}
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil }
func TestPresentRejectsUnknownActionIDs(t *testing.T) {
+137
View File
@@ -0,0 +1,137 @@
package plugins
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"agent-system-backend/internal/store"
)
func TestLoadDirAccountCheckedRejectsDisabledActorWithoutEffects(t *testing.T) {
host, reg, dir := newHost(t)
if err := host.st.CreateAccount("admin", "", "hash", true); err != nil {
t.Fatal(err)
}
if err := host.st.CreateAccount("alice", "", "hash", false); err != nil {
t.Fatal(err)
}
writeExamplePlugin(t, dir, "example")
if err := host.LoadDir()["example"]; err != nil {
t.Fatal(err)
}
if _, _, ok := reg.Get("http_echo"); !ok {
t.Fatal("fixture plugin was not registered")
}
if err := os.RemoveAll(filepath.Join(dir, "example")); err != nil {
t.Fatal(err)
}
root := filepath.Join(dir, "side")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
mark := filepath.Join(t.TempDir(), "sidecar-ran")
manifest := `{"id":"side","name":"Side","version":"1.0.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["` + mark + `"],"schemaVersion":1},"provides":{}}`
if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
account, err := host.st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
if _, err := host.st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
if _, err := host.LoadDirAccountChecked("alice", false); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled reload: %v", err)
}
if _, _, ok := reg.Get("http_echo"); !ok || len(host.List()) != 1 {
t.Fatal("disabled reload changed the existing process registry")
}
if _, err := os.Stat(mark); !os.IsNotExist(err) {
t.Fatalf("disabled reload started sidecar: %v", err)
}
plugins, err := host.st.ListPlugins()
if err != nil {
t.Fatal(err)
}
if len(plugins) != 1 || plugins[0].ID != "example" {
t.Fatalf("disabled reload changed plugin rows: %+v", plugins)
}
if version, err := host.st.PluginSchemaVersion("side"); err != nil || version != 0 {
t.Fatalf("disabled reload changed schema version: %d, %v", version, err)
}
}
func TestLoadDirAccountCheckedWritesForEnabledActor(t *testing.T) {
host, _, dir := newHost(t)
if err := host.st.CreateAccount("alice", "", "hash", false); err != nil {
t.Fatal(err)
}
root := filepath.Join(dir, "plain")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"id":"plain","name":"Plain","version":"1.0.0","apiVersion":"1.0","backend":{"schemaVersion":2},"provides":{}}`
if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
results, err := host.LoadDirAccountChecked("alice", false)
if err != nil || results["plain"] != nil {
t.Fatalf("enabled reload: %v, %+v", err, results)
}
plugins, err := host.st.ListPlugins()
if err != nil || len(plugins) != 1 || plugins[0].ID != "plain" {
t.Fatalf("plugin row: %+v, %v", plugins, err)
}
if version, err := host.st.PluginSchemaVersion("plain"); err != nil || version != 2 {
t.Fatalf("schema version: %d, %v", version, err)
}
}
func TestLoadDirAccountCheckedRechecksBetweenPlugins(t *testing.T) {
host, _, dir := newHost(t)
if err := host.st.CreateAccount("admin", "", "hash", true); err != nil {
t.Fatal(err)
}
if err := host.st.CreateAccount("alice", "", "hash", false); err != nil {
t.Fatal(err)
}
account, err := host.st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
mark := filepath.Join(t.TempDir(), "b-ran")
for id, manifest := range map[string]string{
"a": `{"id":"a","name":"A","version":"1.0.0","apiVersion":"1.0","provides":{}}`,
"b": `{"id":"b","name":"B","version":"1.0.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["` + mark + `"]},"provides":{}}`,
} {
root := filepath.Join(dir, id)
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
}
callbackCount := 0
host.OnCommands = func([]json.RawMessage) error {
callbackCount++
_, err := host.st.SetAccountEnabled("admin", account.ID, account.StateVersion, false)
return err
}
results, err := host.LoadDirAccountChecked("alice", false)
if !errors.Is(err, store.ErrAccountForbidden) || results["a"] != nil || callbackCount != 1 {
t.Fatalf("reload after callback disable: %v, %+v, callbacks=%d", err, results, callbackCount)
}
plugins, err := host.st.ListPlugins()
if err != nil || len(plugins) != 1 || plugins[0].ID != "a" {
t.Fatalf("later plugin wrote after disable: %+v, %v", plugins, err)
}
if _, err := os.Stat(mark); !os.IsNotExist(err) {
t.Fatalf("later sidecar started after disable: %v", err)
}
}
+87 -8
View File
@@ -67,14 +67,55 @@ func New(st *store.Store, reg *tools.Registry, s *settings.Service, dir string)
return &Host{st: st, registry: reg, settings: s, dir: dir, loaded: map[string]*Loaded{}, sidecars: map[string]*exec.Cmd{}}
}
type loadAccountGuard struct {
st *store.Store
actor string
allowImplicitDefault bool
failed error
}
func (g *loadAccountGuard) check() error {
if g == nil {
return nil // startup and existing fixture loader retain legacy behavior
}
if g.failed != nil {
return g.failed
}
g.failed = g.st.CheckEffectiveAccountForExternalEffect(g.actor, g.allowImplicitDefault)
return g.failed
}
func (g *loadAccountGuard) record(err error) error {
if g != nil && err != nil {
g.failed = err
}
return err
}
// LoadDir 全量装载目录(启动与热加载共用);返回每个插件的结果(失败不中断其他)。
func (h *Host) LoadDir() map[string]error {
results, _ := h.loadDir(nil)
return results
}
// LoadDirAccountChecked reloads on behalf of an already authorized actor.
// Each Backend-owned DB write is fenced in its final transaction; process,
// sidecar and filesystem effects get a fresh state check immediately before
// execution. The caller must separately enforce plugin-management authority.
func (h *Host) LoadDirAccountChecked(actor string, allowImplicitDefault bool) (map[string]error, error) {
return h.loadDir(&loadAccountGuard{st: h.st, actor: actor, allowImplicitDefault: allowImplicitDefault})
}
func (h *Host) loadDir(guard *loadAccountGuard) (map[string]error, error) {
if err := guard.check(); err != nil {
return nil, err
}
entries, err := os.ReadDir(h.dir)
if err != nil {
if os.IsNotExist(err) {
return map[string]error{}
return map[string]error{}, nil
}
return map[string]error{"<dir>": err}
return map[string]error{"<dir>": err}, nil
}
results := map[string]error{}
seen := map[string]bool{}
@@ -84,12 +125,19 @@ func (h *Host) LoadDir() map[string]error {
}
id := e.Name()
seen[id] = true
results[id] = h.loadOne(filepath.Join(h.dir, id))
results[id] = h.loadOne(filepath.Join(h.dir, id), guard)
if guard != nil && guard.failed != nil {
return results, guard.failed
}
}
// 移除已消失的插件(卸载语义)。
h.mu.Lock()
for id := range h.loaded {
if !seen[id] {
if err := guard.check(); err != nil {
h.mu.Unlock()
return results, err
}
delete(h.loaded, id)
if cmd := h.sidecars[id]; cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
@@ -104,25 +152,34 @@ func (h *Host) LoadDir() map[string]error {
onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows
h.mu.Unlock()
if onCommands != nil {
if err := guard.check(); err != nil {
return results, err
}
if err := onCommands(cmdOverlay); err != nil {
results["<commands>"] = err
}
}
if onIntents != nil {
if err := guard.check(); err != nil {
return results, err
}
if err := onIntents(overlay); err != nil {
results["<intents>"] = err
}
}
if onWorkflows != nil {
if err := guard.check(); err != nil {
return results, err
}
if err := onWorkflows(defs, scripts); err != nil {
results["<workflows>"] = err
}
}
return results
return results, nil
}
// loadOne 装载单个插件;任何失败返回错误且不改变既有注册(fail-closed)。
func (h *Host) loadOne(dir string) error {
func (h *Host) loadOne(dir string, guard *loadAccountGuard) error {
manifestPath := filepath.Join(dir, "plugin.json")
raw, err := os.ReadFile(manifestPath)
if err != nil {
@@ -145,6 +202,9 @@ func (h *Host) loadOne(dir string) error {
ld := &Loaded{Manifest: m, Dir: dir, Cards: map[string][]core.UINode{}, Prompts: map[string]string{}, Defaults: map[string]any{}, Scripts: map[string]string{}}
// 同 ID 重载:先注销旧注册(含旧版本工具),再装载新资产。
if err := guard.check(); err != nil {
return err
}
h.registry.UnregisterBySource(core.SourcePlugin, m.ID)
// tools/*.json:声明式 HTTP 工具(或含 parameters 的描述型工具)。
@@ -159,6 +219,9 @@ func (h *Host) loadOne(dir string) error {
if err != nil {
return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err)
}
if err := guard.check(); err != nil {
return err
}
if err := h.registry.Register(desc, exec); err != nil {
return fmt.Errorf("plugins: %s: register tool %s: %w", m.ID, name, err)
}
@@ -261,10 +324,13 @@ func (h *Host) loadOne(dir string) error {
}
}
if err := h.loadDeclared(dir, m, ld); err != nil {
if err := h.loadDeclared(dir, m, ld, guard); err != nil {
return err
}
if err := h.startSidecar(m, dir); err != nil {
if err := h.startSidecar(m, dir, guard); err != nil {
return err
}
if err := guard.check(); err != nil {
return err
}
h.mu.Lock()
@@ -274,18 +340,31 @@ func (h *Host) loadOne(dir string) error {
defs, scripts := h.collectWorkflowsLocked()
onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows
h.mu.Unlock()
_ = h.st.UpsertPlugin(m.ID, dir, m)
if guard == nil {
_ = h.st.UpsertPlugin(m.ID, dir, m)
} else if err := h.st.UpsertPluginAccountChecked(guard.actor, m.ID, dir, m, guard.allowImplicitDefault); err != nil {
return guard.record(err)
}
if onCommands != nil {
if err := guard.check(); err != nil {
return err
}
if err := onCommands(cmdOverlay); err != nil {
return fmt.Errorf("plugins: %s: commands rejected by command bus (kept previous): %w", m.ID, err)
}
}
if onIntents != nil {
if err := guard.check(); err != nil {
return err
}
if err := onIntents(overlay); err != nil {
return fmt.Errorf("plugins: %s: intents rejected by host gate (kept previous): %w", m.ID, err)
}
}
if onWorkflows != nil {
if err := guard.check(); err != nil {
return err
}
if err := onWorkflows(defs, scripts); err != nil {
return fmt.Errorf("plugins: %s: workflows rejected: %w", m.ID, err)
}
+20 -2
View File
@@ -21,7 +21,7 @@ func (h *Host) SetHostAPI(baseURL, token string) {
h.hostAPI, h.hostToken = baseURL, token
}
func (h *Host) startSidecar(m core.PluginManifest, dir string) error {
func (h *Host) startSidecar(m core.PluginManifest, dir string, guard *loadAccountGuard) error {
if m.Backend.Mode != "sidecar" || m.Backend.Command == "" {
return nil
}
@@ -38,10 +38,19 @@ func (h *Host) startSidecar(m core.PluginManifest, dir string) error {
"AGENT_HOST_TOKEN="+h.hostToken,
"AGENT_PLUGIN_ID="+m.ID,
)
if err := guard.check(); err != nil {
return err
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("plugins: %s: sidecar: %w", m.ID, err)
}
h.mu.Lock()
if err := guard.check(); err != nil {
h.mu.Unlock()
_ = cmd.Process.Kill()
_ = cmd.Wait()
return err
}
if h.sidecars == nil {
h.sidecars = map[string]*exec.Cmd{}
}
@@ -54,7 +63,7 @@ func (h *Host) startSidecar(m core.PluginManifest, dir string) error {
return nil
}
func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error {
func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded, guard *loadAccountGuard) error {
for _, name := range m.Provides.Skills {
body, err := os.ReadFile(filepath.Join(dir, "skills", name+".md"))
if err != nil {
@@ -70,9 +79,15 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
ld.Skills[name] = string(body)
if h.skillsRoot != "" {
dest := filepath.Join(h.skillsRoot, "plugin-"+m.ID, name)
if err := guard.check(); err != nil {
return err
}
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
if err := guard.check(); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dest, "SKILL.md"), body, 0o644); err != nil {
return err
}
@@ -131,6 +146,9 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
ld.Pages = append(ld.Pages, item)
}
if m.Backend.SchemaVersion > 0 && h.st != nil {
if guard != nil {
return guard.record(h.st.SetPluginSchemaVersionAccountChecked(guard.actor, m.ID, m.Backend.SchemaVersion, guard.allowImplicitDefault))
}
cur, err := h.st.PluginSchemaVersion(m.ID)
if err != nil {
return err
+17
View File
@@ -74,6 +74,23 @@ func (s *Service) Save(owner string, p core.AgentProfile) error {
func (s *Service) Delete(owner, id string) error { return s.st.DeleteProfile(owner, id) }
// SaveAccountChecked is the user-facing write path. Builtin profiles remain
// read-only and the Store binds the write to the actor's effective account.
func (s *Service) SaveAccountChecked(actor string, p core.AgentProfile, allowImplicitDefault bool) error {
if p.ID == DefaultProfileID || p.Builtin {
return errReadOnly("profiles: builtin profile is read-only; duplicate and modify")
}
if p.Tools.Allow == nil {
p.Tools.Allow = []string{"*"}
}
p.Owner = actor
return s.st.SaveProfileAccountChecked(actor, p, allowImplicitDefault)
}
func (s *Service) DeleteAccountChecked(actor, id string, allowImplicitDefault bool) error {
return s.st.DeleteProfileAccountChecked(actor, id, allowImplicitDefault)
}
type errReadOnly string
func (e errReadOnly) Error() string { return string(e) }
+54
View File
@@ -0,0 +1,54 @@
package profiles
import (
"errors"
"path/filepath"
"testing"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func TestProfileServiceAccountCheckedWrites(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.CreateAccount("admin", "", "", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "", "", false); err != nil {
t.Fatal(err)
}
svc := New(st)
if err := svc.EnsureDefault(); err != nil {
t.Fatal(err)
}
if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "profile-a", Name: "A"}, false); err != nil {
t.Fatal(err)
}
got, err := st.GetProfile("alice", "profile-a")
if err != nil || got.Owner != "alice" || got.Builtin || len(got.Tools.Allow) != 1 || got.Tools.Allow[0] != "*" {
t.Fatalf("service did not normalize actor profile: %+v %v", got, err)
}
if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: DefaultProfileID}, false); err == nil {
t.Fatal("service edited builtin default")
}
if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "forged", Builtin: true}, false); err == nil {
t.Fatal("service accepted forged builtin profile")
}
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)
}
if err := svc.DeleteAccountChecked("alice", "profile-a", false); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled actor deleted profile: %v", err)
}
if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "profile-a", Name: "late"}, false); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled actor saved profile: %v", err)
}
}
+35
View File
@@ -0,0 +1,35 @@
// Package projectscope resolves host retrieval filters using service-provided
// lifecycle metadata. Stored/client settings never supply an authority mapping.
package projectscope
import (
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
)
func Resolve(ctx context.Context, host extension.Host, p extension.ProjectLifecycle, id core.ID) (core.ID, error) {
if id == "" {
return id, nil
}
if p.List == nil {
return "", store.ErrNotFound
}
projects, err := p.List(ctx, host)
if err != nil {
return "", err
}
for _, project := range projects {
domainID, _ := project.Settings["rdm.projectId"].(string)
if project.ID != id && domainID != id {
continue
}
state, _ := project.Settings["rdm.state"].(string)
if domainID == "" || state != "ready" {
return "", store.ErrNotFound
}
return core.ID(domainID), nil
}
return "", store.ErrNotFound
}
@@ -1,7 +1,7 @@
---
id: base.deliverables
layer: base
version: 7
version: 9
title: 交付物工作流
description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座)
---
@@ -36,8 +36,10 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
- 技能目录是环境变量 `AGENT_SKILLS_DIR`(环境段会写出绝对路径)。`PYTHONPATH` 已包含其中的 `house-style`,脚本里直接 `import housestyle`。
- **house-style**:设计系统、三套主题、模板与仿制、lint。先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。
- **docx-author / xlsx-author / pptx-author**:各类交付物的版面范式和最小示例。
- **frontend-design / canvas-design / copywriting / copy-editing**(第三方 vendor,溯源见各自 UPSTREAM.md):视觉方向与排版审美、海报/信息图等 png·pdf 视觉产物(PIL 渲染,字体已随包)、营销/商务文案的撰写改写与编辑润色。交付物主题与配色仍以 house-style 为权威;需要超出主题规范的视觉判断或文案功力时 `load_skill` 对应技能。
- 小队角色用 `load_skill`;其他路径用 `run_shell` 查看技能文件,例如 `cat "$AGENT_SKILLS_DIR/house-style/SKILL.md"`。不要假设工作区里有 `skills/` 目录。
- 缺库时:`pip install --user python-docx openpyxl python-pptx`。PDF 仿制可选 `pdfplumber`;没装就说明只能近似,并请用户确认主色和字体。
- 缺库又装不上时,如实告知用户当前环境无法生成带排版的 Office 交付物并说明缺什么;禁止改用 zipfile/字符串手工拼 OOXML 充数——那样生成的包缺母版与主题,结构自检会拒绝登记。
要求与边界:
+2 -2
View File
@@ -1,8 +1,8 @@
---
id: tool.run_python
layer: tool
version: 1
version: 2
title: run_python 工具描述
description: Python 执行原语的模型侧说明文案
---
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`,不要在脚本里手工拼 docx。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排式的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`。任何 Office 交付物(docx/xlsx/pptx)都禁止用 zipfile 或字符串手工拼 OOXML——手拼的包缺母版与主题,register_artifact 的结构自检会直接拒绝;一律走 housestyle kit 构件。缺库先 `pip install --user python-pptx python-docx openpyxl`,装不上就如实告知用户当前环境无法生成本类交付物,不要退回手拼。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排版的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
+21
View File
@@ -228,6 +228,27 @@ func (s *Service) Save(owner string, m core.PromptModule) error {
// Delete 撤销用户覆盖(回到内置)。
func (s *Service) Delete(owner, id string) error { return s.st.DeletePromptModule(owner, id) }
// SaveAccountChecked applies the same user override rules as Save and uses
// the Store's final actor-state transaction for the database write.
func (s *Service) SaveAccountChecked(actor string, m core.PromptModule, allowImplicitDefault bool) error {
if err := validateSubagentOverride(m); err != nil {
return err
}
m.Source = string(core.SourceUser)
m.Owner = actor
for _, b := range s.builtin {
if b.ID == m.ID {
m.BasedOnVersion = b.Version
break
}
}
return s.st.SavePromptModuleAccountChecked(actor, m, allowImplicitDefault)
}
func (s *Service) DeleteAccountChecked(actor, id string, allowImplicitDefault bool) error {
return s.st.DeletePromptModuleAccountChecked(actor, id, allowImplicitDefault)
}
// MatchCommand 命令触发匹配:文本以某技能命令开头时返回该模块与剩余文本。
func (s *Service) MatchCommand(owner, text string) (core.PromptModule, string, bool, error) {
mods, err := s.Effective(owner)
+52
View File
@@ -0,0 +1,52 @@
package prompts
import (
"errors"
"path/filepath"
"testing"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func TestPromptServiceAccountCheckedWrites(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.CreateAccount("admin", "", "", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "", "", false); err != nil {
t.Fatal(err)
}
svc, err := NewService(st, []core.PromptModule{{ID: "task.example", Layer: core.LayerTask, Version: 2}})
if err != nil {
t.Fatal(err)
}
module := core.PromptModule{ID: "task.example", Layer: core.LayerTask, Body: "Alice"}
if err := svc.SaveAccountChecked("alice", module, false); err != nil {
t.Fatal(err)
}
got, err := st.GetPromptModule("alice", module.ID)
if err != nil || got.Owner != "alice" || got.Source != string(core.SourceUser) || got.BasedOnVersion != 2 {
t.Fatalf("service did not normalize actor module: %+v %v", got, err)
}
if err := svc.SaveAccountChecked("missing", module, false); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("missing actor saved prompt: %v", err)
}
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)
}
if err := svc.DeleteAccountChecked("alice", module.ID, false); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled actor deleted prompt: %v", err)
}
if err := svc.SaveAccountChecked("alice", module, false); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("disabled actor saved prompt: %v", err)
}
}
+83
View File
@@ -0,0 +1,83 @@
package rag
import (
"context"
"errors"
"testing"
"agent-system-backend/internal/store"
core "agent-system-core"
)
type checkedKnowledgeIndexer interface {
IndexDocumentAccountChecked(ctx context.Context, owner, projectID core.ID, source, title, ref, plainText string, allowImplicitDefault bool) error
IndexChunksAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error
PublishChunksAccountChecked(ctx context.Context, owner core.ID, pluginID string, scoped bool, docs []core.KnowledgeDoc, allowImplicitDefault bool) error
}
func checkedIndexer(t *testing.T, p *Pipeline) checkedKnowledgeIndexer {
t.Helper()
i, ok := any(p).(checkedKnowledgeIndexer)
if !ok {
t.Fatal("Pipeline is missing account-checked indexing APIs")
}
return i
}
type disableDuringEmbed struct{ st *store.Store }
func (e disableDuringEmbed) Embed(_ context.Context, texts []string) ([][]float32, error) {
if _, err := e.st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
return nil, err
}
vecs := make([][]float32, len(texts))
for i := range vecs {
vecs[i] = []float32{1, 0}
}
return vecs, nil
}
func TestCheckedIndexStopsVectorWriteAfterEmbeddingDisablesAccount(t *testing.T) {
p, st := newTestPipeline(t, nil, nil)
i := checkedIndexer(t, p)
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
p.SetEmbedder(disableDuringEmbed{st: st})
err := i.IndexChunksAccountChecked(context.Background(), "alice", []core.KnowledgeDoc{
{ID: "knw-during-embed", Owner: "alice", Ref: "embedded", Source: "manual", Chunk: "A searchable document"},
}, false)
if !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("vector write after disable: got %v, want ErrAccountForbidden", err)
}
var lexical, vectors int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE owner='alice' AND ref='embedded'`).Scan(&lexical); err != nil {
t.Fatal(err)
}
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge_vectors WHERE owner='alice' AND ref='embedded'`).Scan(&vectors); err != nil {
t.Fatal(err)
}
if lexical != 1 || vectors != 0 {
t.Fatalf("expected pre-disable lexical commit and no late vector: lexical=%d vectors=%d", lexical, vectors)
}
}
func TestCheckedIndexDocumentAndPublishUseGuardedKnowledge(t *testing.T) {
p, st := newTestPipeline(t, nil, nil)
i := checkedIndexer(t, p)
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
if err := i.IndexDocumentAccountChecked(context.Background(), "alice", "p1", "upload", "minutes", "upload-ref", "A document with enough searchable text", false); err != nil {
t.Fatal(err)
}
if err := i.PublishChunksAccountChecked(context.Background(), "alice", "alpha", true, []core.KnowledgeDoc{
{ID: "knw-published", Owner: "alice", ProjectID: "p1", Ref: "published", Source: "plugin:alpha", Chunk: "A published document"},
}, false); err != nil {
t.Fatal(err)
}
var n int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE owner='alice'`).Scan(&n); err != nil || n != 2 {
t.Fatalf("guarded index and publish rows=%d, %v", n, err)
}
}
+140 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"log"
"agent-system-backend/internal/settings"
@@ -85,6 +86,25 @@ func (p *Pipeline) IndexDocument(owner, projectID core.ID, source, title, ref, p
return p.IndexChunks(context.Background(), owner, docs)
}
// IndexDocumentAccountChecked is the upload/artifact indexing entry when the
// caller has an authenticated actor. Chunking takes no DB connection; every
// later lexical/vector write has its own final account transaction check.
func (p *Pipeline) IndexDocumentAccountChecked(ctx context.Context, owner, projectID core.ID, source, title, ref, plainText string, allowImplicitDefault bool) error {
if plainText == "" {
return nil
}
doc := core.ParsedDocument{Blocks: []core.DocBlock{{Type: core.BlockPara, Text: plainText}}}
chunks := p.chunker.Chunk(doc)
if len(chunks) == 0 {
return nil
}
docs := make([]core.KnowledgeDoc, len(chunks))
for i, c := range chunks {
docs[i] = core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: source, Title: title, Ref: ref, Chunk: c.Text}
}
return p.IndexChunksAccountChecked(ctx, owner, docs, allowImplicitDefault)
}
// IndexChunks 写入已构造好的知识分块并建向量(EXT-1 KnowledgeWrite embed=true 的路径)。
// AddKnowledge 原地回填 docs[i].ID;embedding 失败降级为纯全文(记日志,不阻断写入)。
func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc) error {
@@ -118,6 +138,74 @@ func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.K
return nil
}
// IndexChunksAccountChecked preserves batch atomicity for lexical rows and
// fences each vector write after the external embedding call. Embedding cannot
// share the DB transaction; a disable during that call leaves already
// committed lexical rows but blocks subsequent vector publication.
func (p *Pipeline) IndexChunksAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error {
if len(docs) == 0 {
return nil
}
if err := p.st.AddKnowledgeAccountChecked(string(owner), allowImplicitDefault, docs...); err != nil {
return err
}
return p.indexVectorsAccountChecked(ctx, owner, docs, allowImplicitDefault)
}
// PublishChunksAccountChecked replaces all affected refs in one guarded Store
// transaction, then builds vectors through individually guarded writes.
func (p *Pipeline) PublishChunksAccountChecked(ctx context.Context, owner core.ID, pluginID string, scoped bool, docs []core.KnowledgeDoc, allowImplicitDefault bool) error {
if len(docs) == 0 {
return nil
}
if err := p.st.ReplaceKnowledgeAccountChecked(ctx, string(owner), pluginID, scoped, allowImplicitDefault, docs...); err != nil {
return err
}
return p.indexVectorsAccountChecked(ctx, owner, docs, allowImplicitDefault)
}
func (p *Pipeline) indexVectorsAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error {
if p.embedder == nil {
return nil
}
if err := ctx.Err(); err != nil {
return err
}
account, err := p.st.GetAccountByOwner(string(owner))
if errors.Is(err, store.ErrNotFound) && allowImplicitDefault && owner == store.OwnerDefault {
err = nil
account.Enabled = true
}
if err != nil {
return err
}
if !account.Enabled {
return store.ErrAccountForbidden
}
texts := make([]string, len(docs))
for i, d := range docs {
texts[i] = d.Chunk
}
vecs, err := p.embedder.Embed(ctx, texts)
if err != nil {
log.Printf("[rag] embedding 降级为纯全文(ref=%s): %v", firstRef(docs), err)
return nil
}
model := embedderModel(p.embedder)
for i, v := range vecs {
if i >= len(docs) {
break
}
if len(v) == 0 {
continue
}
if err := p.st.UpsertKnowledgeVectorExtAccountChecked(ctx, string(owner), string(docs[i].ID), docs[i].Ref, model, contentHash(docs[i].Chunk), v, allowImplicitDefault); err != nil {
return err
}
}
return nil
}
// embedderModel 模型身份(可选接口:EmbeddingProvider 契约未含,实现侧
// 声明;未声明=空=legacy 无身份行,向后兼容)。
func embedderModel(e core.EmbeddingProvider) string {
@@ -174,19 +262,66 @@ func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.Ret
return merged, nil
}
// RetrieveAuthorized is the resource-level path for HTTP, Host and model
// context. It filters owned and shared candidates before external reranking.
func (p *Pipeline) RetrieveAuthorized(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
own, err := p.retrieve(ctx, owner, q, allow)
if err != nil {
return nil, err
}
shared, err := p.st.SearchKnowledgeSharedAuthorized(string(owner), q, allow)
if err != nil {
return nil, err
}
if p.embedder != nil {
if vectors, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(vectors) > 0 && len(vectors[0]) > 0 {
topK := q.TopK
if topK <= 0 {
topK = 5
}
vs, e := p.st.SearchKnowledgeVectorsSharedAuthorized(ctx, string(owner), embedderModel(p.embedder), vectors[0], topK, q, allow)
if e == nil {
shared = rrfMerge(vs, shared)
}
}
}
merged := rrfMerge(own, shared)
if limit := q.TopK; limit > 0 && len(merged) > limit {
merged = merged[:limit]
}
return merged, nil
}
func authorizedHits(hits []core.RetrievalHit, allow func(core.KnowledgeDoc) bool) []core.RetrievalHit {
if allow == nil {
return hits
}
out := make([]core.RetrievalHit, 0, len(hits))
for _, h := range hits {
if allow(h.Doc) {
out = append(out, h)
}
}
return out
}
// Retrieve 统一检索(core.Retriever)。
func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
return p.retrieve(ctx, owner, q, nil)
}
func (p *Pipeline) retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
if p.embedder == nil {
return p.st.SearchKnowledge(owner, q)
hits, err := p.st.SearchKnowledge(owner, q)
return authorizedHits(hits, allow), err
}
route := p.embeddingRoute(ctx)
offline, _ := p.embedder.(interface{ Offline() bool })
if !route.OK() && (offline == nil || !offline.Offline()) {
return p.st.SearchKnowledge(owner, q)
hits, err := p.st.SearchKnowledge(owner, q)
return authorizedHits(hits, allow), err
}
qvecs, err := p.embedder.Embed(ctx, []string{q.Text})
if err != nil || len(qvecs) == 0 || len(qvecs[0]) == 0 {
@@ -194,7 +329,8 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval
if err != nil {
log.Printf("[rag] query embedding fallback to fulltext: %v", err)
}
return p.st.SearchKnowledge(owner, q)
hits, err := p.st.SearchKnowledge(owner, q)
return authorizedHits(hits, allow), err
}
q.TopK = topK * 2 // 每路多取,融合后再裁剪
vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q)
@@ -205,7 +341,7 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval
if err != nil {
return nil, err
}
merged := rrfMerge(vhits, fhits)
merged := authorizedHits(rrfMerge(vhits, fhits), allow)
if p.reranker != nil && p.rerankRoute(ctx).OK() && len(merged) > 0 {
docs := make([]core.KnowledgeDoc, len(merged))
for i, h := range merged {
+2 -1
View File
@@ -162,8 +162,9 @@ func TestRetrieveFallbackAndHybrid(t *testing.T) {
}
// 有 embedder 且路由已配置:查询向量 A 类 → A 文档排前(RRF 向量路主导)。
// P2 受管:models.* 是系统受管项,配置写入系统层行(owner=OwnerSystemRow)。
pipe2, st2 := newTestPipeline(t, fakeEmbedder{}, nil)
if err := st2.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{
if err := st2.MergeSettingsScope(settings.OwnerSystemRow, settings.ScopeSystem, map[string]any{
"models": map[string]any{"embedding": map[string]any{"baseUrl": "http://fake.internal", "model": "e-test"}},
}); err != nil {
t.Fatal(err)
+145
View File
@@ -0,0 +1,145 @@
package runmgr
import (
"context"
"encoding/json"
"errors"
"testing"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// A missing domain provider must not make a registered account guard optional.
func TestAccountGuardBlocksRunToolAndQueuedHITL(t *testing.T) {
c, st := newExecEnv(t)
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
t.Fatal(err)
}
c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) {
a, err := st.GetAccountByOwner(owner)
return err == nil && a.Enabled, err
})
c.run.ID = store.NewID("run")
c.run.Status = core.RunWaitingHITL
if err := st.CreateRun(&c.run); err != nil {
t.Fatal(err)
}
h := &runHandle{hitlCh: make(chan hitlAnswer, 1)}
c.mgr.active = map[string]*runHandle{c.run.ID: h}
card := core.HITLCard{ID: "disabled-card", RunID: c.run.ID, ConversationID: c.run.ConversationID}
if err := st.SaveHITLCard("alice", &card); err != nil {
t.Fatal(err)
}
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
t.Fatal(err)
}
conv, err := st.GetConversation("alice", c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if _, err := c.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("disabled account started run without domain provider: %v", err)
}
res := c.ExecuteTool(core.ToolCallRequest{CallID: "disabled-tool", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"denied.txt","content":"x"}`)})
if res.Status != "error" {
t.Fatalf("disabled account executed tool: %+v", res)
}
arts, err := st.ListArtifacts("alice", c.run.ProjectID)
if err != nil || len(arts) != 0 {
t.Fatalf("disabled account wrote artifact: %+v %v", arts, err)
}
if _, _, err := c.mgr.ResolveHITL("alice", card.ID, core.HITLResponse{ChoiceID: "yes"}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("disabled account resolved HITL: %v", err)
}
persisted, err := st.GetHITLCard("alice", card.ID)
if err != nil || persisted.Resolved || len(h.hitlCh) != 0 {
t.Fatalf("disabled HITL changed state: %+v channel=%d err=%v", persisted, len(h.hitlCh), err)
}
payload, _ := json.Marshal(HITLCommandPayload{Owner: "alice", CardID: card.ID, Kind: "yes"})
c.mgr.applyHITLCommand(ownedCommand{CommandID: "queued-disabled", Payload: string(payload)})
if len(h.hitlCh) != 0 {
t.Fatal("queued approval delivered to disabled run")
}
}
func TestAccountGuardStoreReadFailureBlocksOutput(t *testing.T) {
c, st := newExecEnv(t)
runCtx, cancel := context.WithCancel(context.Background())
defer cancel()
c.ctx = runCtx
c.mgr.active = map[string]*runHandle{"active": {owner: "alice", cancel: cancel}}
readErr := errors.New("account store unavailable")
c.mgr.SetAccountEffectiveAuthorization(func(context.Context, string) (bool, error) { return false, readErr })
if err := c.Emit(core.EvToolOutput, core.ToolOutputPayload{Text: "secret"}); !errors.Is(err, readErr) {
t.Fatalf("store failure emitted output: %v", err)
}
if runCtx.Err() == nil {
t.Fatal("account-state read failure left active local run executing")
}
if err := c.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "secret"}}); !errors.Is(err, readErr) {
t.Fatalf("store failure retained assistant output: %v", err)
}
evs, err := st.EventsAfter(c.run.ConversationID, 0, 100)
if err != nil || len(evs) != 0 {
t.Fatalf("store failure persisted output events: %+v %v", evs, err)
}
}
func TestAccountGuardBlocksRestoredNoticeOutput(t *testing.T) {
c, st := newExecEnv(t)
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
t.Fatal(err)
}
c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) {
a, err := st.GetAccountByOwner(owner)
return err == nil && a.Enabled, err
})
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
t.Fatal(err)
}
if err := c.mgr.startNoticeRun(c.run.ConversationID, []bgNotice{{owner: "alice", name: "restored", payload: core.AgentResultPayload{Summary: "must not deliver"}}}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("restored notice delivered to disabled account: %v", err)
}
msgs, err := st.ListMessages(c.run.ConversationID)
if err != nil || len(msgs) != 0 {
t.Fatalf("restored notice persisted output: %+v %v", msgs, err)
}
}
func TestAccountGuardDiscardsComposedOutputAfterDisable(t *testing.T) {
c, st := newExecEnv(t)
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
t.Fatal(err)
}
c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) {
a, err := st.GetAccountByOwner(owner)
return err == nil && a.Enabled, err
})
c.run.ID = store.NewID("run")
c.run.Status = core.RunRunning
if err := st.CreateRun(&c.run); err != nil {
t.Fatal(err)
}
c.h = &runHandle{assistant: []core.Part{{Type: core.PartText, Text: "must not persist"}}}
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
t.Fatal(err)
}
c.mgr.finishRun(c, core.RunCompleted, core.Usage{}, nil)
got, err := st.GetRunByID(c.run.ID)
if err != nil || got.Status == core.RunCompleted {
t.Fatalf("disabled run completed: %+v %v", got, err)
}
msgs, err := st.ListMessages(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
for _, msg := range msgs {
for _, part := range msg.Parts {
if part.Text == "must not persist" {
t.Fatal("previously composed assistant output persisted after disable")
}
}
}
}
+10 -3
View File
@@ -70,16 +70,23 @@ func (g *admissionGate) QueueFull() bool {
}
// acquire 阻塞获取执行许可(ctx 取消则放弃排队)。预算关闭时零开销直通。
func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string) error {
func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string, accountGuard ...bool) error {
if g == nil {
return nil
}
budget := g.budget()
if budget.Off() {
return nil
return ctx.Err()
}
for {
if err := st.TryAcquireExecutionSlot(runID, owner, budget); err == nil {
var err error
if len(accountGuard) > 0 && accountGuard[0] {
allowImplicit := len(accountGuard) > 1 && accountGuard[1]
err = st.TryAcquireExecutionSlotAccountChecked(owner, runID, budget, allowImplicit)
} else {
err = st.TryAcquireExecutionSlot(runID, owner, budget)
}
if err == nil {
return nil
} else if !errors.Is(err, store.ErrBudgetExceeded) {
return err // 存储故障:不放行(fail-closed,宁可拒绝执行)
+53 -4
View File
@@ -120,18 +120,67 @@ func TestBackgroundCardLifecycleOnFailure(t *testing.T) {
t.Fatalf("cards = %d, want 1", len(cards))
}
card := cards[0]
if card.Status != core.CardDone || card.Tone != "danger" {
t.Fatalf("status = %s tone = %s, want done/danger", card.Status, card.Tone)
if card.Status != core.CardFailed || card.Tone != "danger" {
t.Fatalf("status = %s tone = %s, want failed/danger", card.Status, card.Tone)
}
var md string
statusLabel := ""
for _, n := range card.Body {
if n.Type == core.UIMarkdown {
md = n.Markdown
}
if n.Type == core.UIKV {
for _, kv := range n.KV {
if kv[0] == core.TaskSummaryKVStatus {
statusLabel = kv[1]
}
}
}
}
if !strings.Contains(md, "执行失败") {
t.Fatalf("failure body = %q", md)
}
if statusLabel != core.TaskSummaryStatusFailed {
t.Fatalf("status kv = %q, want %q", statusLabel, core.TaskSummaryStatusFailed)
}
}
func TestBackgroundCardCreateUsesL1SkeletonNotRawTask(t *testing.T) {
runner := &holdRunner{release: make(chan struct{})}
c, _ := newCardSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
raw := "逐页解析上传材料并对照验收条件" + strings.Repeat("LONGTASK", 40)
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", raw, ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
return err == nil && len(cards) == 1
})
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
card := cards[0]
var md string
for _, n := range card.Body {
if n.Type == core.UIMarkdown {
md = n.Markdown
}
}
if !strings.Contains(md, "执行失败") {
t.Fatalf("failure body = %q", md)
if strings.Contains(md, "LONGTASK") || strings.Contains(md, raw[:32]) {
t.Fatalf("create body still embeds raw task: %q", md)
}
if md != core.TaskSummaryNoFindingsYet {
t.Fatalf("create markdown = %q, want honest placeholder", md)
}
close(runner.release)
waitUntil(t, func() bool {
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy
})
}
// TestBackgroundCardSkippedWithoutInbox:收件箱未启用(cards==nil)时后台派发
+1 -1
View File
@@ -170,7 +170,7 @@ func (m *Manager) applyHITLCommand(c ownedCommand) {
_, _, err := m.ResolveHITL(p.Owner, p.CardID, resp)
if err != nil {
// 过期/未知 gate:明确销项(resolved + 注记),发出可见结论。
_ = m.st.ResolveHITLCard(p.Owner, p.CardID, core.HITLResolvedPayload{
_ = m.resolveHITLCardForEffectiveAccount(p.Owner, p.CardID, core.HITLResolvedPayload{
CardID: core.ID(p.CardID), Kind: "expired", Answer: "跨实例审批迟到:gate 已过期或执行已移交",
})
log.Printf("[runmgr] hitl %s (card %s) rejected: %v", c.CommandID, p.CardID, err)
+81 -5
View File
@@ -20,10 +20,12 @@ import (
// 会话内事件天然单线程(一个会话至多一个在途 run,子代理在父执行栈内同步运行),
// 少数跨 goroutine 路径(HITL resolve)与冲盘互斥由 flushing 锁保证不交叉。
type eventBuffer struct {
store *store.Store
bus *bus.Bus
interval time.Duration // 定时冲盘窗口
maxBuf int // 单会话缓冲条数上限(触发立即冲盘)
store *store.Store
bus *bus.Bus
accountGuardEnabled bool
allowImplicitLocal bool
interval time.Duration // 定时冲盘窗口
maxBuf int // 单会话缓冲条数上限(触发立即冲盘)
// onPersisted 落盘+本地发布后的回调(PG 档 NOTIFY 跨实例扇出;nil = 无)。
onPersisted func(conversationID string, evs []core.StreamEvent)
@@ -116,11 +118,85 @@ func (eb *eventBuffer) emit(conversationID, runID string, typ core.StreamEventTy
return 0, nil
}
// emitAccountChecked flushes any earlier deltas and the new structural event
// in one final account-checked Store transaction. A disabled owner loses its
// buffered deltas instead of persisting them after disable.
func (eb *eventBuffer) emitAccountChecked(actor, conversationID, runID string, typ core.StreamEventType, payload any, allowImplicitDefault bool) error {
e := core.StreamEvent{ConversationID: conversationID, RunID: runID,
TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: typ}
if projectID, ok := core.ProjectFromChannel(core.ID(conversationID)); ok {
e.ProjectID = projectID
}
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
e.Payload = raw
}
eb.flushing.Lock()
defer eb.flushing.Unlock()
eb.mu.Lock()
batch := append([]core.StreamEvent(nil), eb.buf[conversationID]...)
delete(eb.buf, conversationID)
eb.mu.Unlock()
batch = append(batch, e)
if _, err := eb.store.AppendEventsAccountChecked(actor, conversationID, batch, allowImplicitDefault); err != nil {
return err
}
for _, event := range batch {
eb.bus.Publish(event)
}
if eb.onPersisted != nil {
eb.onPersisted(conversationID, batch)
}
return nil
}
// emitProjectShared persists an explicit internal publication and its proof
// before fan-out. A client-visible payload cannot opt itself into this path.
func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
project, ok := core.ProjectFromChannel(channel)
if !ok {
return store.ErrNotFound
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
event := core.StreamEvent{ConversationID: channel, ProjectID: project, RunID: runID, Type: typ, Payload: encoded, TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion}
eb.flushConversation(channel)
eb.flushing.Lock()
defer eb.flushing.Unlock()
events := []core.StreamEvent{event}
if eb.accountGuardEnabled {
_, err = eb.store.AppendProjectSharedEventsAccountChecked(owner, producer, channel, events, eb.allowImplicitLocal)
} else {
_, err = eb.store.AppendProjectSharedEvents(owner, producer, channel, events)
}
if err != nil {
return err
}
for _, event := range events {
eb.bus.Publish(event)
}
if eb.onPersisted != nil {
eb.onPersisted(channel, events)
}
return nil
}
// append 落盘并按 seq 顺序发布。
func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) error {
eb.flushing.Lock()
defer eb.flushing.Unlock()
if _, err := eb.store.AppendEvents(conversationID, evs); err != nil {
var err error
if eb.accountGuardEnabled {
_, err = eb.store.AppendEventsForEffectiveChannel(conversationID, evs, eb.allowImplicitLocal)
} else {
_, err = eb.store.AppendEvents(conversationID, evs)
}
if err != nil {
return err
}
for i := range evs {
+3 -1
View File
@@ -77,9 +77,11 @@ func TestRunPythonRegisterArtifactBackfill(t *testing.T) {
if err := tools.RegisterInterpreterTools(c.mgr.registry); err != nil {
t.Fatal(err)
}
// 夹具必须是结构合法的最小 docx:登记口会做 OPC 结构自检,
// 伪 zip(PK 魔数+垃圾字节)现在会被拒绝而非登记。
py := c.ExecuteTool(core.ToolCallRequest{
CallID: "py1", Name: "run_python",
Arguments: json.RawMessage(`{"script":"open('note.docx','wb').write(b'PK\\x03\\x04hello')\n"}`),
Arguments: json.RawMessage(`{"script":"import zipfile\nwith zipfile.ZipFile('note.docx','w') as z:\n z.writestr('[Content_Types].xml','<?xml version=\"1.0\"?><Types/>')\n z.writestr('word/document.xml','<?xml version=\"1.0\"?><doc/>')\n"}`),
})
if py.Status != "ok" || !strings.Contains(py.Output, "exit=0") {
t.Fatalf("run_python: %s %s", py.Status, py.Output)
@@ -0,0 +1,173 @@
package runmgr
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
type checkedFileEffects interface {
stageInputsChecked(ctx context.Context, conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile, check func(context.Context) error) ([]string, error)
persistInputTextsChecked(ctx context.Context, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error
writeSubagentRequestChecked(ctx context.Context, run core.Run, agentID, name, task, hint string, detach bool, check func(context.Context) error) error
writeSubagentTranscriptChecked(ctx context.Context, run core.Run, parts []core.Part, status string, check func(context.Context) error) error
}
func checkedFiles(t *testing.T, m *Manager) checkedFileEffects {
t.Helper()
c, ok := any(m).(checkedFileEffects)
if !ok {
t.Fatal("Manager is missing checked workspace/transcript file effects")
}
return c
}
func rejectAt(n int, sentinel error) (func(context.Context) error, *int) {
calls := 0
return func(ctx context.Context) error {
calls++
if calls == n {
return sentinel
}
return nil
}, &calls
}
func TestCheckedStageInputsStopsBeforeEachFileEffect(t *testing.T) {
root := t.TempDir()
m := &Manager{workspaceRoot: root}
c := checkedFiles(t, m)
conv := core.Conversation{ID: "conv", Owner: "alice"}
denied := errors.New("account disabled")
check, calls := rejectAt(3, denied)
paths, err := c.stageInputsChecked(context.Background(), conv, core.EffectiveSettings{}, []WorkspaceFile{
{Name: "first.txt", Content: []byte("first")},
{Name: "second.txt", Content: []byte("second")},
}, check)
if !errors.Is(err, denied) || *calls != 3 || len(paths) != 1 || paths[0] != "inputs/first.txt" {
t.Fatalf("late file guard: paths=%v calls=%d err=%v", paths, *calls, err)
}
dir := filepath.Join(root, "alice", "conv", "inputs")
if body, err := os.ReadFile(filepath.Join(dir, "first.txt")); err != nil || string(body) != "first" {
t.Fatalf("first file should predate rejection: %q, %v", body, err)
}
if _, err := os.Stat(filepath.Join(dir, "second.txt")); !os.IsNotExist(err) {
t.Fatalf("late second file was written: %v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := c.stageInputsChecked(cancelled, core.Conversation{ID: "other", Owner: "alice"}, core.EffectiveSettings{}, nil, nil); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled stage: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "alice", "other")); !os.IsNotExist(err) {
t.Fatalf("cancelled stage created directory: %v", err)
}
}
func TestCheckedPersistTextRejectsBeforeWrite(t *testing.T) {
root := t.TempDir()
m := &Manager{workspaceRoot: root}
c := checkedFiles(t, m)
dir := filepath.Join(root, "alice", "conv", "inputs")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
docs := []core.ParsedDocument{{
Meta: core.DocMeta{Filename: "minutes.docx"},
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("substantial meeting content ", 20)}},
}}
denied := errors.New("account disabled")
check, calls := rejectAt(1, denied)
if err := c.persistInputTextsChecked(context.Background(), "alice", "conv", docs, check); !errors.Is(err, denied) || *calls != 1 {
t.Fatalf("persist guard: calls=%d err=%v", *calls, err)
}
if _, err := os.Stat(filepath.Join(dir, "minutes.md")); !os.IsNotExist(err) {
t.Fatalf("persist wrote after rejection: %v", err)
}
if err := c.persistInputTextsChecked(context.Background(), "alice", "conv", docs, nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "minutes.md")); err != nil {
t.Fatalf("permitted persist missing: %v", err)
}
}
func TestCheckedSubagentTranscriptStopsBeforeAppendAndDoneMarker(t *testing.T) {
root := t.TempDir()
m := &Manager{workspaceRoot: root}
c := checkedFiles(t, m)
run := core.Run{ID: "child", Owner: "alice", ConversationID: "conv"}
denied := errors.New("account disabled")
check, calls := rejectAt(2, denied)
if err := c.writeSubagentRequestChecked(context.Background(), run, "agent", "name", "task", "", true, check); !errors.Is(err, denied) || *calls != 2 {
t.Fatalf("request guard: calls=%d err=%v", *calls, err)
}
dir := filepath.Join(root, "alice", "conv", "subagents", "child")
if _, err := os.Stat(filepath.Join(dir, "request.json")); !os.IsNotExist(err) {
t.Fatalf("request written after rejection: %v", err)
}
if err := c.writeSubagentRequestChecked(context.Background(), run, "agent", "name", "task", "", true, nil); err != nil {
t.Fatal(err)
}
check, calls = rejectAt(4, denied) // mkdir, open, first append, second append
parts := []core.Part{{Type: core.PartText, Text: "first"}, {Type: core.PartText, Text: "second"}}
if err := c.writeSubagentTranscriptChecked(context.Background(), run, parts, "completed", check); !errors.Is(err, denied) || *calls != 4 {
t.Fatalf("append guard: calls=%d err=%v", *calls, err)
}
body, err := os.ReadFile(filepath.Join(dir, "transcript.jsonl"))
if err != nil || !strings.Contains(string(body), "first") || strings.Contains(string(body), "second") {
t.Fatalf("transcript after rejection: %q, %v", body, err)
}
if _, err := os.Stat(filepath.Join(dir, "done.json")); !os.IsNotExist(err) {
t.Fatalf("done marker written after rejection: %v", err)
}
}
func TestCheckedResumePassesOwnerAndStopsBeforeRedispatch(t *testing.T) {
c, st := newSpawnEnv(t, &gateRunner{})
c.mgr.runner = noopRunner{}
c.mgr.SetWorkspaceRoot(t.TempDir())
parent := c.run
if err := st.CreateRun(&parent); err != nil {
t.Fatal(err)
}
child := core.Run{
ID: store.NewID("run"), ParentRunID: parent.ID, ConversationID: parent.ConversationID,
ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent,
ProfileID: parent.ProfileID, Status: core.RunRunning, CreatedAt: time.Now().UTC(),
}
if err := st.CreateRun(&child); err != nil {
t.Fatal(err)
}
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "name", "task", "", true)
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, err := st.SubagentRunIDs(parent.ConversationID)
if err != nil {
t.Fatal(err)
}
denied := errors.New("account disabled")
var checkedOwner string
c.mgr.ResumeInterruptedChildrenChecked(context.Background(), func(_ context.Context, owner string) error {
checkedOwner = owner
return denied
})
if checkedOwner != child.Owner {
t.Fatalf("resume checked owner %q, want %q", checkedOwner, child.Owner)
}
after, err := st.SubagentRunIDs(parent.ConversationID)
if err != nil || len(after) != len(before) {
t.Fatalf("denied resume spawned child: before=%d after=%d err=%v", len(before), len(after), err)
}
if _, err := os.Stat(filepath.Join(c.mgr.subagentDir(child.Owner, child.ConversationID, child.ID), "resumed.json")); !os.IsNotExist(err) {
t.Fatalf("denied resume wrote marker: %v", err)
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ func (m *Manager) maybeGenUIAuto(owner, convID, reason string) {
ConversationID: convID, Role: core.RoleSystem,
Parts: []core.Part{{Type: core.PartText, Text: text}},
}
_ = m.st.AppendMessage(&msg)
_ = m.appendMessageForEffectiveAccount(owner, &msg)
}
func planStageChanged(prev, next core.PlanSnapshot) bool {
+49 -4
View File
@@ -1,6 +1,7 @@
package runmgr
import (
"context"
"fmt"
"os"
"path/filepath"
@@ -19,10 +20,20 @@ type WorkspaceFile struct {
// StageWorkspaceInputs 把原件和可选默认模板写到 <root>/<owner>/<conversation>/inputs/。
// root 为空时不写(工作区未配置)。返回工作区相对路径。同一批里重名的文件加序号。
func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFile, templateName string, template []byte) ([]string, error) {
return StageWorkspaceInputsChecked(context.Background(), root, owner, conversation, files, templateName, template, nil)
}
// StageWorkspaceInputsChecked rechecks the actor immediately before each
// directory/file mutation. The caller supplies its effective-account check;
// check and filesystem syscall cannot be atomic with an account DB commit.
func StageWorkspaceInputsChecked(ctx context.Context, root, owner, conversation string, files []WorkspaceFile, templateName string, template []byte, check func(context.Context) error) ([]string, error) {
if strings.TrimSpace(root) == "" {
return nil, nil
}
dir := filepath.Join(root, owner, conversation, "inputs")
if err := checkFileEffect(ctx, check); err != nil {
return nil, err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
@@ -34,6 +45,9 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
continue
}
name = uniqueInputName(name, used)
if err := checkFileEffect(ctx, check); err != nil {
return rels, err
}
if err := os.WriteFile(filepath.Join(dir, name), file.Content, 0o644); err != nil {
return rels, err
}
@@ -47,6 +61,9 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
ext = filepath.Ext(base)
}
name := "_default-template" + ext
if err := checkFileEffect(ctx, check); err != nil {
return rels, err
}
if err := os.WriteFile(filepath.Join(dir, name), template, 0o644); err != nil {
return rels, err
}
@@ -55,13 +72,29 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
return rels, nil
}
func checkFileEffect(ctx context.Context, check func(context.Context) error) error {
if err := ctx.Err(); err != nil {
return err
}
if check != nil {
return check(ctx)
}
return nil
}
// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
// 原本就是文本类(md/txt/csv)的跳过;解析文本过短(<200 字)视为解析失败跳过。
func PersistInputTexts(root, owner, conversation string, attachments []core.ParsedDocument) {
_ = PersistInputTextsChecked(context.Background(), root, owner, conversation, attachments, nil)
}
// PersistInputTextsChecked propagates a failed final check or file write so a
// guarded caller stops before later extracted files are published.
func PersistInputTextsChecked(ctx context.Context, root, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error {
if strings.TrimSpace(root) == "" {
return
return nil
}
dir := filepath.Join(root, owner, conversation, "inputs")
for _, doc := range attachments {
@@ -82,10 +115,14 @@ func PersistInputTexts(root, owner, conversation string, attachments []core.Pars
if _, err := os.Stat(out); err == nil {
continue // 已有同名解析文本(同一附件重发)不覆盖
}
if err := os.WriteFile(out, []byte(text), 0o644); err == nil {
_ = out
if err := checkFileEffect(ctx, check); err != nil {
return err
}
if err := os.WriteFile(out, []byte(text), 0o644); err != nil {
return err
}
}
return nil
}
// ListWorkspaceInputs 列出会话工作区 inputs/ 目录的相对路径(原样返回给环境段)。
@@ -110,6 +147,10 @@ func ListWorkspaceInputs(root, owner, conversation string, staged []string) []st
}
func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile) ([]string, error) {
return m.stageInputsChecked(context.Background(), conv, eff, files, nil)
}
func (m *Manager) stageInputsChecked(ctx context.Context, conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile, check func(context.Context) error) ([]string, error) {
var templateName string
var template []byte
if id := strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")); id != "" && m.st != nil {
@@ -119,7 +160,11 @@ func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings
}
}
}
return StageWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template)
return StageWorkspaceInputsChecked(ctx, m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template, check)
}
func (m *Manager) persistInputTextsChecked(ctx context.Context, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error {
return PersistInputTextsChecked(ctx, m.workspaceRoot, owner, conversation, attachments, check)
}
func safeInputName(name string) (string, bool) {
+143
View File
@@ -0,0 +1,143 @@
package runmgr
import (
"agent-system-backend/internal/rag"
"agent-system-backend/internal/store"
core "agent-system-core"
"context"
"encoding/json"
"errors"
"testing"
)
func TestRunKnowledgeResourceAuthorization(t *testing.T) {
rc, st := newExecEnv(t)
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "allowed-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery published"}, core.KnowledgeDoc{ID: "private-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery private"}, core.KnowledgeDoc{ID: "restricted-self", Owner: "alice", ProjectID: rc.run.ProjectID, Chunk: "resourcequery self"}); err != nil {
t.Fatal(err)
}
rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil))
rc.mgr.SetSharedKnowledgeAllow(func(string, core.ID) bool { return true })
rc.mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool {
return actor == "alice" && d.ID == "allowed-slice" && d.Owner == "bob"
})
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "resourcequery"})
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "allowed-slice" {
t.Fatalf("model-context resources: %+v %v", hits, err)
}
}
func TestProjectWorkRevocationGuardsDirectExecution(t *testing.T) {
rc, st := newExecEnv(t)
rc.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil })
conv, err := st.GetConversation("alice", rc.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if _, err := rc.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("direct run should reject: %v", err)
}
res := rc.ExecuteTool(core.ToolCallRequest{Name: "write_artifact", Arguments: json.RawMessage(`{"name":"forbidden.md","content":"x"}`)})
if res.Status != "error" {
t.Fatalf("revoked execution: %+v", res)
}
artifacts, err := st.ListArtifacts("alice", rc.run.ProjectID)
if err != nil || len(artifacts) != 0 {
t.Fatalf("revoked tool wrote: %+v %v", artifacts, err)
}
}
func TestRunKnowledgeResolvesTrustedProjectScope(t *testing.T) {
rc, st := newExecEnv(t)
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "bob", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "bob", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
t.Fatal(err)
}
rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil))
rc.mgr.SetKnowledgeResourceAllow(func(string, core.KnowledgeDoc) bool { return true })
rc.mgr.SetKnowledgeProjectScope(func(_ context.Context, actor string, id core.ID) (core.ID, error) {
if actor != "alice" || id != rc.run.ProjectID {
t.Fatalf("untrusted scope actor=%s project=%s", actor, id)
}
return "domain-current", nil
})
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "scopeneedle"})
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
t.Fatalf("model current scope: %+v %v", hits, err)
}
}
func TestProjectWorkRevocationBlocksQueuedHITL(t *testing.T) {
c, st := newExecEnv(t)
c.run.ID = "revoked-run"
c.run.Status = core.RunWaitingHITL
if err := st.CreateRun(&c.run); err != nil {
t.Fatal(err)
}
h := &runHandle{hitlCh: make(chan hitlAnswer, 1)}
c.mgr.active = map[core.ID]*runHandle{c.run.ID: h}
card := core.HITLCard{ID: "revoked-card", RunID: c.run.ID, ConversationID: c.run.ConversationID}
if err := st.SaveHITLCard("alice", &card); err != nil {
t.Fatal(err)
}
c.mgr.SetProjectWorkAuthorization(func(_ context.Context, owner string, id core.ID) (bool, error) {
if owner != "alice" || id != c.run.ProjectID {
t.Fatalf("untrusted resolution identity owner=%s project=%s", owner, id)
}
return false, nil
})
if _, _, err := c.mgr.ResolveHITL("alice", card.ID, core.HITLResponse{ChoiceID: "yes"}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("revoked confirmation should reject: %v", err)
}
persisted, err := st.GetHITLCard("alice", card.ID)
if err != nil || persisted.Resolved || len(h.hitlCh) != 0 {
t.Fatalf("revoked confirmation changed gate: card=%+v channel=%d error=%v", persisted, len(h.hitlCh), err)
}
run, err := st.GetRunByID(c.run.ID)
if err != nil || run.Status != core.RunWaitingHITL {
t.Fatalf("revoked confirmation resumed run: %+v %v", run, err)
}
payload, _ := json.Marshal(HITLCommandPayload{Owner: "alice", CardID: card.ID, Kind: "yes"})
c.mgr.applyHITLCommand(ownedCommand{CommandID: "queued-approval", Payload: string(payload)})
if len(h.hitlCh) != 0 {
t.Fatal("queued approval delivered a revoked answer")
}
run, err = st.GetRunByID(c.run.ID)
if err != nil || run.Status != core.RunWaitingHITL {
t.Fatalf("queued approval resumed run: %+v %v", run, err)
}
}
func TestProjectWorkRevocationAfterToolConfirmation(t *testing.T) {
for _, choice := range []string{"yes", "session"} {
t.Run(choice, func(t *testing.T) {
c, st := newExecEnv(t)
c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)}
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: choice}}
c.prof.Tools.Confirm = []string{"write_artifact"}
ready := true
c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { ok := ready; ready = false; return ok, nil })
res := c.ExecuteTool(core.ToolCallRequest{CallID: "revoked-call", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"revoked.txt","content":"x"}`)})
artifacts, err := st.ListArtifacts("alice", c.run.ProjectID)
if err != nil {
t.Fatal(err)
}
if res.Status != "error" || len(artifacts) != 0 {
t.Fatalf("tool executed after revoked confirmation: status=%s artifacts=%d", res.Status, len(artifacts))
}
if c.mgr.sessionAllows(c.run.ConversationID, "write_artifact") {
t.Fatal("revoked confirmation granted session permission")
}
})
}
}
func TestProjectWorkRevocationBlocksDirectArtifactWrite(t *testing.T) {
c, st := newExecEnv(t)
c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil })
if _, err := c.WriteArtifact("revoked.txt", "data", "text/plain", []byte("private")); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("direct artifact should reject: %v", err)
}
artifacts, err := st.ListArtifacts("alice", c.run.ProjectID)
if err != nil || len(artifacts) != 0 {
t.Fatalf("revoked artifact was written: %+v %v", artifacts, err)
}
}
+18 -2
View File
@@ -79,7 +79,23 @@ func TestMaterialIntakeAttachmentOnlyLifecycle(t *testing.T) {
}
close(hold.release)
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
// 等完成去重门禁本身(CardDone + materialActive 已释放),不要等 notice
// 副作用——deliverNotices 早于 defer materialKeyRelease,systemTexts 先可见会 race。
key := materialKey(materialDocs())
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil || len(cards) != 1 {
return false
}
card := cards[0]
if card.Title != materialCardTitle || card.Status != core.CardDone || card.Tone == "danger" {
return false
}
c.mgr.materialMu.Lock()
active := c.mgr.materialActive != nil && c.mgr.materialActive[string(c.run.ConversationID)][key]
c.mgr.materialMu.Unlock()
return !active
})
// 完成后同批再发:已完成说明,同样不重跑;卡片回投保留材料键。
c3 := secondRunCtx(c, materialDocs(), "")
@@ -259,7 +275,7 @@ func TestMaterialQueuedWhenFull(t *testing.T) {
return false
}
for _, card := range cards {
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), "执行中") {
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), core.TaskSummaryStatusRunning) {
return true
}
}
+20 -5
View File
@@ -12,9 +12,11 @@ import (
// storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。
// owner/项目态由适配器持有(端口本身不传租户态)。
type storeMemory struct {
st *store.Store
owner string
projectID string
st *store.Store
owner string
projectID string
checked bool
allowImplicitDefault bool
}
// NewStoreMemory 构建端口实现(owner 为空时用默认租户)。
@@ -25,6 +27,10 @@ func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store {
return &storeMemory{st: st, owner: owner, projectID: projectID}
}
func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) memory.Store {
return &storeMemory{st: st, owner: owner, projectID: projectID, checked: true, allowImplicitDefault: allowImplicitDefault}
}
// Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义),
// 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。
func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) {
@@ -59,6 +65,9 @@ func (c *runCtx) StoreMemory() memory.Store {
if c.mgr == nil || c.mgr.st == nil {
return nil
}
if c.mgr.accountEffectiveAuthorization != nil {
return newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal)
}
return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID)
}
@@ -73,10 +82,16 @@ func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error {
if created.IsZero() {
created = now
}
_, err := m.st.PutMemory(m.owner, core.MemoryEntry{
entry := core.MemoryEntry{
ID: e.ID, Scope: e.Scope, ProjectID: core.ID(projectID), Type: e.Type,
Content: e.Content, Source: e.Source, Confidence: e.Confidence,
CreatedAt: created, ExpiresAt: e.ExpiresAt,
})
}
var err error
if m.checked {
_, err = m.st.PutMemoryAccountChecked(m.owner, entry, m.allowImplicitDefault)
} else {
_, err = m.st.PutMemory(m.owner, entry)
}
return err
}
+12 -1
View File
@@ -1,6 +1,7 @@
package runmgr
import (
"context"
"fmt"
"log"
"path/filepath"
@@ -80,6 +81,11 @@ func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Resul
if err != nil {
return checkpoint.Result{}, err
}
if m.accountEffectiveAuthorization != nil {
if err := m.authorizeAccountEffective(context.Background(), owner); err != nil {
return checkpoint.Result{}, err
}
}
res, err := cpLog.Rewind(messageID, ordered)
if err != nil {
return checkpoint.Result{}, err
@@ -93,7 +99,12 @@ func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Resul
ConversationID: convID, Role: core.RoleSystem,
Parts: []core.Part{{Type: core.PartText, Text: note}},
}
if err := m.st.AppendMessage(&msg); err != nil {
if m.accountEffectiveAuthorization != nil {
err = m.st.AppendMessageWithRequestAccountChecked(owner, m.accountAllowImplicitLocal, &msg, "")
} else {
err = m.st.AppendMessage(&msg)
}
if err != nil {
return res, err
}
return res, nil
+557 -86
View File
File diff suppressed because it is too large Load Diff
+85 -14
View File
@@ -1,6 +1,7 @@
package runmgr
import (
"context"
"encoding/json"
"fmt"
"log"
@@ -39,51 +40,94 @@ type subagentRequest struct {
// writeSubagentRequest 在派生时落 request.json 与空转录(失败只记日志,
// 不阻断派生——转录是辅助面)。
func (m *Manager) writeSubagentRequest(run core.Run, agentID, name, task, hint string, detach bool) {
if err := m.writeSubagentRequestChecked(context.Background(), run, agentID, name, task, hint, detach, nil); err != nil {
log.Printf("[runmgr] subagent request %s: %v", run.ID, err)
}
}
// writeSubagentRequestChecked keeps transcript file effects behind an optional
// live account check. Failures stop later file writes; this auxiliary record
// cannot be committed atomically with the account database.
func (m *Manager) writeSubagentRequestChecked(ctx context.Context, run core.Run, agentID, name, task, hint string, detach bool, check func(context.Context) error) error {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
return
return nil
}
if err := checkFileEffect(ctx, check); err != nil {
return err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Printf("[runmgr] subagent dir %s: %v", run.ID, err)
return
return err
}
body, _ := json.MarshalIndent(subagentRequest{
AgentID: agentID, Name: name, Task: task, Hint: hint, Detach: detach,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}, "", " ")
if err := checkFileEffect(ctx, check); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "request.json"), body, 0o644); err != nil {
log.Printf("[runmgr] subagent request %s: %v", run.ID, err)
return err
}
// 转录文件先建(append 语义清晰可见)。
if _, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644); err != nil {
log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err)
if err := checkFileEffect(ctx, check); err != nil {
return err
}
f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
return f.Close()
}
// writeSubagentTranscript 把本次收集到的 parts 追加进转录并落 done 标记。
// status 是终态(completed/failed/cancelled/interrupted)。
func (m *Manager) writeSubagentTranscript(run core.Run, parts []core.Part, status string) {
if err := m.writeSubagentTranscriptChecked(context.Background(), run, parts, status, nil); err != nil {
log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err)
}
}
func (m *Manager) writeSubagentTranscriptChecked(ctx context.Context, run core.Run, parts []core.Part, status string, check func(context.Context) error) error {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
return
return nil
}
if err := checkFileEffect(ctx, check); err != nil {
return err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
return err
}
if err := checkFileEffect(ctx, check); err != nil {
return err
}
f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
log.Printf("[runmgr] transcript open %s: %v", run.ID, err)
return
return err
}
enc := json.NewEncoder(f)
for i, p := range parts {
_ = enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)})
if err := checkFileEffect(ctx, check); err != nil {
_ = f.Close()
return err
}
if err := enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)}); err != nil {
_ = f.Close()
return err
}
}
if err := f.Close(); err != nil {
return err
}
_ = f.Close()
done, _ := json.Marshal(map[string]string{"status": status, "endedAt": time.Now().UTC().Format(time.RFC3339)})
if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil {
log.Printf("[runmgr] done marker %s: %v", run.ID, err)
if err := checkFileEffect(ctx, check); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil {
return err
}
return nil
}
// doneStatus 读 done.json 的 status(无文件或不可读按 "interrupted" 处理——
@@ -122,12 +166,24 @@ func readSubagentRequest(dir string) (*subagentRequest, error) {
// 未完成未续跑的,用原请求重新派生(后台),并把前次转录路径写进 hint
// 供模型读取后继续(N2 WP5 的「可恢复」)。幂等:done/resumed 标记跳过。
func (m *Manager) ResumeInterruptedChildren() {
m.ResumeInterruptedChildrenChecked(context.Background(), nil)
}
// ResumeInterruptedChildrenChecked rechecks each run owner before redispatch
// and before writing the resumed marker. A disable between redispatch and the
// marker can still leave a spawned run without this file; DB/file atomicity is
// unavailable, so callers must keep the run admission guard as well.
func (m *Manager) ResumeInterruptedChildrenChecked(ctx context.Context, check func(context.Context, string) error) {
runs, err := m.st.ListInterruptedSubagentRuns()
if err != nil {
log.Printf("[runmgr] resume scan: %v", err)
return
}
for _, run := range runs {
if err := ctx.Err(); err != nil {
log.Printf("[runmgr] resume cancelled: %v", err)
return
}
// 恢复隔离(协作存储 U3):未知外部副作用的 run 不自动续跑——hold
// 未解除即跳过;判定出错同样跳过(fail-closed:宁可人工核对,不盲重派)。
if held, herr := m.st.HasOpenRecoveryHold(string(run.ID), string(run.ConversationID)); herr != nil || held {
@@ -162,6 +218,10 @@ func (m *Manager) ResumeInterruptedChildren() {
}
hint := req.Hint + "\n(本任务是前次中断后的自动续跑;前次转录在 " +
filepath.Join(dir, "transcript.jsonl") + ",可读取后继续。)"
if err := checkOwnerFileEffect(ctx, run.Owner, check); err != nil {
log.Printf("[runmgr] resume skip %s: account check: %v", run.ID, err)
continue
}
newID, err := m.spawnBackground(parent, req.AgentID, req.Name, req.Task, hint)
if err != nil {
log.Printf("[runmgr] resume spawn %s: %v", run.ID, err)
@@ -171,6 +231,10 @@ func (m *Manager) ResumeInterruptedChildren() {
"newRun": newID,
"resumedAt": time.Now().UTC().Format(time.RFC3339),
})
if err := checkOwnerFileEffect(ctx, run.Owner, check); err != nil {
log.Printf("[runmgr] resumed marker skip %s: account check: %v", run.ID, err)
continue
}
if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil {
log.Printf("[runmgr] resumed marker %s: %v", run.ID, err)
}
@@ -178,6 +242,13 @@ func (m *Manager) ResumeInterruptedChildren() {
}
}
func checkOwnerFileEffect(ctx context.Context, owner string, check func(context.Context, string) error) error {
if check == nil {
return checkFileEffect(ctx, nil)
}
return checkFileEffect(ctx, func(ctx context.Context) error { return check(ctx, owner) })
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
+1 -1
View File
@@ -45,7 +45,7 @@ type fakeRunCtx struct {
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入断言用)
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
bgCalls int
runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用)
runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用)
attachments []core.ParsedDocument // Attachments 返回值(双速入口前置提示测试用)
spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error)
execStatus string // 非空时覆盖 ExecuteTool 状态(R4 失败链测试)
+154
View File
@@ -0,0 +1,154 @@
package server
import (
"encoding/json"
"net/http"
"net/url"
"testing"
"agent-system-backend/internal/store"
)
func TestAdminAccountSearchFiltersAndVersionedCursor(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
alice, err := st.CreatePendingAccount("admin", "alice-search", store.AccountProfile{Name: "同名", Phone: "private-needle"})
if err != nil {
t.Fatal(err)
}
bob, err := st.CreatePendingAccount("admin", "bob-search", store.AccountProfile{Name: "同名", Phone: "other"})
if err != nil {
t.Fatal(err)
}
search := func(query url.Values) (int, struct {
Accounts []struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
} `json:"accounts"`
NextCursor string `json:"nextCursor"`
}) {
response := do(h, "GET", "/api/admin/users/search?"+query.Encode(), "", admin)
var page struct {
Accounts []struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
} `json:"accounts"`
NextCursor string `json:"nextCursor"`
}
_ = json.Unmarshal(response.Body.Bytes(), &page)
return response.Code, page
}
params := url.Values{"query": {"同名"}, "mode": {"fuzzy"}, "fields": {"name"}, "limit": {"1"}}
code, first := search(params)
if code != http.StatusOK || len(first.Accounts) != 1 || first.Accounts[0].UserID != alice.ID || first.NextCursor == "" {
t.Fatalf("first search page: %d %+v", code, first)
}
params.Set("cursor", first.NextCursor)
other := New(st, srv.mgr, srv.reg, srv.prof, srv.set, srv.plugins)
other.Auth = srv.Auth
h = other.Handler()
code, second := search(params)
if code != http.StatusOK || len(second.Accounts) != 1 || second.Accounts[0].UserID != bob.ID || second.NextCursor != "" {
t.Fatalf("second search page on another server: %d %+v", code, second)
}
params.Del("cursor")
params.Set("query", "private-needle")
params.Set("fields", "phone")
params.Set("mode", "exact")
code, contact := search(params)
if code != http.StatusOK || len(contact.Accounts) != 1 || contact.Accounts[0].UserID != alice.ID {
t.Fatalf("authorized contact search: %d %+v", code, contact)
}
params.Set("query", "同名")
params.Set("fields", "name")
params.Set("mode", "fuzzy")
params.Set("cursor", first.NextCursor)
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "changed-private"}); err != nil {
t.Fatal(err)
}
code, _ = search(params)
if code != http.StatusConflict {
t.Fatalf("private profile edit did not expire system cursor: %d", code)
}
}
func TestSelfProfileRequiresPasswordAuthenticatedFullSession(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
hash, err := store.HashPassword("self-profile-password")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("legacy-self", "", hash, false); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("legacy-self"); err != nil {
t.Fatal(err)
}
legacyCookie, err := st.CreateSession("legacy-self")
if err != nil {
t.Fatal(err)
}
if rec := do(h, "GET", "/api/me", "", legacyCookie); rec.Code != http.StatusOK {
t.Fatalf("historical full session lost ordinary identity: %d", rec.Code)
}
if rec := do(h, "GET", "/api/me/profile", "", legacyCookie); rec.Code != http.StatusForbidden {
t.Fatalf("unknown session lineage read profile: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "PATCH", "/api/me/profile", `{"expectedProfileVersion":1,"patch":{"name":"injected"}}`, legacyCookie); rec.Code != http.StatusForbidden {
t.Fatalf("unknown session lineage edited profile: %d %s", rec.Code, rec.Body.String())
}
login := do(h, "POST", "/api/auth/login", `{"owner":"legacy-self","password":"self-profile-password"}`, "")
if login.Code != http.StatusOK {
t.Fatalf("password login: %d %s", login.Code, login.Body.String())
}
if rec := do(h, "GET", "/api/me/profile", "", cookieOf(login)); rec.Code != http.StatusOK {
t.Fatalf("verified password session could not read profile: %d %s", rec.Code, rec.Body.String())
}
}
func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
adminCookie := bootAdmin(t, h, st)
for _, body := range []string{
`{"owner":"blocked-password","password":"old-pass-9"}`,
`{"owner":"blocked-admin","isAdmin":true}`,
} {
if rec := do(h, "POST", "/api/admin/users", body, adminCookie); rec.Code != http.StatusBadRequest {
t.Fatalf("unsafe create accepted: %d %s", rec.Code, rec.Body.String())
}
}
if _, err := st.GetUser("blocked-password"); err != store.ErrNotFound {
t.Fatalf("unsafe create had side effect: %v", err)
}
rec := do(h, "POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"同名"}}`, adminCookie)
if rec.Code != http.StatusOK {
t.Fatalf("pending create: %d %s", rec.Code, rec.Body.String())
}
var created struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
ProfileVersion int64 `json:"profileVersion"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.UserID == "" || created.Owner != "alice" || created.ProfileVersion != 1 {
t.Fatalf("create projection: %+v %v", created, err)
}
rec = do(h, "PATCH", "/api/admin/users/"+created.UserID+"/profile", `{"expectedProfileVersion":1,"patch":{"phone":"123"}}`, adminCookie)
if rec.Code != http.StatusOK {
t.Fatalf("profile patch: %d %s", rec.Code, rec.Body.String())
}
rec = do(h, "PATCH", "/api/admin/users/"+created.UserID+"/profile", `{"expectedProfileVersion":2,"patch":{"isAdmin":"true"}}`, adminCookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("privilege field accepted: %d %s", rec.Code, rec.Body.String())
}
rec = do(h, "PUT", "/api/admin/users/"+created.UserID+"/state", `{"expectedStateVersion":1,"enabled":false}`, adminCookie)
if rec.Code != http.StatusOK {
t.Fatalf("disable: %d %s", rec.Code, rec.Body.String())
}
account, err := st.GetAccount(created.UserID)
if err != nil || account.Enabled || account.Profile.Phone != "123" {
t.Fatalf("state/profile after disable: %+v %v", account, err)
}
}
+8
View File
@@ -0,0 +1,8 @@
package server
// checkActorBeforeExternalEffect is the last effective-state check before a
// process-local or filesystem effect. Such effects cannot share the account
// database transaction, so callers must also use cancellation where available.
func (s *Server) checkActorBeforeExternalEffect(actor string) error {
return s.st.CheckEffectiveAccountForExternalEffect(actor, s.Auth == nil || !s.Auth.Enabled)
}
+204
View File
@@ -0,0 +1,204 @@
package server
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"agent-system-backend/internal/store"
)
type adminSearchCursor struct {
KeyVersion int `json:"k"`
Version string `json:"v"`
Owner string `json:"o"`
UserID string `json:"u"`
}
func (s *Server) signAdminCursor(cursor adminSearchCursor) string {
cursor.KeyVersion = s.accountSearchKey.Version
data, _ := json.Marshal(cursor)
mac := hmac.New(sha256.New, s.accountSearchKey.Secret[:])
_, _ = mac.Write([]byte("iam-admin-search-cursor-v1\x00"))
_, _ = mac.Write(data)
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (s *Server) parseAdminCursor(encoded, version string) (adminSearchCursor, bool) {
var cursor adminSearchCursor
payload, signature, found := strings.Cut(encoded, ".")
if !found {
return cursor, false
}
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil || len(data) > 1024 {
return cursor, false
}
provided, err := base64.RawURLEncoding.DecodeString(signature)
if err != nil {
return cursor, false
}
mac := hmac.New(sha256.New, s.accountSearchKey.Secret[:])
_, _ = mac.Write([]byte("iam-admin-search-cursor-v1\x00"))
_, _ = mac.Write(data)
if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &cursor) != nil || cursor.KeyVersion != s.accountSearchKey.Version || cursor.Version != version || cursor.Owner == "" || cursor.UserID == "" {
return adminSearchCursor{}, false
}
return cursor, true
}
func searchAccountField(account store.Account, field string) string {
switch field {
case "userId":
return account.ID
case "owner":
return account.Owner
case "name":
return account.Profile.Name
case "phone":
return account.Profile.Phone
case "email":
return account.Profile.Email
case "staffNo":
return account.Profile.StaffNo
case "department":
return account.Profile.Department
case "notes":
return account.Profile.Notes
}
return ""
}
func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
if s.accountSearchKeyErr != nil || s.accountSearchKey.Version < 1 {
http.Error(w, "account directory signing unavailable", http.StatusServiceUnavailable)
return
}
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
params := r.URL.Query()
query := strings.TrimSpace(params.Get("query"))
if !utf8.ValidString(query) || utf8.RuneCountInString(query) > 128 {
http.Error(w, "invalid account search query", http.StatusBadRequest)
return
}
for _, ch := range query {
if unicode.IsControl(ch) {
http.Error(w, "invalid account search query", http.StatusBadRequest)
return
}
}
mode := params.Get("mode")
if mode == "" {
mode = "fuzzy"
}
if mode != "exact" && mode != "fuzzy" {
http.Error(w, "unknown account search mode", http.StatusBadRequest)
return
}
status := params.Get("status")
if status == "" {
status = "all"
}
if status != "all" && status != "enabled" && status != "disabled" {
http.Error(w, "unknown account status filter", http.StatusBadRequest)
return
}
limit := 20
if raw := params.Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 100 {
http.Error(w, "invalid account search limit", http.StatusBadRequest)
return
}
limit = parsed
}
fields := []string{"owner", "name", "userId"}
if raw := params.Get("fields"); raw != "" {
fields = strings.Split(raw, ",")
}
allowed := map[string]bool{"owner": true, "name": true, "userId": true, "phone": true, "email": true,
"staffNo": true, "department": true, "notes": true}
seen := map[string]bool{}
for _, field := range fields {
if !allowed[field] || seen[field] {
http.Error(w, "unknown or duplicate account search field", http.StatusBadRequest)
return
}
seen[field] = true
}
sort.Strings(fields)
accounts, revision, err := s.st.ListAdminAccountsForActor(actor)
if err != nil {
writeStoreErr(w, err)
return
}
visibleRevision := sha256.Sum256([]byte(revision.Private))
directoryVersion := hex.EncodeToString(visibleRevision[:])
fingerprint, _ := json.Marshal(struct {
Actor, Query, Mode, Status, Revision string
Fields []string
}{actor, query, mode, status, directoryVersion, fields})
digest := sha256.Sum256(fingerprint)
version := hex.EncodeToString(digest[:])
var after adminSearchCursor
if token := params.Get("cursor"); token != "" {
var valid bool
after, valid = s.parseAdminCursor(token, version)
if !valid {
http.Error(w, "account search cursor expired or invalid", http.StatusConflict)
return
}
}
page := make([]store.Account, 0, limit+1)
for _, account := range accounts {
if status == "enabled" && !account.Enabled || status == "disabled" && account.Enabled {
continue
}
if after.Owner != "" && (account.Owner < after.Owner || account.Owner == after.Owner && account.ID <= after.UserID) {
continue
}
if query != "" {
matched := false
for _, field := range fields {
value := searchAccountField(account, field)
if mode == "exact" && strings.EqualFold(value, query) || mode == "fuzzy" && strings.Contains(strings.ToLower(value), strings.ToLower(query)) {
matched = true
break
}
}
if !matched {
continue
}
}
page = append(page, account)
if len(page) > limit {
break
}
}
more := len(page) > limit
if more {
page = page[:limit]
}
items := make([]map[string]any, 0, len(page))
for _, account := range page {
items = append(items, accountProfileJSON(account))
}
next := ""
if more {
last := page[len(page)-1]
next = s.signAdminCursor(adminSearchCursor{Version: version, Owner: last.Owner, UserID: last.ID})
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{"accounts": items, "nextCursor": next, "directoryVersion": directoryVersion})
}
+78
View File
@@ -0,0 +1,78 @@
package server
import (
"bufio"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
core "agent-system-core"
)
func TestAccountDisableStopsNextSSEBusinessFrame(t *testing.T) {
srv, st := newAuthTestServer(t)
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("sse-user", "", "user-hash", false); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("sse-user"); err != nil {
t.Fatal(err)
}
user, err := st.GetAccountByOwner("sse-user")
if err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("sse-user", "stream")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("sse-user", project.ID, "stream", "")
if err != nil {
t.Fatal(err)
}
selected := make(chan core.StreamEvent, 1)
srv.Subscribe = func(string) (<-chan core.StreamEvent, func()) { return selected, func() {} }
token, err := st.CreateSession("sse-user")
if err != nil {
t.Fatal(err)
}
httpServer := httptest.NewServer(srv.Handler())
defer httpServer.Close()
request, err := http.NewRequest(http.MethodGet, httpServer.URL+"/api/events?conversationId="+conv.ID, nil)
if err != nil {
t.Fatal(err)
}
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: token})
client := &http.Client{Timeout: 3 * time.Second}
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
reader := bufio.NewReader(response.Body)
first, err := reader.ReadString('\n')
if err != nil || !strings.HasPrefix(first, "retry:") {
t.Fatalf("SSE did not open: %q %v", first, err)
}
if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion, false); err != nil {
t.Fatal(err)
}
selected <- core.StreamEvent{ConversationID: conv.ID, Seq: 1, Type: core.EvToolOutput,
Payload: json.RawMessage(`{"text":"must-not-stream"}`)}
tail, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("revoked stream did not close: %v", err)
}
if strings.Contains(string(tail), "must-not-stream") {
t.Fatal("disabled account received the next SSE business frame")
}
if _, _, ok := st.SessionInfo(token); ok {
t.Fatal("disabled account session still authenticates")
}
}
+208
View File
@@ -0,0 +1,208 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
type pausedRequestBody struct {
reader io.Reader
entered chan struct{}
release chan struct{}
once sync.Once
}
func (b *pausedRequestBody) Read(p []byte) (int, error) {
b.once.Do(func() { close(b.entered) })
<-b.release
return b.reader.Read(p)
}
func (*pausedRequestBody) Close() error { return nil }
func TestProjectCreateCannotCommitAfterAccountDisable(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
_ = bootAdmin(t, h, st)
hash, err := store.HashPassword("actor-password")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("actor", "Actor", hash, false); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("actor"); err != nil {
t.Fatal(err)
}
key, err := st.CreateAPIKey("actor", "request")
if err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner("actor")
if err != nil {
t.Fatal(err)
}
body := &pausedRequestBody{reader: strings.NewReader(`{"name":"late-project"}`), entered: make(chan struct{}), release: make(chan struct{})}
req := httptest.NewRequest(http.MethodPost, "/api/projects", body)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
h.ServeHTTP(rec, req)
close(done)
}()
select {
case <-body.entered:
case <-time.After(3 * time.Second):
t.Fatal("project request never reached its body")
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
close(body.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("request did not finish after disable")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("disabled request committed: status=%d body=%s", rec.Code, rec.Body.String())
}
var n int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM projects WHERE owner='actor'`).Scan(&n); err != nil || n != 0 {
t.Fatalf("late project row count=%d err=%v", n, err)
}
}
func TestUserMessageCannotCommitAfterAccountDisable(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
_ = bootAdmin(t, h, st)
key, err := createLegacyAPIKeyFixture(t, st, "actor", "request")
if err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner("actor")
if err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("actor", "fixture")
if err != nil {
t.Fatal(err)
}
conversation, err := st.CreateConversation("actor", string(project.ID), "fixture", "")
if err != nil {
t.Fatal(err)
}
body := &pausedRequestBody{reader: strings.NewReader(`{"text":"late message"}`), entered: make(chan struct{}), release: make(chan struct{})}
req := httptest.NewRequest(http.MethodPost, "/api/conversations/"+string(conversation.ID)+"/messages", body)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
h.ServeHTTP(rec, req)
close(done)
}()
select {
case <-body.entered:
case <-time.After(3 * time.Second):
t.Fatal("message request never reached its body")
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
close(body.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("message request did not finish after disable")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("disabled message request committed: status=%d body=%s", rec.Code, rec.Body.String())
}
var n int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM messages WHERE conversation_id=?`, conversation.ID).Scan(&n); err != nil || n != 0 {
t.Fatalf("late message count=%d err=%v", n, err)
}
}
func TestAdminCannotDownloadOtherOwnersThroughDatabaseBackup(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
rec := do(h, http.MethodPost, "/api/admin/backup", "{}", admin)
if rec.Code != http.StatusForbidden {
t.Fatalf("system admin received all-owner database backup: %d %s", rec.Code, rec.Body.String())
}
}
func TestRegenerationCannotChangeMessageAfterAccountDisable(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
_ = bootAdmin(t, h, st)
key, err := createLegacyAPIKeyFixture(t, st, "actor", "regenerate")
if err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner("actor")
if err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("actor", "fixture")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("actor", string(project.ID), "fixture", "")
if err != nil {
t.Fatal(err)
}
user := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "question"}}}
target := core.Message{ConversationID: conv.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "answer"}}}
if err := st.AppendMessage(&user); err != nil {
t.Fatal(err)
}
if err := st.AppendMessage(&target); err != nil {
t.Fatal(err)
}
body := &pausedRequestBody{reader: strings.NewReader(`{"messageId":"` + target.ID + `"}`), entered: make(chan struct{}), release: make(chan struct{})}
req := httptest.NewRequest(http.MethodPost, "/api/conversations/"+string(conv.ID)+"/regenerate", body)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
h.ServeHTTP(rec, req)
close(done)
}()
select {
case <-body.entered:
case <-time.After(3 * time.Second):
t.Fatal("regeneration request never reached its body")
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
close(body.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("regeneration did not finish after disable")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("disabled regeneration status=%d body=%s", rec.Code, rec.Body.String())
}
got, err := st.MessageByID(target.ID)
if err != nil || got.Excluded || got.VariantGroup != "" {
t.Fatalf("rejected regeneration changed target: %+v %v", got, err)
}
if _, _, ok := st.PeekRegenPending(string(conv.ID)); ok {
t.Fatal("rejected regeneration left marker")
}
}
+301 -70
View File
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync"
@@ -23,6 +24,19 @@ func decodeJSON(r *http.Request, v any) error {
return json.NewDecoder(r.Body).Decode(v)
}
func decodeStrictJSON(r *http.Request, v any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(v); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return errors.New("exactly one JSON object required")
}
return nil
}
// 登录失败退避(进程内;管理员发放制下用户量小,进程内即可,实例重启清零)。
var loginGuard = struct {
sync.Mutex
@@ -60,20 +74,75 @@ func loginClear(owner string) {
delete(loginGuard.fails, owner)
}
func setSessionCookie(w http.ResponseWriter, token string, maxAge int) {
// 兑换入口退避:共享库窗口计数(v29 redeem_attempts),多实例共担同一限流;
// Key 无属主可先行归因,只能按来源 IP 限流。
const (
redeemMaxFails = 10
redeemWindow = loginLockWindow
)
// requestSecure 判断请求是否经 HTTPS(直连 TLS 或反代 X-Forwarded-Proto 标头)。
func requestSecure(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookie, Value: token, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: maxAge,
SameSite: http.SameSiteLaxMode, MaxAge: maxAge, Secure: requestSecure(r),
})
}
func authStateJSON(st store.UserAuthState) map[string]any {
return map[string]any{
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin,
"mustChangePassword": st.MustChangePassword, "auth": true,
"mustChangePassword": st.MustChangePassword, "passwordSet": st.HasPassword,
"auth": true,
}
}
// redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。
// 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。
func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if s.Auth == nil || !s.Auth.Enabled {
http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed)
return
}
ip, _, _ := strings.Cut(r.RemoteAddr, ":")
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
ip = strings.TrimSpace(strings.Split(xf, ",")[0])
}
if blocked, err := s.st.RedeemBlocked(ip, redeemMaxFails, redeemWindow); err == nil && blocked {
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
return
}
var req struct {
Key string `json:"key"`
}
if err := decodeJSON(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key))
if err != nil {
_ = s.st.RecordRedeemFailure(ip) // 尽力记录;限流判定的权威在 RedeemBlocked
if errors.Is(err, store.ErrConflict) {
http.Error(w, "login key already used", http.StatusConflict)
return
}
http.Error(w, "invalid or expired login key", http.StatusUnauthorized)
return
}
setSessionCookie(w, r, token, int(10*60))
st, _ := s.st.GetUserAuthState(owner)
out := authStateJSON(st)
out["sessionScope"] = store.SessionScopeSetPasswordOnly
out["passwordChangeMode"] = store.PasswordChangeModeGrantSetup
out["reloginRequired"] = false
writeJSON(w, http.StatusOK, out)
}
// login:用户名+密码 → 会话。失败统一 401(不区分账号是否存在);
// 连续失败触发进程内退避(429)。
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
@@ -98,20 +167,35 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
return
}
st, ok := s.st.VerifyLogin(owner, req.Password)
st, ver, ok := s.st.VerifyLoginWithVersion(owner, req.Password)
if !ok {
loginFail(owner)
http.Error(w, "invalid owner or password", http.StatusUnauthorized)
return
}
loginClear(owner)
token, err := s.st.CreateSession(owner)
// 待改密/未设密:建受限会话(只能设密);否则正常会话。会话创建绑定
// 认证版本——密码验证在途期间账户被恢复/重置时本请求不得再建会话。
scope := store.SessionScopeFull
maxAge := 7 * 24 * 3600
if st.MustChangePassword || !st.HasPassword {
scope = store.SessionScopeSetPasswordOnly
maxAge = 10 * 60
}
token, err := s.st.CreateSessionVerified(owner, scope, time.Duration(maxAge)*time.Second, ver)
if err != nil {
if errors.Is(err, store.ErrConflict) {
http.Error(w, "credentials changed concurrently; retry login", http.StatusUnauthorized)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
setSessionCookie(w, token, 7*24*3600)
writeJSON(w, http.StatusOK, authStateJSON(st))
setSessionCookie(w, r, token, maxAge)
out := authStateJSON(st)
out["sessionScope"] = scope
out["passwordChangeMode"] = store.PasswordChangeModeCurrentRequired
writeJSON(w, http.StatusOK, out)
}
// logout:注销当前会话。
@@ -119,23 +203,30 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if tok := sessionToken(r); tok != "" {
s.st.DeleteSession(tok)
}
setSessionCookie(w, "", -1)
setSessionCookie(w, r, "", -1)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// changePassword:会话登录者改密。校验当前密码 → 新哈希落库 → 清 must_change →
// 吊销其余会话(保留当前)。重置后的首登改密走同一路径。
// changePassword:会话登录者改密。
// - 受限会话或 must_change/未设密:不要求 current;事务内复验会话与认证版本,
// 完成后销毁全部会话,要求重新密码登录(reloginRequired=true)。
// - 普通主动改密:要求 current;单事务完成校验、更新、吊销其他会话(保留当前)。
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
tok := sessionToken(r)
if tok == "" {
http.Error(w, "session login required (API keys do not own passwords)", http.StatusUnauthorized)
return
}
owner, ok := s.st.SessionOwner(tok)
owner, scope, ok := s.st.SessionInfo(tok)
if !ok {
http.Error(w, "session expired", http.StatusUnauthorized)
return
}
mode, err := s.st.SessionPasswordChangeMode(tok)
if err != nil {
writeStoreErr(w, err)
return
}
var req struct {
Current string `json:"current"`
Next string `json:"next"`
@@ -144,10 +235,6 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, ok := s.st.VerifyLogin(owner, req.Current); !ok {
http.Error(w, "current password incorrect", http.StatusUnauthorized)
return
}
if err := validPassword(req.Next); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -157,18 +244,48 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.SetUserPassword(owner, hash); err != nil {
if scope == store.SessionScopeSetPasswordOnly {
var completeErr error
switch mode {
case store.PasswordChangeModeGrantSetup:
_, completeErr = s.st.CompleteSetPasswordChecked(tok, hash)
case store.PasswordChangeModeCurrentRequired:
_, completeErr = s.st.CompleteForcedPasswordChecked(tok, req.Current, hash)
default:
http.Error(w, "unsupported password change mode", http.StatusForbidden)
return
}
if completeErr != nil {
if errors.Is(completeErr, store.ErrBadCredentials) {
http.Error(w, "current password incorrect", http.StatusUnauthorized)
return
}
if errors.Is(completeErr, store.ErrNotFound) {
http.Error(w, "session expired", http.StatusUnauthorized)
return
}
writeStoreErr(w, completeErr)
return
}
setSessionCookie(w, r, "", -1)
st, _ := s.st.GetUserAuthState(owner)
out := authStateJSON(st)
out["reloginRequired"] = true
writeJSON(w, http.StatusOK, out)
return
}
if err := s.st.ChangeUserPasswordActive(owner, req.Current, hash, tok); err != nil {
if errors.Is(err, store.ErrBadCredentials) {
http.Error(w, "current password incorrect", http.StatusUnauthorized)
return
}
writeStoreErr(w, err)
return
}
// SetUserPassword 置了 must_change=1(重置语义);本人主动改密后即清除。
if err := s.st.ClearMustChangePassword(owner); err != nil {
writeStoreErr(w, err)
return
}
s.st.DeleteUserSessions(owner, tok)
st, _ := s.st.GetUserAuthState(owner)
writeJSON(w, http.StatusOK, authStateJSON(st))
out := authStateJSON(st)
out["reloginRequired"] = false
writeJSON(w, http.StatusOK, out)
}
func validPassword(pw string) error {
@@ -186,7 +303,7 @@ func validPassword(pw string) error {
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
owner := s.owner(r)
st, err := s.st.GetUserAuthState(owner)
if err != nil || !st.IsAdmin {
if err != nil || !st.Enabled || !st.IsAdmin || s.Auth == nil || !s.Auth.Enabled {
http.Error(w, "admin required", http.StatusForbidden)
return "", false
}
@@ -202,16 +319,15 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
var req struct {
Owner string `json:"owner"`
Name string `json:"name"`
Password string `json:"password"`
IsAdmin bool `json:"isAdmin"`
Owner string `json:"owner"`
Profile store.AccountProfile `json:"profile"`
}
if err := decodeJSON(r, &req); err != nil {
if err := decodeStrictJSON(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
@@ -220,57 +336,171 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest)
return
}
if err := validPassword(req.Password); err != nil {
http.Error(w, "initial password must be 8+ characters", http.StatusBadRequest)
return
}
hash, err := store.HashPassword(req.Password)
account, err := s.st.CreatePendingAccount(actor, req.Owner, req.Profile)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil {
writeStoreErr(w, err)
return
}
st, _ := s.st.GetUserAuthState(req.Owner)
writeJSON(w, http.StatusOK, map[string]any{
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin,
"mustChangePassword": st.MustChangePassword,
})
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
// resetAdminUserPassword:管理员重置密码 → must_change=1,吊销该用户全部会话。
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
func accountProfileJSON(account store.Account) map[string]any {
return map[string]any{
"userId": account.ID, "owner": account.Owner, "profile": account.Profile,
"profileVersion": account.ProfileVersion, "enabled": account.Enabled,
"stateVersion": account.StateVersion, "isAdmin": account.IsAdmin,
"passwordSet": account.HasPassword, "initializationState": account.InitializationState,
"initializationGeneration": account.InitializationGeneration,
}
}
func (s *Server) requireFullPasswordSession(w http.ResponseWriter, r *http.Request) (string, bool) {
if s.Auth == nil || !s.Auth.Enabled || AuthVia(r) != AuthViaSession || SessionScope(r) != store.SessionScopeFull {
http.Error(w, "full password session required", http.StatusForbidden)
return "", false
}
token := sessionToken(r)
owner, scope, valid := s.st.SessionInfo(token)
if !valid || scope != store.SessionScopeFull || owner != s.owner(r) {
http.Error(w, "full password session required", http.StatusForbidden)
return "", false
}
st, err := s.st.GetUserAuthState(owner)
if err != nil || !st.Enabled || st.MustChangePassword || !st.HasPassword {
http.Error(w, "full password session required", http.StatusForbidden)
return "", false
}
method, err := s.st.SessionAuthMethod(token)
if err != nil || method != store.AuthMethodPassword {
http.Error(w, "password-authenticated session required", http.StatusForbidden)
return "", false
}
return owner, true
}
func (s *Server) meProfile(w http.ResponseWriter, r *http.Request) {
owner, ok := s.requireFullPasswordSession(w, r)
if !ok {
return
}
account, err := s.st.GetAccountByOwner(owner)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
func (s *Server) patchMeProfile(w http.ResponseWriter, r *http.Request) {
owner, ok := s.requireFullPasswordSession(w, r)
if !ok {
return
}
account, err := s.st.GetAccountByOwner(owner)
if err != nil {
writeStoreErr(w, err)
return
}
s.patchAccountProfile(w, r, owner, account.ID)
}
func (s *Server) adminAccount(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
owner := r.PathValue("owner")
var req struct {
Password string `json:"password"`
}
if err := decodeJSON(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := validPassword(req.Password); err != nil {
http.Error(w, "new password must be 8+ characters", http.StatusBadRequest)
return
}
hash, err := store.HashPassword(req.Password)
account, err := s.st.GetAccount(r.PathValue("userId"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.SetUserPassword(owner, hash); err != nil {
writeStoreErr(w, err)
return
}
s.st.DeleteUserSessions(owner, "")
st, _ := s.st.GetUserAuthState(owner)
writeJSON(w, http.StatusOK, map[string]any{
"owner": st.Owner, "mustChangePassword": st.MustChangePassword,
})
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
func (s *Server) patchAdminAccountProfile(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
s.patchAccountProfile(w, r, actor, r.PathValue("userId"))
}
func (s *Server) patchAccountProfile(w http.ResponseWriter, r *http.Request, actor, userID string) {
var req struct {
ExpectedProfileVersion int64 `json:"expectedProfileVersion"`
Patch map[string]string `json:"patch"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.Patch == nil {
http.Error(w, "invalid profile patch", http.StatusBadRequest)
return
}
account, err := s.st.PatchAccountProfile(actor, userID, req.ExpectedProfileVersion, req.Patch)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
var req struct {
ExpectedStateVersion int64 `json:"expectedStateVersion"`
Enabled *bool `json:"enabled"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.Enabled == nil {
http.Error(w, "invalid state change", http.StatusBadRequest)
return
}
account, err := s.st.SetAccountEnabled(actor, r.PathValue("userId"), req.ExpectedStateVersion, *req.Enabled)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复
// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用
// 登录 Key、吊销全部会话、审计。
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); ok {
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
}
}
// ---- 管理员:一次性登录 Key(恢复流程主入口)----
// issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。
// 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。
func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); ok {
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
}
}
// listAdminLoginKeys:指定用户的 Key 状态与有效期(不返回明文)。
func (s *Server) listAdminLoginKeys(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
keys, err := s.st.ListLoginKeys(r.PathValue("owner"))
writeJSONOr(w, map[string]any{"keys": keys}, err)
}
// revokeAdminLoginKey:撤销 Key 并作废其关联受限会话。
func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
if err := s.st.RevokeLoginKeyCascadeAdmin(actor, r.PathValue("owner"), r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// RandomPassword 生成随机初始密码(管理员/CLI 未显式给定密码时)。
@@ -294,7 +524,8 @@ func (s *Server) listRecoveryHolds(w http.ResponseWriter, r *http.Request) {
// releaseRecoveryHold 人工解除单条 hold(幂等);解除后该 run 才可能被再次
// 续跑/接管。副作用未核对就解除的风险由操作者承担——与 task-close 同级谨慎。
func (s *Server) releaseRecoveryHold(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
runID := r.PathValue("runId")
@@ -302,5 +533,5 @@ func (s *Server) releaseRecoveryHold(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
writeOKOr(w, s.st.ReleaseRecoveryHold(runID))
writeOKOr(w, s.st.ReleaseRecoveryHoldAccountChecked(actor, runID, s.Auth == nil || !s.Auth.Enabled))
}
+1 -1
View File
@@ -46,7 +46,7 @@ func TestListAdminZoneSections(t *testing.T) {
}},
}
h := srv.Handler()
key, err := st.CreateAPIKey("alice", "e2e")
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
+17
View File
@@ -0,0 +1,17 @@
package server
import (
"testing"
"agent-system-backend/internal/store"
)
// createLegacyAPIKeyFixture creates the historical integration identity
// explicitly before exercising the formal, non-creating key issuance path.
func createLegacyAPIKeyFixture(t *testing.T, st *store.Store, owner, name string) (string, error) {
t.Helper()
if err := st.EnsureUser(owner, ""); err != nil {
t.Fatal(err)
}
return st.CreateAPIKey(owner, name)
}
+2 -2
View File
@@ -17,11 +17,11 @@ func TestCommandAuditOwnerScoping(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
aliceKey, err := st.CreateAPIKey("alice", "e2e")
aliceKey, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
bobKey, err := st.CreateAPIKey("bob", "e2e")
bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
}
+85 -18
View File
@@ -14,13 +14,16 @@ type ownerCtxKey struct{}
// authViaCtxKey 携带凭证通道(AuthViaKey / AuthViaSession;未启用认证与豁免路径为空)。
type authViaCtxKey struct{}
// sessionScopeCtxKey 携带会话 scope(full / set_password_only)。
type sessionScopeCtxKey struct{}
// 凭证通道值:/api/me 的 authVia 字段;前端据此区分「改密门」只对会话登录者放行。
const (
AuthViaKey = "key"
AuthViaSession = "session"
)
// Authenticator 是 API key 认证(阶段 1:user 即租户)。
// Authenticator 是 API key 认证(阶段 1:owner 即租户)。
// Enabled=false(嵌入式档默认,回环本地单用户)时全部请求回落默认租户。
// SSE 的 EventSource 无法带 Authorization 头,允许 ?key= 查询参数兜底。
type Authenticator struct {
@@ -41,41 +44,97 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(withOwner(r, store.OwnerDefault)))
return
}
// 豁免:静态前端(登录页须可加载)与登录入口本身。
p := r.URL.Path
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" {
// 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" || p == "/api/auth/recovery-grants/redeem" {
next.ServeHTTP(w, r)
return
}
// 凭证二选一:Authorization: Bearer <api key>(程序化;?key= 兼容 SSE 老客户端)
// 或会话 cookie(浏览器登录;2026-09-30 R2 管理员发放制)。
// 失效 key 不一票否决:浏览器 localStorage 可能残留已吊销 key(前端对所有请求
// 附加 Bearer 头),会话 cookie 仍有效时回落会话;程序化客户端不带 cookie,
// 行为不变(两者都无效才 401)。
key := bearerKey(r)
if key == "" {
key = r.URL.Query().Get("key")
}
if key != "" && sessionToken(r) != "" {
http.Error(w, "conflicting credentials", http.StatusUnauthorized)
return
}
// 一次性登录 Key 不得经 Bearer / ?key= 使用。
if strings.HasPrefix(key, "alk_") {
http.Error(w, "unauthorized: one-time login keys must use /api/auth/login-key/redeem", http.StatusUnauthorized)
return
}
var owner string
var via string
var scope string
ok := false
if key != "" {
if owner, ok := a.st.AuthOwner(key); ok {
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaKey)))
if o, keyOK := a.st.AuthOwner(key); keyOK {
owner, via, scope, ok = o, AuthViaKey, store.SessionScopeFull, true
} else {
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
return
}
if owner, ok := cookieOwner(r, a.st); ok {
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
return
}
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
} else if o, sc, sessOK := cookieSession(r, a.st); sessOK {
owner, via, scope, ok = o, AuthViaSession, sc, true
}
if !ok {
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
return
}
if owner, ok := cookieOwner(r, a.st); ok {
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
ctx := withAuthVia(withOwner(r, owner), via)
ctx = context.WithValue(ctx, sessionScopeCtxKey{}, scope)
if err := a.enforcePasswordGate(w, r, owner, via, scope, p); err != nil {
return
}
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (a *Authenticator) enforcePasswordGate(w http.ResponseWriter, r *http.Request, owner, via, scope, path string) error {
allow := path == "/api/me" || path == "/api/auth/password" || path == "/api/auth/logout" ||
path == "/api/health"
if scope == store.SessionScopeSetPasswordOnly {
if !allow {
http.Error(w, "forbidden: set-password session only", http.StatusForbidden)
return errGate
}
return nil
}
st, err := a.st.GetUserAuthState(owner)
if err != nil || !st.Enabled {
http.Error(w, "unauthorized: account unavailable", http.StatusUnauthorized)
return errGate
}
// 长期 API Key:仅当 must_change(已有密码待改)时拒绝业务,防止绕过改密门;
// 无密码的纯集成账户仍可走 Key(与历史 apikey 语义兼容)。
if via == AuthViaKey {
if st.MustChangePassword && !allow {
http.Error(w, "forbidden: password change required", http.StatusForbidden)
return errGate
}
return nil
}
// 会话:待改密或尚未设密 → 只允许身份/设密/退出。
if (st.MustChangePassword || !st.HasPassword) && !allow {
http.Error(w, "forbidden: password setup required", http.StatusForbidden)
return errGate
}
return nil
}
var errGate = errorsGate{}
type errorsGate struct{}
func (errorsGate) Error() string { return "gate" }
func cookieSession(r *http.Request, st *store.Store) (owner, scope string, ok bool) {
if tok := sessionToken(r); tok != "" {
return st.SessionInfo(tok)
}
return "", "", false
}
// cookieOwner 会话 cookie 有效时返回其 owner。
func cookieOwner(r *http.Request, st *store.Store) (string, bool) {
if tok := sessionToken(r); tok != "" {
@@ -108,6 +167,14 @@ func AuthVia(r *http.Request) string {
return ""
}
// SessionScope 返回当前会话 scope(非会话为空)。
func SessionScope(r *http.Request) string {
if v, ok := r.Context().Value(sessionScopeCtxKey{}).(string); ok {
return v
}
return ""
}
func bearerKey(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
+156 -35
View File
@@ -36,7 +36,11 @@ func newAuthTestServer(t *testing.T) (*Server, *store.Store) {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return newAuthTestServerOnStore(t, st, dir), st
}
func newAuthTestServerOnStore(t *testing.T, st *store.Store, dir string) *Server {
t.Helper()
b := bus.New()
reg := tools.NewRegistry()
reg.RegisterBuiltin()
@@ -48,7 +52,7 @@ func newAuthTestServer(t *testing.T) (*Server, *store.Store) {
srv := New(st, mgr, reg, profSvc, setSvc, ph)
srv.Auth = NewAuthenticator(st, true)
srv.Subscribe = func(cid string) (<-chan core.StreamEvent, func()) { return b.Subscribe(cid) }
return srv, st
return srv
}
func TestAuthMiddleware(t *testing.T) {
@@ -64,7 +68,7 @@ func TestAuthMiddleware(t *testing.T) {
}
// 创建密钥。
key, err := st.CreateAPIKey("alice", "e2e")
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
@@ -103,8 +107,8 @@ func TestAuthMiddleware(t *testing.T) {
func TestTenantIsolationHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
keyA, _ := st.CreateAPIKey("alice", "a")
keyB, _ := st.CreateAPIKey("bob", "b")
keyA, _ := createLegacyAPIKeyFixture(t, st, "alice", "a")
keyB, _ := createLegacyAPIKeyFixture(t, st, "bob", "b")
do := func(key, method, path string, body any) int {
var buf bytes.Buffer
@@ -321,21 +325,47 @@ func TestAdminAccountFlow(t *testing.T) {
return rec
}
// 会话可访问 API;/api/me 携带身份与管理员位。
// /api/me 允许 must_change;业务 API 拒绝(U4 设密门)。
if rec := session("GET", "/api/me", ""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"isAdmin":true`) {
t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String())
}
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusForbidden {
t.Fatalf("must_change admin must be gated before create: %d %s", rec.Code, rec.Body.String())
}
// 历史初始密码必须提供 current;成功后销毁会话,须重新密码登录。
if rec := session("POST", "/api/auth/password", `{"current":"boot-pass-00","next":"admin-final-9"}`); rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String())
}
if rec := session("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("admin session must die after set-password: %d", rec.Code)
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("admin relogin after set-password: %d %s", rec.Code, rec.Body.String())
}
tok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
// 管理员建号(普通用户,初始密码)。
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusOK {
// 管理员只建无凭据的普通账号;历史强制改密夹具由 Store 直接设置。
if rec := session("POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"爱丽丝"}}`); rec.Code != http.StatusOK {
t.Fatalf("admin create user: %d %s", rec.Code, rec.Body.String())
}
// 重名冲突 409。
if rec := session("POST", "/api/admin/users", `{"owner":"alice","password":"alice-init-01"}`); rec.Code != http.StatusConflict {
if rec := session("POST", "/api/admin/users", `{"owner":"alice"}`); rec.Code != http.StatusConflict {
t.Fatalf("duplicate owner must 409: %d", rec.Code)
}
legacyHash, err := store.HashPassword("alice-init-01")
if err != nil {
t.Fatal(err)
}
if err := st.SetUserPassword("alice", legacyHash); err != nil {
t.Fatal(err)
}
// alice 首登:mustChangePassword=true;改密后清除。
// alice 首登:mustChangePassword=true;业务 API 被设密门拒绝。
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
@@ -358,16 +388,19 @@ func TestAdminAccountFlow(t *testing.T) {
if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("alice first login must force change: %s", rec.Body.String())
}
// alice 非管理员:建号被拒 403。
if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden {
t.Fatalf("non-admin create must 403: %d", rec.Code)
t.Fatalf("must_change non-admin create must 403: %d", rec.Code)
}
// alice 改密(错误当前密码 → 401;成功 → must_change 清除)。
if rec := alice("POST", "/api/auth/password", `{"current":"bad","next":"alice-final-9"}`); rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong current password: %d", rec.Code)
// must_change 改密要求 current;短密码拒绝;成功后 reloginRequired。
if rec := alice("POST", "/api/auth/password", `{"next":"short"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("short password must 400: %d", rec.Code)
}
if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("alice change password: %d %s", rec.Code, rec.Body.String())
if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
t.Fatalf("alice force set-password: %d %s", rec.Code, rec.Body.String())
}
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("alice session must die after set-password: %d", rec.Code)
}
// 旧密码不能再登录;新密码可以且无需改密。
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`))
@@ -377,24 +410,36 @@ func TestAdminAccountFlow(t *testing.T) {
if rec.Code != http.StatusUnauthorized {
t.Fatalf("old password must fail after change: %d", rec.Code)
}
// 管理员重置 alice 密码 → 其旧会话吊销 + 首登再改密。
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusOK {
t.Fatalf("admin reset: %d %s", rec.Code, rec.Body.String())
}
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("alice old session must be revoked after reset: %d", rec.Code)
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`))
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("reset must force change on next login: %s", rec.Body.String())
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("alice relogin: %d %s", rec.Code, rec.Body.String())
}
aliceTok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
// 已设密普通用户:建号仍 403(非管理员)。
if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden {
t.Fatalf("non-admin create must 403: %d", rec.Code)
}
// 旧管理员重置入口关闭,不能动 alice 的密码与当前会话。
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusGone {
t.Fatalf("admin reset must be closed: %d %s", rec.Code, rec.Body.String())
}
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK {
t.Fatalf("closed reset must preserve alice session: %d", rec.Code)
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("closed reset must preserve normal login: %d %s", rec.Code, rec.Body.String())
}
// API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。
key, err := st.CreateAPIKey("admin", "cli")
key, err := createLegacyAPIKeyFixture(t, st, "admin", "cli")
if err != nil {
t.Fatal(err)
}
@@ -413,7 +458,7 @@ func TestAdminAccountFlow(t *testing.T) {
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`))
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
@@ -430,8 +475,8 @@ func TestAdminAccountFlow(t *testing.T) {
}
}
// TestStaleKeySessionFallback:浏览器残留失效 Bearer key 时,有效会话不被一票否决;
// /api/me 的 authVia 区分凭证通道(key / session),供前端区分改密门放行范围。
// TestStaleKeySessionFallback:混合 Bearer 与 cookie 的认证上下文不确定,必须拒绝。
// /api/me 的 authVia 仍区分单独有效的 key / session。
func TestStaleKeySessionFallback(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
@@ -452,14 +497,14 @@ func TestStaleKeySessionFallback(t *testing.T) {
}
tok := strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
// 会话 + 失效 key:回落会话(200,authVia=session),不再被 key 一票否决。
// 会话 + 失效 key:拒绝混合凭证,不把有效 cookie 当证明兜底。
req = httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
req.Header.Set("Authorization", "Bearer ask_stale_key")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) {
t.Fatalf("stale key + valid session must fall back to session: %d %s", rec.Code, rec.Body.String())
if rec.Code != http.StatusUnauthorized {
t.Fatalf("stale key + valid session must be rejected: %d %s", rec.Code, rec.Body.String())
}
// 失效 key 且无会话:仍 401(key 专属文案)。
@@ -472,7 +517,7 @@ func TestStaleKeySessionFallback(t *testing.T) {
}
// 有效 key:authVia=key。
key, err := st.CreateAPIKey("carol", "cli")
key, err := createLegacyAPIKeyFixture(t, st, "carol", "cli")
if err != nil {
t.Fatal(err)
}
@@ -493,3 +538,79 @@ func TestStaleKeySessionFallback(t *testing.T) {
t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String())
}
}
// TestSystemSettingsAdminGate:P2 系统设置面按管理员准入——普通账号 403,
// 管理员可读写且值对所有租户生效;普通账号写受管项仍 400。
func TestSystemSettingsAdminGate(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
hash, err := store.HashPassword("init-pass-01")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("boss", "管理员甲", hash, true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("dave", "普通乙", hash, false); err != nil {
t.Fatal(err)
}
keyBoss, err := createLegacyAPIKeyFixture(t, st, "boss", "cli")
if err != nil {
t.Fatal(err)
}
keyDave, err := createLegacyAPIKeyFixture(t, st, "dave", "cli")
if err != nil {
t.Fatal(err)
}
// 初始密码改密门不参与本测试对象(受管拦截语义),先解除。
if err := st.ClearMustChangePassword("boss"); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("dave"); err != nil {
t.Fatal(err)
}
do := func(key, method, path string, body string) *httptest.ResponseRecorder {
var rd io.Reader
if body != "" {
rd = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, rd)
req.Header.Set("Authorization", "Bearer "+key)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// 普通账号:读写系统面均 403;写受管项走 user 面仍是 400(先于管理员判定也成立)。
if rec := do(keyDave, "GET", "/api/settings/system", ""); rec.Code != http.StatusForbidden {
t.Fatalf("dave get system: %d", rec.Code)
}
if rec := do(keyDave, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusForbidden {
t.Fatalf("dave put system: %d", rec.Code)
}
if rec := do(keyDave, "PUT", "/api/settings/user", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusBadRequest {
t.Fatalf("dave managed via user: %d body=%s", rec.Code, rec.Body.String())
}
if rec := do(keyDave, "PUT", "/api/settings/user", `{"ui":{"theme":"light"}}`); rec.Code != http.StatusOK {
t.Fatalf("dave non-managed via user: %d body=%s", rec.Code, rec.Body.String())
}
// 管理员:写入生效,系统读面掩码返回,dave 的有效设置拿到系统值。
if rec := do(keyBoss, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-boss","apiKey":"sk-boss-secret"}}}`); rec.Code != http.StatusOK {
t.Fatalf("boss put system: %d %s", rec.Code, rec.Body.String())
}
rec := do(keyBoss, "GET", "/api/settings/system", "")
if rec.Code != http.StatusOK {
t.Fatalf("boss get system: %d", rec.Code)
}
if strings.Contains(rec.Body.String(), "sk-boss-secret") {
t.Fatalf("system read leaked secret key")
}
if eff := settings.NewService(st).Effective("dave", ""); eff.GetString("models.chat.model", "") != "m-boss" {
t.Fatalf("system value not effective for dave: %q", eff.GetString("models.chat.model", ""))
}
}
+59 -42
View File
@@ -28,16 +28,20 @@ func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sec, err := s.st.AddSecret(s.owner(r), req.Service, req.Label, req.Value)
if strings.TrimSpace(req.Service) == "" || strings.TrimSpace(req.Value) == "" {
http.Error(w, "store: secret service and value are required", http.StatusBadRequest)
return
}
sec, err := s.st.AddSecretAccountChecked(s.owner(r), req.Service, req.Label, req.Value, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, sec)
}
func (s *Server) activateSecret(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.ActivateSecret(s.owner(r), r.PathValue("id")))
writeOKOr(w, s.st.ActivateSecretAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
}
func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
@@ -48,11 +52,11 @@ func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeOKOr(w, s.st.RenameSecret(s.owner(r), r.PathValue("id"), req.Label))
writeOKOr(w, s.st.RenameSecretAccountChecked(s.owner(r), r.PathValue("id"), req.Label, s.Auth == nil || !s.Auth.Enabled))
}
func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.DeleteSecret(s.owner(r), r.PathValue("id")))
writeOKOr(w, s.st.DeleteSecretAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
}
func (s *Server) listContextEntries(w http.ResponseWriter, r *http.Request) {
@@ -67,16 +71,20 @@ func (s *Server) saveContextEntry(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
saved, err := s.st.SaveContextEntry(s.owner(r), e)
if strings.TrimSpace(e.Content) == "" {
http.Error(w, "store: entry content required", http.StatusBadRequest)
return
}
saved, err := s.st.SaveContextEntryAccountChecked(s.owner(r), e, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) deleteContextEntry(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.DeleteContextEntry(s.owner(r), r.PathValue("id")))
writeOKOr(w, s.st.DeleteContextEntryAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
}
func (s *Server) listUserPromptCommands(w http.ResponseWriter, r *http.Request) {
@@ -90,31 +98,46 @@ func (s *Server) saveUserPromptCommand(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
saved, err := s.st.SaveUserCommand(s.owner(r), c)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
if strings.TrimSpace(c.Template) == "" {
http.Error(w, "store: command template required", http.StatusBadRequest)
return
}
s.registerUserPrompt(saved)
owner := s.owner(r)
allowImplicitDefault := s.Auth == nil || !s.Auth.Enabled
saved, err := s.st.SaveUserCommandAccountChecked(owner, c, allowImplicitDefault)
if err != nil {
writeStoreErr(w, err)
return
}
if s.Bus != nil {
// The Bus is outside the database transaction. Recheck immediately before
// mutation; a disable between this check and RegisterUser remains possible.
if err := s.st.CheckEffectiveAccountForExternalEffect(owner, allowImplicitDefault); err != nil {
writeStoreErr(w, err)
return
}
s.registerUserPrompt(saved)
}
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) deleteUserPromptCommand(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
id := r.PathValue("id")
list, _ := s.st.ListUserCommands(owner)
var name string
for _, c := range list {
if c.ID == id {
name = c.Name
}
}
if err := s.st.DeleteUserCommand(owner, id); err != nil {
allowImplicitDefault := s.Auth == nil || !s.Auth.Enabled
deleted, err := s.st.DeleteUserCommandAccountChecked(owner, id, allowImplicitDefault)
if err != nil {
writeStoreErr(w, err)
return
}
if s.Bus != nil && name != "" {
s.Bus.UnregisterUser(userCommandID(name), name)
if s.Bus != nil && deleted.Name != "" {
// The account check is the last database action before the Bus mutation;
// the two systems cannot share an atomic commit.
if err := s.st.CheckEffectiveAccountForExternalEffect(owner, allowImplicitDefault); err != nil {
writeStoreErr(w, err)
return
}
s.Bus.UnregisterUser(userCommandID(deleted.Name), deleted.Name)
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
@@ -140,7 +163,7 @@ func (s *Server) excludeMessage(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
writeOKOr(w, s.st.ExcludeRunGroup(msg.ID, excluded))
writeOKOr(w, s.st.ExcludeRunGroupAccountChecked(s.owner(r), msg.ID, excluded, s.Auth == nil || !s.Auth.Enabled))
}
func (s *Server) selectVariant(w http.ResponseWriter, r *http.Request) {
@@ -153,7 +176,7 @@ func (s *Server) selectVariant(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
writeOKOr(w, s.st.SelectVariant(msg.ID))
writeOKOr(w, s.st.SelectVariantAccountChecked(s.owner(r), msg.ID, s.Auth == nil || !s.Auth.Enabled))
}
func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
@@ -164,6 +187,10 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
writeProjectErr(w, err)
return
}
var req struct {
MessageID string `json:"messageId"`
}
@@ -183,11 +210,7 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
}
var user core.Message
found := false
maxIndex := target.VariantIndex
for _, m := range hist {
if m.VariantGroup != "" && m.VariantGroup == target.VariantGroup && m.VariantIndex > maxIndex {
maxIndex = m.VariantIndex
}
if m.ID == target.ID {
found = true
}
@@ -199,19 +222,8 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no user turn to regenerate", http.StatusBadRequest)
return
}
group := target.VariantGroup
if group == "" {
group = "var_" + target.ID
}
if err := s.st.SetMessageFlags(target.ID, true, group, target.VariantIndex); err != nil {
writeStoreErr(w, err)
return
}
if err := s.st.ExcludeRunGroup(target.ID, true); err != nil {
writeStoreErr(w, err)
return
}
if err := s.st.SetRegenPending(convID, group, maxIndex+1); err != nil {
group, index, err := s.st.PrepareRegenerationAccountChecked(owner, convID, target.ID, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
writeStoreErr(w, err)
return
}
@@ -220,7 +232,7 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
}
run, err := s.mgr.StartConversationRun(conv, user, nil, nil)
if err != nil {
_, _, _ = s.st.TakeRegenPending(convID)
_ = s.st.ClearRegenPendingAfterFailedStart(owner, convID, group, index, s.Auth == nil || !s.Auth.Enabled)
writeStoreErr(w, err)
return
}
@@ -277,6 +289,11 @@ func (s *Server) appendUserPromptCommands(owner string, out []map[string]any) []
return out
}
for _, c := range list {
if s.Bus != nil {
if err := s.st.CheckEffectiveAccountForExternalEffect(owner, s.Auth == nil || !s.Auth.Enabled); err != nil {
return out
}
}
s.registerUserPrompt(c)
out = append(out, map[string]any{
"id": userCommandID(c.Name), "name": c.Name, "title": c.Title,
@@ -0,0 +1,158 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
)
func TestBorrowWritesCannotCommitAfterAccountDisable(t *testing.T) {
for _, tc := range []struct {
name, path, body, table string
}{
{"secret", "/api/secrets", `{"service":"late","value":"hidden"}`, "secret_keys"},
{"context", "/api/context-entries", `{"content":"late"}`, "context_entries"},
{"command", "/api/prompt-commands", `{"name":"late","template":"late"}`, "user_commands"},
} {
t.Run(tc.name, func(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
_ = bootAdmin(t, h, st)
key, err := createLegacyAPIKeyFixture(t, st, "actor", "borrow")
if err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner("actor")
if err != nil {
t.Fatal(err)
}
body := &pausedRequestBody{reader: strings.NewReader(tc.body), entered: make(chan struct{}), release: make(chan struct{})}
req := httptest.NewRequest(http.MethodPost, tc.path, body)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
h.ServeHTTP(rec, req)
close(done)
}()
select {
case <-body.entered:
case <-time.After(3 * time.Second):
t.Fatal("request did not reach its body")
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
close(body.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("request did not finish")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("late write status=%d body=%s", rec.Code, rec.Body.String())
}
var count int
if err := st.DB().QueryRow("SELECT COUNT(*) FROM " + tc.table + " WHERE owner='actor'").Scan(&count); err != nil || count != 0 {
t.Fatalf("late rows=%d err=%v", count, err)
}
})
}
}
func TestBorrowImplicitLocalCannotWriteWhenExplicitlyDisabled(t *testing.T) {
srv, st := newTestServer(t)
if err := st.CreateAccount("admin", "", "", true); err != nil {
t.Fatal(err)
}
secret, err := st.AddSecret(store.OwnerDefault, "service", "original", "hidden")
if err != nil {
t.Fatal(err)
}
entry, err := st.SaveContextEntry(store.OwnerDefault, store.ContextEntry{Content: "original"})
if err != nil {
t.Fatal(err)
}
command, err := st.SaveUserCommand(store.OwnerDefault, store.UserCommand{Name: "original", Template: "original"})
if err != nil {
t.Fatal(err)
}
if err := st.EnsureUser(store.OwnerDefault, ""); err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner(store.OwnerDefault)
if err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ method, path, body string }{
{http.MethodPost, "/api/secrets", `{"service":"late","value":"hidden"}`},
{http.MethodPost, "/api/secrets/" + secret.ID + "/activate", ""},
{http.MethodPatch, "/api/secrets/" + secret.ID, `{"label":"late"}`},
{http.MethodDelete, "/api/secrets/" + secret.ID, ""},
{http.MethodPost, "/api/context-entries", `{"content":"late"}`},
{http.MethodDelete, "/api/context-entries/" + entry.ID, ""},
{http.MethodPost, "/api/prompt-commands", `{"name":"late","template":"late"}`},
{http.MethodDelete, "/api/prompt-commands/" + command.ID, ""},
} {
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
if rec.Code != http.StatusForbidden {
t.Fatalf("disabled local account wrote %s: %d %s", tc.path, rec.Code, rec.Body.String())
}
}
if value, ok := st.ActiveSecret(store.OwnerDefault, "service"); !ok || value != "hidden" {
t.Fatalf("rejected secret mutations changed active value: %q %v", value, ok)
}
entries, err := st.ListContextEntries(store.OwnerDefault, "", "")
if err != nil || len(entries) != 1 || entries[0].Content != "original" {
t.Fatalf("rejected context mutations changed rows: %+v %v", entries, err)
}
commands, err := st.ListUserCommands(store.OwnerDefault)
if err != nil || len(commands) != 1 || commands[0].Template != "original" {
t.Fatalf("rejected command mutations changed rows: %+v %v", commands, err)
}
srv.appendUserPromptCommands(store.OwnerDefault, nil)
for _, registered := range srv.Bus.List() {
if registered.Spec.ID == "user.original" {
t.Fatal("disabled account registered a command during listing")
}
}
}
func TestBorrowPromptCommandBusFollowsCommittedWrite(t *testing.T) {
srv, st := newTestServer(t)
h := srv.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/prompt-commands", strings.NewReader(`{"name":"local","template":"hello"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("save command: %d %s", rec.Code, rec.Body.String())
}
commands, err := st.ListUserCommands(store.OwnerDefault)
if err != nil || len(commands) != 1 {
t.Fatalf("saved command missing: %+v %v", commands, err)
}
registered := false
for _, command := range srv.Bus.List() {
registered = registered || command.Spec.ID == "user.local"
}
if !registered {
t.Fatal("committed command was not registered in the Bus")
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/prompt-commands/"+commands[0].ID, nil))
if rec.Code != http.StatusOK {
t.Fatalf("delete command: %d %s", rec.Code, rec.Body.String())
}
for _, command := range srv.Bus.List() {
if command.Spec.ID == "user.local" {
t.Fatal("deleted command remained registered in the Bus")
}
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func TestListPluginsMergesCompiled(t *testing.T) {
{ID: "generative-ui", Commands: []string{"generative-ui.present"}},
}
h := srv.Handler()
key, err := st.CreateAPIKey("alice", "e2e")
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
+107 -45
View File
@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
agentcommand "agent-command"
@@ -48,15 +49,27 @@ func newTestServer(t *testing.T) (*Server, *store.Store) {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
_ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}})
b := bus.New()
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
// P2 受管:runtime.mock 是系统受管项,mock 驱动改经系统层写入(对所有租户生效)。
if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
t.Fatal(err)
}
profSvc := profiles.New(st)
profSvc.EnsureDefault()
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) {
account, err := st.GetAccountByOwner(owner)
if errors.Is(err, store.ErrNotFound) && owner == store.OwnerDefault {
return true, nil
}
if err != nil {
return false, err
}
return account.Enabled, nil
}, true)
pluginDir := filepath.Join(dir, "plugins")
os.MkdirAll(pluginDir, 0o755)
ph := plugins.New(st, reg, setSvc, pluginDir)
@@ -64,6 +77,7 @@ func newTestServer(t *testing.T) (*Server, *store.Store) {
// 生产同款装配(app.go):卡片事件经 runmgr 事件缓冲持久化/发布——项目流
//(project:<id> 伪键,D50)依赖它;此前为 no-op emitter,事件不入库。
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
cardSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked)
srv.Cards = cardSvc
mgr.SetCards(cardSvc) // run 内产卡(摄取/编排/submit_card)经 runmgr,与 app.go 装配一致
srv.Bus = cmdbus.New(st, srv.Cards, nil)
@@ -1010,41 +1024,59 @@ func TestSquadOrchestration(t *testing.T) {
t.Fatal("squad deliverable artifact not found")
}
// TestModelRoutingOverrideChain:设置覆盖链集成(defaults → user → project)
// 与画像层最终覆盖——经由真实 run 的失败信息携带解析出的模型名取证。
// TestModelRoutingOverrideChain:P2 受管语义下的模型配置链路——models.* 是系统
// 受管项,user/project 层整笔拒绝(含嵌套与点路径),仅系统层可写;画像层模型名
// 仍是最终覆盖。经由真实 run 的失败信息携带解析出的模型名取证。
func TestModelRoutingOverrideChain(t *testing.T) {
srv, st := newTestServer(t)
h := srv.Handler()
projectID, convID := setupConversation(t, h)
set := func(body map[string]any, path string) {
code, out := doJSON(t, h, "PUT", path, body)
if code != 200 {
t.Fatalf("PUT %s: %d %v", path, code, out)
// user/project 层写受管项 → 400(含点路径与整体子树覆盖)。
for _, tc := range []struct {
name string
path string
body map[string]any
}{
{"nested user", "/api/settings/user", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}},
{"dotted user", "/api/settings/user", map[string]any{"models.chat.model": "model-A"}},
{"nested project", "/api/settings/project/" + projectID, map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}},
{"subtree smash user", "/api/settings/user", map[string]any{"models": "smash"}},
} {
code, out := doJSON(t, h, "PUT", tc.path, tc.body)
if code != http.StatusBadRequest {
t.Fatalf("%s: PUT %s = %d %v,want 400", tc.name, tc.path, code, out)
}
}
// user 层:model-A。
set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}, "/api/settings/user")
eff := settings.NewService(st).Effective(store.OwnerDefault, projectID)
if got := eff.GetString("models.chat.model", ""); got != "model-A" {
t.Fatalf("user layer: %q", got)
}
// project 层:model-B(覆盖 user)。
set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}, "/api/settings/project/"+projectID)
eff = settings.NewService(st).Effective(store.OwnerDefault, projectID)
if got := eff.GetString("models.chat.model", ""); got != "model-B" {
t.Fatalf("project layer should win: %q", got)
}
// 设置 API 呈现两层。
code, out := doJSON(t, h, "GET", "/api/settings?project="+projectID, nil)
// 系统层(无认证测试档 = 本机管理员)写入生效。
code, out := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{
"models": map[string]any{"chat": map[string]any{"model": "model-SYS"}},
})
if code != 200 {
t.Fatal("get settings failed")
t.Fatalf("system write: %d %v", code, out)
}
if eff := settings.NewService(st).Effective(store.OwnerDefault, projectID); eff.GetString("models.chat.model", "") != "model-SYS" {
t.Fatalf("system layer not effective: %q", eff.GetString("models.chat.model", ""))
}
// 系统 GET 面:schema 携带 scope,系统值脱敏返回。
code, out = doJSON(t, h, "GET", "/api/settings/system", nil)
if code != 200 {
t.Fatalf("get system: %d %v", code, out)
}
sysView := out
if sysView["system"] == nil || sysView["schema"] == nil {
t.Fatalf("system view malformed: %#v", sysView)
}
// 非受管项仍可走 user 层(回归)。
if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{"ui": map[string]any{"theme": "light"}}); code != 200 {
t.Fatalf("non-managed user write rejected")
}
_ = out
// 画像层:model-C 为最终覆盖。关 mock → 发消息 → run 失败信息携带解析结果。
set(map[string]any{"runtime": map[string]any{"mock": false}}, "/api/settings/user")
// 画像层:model-C 为最终覆盖。关 mock(系统层)→ 发消息 → run 失败信息携带解析结果。
if code, _ := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{"runtime": map[string]any{"mock": false}}); code != 200 {
t.Fatal("disable mock via system layer failed")
}
prof := mustCode(t, h, "POST", "/api/profiles", map[string]any{
"id": "prof_route_c", "name": "路由C", "systemPrompt": "x",
"model": map[string]any{"chat": "model-C"},
@@ -1205,18 +1237,27 @@ func TestProjectInbox(t *testing.T) {
}
}
// TestSettingsDottedKeyAndSecretMask:点路径写入生效、嵌套胜出、掩码不回写、GET 不回明文。
// TestSettingsDottedKeyAndSecretMask:点路径写入生效、受管密钥归系统层、
// 掩码不回写、user/system 读面均不回明文、user 面不回显受管项(P2)。
func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
srv, st := newTestServer(t)
h := srv.Handler()
svc := settings.NewService(st)
set := func(body map[string]any) {
t.Helper()
code, out := doJSON(t, h, "PUT", "/api/settings/user", body)
if code != 200 {
t.Fatalf("PUT: %d %v", code, out)
t.Fatalf("PUT user: %d %v", code, out)
}
}
setSys := func(body map[string]any) {
t.Helper()
code, out := doJSON(t, h, "PUT", "/api/settings/system", body)
if code != 200 {
t.Fatalf("PUT system: %d %v", code, out)
}
}
svc := settings.NewService(st)
set(map[string]any{"runtime.entry": "squad"})
if got := svc.Effective(store.OwnerDefault, "").GetString("runtime.entry", ""); got != "squad" {
@@ -1232,21 +1273,25 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
const pool = "tvly-should-not-leak"
const modelKey = "sk-should-not-leak"
set(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}})
set(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}})
set(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}})
set(map[string]any{
"tools": map[string]any{"tavily": map[string]any{"apiKeys": "nested-wins"}},
"tools.tavily.apiKeys": "flat-loses",
})
// P2 受管:密钥项 user 层整笔拒绝,系统层写入生效。
if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{
"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}},
}); code != http.StatusBadRequest {
t.Fatalf("managed pool write via user should 400, got %d", code)
}
setSys(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}})
setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}})
// 掩码回写经系统层:不覆盖已存明文。
setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}})
eff := svc.Effective(store.OwnerDefault, "")
if got := eff.GetString("models.chat.apiKey", ""); got != modelKey {
t.Fatalf("mask writeback overwrote key: %q", got)
}
if got := eff.GetString("tools.tavily.apiKeys", ""); got != "nested-wins" {
t.Fatalf("nested form should win, got %q", got)
if got := eff.GetString("tools.tavily.apiKeys", ""); got != pool {
t.Fatalf("system pool not effective: %q", got)
}
// 读面:user 面不回显受管项;系统面返回掩码;两个面都不泄漏明文。
code, out := doJSON(t, h, "GET", "/api/settings", nil)
if code != 200 {
t.Fatalf("GET settings: %d", code)
@@ -1255,19 +1300,36 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{pool, modelKey, "nested-wins", "flat-loses"} {
for _, secret := range []string{pool, modelKey} {
if strings.Contains(string(blob), secret) {
t.Fatalf("GET leaked %q", secret)
t.Fatalf("GET /api/settings leaked %q", secret)
}
}
user := out["user"].(map[string]any)
tavily := user["tools"].(map[string]any)["tavily"].(map[string]any)
if tavily["apiKeys"] != settings.MaskPlaceholder {
t.Fatalf("apiKeys mask = %v", tavily["apiKeys"])
if user["models"] != nil || user["tools"] != nil {
t.Fatalf("managed subtrees exposed in user scope: %#v", user)
}
chat := user["models"].(map[string]any)["chat"].(map[string]any)
code, sys := doJSON(t, h, "GET", "/api/settings/system", nil)
if code != 200 {
t.Fatalf("GET system: %d", code)
}
sysBlob, err := json.Marshal(sys)
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{pool, modelKey} {
if strings.Contains(string(sysBlob), secret) {
t.Fatalf("GET /api/settings/system leaked %q", secret)
}
}
sysValues := sys["system"].(map[string]any)
tavily := sysValues["tools"].(map[string]any)["tavily"].(map[string]any)
if tavily["apiKeys"] != settings.MaskPlaceholder {
t.Fatalf("system apiKeys mask = %v", tavily["apiKeys"])
}
chat := sysValues["models"].(map[string]any)["chat"].(map[string]any)
if chat["apiKey"] != settings.MaskPlaceholder {
t.Fatalf("apiKey mask = %v", chat["apiKey"])
t.Fatalf("system apiKey mask = %v", chat["apiKey"])
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
func TestUploadPreIndexSwitch(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
alice, err := st.CreateAPIKey("alice", "e2e")
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
+69
View File
@@ -0,0 +1,69 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"agent-system-backend/internal/store"
)
func TestRedeemLoginKeyRestrictedSession(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
t.Fatal(err)
}
account, err := st.CreatePendingAccount("admin", "carol", store.AccountProfile{})
if err != nil {
t.Fatal(err)
}
plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{"key": plain})
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()
var sess string
for _, c := range cookie {
if c.Name == SessionCookie {
sess = c.Value
}
}
if sess == "" {
t.Fatal("missing session cookie")
}
// 业务拒绝
req = httptest.NewRequest("GET", "/api/projects", nil)
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("projects want 403, got %d", rec.Code)
}
// me 允许
req = httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("me: %d", rec.Code)
}
// 设密后要求重新登录
pwBody, _ := json.Marshal(map[string]string{"next": "carol-final-99"})
req = httptest.NewRequest("POST", "/api/auth/password", bytes.NewReader(pwBody))
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(`"reloginRequired":true`)) {
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
}
}
+217
View File
@@ -0,0 +1,217 @@
package server
// 登录恢复流程 HTTP 合同(2026-10-13):管理员签发一次性登录 Key → 用户兑换 →
// 受限会话强制设密 → 重新密码登录;must-change 首登同样建受限会话。
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"agent-system-backend/internal/store"
)
// bootAdmin:建管理员并完成首登改密,返回已放行的会话 cookie 值。
func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string {
t.Helper()
bootHash, err := store.HashPassword("boot-pass-00")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("admin", "", bootHash, true); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"boot-pass-00"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("boot login: %d %s", rec.Code, rec.Body.String())
}
tok := cookieOf(rec)
if tok == "" {
t.Fatal("missing session cookie")
}
req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"current":"boot-pass-00","next":"admin-final-9"}`))
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("boot set password: %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("relogin: %d %s", rec.Code, rec.Body.String())
}
return cookieOf(rec)
}
func cookieOf(rec *httptest.ResponseRecorder) string {
for _, ck := range rec.Result().Cookies() {
if ck.Name == SessionCookie {
return ck.Value
}
}
return ""
}
func reqWithCookie(method, path, body, cookie string) *http.Request {
var rdr *bytes.Reader
if body != "" {
rdr = bytes.NewReader([]byte(body))
} else {
rdr = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rdr)
if cookie != "" {
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: cookie})
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
return req
}
func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, reqWithCookie(method, path, body, cookie))
return rec
}
// 管理员签发 → 列表无明文 → 兑换 → 受限 → 设密 → 新密码登录。
func TestAdminLoginKeyRecoveryFlow(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
hash, err := store.HashPassword("victim-old-password")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("victim", "", hash, false); err != nil {
t.Fatal(err)
}
legacy, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0)
if err != nil {
t.Fatal(err)
}
if rec := do(h, "POST", "/api/admin/users/victim/login-keys", `{}`, admin); rec.Code != http.StatusGone {
t.Fatalf("administrator secret issuance must be closed: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK {
t.Fatal("legacy administrator key redeemed after cutover")
}
if rec := do(h, "GET", "/api/admin/users/victim/login-keys", "", admin); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), legacy) {
t.Fatalf("metadata lookup leaked secret: %d %s", rec.Code, rec.Body.String())
}
}
func TestAdminLoginKeyReissueAndRevoke(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("victim", "", "", false); err != nil {
t.Fatal(err)
}
legacy, meta, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0)
if err != nil {
t.Fatal(err)
}
if rec := do(h, "DELETE", "/api/admin/users/other/login-keys/"+meta.ID, "", admin); rec.Code != http.StatusNotFound {
t.Fatalf("path owner mismatch revoked key: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "DELETE", "/api/admin/users/victim/login-keys/"+meta.ID, "", admin); rec.Code != http.StatusOK {
t.Fatalf("legacy metadata revoke: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK {
t.Fatal("revoked legacy key redeemed")
}
}
// must-change 首登:登录即建受限会话(sessionScope 回传),设密后重新登录为 full。
func TestLoginMustChangeCreatesRestrictedSession(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
bootHash, err := store.HashPassword("temp-pass-00")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("oscar", "", bootHash, false); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"temp-pass-00"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) {
t.Fatalf("must-change login must create restricted session: %d %s", rec.Code, rec.Body.String())
}
tok := cookieOf(rec)
if rec := do(h, "GET", "/api/projects", "", tok); rec.Code != http.StatusForbidden {
t.Fatalf("business api before set: %d", rec.Code)
}
rec = do(h, "POST", "/api/auth/password", `{"current":"temp-pass-00","next":"oscar-final-9"}`, tok)
if rec.Code != http.StatusOK {
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"oscar-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"full"`) {
t.Fatalf("full login after set: %d %s", rec.Code, rec.Body.String())
}
}
// 主动改密:必须带正确 current;成功保留当前会话。
func TestActiveChangePasswordRequiresCurrent(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
_ = srv
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
t.Fatal(err)
}
account, err := st.CreatePendingAccount("admin", "petra", store.AccountProfile{})
if err != nil {
t.Fatal(err)
}
plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+plain+`"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
tok := cookieOf(rec)
rec = do(h, "POST", "/api/auth/password", `{"next":"petra-first-8"}`, tok)
if rec.Code != http.StatusOK {
t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String())
}
// 重新登录得 full 会话。
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"petra","password":"petra-first-8"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
tok = cookieOf(rec)
// 缺 current / 错 current 拒绝。
if rec := do(h, "POST", "/api/auth/password", `{"next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized {
t.Fatalf("active change without current: %d", rec.Code)
}
if rec := do(h, "POST", "/api/auth/password", `{"current":"wrong-current","next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized {
t.Fatalf("active change with wrong current: %d", rec.Code)
}
// 正确 current 成功且当前会话保留。
rec = do(h, "POST", "/api/auth/password", `{"current":"petra-first-8","next":"petra-second-9"}`, tok)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
t.Fatalf("active change: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "GET", "/api/me", "", tok); rec.Code != http.StatusOK {
t.Fatalf("current session must survive active change: %d", rec.Code)
}
}
+30 -16
View File
@@ -23,9 +23,9 @@ func (s *Server) createBackgroundTask(w http.ResponseWriter, r *http.Request) {
http.Error(w, "kind required", http.StatusBadRequest)
return
}
task, err := s.st.CreateBackgroundTask(s.owner(r), body.Kind, body.Title, body.ConversationID)
task, err := s.st.CreateBackgroundTaskAccountChecked(s.owner(r), body.Kind, body.Title, body.ConversationID, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, task)
@@ -59,8 +59,8 @@ func (s *Server) updateBackgroundTask(w http.ResponseWriter, r *http.Request) {
http.Error(w, "status required", http.StatusBadRequest)
return
}
if err := s.st.UpdateBackgroundTask(s.owner(r), r.PathValue("id"), body.Status, body.Progress, body.Error); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
if err := s.st.UpdateBackgroundTaskAccountChecked(s.owner(r), r.PathValue("id"), body.Status, body.Progress, body.Error, s.Auth == nil || !s.Auth.Enabled); err != nil {
writeStoreErr(w, err)
return
}
s.getBackgroundTask(w, r)
@@ -81,9 +81,9 @@ func (s *Server) putMemory(w http.ResponseWriter, r *http.Request) {
if e.Source == "" {
e.Source = "manual"
}
saved, err := s.st.PutMemory(s.owner(r), e)
saved, err := s.st.PutMemoryAccountChecked(s.owner(r), e, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, saved)
@@ -119,11 +119,11 @@ func (s *Server) postAudit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "action required", http.StatusBadRequest)
return
}
if err := s.st.AppendAuditEvent(store.AuditEvent{
if err := s.st.AppendAuditEventAccountChecked(s.owner(r), store.AuditEvent{
Owner: s.owner(r), Actor: s.owner(r), Action: body.Action, Target: body.Target,
Evidence: body.Evidence, TraceID: r.Header.Get("X-Trace-Id"),
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}, s.Auth == nil || !s.Auth.Enabled); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -137,8 +137,8 @@ func (s *Server) setPluginEnabled(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := s.st.SetPluginEnabled(s.owner(r), r.PathValue("id"), body.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
if err := s.st.SetPluginEnabledAccountChecked(s.owner(r), r.PathValue("id"), body.Enabled, s.Auth == nil || !s.Auth.Enabled); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -210,11 +210,11 @@ func (s *Server) submitSurface(w http.ResponseWriter, r *http.Request) {
http.Error(w, "pluginId and spec required", http.StatusBadRequest)
return
}
snap, err := s.Surfaces.Submit(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{
snap, err := s.Surfaces.SubmitAccountChecked(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{
SurfaceID: core.ID(body.SurfaceID), ConversationID: core.ID(body.ConversationID),
BaseRevision: body.BaseRevision, ObservedEpoch: body.ObservedEpoch,
ContextVersion: body.ContextVersion, SemanticText: body.SemanticText, Spec: body.Spec,
})
}, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
s.writeSurfaceError(w, err)
return
@@ -260,7 +260,7 @@ func (s *Server) setSurfaceActivation(w http.ResponseWriter, r *http.Request) {
http.Error(w, "enabled required", http.StatusBadRequest)
return
}
row, err := s.Surfaces.SetActivation(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled)
row, err := s.Surfaces.SetActivationAccountChecked(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
s.writeSurfaceError(w, err)
return
@@ -273,6 +273,20 @@ func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
if s.projectLifecycle != nil {
snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
if snap.Identity.ProjectID != "" {
if err := s.projectWork(r.Context(), s.owner(r), snap.Identity.ProjectID); err != nil {
writeProjectErr(w, err)
return
}
}
}
// 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道
// (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。
var body struct {
@@ -285,10 +299,10 @@ func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
http.Error(w, "actionId and requestId required", http.StatusBadRequest)
return
}
res, err := s.Surfaces.SubmitAction(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{
res, err := s.Surfaces.SubmitActionAccountChecked(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{
SurfaceID: core.ID(r.PathValue("id")), ActionID: body.ActionID,
Revision: body.Revision, RequestID: body.RequestID, Data: body.Data,
})
}, s.Auth == nil || !s.Auth.Enabled)
if err != nil {
s.writeSurfaceError(w, err)
return
+30
View File
@@ -0,0 +1,30 @@
package server
import (
core "agent-system-core"
)
// projectStreamMembership consults the registered resource authorizer using the
// physical project's trusted owner, never the event publisher or payload claims.
func (s *Server) projectStreamMembership(actor, project string) bool {
p, err := s.st.ProjectMetadata(project)
if err != nil {
return false
}
return s.authorizeSharedResource(actor, "stream", "project", project, p.Owner, project)
}
func (s *Server) projectEventVisible(actor string, event core.StreamEvent) bool {
if s.projectLifecycle == nil {
return true
}
access, err := s.st.ResolveProjectEventAccess(event)
if err != nil || access.Owner == "" {
return false
}
if access.Owner == actor {
return true
}
project, ok := core.ProjectFromChannel(event.ConversationID)
return ok && access.Shared && s.projectStreamMembership(actor, project)
}
+167
View File
@@ -0,0 +1,167 @@
package server
import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+project+"&since=0", nil)
r.Header.Set("Authorization", "Bearer "+key)
// Replay can pass more than a thousand private events before the first
// visible frame; account checks on CI and under -race need headroom.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r.WithContext(ctx))
return w
}
func projectEventFixture(t *testing.T) (*Server, string, string, string, *atomic.Bool) {
t.Helper()
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
p, _ := st.CreateProject("alice", "Project")
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked)
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
member := &atomic.Bool{}
member.Store(true)
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || (q.Actor == "bob" && member.Load())), nil
})
return s, alice, bob, p.ID, member
}
func TestProjectEventsLegacyReplayIsPrivate(t *testing.T) {
s, alice, bob, p, _ := projectEventFixture(t)
s.DisableUploadIndexing = true
if w := lifecycleUpload(t, s, alice, p, "legacy-owner-private.txt", "PRIVATE"); w.Code != 200 {
t.Fatal(w.Code)
}
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }}, nil)
member := projectStreamReplay(t, s, bob, p)
if member.Code != 200 || bytes.Contains(member.Body.Bytes(), []byte("legacy-owner-private.txt")) || bytes.Contains(member.Body.Bytes(), []byte("builtin:txt")) {
t.Fatal("legacy private upload leaked to member")
}
owner := projectStreamReplay(t, s, alice, p)
if owner.Code != 200 || !bytes.Contains(owner.Body.Bytes(), []byte("legacy-owner-private.txt")) {
t.Fatal("owner lost private history")
}
}
func TestProjectEventsSharedProofAndPrivatePagination(t *testing.T) {
s, _, bob, p, _ := projectEventFixture(t)
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
card, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "PRIVATE HISTORY"}, "")
if err != nil {
t.Fatal(err)
}
payload, _ := json.Marshal(map[string]any{"card": card, "owner": "bob", "shared": true, "producer": "plugin:rdm:structure"})
private := make([]core.StreamEvent, 1001)
for i := range private {
private[i] = core.StreamEvent{Type: core.EvCardUpdated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1}
}
if _, err := s.st.AppendEvents(core.ProjectChannelKey(p), private); err != nil {
t.Fatal(err)
}
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "EXPLICIT SHARED"}, ""); err != nil {
t.Fatal(err)
}
w := projectStreamReplay(t, s, bob, p)
if w.Code != 200 || bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")) || !bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")) {
t.Fatalf("private pagination/spoof proof failed: code=%d private=%t shared=%t bytes=%d", w.Code,
bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")), bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")), w.Body.Len())
}
}
func TestProjectEventsLivePrivacyAndRevocation(t *testing.T) {
s, _, bob, p, member := projectEventFixture(t)
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
httpServer := httptest.NewServer(s.Handler())
defer httpServer.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil)
req.Header.Set("Authorization", "Bearer "+bob)
response, err := httpServer.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LIVE PRIVATE"}, ""); err != nil {
t.Fatal(err)
}
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "LIVE SHARED"}, ""); err != nil {
t.Fatal(err)
}
reader := bufio.NewReader(response.Body)
var before bytes.Buffer
for {
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("live shared event did not arrive: %v", err)
}
before.WriteString(line)
if bytes.Contains([]byte(line), []byte("LIVE SHARED")) {
break
}
}
if bytes.Contains(before.Bytes(), []byte("LIVE PRIVATE")) {
t.Fatal("live private frame leaked before revocation")
}
member.Store(false)
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "AFTER REMOVAL"}, ""); err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("revoked stream did not stop: %v", err)
}
if bytes.Contains(body, []byte("LIVE PRIVATE")) || bytes.Contains(body, []byte("AFTER REMOVAL")) {
t.Fatal("live private/revoked event leaked")
}
}
func TestUnregisteredProjectEventsRetainLegacyReads(t *testing.T) {
s, _, bob, p, _ := projectEventFixture(t)
if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LEGACY BASE"}, ""); err != nil {
t.Fatal(err)
}
w := projectStreamReplay(t, s, bob, p)
if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte("LEGACY BASE")) {
t.Fatal("unregistered project stream changed")
}
}
func TestProjectEventsIdleStreamStopsAfterMembershipRemoval(t *testing.T) {
previous := sseAuthRecheck
sseAuthRecheck = 20 * time.Millisecond
defer func() { sseAuthRecheck = previous }()
s, _, bob, p, member := projectEventFixture(t)
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
httpServer := httptest.NewServer(s.Handler())
defer httpServer.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil)
request.Header.Set("Authorization", "Bearer "+bob)
response, err := httpServer.Client().Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
member.Store(false)
body, err := io.ReadAll(response.Body)
if err != nil || !bytes.Contains(body, []byte("project-access-revoked")) {
t.Fatal("idle project stream kept revoked member connected")
}
}
+82
View File
@@ -0,0 +1,82 @@
package server
import (
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
"errors"
"net/http"
)
// SetProjectLifecycle is wired only for a registered in-process provider. The
// optional host factory constructs a capability scoped to this request's actor.
func (s *Server) SetProjectLifecycle(p extension.ProjectLifecycle, host func(string) extension.Host) {
s.projectLifecycle = &p
s.projectHost = host
}
func (s *Server) lifecycleHost(owner string) extension.Host {
if s.projectHost == nil {
return nil
}
return s.projectHost(owner)
}
func (s *Server) projectWork(ctx context.Context, owner, projectID string) error {
if s.projectLifecycle == nil {
return nil
}
if s.projectLifecycle.Work == nil {
return store.ErrNotFound
}
ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID))
if err != nil {
return err
}
if !ok {
return store.ErrNotFound
}
return nil
}
// createPrivateConversation is shared by the API and /new. Only live work
// authorization grants member creation; the resulting conversation stays owned.
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
if s.projectLifecycle != nil {
if err := s.projectWork(ctx, owner, projectID); err != nil {
return core.Conversation{}, err
}
return s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, false)
}
c, err := s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, true)
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) {
return s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, false)
}
return c, err
}
func writeProjectErr(w http.ResponseWriter, err error) {
var coded interface{ StatusCode() int }
if errors.As(err, &coded) {
switch code := coded.StatusCode(); code {
case 400, 403, 404, 409, 503:
http.Error(w, err.Error(), code)
return
}
}
writeStoreErr(w, err)
}
func writeProjectJSON(w http.ResponseWriter, p any, err error) {
if err != nil {
writeProjectErr(w, err)
return
}
writeJSON(w, http.StatusOK, p)
}
// publicProjectMetadata also masks provider-returned metadata, since providers
// may obtain raw owner-management rows before producing a shared public view.
func (s *Server) publicProjectMetadata(p core.Project) core.Project {
if s.projectLifecycle != nil {
p.Settings = s.set.MaskedProjectMetadata(p.Settings)
}
return p
}
+506
View File
@@ -0,0 +1,506 @@
package server
import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
)
type lifecycleStatusError int
func (e lifecycleStatusError) Error() string { return "provider refusal" }
func (e lifecycleStatusError) StatusCode() int { return int(e) }
func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
r.Header.Set("Authorization", "Bearer "+key)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}
func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Original")
c, _ := st.CreateConversation("alice", p.ID, "Private", "")
calls := []string{}
s.SetProjectLifecycle(extension.ProjectLifecycle{
List: func(context.Context, extension.Host) ([]core.Project, error) {
calls = append(calls, "list")
return []core.Project{{ID: p.ID, Owner: "alice", Name: "Decorated", Settings: map[string]any{"rdm.state": "ready"}}}, nil
},
Create: func(_ context.Context, _ extension.Host, name string) (core.Project, error) {
calls = append(calls, "create")
return core.Project{ID: "provider-created", Name: name}, nil
},
Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
calls = append(calls, "rename")
return core.Project{ID: id, Name: name}, nil
},
Archive: func(context.Context, extension.Host, string) error { calls = append(calls, "archive"); return nil },
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
}, nil)
for _, q := range []struct{ method, path, body string }{{"GET", "/api/projects", ""}, {"POST", "/api/projects", `{"name":" New "}`}, {"PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {"DELETE", "/api/projects/" + p.ID, ""}} {
w := lifecycleRequest(t, s, key, q.method, q.path, q.body)
if w.Code != 200 {
t.Fatalf("%s: %d %s", q.method, w.Code, w.Body.String())
}
}
if len(calls) != 4 || calls[0] != "list" || calls[1] != "create" || calls[2] != "rename" || calls[3] != "archive" {
t.Fatalf("calls=%v", calls)
}
if _, err := st.GetConversation("alice", c.ID); err != nil {
t.Fatalf("archive removed history: %v", err)
}
s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) {
return core.Project{}, fmtWrap(lifecycleStatusError(409))
}}, nil)
if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`); w.Code != 409 {
t.Fatalf("trusted status=%d %s", w.Code, w.Body.String())
}
}
func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) }
func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test")
p, _ := st.CreateProject("alice", "Shared")
ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "")
ready := true
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return ready && (actor == "alice" || actor == "bob") && id == core.ID(p.ID), nil
}}, nil)
w := lifecycleRequest(t, s, bob, "POST", "/api/projects/"+p.ID+"/conversations", `{"title":"Bob private"}`)
if w.Code != 200 {
t.Fatalf("member create=%d %s", w.Code, w.Body.String())
}
var bc core.Conversation
if err := json.Unmarshal(w.Body.Bytes(), &bc); err != nil {
t.Fatal(err)
}
w = lifecycleRequest(t, s, bob, "GET", "/api/projects/"+p.ID+"/conversations", "")
var cs []core.Conversation
_ = json.Unmarshal(w.Body.Bytes(), &cs)
if w.Code != 200 || len(cs) != 1 || cs[0].ID != bc.ID {
t.Fatalf("member list=%d %+v", w.Code, cs)
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+ac.ID, ""); w.Code != 404 {
t.Fatalf("private history=%d", w.Code)
}
if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
t.Fatalf("nonmember create=%d", w.Code)
}
if w := lifecycleRequest(t, s, mallory, "GET", "/api/projects/"+p.ID+"/conversations", ""); w.Code != 404 {
t.Fatalf("nonmember list=%d", w.Code)
}
s.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() })
s.WireBuiltinCommands()
w = lifecycleRequest(t, s, bob, "POST", "/api/conversations/"+bc.ID+"/messages", `{"text":"/new"}`)
if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte(`"conversation"`)) {
t.Fatalf("/new=%d %s", w.Code, w.Body.String())
}
ready = false
for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} {
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
if w.Code != 404 {
t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String())
}
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 {
t.Fatalf("owned revoked history=%d", w.Code)
}
}
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
p, _ := st.CreateProject("alice", "Shared")
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
if w := lifecycleRequest(t, s, key, http.MethodPost, "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
t.Fatalf("read-only write=%d %s", w.Code, w.Body.String())
}
if cs, err := st.ListConversations("bob", p.ID); err != nil || len(cs) != 0 {
t.Fatalf("denial wrote state: %v %v", cs, err)
}
}
func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Project")
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
card := core.HITLCard{ID: "hitl-revoked", ConversationID: conv.ID, RunID: "run-revoked", Kind: "confirm"}
if err := st.SaveHITLCard("alice", &card); err != nil {
t.Fatal(err)
}
for _, q := range []struct{ path, body string }{{"/api/conversations/" + conv.ID + "/regenerate", `{}`}, {"/api/hitl/" + card.ID + "/resolve", `{"choiceId":"yes"}`}} {
w := lifecycleRequest(t, s, key, "POST", q.path, q.body)
if w.Code != 404 {
t.Fatalf("resumption %s=%d %s", q.path, w.Code, w.Body.String())
}
}
}
func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Project")
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
svc := surfacesvc.New(st, nil)
s.Surfaces = svc
if _, err := svc.SetActivation(context.Background(), "alice", "generative-ui", true); err != nil {
t.Fatal(err)
}
snap, err := svc.Submit(context.Background(), "alice", "generative-ui", surfacesvc.SubmitRequest{ConversationID: conv.ID, Spec: json.RawMessage(`{"title":"Page","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"Submit"}]}]}`)})
if err != nil {
t.Fatal(err)
}
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
w := lifecycleRequest(t, s, key, "POST", "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", `{"actionId":"generative-ui.submit","revision":1,"requestId":"request"}`)
if w.Code != 404 {
t.Fatalf("revoked surface action=%d %s", w.Code, w.Body.String())
}
}
func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Project")
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
if err != nil {
return nil, err
}
if current.Settings == nil {
current.Settings = map[string]any{}
}
current.Settings["rdm.projectId"] = "canonical"
current.Settings["rdm.state"] = "ready"
return []core.Project{current}, nil
}}, nil)
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"rdm.projectId":"forged","example":true}}`)
var got core.Project
_ = json.Unmarshal(w.Body.Bytes(), &got)
if w.Code != 200 || got.Settings["rdm.projectId"] != "canonical" || got.Settings["example"] != true {
t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String())
}
}
func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
p, _ := st.CreateProject("alice", "Shared")
raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
t.Fatal(err)
}
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugin.private.credential", Secret: true}})
read := func() (core.Project, error) { return st.GetProject("alice", p.ID) }
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := read()
return []core.Project{current}, err
}, Create: func(context.Context, extension.Host, string) (core.Project, error) { return read() }, Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { return read() }}, nil)
for _, q := range []struct{ key, method, path, body string }{{bob, "GET", "/api/projects", ""}, {alice, "GET", "/api/projects", ""}, {alice, "POST", "/api/projects", `{"name":"New"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"settings":{"display":{"caption":"Updated"}}}`}} {
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
if w.Code != 200 {
t.Fatalf("public metadata %s=%d", q.method, w.Code)
}
for _, secret := range []string{"fixture-key", "fixture-token", "fixture-flat-token", "fixture-schema-token"} {
if bytes.Contains(w.Body.Bytes(), []byte(secret)) {
t.Fatalf("%s %s leaked a fixture credential", q.method, q.path)
}
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) || !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.state":"ready"`)) {
t.Fatal("service metadata lost")
}
}
owned, err := read()
if err != nil {
t.Fatal(err)
}
if owned.Settings["models.chat.apiKey"] != "fixture-key" || owned.Settings["custom"].(map[string]any)["token"] != "fixture-token" {
t.Fatal("public metadata masking changed stored credentials")
}
}
func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) {
for _, registered := range []bool{false, true} {
t.Run(fmt.Sprint(registered), func(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Original")
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
if registered {
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
return []core.Project{current}, err
}, Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
if err := st.RenameProject("alice", id, name); err != nil {
return core.Project{}, err
}
return st.GetProject("alice", id)
}}, nil)
}
for _, body := range []string{`{"name":"Changed","settings":{"models":{"chat":{"apiKey":"fixture-key"}},"display":{"caption":"Forbidden partial write"}}}`, `{"settings":{"models.chat.apiKey":"fixture-key"}}`, `{"settings":{"models":"smash"}}`, `{"settings":{"plugins.private.managed":"override"}}`} {
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body)
if w.Code != 400 {
t.Fatalf("managed patch=%d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil {
t.Fatal(err)
}
if current.Name != "Original" || len(current.Settings) != 0 {
t.Fatal("rejected managed patch applied a partial mutation")
}
}
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"custom.caption":"Allowed"}}`)
if w.Code != 200 {
t.Fatalf("allowed patch=%d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil || current.Settings["custom.caption"] != "Allowed" {
t.Fatal("allowed metadata shape changed")
}
})
}
}
func TestGenericProjectPatchAcceptsNestedMaskedWriteback(t *testing.T) {
for _, registered := range []bool{false, true} {
t.Run(fmt.Sprint(registered), func(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Project")
if err := st.UpdateProjectSettings("alice", p.ID, map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-persisted-key"}}, "display": map[string]any{"caption": "Original"}}); err != nil {
t.Fatal(err)
}
if registered {
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
return []core.Project{current}, err
}}, nil)
}
for _, tc := range []struct{ body, caption string }{
{`{"settings":{"models":{"chat":{"apiKey":"••••••"}},"display":{"caption":"Nested updated"}}}`, "Nested updated"},
{`{"settings":{"models.chat":{"apiKey":"••••••"},"display":{"caption":"Mixed updated"}}}`, "Mixed updated"},
{`{"settings":{"models":{"chat.apiKey":"••••••"},"display":{"caption":"Mixed leaf updated"}}}`, "Mixed leaf updated"},
} {
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, tc.body)
if w.Code != 200 {
t.Fatalf("masked writeback rejected legal edit: %d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil {
t.Fatal(err)
}
if current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] != "fixture-persisted-key" || current.Settings["display"].(map[string]any)["caption"] != tc.caption {
t.Fatalf("masked writeback secret preserved=%v caption=%q expected=%q", current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] == "fixture-persisted-key", current.Settings["display"].(map[string]any)["caption"], tc.caption)
}
}
for _, body := range []string{`{"settings":{"models":{}}}`, `{"settings":{"models":{"chat":{}}}}`, `{"settings":{"models.chat":{}}}`, `{"settings":{"models":{"chat":{"apiKey":"••••••","model":"Overwrite"}}}}`} {
if w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body); w.Code != 400 {
t.Fatalf("explicit empty/real managed write accepted: %d", w.Code)
}
}
})
}
}
func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content string) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
file, err := writer.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
if _, err := file.Write([]byte(content)); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", &body)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec
}
func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test")
p, _ := st.CreateProject("alice", "Shared")
ready := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return ready && id == p.ID && (actor == "alice" || actor == "bob"), nil
}}, nil)
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
if q.ResourceType == "artifact" {
return q.Actor == q.Owner, nil
}
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
})
s.DisableUploadIndexing = true
w := lifecycleUpload(t, s, bob, p.ID, "member-private.txt", "QA_MEMBER_PRIVATE_ORIGINAL")
if w.Code != 200 {
t.Fatalf("member upload=%d", w.Code)
}
var info core.UploadInfo
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
artifact, err := st.GetArtifact("bob", info.ArtifactID)
if err != nil || artifact.ProjectID != p.ID || artifact.Owner != "bob" {
t.Fatalf("upload ownership/target incorrect: %+v %v", artifact, err)
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/artifacts/"+info.ArtifactID+"/content", ""); w.Code != 200 || w.Body.String() != "QA_MEMBER_PRIVATE_ORIGINAL" {
t.Fatalf("owner original read=%d", w.Code)
}
for _, key := range []string{alice, mallory} {
for _, suffix := range []string{"", "/content"} {
if w := lifecycleRequest(t, s, key, "GET", "/api/artifacts/"+info.ArtifactID+suffix, ""); w.Code != 404 {
t.Fatalf("other actor read private original=%d", w.Code)
}
}
}
if w := lifecycleUpload(t, s, mallory, p.ID, "forbidden.txt", "DENIED"); w.Code != 404 {
t.Fatalf("nonmember upload=%d", w.Code)
}
ready = false
if w := lifecycleUpload(t, s, bob, p.ID, "revoked.txt", "DENIED"); w.Code != 404 {
t.Fatalf("revoked upload=%d", w.Code)
}
artifacts, err := st.ListProjectArtifactsAll(p.ID)
if err != nil || len(artifacts) != 1 {
t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err)
}
}
func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
p, _ := st.CreateProject("alice", "Shared")
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return id == p.ID && (actor == "alice" || actor == "bob"), nil
}}, nil)
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
})
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked)
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
s.DisableUploadIndexing = true
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil {
t.Fatal(err)
}
// The owner path already admitted upload before the member authorization fix.
if w := lifecycleUpload(t, s, alice, p.ID, "owner-private-filename.txt", "PRIVATE ORIGINAL"); w.Code != 200 {
t.Fatalf("owner upload=%d", w.Code)
}
if w := lifecycleUpload(t, s, bob, p.ID, "member-private-filename.txt", "MEMBER PRIVATE ORIGINAL"); w.Code != 200 {
t.Fatalf("member upload=%d", w.Code)
}
for _, owner := range []string{"alice", "bob"} {
cards, err := st.ListProjectCards(owner, p.ID)
if err != nil {
t.Fatal(err)
}
expected := 0
if owner == "alice" {
expected = 1
}
if len(cards) != expected {
t.Fatal("private upload added project card history")
}
for _, card := range cards {
if card.Title != "Shared structure" {
t.Fatal("private filename/parser card entered project history")
}
}
}
events, err := st.EventsAfter(core.ProjectChannelKey(p.ID), 0, 100)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 {
t.Fatal("private upload added project events")
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte("owner-private-filename.txt")) || bytes.Contains(event.Payload, []byte("member-private-filename.txt")) {
t.Fatal("private upload metadata entered persisted project stream")
}
}
request := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+p.ID, nil)
request.Header.Set("Authorization", "Bearer "+bob)
ctx, cancel := context.WithTimeout(request.Context(), 20*time.Millisecond)
defer cancel()
request = request.WithContext(ctx)
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != 200 || !bytes.Contains(response.Body.Bytes(), []byte("Shared structure")) {
t.Fatalf("member structural stream did not replay: %d", response.Code)
}
if bytes.Contains(response.Body.Bytes(), []byte("owner-private-filename.txt")) || bytes.Contains(response.Body.Bytes(), []byte("member-private-filename.txt")) {
t.Fatal("another member's SSE exposed private upload metadata")
}
}
func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Private")
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked)
s.DisableUploadIndexing = true
if w := lifecycleUpload(t, s, key, p.ID, "legacy-upload.txt", "Original"); w.Code != 200 {
t.Fatalf("unregistered owner upload=%d", w.Code)
}
cards, err := st.ListProjectCards("alice", p.ID)
if err != nil || len(cards) != 1 || cards[0].Title != "legacy-upload.txt" {
t.Fatalf("unregistered ingestion card changed: count=%d error=%v", len(cards), err)
}
if w := lifecycleRequest(t, s, key, "POST", "/api/projects/missing/uploads", "not multipart"); w.Code != 400 {
t.Fatalf("legacy missing-file validation changed: %d", w.Code)
}
}
func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
p, _ := st.CreateProject("alice", "Shared")
ready := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) {
allowed := ready
ready = false
return allowed, nil
}}, nil)
if w := lifecycleUpload(t, s, key, p.ID, "revoked-during-upload.txt", "PRIVATE"); w.Code != 404 {
t.Fatalf("upload persisted after authorization changed: %d", w.Code)
}
artifacts, err := st.ListProjectArtifactsAll(p.ID)
if err != nil || len(artifacts) != 0 {
t.Fatal("revoked upload created an artifact")
}
}
@@ -0,0 +1,100 @@
package server
import (
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
"encoding/json"
"testing"
)
func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
docs := []core.KnowledgeDoc{{ID: "published-slice", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle published"}, {ID: "private-draft", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle draft"}, {ID: "self-denied", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own restricted"}, {ID: "self-allowed", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own"}}
if err := st.AddKnowledge(docs...); err != nil {
t.Fatal(err)
}
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
seen := map[string]extension.ResourceAuthzQuery{}
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
seen[q.ResourceID] = q
return q.ResourceType == "project" || q.ResourceID == "published-slice" || q.ResourceID == "self-allowed", nil
})
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sharedneedle", "")
var hits []core.RetrievalHit
_ = json.Unmarshal(w.Body.Bytes(), &hits)
if w.Code != 200 || len(hits) != 2 {
t.Fatalf("V2 filtered hits=%d %s", w.Code, w.Body.String())
}
for _, hit := range hits {
if hit.Doc.ID != "published-slice" && hit.Doc.ID != "self-allowed" {
t.Fatalf("private leak: %+v", hit.Doc)
}
}
q := seen["published-slice"]
if q.ResourceType != "knowledge" || q.Actor != "bob" || q.Action != "search" || q.Owner != "alice" || q.ProjectID != "domain-p" {
t.Fatalf("trusted doc query: %+v", q)
}
}
func TestArtifactListV2FiltersEachResource(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
p, _ := st.CreateProject("alice", "Project")
private := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Private", MIME: "text/plain"}
public := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Shared", MIME: "text/plain"}
if err := st.CreateArtifact(&private, []byte("private")); err != nil {
t.Fatal(err)
}
if err := st.CreateArtifact(&public, []byte("shared")); err != nil {
t.Fatal(err)
}
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "project" || (q.ResourceType == "artifact" && q.Action == "list" && q.ResourceID == public.ID && q.Owner == "alice"), nil
})
w := lifecycleRequest(t, s, key, "GET", "/api/projects/"+p.ID+"/artifacts", "")
var as []core.Artifact
_ = json.Unmarshal(w.Body.Bytes(), &as)
if w.Code != 200 || len(as) != 1 || as[0].ID != public.ID {
t.Fatalf("artifact list=%d %s", w.Code, w.Body.String())
}
}
func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
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)
}
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { return q.ResourceType == "knowledge", nil })
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}, {ID: "host-other", Settings: map[string]any{"rdm.projectId": "domain-other", "rdm.state": "ready"}}}, nil
}}, nil)
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=host-current", "")
var hits []core.RetrievalHit
_ = json.Unmarshal(w.Body.Bytes(), &hits)
if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
t.Fatalf("current scope=%d %s", w.Code, w.Body.String())
}
if w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=unknown", ""); w.Code != 404 {
t.Fatalf("unknown scope=%d %s", w.Code, w.Body.String())
}
}
func TestKnowledgeV2UsesStoredPublicationSource(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
// A known published identity replaced by a private row must lose sharing.
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "known-published-id", Owner: "alice", ProjectID: "domain", Source: "manual", Chunk: "sourceneedle private"}, core.KnowledgeDoc{ID: "real-published-id", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sourceneedle public"}); err != nil {
t.Fatal(err)
}
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "knowledge" && q.Source == "plugin:rdm:published", nil
})
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sourceneedle", "")
var hits []core.RetrievalHit
_ = json.Unmarshal(w.Body.Bytes(), &hits)
if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "real-published-id" {
t.Fatalf("actual stored source=%d %s", w.Code, w.Body.String())
}
}
+128
View File
@@ -0,0 +1,128 @@
package server
import (
"net/http"
)
func (s *Server) createAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
var req struct {
ExpectedStateVersion int64 `json:"expectedStateVersion"`
IdempotencyKey string `json:"idempotencyKey"`
}
if err := decodeStrictJSON(r, &req); err != nil {
http.Error(w, "invalid recovery proposal", http.StatusBadRequest)
return
}
proposal, err := s.st.CreateRecoveryProposal(actor, r.PathValue("userId"), req.ExpectedStateVersion, req.IdempotencyKey)
if err != nil {
writeStoreErr(w, err)
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, proposal)
}
func (s *Server) getAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
proposal, err := s.st.GetRecoveryProposalForAdmin(actor, r.PathValue("userId"), r.PathValue("id"))
writeJSONOr(w, proposal, err)
}
func (s *Server) listAdminRecoveryProposals(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
proposals, err := s.st.ListRecoveryProposalsForAdmin(actor, r.PathValue("userId"))
writeJSONOr(w, map[string]any{"proposals": proposals}, err)
}
func (s *Server) revokeAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
var req struct {
ExpectedVersion int64 `json:"expectedVersion"`
}
if err := decodeStrictJSON(r, &req); err != nil {
http.Error(w, "invalid recovery revocation", http.StatusBadRequest)
return
}
proposal, err := s.st.RevokeRecoveryProposal(actor, r.PathValue("userId"), r.PathValue("id"), req.ExpectedVersion)
writeJSONOr(w, proposal, err)
}
func (s *Server) listMyRecoveryProposals(w http.ResponseWriter, r *http.Request) {
owner, ok := s.requireFullPasswordSession(w, r)
if !ok {
return
}
proposals, err := s.st.ListOwnerRecoveryProposals(owner)
writeJSONOr(w, map[string]any{"proposals": proposals}, err)
}
func (s *Server) getMyRecoveryProposal(w http.ResponseWriter, r *http.Request) {
owner, ok := s.requireFullPasswordSession(w, r)
if !ok {
return
}
proposal, err := s.st.GetOwnerRecoveryProposal(owner, r.PathValue("id"))
writeJSONOr(w, proposal, err)
}
func (s *Server) confirmRecoveryProposal(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireFullPasswordSession(w, r); !ok {
return
}
var req struct {
ExpectedVersion int64 `json:"expectedVersion"`
}
if err := decodeStrictJSON(r, &req); err != nil {
http.Error(w, "invalid recovery confirmation", http.StatusBadRequest)
return
}
plain, proposal, err := s.st.ConfirmRecoveryProposal(sessionToken(r), r.PathValue("id"), req.ExpectedVersion)
if err != nil {
writeStoreErr(w, err)
return
}
w.Header().Set("Cache-Control", "no-store")
out := map[string]any{"proposal": proposal}
if plain != "" {
out["grant"] = plain
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) rotateRecoveryGrant(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireFullPasswordSession(w, r); !ok {
return
}
var req struct {
ExpectedVersion int64 `json:"expectedVersion"`
ExpectedGeneration int64 `json:"expectedGeneration"`
}
if err := decodeStrictJSON(r, &req); err != nil {
http.Error(w, "invalid recovery grant rotation", http.StatusBadRequest)
return
}
plain, proposal, err := s.st.RotateRecoveryGrant(sessionToken(r), r.PathValue("id"), req.ExpectedVersion, req.ExpectedGeneration)
if err != nil {
writeStoreErr(w, err)
return
}
w.Header().Set("Cache-Control", "no-store")
out := map[string]any{"proposal": proposal}
if plain != "" {
out["grant"] = plain
}
writeJSON(w, http.StatusOK, out)
}
+168
View File
@@ -0,0 +1,168 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"agent-system-backend/internal/store"
)
func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
pending, err := st.CreatePendingAccount("admin", "recovery-user", store.AccountProfile{})
if err != nil {
t.Fatal(err)
}
activation, err := st.InitializePendingAccount(pending.ID, pending.StateVersion, pending.InitializationGeneration)
if err != nil {
t.Fatal(err)
}
setup := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+activation+`"}`, "")
if setup.Code != http.StatusOK || !strings.Contains(setup.Body.String(), `"passwordChangeMode":"grant_setup"`) {
t.Fatalf("activation redeem: %d %s", setup.Code, setup.Body.String())
}
if rec := do(h, "POST", "/api/auth/password", `{"next":"alice-initial-password"}`, cookieOf(setup)); rec.Code != http.StatusOK {
t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String())
}
ownerLogin := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-initial-password"}`, "")
if ownerLogin.Code != http.StatusOK {
t.Fatalf("trusted owner login: %d %s", ownerLogin.Code, ownerLogin.Body.String())
}
ownerCookie := cookieOf(ownerLogin)
account, err := st.GetAccount(pending.ID)
if err != nil {
t.Fatal(err)
}
proposalResponse := do(h, "POST", "/api/admin/users/"+pending.ID+"/recovery-proposals",
`{"expectedStateVersion":`+jsonNumber(account.StateVersion)+`,"idempotencyKey":"http-request-1"}`, admin)
if proposalResponse.Code != http.StatusOK || strings.Contains(proposalResponse.Body.String(), "grant") {
t.Fatalf("administrator proposal leaked grant: %d %s", proposalResponse.Code, proposalResponse.Body.String())
}
var proposal store.RecoveryProposal
if err := json.Unmarshal(proposalResponse.Body.Bytes(), &proposal); err != nil || proposal.ID == "" {
t.Fatalf("proposal metadata: %+v %v", proposal, err)
}
if got := do(h, "GET", "/api/admin/users/"+pending.ID+"/recovery-proposals/"+proposal.ID, "", admin); got.Code != http.StatusOK || strings.Contains(got.Body.String(), "grant") {
t.Fatalf("administrator status leaked grant: %d %s", got.Code, got.Body.String())
}
confirmed := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie)
if confirmed.Code != http.StatusOK || confirmed.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("owner confirm: %d %s", confirmed.Code, confirmed.Body.String())
}
var confirmation struct {
Proposal store.RecoveryProposal `json:"proposal"`
Grant string `json:"grant"`
}
if err := json.Unmarshal(confirmed.Body.Bytes(), &confirmation); err != nil || confirmation.Grant == "" {
t.Fatalf("confirmation secret response: %v", err)
}
if duplicate := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie); duplicate.Code != http.StatusOK || strings.Contains(duplicate.Body.String(), `"grant"`) {
t.Fatalf("duplicate confirmation replayed grant: %d %s", duplicate.Code, duplicate.Body.String())
}
if got := do(h, "GET", "/api/me/recovery-proposals/"+proposal.ID, "", ownerCookie); got.Code != http.StatusOK || strings.Contains(got.Body.String(), confirmation.Grant) {
t.Fatalf("owner status replayed grant: %d %s", got.Code, got.Body.String())
}
rotated := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant",
`{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie)
if rotated.Code != http.StatusOK {
t.Fatalf("owner rotate: %d %s", rotated.Code, rotated.Body.String())
}
var rotation struct {
Grant string `json:"grant"`
}
if err := json.Unmarshal(rotated.Body.Bytes(), &rotation); err != nil || rotation.Grant == "" || rotation.Grant == confirmation.Grant {
t.Fatalf("rotation grant: %v", err)
}
if duplicate := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant",
`{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie); duplicate.Code != http.StatusOK || strings.Contains(duplicate.Body.String(), `"grant"`) {
t.Fatalf("duplicate rotation replayed grant: %d %s", duplicate.Code, duplicate.Body.String())
}
if old := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmation.Grant+`"}`, ""); old.Code == http.StatusOK {
t.Fatal("old recovery grant remained usable")
}
redeemed := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+rotation.Grant+`"}`, "")
if redeemed.Code != http.StatusOK {
t.Fatalf("recovery redeem: %d %s", redeemed.Code, redeemed.Body.String())
}
if done := do(h, "POST", "/api/auth/password", `{"next":"alice-recovered-password"}`, cookieOf(redeemed)); done.Code != http.StatusOK {
t.Fatalf("recovery finish: %d %s", done.Code, done.Body.String())
}
if login := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-recovered-password"}`, ""); login.Code != http.StatusOK {
t.Fatalf("relogin: %d %s", login.Code, login.Body.String())
}
}
func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) }
func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
hash, err := store.HashPassword("old-victim-pass")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("victim", "", hash, false); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("victim"); err != nil {
t.Fatal(err)
}
for _, route := range []struct{ path, body string }{
{"/api/admin/users/victim/password", `{"password":"attacker-pass"}`},
{"/api/admin/users/victim/login-keys", `{"purpose":"recover"}`},
{"/api/admin/users/victim/login-keys", `{"purpose":"activate"}`},
} {
rec := do(h, "POST", route.path, route.body, admin)
if rec.Code != http.StatusGone || !strings.Contains(rec.Body.String(), "RECOVERY_CONTRACT_REPLACED") {
t.Fatalf("legacy route remained active: %s %d %s", route.path, rec.Code, rec.Body.String())
}
}
if _, ok := st.VerifyLogin("victim", "old-victim-pass"); !ok {
t.Fatal("closed routes changed existing password")
}
if keys, err := st.ListLoginKeys("victim"); err != nil || len(keys) != 0 {
t.Fatalf("closed routes issued a grant: %+v %v", keys, err)
}
}
func TestLegacyGrantAndRestrictedSessionCannotSkipCurrentPassword(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
hash, err := store.HashPassword("old-victim-pass")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("victim", "", hash, false); err != nil {
t.Fatal(err)
}
grant, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0)
if err != nil {
t.Fatal(err)
}
if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+grant+`"}`, ""); rec.Code == http.StatusOK {
t.Fatal("legacy administrator grant created a restricted session")
}
if err := st.SetUserPassword("victim", hash); err != nil {
t.Fatal(err)
}
login := do(h, "POST", "/api/auth/login", `{"owner":"victim","password":"old-victim-pass"}`, "")
if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), `"passwordChangeMode":"current_required"`) {
t.Fatalf("legacy forced login mode: %d %s", login.Code, login.Body.String())
}
cookie := cookieOf(login)
if rec := do(h, "POST", "/api/auth/password", `{"next":"victim-new-pass"}`, cookie); rec.Code == http.StatusOK {
t.Fatal("forced password session skipped current password")
}
if rec := do(h, "POST", "/api/auth/password", `{"current":"old-victim-pass","next":"victim-new-pass"}`, cookie); rec.Code != http.StatusOK {
t.Fatalf("legacy forced change with current failed: %d %s", rec.Code, rec.Body.String())
}
if _, ok := st.VerifyLogin("victim", "victim-new-pass"); !ok {
t.Fatal("current-required password change did not complete")
}
}
@@ -0,0 +1,124 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
)
func twinRecoveryStores(t *testing.T) (*store.Store, *store.Store) {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
path := filepath.Join(t.TempDir(), "shared.db")
first, err := store.OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
second, err := store.OpenSQLite(path)
if err != nil {
first.Close()
t.Fatal(err)
}
t.Cleanup(func() { second.Close(); first.Close() })
return first, second
}
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 {
t.Fatal(err)
}
schema := "t_" + strings.ToLower(store.NewID("recovery"))
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() })
isolate := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
first, err := store.OpenPostgres(isolate, filepath.Join(t.TempDir(), "a-artifacts"))
if err != nil {
t.Fatal(err)
}
second, err := store.OpenPostgres(isolate, filepath.Join(t.TempDir(), "b-artifacts"))
if err != nil {
first.Close()
t.Fatal(err)
}
t.Cleanup(func() { second.Close(); first.Close() })
return first, second
}
func TestRecoveryAcrossTwoHTTPInstancesAndIndependentPools(t *testing.T) {
first, second := twinRecoveryStores(t)
t.Logf("recovery HTTP dialect=%s two independent stores", first.Dialect())
hA := newAuthTestServerOnStore(t, first, t.TempDir()).Handler()
hB := newAuthTestServerOnStore(t, second, t.TempDir()).Handler()
adminCookie := bootAdmin(t, hA, first)
account, err := first.CreatePendingAccount("admin", "two-instance-owner", store.AccountProfile{})
if err != nil {
t.Fatal(err)
}
activation, err := first.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
if err != nil {
t.Fatal(err)
}
setup := do(hB, "POST", "/api/auth/login-key/redeem", `{"key":"`+activation+`"}`, "")
if setup.Code != http.StatusOK {
t.Fatalf("second instance activation redeem: %d %s", setup.Code, setup.Body.String())
}
if done := do(hA, "POST", "/api/auth/password", `{"next":"two-instance-initial"}`, cookieOf(setup)); done.Code != http.StatusOK {
t.Fatalf("first instance initial set: %d %s", done.Code, done.Body.String())
}
login := do(hA, "POST", "/api/auth/login", `{"owner":"two-instance-owner","password":"two-instance-initial"}`, "")
if login.Code != http.StatusOK {
t.Fatalf("first instance login: %d %s", login.Code, login.Body.String())
}
ownerCookie := cookieOf(login)
current, err := second.GetAccount(account.ID)
if err != nil {
t.Fatal(err)
}
proposalResponse := do(hA, "POST", "/api/admin/users/"+account.ID+"/recovery-proposals",
`{"expectedStateVersion":`+jsonNumber(current.StateVersion)+`,"idempotencyKey":"two-instance-proposal"}`, adminCookie)
if proposalResponse.Code != http.StatusOK {
t.Fatalf("proposal: %d %s", proposalResponse.Code, proposalResponse.Body.String())
}
var proposal store.RecoveryProposal
if err := json.Unmarshal(proposalResponse.Body.Bytes(), &proposal); err != nil {
t.Fatal(err)
}
confirmation := do(hB, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie)
if confirmation.Code != http.StatusOK {
t.Fatalf("second instance confirm: %d %s", confirmation.Code, confirmation.Body.String())
}
var confirmed struct {
Grant string `json:"grant"`
}
if err := json.Unmarshal(confirmation.Body.Bytes(), &confirmed); err != nil || confirmed.Grant == "" {
t.Fatalf("grant response: %v", err)
}
redeemed := do(hA, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmed.Grant+`"}`, "")
if redeemed.Code != http.StatusOK {
t.Fatalf("first instance grant redeem: %d %s", redeemed.Code, redeemed.Body.String())
}
if completed := do(hB, "POST", "/api/auth/password", `{"next":"two-instance-recovered"}`, cookieOf(redeemed)); completed.Code != http.StatusOK {
t.Fatalf("second instance recovery completion: %d %s", completed.Code, completed.Body.String())
}
if old := do(hB, "GET", "/api/me", "", ownerCookie); old.Code != http.StatusUnauthorized {
t.Fatalf("old proof session survived: %d", old.Code)
}
if fresh := do(hA, "POST", "/api/auth/login", `{"owner":"two-instance-owner","password":"two-instance-recovered"}`, ""); fresh.Code != http.StatusOK {
t.Fatalf("recovered password login: %d %s", fresh.Code, fresh.Body.String())
}
after, err := first.GetAccount(account.ID)
if err != nil || after.CredentialOrigin != store.CredentialOriginOwnerRecovery || after.ID != account.ID {
t.Fatalf("persistent identity or provenance changed: %+v %v", after, err)
}
}
@@ -0,0 +1,129 @@
package server
// EXT-8b 读面消费红测(方案 r1 U9 第二切片):五处共享读路径在 V2 注册后按
// 资源级查询咨询(Actor/Action/ResourceType/ResourceID/Owner/ProjectID 由
// 受信上下文构造);V2 拒绝压过 V1 放行(资源级权威);V2 未注册时五处全部
// 回落 V1(兼容现行,RDM 现网行为不变)。红:当前 server 尚无 V2 槽位与
// AuthorizeResource 消费——注册接口不存在,编译红先行。
import (
"net/http"
"net/http/httptest"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
func newV2CaptureServer(t *testing.T) (*Server, *store.Store, string, string, string) {
t.Helper()
srv, st := newAuthTestServer(t)
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
}
pj, err := st.CreateProject("alice", "资源级项目")
if err != nil {
t.Fatal(err)
}
return srv, st, alice, bob, pj.ID
}
func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) {
srv, st, alice, bob, pid := newV2CaptureServer(t)
_ = alice
// 项目内造一个工件(供 download 路径取元数据)。
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "res.txt", Kind: "file", MIME: "text/plain"}
if err := st.CreateArtifact(art, []byte("body")); err != nil {
t.Fatal(err)
}
var got []extension.ResourceAuthzQuery
srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
got = append(got, q)
return true, nil
})
h := srv.Handler()
call := func(method, path string) int {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+bob)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
// ① 工件下载(owner 自有未命中 → 资源级咨询)。
code := call(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1")
if code != http.StatusOK {
t.Fatalf("download: %d", code)
}
// ② 项目工件清单。
call(http.MethodGet, "/api/projects/"+pid+"/artifacts")
// ③ 共享项目建会话。
call(http.MethodPost, "/api/projects/"+pid+"/conversations")
byAction := map[string]extension.ResourceAuthzQuery{}
for _, q := range got {
byAction[q.Action] = q
}
dl := byAction["download"]
if dl.Actor != "bob" || dl.ResourceType != "artifact" || dl.ResourceID != art.ID || dl.ProjectID != pid || dl.Owner != "alice" {
t.Fatalf("download 查询六要素不符: %+v", dl)
}
ls := byAction["list"]
if ls.Actor != "bob" || ls.ResourceType != "artifact" || ls.ResourceID != art.ID || ls.ProjectID != pid || ls.Owner != "alice" {
t.Fatalf("list 查询不符: %+v", ls)
}
rd := byAction["read"]
if rd.Actor != "bob" || rd.ResourceType != "project" || rd.ResourceID != pid {
t.Fatalf("read(conversation) 查询不符: %+v", rd)
}
}
func TestV2DenyOverridesV1Allow(t *testing.T) {
srv, st, alice, bob, pid := newV2CaptureServer(t)
_ = alice
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "d.txt", Kind: "file"}
if err := st.CreateArtifact(art, []byte("body")); err != nil {
t.Fatal(err)
}
// V1 全放行、V2 全拒绝——资源级权威必须压过 V1。
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return true, nil
})
srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return false, nil
})
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1", nil)
req.Header.Set("Authorization", "Bearer "+bob)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("V2 拒绝须压过 V1 放行并保持 404(不泄露存在性), got %d", rec.Code)
}
}
func TestV2AbsentFallsBackToV1(t *testing.T) {
srv, st, alice, bob, pid := newV2CaptureServer(t)
_ = alice
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "f.txt", Kind: "file"}
if err := st.CreateArtifact(art, []byte("body")); err != nil {
t.Fatal(err)
}
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return owner == "bob" && string(projectID) == pid, nil
})
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1", nil)
req.Header.Set("Authorization", "Bearer "+bob)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("V2 未注册应回落 V1(现行行为), got %d", rec.Code)
}
}
+3 -3
View File
@@ -19,15 +19,15 @@ func TestProjectResourceAuthorizerSharesReads(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
alice, err := st.CreateAPIKey("alice", "e2e")
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := st.CreateAPIKey("bob", "e2e")
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
}
carol, err := st.CreateAPIKey("carol", "e2e")
carol, err := createLegacyAPIKeyFixture(t, st, "carol", "e2e")
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (s *Server) rewindFiles(w http.ResponseWriter, r *http.Request) {
http.Error(w, "messageId required", http.StatusBadRequest)
return
}
if err := s.mgr.ClaimConversation(id); err != nil {
if err := s.mgr.ClaimConversation(id, owner); err != nil {
if errors.Is(err, runmgr.ErrConversationBusy) {
http.Error(w, "conversation busy", http.StatusConflict)
return
+131
View File
@@ -0,0 +1,131 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) {
srv, st := newTestServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
_, otherConvID := setupConversation(t, h)
runID := postMessage(t, h, convID, "整理材料")
waitRunTerminal(t, h, runID, 5*time.Second)
events := []core.StreamEvent{
{RunID: runID, Type: core.EvThinkingDelta, Payload: json.RawMessage(`{"index":0,"text":"先核对日期"}`)},
{RunID: runID, Type: core.EvToolOutput, Payload: json.RawMessage(`{"callId":"call-1","stream":"stdout","text":"完整工具输出"}`)},
}
if _, err := st.AppendEvents(convID, events); err != nil {
t.Fatal(err)
}
// The runner may persist its terminal event after its status becomes terminal.
// Anchor paging to the events this test appended, not an earlier snapshot.
path := "/api/conversations/" + convID + "/runs/" + runID + "/events?after=" + strconv.FormatInt(events[0].Seq-1, 10) + "&limit=1"
code, first := doJSON(t, h, http.MethodGet, path, nil)
if code != http.StatusOK {
t.Fatalf("first page: %d %v", code, first)
}
page := first["events"].([]any)
if len(page) != 1 || page[0].(map[string]any)["type"] != string(core.EvThinkingDelta) {
t.Fatalf("first page: %v", page)
}
seq := int64(page[0].(map[string]any)["seq"].(float64))
code, second := doJSON(t, h, http.MethodGet, "/api/conversations/"+convID+"/runs/"+runID+"/events?after="+strconv.FormatInt(seq, 10)+"&limit=1", nil)
if code != http.StatusOK {
t.Fatalf("second page: %d %v", code, second)
}
page = second["events"].([]any)
if len(page) != 1 || page[0].(map[string]any)["type"] != string(core.EvToolOutput) {
t.Fatalf("second page: %v", page)
}
if payload := page[0].(map[string]any)["payload"].(map[string]any); payload["text"] != "完整工具输出" {
t.Fatalf("payload: %v", payload)
}
parent, err := st.GetRunByID(runID)
if err != nil {
t.Fatal(err)
}
child := core.Run{ID: "run_child_process", ParentRunID: parent.ID, ConversationID: parent.ConversationID,
ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, Status: core.RunCompleted}
if err := st.CreateRun(&child); err != nil {
t.Fatal(err)
}
childEvents := []core.StreamEvent{
{RunID: child.ID, Type: core.EvAgentProgress, Payload: json.RawMessage(`{"agentRun":"run_child_process","note":"正在核对附录"}`)},
{RunID: child.ID, Type: core.EvToolOutput, Payload: json.RawMessage(`{"callId":"hidden","text":"child raw output"}`)},
{RunID: child.ID, Type: core.EvAgentResult, Payload: json.RawMessage(`{"agentRun":"run_child_process","status":"completed","summary":"附录已核对"}`)},
}
if _, err := st.AppendEvents(convID, childEvents); err != nil {
t.Fatal(err)
}
code, childPage := doJSON(t, h, http.MethodGet, "/api/conversations/"+convID+"/runs/"+runID+"/events?after="+strconv.FormatInt(events[1].Seq, 10), nil)
if code != http.StatusOK {
t.Fatalf("child page: %d %v", code, childPage)
}
childRows := childPage["events"].([]any)
if len(childRows) != 2 || childRows[0].(map[string]any)["type"] != string(core.EvAgentProgress) || childRows[1].(map[string]any)["type"] != string(core.EvAgentResult) {
t.Fatalf("child progress/result not retained or raw child output leaked: %v", childRows)
}
code, _ = doJSON(t, h, http.MethodGet, "/api/conversations/"+otherConvID+"/runs/"+runID+"/events", nil)
if code != http.StatusNotFound {
t.Fatalf("mismatched conversation exposed run: %d", code)
}
}
func TestRunProcessEventsRejectsDisabledImplicitLocal(t *testing.T) {
first, second := twinRecoveryStores(t)
t.Logf("process-event replay dialect=%s independent stores", first.Dialect())
if err := first.CreateAccount("admin", "", "", true); err != nil {
t.Fatal(err)
}
if err := first.EnsureUser(store.OwnerDefault, ""); err != nil {
t.Fatal(err)
}
project, err := first.CreateProject(store.OwnerDefault, "Project")
if err != nil {
t.Fatal(err)
}
conversation, err := first.CreateConversation(store.OwnerDefault, project.ID, "Process", "")
if err != nil {
t.Fatal(err)
}
run := core.Run{ID: "run_local_process", ConversationID: conversation.ID, ProjectID: project.ID,
Owner: store.OwnerDefault, Kind: core.RunConversation, Status: core.RunCompleted}
if err := first.CreateRun(&run); err != nil {
t.Fatal(err)
}
if _, err := first.AppendEvents(conversation.ID, []core.StreamEvent{{RunID: run.ID, Type: core.EvThinkingDelta,
Payload: json.RawMessage(`{"index":0,"text":"private process"}`)}}); err != nil {
t.Fatal(err)
}
srv := newAuthTestServerOnStore(t, second, t.TempDir())
h := srv.Handler()
path := "/api/conversations/" + conversation.ID + "/runs/" + run.ID + "/events"
code, _ := doJSON(t, h, http.MethodGet, path, nil)
if code != http.StatusUnauthorized {
t.Fatalf("auth-on replay without credential: %d", code)
}
srv.Auth.Enabled = false
code, _ = doJSON(t, h, http.MethodGet, path, nil)
if code != http.StatusOK {
t.Fatalf("enabled local account could not read process events: %d", code)
}
account, err := first.GetAccountByOwner(store.OwnerDefault)
if err != nil {
t.Fatal(err)
}
if _, err := first.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
code, _ = doJSON(t, h, http.MethodGet, path, nil)
if code != http.StatusForbidden {
t.Fatalf("disabled local account read process events: %d", code)
}
}
+667 -105
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -16,11 +16,11 @@ import (
func TestSharedProjectListAndConversation(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
alice, err := st.CreateAPIKey("alice", "e2e")
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := st.CreateAPIKey("bob", "e2e")
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
}

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