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.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# IAM U3 A1: account write fence inventory
|
||||
|
||||
Status: stage 4–5 implementation inventory with **one open delegated-projection blocker**; stage 6 is not ready. 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 amendment `docs/proposals/2026-10-14-iam-u3-stage6-review-amendment.md` at `fb3a8214df963697ebd745ad11eb852ed9444fe3`. The pending additional proposal is `docs/proposals/2026-10-14-iam-u3-delegated-projection-actor-amendment.md` in the Documents task branch.
|
||||
|
||||
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's RDM callback has a separate last actor check but the delegated rename gap below remains. 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.
|
||||
|
||||
**Open H05/V17–V19 blocker:** delegated RDM rename reconstructs a creator-scoped Host after authorizing the real operator. The current Backend `RenameOwnedProject` final transaction checks the creator as actor and can therefore commit after the delegated operator is disabled. This is a Backend same-database late write, outside the accepted cross-database limitation. The additional Documents proposal specifies a request-bound operator projection capability and a two-account final fence. Do not mark A1 or stage 6 complete until that change is approved, implemented and verified.
|
||||
|
||||
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.
|
||||
@@ -3,7 +3,6 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
@@ -20,18 +19,19 @@ import (
|
||||
)
|
||||
|
||||
type accountDirectorySlot struct {
|
||||
mu sync.RWMutex
|
||||
pluginID string
|
||||
resolver extension.AccountDirectoryScopeResolver
|
||||
secret [32]byte
|
||||
mu sync.RWMutex
|
||||
pluginID string
|
||||
resolver extension.AccountDirectoryScopeResolver
|
||||
secret [32]byte
|
||||
keyVersion int
|
||||
}
|
||||
|
||||
func newAccountDirectorySlot() (*accountDirectorySlot, error) {
|
||||
slot := &accountDirectorySlot{}
|
||||
if _, err := rand.Read(slot.secret[:]); err != nil {
|
||||
func newAccountDirectorySlot(st *store.Store) (*accountDirectorySlot, error) {
|
||||
key, err := st.GetAccountCursorKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slot, nil
|
||||
return &accountDirectorySlot{secret: key.Secret, keyVersion: key.Version}, nil
|
||||
}
|
||||
|
||||
func (s *accountDirectorySlot) register(pluginID string, fn extension.AccountDirectoryScopeResolver) error {
|
||||
@@ -52,22 +52,32 @@ type directoryPolicy struct {
|
||||
match []string
|
||||
candidates map[string]bool
|
||||
owners map[string]bool
|
||||
rows []store.DirectoryAccountRow
|
||||
all bool
|
||||
status string
|
||||
version string
|
||||
}
|
||||
|
||||
type directoryCursor struct {
|
||||
Version string `json:"v"`
|
||||
Owner string `json:"o"`
|
||||
UserID string `json:"u"`
|
||||
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) (directoryPolicy, error) {
|
||||
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
|
||||
@@ -174,9 +184,26 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
|
||||
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
|
||||
p.all = true
|
||||
}
|
||||
revision, err := h.caps.st.GetAccountDirectoryRevision()
|
||||
if err != nil {
|
||||
return p, err
|
||||
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 {
|
||||
@@ -188,16 +215,19 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
|
||||
owners = append(owners, owner)
|
||||
}
|
||||
sort.Strings(owners)
|
||||
fingerprint := struct {
|
||||
Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Public, Staff, Scope string
|
||||
Match, IDs, Owners []string
|
||||
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, Public: revision.Public,
|
||||
Scope: scope.ScopeVersion, Match: p.match, IDs: ids, Owners: owners, All: p.all}
|
||||
if p.allowed["staffNo"] {
|
||||
fingerprint.Staff = revision.StaffNo
|
||||
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
|
||||
@@ -208,11 +238,13 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -232,8 +264,9 @@ func (s *accountDirectorySlot) decodeCursor(token, version string) (directoryCur
|
||||
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.Version != version || c.Owner == "" || c.UserID == "" {
|
||||
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
|
||||
@@ -277,7 +310,8 @@ func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool
|
||||
}
|
||||
|
||||
func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) {
|
||||
p, err := h.directoryPolicy(ctx, q)
|
||||
query := strings.TrimSpace(q.Query)
|
||||
p, err := h.directoryPolicy(ctx, q, query != "")
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryPage{}, err
|
||||
}
|
||||
@@ -292,7 +326,6 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou
|
||||
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
|
||||
}
|
||||
page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version}
|
||||
query := strings.TrimSpace(q.Query)
|
||||
if query == "" {
|
||||
if q.Cursor != "" {
|
||||
return extension.AccountDirectoryPage{}, store.ErrConflict
|
||||
@@ -309,13 +342,9 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou
|
||||
return extension.AccountDirectoryPage{}, err
|
||||
}
|
||||
}
|
||||
rows, err := h.caps.st.ListDirectoryAccountRows()
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryPage{}, err
|
||||
}
|
||||
matched := make([]store.DirectoryAccountRow, 0, q.Limit+1)
|
||||
for _, row := range rows {
|
||||
if !directoryRowAllowed(row, p) || !directoryMatches(row, p.match, q.Mode, query) {
|
||||
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)) {
|
||||
@@ -347,46 +376,32 @@ func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.Acco
|
||||
if userID == "" || q.Cursor != "" {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile
|
||||
}
|
||||
p, err := h.directoryPolicy(ctx, q)
|
||||
p, err := h.directoryPolicy(ctx, q, true)
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryLookup{}, err
|
||||
}
|
||||
if !p.all && len(p.owners) == 0 && !p.candidates[userID] {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
||||
for _, row := range p.rows {
|
||||
if row.UserID == userID {
|
||||
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
|
||||
}
|
||||
}
|
||||
account, err := h.caps.st.GetAccount(userID)
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryLookup{}, err
|
||||
}
|
||||
row := store.DirectoryAccountRow{UserID: account.ID, Owner: account.Owner, Name: account.Profile.Name,
|
||||
StaffNo: account.Profile.StaffNo, Enabled: account.Enabled}
|
||||
if !directoryRowAllowed(row, p) {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
||||
}
|
||||
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)
|
||||
p, err := h.directoryPolicy(ctx, q, true)
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryLookup{}, err
|
||||
}
|
||||
if !p.all && len(p.candidates) == 0 && !p.owners[owner] {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
||||
for _, row := range p.rows {
|
||||
if row.Owner == owner {
|
||||
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
|
||||
}
|
||||
}
|
||||
account, err := h.caps.st.GetAccountByOwner(owner)
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryLookup{}, err
|
||||
}
|
||||
row := store.DirectoryAccountRow{UserID: account.ID, Owner: account.Owner, Name: account.Profile.Name,
|
||||
StaffNo: account.Profile.StaffNo, Enabled: account.Enabled}
|
||||
if !directoryRowAllowed(row, p) {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
||||
}
|
||||
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
|
||||
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
var _ extension.AccountDirectoryProvider = inprocHost{}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -65,11 +66,11 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "同名", StaffNo: "hidden-x"})
|
||||
outsider, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "同名", StaffNo: "hidden-x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slot, err := newAccountDirectorySlot()
|
||||
slot, err := newAccountDirectorySlot(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -91,9 +92,16 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
|
||||
t.Fatalf("first scoped page: %+v %v", page, err)
|
||||
}
|
||||
query.Cursor = page.NextCursor
|
||||
second, err := h.QueryAccountDirectory(context.Background(), query)
|
||||
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: %+v %v", second, err)
|
||||
t.Fatalf("second scoped page on another host: %+v %v", second, err)
|
||||
}
|
||||
query.Cursor = ""
|
||||
query.Query = "hidden-x"
|
||||
@@ -126,10 +134,88 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
|
||||
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{"name": "changed"}); err != nil {
|
||||
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]"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
@@ -57,6 +60,36 @@ func TestInprocHostRechecksEffectiveAccountOnEveryCapability(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -16,6 +16,9 @@ func TestInprocHostListAccounts(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
+37
-5
@@ -527,14 +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)
|
||||
}
|
||||
}()
|
||||
@@ -557,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) {
|
||||
@@ -600,11 +622,11 @@ func Run(opt Options) error {
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
authzSlot := &projectAuthz{}
|
||||
projectSlot := &projectLifecycleSlot{}
|
||||
directorySlot, err := newAccountDirectorySlot()
|
||||
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,
|
||||
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 {
|
||||
@@ -916,6 +938,16 @@ func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) err
|
||||
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
|
||||
@@ -928,7 +960,7 @@ func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error)
|
||||
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 不存在(先 apikey create 创建)", owner)
|
||||
return fmt.Errorf("owner %q 不存在(先建立账号)", owner)
|
||||
}
|
||||
var e error
|
||||
out, e = st.ListAPIKeys(owner)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+36
-37
@@ -21,14 +21,15 @@ import (
|
||||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||||
type hostcaps struct {
|
||||
projects *projectLifecycleSlot
|
||||
directory *accountDirectorySlot
|
||||
accountGuard func(string) error
|
||||
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。
|
||||
@@ -112,7 +113,9 @@ 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 }
|
||||
if err := c.accountGuard(owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
|
||||
if c.projects != nil && projectID != "" {
|
||||
@@ -149,12 +152,22 @@ 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 }
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -296,7 +309,7 @@ func (h inprocHost) KVPut(key, value string) error {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
|
||||
return h.caps.st.PutPluginKVAccountChecked(h.owner, h.pluginID, key, value, h.caps.allowImplicitLocal)
|
||||
}
|
||||
|
||||
func (h inprocHost) KVGet(key string) (string, bool, error) {
|
||||
@@ -310,10 +323,10 @@ func (h inprocHost) Audit(action, target, evidence string) error {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.caps.st.AppendAuditEvent(store.AuditEvent{
|
||||
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) {
|
||||
@@ -365,9 +378,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
|
||||
}
|
||||
}
|
||||
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 replaces refs and their vectors within the publication scope.
|
||||
@@ -380,8 +393,6 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
type publicationRef struct{ projectID, ref string }
|
||||
refs := map[publicationRef]bool{}
|
||||
for i := range docs {
|
||||
docs[i].Owner = core.ID(h.owner)
|
||||
if docs[i].Source == "" {
|
||||
@@ -390,24 +401,12 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD
|
||||
if err := h.knowledgeSource(docs[i].Source); err != nil {
|
||||
return err
|
||||
}
|
||||
if docs[i].Ref != "" {
|
||||
refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true
|
||||
}
|
||||
}
|
||||
scoped := h.caps.projects != nil && h.caps.projects.active()
|
||||
for ref := range refs {
|
||||
var err error
|
||||
if scoped {
|
||||
err = h.caps.st.DeletePluginProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref, h.pluginID)
|
||||
} else {
|
||||
err = h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if embed {
|
||||
return h.caps.pipe.PublishChunksAccountChecked(ctx, core.ID(h.owner), h.pluginID, scoped, docs, h.caps.allowImplicitLocal)
|
||||
}
|
||||
|
||||
return h.KnowledgeWrite(ctx, docs, embed)
|
||||
return h.caps.st.ReplaceKnowledgeAccountChecked(ctx, h.owner, h.pluginID, scoped, h.caps.allowImplicitLocal, docs...)
|
||||
}
|
||||
|
||||
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
|
||||
@@ -419,7 +418,7 @@ func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) er
|
||||
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
|
||||
}
|
||||
|
||||
@@ -428,7 +427,7 @@ func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, erro
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID)
|
||||
t, err := h.caps.st.CreateBackgroundTaskAccountChecked(h.owner, kind, title, conversationID, h.caps.allowImplicitLocal)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -439,7 +438,7 @@ func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText stri
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText)
|
||||
return h.caps.st.UpdateBackgroundTaskAccountChecked(h.owner, taskID, status, progress, errText, h.caps.allowImplicitLocal)
|
||||
}
|
||||
|
||||
// ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。
|
||||
@@ -483,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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -16,6 +16,11 @@ func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) {
|
||||
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}}
|
||||
@@ -76,6 +81,9 @@ func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) {
|
||||
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 {
|
||||
@@ -110,6 +118,9 @@ func TestProjectPublishPreservesForeignPluginAndScope(t *testing.T) {
|
||||
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 {
|
||||
@@ -150,6 +161,9 @@ func TestUnregisteredPublishRetainsLegacyRefScope(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -54,3 +55,44 @@ func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,13 +126,22 @@ func (h inprocHost) EnsureProject(id, name string) (core.Project, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return h.caps.st.EnsureProject(h.owner, id, name)
|
||||
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
|
||||
}
|
||||
return h.caps.st.RenameOwnedProject(h.owner, id, name)
|
||||
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 {
|
||||
|
||||
@@ -55,6 +55,11 @@ func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) {
|
||||
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 {
|
||||
@@ -131,6 +136,9 @@ func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) {
|
||||
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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -17,19 +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
|
||||
projectSharedEmit ProjectSharedEmitter
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
accountCheckedEmit AccountCheckedEmitter
|
||||
projectSharedEmit ProjectSharedEmitter
|
||||
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
|
||||
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
|
||||
MirrorConvCards bool
|
||||
@@ -42,6 +49,10 @@ 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
|
||||
|
||||
@@ -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
@@ -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()}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,宁可拒绝执行)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,6 +118,41 @@ 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 {
|
||||
@@ -132,7 +169,12 @@ func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string,
|
||||
eb.flushing.Lock()
|
||||
defer eb.flushing.Unlock()
|
||||
events := []core.StreamEvent{event}
|
||||
if _, err := eb.store.AppendProjectSharedEvents(owner, producer, channel, events); err != nil {
|
||||
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 {
|
||||
@@ -148,7 +190,13 @@ func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string,
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+266
-56
@@ -200,6 +200,73 @@ func (m *Manager) EmitCardEvent(conversationID, runID string, typ core.StreamEve
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) EmitCardEventAccountChecked(actor, conversationID, runID string, typ core.StreamEventType, payload any) error {
|
||||
return m.events.emitAccountChecked(actor, conversationID, runID, typ, payload, m.accountAllowImplicitLocal)
|
||||
}
|
||||
|
||||
func (m *Manager) appendMessageForEffectiveAccount(actor string, msg *core.Message) error {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.st.AppendMessageWithRequestAccountChecked(actor, m.accountAllowImplicitLocal, msg, "")
|
||||
}
|
||||
return m.st.AppendMessage(msg)
|
||||
}
|
||||
|
||||
func (m *Manager) saveHITLCardForEffectiveAccount(actor string, card *core.HITLCard) error {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.st.SaveHITLCardAccountChecked(actor, card, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.st.SaveHITLCard(actor, card)
|
||||
}
|
||||
|
||||
func (m *Manager) resolveHITLCardForEffectiveAccount(actor, id string, resolution core.HITLResolvedPayload) error {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.st.ResolveHITLCardAccountChecked(actor, id, resolution, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.st.ResolveHITLCard(actor, id, resolution)
|
||||
}
|
||||
|
||||
func (m *Manager) resolveProjectedCardForEffectiveAccount(actor, ref, actionID, answer string) error {
|
||||
if m.cards == nil {
|
||||
return nil
|
||||
}
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.cards.ResolveByRefAccountChecked(actor, ref, actionID, answer, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.cards.ResolveByRef(actor, ref, actionID, answer)
|
||||
}
|
||||
|
||||
func (m *Manager) createCardForEffectiveAccount(actor string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.cards.CreateAccountChecked(actor, card, ref, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.cards.Create(actor, card, ref)
|
||||
}
|
||||
|
||||
func (m *Manager) setCardStatusForEffectiveAccount(actor, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.cards.SetStatusAccountChecked(actor, id, status, tone, note, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.cards.SetStatus(actor, id, status, tone, note)
|
||||
}
|
||||
|
||||
func (m *Manager) updateCardForEffectiveAccount(actor string, card core.ActivityCard) error {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
_, version, err := m.st.GetCardVersion(actor, card.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.cards.UpdateVersionedAccountChecked(actor, card, version, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.cards.Update(actor, card)
|
||||
}
|
||||
|
||||
func (m *Manager) appendCardForEffectiveAccount(actor, id, text string) error {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.cards.AppendAccountChecked(actor, id, text, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.cards.Append(actor, id, text)
|
||||
}
|
||||
|
||||
// SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。
|
||||
func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
|
||||
|
||||
@@ -225,8 +292,10 @@ func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, c
|
||||
// independent of the optional domain project provider.
|
||||
func (m *Manager) SetAccountEffectiveAuthorization(fn func(context.Context, string) (bool, error), allowImplicitLocal ...bool) {
|
||||
m.accountEffectiveAuthorization = fn
|
||||
m.events.accountGuardEnabled = fn != nil
|
||||
if len(allowImplicitLocal) > 0 {
|
||||
m.accountAllowImplicitLocal = allowImplicitLocal[0]
|
||||
m.events.allowImplicitLocal = allowImplicitLocal[0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,11 +563,20 @@ func conversationMode(m *Manager, owner, id string) string {
|
||||
func (c *runCtx) PermissionMode() string { return c.permissionMode }
|
||||
|
||||
func (c *runCtx) ExitPlanMode() error {
|
||||
c.permissionMode = ""
|
||||
if c.mgr == nil || c.mgr.st == nil {
|
||||
c.permissionMode = ""
|
||||
return nil
|
||||
}
|
||||
return c.mgr.st.SetPermissionMode(c.run.Owner, c.run.ConversationID, "default")
|
||||
var err error
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
err = c.mgr.st.SetPermissionModeAccountChecked(c.run.Owner, c.run.ConversationID, "default", c.mgr.accountAllowImplicitLocal)
|
||||
} else {
|
||||
err = c.mgr.st.SetPermissionMode(c.run.Owner, c.run.ConversationID, "default")
|
||||
}
|
||||
if err == nil {
|
||||
c.permissionMode = ""
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) rememberSessionAllow(conv, tool string) {
|
||||
@@ -701,10 +779,12 @@ func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err erro
|
||||
if card.CreatedAt == "" {
|
||||
card.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
if err := c.mgr.st.SaveHITLCard(c.run.Owner, &card); err != nil {
|
||||
if err := c.mgr.saveHITLCardForEffectiveAccount(c.run.Owner, &card); err != nil {
|
||||
return core.HITLResponse{}, err
|
||||
}
|
||||
if err := c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase); err != nil {
|
||||
return core.HITLResponse{}, err
|
||||
}
|
||||
c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase)
|
||||
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvHITLRequest, card); err != nil {
|
||||
return core.HITLResponse{}, err
|
||||
}
|
||||
@@ -724,7 +804,7 @@ func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err erro
|
||||
if card.AllowFreeInput {
|
||||
proj.Body = append(proj.Body, core.UINode{Type: core.UIMarkdown, Markdown: "_可在输入框自由输入回应。_"})
|
||||
}
|
||||
if _, err := c.mgr.cards.Create(c.run.Owner, proj, "hitl:"+card.ID); err != nil {
|
||||
if _, err := c.mgr.createCardForEffectiveAccount(c.run.Owner, proj, "hitl:"+card.ID); err != nil {
|
||||
return core.HITLResponse{}, err
|
||||
}
|
||||
}
|
||||
@@ -736,7 +816,7 @@ func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err erro
|
||||
// 排队,取消语境下重取失败由取消路径收口)。
|
||||
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
|
||||
defer func() {
|
||||
if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); acquireErr != nil {
|
||||
if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner, c.mgr.accountEffectiveAuthorization != nil, c.mgr.accountAllowImplicitLocal); acquireErr != nil {
|
||||
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr)
|
||||
if err == nil {
|
||||
err = acquireErr
|
||||
@@ -797,7 +877,13 @@ func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
|
||||
}
|
||||
prev, _ := c.mgr.st.GetPlan(c.run.ConversationID)
|
||||
p.PlanID = c.run.ConversationID
|
||||
if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
|
||||
var saveErr error
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
saveErr = c.mgr.st.SavePlanAccountChecked(c.run.Owner, c.run.ConversationID, p, c.mgr.accountAllowImplicitLocal)
|
||||
} else {
|
||||
saveErr = c.mgr.st.SavePlan(c.run.ConversationID, p)
|
||||
}
|
||||
if err := saveErr; err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
|
||||
@@ -831,7 +917,13 @@ func (c *runCtx) UpdateTodos(l core.TodoList) error {
|
||||
}
|
||||
prev, _ := c.mgr.st.GetTodos(c.run.ConversationID)
|
||||
l.ConversationID = c.run.ConversationID
|
||||
if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
|
||||
var saveErr error
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
saveErr = c.mgr.st.SaveTodosAccountChecked(c.run.Owner, c.run.ConversationID, l, c.mgr.accountAllowImplicitLocal)
|
||||
} else {
|
||||
saveErr = c.mgr.st.SaveTodos(c.run.ConversationID, l)
|
||||
}
|
||||
if err := saveErr; err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
|
||||
@@ -858,7 +950,7 @@ func (c *runCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
}
|
||||
card.ProjectID = c.run.ProjectID
|
||||
card.SourceRunID = c.run.ID
|
||||
return c.mgr.cards.Create(c.run.Owner, card, "")
|
||||
return c.mgr.createCardForEffectiveAccount(c.run.Owner, card, "")
|
||||
}
|
||||
|
||||
func (c *runCtx) SetCardStatus(id string, status core.CardStatus, tone, note string) error {
|
||||
@@ -868,7 +960,7 @@ func (c *runCtx) SetCardStatus(id string, status core.CardStatus, tone, note str
|
||||
if c.mgr.cards == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := c.mgr.cards.SetStatus(c.run.Owner, id, status, tone, note)
|
||||
_, err := c.mgr.setCardStatusForEffectiveAccount(c.run.Owner, id, status, tone, note)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -913,7 +1005,7 @@ func (c *runCtx) AppendStreamCard(text string) error {
|
||||
if id == "" {
|
||||
return errors.New("card: no active stream card (card_begin first)")
|
||||
}
|
||||
return c.mgr.cards.Append(c.run.Owner, string(id), text)
|
||||
return c.mgr.appendCardForEffectiveAccount(c.run.Owner, string(id), text)
|
||||
}
|
||||
|
||||
// CommitStreamCard:card_commit 工具入口——活跃流式卡置 done、登记锚点回填
|
||||
@@ -929,7 +1021,7 @@ func (c *runCtx) CommitStreamCard() (core.ActivityCard, error) {
|
||||
if id == "" {
|
||||
return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)")
|
||||
}
|
||||
card, err := c.mgr.cards.SetStatus(c.run.Owner, string(id), core.CardDone, "ok", "")
|
||||
card, err := c.mgr.setCardStatusForEffectiveAccount(c.run.Owner, string(id), core.CardDone, "ok", "")
|
||||
if err != nil {
|
||||
return card, err
|
||||
}
|
||||
@@ -970,7 +1062,13 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
|
||||
return core.Artifact{}, err
|
||||
}
|
||||
art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
|
||||
if err := c.mgr.st.CreateArtifact(&art, content); err != nil {
|
||||
var writeErr error
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
writeErr = c.mgr.st.CreateArtifactAccountChecked(c.run.Owner, &art, content, c.mgr.accountAllowImplicitLocal)
|
||||
} else {
|
||||
writeErr = c.mgr.st.CreateArtifact(&art, content)
|
||||
}
|
||||
if err := writeErr; err != nil {
|
||||
return art, err
|
||||
}
|
||||
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
|
||||
@@ -983,7 +1081,21 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
|
||||
// 抽不到则跳过,避免把 ZIP 字节当文本索引。
|
||||
if kind == "document" && len(content) < 200000 {
|
||||
if text := indexTextOf(name, mime, content); text != "" {
|
||||
if c.mgr.indexer != nil {
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
if c.mgr.indexer != nil {
|
||||
checked, ok := c.mgr.indexer.(interface {
|
||||
IndexDocumentAccountChecked(context.Context, core.ID, core.ID, string, string, string, string, bool) error
|
||||
})
|
||||
if !ok {
|
||||
return art, fmt.Errorf("document indexer does not support account fencing")
|
||||
}
|
||||
if err := checked.IndexDocumentAccountChecked(c.ctx, c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text, c.mgr.accountAllowImplicitLocal); err != nil {
|
||||
return art, err
|
||||
}
|
||||
} else if err := c.mgr.st.AddKnowledgeAccountChecked(string(c.run.Owner), c.mgr.accountAllowImplicitLocal, core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text}); err != nil {
|
||||
return art, err
|
||||
}
|
||||
} else if c.mgr.indexer != nil {
|
||||
_ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text)
|
||||
} else {
|
||||
_ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text})
|
||||
@@ -1193,7 +1305,10 @@ func (t *toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.A
|
||||
}
|
||||
return art, err
|
||||
}
|
||||
func (t *toolCtx) Context() context.Context { return t.rc.Context() }
|
||||
func (t *toolCtx) Context() context.Context { return t.rc.Context() }
|
||||
func (t *toolCtx) CheckExternalEffect() error {
|
||||
return t.rc.mgr.authorizeRunWork(t.rc.ctx, t.rc.run.Owner, t.rc.run.ProjectID)
|
||||
}
|
||||
func (t *toolCtx) OwnerID() string { return t.rc.run.Owner }
|
||||
func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot }
|
||||
func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) }
|
||||
@@ -1365,14 +1480,24 @@ func workflowScriptBodies(reg *workflow.Registry, def core.WorkflowDef) map[stri
|
||||
}
|
||||
|
||||
// ClaimConversation 抢占会话槽位。忙则返回 ErrConversationBusy,此时调用方不应写消息。
|
||||
func (m *Manager) ClaimConversation(conversationID string) error {
|
||||
func (m *Manager) ClaimConversation(conversationID string, actor ...string) error {
|
||||
if err := m.claimConversation(conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 执行所有权(U5/KTD4):PG 档在内存槽位之上叠加 DB 租约——跨实例的
|
||||
// 同会话争抢由库裁决;接管递增 epoch 供结算围栏。失败时回退内存槽位。
|
||||
if m.st.Dialect() == "postgres" {
|
||||
epoch, err := m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60)
|
||||
var epoch int64
|
||||
var err error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
if len(actor) == 0 || actor[0] == "" {
|
||||
m.releaseConversation(conversationID)
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
epoch, err = m.st.TryClaimExecutionAccountChecked(actor[0], conversationID, m.InstanceID(), "", 15*60, m.accountAllowImplicitLocal)
|
||||
} else {
|
||||
epoch, err = m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60)
|
||||
}
|
||||
if err != nil {
|
||||
m.releaseConversation(conversationID)
|
||||
if errors.Is(err, store.ErrExecutionBusy) {
|
||||
@@ -1451,7 +1576,13 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
|
||||
ID: store.NewID("run"), ConversationID: conv.ID, ProjectID: conv.ProjectID, Owner: conv.Owner,
|
||||
Kind: core.RunConversation, ProfileID: prof.ID, Status: core.RunPending, Phase: core.PhaseThinking,
|
||||
}
|
||||
if err := m.st.CreateRun(&run); err != nil {
|
||||
var createErr error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
createErr = m.st.CreateRunAccountChecked(&run, m.accountAllowImplicitLocal)
|
||||
} else {
|
||||
createErr = m.st.CreateRun(&run)
|
||||
}
|
||||
if err := createErr; err != nil {
|
||||
m.releaseConversation(conv.ID)
|
||||
return run, err
|
||||
}
|
||||
@@ -1462,8 +1593,25 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
|
||||
m.mu.Unlock()
|
||||
|
||||
runner := selectConversationRunner(m, eff, launch)
|
||||
inputPaths, _ := m.stageInputs(conv, eff, inputs)
|
||||
PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments)
|
||||
var checkInputEffect func(context.Context) error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
checkInputEffect = func(checkCtx context.Context) error {
|
||||
return m.authorizeAccountEffective(checkCtx, conv.Owner)
|
||||
}
|
||||
}
|
||||
inputPaths, inputErr := m.stageInputsChecked(ctx, conv, eff, inputs, checkInputEffect)
|
||||
if inputErr == nil {
|
||||
inputErr = m.persistInputTextsChecked(ctx, conv.Owner, conv.ID, attachments, checkInputEffect)
|
||||
}
|
||||
if inputErr != nil {
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
delete(m.active, run.ID)
|
||||
m.mu.Unlock()
|
||||
m.releaseConversation(conv.ID)
|
||||
m.setRunStatus(&run, core.RunFailed, core.PhaseDone)
|
||||
return run, inputErr
|
||||
}
|
||||
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
|
||||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
|
||||
// 材料整理(附件即保证):无文字/大材料发送瞬间预启动,说明注入第 0 回合;
|
||||
@@ -1474,7 +1622,7 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
|
||||
go func() {
|
||||
// 执行许可(U3,R8):排队有界;获取失败(队列满/取消/存储故障)以
|
||||
// failed 收口——不执行任何模型调用,副作用无从产生。
|
||||
if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner); err != nil {
|
||||
if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||||
log.Printf("[runmgr] admission denied run %s: %v", run.ID, err)
|
||||
m.setRunStatus(&run, core.RunFailed, core.PhaseThinking)
|
||||
if uErr, ok := err.(error); ok {
|
||||
@@ -1521,7 +1669,10 @@ func (m *Manager) execute(rc *runCtx, runner runtime.Runner) {
|
||||
m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking)
|
||||
if err := m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking); err != nil {
|
||||
m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseThinking})
|
||||
|
||||
execErr := func() (err error) {
|
||||
@@ -1587,7 +1738,7 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
|
||||
var msg *core.Message
|
||||
if len(parts) > 0 {
|
||||
msg = &core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts}
|
||||
if group, index, ok := m.st.TakeRegenPending(rc.run.ConversationID); ok {
|
||||
if group, index, ok := m.st.PeekRegenPending(rc.run.ConversationID); ok {
|
||||
msg.VariantGroup = group
|
||||
msg.VariantIndex = index
|
||||
}
|
||||
@@ -1618,7 +1769,7 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
|
||||
for _, id := range cardIDs {
|
||||
if card, err := m.cards.Get(rc.run.Owner, string(id)); err == nil {
|
||||
card.AnchorMessageID = msg.ID
|
||||
_ = m.cards.Update(rc.run.Owner, card)
|
||||
_ = m.updateCardForEffectiveAccount(rc.run.Owner, card)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1635,7 +1786,7 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
|
||||
if status != core.RunCompleted {
|
||||
st = core.CardFailed
|
||||
}
|
||||
if card, err := m.cards.SetStatus(rc.run.Owner, string(streamID), st, "warn", "未正常收尾"); err == nil {
|
||||
if card, err := m.setCardStatusForEffectiveAccount(rc.run.Owner, string(streamID), st, "warn", "未正常收尾"); err == nil {
|
||||
m.mu.Lock()
|
||||
rc.h.submittedCards = append(rc.h.submittedCards, card.ID)
|
||||
m.mu.Unlock()
|
||||
@@ -1686,10 +1837,13 @@ func (m *Manager) failSettle(rc *runCtx, cause error) {
|
||||
}
|
||||
|
||||
// setRunStatus 更新并广播 phase(状态不单独出事件,phase 事件携带)。
|
||||
func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) {
|
||||
func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) error {
|
||||
r.Status = status
|
||||
r.Phase = phase
|
||||
m.st.UpdateRun(r)
|
||||
if m.accountEffectiveAuthorization != nil && status != core.RunFailed && status != core.RunCancelled && status != core.RunInterrupted {
|
||||
return m.st.UpdateRunAccountChecked(string(r.Owner), r, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.st.UpdateRun(r)
|
||||
}
|
||||
|
||||
// emit 经批处理器持久化事件并广播;返回 seq(缓冲中的增量返回 0)。
|
||||
@@ -1843,11 +1997,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
return "", false, err
|
||||
}
|
||||
_ = m.CancelRun(run.ID)
|
||||
m.st.ResolveHITLCard(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
m.resolveHITLCardForEffectiveAccount(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
if m.cards != nil {
|
||||
_ = m.cards.ResolveByRef(owner, "hitl:"+cardID, "", answer)
|
||||
}
|
||||
_ = m.resolveProjectedCardForEffectiveAccount(owner, "hitl:"+cardID, "", answer)
|
||||
_ = pivotReason
|
||||
return trimmed, true, nil
|
||||
}
|
||||
@@ -1856,14 +2008,12 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
return "", false, err
|
||||
}
|
||||
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
|
||||
if err := m.st.ResolveHITLCard(owner, cardID, resolution); err != nil {
|
||||
if err := m.resolveHITLCardForEffectiveAccount(owner, cardID, resolution); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, resolution)
|
||||
// 投影卡同步销项(双现;幂等)。
|
||||
if m.cards != nil {
|
||||
_ = m.cards.ResolveByRef(owner, "hitl:"+cardID, resp.ChoiceID, answer)
|
||||
}
|
||||
_ = m.resolveProjectedCardForEffectiveAccount(owner, "hitl:"+cardID, resp.ChoiceID, answer)
|
||||
|
||||
m.mu.Lock()
|
||||
h, ok := m.active[run.ID]
|
||||
@@ -1879,7 +2029,13 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
// 恢复运行状态。
|
||||
if run.Status == core.RunWaitingHITL {
|
||||
run.Status = core.RunRunning
|
||||
m.st.UpdateRun(&run)
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
if err := m.st.UpdateRunAccountChecked(string(run.Owner), &run, m.accountAllowImplicitLocal); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
} else if err := m.st.UpdateRun(&run); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -1899,11 +2055,11 @@ func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agen
|
||||
// 取消语境下重取失败仅记录,run 由取消路径收口)。
|
||||
m.adm.release(m.st, string(parent.ID))
|
||||
defer func() {
|
||||
if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner); err != nil {
|
||||
if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||||
log.Printf("[runmgr] admission reacquire after child %s: %v", parent.ID, err)
|
||||
}
|
||||
}()
|
||||
if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner); err != nil {
|
||||
if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||||
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
|
||||
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
|
||||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "子代理执行许可排队已满或已取消"})
|
||||
@@ -1936,11 +2092,26 @@ func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o b
|
||||
ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, ProfileID: parent.ProfileID,
|
||||
Status: core.RunPending, Phase: core.PhaseThinking, AgentName: name,
|
||||
}
|
||||
if err := m.st.CreateRun(&child); err != nil {
|
||||
var createErr error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
createErr = m.st.CreateRunAccountChecked(&child, m.accountAllowImplicitLocal)
|
||||
} else {
|
||||
createErr = m.st.CreateRun(&child)
|
||||
}
|
||||
if err := createErr; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 独立转录(N2 WP5):派生请求落盘,续跑的重建凭据。
|
||||
m.writeSubagentRequest(child, agentID, name, task, hint, detach)
|
||||
var checkChildEffect func(context.Context) error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
checkChildEffect = func(checkCtx context.Context) error {
|
||||
return m.authorizeAccountEffective(checkCtx, child.Owner)
|
||||
}
|
||||
}
|
||||
if err := m.writeSubagentRequestChecked(parentCtx, child, agentID, name, task, hint, detach, checkChildEffect); err != nil {
|
||||
m.setRunStatus(&child, core.RunFailed, core.PhaseDone)
|
||||
return nil, nil, err
|
||||
}
|
||||
m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task})
|
||||
|
||||
base := parentCtx
|
||||
@@ -1956,7 +2127,13 @@ func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o b
|
||||
prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID)
|
||||
eff := m.settings.Effective(parent.Owner, parent.ProjectID)
|
||||
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, subModel: o.model, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
|
||||
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
|
||||
if err := m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing); err != nil {
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
delete(m.active, child.ID)
|
||||
m.mu.Unlock()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
runner := m.subRunner
|
||||
if eff.GetBool("runtime.mock", false) || runner == nil {
|
||||
@@ -2022,7 +2199,18 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
|
||||
child.Status = status
|
||||
child.Phase = core.PhaseDone
|
||||
child.Usage = &usage
|
||||
m.st.UpdateRun(&child)
|
||||
if m.accountEffectiveAuthorization != nil && status == core.RunCompleted {
|
||||
if err := m.st.UpdateRunAccountChecked(string(child.Owner), &child, m.accountAllowImplicitLocal); err != nil {
|
||||
accountDenied = err
|
||||
status = core.RunFailed
|
||||
child.Status = status
|
||||
payload.Status = status
|
||||
payload.Summary = err.Error()
|
||||
_ = m.st.UpdateRun(&child)
|
||||
}
|
||||
} else {
|
||||
_ = m.st.UpdateRun(&child)
|
||||
}
|
||||
if emitResult && accountDenied == nil {
|
||||
m.emit(child.ConversationID, child.ID, core.EvAgentResult, payload)
|
||||
}
|
||||
@@ -2034,10 +2222,18 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
|
||||
subParts = nil
|
||||
}
|
||||
// 独立转录落定(含终态 done 标记,供续跑幂等判定)。
|
||||
m.writeSubagentTranscript(child, subParts, string(status))
|
||||
transcriptCheck := func(checkCtx context.Context) error {
|
||||
return m.authorizeAccountEffective(checkCtx, child.Owner)
|
||||
}
|
||||
if m.accountEffectiveAuthorization == nil {
|
||||
transcriptCheck = nil
|
||||
}
|
||||
if err := m.writeSubagentTranscriptChecked(rc.ctx, child, subParts, string(status), transcriptCheck); err != nil {
|
||||
log.Printf("[runmgr] subagent transcript %s: %v", child.ID, err)
|
||||
}
|
||||
fullText := partsText(subParts)
|
||||
if len(subParts) > 0 {
|
||||
_ = m.st.AppendMessage(&core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts})
|
||||
_ = m.appendMessageForEffectiveAccount(child.Owner, &core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts})
|
||||
}
|
||||
if fullText != "" {
|
||||
payload.Text = capSubagentResult(rc, fullText)
|
||||
@@ -2150,7 +2346,7 @@ func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard co
|
||||
defer m.materialKeyRelease(string(parent.ConversationID), o.taskKey)
|
||||
}
|
||||
// 后台子代理同样计入执行预算(U3:子代理预算计入全局)。
|
||||
if err := m.adm.acquire(context.Background(), m.st, rc.run.ID, parent.Owner); err != nil {
|
||||
if err := m.adm.acquire(rc.ctx, m.st, rc.run.ID, parent.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||||
log.Printf("[runmgr] admission denied background %s: %v", rc.run.ID, err)
|
||||
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
|
||||
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
|
||||
@@ -2205,7 +2401,7 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t
|
||||
focus := "正在执行:" + title
|
||||
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
|
||||
body := (core.TaskSummaryView{Focus: focus}).RunningBody(statusLabel, extra)
|
||||
card, err := m.cards.Create(parent.Owner, core.ActivityCard{
|
||||
card, err := m.createCardForEffectiveAccount(parent.Owner, core.ActivityCard{
|
||||
Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info",
|
||||
ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID,
|
||||
Body: body,
|
||||
@@ -2233,7 +2429,7 @@ func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childI
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := m.cards.Update(owner, card); err != nil {
|
||||
if err := m.updateCardForEffectiveAccount(owner, card); err != nil {
|
||||
log.Printf("[runmgr] queued card promote: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2262,10 +2458,10 @@ func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, pay
|
||||
}
|
||||
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
|
||||
card.Body = (core.TaskSummaryView{OutcomeLine: summary}).FinishedBody(statusLabel, extra)
|
||||
if uerr := m.cards.Update(owner, card); uerr != nil {
|
||||
if uerr := m.updateCardForEffectiveAccount(owner, card); uerr != nil {
|
||||
log.Printf("[runmgr] background card update: %v", uerr)
|
||||
}
|
||||
if _, serr := m.cards.SetStatus(owner, card.ID, status, tone, ""); serr != nil {
|
||||
if _, serr := m.setCardStatusForEffectiveAccount(owner, card.ID, status, tone, ""); serr != nil {
|
||||
log.Printf("[runmgr] background card status: %v", serr)
|
||||
}
|
||||
}
|
||||
@@ -2285,14 +2481,22 @@ func (m *Manager) decBackground(convID string) {
|
||||
}
|
||||
|
||||
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
|
||||
// 先落库(重启重投凭据),再进内存热队列;落库失败不丢内存路径。
|
||||
// 先落库(重启重投凭据),再进内存热队列;账号拒绝时不投递迟到结果。
|
||||
if n.id == "" {
|
||||
n.id = store.NewID("ntc")
|
||||
}
|
||||
if err := m.st.AppendNotice(store.Notice{
|
||||
notice := store.Notice{
|
||||
ID: n.id, ConversationID: convID, Owner: n.owner, Name: n.name, Payload: n.payload,
|
||||
}); err != nil {
|
||||
log.Printf("[runmgr] notice persist %s: %v", convID, err)
|
||||
}
|
||||
var persistErr error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
persistErr = m.st.AppendNoticeAccountChecked(notice, m.accountAllowImplicitLocal)
|
||||
} else {
|
||||
persistErr = m.st.AppendNotice(notice)
|
||||
}
|
||||
if persistErr != nil {
|
||||
log.Printf("[runmgr] notice persist %s: %v", convID, persistErr)
|
||||
return
|
||||
}
|
||||
m.noticeMu.Lock()
|
||||
defer m.noticeMu.Unlock()
|
||||
@@ -2445,7 +2649,7 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
|
||||
m.releaseConversation(convID)
|
||||
return err
|
||||
}
|
||||
if err := m.st.AppendMessage(&msg); err != nil {
|
||||
if err := m.appendMessageForEffectiveAccount(owner, &msg); err != nil {
|
||||
m.releaseConversation(convID)
|
||||
return err
|
||||
}
|
||||
@@ -2487,7 +2691,13 @@ func (m *Manager) InterruptRunning() {
|
||||
parts = append(parts, h.assistant...)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.writeSubagentTranscript(run, parts, "interrupted")
|
||||
var check func(context.Context) error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
check = func(ctx context.Context) error { return m.authorizeAccountEffective(ctx, run.Owner) }
|
||||
}
|
||||
if err := m.writeSubagentTranscriptChecked(context.Background(), run, parts, "interrupted", check); err != nil {
|
||||
log.Printf("[runmgr] interrupted transcript %s: %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
m.sealRunInterrupted(id)
|
||||
}
|
||||
@@ -2532,7 +2742,7 @@ func (m *Manager) closePendingHITL(r core.Run) {
|
||||
}
|
||||
for _, c := range cards {
|
||||
res := core.HITLResolvedPayload{CardID: c.ID, Kind: "cancelled"}
|
||||
if err := m.st.ResolveHITLCard(r.Owner, string(c.ID), res); err != nil {
|
||||
if err := m.resolveHITLCardForEffectiveAccount(r.Owner, string(c.ID), res); err != nil {
|
||||
continue
|
||||
}
|
||||
m.emit(r.ConversationID, r.ID, core.EvHITLResolved, res)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,9 +45,12 @@ func TestAdminAccountSearchFiltersAndVersionedCursor(t *testing.T) {
|
||||
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: %d %+v", code, second)
|
||||
t.Fatalf("second search page on another server: %d %+v", code, second)
|
||||
}
|
||||
params.Del("cursor")
|
||||
params.Set("query", "private-needle")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
@@ -17,23 +16,18 @@ import (
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func newAccountSearchSecret() [32]byte {
|
||||
var key [32]byte
|
||||
if _, err := rand.Read(key[:]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
type adminSearchCursor struct {
|
||||
Version string `json:"v"`
|
||||
Owner string `json:"o"`
|
||||
UserID string `json:"u"`
|
||||
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.accountSearchSecret[:])
|
||||
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))
|
||||
}
|
||||
@@ -52,9 +46,10 @@ func (s *Server) parseAdminCursor(encoded, version string) (adminSearchCursor, b
|
||||
if err != nil {
|
||||
return cursor, false
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.accountSearchSecret[:])
|
||||
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.Version != version || cursor.Owner == "" || cursor.UserID == "" {
|
||||
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
|
||||
@@ -83,6 +78,10 @@ func searchAccountField(account store.Account, field string) string {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -496,7 +496,7 @@ func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor, r.PathValue("owner")); err != nil {
|
||||
if err := s.st.RevokeLoginKeyCascadeAdmin(actor, r.PathValue("owner"), r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -524,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")
|
||||
@@ -532,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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -68,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)
|
||||
}
|
||||
@@ -107,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
|
||||
@@ -439,7 +439,7 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
// API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。
|
||||
key, err := st.CreateAPIKey("admin", "cli")
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "admin", "cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -517,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)
|
||||
}
|
||||
@@ -555,11 +555,11 @@ func TestSystemSettingsAdminGate(t *testing.T) {
|
||||
if err := st.CreateAccount("dave", "普通乙", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyBoss, err := st.CreateAPIKey("boss", "cli")
|
||||
keyBoss, err := createLegacyAPIKeyFixture(t, st, "boss", "cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDave, err := st.CreateAPIKey("dave", "cli")
|
||||
keyDave, err := createLegacyAPIKeyFixture(t, st, "dave", "cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+55
-42
@@ -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) {
|
||||
@@ -187,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
|
||||
}
|
||||
@@ -203,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
|
||||
}
|
||||
@@ -224,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
|
||||
}
|
||||
@@ -281,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
agentcommand "agent-command"
|
||||
@@ -59,6 +60,16 @@ func newTestServer(t *testing.T) (*Server, *store.Store) {
|
||||
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)
|
||||
@@ -66,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -299,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
|
||||
|
||||
@@ -29,10 +29,11 @@ func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest
|
||||
func projectEventFixture(t *testing.T) (*Server, string, string, string, *atomic.Bool) {
|
||||
t.Helper()
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
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)
|
||||
|
||||
@@ -45,11 +45,11 @@ func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID
|
||||
if err := s.projectWork(ctx, owner, projectID); err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
return s.st.CreateConversationInProject(owner, projectID, title, profile)
|
||||
return s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, false)
|
||||
}
|
||||
c, err := s.st.CreateConversation(owner, projectID, title, profile)
|
||||
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.CreateConversationInProject(owner, projectID, title, profile)
|
||||
return s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, false)
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *
|
||||
}
|
||||
func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Original")
|
||||
c, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
calls := []string{}
|
||||
@@ -74,9 +74,9 @@ func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
|
||||
func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) }
|
||||
func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
mallory, _ := st.CreateAPIKey("mallory", "test")
|
||||
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
|
||||
@@ -126,7 +126,7 @@ func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
|
||||
}
|
||||
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
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)
|
||||
@@ -140,7 +140,7 @@ func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
|
||||
|
||||
func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
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)
|
||||
@@ -158,7 +158,7 @@ func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) {
|
||||
|
||||
func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
svc := surfacesvc.New(st, nil)
|
||||
@@ -179,7 +179,7 @@ func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) {
|
||||
|
||||
func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
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)
|
||||
@@ -203,8 +203,8 @@ func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
|
||||
|
||||
func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
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 {
|
||||
@@ -243,7 +243,7 @@ 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, _ := st.CreateAPIKey("alice", "test")
|
||||
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 {
|
||||
@@ -286,7 +286,7 @@ 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, _ := st.CreateAPIKey("alice", "test")
|
||||
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)
|
||||
@@ -347,9 +347,9 @@ func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content stri
|
||||
|
||||
func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
mallory, _ := st.CreateAPIKey("mallory", "test")
|
||||
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) {
|
||||
@@ -399,8 +399,8 @@ func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) {
|
||||
|
||||
func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
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
|
||||
@@ -409,6 +409,7 @@ func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
|
||||
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 {
|
||||
@@ -468,9 +469,10 @@ func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
|
||||
|
||||
func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
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)
|
||||
@@ -486,7 +488,7 @@ func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) {
|
||||
|
||||
func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
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) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
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)
|
||||
@@ -39,7 +39,7 @@ func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) {
|
||||
}
|
||||
func TestArtifactListV2FiltersEachResource(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
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"}
|
||||
@@ -62,7 +62,7 @@ func TestArtifactListV2FiltersEachResource(t *testing.T) {
|
||||
|
||||
func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
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)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) {
|
||||
|
||||
func TestKnowledgeV2UsesStoredPublicationSource(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
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)
|
||||
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
func newV2CaptureServer(t *testing.T) (*Server, *store.Store, string, string, string) {
|
||||
t.Helper()
|
||||
srv, st := newAuthTestServer(t)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
-52
@@ -42,7 +42,8 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
accountSearchSecret [32]byte
|
||||
accountSearchKey store.AccountCursorKey
|
||||
accountSearchKeyErr error
|
||||
projectLifecycle *extension.ProjectLifecycle
|
||||
projectHost func(string) extension.Host
|
||||
st *store.Store
|
||||
@@ -161,8 +162,9 @@ func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.
|
||||
if err != nil {
|
||||
panic(err) // embed 布局错误属构建期问题
|
||||
}
|
||||
key, keyErr := st.GetAccountCursorKey()
|
||||
return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub,
|
||||
accountSearchSecret: newAccountSearchSecret()}
|
||||
accountSearchKey: key, accountSearchKeyErr: keyErr}
|
||||
}
|
||||
|
||||
// NLIntentGate 意图闸门端口(intentgate.Gate 实现;避免包依赖方向反转)。
|
||||
@@ -221,7 +223,7 @@ func (s *Server) WireBuiltinCommands() {
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["on"],"properties":{"on":{"type":"boolean"}}}`)},
|
||||
"/mock", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
on, _ := req.Params["on"].(bool)
|
||||
if err := s.set.MergeUser(req.Owner, map[string]any{"runtime": map[string]any{"mock": on}}); err != nil {
|
||||
if err := s.set.MergeUserAccountChecked(req.Owner, map[string]any{"runtime": map[string]any{"mock": on}}, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"runtime.mock": on}, nil
|
||||
@@ -230,7 +232,7 @@ func (s *Server) WireBuiltinCommands() {
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["model"],"properties":{"model":{"type":"string"}}}`)},
|
||||
"/model", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
m, _ := req.Params["model"].(string)
|
||||
if err := s.set.MergeUser(req.Owner, map[string]any{"models": map[string]any{"chat": map[string]any{"model": m}}}); err != nil {
|
||||
if err := s.set.MergeUserAccountChecked(req.Owner, map[string]any{"models": map[string]any{"chat": map[string]any{"model": m}}}, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"model": m}, nil
|
||||
@@ -239,7 +241,7 @@ func (s *Server) WireBuiltinCommands() {
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["profileId"],"properties":{"profileId":{"type":"string"}}}`)},
|
||||
"/profile", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
pid, _ := req.Params["profileId"].(string)
|
||||
if err := s.st.UpdateConversation(req.Owner, req.ConversationID, "", pid); err != nil {
|
||||
if err := s.st.UpdateConversationAccountChecked(req.Owner, req.ConversationID, "", pid, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"profile": pid}, nil
|
||||
@@ -725,7 +727,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeProjectJSON(w, s.publicProjectMetadata(p), err)
|
||||
return
|
||||
}
|
||||
p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name))
|
||||
p, err := s.st.CreateProjectAccountChecked(s.owner(r), strings.TrimSpace(req.Name), s.Auth == nil || !s.Auth.Enabled)
|
||||
writeJSONOr(w, p, err)
|
||||
}
|
||||
|
||||
@@ -766,7 +768,7 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if req.Settings != nil {
|
||||
if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil {
|
||||
if err := s.st.UpdateProjectSettingsAccountChecked(s.owner(r), id, req.Settings, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -792,13 +794,13 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if req.Name != nil {
|
||||
if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil {
|
||||
if err := s.st.RenameProjectAccountChecked(s.owner(r), id, *req.Name, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Settings != nil {
|
||||
if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil {
|
||||
if err := s.st.UpdateProjectSettingsAccountChecked(s.owner(r), id, req.Settings, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -820,7 +822,7 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteProjectAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -925,28 +927,28 @@ func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) {
|
||||
onlyMode := req.PermissionMode != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.SessionNote == nil
|
||||
onlyNote := req.SessionNote != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.PermissionMode == nil
|
||||
if !onlyMode && !onlyNote {
|
||||
if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil {
|
||||
if err := s.st.UpdateConversationAccountChecked(s.owner(r), id, req.Title, req.ProfileID, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.PermissionMode != nil {
|
||||
if err := s.st.SetPermissionMode(s.owner(r), id, *req.PermissionMode); err != nil {
|
||||
if err := s.st.SetPermissionModeAccountChecked(s.owner(r), id, *req.PermissionMode, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.SessionNote != nil {
|
||||
if err := s.st.SetSessionNote(s.owner(r), id, *req.SessionNote); err != nil {
|
||||
if err := s.st.SetSessionNoteAccountChecked(s.owner(r), id, *req.SessionNote, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeOKOr(w, s.st.SetConversationFlags(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate))
|
||||
writeOKOr(w, s.st.SetConversationFlagsAccountChecked(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate, s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) deleteConversation(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.DeleteConversation(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteConversationAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
// ---- 消息发送(含命令路由与附件) ----
|
||||
@@ -1066,14 +1068,14 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "execution queue full(runtime.execQueueMax)", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
if err := s.mgr.ClaimConversation(convID); err != nil {
|
||||
if err := s.mgr.ClaimConversation(convID, owner); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
claimed = true
|
||||
}
|
||||
msg := core.Message{ConversationID: convID, Role: core.RoleUser, Parts: parts}
|
||||
if err := s.st.AppendMessageWithRequest(&msg, req.RequestID); err != nil {
|
||||
if err := s.st.AppendMessageWithRequestAccountChecked(owner, s.Auth == nil || !s.Auth.Enabled, &msg, req.RequestID); err != nil {
|
||||
var dup *store.DuplicateRequestError
|
||||
if errors.As(err, &dup) {
|
||||
if claimed {
|
||||
@@ -1121,7 +1123,11 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeProjectErr(w, startErr)
|
||||
return
|
||||
}
|
||||
_ = s.st.SetMessageRunID(msg.ID, run.ID)
|
||||
if err := s.st.SetMessageRunIDAccountChecked(owner, msg.ID, string(run.ID), s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
_ = s.mgr.CancelRun(string(run.ID))
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
msg.RunID = run.ID
|
||||
writeJSON(w, http.StatusOK, map[string]any{"message": msg, "run": run})
|
||||
}
|
||||
@@ -1282,12 +1288,8 @@ func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) {
|
||||
// conversation_id 必须回填真实值:消费侧按执行权 JOIN 匹配,空串永远
|
||||
// 无法送达(同批实测)。
|
||||
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
|
||||
convID := ""
|
||||
if run, gerr := s.st.GetRun(s.owner(r), runID); gerr == nil {
|
||||
convID = string(run.ConversationID)
|
||||
}
|
||||
if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "cancel", runID, convID, 0,
|
||||
runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}); cerr != nil {
|
||||
if cerr := s.st.EnqueueCommandAccountChecked(s.owner(r), store.NewID("cmd"), "cancel", runID, 0,
|
||||
runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}, s.Auth == nil || !s.Auth.Enabled); cerr != nil {
|
||||
writeStoreErr(w, cerr)
|
||||
return
|
||||
}
|
||||
@@ -1341,8 +1343,8 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
|
||||
if kind == "" {
|
||||
kind = "free"
|
||||
}
|
||||
if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "hitl", string(card.RunID), string(card.ConversationID), 0,
|
||||
runmgr.HITLCommandPayload{Owner: s.owner(r), CardID: string(resp.CardID), Kind: kind, Answer: json.RawMessage(resp.FreeText)}); cerr == nil {
|
||||
if cerr := s.st.EnqueueCommandAccountChecked(s.owner(r), store.NewID("cmd"), "hitl", string(card.RunID), 0,
|
||||
runmgr.HITLCommandPayload{Owner: s.owner(r), CardID: string(resp.CardID), Kind: kind, Answer: json.RawMessage(resp.FreeText)}, s.Auth == nil || !s.Auth.Enabled); cerr == nil {
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"})
|
||||
return
|
||||
}
|
||||
@@ -1382,7 +1384,7 @@ func (s *Server) listProjectCards(w http.ResponseWriter, r *http.Request) {
|
||||
// cardToProject 会话卡留档到项目收件箱(D48):复制为项目级卡(幂等),原卡
|
||||
// dismissed 留痕;回执给前端 upsert 两张卡的终态。
|
||||
func (s *Server) cardToProject(w http.ResponseWriter, r *http.Request) {
|
||||
created, err := s.Cards.ToProject(s.owner(r), r.PathValue("id"))
|
||||
created, err := s.Cards.ToProjectAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -1427,12 +1429,12 @@ func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) {
|
||||
// 命令确认卡(ref=cmd:<id>)走总线回道:confirm=执行挂起命令,其余=取消。
|
||||
if ref, err := s.st.CardRef(owner, r.PathValue("id")); err == nil && strings.HasPrefix(ref, "cmd:") {
|
||||
if card, err := s.Cards.Get(owner, r.PathValue("id")); err == nil && card.Status == core.CardActionRequired {
|
||||
resp := s.Bus.Confirm(owner, card.ID, r.PathValue("actionId"))
|
||||
resp := s.Bus.ConfirmContext(r.Context(), owner, card.ID, r.PathValue("actionId"))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"card": card, "receipt": resp.Receipt, "payload": resp.Payload})
|
||||
return
|
||||
}
|
||||
}
|
||||
card, label, err := s.Cards.Action(owner, r.PathValue("id"), r.PathValue("actionId"), body.FreeText)
|
||||
card, label, err := s.Cards.ActionAccountChecked(owner, r.PathValue("id"), r.PathValue("actionId"), body.FreeText, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -1443,7 +1445,10 @@ func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) {
|
||||
if card.ConversationID != "" {
|
||||
receipt = &core.Message{ID: store.NewID("msg"), ConversationID: card.ConversationID, Role: core.RoleAssistant, CreatedAt: time.Now().UTC()}
|
||||
receipt.Parts = []core.Part{{Type: core.PartText, Text: fmt.Sprintf("✓ 已按「%s」处理「%s」——结论已记录并留痕(card:%s)", label, card.Title, card.ID)}}
|
||||
_ = s.st.AppendMessage(receipt)
|
||||
if err := s.st.AppendMessageWithRequestAccountChecked(owner, s.Auth == nil || !s.Auth.Enabled, receipt, ""); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"card": card, "receipt": receipt})
|
||||
}
|
||||
@@ -1453,12 +1458,12 @@ func (s *Server) cardPin(w http.ResponseWriter, r *http.Request) {
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
card, err := s.Cards.SetPinned(s.owner(r), r.PathValue("id"), body.Pinned)
|
||||
card, err := s.Cards.SetPinnedAccountChecked(s.owner(r), r.PathValue("id"), body.Pinned, s.Auth == nil || !s.Auth.Enabled)
|
||||
writeJSONOr(w, map[string]any{"card": card}, err)
|
||||
}
|
||||
|
||||
func (s *Server) cardDismiss(w http.ResponseWriter, r *http.Request) {
|
||||
card, err := s.Cards.Dismiss(s.owner(r), r.PathValue("id"))
|
||||
card, err := s.Cards.DismissAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)
|
||||
writeJSONOr(w, map[string]any{"card": card}, err)
|
||||
}
|
||||
|
||||
@@ -1504,7 +1509,7 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
mime := header.Header.Get("Content-Type")
|
||||
art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime}
|
||||
if err := s.st.CreateArtifact(&art, content); err != nil {
|
||||
if err := s.st.CreateArtifactAccountChecked(owner, &art, content, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -1519,7 +1524,13 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
// original filename/parser metadata out of shared project cards and events.
|
||||
showIngestCard := s.Cards != nil && s.projectLifecycle == nil
|
||||
if showIngestCard {
|
||||
ingestCard, _ = s.Cards.Create(owner, ingestCard, "")
|
||||
var cardErr error
|
||||
ingestCard, cardErr = s.Cards.CreateAccountChecked(owner, ingestCard, "", s.Auth == nil || !s.Auth.Enabled)
|
||||
if errors.Is(cardErr, store.ErrAccountForbidden) {
|
||||
writeStoreErr(w, cardErr)
|
||||
return
|
||||
}
|
||||
showIngestCard = cardErr == nil
|
||||
}
|
||||
doc := ingest.Parse(art.Name, mime, content)
|
||||
if showIngestCard {
|
||||
@@ -1538,19 +1549,42 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
body = append(body, core.UINode{Type: core.UIMarkdown, Markdown: "_解析不完整不静默:原始文件已永久保留,可取回或重试。_"})
|
||||
}
|
||||
ingestCard.Body = body
|
||||
_ = s.Cards.Update(owner, ingestCard)
|
||||
if _, version, err := s.st.GetCardVersion(owner, ingestCard.ID); err == nil {
|
||||
if err := s.Cards.UpdateVersionedAccountChecked(owner, ingestCard, version, s.Auth == nil || !s.Auth.Enabled); errors.Is(err, store.ErrAccountForbidden) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// 文本可解析的进检索索引(管线分块+全文+可选向量)。
|
||||
// EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。
|
||||
if !s.DisableUploadIndexing {
|
||||
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
|
||||
var indexErr error
|
||||
if s.Indexer != nil {
|
||||
_ = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain)
|
||||
if checked, ok := s.Indexer.(interface {
|
||||
IndexDocumentAccountChecked(context.Context, core.ID, core.ID, string, string, string, string, bool) error
|
||||
}); ok {
|
||||
indexErr = checked.IndexDocumentAccountChecked(r.Context(), core.ID(owner), core.ID(projectID), "upload", art.Name, art.ID, plain, s.Auth == nil || !s.Auth.Enabled)
|
||||
} else if s.Auth != nil && s.Auth.Enabled {
|
||||
indexErr = store.ErrAccountForbidden // Unknown indexer has no final account fence.
|
||||
} else {
|
||||
indexErr = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain)
|
||||
}
|
||||
} else {
|
||||
_ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
|
||||
indexErr = s.st.AddKnowledgeAccountChecked(owner, s.Auth == nil || !s.Auth.Enabled,
|
||||
core.KnowledgeDoc{Owner: core.ID(owner), ProjectID: core.ID(projectID), Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
|
||||
}
|
||||
if errors.Is(indexErr, store.ErrAccountForbidden) {
|
||||
writeStoreErr(w, indexErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.checkActorBeforeExternalEffect(owner); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, core.UploadInfo{ArtifactID: art.ID, Filename: art.Name, MIME: mime, Size: art.Size, Parsed: &doc})
|
||||
}
|
||||
|
||||
@@ -1648,11 +1682,11 @@ func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) putArtifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 20<<20))
|
||||
writeOKOr(w, s.st.UpdateArtifactContent(s.owner(r), r.PathValue("id"), body))
|
||||
writeOKOr(w, s.st.UpdateArtifactContentAccountChecked(s.owner(r), r.PathValue("id"), body, s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) deleteArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.DeleteArtifact(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteArtifactAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
// ---- 画像 ----
|
||||
@@ -1671,15 +1705,19 @@ func (s *Server) saveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if p.ID == "" {
|
||||
p.ID = "prof_" + strconv.FormatInt(time.Now().UnixNano()%100000, 10)
|
||||
}
|
||||
if err := s.prof.Save(s.owner(r), p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if err := s.prof.SaveAccountChecked(s.owner(r), p, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
if errors.Is(err, store.ErrAccountForbidden) {
|
||||
writeStoreErr(w, err)
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.prof.Delete(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.prof.DeleteAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
// ---- 设置 ----
|
||||
@@ -1718,7 +1756,9 @@ func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.writeSettingsMerge(w, func() error { return s.set.MergeUser(s.owner(r), values) })
|
||||
s.writeSettingsMerge(w, func() error {
|
||||
return s.set.MergeUserAccountChecked(s.owner(r), values, s.Auth == nil || !s.Auth.Enabled)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1728,7 +1768,7 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.writeSettingsMerge(w, func() error {
|
||||
return s.set.MergeProject(s.owner(r), r.PathValue("id"), values)
|
||||
return s.set.MergeProjectAccountChecked(s.owner(r), r.PathValue("id"), values, s.Auth == nil || !s.Auth.Enabled)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1754,7 +1794,7 @@ func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) {
|
||||
// (Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏
|
||||
// 嵌入式部署的配置播种,不增加安全边界。
|
||||
func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
if s.Auth == nil {
|
||||
if s.Auth == nil || !s.Auth.Enabled {
|
||||
return s.owner(r), true
|
||||
}
|
||||
return s.requireAdmin(w, r)
|
||||
@@ -1778,7 +1818,8 @@ func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
actor, ok := s.requireSystemAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var values map[string]any
|
||||
@@ -1786,7 +1827,7 @@ func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.set.MergeSystem(values))
|
||||
writeOKOr(w, s.set.MergeSystemAccountChecked(actor, values, s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
// ---- 工具(用户声明式 HTTP 工具注册)----
|
||||
@@ -1822,6 +1863,10 @@ func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
lookup := func(key string) string { return s.set.Effective(s.owner(r), "").GetString(key, "") }
|
||||
if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -1835,6 +1880,10 @@ func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "仅可删除 user 来源工具", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.reg.UnregisterBySource(core.SourceUser, name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": name})
|
||||
}
|
||||
@@ -1904,7 +1953,19 @@ func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) reloadPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
results := s.plugins.LoadDir()
|
||||
actor, ok := s.requireSystemAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.checkActorBeforeExternalEffect(actor); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
results, err := s.plugins.LoadDirAccountChecked(actor, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"reloaded": len(results), "errors": results})
|
||||
}
|
||||
|
||||
@@ -2032,8 +2093,12 @@ func (s *Server) savePromptModule(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "id/layer/body required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.Prompts.Save(s.owner(r), m); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if err := s.Prompts.SaveAccountChecked(s.owner(r), m, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
if errors.Is(err, store.ErrAccountForbidden) {
|
||||
writeStoreErr(w, err)
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
@@ -2044,7 +2109,7 @@ func (s *Server) deletePromptModule(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "prompt library not enabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSONOr(w, map[string]any{"deleted": r.PathValue("id")}, s.Prompts.Delete(s.owner(r), r.PathValue("id")))
|
||||
writeJSONOr(w, map[string]any{"deleted": r.PathValue("id")}, s.Prompts.DeleteAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
func (s *Server) listParsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, ingest.ParsersDescription())
|
||||
@@ -2068,7 +2133,7 @@ func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
docs[i].Source = "manual"
|
||||
}
|
||||
}
|
||||
writeOKOr(w, s.st.AddKnowledge(docs...))
|
||||
writeOKOr(w, s.st.AddKnowledgeForOwnedProjectAccountChecked(owner, r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled, docs...))
|
||||
}
|
||||
|
||||
func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2220,12 +2285,26 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
|
||||
// backup 触发一致性在线备份(嵌入式档:VACUUM INTO;PG 档见运维指南 pg_dump)。
|
||||
func (s *Server) backup(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Auth != nil && s.Auth.Enabled {
|
||||
// A database backup includes other owners' business content. IAM U3
|
||||
// does not grant a system administrator that content authority.
|
||||
http.Error(w, "backup requires local owner maintenance", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
dir := filepath.Join(s.st.DataDir(), "backups")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dest := filepath.Join(dir, "agent-system-"+time.Now().UTC().Format("20060102T150405Z")+".db")
|
||||
if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.st.Backup(dest); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotImplemented)
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
func TestSharedKnowledgeRetrieval(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)
|
||||
}
|
||||
@@ -94,11 +94,11 @@ func TestProjectSSEScopeGate(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
_ = st
|
||||
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)
|
||||
}
|
||||
@@ -147,11 +147,11 @@ func TestProjectSSEScopeGate(t *testing.T) {
|
||||
func TestSharedCitationRefDownloadable(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)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func TestSurfaceDirectAPIGuards(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestSurfaceDirectAPIGuards(t *testing.T) {
|
||||
func TestSurfaceActionEndpoint(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, _ := st.CreateAPIKey("alice", "e2e")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
svc := surfacesvc.New(st, nil)
|
||||
svc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
|
||||
return surfacesvc.ActionOutcome{State: "committed", Receipt: map[string]any{"ok": true}}, nil
|
||||
|
||||
@@ -79,6 +79,24 @@ func (s *Service) snapshotThrottled(owner, scope string) {
|
||||
_, _ = s.st.SaveSnapshotIfChanged(owner, scope, string(raw), "save")
|
||||
}
|
||||
|
||||
// snapshotThrottledAccountChecked keeps the best-effort snapshot behavior of
|
||||
// settings writes while fencing its separate final insert transaction. A
|
||||
// disable between the settings commit and this snapshot can omit the snapshot.
|
||||
func (s *Service) snapshotThrottledAccountChecked(actor, owner, scope string, allowImplicitDefault bool) {
|
||||
if at, ok := s.st.LatestSnapshotAt(owner, scope); ok && time.Since(at) < 10*time.Minute {
|
||||
return
|
||||
}
|
||||
cur, err := s.st.GetSettingsScope(owner, scope)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(cur)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.st.SaveSnapshotIfChangedAccountChecked(actor, owner, scope, string(raw), "save", allowImplicitDefault)
|
||||
}
|
||||
|
||||
func lookupString(m map[string]any, path string) (string, bool) {
|
||||
var cur any = m
|
||||
for _, p := range strings.Split(path, ".") {
|
||||
|
||||
@@ -233,6 +233,39 @@ func (s *Service) MergeSystem(values map[string]any) error {
|
||||
return s.mergeScope(OwnerSystemRow, ScopeSystem, values)
|
||||
}
|
||||
|
||||
// MergeUserAccountChecked and MergeProjectAccountChecked are request-facing
|
||||
// writes. Their final Store transaction locks and checks the actor before the
|
||||
// scope lock and write; startup migration keeps using the legacy methods.
|
||||
func (s *Service) MergeUserAccountChecked(actor string, values map[string]any, allowImplicitDefault bool) error {
|
||||
return s.mergeScopeAccountChecked(actor, actor, ScopeUser, values, allowImplicitDefault)
|
||||
}
|
||||
|
||||
func (s *Service) MergeProjectAccountChecked(actor, projectID string, values map[string]any, allowImplicitDefault bool) error {
|
||||
return s.mergeScopeAccountChecked(actor, actor, ScopeProjectPrefix+projectID, values, allowImplicitDefault)
|
||||
}
|
||||
|
||||
// MergeSystemAccountChecked persists under the synthetic system owner while
|
||||
// checking the real request actor's enabled state and administrator role.
|
||||
func (s *Service) MergeSystemAccountChecked(actor string, values map[string]any, allowImplicitDefault bool) error {
|
||||
return s.mergeScopeAccountChecked(actor, OwnerSystemRow, ScopeSystem, values, allowImplicitDefault)
|
||||
}
|
||||
|
||||
func (s *Service) mergeScopeAccountChecked(actor, owner, scope string, values map[string]any, allowImplicitDefault bool) error {
|
||||
incoming := prepareWrite(values)
|
||||
if scope != ScopeSystem {
|
||||
if err := s.checkManagedWrite(incoming); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.st.UpdateSettingsScopeAccountChecked(actor, owner, scope, allowImplicitDefault, func(cur map[string]any) (map[string]any, error) {
|
||||
return core.MergeSettings(normalizeKeys(cur), incoming), nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.snapshotThrottledAccountChecked(actor, owner, scope, allowImplicitDefault)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeScope 先把库里和本次写入的点路径键展开成嵌套,再深合并。
|
||||
// 租户层(user/project)触碰受管路径时整笔拒绝。掩码占位符视为未修改,不覆盖已存密钥。
|
||||
func (s *Service) mergeScope(owner, scope string, values map[string]any) error {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestGuardedSettingsMergePreservesManagedPathsAndEffectiveState(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "settings.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
svc := NewService(st)
|
||||
for _, a := range []struct {
|
||||
owner string
|
||||
admin bool
|
||||
}{{"admin", true}, {"alice", false}} {
|
||||
if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
project, err := st.CreateProject("alice", "owned")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.MergeUserAccountChecked("alice", map[string]any{"models.chat.apiKey": "forbidden"}, false); err == nil {
|
||||
t.Fatal("guarded user merge accepted managed path")
|
||||
} else {
|
||||
var managed *ErrManagedPath
|
||||
if !errors.As(err, &managed) {
|
||||
t.Fatalf("managed path error lost: %v", err)
|
||||
}
|
||||
}
|
||||
if err := svc.MergeUserAccountChecked("alice", map[string]any{"ui.theme": "light"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.MergeProjectAccountChecked("alice", project.ID, map[string]any{"context.maxHistoryMessages": 12}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.MergeSystemAccountChecked("alice", map[string]any{"models.chat.apiKey": "forbidden"}, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("non-admin wrote system settings: %v", err)
|
||||
}
|
||||
if err := svc.MergeSystemAccountChecked("admin", map[string]any{"models.chat.apiKey": "system-secret"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.Effective("alice", project.ID); got.GetString("models.chat.apiKey", "") != "system-secret" || got.GetString("ui.theme", "") != "light" {
|
||||
t.Fatalf("guarded merges did not produce effective settings: %#v", got)
|
||||
}
|
||||
for _, scope := range []struct{ owner, name string }{{"alice", ScopeUser}, {"alice", ScopeProjectPrefix + project.ID}, {OwnerSystemRow, ScopeSystem}} {
|
||||
var count int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM settings_snapshots WHERE owner=? AND scope=?`, scope.owner, scope.name).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("guarded merge snapshot %s/%s count=%d err=%v", scope.owner, scope.name, count, 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.MergeUserAccountChecked("alice", map[string]any{"ui.theme": "dark"}, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled user merge admitted: %v", err)
|
||||
}
|
||||
if err := svc.MergeProjectAccountChecked("alice", project.ID, map[string]any{"context.maxHistoryMessages": 99}, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled project merge admitted: %v", err)
|
||||
}
|
||||
if got := svc.Effective("alice", project.ID); got.GetString("ui.theme", "") != "light" {
|
||||
t.Fatalf("disabled merge changed effective settings: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedSystemMergeAllowsImplicitLocalAndRejectsExplicitDisable(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "settings.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
svc := NewService(st)
|
||||
if err := svc.MergeSystemAccountChecked(store.OwnerDefault, map[string]any{"runtime.mock": true}, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("implicit local system write admitted without allowance: %v", err)
|
||||
}
|
||||
if err := svc.MergeSystemAccountChecked(store.OwnerDefault, map[string]any{"runtime.mock": true}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("admin", "", "", true); 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)
|
||||
}
|
||||
if err := svc.MergeSystemAccountChecked(store.OwnerDefault, map[string]any{"runtime.mock": false}, true); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled local system write admitted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestZeroTokenBudgetLookupDoesNotWrite(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
status, err := st.AccountTokens("viewer", 0, 100)
|
||||
if err != nil || status.Total != 0 || status.Over {
|
||||
t.Fatalf("empty budget lookup: %+v %v", status, err)
|
||||
}
|
||||
var rows int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM llm_usage_daily WHERE owner='viewer'`).Scan(&rows); err != nil || rows != 0 {
|
||||
t.Fatalf("read-only budget lookup wrote %d rows: %v", rows, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountCursorKeySharedAcrossStoreOpens(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "accounts.db")
|
||||
first, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
second, err := OpenExistingSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Close()
|
||||
key1, err := first.GetAccountCursorKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key2, err := second.GetAccountCursorKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key1.Version != 1 || key1 != key2 || key1.Secret == ([32]byte{}) {
|
||||
t.Fatal("distinct instances did not share a nonzero versioned key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountCursorKeyMigrationAddsToExistingU3Schema(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "accounts.db")
|
||||
st, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("existing", "Existing", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.MigrateDown(33); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
st, err = OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if _, err := st.GetAccountByOwner("existing"); err != nil {
|
||||
t.Fatalf("v34 migration lost existing account: %v", err)
|
||||
}
|
||||
if key, err := st.GetAccountCursorKey(); err != nil || key.Version != 1 {
|
||||
t.Fatalf("v34 migration failed to initialize key: %+v %v", key.Version, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGAccountCursorKeyConcurrentInstances(t *testing.T) {
|
||||
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if baseDSN == "" {
|
||||
t.Skip("isolated PostgreSQL test DSN required")
|
||||
}
|
||||
first := openTestStore(t)
|
||||
if err := first.EnsureSystemIdentity("cursor-key-test", "pg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var schema string
|
||||
if err := first.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := OpenExistingPostgres(withTestSearchPath(baseDSN, schema), filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Close()
|
||||
var keys [2]AccountCursorKey
|
||||
var failures [2]error
|
||||
var wg sync.WaitGroup
|
||||
for i, st := range []*Store{first, second} {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
keys[i], failures[i] = st.GetAccountCursorKey()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if failures[0] != nil || failures[1] != nil || keys[0] != keys[1] || keys[0].Version != 1 || keys[0].Secret == ([32]byte{}) {
|
||||
t.Fatalf("concurrent instances did not converge: %v %v", failures[0], failures[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestAccountCheckedEventAppendStopsAfterDisable(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := st.CreateProject("alice", "fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := st.CreateConversation("alice", string(project.ID), "fixture", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
one := []core.StreamEvent{{ConversationID: string(conversation.ID), Type: core.EvCardCreated, V: core.ProtocolVersion}}
|
||||
if _, err := st.AppendEventsAccountChecked("admin", string(conversation.ID), one, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("administrator bypassed conversation owner: %v", err)
|
||||
}
|
||||
if _, err := st.AppendEventsAccountChecked("alice", string(conversation.ID), one, false); err != nil {
|
||||
t.Fatal(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 := st.AppendEventsAccountChecked("alice", string(conversation.ID), one, false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled account appended event: %v", err)
|
||||
}
|
||||
var count int
|
||||
if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM events WHERE conversation_id=?`), conversation.ID).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("event count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPGSameOwnerBootstrapAndCreateSerialize(t *testing.T) {
|
||||
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if baseDSN == "" {
|
||||
t.Skip("isolated PostgreSQL test DSN required")
|
||||
}
|
||||
first := openTestStore(t)
|
||||
if err := first.EnsureSystemIdentity("same-owner-admission", "pg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var schema string
|
||||
if err := first.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := OpenExistingPostgres(withTestSearchPath(baseDSN, schema), filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Close()
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
go func() {
|
||||
<-start
|
||||
_, err := first.CreateActivatedAccount(AccountWrite{Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, Actor: "fixture", CreatedBy: "fixture"})
|
||||
results <- err
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
_, err := second.BootstrapFirstAdminWrite(AccountWrite{Owner: "same", Name: "Same", Actor: "fixture", CreatedBy: "fixture"})
|
||||
results <- err
|
||||
}()
|
||||
close(start)
|
||||
wins, conflicts := 0, 0
|
||||
for range 2 {
|
||||
select {
|
||||
case err := <-results:
|
||||
if err == nil {
|
||||
wins++
|
||||
} else if errors.Is(err, ErrConflict) {
|
||||
conflicts++
|
||||
} else {
|
||||
t.Fatalf("same-owner admission returned %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("account/bootstrap lock ordering did not make progress")
|
||||
}
|
||||
}
|
||||
if wins != 1 || conflicts != 1 {
|
||||
t.Fatalf("wins=%d conflicts=%d, want 1/1", wins, conflicts)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,11 @@ type AccountDirectoryRevision struct {
|
||||
Private string
|
||||
}
|
||||
|
||||
type AccountCursorKey struct {
|
||||
Version int
|
||||
Secret [32]byte
|
||||
}
|
||||
|
||||
const accountColumns = `id,owner,COALESCE(name,''),phone,email,staff_no,department,notes,
|
||||
profile_version,enabled,state_version,is_admin,password_hash<>'',must_change_password,
|
||||
auth_version,credential_origin,initialization_state,initialization_generation`
|
||||
@@ -331,6 +336,38 @@ func (s *Store) GetAccountDirectoryRevision() (AccountDirectoryRevision, error)
|
||||
return revision, err
|
||||
}
|
||||
|
||||
// GetAccountCursorKey initializes one random signing key in the shared account
|
||||
// database. The compare-and-set update lets concurrent Backend instances
|
||||
// converge on the same key without exposing it through directory responses.
|
||||
func (s *Store) GetAccountCursorKey() (AccountCursorKey, error) {
|
||||
var key AccountCursorKey
|
||||
var generated [32]byte
|
||||
if _, err := rand.Read(generated[:]); err != nil {
|
||||
return key, err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return key, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(s.q(`UPDATE account_directory_revision SET cursor_secret=? WHERE id=1 AND cursor_secret=''`), hex.EncodeToString(generated[:])); err != nil {
|
||||
return key, err
|
||||
}
|
||||
var encoded string
|
||||
if err := tx.QueryRow(`SELECT cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan(&key.Version, &encoded); err != nil {
|
||||
return key, err
|
||||
}
|
||||
decoded, err := hex.DecodeString(encoded)
|
||||
if err != nil || len(decoded) != len(key.Secret) || key.Version < 1 {
|
||||
return AccountCursorKey{}, ErrConflict
|
||||
}
|
||||
copy(key.Secret[:], decoded)
|
||||
if err := tx.Commit(); err != nil {
|
||||
return AccountCursorKey{}, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func newAccountDirectoryRevision() (string, error) {
|
||||
var bytes [16]byte
|
||||
if _, err := rand.Read(bytes[:]); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// SetAccountEnabled changes only the effective account state. The actor and
|
||||
@@ -12,11 +13,25 @@ func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersio
|
||||
if expectedStateVersion < 1 {
|
||||
return Account{}, ErrConflict
|
||||
}
|
||||
pre, err := s.GetAccount(userID)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
owners := []string{actorOwner, pre.Owner}
|
||||
sort.Strings(owners)
|
||||
for i, owner := range owners {
|
||||
if i > 0 && owner == owners[i-1] {
|
||||
continue
|
||||
}
|
||||
if err := s.dialect.lockAccount(tx, owner); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
}
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
|
||||
@@ -223,11 +223,6 @@ func (s *Store) accountTx(w AccountWrite, fn func(*sql.Tx, string) error) error
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if w.Action == "account.bootstrap" || w.Action == "account.create.local_pending" {
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if w.Owner != "" {
|
||||
if err := s.dialect.lockAccount(tx, w.Owner); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// withEffectiveAccountWrite runs a Backend-owned business mutation after the
|
||||
// actor's effective state has been locked and checked in the final transaction.
|
||||
// Disable uses the same account advisory/user-row lock, so it cannot commit
|
||||
// before a write that has passed this check, nor can a later write pass after
|
||||
// disable commits. Callers acquire resource and scope locks only inside fn.
|
||||
func (s *Store) withEffectiveAccountWrite(actor string, allowImplicitDefault bool, fn func(*sql.Tx, Account) error) error {
|
||||
if actor == "" || fn == nil {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
accounts, err := s.lockAccountUsers(tx, actor)
|
||||
var account Account
|
||||
if errors.Is(err, ErrNotFound) && allowImplicitDefault && actor == OwnerDefault {
|
||||
account = Account{Owner: OwnerDefault, Enabled: true}
|
||||
} else if errors.Is(err, ErrNotFound) {
|
||||
return ErrAccountForbidden
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else {
|
||||
account = accounts[actor]
|
||||
}
|
||||
if !account.Enabled {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
if err := fn(tx, account); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -38,6 +38,37 @@ func (s *Store) TryAcquireExecutionSlot(runID, owner string, budget ExecutionBud
|
||||
if err := s.lockAdmission(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.acquireExecutionSlotTx(tx, runID, owner, budget); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TryAcquireExecutionSlotAccountChecked takes the actor account lock before
|
||||
// the global admission lock. The run row, not a caller-supplied owner string,
|
||||
// supplies the authoritative owner for a new or reactivated slot.
|
||||
func (s *Store) TryAcquireExecutionSlotAccountChecked(actor, runID string, budget ExecutionBudget, allowImplicitDefault bool) error {
|
||||
if budget.Off() {
|
||||
return nil // no lease row or other DB mutation
|
||||
}
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
if err := s.lockAdmission(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
var owner string
|
||||
if err := tx.QueryRow(s.q(`SELECT owner FROM runs WHERE id=?`), runID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actor {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.acquireExecutionSlotTx(tx, runID, actor, budget)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) acquireExecutionSlotTx(tx *sql.Tx, runID, owner string, budget ExecutionBudget) error {
|
||||
var global, perUser int
|
||||
if budget.Global > 0 {
|
||||
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM admission_leases WHERE released_at IS NULL AND run_id<>?`), runID).Scan(&global); err != nil {
|
||||
@@ -60,7 +91,7 @@ func (s *Store) TryAcquireExecutionSlot(runID, owner string, budget ExecutionBud
|
||||
runID, owner, nowISO()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseExecutionSlot 释放执行许可(幂等):父 run 等待子代理/HITL 时让出,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/blobstore"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type artifactAccountWriter interface {
|
||||
CreateArtifactAccountChecked(actor string, art *core.Artifact, content []byte, allowImplicitDefault bool) error
|
||||
UpdateArtifactContentAccountChecked(actor, id string, content []byte, allowImplicitDefault bool) error
|
||||
DeleteArtifactAccountChecked(actor, id string, allowImplicitDefault bool) error
|
||||
}
|
||||
|
||||
func artifactWriter(t *testing.T, st *Store) artifactAccountWriter {
|
||||
t.Helper()
|
||||
w, ok := any(st).(artifactAccountWriter)
|
||||
if !ok {
|
||||
t.Fatal("Store is missing the account-checked artifact write API")
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func openArtifactFenceStore(t *testing.T, path string) *Store {
|
||||
t.Helper()
|
||||
st, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func artifactRef(t *testing.T, st *Store, id string) string {
|
||||
t.Helper()
|
||||
var ref string
|
||||
if err := st.DB().QueryRow(`SELECT content_path FROM artifacts WHERE id=?`, id).Scan(&ref); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func TestArtifactAccountFenceCreateRejectsDisabledAndForeignOwner(t *testing.T) {
|
||||
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "account-artifacts.db"))
|
||||
w := artifactWriter(t, st)
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := st.EnsureUser(owner, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
foreign := core.Artifact{Owner: "alice", ProjectID: "project", Name: "foreign.txt", Kind: "data"}
|
||||
if err := w.CreateArtifactAccountChecked("bob", &foreign, []byte("wrong"), false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign-owner create: got %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocked := core.Artifact{Owner: "alice", ProjectID: "project", Name: "blocked.txt", Kind: "data"}
|
||||
if err := w.CreateArtifactAccountChecked("alice", &blocked, []byte("late"), false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled create: got %v, want ErrAccountForbidden", err)
|
||||
}
|
||||
var n int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM artifacts`).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("rejected creates left %d artifact rows: %v", n, err)
|
||||
}
|
||||
files, err := os.ReadDir(st.DataDir())
|
||||
if err != nil || len(files) != 0 {
|
||||
t.Fatalf("rejected creates left staged blobs: %d entries, %v", len(files), err)
|
||||
}
|
||||
}
|
||||
|
||||
type pauseAfterArtifactPut struct {
|
||||
blobstore.Store
|
||||
putDone chan struct{}
|
||||
resume chan struct{}
|
||||
}
|
||||
|
||||
func (p *pauseAfterArtifactPut) Put(id string, content []byte) (string, error) {
|
||||
ref, err := p.Store.Put(id, content)
|
||||
if err == nil {
|
||||
p.putDone <- struct{}{}
|
||||
<-p.resume
|
||||
}
|
||||
return ref, err
|
||||
}
|
||||
|
||||
func TestArtifactAccountFenceUpdatePreservesVisibleBytesWhenDisableWins(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "account-artifacts.db")
|
||||
st := openArtifactFenceStore(t, path)
|
||||
other := openArtifactFenceStore(t, path)
|
||||
w := artifactWriter(t, st)
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
art := core.Artifact{Owner: "alice", ProjectID: "project", Name: "minutes.txt", Kind: "data"}
|
||||
if err := st.CreateArtifact(&art, []byte("original")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldRef := artifactRef(t, st, art.ID)
|
||||
pause := &pauseAfterArtifactPut{Store: st.blobs, putDone: make(chan struct{}), resume: make(chan struct{})}
|
||||
st.blobs = pause
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- w.UpdateArtifactContentAccountChecked("alice", art.ID, []byte("late edit"), false) }()
|
||||
select {
|
||||
case <-pause.putDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
close(pause.resume)
|
||||
t.Fatal("new blob was not staged")
|
||||
}
|
||||
if _, err := other.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
close(pause.resume)
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(pause.resume)
|
||||
if err := <-result; !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("late update: got %v, want ErrAccountForbidden", err)
|
||||
}
|
||||
if err := w.DeleteArtifactAccountChecked("alice", art.ID, false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled delete: got %v, want ErrAccountForbidden", err)
|
||||
}
|
||||
if got, err := st.ArtifactContent("alice", art.ID); err != nil || string(got) != "original" {
|
||||
t.Fatalf("visible content after rejected update: %q, %v", got, err)
|
||||
}
|
||||
if got := artifactRef(t, st, art.ID); got != oldRef {
|
||||
t.Fatalf("rejected update switched content ref: %q -> %q", oldRef, got)
|
||||
}
|
||||
files, err := os.ReadDir(st.DataDir())
|
||||
if err != nil || len(files) != 1 {
|
||||
t.Fatalf("rejected update left staged blob: %d entries, %v", len(files), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactAccountFenceImplicitLocalRejectsRecordedDisable(t *testing.T) {
|
||||
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "account-artifacts.db"))
|
||||
w := artifactWriter(t, st)
|
||||
art := core.Artifact{Owner: OwnerDefault, ProjectID: "project", Name: "local.txt", Kind: "data"}
|
||||
if err := w.CreateArtifactAccountChecked(OwnerDefault, &art, []byte("embedded"), true); err != nil {
|
||||
t.Fatalf("missing implicit local account should be effective: %v", err)
|
||||
}
|
||||
if err := st.EnsureUser(OwnerDefault, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, OwnerDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.UpdateArtifactContentAccountChecked(OwnerDefault, art.ID, []byte("late"), true); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("recorded disabled local account update: got %v, want ErrAccountForbidden", err)
|
||||
}
|
||||
if got, err := st.ArtifactContent(OwnerDefault, art.ID); err != nil || string(got) != "embedded" {
|
||||
t.Fatalf("disabled local account changed visible content: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactAccountFenceUpdateAndDeleteKeepOwnerAndIndexIsolation(t *testing.T) {
|
||||
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "account-artifacts.db"))
|
||||
w := artifactWriter(t, st)
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := st.EnsureUser(owner, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
art := core.Artifact{Owner: "alice", ProjectID: "project", Name: "minutes.txt", Kind: "data"}
|
||||
if err := w.CreateArtifactAccountChecked("alice", &art, []byte("original"), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstRef := artifactRef(t, st, art.ID)
|
||||
if err := w.UpdateArtifactContentAccountChecked("bob", art.ID, []byte("foreign"), false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign update: got %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := w.DeleteArtifactAccountChecked("bob", art.ID, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign delete: got %v, want ErrNotFound", err)
|
||||
}
|
||||
if got, err := st.ArtifactContent("alice", art.ID); err != nil || string(got) != "original" {
|
||||
t.Fatalf("foreign actor changed content: %q, %v", got, err)
|
||||
}
|
||||
if err := w.UpdateArtifactContentAccountChecked("alice", art.ID, []byte("replacement"), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := st.ArtifactContent("alice", art.ID); err != nil || string(got) != "replacement" {
|
||||
t.Fatalf("guarded update content: %q, %v", got, err)
|
||||
}
|
||||
if ref := artifactRef(t, st, art.ID); ref == firstRef {
|
||||
t.Fatal("guarded update overwrote the visible blob ref")
|
||||
}
|
||||
if _, err := os.Stat(firstRef); !os.IsNotExist(err) {
|
||||
t.Fatalf("old blob remains after successful switch: %v", err)
|
||||
}
|
||||
doc := core.KnowledgeDoc{ID: "knw-artifact-fence", Owner: "alice", Ref: art.ID, Source: "artifact", Title: "minutes", Chunk: "indexed content"}
|
||||
if err := st.AddKnowledge(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpsertKnowledgeVectorExt(t.Context(), "alice", string(doc.ID), art.ID, "test", "hash", []float32{1, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.DeleteArtifactAccountChecked("alice", art.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.GetArtifact("alice", art.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("deleted artifact still visible: %v", err)
|
||||
}
|
||||
for _, table := range []string{"knowledge", "knowledge_vectors"} {
|
||||
var n int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE owner=? AND ref=?`, "alice", art.ID).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("%s rows after delete: %d, %v", table, n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,22 @@ func (s *Store) AppendAudit(owner string, env agentcommand.AuditEnvelope) error
|
||||
return err
|
||||
}
|
||||
|
||||
// AppendAuditAccountChecked fences command result envelopes against account
|
||||
// disable. Security-denial audit uses its separate account-state path.
|
||||
func (s *Store) AppendAuditAccountChecked(owner string, env agentcommand.AuditEnvelope, allowImplicitDefault bool) error {
|
||||
raw, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.withEffectiveAccountWrite(owner, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
_, err := tx.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State),
|
||||
env.OperationID, env.ConversationID, owner, string(raw))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ListAudits 审计查询(owner 作用域,时间倒序;conversationID 空 = 该租户全局)。
|
||||
// owner 为空视为无作用域:不返回任何行(防调用方漏传租户时把未归属存量行
|
||||
// 全部泄出——服务端认证层恒注入非空 owner,这里是纵深防御)。
|
||||
|
||||
+36
-6
@@ -81,9 +81,6 @@ func (s *Store) ListUsers() ([]User, error) {
|
||||
|
||||
// CreateAPIKey 创建密钥:明文 `ask_<hex32>` 仅此一次返回。
|
||||
func (s *Store) CreateAPIKey(owner, name string) (string, error) {
|
||||
if err := s.EnsureUser(owner, ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
@@ -92,9 +89,39 @@ func (s *Store) CreateAPIKey(owner, name string) (string, error) {
|
||||
if name == "" {
|
||||
name = "key-" + nowISO()[:10]
|
||||
}
|
||||
result, err := s.db.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked)
|
||||
SELECT ?,owner,?,?,? FROM users WHERE owner=? AND enabled=?`),
|
||||
hashKey(plain), name, nowISO(), 0, owner, true)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockAccount(tx, owner); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The account row lock and key insert share a transaction with disable.
|
||||
// The version predicate also protects the admission decision if the row
|
||||
// changes between the read and the insert on a different dialect.
|
||||
var stateVersion int64
|
||||
var enabled bool
|
||||
var initializationState string
|
||||
query := s.q(`SELECT state_version,enabled,initialization_state FROM users WHERE owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
err = tx.QueryRow(query, owner).Scan(&stateVersion, &enabled, &initializationState)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !enabled || (initializationState != InitializationInitialized && initializationState != InitializationLegacy) {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
result, err := tx.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked)
|
||||
SELECT ?,owner,?,?,? FROM users WHERE owner=? AND state_version=? AND enabled=?
|
||||
AND initialization_state IN (?,?)`),
|
||||
hashKey(plain), name, nowISO(), 0, owner, stateVersion, true,
|
||||
InitializationInitialized, InitializationLegacy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -103,6 +130,9 @@ func (s *Store) CreateAPIKey(owner, name string) (string, error) {
|
||||
} else if n != 1 {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateAPIKeyRequiresExistingEnabledReadyAccount(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
t.Logf("API key account admission dialect=%s", st.Dialect())
|
||||
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreatePendingAccount("admin", "pending", AccountProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureUser("legacy", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureUser("disabled", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled, err := st.GetAccountByOwner("disabled")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", disabled.ID, disabled.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready, err := st.CreatePendingAccount("admin", "ready", AccountProfile{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, err := st.InitializePendingAccount(ready.ID, ready.StateVersion, ready.InitializationGeneration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, _, err := st.RedeemLoginKey(grant)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := HashPassword("ready-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CompleteSetPasswordChecked(session, hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var usersBefore, keysBefore int
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&usersBefore); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM api_keys`).Scan(&keysBefore); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, owner := range []string{"missing", "pending", "disabled"} {
|
||||
if key, err := st.CreateAPIKey(owner, "rejected"); key != "" || !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("%s received an API key: key=%q err=%v", owner, key, err)
|
||||
}
|
||||
var usersAfter, keysAfter int
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&usersAfter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM api_keys`).Scan(&keysAfter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if usersAfter != usersBefore || keysAfter != keysBefore {
|
||||
t.Fatalf("%s rejection changed rows: users %d->%d keys %d->%d", owner, usersBefore, usersAfter, keysBefore, keysAfter)
|
||||
}
|
||||
}
|
||||
for _, owner := range []string{"legacy", "ready"} {
|
||||
key, err := st.CreateAPIKey(owner, owner+"-integration")
|
||||
if err != nil || key == "" {
|
||||
t.Fatalf("%s could not receive an API key: %v", owner, err)
|
||||
}
|
||||
if got, ok := st.AuthOwner(key); !ok || got != owner {
|
||||
t.Fatalf("%s key authenticated as %q, ok=%v", owner, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGCreateAPIKeyAndDisableSerialize(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if st.Dialect() != "postgres" {
|
||||
t.Skip("real PostgreSQL required")
|
||||
}
|
||||
other := reopenAccountModelStore(t, st)
|
||||
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("owner", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := st.GetAccountByOwner("owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release := holdPGAccountWrite(t, st, "api_keys", "INSERT", "api_key")
|
||||
type keyResult struct {
|
||||
key string
|
||||
err error
|
||||
}
|
||||
keyDone := make(chan keyResult, 1)
|
||||
go func() {
|
||||
key, err := st.CreateAPIKey("owner", "racing")
|
||||
keyDone <- keyResult{key, err}
|
||||
}()
|
||||
waitForPGAccountWrite(t, st, "api_key")
|
||||
disableDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := other.SetAccountEnabled("admin", owner.ID, owner.StateVersion, false)
|
||||
disableDone <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-disableDone:
|
||||
t.Fatalf("disable committed ahead of the in-flight key issuance: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
issued := awaitPGAccountResult(t, keyDone)
|
||||
if issued.err != nil || issued.key == "" {
|
||||
t.Fatalf("in-flight issuance failed: %v", issued.err)
|
||||
}
|
||||
if err := awaitPGAccountResult(t, disableDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := st.AuthOwner(issued.key); ok {
|
||||
t.Fatal("key issued before disable survived the disabling transaction")
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,90 @@ func (s *Store) DeleteSecret(owner, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddSecretAccountChecked fences the final secret write against account disable.
|
||||
func (s *Store) AddSecretAccountChecked(actor, service, label, value string, allowImplicitDefault bool) (Secret, error) {
|
||||
service, label, value = strings.TrimSpace(service), strings.TrimSpace(label), strings.TrimSpace(value)
|
||||
if service == "" || value == "" {
|
||||
return Secret{}, fmt.Errorf("store: secret service and value are required")
|
||||
}
|
||||
if label == "" {
|
||||
label = "未命名"
|
||||
}
|
||||
sec := Secret{ID: newID("key"), Service: service, Label: label, Active: true, CreatedAt: nowISO(), Mask: maskSecret(value)}
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), actor, service); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(s.q(`INSERT INTO secret_keys(id,owner,service,label,value,active,created_at) VALUES(?,?,?,?,?,?,?)`),
|
||||
sec.ID, actor, service, label, value, 1, sec.CreatedAt)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return Secret{}, err
|
||||
}
|
||||
return sec, nil
|
||||
}
|
||||
|
||||
func (s *Store) ActivateSecretAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
var service string
|
||||
err := tx.QueryRow(s.q(`SELECT service FROM secret_keys WHERE id=? AND owner=?`), id, actor).Scan(&service)
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), actor, service); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE secret_keys SET active=1 WHERE id=? AND owner=?`), id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) RenameSecretAccountChecked(actor, id, label string, allowImplicitDefault bool) error {
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" {
|
||||
return fmt.Errorf("store: label required")
|
||||
}
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
res, err := tx.Exec(s.q(`UPDATE secret_keys SET label=? WHERE id=? AND owner=?`), label, id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSecretAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
res, err := tx.Exec(s.q(`DELETE FROM secret_keys WHERE id=? AND owner=?`), id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) HasSecret(owner, service string) (bool, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM secret_keys WHERE owner=? AND service=?`), owner, service).Scan(&n)
|
||||
@@ -184,6 +268,36 @@ func (s *Store) SaveSnapshotIfChanged(owner, scope, body, reason string) (bool,
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// SaveSnapshotIfChangedAccountChecked fences the optional post-merge snapshot
|
||||
// in its own final transaction. A disable between the settings commit and this
|
||||
// transaction may suppress the snapshot, but cannot admit a late write.
|
||||
func (s *Store) SaveSnapshotIfChangedAccountChecked(actor, owner, scope, body, reason string, allowImplicitDefault bool) (bool, error) {
|
||||
var saved bool
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, account Account) error {
|
||||
if err := s.dialect.lockScope(tx, owner, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.checkSettingsScopeWriteActor(tx, actor, owner, scope, account, allowImplicitDefault); err != nil {
|
||||
return err
|
||||
}
|
||||
var prev string
|
||||
err := tx.QueryRow(s.q(`SELECT body FROM settings_snapshots WHERE owner=? AND scope=? ORDER BY created_at DESC LIMIT 1`), owner, scope).Scan(&prev)
|
||||
if err == nil && prev == body {
|
||||
return nil
|
||||
}
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(s.q(`INSERT INTO settings_snapshots(id,owner,scope,body,reason,created_at) VALUES(?,?,?,?,?,?)`),
|
||||
newID("snap"), owner, scope, body, reason, nowISO())
|
||||
if err == nil {
|
||||
saved = true
|
||||
}
|
||||
return err
|
||||
})
|
||||
return saved && err == nil, err
|
||||
}
|
||||
|
||||
// LatestSnapshotAge 返回最近一份快照的时间。没有快照时 ok 为 false。
|
||||
func (s *Store) LatestSnapshotAt(owner, scope string) (time.Time, bool) {
|
||||
var raw string
|
||||
@@ -280,6 +394,13 @@ func (s *Store) TakeRegenPending(conversationID string) (group string, index int
|
||||
return group, index, true
|
||||
}
|
||||
|
||||
// PeekRegenPending leaves the marker in place until settlement commits its
|
||||
// assistant message, avoiding a disable/fence between consumption and settle.
|
||||
func (s *Store) PeekRegenPending(conversationID string) (group string, index int, ok bool) {
|
||||
err := s.db.QueryRow(s.q(`SELECT variant_group, variant_index FROM regen_pending WHERE conversation_id=?`), conversationID).Scan(&group, &index)
|
||||
return group, index, err == nil
|
||||
}
|
||||
|
||||
func (s *Store) ListContextEntries(owner, projectID, conversationID string) ([]ContextEntry, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled
|
||||
FROM context_entries WHERE owner=? ORDER BY sort_order, created_at`), owner)
|
||||
@@ -378,6 +499,72 @@ func (s *Store) DeleteContextEntry(owner, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveContextEntryAccountChecked(actor string, e ContextEntry, allowImplicitDefault bool) (ContextEntry, error) {
|
||||
if strings.TrimSpace(e.Content) == "" {
|
||||
return ContextEntry{}, fmt.Errorf("store: entry content required")
|
||||
}
|
||||
if e.Logic != "and" && e.Logic != "or" && e.Logic != "not" {
|
||||
e.Logic = "and"
|
||||
}
|
||||
if e.ScanDepth <= 0 {
|
||||
e.ScanDepth = 4
|
||||
}
|
||||
if e.BudgetChars <= 0 {
|
||||
e.BudgetChars = 2000
|
||||
}
|
||||
raw, _ := json.Marshal(e.Keys)
|
||||
constant, enabled := 0, 0
|
||||
if e.Constant {
|
||||
constant = 1
|
||||
}
|
||||
if e.Enabled || e.ID == "" {
|
||||
enabled = 1
|
||||
e.Enabled = true
|
||||
}
|
||||
creating := e.ID == ""
|
||||
if creating {
|
||||
e.ID = newID("ctx")
|
||||
}
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
if creating {
|
||||
_, err := tx.Exec(s.q(`INSERT INTO context_entries(id,owner,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`),
|
||||
e.ID, actor, e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, nowISO())
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE context_entries SET project_id=?, conversation_id=?, keys_json=?, secondary=?, logic=?, content=?, constant=?, scan_depth=?, sort_order=?, budget_chars=?, enabled=? WHERE id=? AND owner=?`),
|
||||
e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, e.ID, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ContextEntry{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteContextEntryAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
res, err := tx.Exec(s.q(`DELETE FROM context_entries WHERE id=? AND owner=?`), id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ListUserCommands(owner string) ([]UserCommand, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,name,title,template,created_at FROM user_commands WHERE owner=? ORDER BY name`), owner)
|
||||
if err != nil {
|
||||
@@ -436,6 +623,85 @@ func (s *Store) DeleteUserCommand(owner, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveUserCommandAccountChecked(actor string, c UserCommand, allowImplicitDefault bool) (UserCommand, error) {
|
||||
c.Name = strings.TrimSpace(c.Name)
|
||||
if !strings.HasPrefix(c.Name, "/") {
|
||||
c.Name = "/" + c.Name
|
||||
}
|
||||
if c.Title == "" {
|
||||
c.Title = c.Name
|
||||
}
|
||||
if strings.TrimSpace(c.Template) == "" {
|
||||
return UserCommand{}, fmt.Errorf("store: command template required")
|
||||
}
|
||||
creating := c.ID == ""
|
||||
if creating {
|
||||
c.ID = newID("cmd")
|
||||
c.CreatedAt = nowISO()
|
||||
}
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
if creating {
|
||||
_, err := tx.Exec(s.q(`INSERT INTO user_commands(id,owner,name,title,template,created_at) VALUES(?,?,?,?,?,?)`),
|
||||
c.ID, actor, c.Name, c.Title, c.Template, c.CreatedAt)
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE user_commands SET name=?, title=?, template=? WHERE id=? AND owner=?`), c.Name, c.Title, c.Template, c.ID, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return UserCommand{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DeleteUserCommandAccountChecked returns the deleted name from the guarded
|
||||
// transaction so the caller can unregister that exact command from its Bus.
|
||||
func (s *Store) DeleteUserCommandAccountChecked(actor, id string, allowImplicitDefault bool) (UserCommand, error) {
|
||||
var deleted UserCommand
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
query := s.q(`SELECT id,name,title,template,created_at FROM user_commands WHERE id=? AND owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
err := tx.QueryRow(query, id, actor).Scan(&deleted.ID, &deleted.Name, &deleted.Title, &deleted.Template, &deleted.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(s.q(`DELETE FROM user_commands WHERE id=? AND owner=?`), id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return UserCommand{}, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// CheckEffectiveAccountForExternalEffect is a final state check immediately
|
||||
// before a Bus mutation. The Bus is outside the account database transaction:
|
||||
// disable can still commit between this check and the in-memory effect.
|
||||
func (s *Store) CheckEffectiveAccountForExternalEffect(actor string, allowImplicitDefault bool) error {
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(*sql.Tx, Account) error { return nil })
|
||||
}
|
||||
|
||||
// MessageByID 读取一条消息。不存在时返回 ErrNotFound。
|
||||
func (s *Store) MessageByID(id string) (core.Message, error) {
|
||||
var m core.Message
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBorrowAccountCheckedMutationsRespectStateAndOwner(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
for _, a := range []struct {
|
||||
owner string
|
||||
admin bool
|
||||
}{{"admin", true}, {"alice", false}, {"bob", false}} {
|
||||
if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
secret, err := st.AddSecretAccountChecked("alice", "model", "first", "top-secret", false)
|
||||
if err != nil || secret.ID == "" {
|
||||
t.Fatalf("active secret create: %+v %v", secret, err)
|
||||
}
|
||||
second, err := st.AddSecretAccountChecked("alice", "model", "second", "new-secret", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ActivateSecretAccountChecked("alice", secret.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value, ok := st.ActiveSecret("alice", "model"); !ok || value != "top-secret" {
|
||||
t.Fatalf("activation changed wrong service: %q %v", value, ok)
|
||||
}
|
||||
if err := st.RenameSecretAccountChecked("alice", secret.ID, "renamed", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := st.SaveContextEntryAccountChecked("alice", ContextEntry{Content: "before"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry.Content = "after"
|
||||
if _, err := st.SaveContextEntryAccountChecked("alice", entry, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command, err := st.SaveUserCommandAccountChecked("alice", UserCommand{Name: "draft", Template: "before"}, false)
|
||||
if err != nil || command.Name != "/draft" {
|
||||
t.Fatalf("active command create: %+v %v", command, err)
|
||||
}
|
||||
command.Template = "after"
|
||||
if _, err := st.SaveUserCommandAccountChecked("alice", command, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
throwaway, err := st.AddSecretAccountChecked("alice", "throwaway", "", "temporary", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DeleteSecretAccountChecked("alice", throwaway.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
throwawayEntry, err := st.SaveContextEntryAccountChecked("alice", ContextEntry{Content: "temporary"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DeleteContextEntryAccountChecked("alice", throwawayEntry.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
throwawayCommand, err := st.SaveUserCommandAccountChecked("alice", UserCommand{Name: "temporary", Template: "temporary"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := st.DeleteUserCommandAccountChecked("alice", throwawayCommand.ID, false)
|
||||
if err != nil || deleted.Name != "/temporary" {
|
||||
t.Fatalf("guarded command delete lost Bus unregister name: %+v %v", deleted, err)
|
||||
}
|
||||
for _, mutate := range []func() error{
|
||||
func() error { return st.ActivateSecretAccountChecked("bob", secret.ID, false) },
|
||||
func() error { return st.RenameSecretAccountChecked("bob", secret.ID, "foreign", false) },
|
||||
func() error { return st.DeleteSecretAccountChecked("bob", secret.ID, false) },
|
||||
func() error {
|
||||
_, err := st.SaveContextEntryAccountChecked("bob", ContextEntry{ID: entry.ID, Content: "foreign"}, false)
|
||||
return err
|
||||
},
|
||||
func() error { return st.DeleteContextEntryAccountChecked("bob", entry.ID, false) },
|
||||
func() error {
|
||||
_, err := st.SaveUserCommandAccountChecked("bob", UserCommand{ID: command.ID, Name: "draft", Template: "foreign"}, false)
|
||||
return err
|
||||
},
|
||||
func() error { _, err := st.DeleteUserCommandAccountChecked("bob", command.ID, false); return err },
|
||||
} {
|
||||
if err := mutate(); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign owner changed borrowed resource: %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)
|
||||
}
|
||||
for _, mutate := range []func() error{
|
||||
func() error { _, err := st.AddSecretAccountChecked("alice", "late", "late", "late", false); return err },
|
||||
func() error { return st.ActivateSecretAccountChecked("alice", second.ID, false) },
|
||||
func() error { return st.RenameSecretAccountChecked("alice", secret.ID, "late", false) },
|
||||
func() error { return st.DeleteSecretAccountChecked("alice", secret.ID, false) },
|
||||
func() error {
|
||||
_, err := st.SaveContextEntryAccountChecked("alice", ContextEntry{Content: "late"}, false)
|
||||
return err
|
||||
},
|
||||
func() error { return st.DeleteContextEntryAccountChecked("alice", entry.ID, false) },
|
||||
func() error {
|
||||
_, err := st.SaveUserCommandAccountChecked("alice", UserCommand{Name: "late", Template: "late"}, false)
|
||||
return err
|
||||
},
|
||||
func() error { _, err := st.DeleteUserCommandAccountChecked("alice", command.ID, false); return err },
|
||||
func() error { return st.CheckEffectiveAccountForExternalEffect("alice", false) },
|
||||
} {
|
||||
if err := mutate(); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled owner changed borrowed resource: %v", err)
|
||||
}
|
||||
}
|
||||
if value, ok := st.ActiveSecret("alice", "model"); !ok || value != "top-secret" {
|
||||
t.Fatalf("rejected secret mutation changed active value: %q %v", value, ok)
|
||||
}
|
||||
entries, err := st.ListContextEntries("alice", "", "")
|
||||
if err != nil || len(entries) != 1 || entries[0].Content != "after" {
|
||||
t.Fatalf("rejected context mutation changed rows: %+v %v", entries, err)
|
||||
}
|
||||
commands, err := st.ListUserCommands("alice")
|
||||
if err != nil || len(commands) != 1 || commands[0].Template != "after" {
|
||||
t.Fatalf("rejected command mutation changed rows: %+v %v", commands, err)
|
||||
}
|
||||
if err := st.DeleteSecretAccountChecked("bob", second.ID, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign delete of inactive secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBorrowAccountCheckedImplicitDefault(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if _, err := st.AddSecretAccountChecked(OwnerDefault, "service", "", "secret", false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("implicit owner admitted without allowance: %v", err)
|
||||
}
|
||||
secret, err := st.AddSecretAccountChecked(OwnerDefault, "service", "", "secret", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.RenameSecretAccountChecked(OwnerDefault, secret.ID, "local", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SaveContextEntryAccountChecked(OwnerDefault, ContextEntry{Content: "local"}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SaveUserCommandAccountChecked(OwnerDefault, UserCommand{Name: "local", Template: "local"}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGBorrowSecretWriteSerializesWithDisable(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if st.Dialect() != "postgres" {
|
||||
t.Skip("real PostgreSQL required")
|
||||
}
|
||||
other := reopenAccountModelStore(t, st)
|
||||
for _, a := range []struct {
|
||||
owner string
|
||||
admin bool
|
||||
}{{"admin", true}, {"alice", false}} {
|
||||
if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
account, err := st.GetAccountByOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release := holdPGAccountWrite(t, st, "secret_keys", "INSERT", "borrow_secret")
|
||||
writeDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := st.AddSecretAccountChecked("alice", "service", "first", "secret", false)
|
||||
writeDone <- err
|
||||
}()
|
||||
waitForPGAccountWrite(t, st, "borrow_secret")
|
||||
disableDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false)
|
||||
disableDone <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-disableDone:
|
||||
t.Fatalf("disable committed ahead of paused secret insert: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
if err := awaitPGAccountResult(t, writeDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := awaitPGAccountResult(t, disableDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AddSecretAccountChecked("alice", "service", "late", "late", false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("late secret insert passed after disable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type storedCardLink struct {
|
||||
owner, projectID, conversationID, ref, payload string
|
||||
version int
|
||||
}
|
||||
|
||||
func (s *Store) cardLinkForWrite(tx *sql.Tx, id string, lock bool) (storedCardLink, bool, error) {
|
||||
query := s.q(`SELECT owner,project_id,conversation_id,ref,payload,version FROM activity_cards WHERE id=?`)
|
||||
if lock && s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var link storedCardLink
|
||||
err := tx.QueryRow(query, id).Scan(&link.owner, &link.projectID, &link.conversationID, &link.ref, &link.payload, &link.version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return storedCardLink{}, false, nil
|
||||
}
|
||||
return link, err == nil, err
|
||||
}
|
||||
|
||||
// cardResourcesForWrite verifies the stored links after the actor lock and
|
||||
// before taking the card row lock. A member can own a private conversation
|
||||
// inside another creator's shared project; project ownership is a separate
|
||||
// authorization decision made by the calling domain service.
|
||||
func (s *Store) cardResourcesForWrite(tx *sql.Tx, actor, projectID, conversationID string) error {
|
||||
if conversationID != "" && projectID == "" {
|
||||
return ErrNotFound
|
||||
}
|
||||
if projectID != "" {
|
||||
if conversationID == "" {
|
||||
if _, err := s.projectForAccountWrite(tx, actor, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
query := s.q(`SELECT id FROM projects WHERE id=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var found string
|
||||
if err := tx.QueryRow(query, projectID).Scan(&found); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if conversationID != "" {
|
||||
query := s.q(`SELECT owner,project_id FROM conversations WHERE id=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var owner, project string
|
||||
err := tx.QueryRow(query, conversationID).Scan(&owner, &project)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actor || project != projectID {
|
||||
return ErrNotFound
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) saveCardAccountChecked(actor string, card *core.ActivityCard, ref string, expected *int, allowImplicitDefault bool) error {
|
||||
if card == nil || card.ID == "" {
|
||||
return fmt.Errorf("save card: id and card required")
|
||||
}
|
||||
payload, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save card %s: %w", card.ID, err)
|
||||
}
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
// Read immutable links first, then acquire project, conversation and card
|
||||
// row locks in that order. Recheck links after the row lock.
|
||||
first, exists, err := s.cardLinkForWrite(tx, card.ID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists && (first.owner != actor || first.projectID != card.ProjectID || first.conversationID != card.ConversationID) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := s.cardResourcesForWrite(tx, actor, card.ProjectID, card.ConversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
current, stillExists, err := s.cardLinkForWrite(tx, card.ID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stillExists != exists || (exists && (current.owner != actor || current.projectID != card.ProjectID || current.conversationID != card.ConversationID)) {
|
||||
return ErrConflict
|
||||
}
|
||||
if expected != nil && current.version != *expected {
|
||||
return ErrConflict
|
||||
}
|
||||
if !exists {
|
||||
res, err := tx.Exec(s.q(`INSERT INTO activity_cards(id,project_id,conversation_id,ref,owner,payload,status,pinned,created_at,updated_at,version)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,1) ON CONFLICT(id) DO NOTHING`), card.ID, card.ProjectID, card.ConversationID, ref, actor,
|
||||
string(payload), string(card.Status), boolInt(card.Pinned), card.CreatedAt.Format(time.RFC3339Nano), card.UpdatedAt.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ref == "" {
|
||||
ref = current.ref
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE activity_cards SET payload=?,status=?,pinned=?,ref=?,updated_at=?,version=version+1 WHERE id=? AND owner=? AND version=?`),
|
||||
string(payload), string(card.Status), boolInt(card.Pinned), ref, card.UpdatedAt.Format(time.RFC3339Nano), card.ID, actor, current.version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SaveCardAccountChecked is the guarded counterpart of the legacy fixture API.
|
||||
// The actor lock and effective-state check cover the final card mutation.
|
||||
func (s *Store) SaveCardAccountChecked(actor string, card *core.ActivityCard, ref string, allowImplicitDefault bool) error {
|
||||
return s.saveCardAccountChecked(actor, card, ref, nil, allowImplicitDefault)
|
||||
}
|
||||
|
||||
// SaveCardVersionedAccountChecked also enforces the stored card version.
|
||||
func (s *Store) SaveCardVersionedAccountChecked(actor string, card *core.ActivityCard, ref string, expected int, allowImplicitDefault bool) error {
|
||||
return s.saveCardAccountChecked(actor, card, ref, &expected, allowImplicitDefault)
|
||||
}
|
||||
|
||||
// ArchiveCardToProjectAccountChecked commits the project copy and origin
|
||||
// dismissal together. The return flag is false on an idempotent retry.
|
||||
func (s *Store) ArchiveCardToProjectAccountChecked(actor, id string, now time.Time, allowImplicitDefault bool) (core.ActivityCard, core.ActivityCard, bool, error) {
|
||||
var archived, origin core.ActivityCard
|
||||
created := false
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
first, exists, err := s.cardLinkForWrite(tx, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists || first.owner != actor {
|
||||
return ErrNotFound
|
||||
}
|
||||
if first.conversationID == "" {
|
||||
return errors.New("card: already project-level")
|
||||
}
|
||||
if err := s.cardResourcesForWrite(tx, actor, first.projectID, first.conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
current, exists, err := s.cardLinkForWrite(tx, id, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists || current.owner != actor || current.projectID != first.projectID || current.conversationID != first.conversationID {
|
||||
return ErrNotFound
|
||||
}
|
||||
if strings.HasPrefix(current.ref, "hitl:") || strings.HasPrefix(current.ref, "cmd:") {
|
||||
return errors.New("card: flow-bound card cannot be archived to project")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(current.payload), &origin); err != nil {
|
||||
return fmt.Errorf("card %s payload: %w", id, err)
|
||||
}
|
||||
if origin.ID != id || origin.ProjectID != current.projectID || origin.ConversationID != current.conversationID {
|
||||
return ErrNotFound
|
||||
}
|
||||
ref := "to_project:" + id
|
||||
var prior string
|
||||
err = tx.QueryRow(s.q(`SELECT id FROM activity_cards WHERE owner=? AND ref=? ORDER BY updated_at DESC LIMIT 1`), actor, ref).Scan(&prior)
|
||||
if err == nil {
|
||||
link, ok, err := s.cardLinkForWrite(tx, prior, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || link.owner != actor || link.projectID != current.projectID || link.conversationID != "" {
|
||||
return ErrNotFound
|
||||
}
|
||||
return json.Unmarshal([]byte(link.payload), &archived)
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
archived = origin
|
||||
archived.ID = NewID("card")
|
||||
archived.ConversationID = ""
|
||||
archived.AnchorMessageID = core.ID(id)
|
||||
archived.Pinned = false
|
||||
archived.CreatedAt, archived.UpdatedAt = now, now
|
||||
archived.Body = append(append([]core.UINode{}, origin.Body...), core.UINode{Type: core.UIKV, KV: [][2]string{{"留档", "自会话卡 " + id}}})
|
||||
payload, err := json.Marshal(&archived)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO activity_cards(id,project_id,conversation_id,ref,owner,payload,status,pinned,created_at,updated_at,version)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,1)`), archived.ID, archived.ProjectID, "", ref, actor, string(payload), string(archived.Status), boolInt(archived.Pinned), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
origin.Status = core.CardDismissed
|
||||
origin.Tone = "info"
|
||||
origin.UpdatedAt = now
|
||||
payload, err = json.Marshal(&origin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE activity_cards SET payload=?,status=?,updated_at=?,version=version+1 WHERE id=? AND owner=? AND version=?`),
|
||||
string(payload), string(origin.Status), now.Format(time.RFC3339Nano), id, actor, current.version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return core.ActivityCard{}, core.ActivityCard{}, false, err
|
||||
}
|
||||
return archived, origin, created, nil
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func cardAccountFixture(t *testing.T, st *Store) (core.Project, core.Conversation, core.ActivityCard) {
|
||||
t.Helper()
|
||||
for _, a := range []struct {
|
||||
owner string
|
||||
admin bool
|
||||
}{{"admin", true}, {"alice", false}, {"bob", false}} {
|
||||
if err := st.CreateAccount(a.owner, "", "", a.admin); 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)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
card := core.ActivityCard{ID: NewID("card"), ProjectID: project.ID, ConversationID: conv.ID,
|
||||
Title: "Review", Status: core.CardActionRequired, CreatedAt: now, UpdatedAt: now}
|
||||
return project, conv, card
|
||||
}
|
||||
|
||||
func TestCardAccountCheckedWriteUsesPersistedOwnerAndResourceLinks(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
project, conv, card := cardAccountFixture(t, st)
|
||||
if err := st.SaveCardAccountChecked("alice", &card, "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card.Title = "foreign replacement"
|
||||
if err := st.SaveCardVersionedAccountChecked("bob", &card, "", 1, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign actor changed card: %v", err)
|
||||
}
|
||||
card.Title = "linked elsewhere"
|
||||
card.ProjectID = "other-project"
|
||||
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("payload changed immutable project link: %v", err)
|
||||
}
|
||||
card.ProjectID = project.ID
|
||||
card.ConversationID = "other-conversation"
|
||||
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("payload changed immutable conversation link: %v", err)
|
||||
}
|
||||
card.ConversationID = conv.ID
|
||||
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); err != nil {
|
||||
t.Fatalf("owner update: %v", err)
|
||||
}
|
||||
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale version replaced card: %v", err)
|
||||
}
|
||||
foreignProject, err := st.CreateProject("bob", "foreign")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foreign := card
|
||||
foreign.ID = NewID("card")
|
||||
foreign.ProjectID = foreignProject.ID
|
||||
foreign.ConversationID = ""
|
||||
if err := st.SaveCardAccountChecked("alice", &foreign, "", false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign project accepted: %v", err)
|
||||
}
|
||||
foreign.ProjectID = project.ID
|
||||
foreign.ConversationID = conv.ID
|
||||
if err := st.SaveCardAccountChecked("bob", &foreign, "", false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign conversation accepted: %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)
|
||||
}
|
||||
card.Title = "late"
|
||||
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 2, false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled actor changed card: %v", err)
|
||||
}
|
||||
if err := st.SaveCardAccountChecked("alice", &foreign, "", false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled actor created card: %v", err)
|
||||
}
|
||||
got, version, err := st.GetCardVersion("alice", card.ID)
|
||||
if err != nil || got.Title != "linked elsewhere" || version != 2 {
|
||||
t.Fatalf("denied write altered card: %+v version=%d err=%v", got, version, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedProjectMemberCanSaveOwnConversationCard(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
for _, owner := range []string{"creator", "member"} {
|
||||
if err := st.CreateAccount(owner, "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
project, err := st.CreateProject("creator", "shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := st.CreateConversationAccountChecked("member", string(project.ID), "private work", "", false, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
card := core.ActivityCard{ID: NewID("card"), ProjectID: project.ID, ConversationID: conv.ID,
|
||||
Title: "Member decision", Status: core.CardActionRequired, CreatedAt: now, UpdatedAt: now}
|
||||
if err := st.SaveCardAccountChecked("member", &card, "hitl:member", false); err != nil {
|
||||
t.Fatalf("member conversation card rejected: %v", err)
|
||||
}
|
||||
card.Status = core.CardResolved
|
||||
if err := st.SaveCardVersionedAccountChecked("member", &card, "", 1, false); err != nil {
|
||||
t.Fatalf("member card status rejected: %v", err)
|
||||
}
|
||||
if _, err := st.GetCard("member", card.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardArchiveAccountCheckedCopiesAndDismissesAtomically(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
project, _, card := cardAccountFixture(t, st)
|
||||
if err := st.SaveCardAccountChecked("alice", &card, "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isSQLite(st) {
|
||||
if _, err := st.db.Exec(`CREATE TRIGGER block_archive_dismiss BEFORE UPDATE ON activity_cards
|
||||
WHEN NEW.status='dismissed' BEGIN SELECT RAISE(ABORT, 'blocked'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false); err == nil {
|
||||
t.Fatal("archive should fail when origin dismissal fails")
|
||||
}
|
||||
if _, err := st.CardIDByRef("alice", "to_project:"+card.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("failed dismissal committed project copy: %v", err)
|
||||
}
|
||||
if _, err := st.db.Exec(`DROP TRIGGER block_archive_dismiss`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
archived, origin, created, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false)
|
||||
if err != nil || !created || archived.ProjectID != project.ID || archived.ConversationID != "" || archived.AnchorMessageID != core.ID(card.ID) || origin.Status != core.CardDismissed {
|
||||
t.Fatalf("archive did not atomically copy and dismiss: archived=%+v origin=%+v created=%v err=%v", archived, origin, created, err)
|
||||
}
|
||||
again, _, created, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false)
|
||||
if err != nil || created || again.ID != archived.ID {
|
||||
t.Fatalf("retry duplicated archive: %+v created=%v err=%v", again, created, err)
|
||||
}
|
||||
if _, _, _, err := st.ArchiveCardToProjectAccountChecked("bob", card.ID, time.Now().UTC(), false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign actor archived card: %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 := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled actor passed idempotent archive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardAccountCheckedImplicitDefaultRequiresAllowance(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
card := core.ActivityCard{ID: NewID("card"), Title: "local", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||||
if err := st.SaveCardAccountChecked(OwnerDefault, &card, "", false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("implicit owner admitted without allowance: %v", err)
|
||||
}
|
||||
if err := st.SaveCardAccountChecked(OwnerDefault, &card, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureUser(OwnerDefault, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(st.q(`UPDATE users SET enabled=FALSE WHERE owner=?`), OwnerDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveCardVersionedAccountChecked(OwnerDefault, &card, "", 1, true); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("explicitly disabled local actor updated card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGCardAccountFenceSerializesWithDisable(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if st.Dialect() != "postgres" {
|
||||
t.Skip("real PostgreSQL required")
|
||||
}
|
||||
other := reopenAccountModelStore(t, st)
|
||||
_, _, card := cardAccountFixture(t, st)
|
||||
account, err := st.GetAccountByOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release := holdPGAccountWrite(t, st, "activity_cards", "INSERT", "card_create")
|
||||
writeDone := make(chan error, 1)
|
||||
go func() { writeDone <- st.SaveCardAccountChecked("alice", &card, "", false) }()
|
||||
waitForPGAccountWrite(t, st, "card_create")
|
||||
disableDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false)
|
||||
disableDone <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-disableDone:
|
||||
t.Fatalf("disable committed ahead of paused card insert: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
if err := awaitPGAccountResult(t, writeDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := awaitPGAccountResult(t, disableDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card.Title = "late"
|
||||
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("card write crossed disable boundary: %v", err)
|
||||
}
|
||||
got, version, err := st.GetCardVersion("alice", card.ID)
|
||||
if err != nil || got.Title != "Review" || version != 1 {
|
||||
t.Fatalf("late write changed card: %+v version=%d err=%v", got, version, err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -63,6 +64,67 @@ func (s *Store) DeleteProfile(owner, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveProfileAccountChecked writes a user profile only while its actor remains
|
||||
// effective. Profile IDs are global, so an existing foreign or builtin row
|
||||
// cannot be taken over through the upsert path.
|
||||
func (s *Store) SaveProfileAccountChecked(actor string, p core.AgentProfile, allowImplicitDefault bool) error {
|
||||
if p.Builtin || (p.Owner != "" && p.Owner != actor) {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
p.Owner = actor
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
res, err := tx.Exec(s.q(`INSERT INTO profiles(id,owner,builtin,payload) VALUES(?,?,0,?)
|
||||
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload
|
||||
WHERE profiles.owner=excluded.owner AND profiles.builtin=0`),
|
||||
p.ID, actor, marshalJSON(p))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteProfileAccountChecked resolves owner and builtin state from the row
|
||||
// inside the final actor-guarded transaction before deleting it.
|
||||
func (s *Store) DeleteProfileAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
var owner string
|
||||
var builtin int
|
||||
query := s.q(`SELECT owner,builtin FROM profiles WHERE id=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
err := tx.QueryRow(query, id).Scan(&owner, &builtin)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actor {
|
||||
return ErrNotFound
|
||||
}
|
||||
if builtin == 1 {
|
||||
return errors.New("store: builtin profile cannot be deleted")
|
||||
}
|
||||
res, err := tx.Exec(s.q(`DELETE FROM profiles WHERE id=? AND owner=? AND builtin=0`), id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 设置(scope = user | project:<id>;PK=(owner,scope),租户隔离)----
|
||||
|
||||
func (s *Store) GetSettingsScope(owner, scope string) (map[string]any, error) {
|
||||
@@ -149,6 +211,74 @@ func (s *Store) UpdateSettingsScope(owner, scope string, fn func(cur map[string]
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateSettingsScopeAccountChecked resolves the request actor independently
|
||||
// from the persisted settings owner. The actor lock is acquired before the
|
||||
// settings scope and project row locks in the final write transaction.
|
||||
func (s *Store) UpdateSettingsScopeAccountChecked(actor, owner, scope string, allowImplicitDefault bool, fn func(cur map[string]any) (map[string]any, error)) error {
|
||||
if fn == nil {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, account Account) error {
|
||||
if err := s.dialect.lockScope(tx, owner, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.checkSettingsScopeWriteActor(tx, actor, owner, scope, account, allowImplicitDefault); err != nil {
|
||||
return err
|
||||
}
|
||||
var payload string
|
||||
err := tx.QueryRow(s.q(`SELECT "values" FROM settings WHERE owner=? AND scope=?`), owner, scope).Scan(&payload)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
cur := map[string]any{}
|
||||
if payload != "" {
|
||||
cur, err = unmarshalJSON[map[string]any](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cur == nil {
|
||||
cur = map[string]any{}
|
||||
}
|
||||
}
|
||||
next, err := fn(cur)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if next == nil {
|
||||
next = map[string]any{}
|
||||
}
|
||||
if marshalJSON(cur) == marshalJSON(next) {
|
||||
return nil
|
||||
}
|
||||
_, err = tx.Exec(s.q(`INSERT INTO settings(owner,scope,"values") VALUES(?,?,?)
|
||||
ON CONFLICT(owner,scope) DO UPDATE SET "values"=excluded."values"`), owner, scope, marshalJSON(next))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// checkSettingsScopeWriteActor keeps settings ownership and administrator
|
||||
// policy separate from the actor's enabled-state admission.
|
||||
func (s *Store) checkSettingsScopeWriteActor(tx *sql.Tx, actor, owner, scope string, account Account, allowImplicitDefault bool) error {
|
||||
switch {
|
||||
case scope == "system" && owner == "":
|
||||
if account.IsAdmin || (allowImplicitDefault && actor == OwnerDefault) {
|
||||
return nil
|
||||
}
|
||||
return ErrAccountForbidden
|
||||
case scope == "user" && owner == actor:
|
||||
return nil
|
||||
case strings.HasPrefix(scope, "project:") && owner == actor:
|
||||
projectID := strings.TrimPrefix(scope, "project:")
|
||||
if projectID == "" {
|
||||
return ErrNotFound
|
||||
}
|
||||
_, err := s.projectForAccountWrite(tx, actor, projectID)
|
||||
return err
|
||||
default:
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
}
|
||||
|
||||
// MergeSettingsScope 读改写走事务 + scope 锁:并发合并不再丢失更新。
|
||||
func (s *Store) MergeSettingsScope(owner, scope string, values map[string]any) error {
|
||||
return s.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
|
||||
|
||||
+292
-17
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
@@ -34,27 +35,124 @@ func (s *Store) CreateArtifact(a *core.Artifact, content []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateArtifactAccountChecked stages content under a fresh, unreferenced key.
|
||||
// The artifact only becomes visible when its row commits with the actor's
|
||||
// effective-account check. A rejected transaction cannot expose staged bytes.
|
||||
func (s *Store) CreateArtifactAccountChecked(actor string, a *core.Artifact, content []byte, allowImplicitDefault bool) error {
|
||||
if a == nil || a.Owner != actor {
|
||||
return ErrNotFound
|
||||
}
|
||||
if actor == "" {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
if a.ID == "" {
|
||||
a.ID = newID("art")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
a.CreatedAt, a.UpdatedAt = now, now
|
||||
a.Size = int64(len(content))
|
||||
ref, err := s.blobs.Put(newID("blob"), content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var callbackReached bool
|
||||
var callbackErr error
|
||||
err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
callbackReached = true
|
||||
_, callbackErr = tx.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
|
||||
a.ID, a.ProjectID, actor, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, ref, nowISO(), nowISO())
|
||||
return callbackErr
|
||||
})
|
||||
if err != nil && (!callbackReached || callbackErr != nil) {
|
||||
_ = s.blobs.Delete(ref)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateArtifactContent(owner, id string, content []byte) error {
|
||||
var path string
|
||||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
// Legacy callers retain the old signature, but share immutable replacement:
|
||||
// account-checked creations may already point at a key other than the ID.
|
||||
ref, err := s.blobs.Put(newID("blob"), content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
_ = s.blobs.Delete(ref)
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
oldRef, err := s.switchArtifactContentTx(tx, owner, id, ref, len(content))
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
_ = s.blobs.Delete(ref)
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
// The commit may have reached the DB. Preserve the staged object.
|
||||
return err
|
||||
}
|
||||
// The DB pointer is authoritative. Blob deletion is external and may fail
|
||||
// after a successful commit; callers can retry cleanup separately.
|
||||
if oldRef == "" {
|
||||
return nil
|
||||
}
|
||||
return s.blobs.Delete(oldRef)
|
||||
}
|
||||
|
||||
// UpdateArtifactContentAccountChecked swaps only the visible DB reference in
|
||||
// the account-guarded transaction. Never overwrite the old referenced object.
|
||||
func (s *Store) UpdateArtifactContentAccountChecked(actor, id string, content []byte, allowImplicitDefault bool) error {
|
||||
if actor == "" {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
ref, err := s.blobs.Put(newID("blob"), content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var oldRef string
|
||||
var callbackReached bool
|
||||
var callbackErr error
|
||||
err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
callbackReached = true
|
||||
oldRef, callbackErr = s.switchArtifactContentTx(tx, actor, id, ref, len(content))
|
||||
return callbackErr
|
||||
})
|
||||
if err != nil && (!callbackReached || callbackErr != nil) {
|
||||
_ = s.blobs.Delete(ref)
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 同 ID 原子覆写(临时文件+rename);行内引用不变,避免行/对象漂移。
|
||||
if _, err := s.blobs.Put(id, content); err != nil {
|
||||
return err
|
||||
// The old object can be removed only after the new reference commits.
|
||||
if oldRef == "" {
|
||||
return nil
|
||||
}
|
||||
res, err := s.db.Exec(s.q(`UPDATE artifacts SET size=?, updated_at=? WHERE id=? AND owner=?`), len(content), nowISO(), id, owner)
|
||||
return s.blobs.Delete(oldRef)
|
||||
}
|
||||
|
||||
func (s *Store) switchArtifactContentTx(tx *sql.Tx, owner, id, newRef string, size int) (string, error) {
|
||||
query := s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var oldRef string
|
||||
if err := tx.QueryRow(query, id, owner).Scan(&oldRef); errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE artifacts SET content_path=?, size=?, updated_at=? WHERE id=? AND owner=?`), newRef, size, nowISO(), id, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return "", err
|
||||
} else if n != 1 {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return nil
|
||||
return oldRef, nil
|
||||
}
|
||||
|
||||
const artifactColumns = `id,project_id,owner,name,kind,mime,size,source_run,created_at,updated_at`
|
||||
@@ -175,6 +273,44 @@ func (s *Store) DeleteArtifact(owner, id string) error {
|
||||
return s.blobs.Delete(path)
|
||||
}
|
||||
|
||||
// DeleteArtifactAccountChecked removes Backend-owned rows in one guarded
|
||||
// transaction. The blob is unlinked only after commit; an unlink failure may
|
||||
// leave an orphan, but cannot restore a deleted DB row or authorize a late write.
|
||||
func (s *Store) DeleteArtifactAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||||
var ref string
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
query := s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
if err := tx.QueryRow(query, id, actor).Scan(&ref); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM knowledge_vectors WHERE ref=? AND owner=?`), id, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM knowledge WHERE ref=? AND owner=?`), id, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.blobs.Delete(ref)
|
||||
}
|
||||
|
||||
// ---- HITL 卡片(resolve 原子化:条件更新消灭先查后改窗口)----
|
||||
|
||||
func (s *Store) SaveHITLCard(owner string, c *core.HITLCard) error {
|
||||
@@ -292,6 +428,79 @@ func (s *Store) AddKnowledge(docs ...core.KnowledgeDoc) error {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.addKnowledgeTx(tx, docs); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// AddKnowledgeAccountChecked keeps the whole batch behind one final account
|
||||
// check and transaction. A caller cannot publish rows under another owner.
|
||||
func (s *Store) AddKnowledgeAccountChecked(actor string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if doc.Owner != core.ID(actor) {
|
||||
return ErrNotFound
|
||||
}
|
||||
}
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
return s.addKnowledgeTx(tx, docs)
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceKnowledgeAccountChecked atomically removes the affected publication
|
||||
// refs and inserts all replacement chunks. A failed insert rolls the removals
|
||||
// back; embeddings and their later vectors are separate guarded operations.
|
||||
// scoped=true limits each ref to the exact project and plugin namespace.
|
||||
func (s *Store) ReplaceKnowledgeAccountChecked(ctx context.Context, actor, pluginID string, scoped, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if scoped && pluginID == "" {
|
||||
return ErrNotFound
|
||||
}
|
||||
prefix := "plugin:" + pluginID
|
||||
type publicationRef struct{ projectID, ref string }
|
||||
refs := map[publicationRef]bool{}
|
||||
for _, doc := range docs {
|
||||
if doc.Owner != core.ID(actor) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if scoped && doc.Source != prefix && !strings.HasPrefix(doc.Source, prefix+":") {
|
||||
return ErrNotFound
|
||||
}
|
||||
if doc.Ref != "" {
|
||||
refs[publicationRef{projectID: string(doc.ProjectID), ref: doc.Ref}] = true
|
||||
}
|
||||
}
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for ref := range refs {
|
||||
pred := `owner=? AND ref=?`
|
||||
args := []any{actor, ref.ref}
|
||||
if scoped {
|
||||
pred += ` AND project_id=? AND (source=? OR substr(source,1,length(?))=?)`
|
||||
args = append(args, ref.projectID, prefix, prefix+":", prefix+":")
|
||||
} else if ref.projectID != "" {
|
||||
pred += ` AND project_id=?`
|
||||
args = append(args, ref.projectID)
|
||||
}
|
||||
if _, err := s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.addKnowledgeTx(tx, docs)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) addKnowledgeTx(tx *sql.Tx, docs []core.KnowledgeDoc) error {
|
||||
ts := nowISO()
|
||||
// 按下标写回生成的 ID:调用方(rag 管线)用 ID 关联向量。
|
||||
for i := range docs {
|
||||
@@ -303,7 +512,7 @@ func (s *Store) AddKnowledge(docs ...core.KnowledgeDoc) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteKnowledgeByRef 删除引用对应的知识(工件删除时联动清索引)。
|
||||
@@ -312,6 +521,35 @@ func (s *Store) DeleteKnowledgeByRef(owner, ref string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteKnowledgeByRefAccountChecked removes the index rows and their vectors
|
||||
// in the same account-guarded transaction.
|
||||
func (s *Store) DeleteKnowledgeByRefAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error {
|
||||
_, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, `owner=? AND ref=?`, actor, ref)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteKnowledgeBySourceAccountChecked treats the prefix literally and only
|
||||
// matches its namespace (prefix itself or prefix followed by ':'). SQL LIKE
|
||||
// would treat plugin IDs containing '_' or '%' as wildcards.
|
||||
func (s *Store) DeleteKnowledgeBySourceAccountChecked(ctx context.Context, actor, sourcePrefix string, allowImplicitDefault bool) (int64, error) {
|
||||
if sourcePrefix == "" {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
childPrefix := sourcePrefix + ":"
|
||||
return s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault,
|
||||
`owner=? AND (source=? OR substr(source,1,length(?))=?)`, actor, sourcePrefix, childPrefix, childPrefix)
|
||||
}
|
||||
|
||||
func (s *Store) deleteKnowledgeAccountChecked(ctx context.Context, actor string, allowImplicitDefault bool, pred string, args ...any) (int64, error) {
|
||||
var deleted int64
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
var err error
|
||||
deleted, err = s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...)
|
||||
return err
|
||||
})
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
// DeleteKnowledgeBySource 按来源前缀撤销知识(EXT-9):删除该 owner 下 source 匹配
|
||||
// 前缀的行,并经 ref 关联清理向量;幂等(无匹配返回 0, nil)。供宿主在
|
||||
// KnowledgeDelete(前缀已强制 plugin:<插件ID>)后调用——不作用他人来源。
|
||||
@@ -421,6 +659,19 @@ func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectI
|
||||
return s.deleteKnowledgeAndVectors(ctx, pred, args...)
|
||||
}
|
||||
|
||||
// DeleteProjectKnowledgeByRefAccountChecked retains legacy project scoping:
|
||||
// empty projectID means all of this actor's projects for the given ref.
|
||||
func (s *Store) DeleteProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref string, allowImplicitDefault bool) error {
|
||||
args := []any{actor, ref}
|
||||
pred := `owner=? AND ref=?`
|
||||
if projectID != "" {
|
||||
pred += ` AND project_id=?`
|
||||
args = append(args, projectID)
|
||||
}
|
||||
_, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, pred, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeletePluginProjectKnowledgeByRef replaces only this actor/plugin publication
|
||||
// scope. Empty project is an exact unscoped domain, never a wildcard. The source
|
||||
// prefix is compared literally, including any percent/underscore in plugin IDs.
|
||||
@@ -433,21 +684,45 @@ func (s *Store) DeletePluginProjectKnowledgeByRef(ctx context.Context, owner, pr
|
||||
return s.deleteKnowledgeAndVectors(ctx, pred, owner, projectID, ref, prefix, prefix+":", prefix+":")
|
||||
}
|
||||
|
||||
// DeletePluginProjectKnowledgeByRefAccountChecked retains the exact project,
|
||||
// ref and plugin namespace restriction, including literal '%' and '_' IDs.
|
||||
func (s *Store) DeletePluginProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref, pluginID string, allowImplicitDefault bool) error {
|
||||
if pluginID == "" {
|
||||
return ErrNotFound
|
||||
}
|
||||
prefix := "plugin:" + pluginID
|
||||
pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)`
|
||||
_, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, pred,
|
||||
actor, projectID, ref, prefix, prefix+":", prefix+":")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) deleteKnowledgeAndVectors(ctx context.Context, pred string, args ...any) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...); err != nil {
|
||||
if _, err := s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) deleteKnowledgeAndVectorsTx(ctx context.Context, tx *sql.Tx, pred string, args ...any) (int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// CreateConversationAccountChecked creates a conversation only while its actor
|
||||
// is effective. The project lookup and insert use the same guarded transaction;
|
||||
// shared-project authorization remains the caller's separate domain decision.
|
||||
func (s *Store) CreateConversationAccountChecked(actor, projectID, title, profileID string, allowImplicitDefault, requireProjectOwner bool) (core.Conversation, error) {
|
||||
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: actor, Title: title, ProfileID: profileID,
|
||||
AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
query := s.q(`SELECT 1 FROM projects WHERE id=?`)
|
||||
args := []any{projectID}
|
||||
if requireProjectOwner {
|
||||
query = s.q(`SELECT 1 FROM projects WHERE id=? AND owner=?`)
|
||||
args = append(args, actor)
|
||||
}
|
||||
var one int
|
||||
if err := tx.QueryRow(query, args...).Scan(&one); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
|
||||
c.ID, c.ProjectID, actor, c.Title, c.ProfileID, nowISO(), nowISO())
|
||||
return err
|
||||
})
|
||||
return c, err
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConversationMetadataAccountCheckedStateAndOwner(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
t.Logf("conversation metadata fence dialect=%s", st.Dialect())
|
||||
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", "p")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := st.CreateConversation("alice", project.ID, "before", "profile-before")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yes := true
|
||||
for _, mutate := range []func(string) error{
|
||||
func(actor string) error {
|
||||
return st.UpdateConversationAccountChecked(actor, conversation.ID, "after", "profile-after", false)
|
||||
},
|
||||
func(actor string) error {
|
||||
return st.SetConversationFlagsAccountChecked(actor, conversation.ID, &yes, nil, nil, nil, false)
|
||||
},
|
||||
func(actor string) error {
|
||||
return st.SetPermissionModeAccountChecked(actor, conversation.ID, "plan", false)
|
||||
},
|
||||
func(actor string) error {
|
||||
return st.SetSessionNoteAccountChecked(actor, conversation.ID, "note", false)
|
||||
},
|
||||
func(actor string) error { return st.DeleteConversationAccountChecked(actor, conversation.ID, false) },
|
||||
} {
|
||||
if err := mutate("bob"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign actor changed conversation: %v", err)
|
||||
}
|
||||
if err := mutate("missing"); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("missing actor changed conversation: %v", err)
|
||||
}
|
||||
}
|
||||
if err := st.UpdateConversationAccountChecked("alice", conversation.ID, "after", "profile-after", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetConversationFlagsAccountChecked("alice", conversation.ID, &yes, nil, nil, nil, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetPermissionModeAccountChecked("alice", conversation.ID, "plan", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetSessionNoteAccountChecked("alice", conversation.ID, "note", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetConversation("alice", conversation.ID)
|
||||
if err != nil || got.Title != "after" || got.ProfileID != "profile-after" || !got.Pinned || got.PermissionMode != "plan" || got.SessionNote != "note" {
|
||||
t.Fatalf("metadata writes did not persist: %+v %v", got, err)
|
||||
}
|
||||
if err := st.SetPermissionModeAccountChecked("alice", conversation.ID, "invalid", false); err == nil {
|
||||
t.Fatal("invalid permission mode was accepted")
|
||||
}
|
||||
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 {
|
||||
return st.UpdateConversationAccountChecked("alice", conversation.ID, "late", "late", false)
|
||||
},
|
||||
func() error {
|
||||
return st.SetConversationFlagsAccountChecked("alice", conversation.ID, nil, nil, nil, nil, false)
|
||||
},
|
||||
func() error { return st.SetPermissionModeAccountChecked("alice", conversation.ID, "auto_edit", false) },
|
||||
func() error { return st.SetSessionNoteAccountChecked("alice", conversation.ID, "late", false) },
|
||||
func() error { return st.DeleteConversationAccountChecked("alice", conversation.ID, false) },
|
||||
} {
|
||||
if err := mutate(); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled actor changed conversation: %v", err)
|
||||
}
|
||||
}
|
||||
got, err = st.GetConversation("alice", conversation.ID)
|
||||
if err != nil || got.Title != "after" || got.ProfileID != "profile-after" || got.PermissionMode != "plan" || got.SessionNote != "note" {
|
||||
t.Fatalf("disabled metadata write changed conversation: %+v %v", got, err)
|
||||
}
|
||||
|
||||
bobProject, err := st.CreateProject("bob", "bob project")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bobConversation, err := st.CreateConversation("bob", bobProject.ID, "temporary", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DeleteConversationAccountChecked("bob", bobConversation.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.GetConversation("bob", bobConversation.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("successful delete left conversation behind: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGConversationMetadataFenceSerializesWithDisable(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if st.Dialect() != "postgres" {
|
||||
t.Skip("real PostgreSQL required")
|
||||
}
|
||||
other := reopenAccountModelStore(t, st)
|
||||
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", "p")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := st.CreateConversation("alice", project.ID, "before", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release := holdPGAccountWrite(t, st, "conversations", "UPDATE", "conversation_metadata")
|
||||
updateDone := make(chan error, 1)
|
||||
go func() {
|
||||
updateDone <- st.UpdateConversationAccountChecked("alice", conversation.ID, "after", "", false)
|
||||
}()
|
||||
waitForPGAccountWrite(t, st, "conversation_metadata")
|
||||
disableDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false)
|
||||
disableDone <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-disableDone:
|
||||
t.Fatalf("disable committed ahead of paused metadata write: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
if err := awaitPGAccountResult(t, updateDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := awaitPGAccountResult(t, disableDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetSessionNoteAccountChecked("alice", conversation.ID, "late", false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("metadata write passed after disable: %v", err)
|
||||
}
|
||||
got, err := st.GetConversation("alice", conversation.ID)
|
||||
if err != nil || got.Title != "after" || got.SessionNote != "" {
|
||||
t.Fatalf("metadata crossed disable boundary: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
+102
-10
@@ -37,9 +37,6 @@ type ExecutionState struct {
|
||||
// TryClaimExecution 占用(或租约到期后接管)会话执行权。
|
||||
// 返回接管后的 epoch(接管必然 +1)。被他人持有且未到期 → ErrExecutionBusy。
|
||||
func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leaseSec int) (int64, error) {
|
||||
if leaseSec <= 0 {
|
||||
leaseSec = 90
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -48,20 +45,63 @@ func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leas
|
||||
if err := s.lockAdmission(tx); err != nil { // 复用全局账本锁串行化清点
|
||||
return 0, err
|
||||
}
|
||||
epoch, err := s.claimExecutionTx(tx, conversationID, instanceID, runID, leaseSec)
|
||||
if err != nil {
|
||||
return epoch, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return epoch, nil
|
||||
}
|
||||
|
||||
// TryClaimExecutionAccountChecked locks the actor before the admission ledger
|
||||
// and verifies the conversation/run owner in the same final write transaction.
|
||||
func (s *Store) TryClaimExecutionAccountChecked(actor, conversationID, instanceID, runID string, leaseSec int, allowImplicitDefault bool) (int64, error) {
|
||||
var epoch int64
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
if err := s.lockAdmission(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
var owner string
|
||||
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actor {
|
||||
return ErrNotFound
|
||||
}
|
||||
if runID != "" {
|
||||
var one int
|
||||
if err := tx.QueryRow(s.q(`SELECT 1 FROM runs WHERE id=? AND owner=? AND conversation_id=?`), runID, actor, conversationID).Scan(&one); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var err error
|
||||
epoch, err = s.claimExecutionTx(tx, conversationID, instanceID, runID, leaseSec)
|
||||
return err
|
||||
})
|
||||
return epoch, err
|
||||
}
|
||||
|
||||
func (s *Store) claimExecutionTx(tx *sql.Tx, conversationID, instanceID, runID string, leaseSec int) (int64, error) {
|
||||
if leaseSec <= 0 {
|
||||
leaseSec = 90
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
until := now.Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano)
|
||||
var inst, lease string
|
||||
var epoch int64
|
||||
err = tx.QueryRow(s.q(`SELECT instance_id, epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).Scan(&inst, &epoch, &lease)
|
||||
err := tx.QueryRow(s.q(`SELECT instance_id, epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).Scan(&inst, &epoch, &lease)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO conversation_executions (conversation_id, instance_id, run_id, epoch, lease_until, updated_at) VALUES (?,?,?,1,?,?)`),
|
||||
conversationID, instanceID, runID, until, nowISO()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 1, nil
|
||||
case err != nil:
|
||||
return 0, err
|
||||
@@ -74,9 +114,6 @@ func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leas
|
||||
instanceID, runID, until, nowISO(), conversationID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return epoch + 1, nil
|
||||
}
|
||||
|
||||
@@ -95,6 +132,39 @@ func (s *Store) RenewExecution(conversationID, instanceID string, epoch int64, l
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// RenewExecutionAccountChecked only extends the lease while the conversation
|
||||
// owner is effective in the same transaction as the conditional UPDATE.
|
||||
func (s *Store) RenewExecutionAccountChecked(actor, conversationID, instanceID string, epoch int64, leaseSec int, allowImplicitDefault bool) bool {
|
||||
if leaseSec <= 0 {
|
||||
leaseSec = 90
|
||||
}
|
||||
until := time.Now().UTC().Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano)
|
||||
updated := false
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
var owner string
|
||||
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actor {
|
||||
return ErrNotFound
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE conversation_executions SET lease_until=?, updated_at=? WHERE conversation_id=? AND instance_id=? AND epoch=?`),
|
||||
until, nowISO(), conversationID, instanceID, epoch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated = n == 1
|
||||
return nil
|
||||
})
|
||||
return err == nil && updated
|
||||
}
|
||||
|
||||
// ReleaseExecution 主动释放(优雅停机):行保留、租约置为已过期——epoch 保留,
|
||||
// 下一次占用仍递增(fencing 单调)。
|
||||
func (s *Store) ReleaseExecution(conversationID, instanceID string, epoch int64) {
|
||||
@@ -173,6 +243,28 @@ func (s *Store) EnqueueCommand(commandID, kind, runID, conversationID string, ep
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnqueueCommandAccountChecked resolves the run owner and conversation from
|
||||
// persisted rows in the final account-guarded transaction. A client-supplied
|
||||
// run ID can locate a command target but cannot authorize another owner's run.
|
||||
func (s *Store) EnqueueCommandAccountChecked(actor, commandID, kind, runID string, epoch int64, payload any, allowImplicitDefault bool) error {
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
var owner, conversationID string
|
||||
query := s.q(`SELECT owner,conversation_id FROM runs WHERE id=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
if err := tx.QueryRow(query, runID).Scan(&owner, &conversationID); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actor {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.EnqueueCommandInTx(tx, commandID, kind, runID, conversationID, epoch, payload)
|
||||
})
|
||||
}
|
||||
|
||||
// ClaimPendingCommands 认领待处理命令(原子 pending→consumed)。
|
||||
// PG:FOR UPDATE SKIP LOCKED(多实例并发认领不重复);SQLite:单写者条件更新。
|
||||
// 只认领「本实例相关」的命令由调用方过滤——收件箱按执行权路由:命令在
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type executionAccountWriter interface {
|
||||
TryClaimExecutionAccountChecked(actor, conversationID, instanceID, runID string, leaseSec int, allowImplicitDefault bool) (int64, error)
|
||||
RenewExecutionAccountChecked(actor, conversationID, instanceID string, epoch int64, leaseSec int, allowImplicitDefault bool) bool
|
||||
TryAcquireExecutionSlotAccountChecked(actor, runID string, budget ExecutionBudget, allowImplicitDefault bool) error
|
||||
}
|
||||
|
||||
func executionWriter(t *testing.T, st *Store) executionAccountWriter {
|
||||
t.Helper()
|
||||
w, ok := any(st).(executionAccountWriter)
|
||||
if !ok {
|
||||
t.Fatal("Store is missing checked execution lease/admission APIs")
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func executionFenceFixture(t *testing.T) (*Store, core.Conversation, core.Run) {
|
||||
t.Helper()
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "execution.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := st.EnsureUser(owner, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p, err := st.CreateProject("alice", "p")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := st.CreateConversation("alice", p.ID, "c", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := core.Run{ID: "run-exec-account", Owner: "alice", ConversationID: conv.ID, ProjectID: p.ID, Status: core.RunPending}
|
||||
if err := st.CreateRun(&run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st, conv, run
|
||||
}
|
||||
|
||||
func TestExecutionAccountFenceClaimRejectsDisabledAndForeignActor(t *testing.T) {
|
||||
st, conv, _ := executionFenceFixture(t)
|
||||
w := executionWriter(t, st)
|
||||
if _, err := w.TryClaimExecutionAccountChecked("bob", conv.ID, "instance", "", 60, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign claim: got %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.TryClaimExecutionAccountChecked("alice", conv.ID, "instance", "", 60, false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled claim: got %v, want ErrAccountForbidden", err)
|
||||
}
|
||||
if _, err := st.GetExecution(conv.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("rejected claim left execution lease: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionAccountFenceRenewDoesNotExtendAfterDisable(t *testing.T) {
|
||||
st, conv, run := executionFenceFixture(t)
|
||||
w := executionWriter(t, st)
|
||||
epoch, err := w.TryClaimExecutionAccountChecked("alice", conv.ID, "instance", run.ID, 60, false)
|
||||
if err != nil || epoch != 1 {
|
||||
t.Fatalf("initial claim: epoch=%d err=%v", epoch, err)
|
||||
}
|
||||
before, err := st.GetExecution(conv.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.RenewExecutionAccountChecked("alice", conv.ID, "instance", epoch, 180, false) {
|
||||
t.Fatal("disabled actor renewed execution")
|
||||
}
|
||||
after, err := st.GetExecution(conv.ID)
|
||||
if err != nil || after.LeaseUntil != before.LeaseUntil || after.Epoch != before.Epoch {
|
||||
t.Fatalf("disabled renew changed lease: before=%+v after=%+v err=%v", before, after, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionAccountFenceSlotRejectsDisabledForeignRunAndBudgetOff(t *testing.T) {
|
||||
st, _, run := executionFenceFixture(t)
|
||||
w := executionWriter(t, st)
|
||||
budget := ExecutionBudget{Global: 2, PerUser: 1}
|
||||
if err := w.TryAcquireExecutionSlotAccountChecked("bob", run.ID, budget, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign run slot: got %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.TryAcquireExecutionSlotAccountChecked("alice", run.ID, budget, false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled slot: got %v, want ErrAccountForbidden", err)
|
||||
}
|
||||
if err := w.TryAcquireExecutionSlotAccountChecked("alice", run.ID, ExecutionBudget{}, false); err != nil {
|
||||
t.Fatalf("off budget should remain a no-write fast path: %v", err)
|
||||
}
|
||||
if n, _, err := st.ActiveExecutionSlots(); err != nil || n != 0 {
|
||||
t.Fatalf("rejected/off slot created %d leases: %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionAccountFenceImplicitLocalStopsAtRecordedDisable(t *testing.T) {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "execution.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
w := executionWriter(t, st)
|
||||
p, err := st.CreateProject(OwnerDefault, "local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := st.CreateConversation(OwnerDefault, p.ID, "c", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := core.Run{ID: "run-local-exec", Owner: OwnerDefault, ConversationID: conv.ID, ProjectID: p.ID, Status: core.RunPending}
|
||||
if err := st.CreateRun(&run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epoch, err := w.TryClaimExecutionAccountChecked(OwnerDefault, conv.ID, "instance", run.ID, 60, true)
|
||||
if err != nil || epoch != 1 {
|
||||
t.Fatalf("implicit local claim: epoch=%d err=%v", epoch, err)
|
||||
}
|
||||
if err := w.TryAcquireExecutionSlotAccountChecked(OwnerDefault, run.ID, ExecutionBudget{Global: 1}, true); err != nil {
|
||||
t.Fatalf("implicit local slot: %v", err)
|
||||
}
|
||||
if err := st.EnsureUser(OwnerDefault, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, OwnerDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.RenewExecutionAccountChecked(OwnerDefault, conv.ID, "instance", epoch, 180, true) {
|
||||
t.Fatal("recorded disabled local account renewed lease")
|
||||
}
|
||||
if err := w.TryAcquireExecutionSlotAccountChecked(OwnerDefault, run.ID, ExecutionBudget{Global: 1}, true); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("recorded disabled local account reacquired slot: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func (s *Store) SaveHITLCardAccountChecked(actor string, card *core.HITLCard, allowImplicitDefault bool) error {
|
||||
if card == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
var runOwner, conversationID string
|
||||
if err := tx.QueryRow(s.q(`SELECT owner,conversation_id FROM runs WHERE id=?`), card.RunID).Scan(&runOwner, &conversationID); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if runOwner != actor || conversationID != string(card.ConversationID) {
|
||||
return ErrNotFound
|
||||
}
|
||||
_, err := tx.Exec(s.q(`INSERT INTO hitl_cards(id,owner,run_id,conversation_id,payload,created_at,resolved,resolution) VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload,resolved=excluded.resolved,resolution=excluded.resolution`),
|
||||
card.ID, actor, card.RunID, card.ConversationID, marshalJSON(card), nowISO(), boolInt(card.Resolved), "")
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ResolveHITLCardAccountChecked(actor, id string, resolution core.HITLResolvedPayload, allowImplicitDefault bool) error {
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
query := s.q(`SELECT payload,run_id,conversation_id,resolved FROM hitl_cards WHERE id=? AND owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var payload, runID, conversationID string
|
||||
var resolved bool
|
||||
if err := tx.QueryRow(query, id, actor).Scan(&payload, &runID, &conversationID, &resolved); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved {
|
||||
return ErrConflict
|
||||
}
|
||||
var runOwner, runConversation string
|
||||
if err := tx.QueryRow(s.q(`SELECT owner,conversation_id FROM runs WHERE id=?`), runID).Scan(&runOwner, &runConversation); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if runOwner != actor || runConversation != conversationID {
|
||||
return ErrNotFound
|
||||
}
|
||||
card, err := unmarshalJSON[core.HITLCard](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
card.Resolved = true
|
||||
res, err := tx.Exec(s.q(`UPDATE hitl_cards SET resolved=1,payload=?,resolution=? WHERE id=? AND owner=? AND resolved=0`),
|
||||
marshalJSON(card), marshalJSON(resolution), id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type knowledgeAccountWriter interface {
|
||||
AddKnowledgeAccountChecked(actor string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error
|
||||
DeleteKnowledgeBySourceAccountChecked(ctx context.Context, actor, sourcePrefix string, allowImplicitDefault bool) (int64, error)
|
||||
DeleteKnowledgeByRefAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error
|
||||
DeleteProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref string, allowImplicitDefault bool) error
|
||||
DeletePluginProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref, pluginID string, allowImplicitDefault bool) error
|
||||
ReplaceKnowledgeAccountChecked(ctx context.Context, actor, pluginID string, scoped, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error
|
||||
UpsertKnowledgeVectorExtAccountChecked(ctx context.Context, actor, docID, ref, model, contentHash string, vec []float32, allowImplicitDefault bool) error
|
||||
DeleteKnowledgeVectorsAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error
|
||||
}
|
||||
|
||||
func knowledgeWriter(t *testing.T, st *Store) knowledgeAccountWriter {
|
||||
t.Helper()
|
||||
w, ok := any(st).(knowledgeAccountWriter)
|
||||
if !ok {
|
||||
t.Fatal("Store is missing the account-checked knowledge and vector API")
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func knowledgeRows(t *testing.T, st *Store, table, owner, ref string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE owner=? AND ref=?`, owner, ref).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestKnowledgeAccountFenceRejectsDisabledAndMixedOwnerBatch(t *testing.T) {
|
||||
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db"))
|
||||
w := knowledgeWriter(t, st)
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
good := core.KnowledgeDoc{ID: "knw-good", Owner: "alice", Ref: "batch", Source: "manual", Chunk: "good"}
|
||||
foreign := core.KnowledgeDoc{ID: "knw-foreign", Owner: "bob", Ref: "batch", Source: "manual", Chunk: "foreign"}
|
||||
if err := w.AddKnowledgeAccountChecked("alice", false, good, foreign); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("mixed owner batch: got %v, want ErrNotFound", err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge", "alice", "batch"); n != 0 {
|
||||
t.Fatalf("mixed owner batch wrote %d rows", n)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.AddKnowledgeAccountChecked("alice", false, good); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled add: got %v, want ErrAccountForbidden", err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge", "alice", "batch"); n != 0 {
|
||||
t.Fatalf("disabled add wrote %d rows", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeAccountFenceDeletesOnlyExactSourceAndScope(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db"))
|
||||
w := knowledgeWriter(t, st)
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := st.EnsureUser(owner, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
docs := []core.KnowledgeDoc{
|
||||
{ID: "knw-a1", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b", Chunk: "a1"},
|
||||
{ID: "knw-a2", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b:child", Chunk: "a2"},
|
||||
{ID: "knw-a3", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:axb", Chunk: "a3"},
|
||||
{ID: "knw-a4", Owner: "alice", ProjectID: "p2", Ref: "r1", Source: "plugin:a_b", Chunk: "a4"},
|
||||
{ID: "knw-b1", Owner: "bob", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b", Chunk: "b1"},
|
||||
}
|
||||
if err := st.AddKnowledge(docs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range docs {
|
||||
if err := st.UpsertKnowledgeVectorExt(ctx, string(d.Owner), string(d.ID), d.Ref, "test", "hash", []float32{1, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n, err := w.DeleteKnowledgeBySourceAccountChecked(ctx, "alice", "plugin:a_b", false); err != nil || n != 3 {
|
||||
t.Fatalf("exact source delete: %d, %v; want 3", n, err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge", "alice", "r1"); n != 1 {
|
||||
t.Fatalf("literal prefix matched unrelated source: %d alice rows remain", n)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "r1"); n != 1 {
|
||||
t.Fatalf("source delete left wrong vector set: %d", n)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge", "bob", "r1"); n != 1 {
|
||||
t.Fatalf("source delete crossed owner: %d", n)
|
||||
}
|
||||
if err := w.DeletePluginProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "r1", "axb", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge", "alice", "r1"); n != 0 {
|
||||
t.Fatalf("plugin scoped delete left %d rows", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeAccountFenceProjectPluginAndRefDeletesKeepOtherScopes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db"))
|
||||
w := knowledgeWriter(t, st)
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docs := []core.KnowledgeDoc{
|
||||
{ID: "knw-p1-a", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "one"},
|
||||
{ID: "knw-p1-b", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:beta", Chunk: "two"},
|
||||
{ID: "knw-p2-a", Owner: "alice", ProjectID: "p2", Ref: "shared", Source: "plugin:alpha", Chunk: "three"},
|
||||
}
|
||||
if err := st.AddKnowledge(docs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range docs {
|
||||
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", string(d.ID), d.Ref, "test", "hash", []float32{1, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.DeletePluginProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "shared", "alpha", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 2 {
|
||||
t.Fatalf("plugin delete removed other scope: %d remain", n)
|
||||
}
|
||||
if err := w.DeleteProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "shared", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 1 {
|
||||
t.Fatalf("project delete removed other project: %d remain", n)
|
||||
}
|
||||
if err := w.DeleteKnowledgeByRefAccountChecked(ctx, "alice", "shared", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 0 {
|
||||
t.Fatalf("ref delete left %d knowledge rows", n)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 0 {
|
||||
t.Fatalf("ref delete left %d vectors", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeAccountFenceVectorRequiresOwnedMatchingKnowledge(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db"))
|
||||
w := knowledgeWriter(t, st)
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := st.EnsureUser(owner, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := st.AddKnowledge(
|
||||
core.KnowledgeDoc{ID: "knw-alice", Owner: "alice", Ref: "a", Chunk: "alice"},
|
||||
core.KnowledgeDoc{ID: "knw-bob", Owner: "bob", Ref: "b", Chunk: "bob"},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-bob", "b", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign doc vector: got %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "wrong-ref", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("mismatched ref vector: got %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := st.UpsertKnowledgeVectorExt(ctx, "bob", "knw-alice", "a", "legacy", "hash", []float32{0, 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("foreign existing vector takeover: got %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := st.DeleteKnowledgeVectors(ctx, "bob", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash", []float32{1, 0}, false); 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 := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash2", []float32{0, 1}, false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled vector upsert: got %v, want ErrAccountForbidden", err)
|
||||
}
|
||||
if err := w.DeleteKnowledgeVectorsAccountChecked(ctx, "alice", "a", false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled vector delete: got %v, want ErrAccountForbidden", err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "a"); n != 1 {
|
||||
t.Fatalf("disabled actor changed %d vector rows", n)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=TRUE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.DeleteKnowledgeVectorsAccountChecked(ctx, "alice", "a", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "a"); n != 0 {
|
||||
t.Fatalf("guarded vector delete left %d rows", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeAccountFenceReplaceRollsBackDeletionWhenInsertFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db"))
|
||||
w := knowledgeWriter(t, st)
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := core.KnowledgeDoc{ID: "knw-old", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "old"}
|
||||
other := core.KnowledgeDoc{ID: "knw-fixed", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:beta", Chunk: "other plugin"}
|
||||
otherProject := core.KnowledgeDoc{ID: "knw-p2", Owner: "alice", ProjectID: "p2", Ref: "shared", Source: "plugin:alpha", Chunk: "other project"}
|
||||
if err := st.AddKnowledge(old, other, otherProject); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", string(old.ID), old.Ref, "test", "hash", []float32{1, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := core.KnowledgeDoc{ID: "knw-new", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "replacement"}
|
||||
duplicate := core.KnowledgeDoc{ID: other.ID, Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "duplicate id"}
|
||||
if err := w.ReplaceKnowledgeAccountChecked(ctx, "alice", "alpha", true, false, first, duplicate); err == nil {
|
||||
t.Fatal("replacement with duplicate ID unexpectedly committed")
|
||||
}
|
||||
var n int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, old.ID).Scan(&n); err != nil || n != 1 {
|
||||
t.Fatalf("failed replacement removed original row: %d, %v", n, err)
|
||||
}
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, first.ID).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("failed replacement inserted first row: %d, %v", n, err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 1 {
|
||||
t.Fatalf("failed replacement removed original vector: %d", n)
|
||||
}
|
||||
if err := w.ReplaceKnowledgeAccountChecked(ctx, "alice", "alpha", true, false, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id IN (?,?,?)`, first.ID, other.ID, otherProject.ID).Scan(&n); err != nil || n != 3 {
|
||||
t.Fatalf("successful replacement lost other scopes: %d, %v", n, err)
|
||||
}
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, old.ID).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("successful replacement kept old row: %d, %v", n, err)
|
||||
}
|
||||
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 0 {
|
||||
t.Fatalf("successful replacement kept old vector: %d", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// AddKnowledgeForOwnedProjectAccountChecked is the HTTP owner's project path.
|
||||
// Domain/plugin publication has a separate authorization callback and uses the
|
||||
// broader account-checked method; this entry rechecks physical ownership in
|
||||
// the final Backend transaction.
|
||||
func (s *Store) AddKnowledgeForOwnedProjectAccountChecked(actor, projectID string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if doc.Owner != core.ID(actor) || doc.ProjectID != core.ID(projectID) {
|
||||
return ErrNotFound
|
||||
}
|
||||
}
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
var owner string
|
||||
if err := tx.QueryRow(s.q(`SELECT owner FROM projects WHERE id=?`), projectID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actor {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.addKnowledgeTx(tx, docs)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// RevokeLoginKeyCascadeAdmin rechecks the actual administrator and the target
|
||||
// grant under sorted account locks before revoking it. The path owner is an
|
||||
// exact-match locator, never an authorization source.
|
||||
func (s *Store) RevokeLoginKeyCascadeAdmin(actor, targetOwner, id string) error {
|
||||
if actor == "" || targetOwner == "" || id == "" {
|
||||
return ErrNotFound
|
||||
}
|
||||
var routeOwner string
|
||||
if err := s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&routeOwner); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if routeOwner != targetOwner {
|
||||
return ErrNotFound
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
locked, err := s.lockAccountUsers(tx, actor, targetOwner)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !locked[actor].Enabled || !locked[actor].IsAdmin {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
query := s.q(`SELECT owner FROM login_keys WHERE id=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var currentOwner string
|
||||
if err := tx.QueryRow(query, id).Scan(¤tOwner); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentOwner != targetOwner {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`),
|
||||
LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), targetOwner, SessionScopeSetPasswordOnly); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, actor, "admin-http", targetOwner, "login_key_revoked", id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.auditAccountMutation(tx, targetOwner, actor, "account.login_key.revoke"); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ExcludeRunGroupAccountChecked and SelectVariantAccountChecked recheck the
|
||||
// message's persisted conversation owner after the actor lock. The entire
|
||||
// group update commits behind the same disable fence.
|
||||
func (s *Store) ExcludeRunGroupAccountChecked(actor, id string, excluded, allowImplicitDefault bool) error {
|
||||
return s.mutateMessageChoiceAccountChecked(actor, id, excluded, false, allowImplicitDefault)
|
||||
}
|
||||
|
||||
func (s *Store) SelectVariantAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||||
return s.mutateMessageChoiceAccountChecked(actor, id, false, true, allowImplicitDefault)
|
||||
}
|
||||
|
||||
func (s *Store) mutateMessageChoiceAccountChecked(actor, id string, excluded, selectVariant, allowImplicitDefault bool) error {
|
||||
var routeConversation string
|
||||
if err := s.db.QueryRow(s.q(`SELECT conversation_id FROM messages WHERE id=?`), id).Scan(&routeConversation); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
if err := s.dialect.lockConversation(tx, routeConversation); err != nil {
|
||||
return err
|
||||
}
|
||||
query := s.q(`SELECT m.run_id,m.variant_group,m.conversation_id,c.owner FROM messages m
|
||||
JOIN conversations c ON c.id=m.conversation_id WHERE m.id=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE OF m`
|
||||
}
|
||||
var runID, group, conversationID, owner string
|
||||
if err := tx.QueryRow(query, id).Scan(&runID, &group, &conversationID, &owner); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actor || conversationID != routeConversation {
|
||||
return ErrNotFound
|
||||
}
|
||||
if selectVariant && group != "" {
|
||||
if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=1 WHERE conversation_id=? AND variant_group=?`), conversationID, group); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(s.q(`UPDATE messages SET excluded=0 WHERE id=? AND conversation_id=?`), id, conversationID)
|
||||
return err
|
||||
}
|
||||
flag := 0
|
||||
if excluded {
|
||||
flag = 1
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=? WHERE id=? AND conversation_id=?`), flag, id, conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
if runID != "" {
|
||||
_, err := tx.Exec(s.q(`UPDATE messages SET excluded=? WHERE run_id=? AND conversation_id=?`), flag, runID, conversationID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
+114
-10
@@ -35,6 +35,16 @@ func (s *Store) AppendMessage(m *core.Message) error {
|
||||
// AppendMessageWithRequest 同 AppendMessage,并记录可选的 request_id。
|
||||
// 同一会话里非空 request_id 已存在时,把 *m 换成已有行并返回 DuplicateRequestError。
|
||||
func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) error {
|
||||
return s.appendMessageWithRequest(m, requestID, "", false, false)
|
||||
}
|
||||
|
||||
// AppendMessageWithRequestAccountChecked fences the final user-message write
|
||||
// against account disable. The actor lock precedes the conversation lock.
|
||||
func (s *Store) AppendMessageWithRequestAccountChecked(actor string, allowImplicitDefault bool, m *core.Message, requestID string) error {
|
||||
return s.appendMessageWithRequest(m, requestID, actor, allowImplicitDefault, true)
|
||||
}
|
||||
|
||||
func (s *Store) appendMessageWithRequest(m *core.Message, requestID, actor string, allowImplicitDefault, guarded bool) error {
|
||||
if m.ID == "" {
|
||||
m.ID = newID("msg")
|
||||
}
|
||||
@@ -46,9 +56,8 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro
|
||||
// 竞态在锁下消失。唯一约束仍留作兜底——冲突只剩事务边界外的极端
|
||||
// 竞态(如手工写入),小退避重试即可收敛。
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
err := s.appendMessageTx(m, requestID, insert)
|
||||
err := s.appendMessageTx(m, requestID, insert, actor, allowImplicitDefault, guarded)
|
||||
if err == nil {
|
||||
s.TouchConversation(m.ConversationID)
|
||||
return nil
|
||||
}
|
||||
if !isUniqueViolation(err) {
|
||||
@@ -72,19 +81,43 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro
|
||||
|
||||
// appendMessageTx 单事务完成同会话消息追加:会话锁 → 序号分配 → INSERT。
|
||||
// 请求幂等依赖 uq_messages_conv_request 唯一索引在事务内暴露冲突。
|
||||
func (s *Store) appendMessageTx(m *core.Message, requestID, insert string) error {
|
||||
func (s *Store) appendMessageTx(m *core.Message, requestID, insert, actor string, allowImplicitDefault, guarded bool) error {
|
||||
if guarded {
|
||||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
return s.appendMessageRows(tx, m, requestID, insert, actor)
|
||||
})
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.appendMessageRows(tx, m, requestID, insert, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) appendMessageRows(tx *sql.Tx, m *core.Message, requestID, insert, actor string) error {
|
||||
if actor != "" {
|
||||
var owner string
|
||||
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), m.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actor {
|
||||
return ErrNotFound
|
||||
}
|
||||
}
|
||||
if err := s.dialect.lockConversation(tx, string(m.ConversationID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(insert), m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), nowISO(), requestID, m.ConversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
_, err := tx.Exec(s.q(`UPDATE conversations SET updated_at=? WHERE id=?`), nowISO(), m.ConversationID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListMessages(conversationID string) ([]core.Message, error) {
|
||||
@@ -278,6 +311,83 @@ func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owne
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
base, err := s.appendEventsInTx(tx, conversationID, evs, owner, producer)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
assignEventSequences(evs, base)
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// AppendEventsAccountChecked is the final persistence boundary for an
|
||||
// actor-attributed card/surface event. The actor account lock precedes the
|
||||
// channel sequence lock and event insert.
|
||||
func (s *Store) AppendEventsAccountChecked(actor, conversationID string, evs []core.StreamEvent, allowImplicitDefault bool) (int64, error) {
|
||||
if len(evs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var base int64
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
if projectID, projectChannel := core.ProjectFromChannel(core.ID(conversationID)); projectChannel {
|
||||
var one int
|
||||
if err := tx.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var owner string
|
||||
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actor {
|
||||
return ErrNotFound
|
||||
}
|
||||
}
|
||||
var err error
|
||||
base, err = s.appendEventsInTx(tx, conversationID, evs, "", "")
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
assignEventSequences(evs, base)
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// AppendEventsForEffectiveChannel resolves an internal run event's account
|
||||
// from the persisted channel before entering the final guarded append. Card
|
||||
// and Surface APIs use their explicit actor-aware emitter instead.
|
||||
func (s *Store) AppendEventsForEffectiveChannel(conversationID string, evs []core.StreamEvent, allowImplicitDefault bool) (int64, error) {
|
||||
var owner string
|
||||
if projectID, projectChannel := core.ProjectFromChannel(core.ID(conversationID)); projectChannel {
|
||||
if err := s.db.QueryRow(s.q(`SELECT owner FROM projects WHERE id=?`), projectID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
} else if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
} else {
|
||||
if err := s.db.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
} else if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return s.AppendEventsAccountChecked(owner, conversationID, evs, allowImplicitDefault)
|
||||
}
|
||||
|
||||
func assignEventSequences(evs []core.StreamEvent, base int64) {
|
||||
for i := range evs {
|
||||
evs[i].Seq = base + int64(i) + 1
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) appendEventsInTx(tx *sql.Tx, conversationID string, evs []core.StreamEvent, owner, producer string) (int64, error) {
|
||||
if err := s.dialect.lockConversation(tx, conversationID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -317,12 +427,6 @@ func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owne
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for i := range evs {
|
||||
evs[i].Seq = base.Int64 + int64(i) + 1
|
||||
}
|
||||
return base.Int64, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -882,6 +882,21 @@ func init() {
|
||||
up: map[string][]string{"sqlite": recoveryFoundationUp, "postgres": recoveryFoundationUp},
|
||||
down: map[string][]string{"sqlite": recoveryFoundationDown, "postgres": recoveryFoundationDown},
|
||||
},
|
||||
migration{version: 34, name: "shared_account_cursor_key",
|
||||
up: map[string][]string{"sqlite": {
|
||||
`ALTER TABLE account_directory_revision ADD COLUMN cursor_key_version INTEGER NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE account_directory_revision ADD COLUMN cursor_secret TEXT NOT NULL DEFAULT ''`,
|
||||
}, "postgres": {
|
||||
`ALTER TABLE account_directory_revision ADD COLUMN cursor_key_version INTEGER NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE account_directory_revision ADD COLUMN cursor_secret TEXT NOT NULL DEFAULT ''`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`ALTER TABLE account_directory_revision DROP COLUMN cursor_secret`,
|
||||
`ALTER TABLE account_directory_revision DROP COLUMN cursor_key_version`,
|
||||
}, "postgres": {
|
||||
`ALTER TABLE account_directory_revision DROP COLUMN cursor_secret`,
|
||||
`ALTER TABLE account_directory_revision DROP COLUMN cursor_key_version`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
31: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取
|
||||
32: true, // 账号有效态/凭据来源是安全协议边界;旧认证写者必须排除
|
||||
33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
|
||||
34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
@@ -25,6 +25,25 @@ func (s *Store) AppendNotice(n Notice) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// AppendNoticeAccountChecked stores a new result notice only while the owner
|
||||
// is effective in the same transaction that creates the notice.
|
||||
func (s *Store) AppendNoticeAccountChecked(n Notice, allowImplicitDefault bool) error {
|
||||
return s.withEffectiveAccountWrite(n.Owner, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
var owner string
|
||||
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), n.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != n.Owner {
|
||||
return ErrNotFound
|
||||
}
|
||||
_, err := tx.Exec(s.q(`INSERT INTO pending_notices (id,conversation_id,owner,name,payload,created_at) VALUES(?,?,?,?,?,?)`),
|
||||
n.ID, n.ConversationID, n.Owner, n.Name, marshalJSON(&n.Payload), nowISO())
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ListNotices 取回全部待投递通知(Manager 启动时装入内存队列)。
|
||||
func (s *Store) ListNotices() ([]Notice, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,conversation_id,owner,name,payload,created_at FROM pending_notices ORDER BY created_at`))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user