Merge Backend main into the shared renderer and two-trees work
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m2s

Why: PR #209 is behind main by 14 commits (admin passwords, managed
interpreter path, U8 SQLite instance guard, EXT-20 regulations) and conflicted
in app wiring, host capabilities, migrations and the extension surface; the
user authorized merging the renderer line on 2026-10-18.
Evidence: content is the agent-exec transport 87a99ece (cursor worker, unit
backend-task10-sync-main-56a7f9f-r2, receipt
/tmp/cleanup-20261018/render/backend-sync-run2.json, SELF): gofmt, go build,
go vet and go test ./... (43 packages) passed. Host check: main migrations
1-36 unchanged, feature migrations moved from 36-47 to 37-48 in order with
identical SQL, surface.txt is the sorted union.
Impact: main's migration 36 instance_guard stays authoritative; databases that
ran unmerged feature builds with the old numbering must be recreated.
SurfaceVersion becomes 2.24.0-experimental.
Refs: PR #209, Backend main 56a7f9f

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-10-18 12:34:42 +00:00
co-authored by Cursor
39 changed files with 2828 additions and 133 deletions
+30
View File
@@ -0,0 +1,30 @@
# feature/regulations-kb-ext20 任务卡
任务主记录:本文件随 PR 描述维护。
状态:已完成(合入前闭合);owner:weichen。PR:https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/212
## 基线与授权
- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git;root_target:main;immediate_target:main
- branch_kind:standalone;跨仓总任务:Agent-System-Dev `feature/regulations-kb`([Dev PR #614](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/614))
- R0(initial_main_sha):cac60c0;工作区:/home/weichen/projects/Agent-System-Backend-wt-feature-regulations-kb-ext20;分支:feature/regulations-kb-ext20
- 授权与交付终点:用户 2026-10-18 /goal 指示按法规知识库计划实现全部内容;本任务交付 U9 的基座公开契约 EXT-20(提案:Documents `docs/proposals/2026-10-18-ext-20-regulations-provider.md`,分支 `feature/regulations-kb-u9-contract`),经 PR 合入 main。不含法规提供方实现(属 KB 仓)与 RDM 消费(U6)。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
## 目标与非目标
- 目标:`pkg/extension` 新增 `RegulationsProvider`/`RegulationsRegistrar`/`RegulationsHost` 与 DTO;进程内注册槽仅接受插件 `kbreg` 且只注册一次;分发 Host 实现四个方法并强制调用方身份、查询最小化(机密词拒绝、长度限制)、状态透传与无明文审计;快照、边界与契约/兼容测试。
- 非目标:不实现搜索或语料检索;不改 `KnowledgeSearch` 语义。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| backend-ext20-regulations-provider | pkg/extension、internal/app | implementation | cursor, antigravity, pi | 1 | cursor-agent-stable / auto(session not-observable,新进程) | success | refs/agent-exec/cfc2e055f212789112cf11716f5649c5/c04b36e76696 → aaed108(apply 快进) | 否 | /tmp/regkb-u9-receipt.json |
## 结果与验证(完成后填写)
- 结果:`pkg/extension/regulations.go`(EXT-20 接口、DTO、状态常量、`ErrRegulationsUnavailable`)、`surface.txt` 与 `SurfaceVersion` 2.15.0-experimental;`internal/app/regulations.go` 注册槽(仅 kbreg、一次、非 nil;声明式拒绝)与 inprocHost 四方法(有效账户检查、调用方取自分发上下文、schema 校验、查询去空白/1–256 字符/排除词拒绝且不调用提供方、状态透传、审计仅含修订/计数/状态/查询 sha256);单元与兼容测试。
- 验证(SELF,host 于 HEAD aaed108):`go vet ./pkg/extension/ ./internal/app/` 与 `go test ./pkg/extension/... ./internal/app/...` PASS;全量 `GOWORK=off go vet ./... && go test -count=1 ./...` 40 包 ok,仅 `pkg/identity TestDSNFileMode` 失败——在未改动基线 cac60c0 上同样失败(本机环境问题,本变更未触及 pkg/identity),以 CI(GATE)为准。
- Review:host 主模型审阅实际 diff(base 3187a00 / head aaed108),与提案第 2、4、5 节一致,无阻塞项。
- 影响与剩余项:新增可选能力,未注册提供方时返回不可用,既有行为不变。
+7 -2
View File
@@ -48,9 +48,11 @@ func main() {
os.Exit(runCapabilities(os.Args[2:]))
case "env":
os.Exit(runEnv(os.Args[2:]))
case "migrate":
os.Exit(runMigrate(os.Args[2:]))
default:
fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1])
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities|env> [参数]")
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities|env|migrate> [参数]")
os.Exit(2)
}
}
@@ -77,6 +79,7 @@ func main() {
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
adoptGuard := fs.Bool("adopt-instance-guard", false, "数据库被替换或锁文件被重建后,显式重写 SQLite 活动服务守卫")
extension.RegisterCLIFlags(fs)
fs.Parse(os.Args[1:])
handled, hookErr := extension.RunCLIHooks(extension.Options{
@@ -91,6 +94,7 @@ func main() {
return
}
cliSet := visited(fs)
delete(cliSet, "adopt-instance-guard")
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
@@ -141,7 +145,8 @@ func main() {
DisableConvCardMirror: extOpt.DisableConvCardMirror,
DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
ExecWorkspace: resolved.ExecWorkspace, ExecEnvRoot: resolved.ExecEnvRoot,
Tier: resolved.Tier,
Tier: resolved.Tier,
AdoptInstanceGuard: *adoptGuard,
PGPool: store.PoolOptions{
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"flag"
"fmt"
"os"
"agent-system-backend/internal/app"
)
func runMigrate(args []string) int {
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
data := fs.String("data", "./data", "数据目录(SQLite 档)")
dsn := fs.String("dsn", "", "PostgreSQL DSN(空则读 AGENT_SYSTEM_DSN)")
adopt := fs.Bool("adopt-instance-guard", false, "数据库被替换或锁文件被重建后,显式重写守卫")
_ = fs.Parse(args)
storage := *dsn
if storage == "" {
storage = os.Getenv("AGENT_SYSTEM_DSN")
}
if err := app.Migrate(app.Options{
DataDir: *data,
StorageDSN: storage,
AdoptInstanceGuard: *adopt,
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
return 1
}
return 0
}
+2
View File
@@ -304,6 +304,8 @@ var copyOrder = []string{
"recovery_proposals", "recovery_proposal_proofs",
// v35 分层 Python 环境(无外键;申请与会话绑定要随库迁走)
"exec_env_requests", "exec_env_bindings",
// U8:活动服务锁的库内标记(无外键;迁移须保留以免目标库丢掉锁根)
"instance_guard",
}
const controlDDL = `
+2 -2
View File
@@ -231,7 +231,7 @@ func TestBackupAndMigrateRejectCandidateRawContextScope(t *testing.T) {
if _, err := db.Exec(`ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`DELETE FROM schema_migrations WHERE version=40`); err != nil {
if _, err := db.Exec(`DELETE FROM schema_migrations WHERE version=41`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO context_manifests(id,attempt_id,owner,target_id,project_id,input_sha256,delivery,manifest_json,created_at,scope_bound,resolve_goal,resolve_target_id) VALUES('candidate-ctx','candidate-attempt','alice','r1','p1','input-hash','attempted','{}','2026-10-14T00:00:00Z',1,'OLD_RESTRICTED_BODY','doc-private')`); err != nil {
@@ -315,7 +315,7 @@ func TestBackupAndMigrateRejectPendingOrMissingContextScrubMarker(t *testing.T)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`DELETE FROM schema_migrations WHERE version=40`); err != nil {
if _, err := db.Exec(`DELETE FROM schema_migrations WHERE version=41`); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
+6 -43
View File
@@ -31,7 +31,6 @@ import (
"agent-native/runtime/telemetry"
agenttools "agent-native/runtime/tools"
nativeadp "agent-squad/adapters/native"
"agent-system-backend/internal/blobstore"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
@@ -99,6 +98,8 @@ type Options struct {
Tier string
// PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。
PGPool store.PoolOptions
// AdoptInstanceGuard 在数据库被替换或锁文件被重建后,显式重写守卫记录。
AdoptInstanceGuard bool
}
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
@@ -177,49 +178,11 @@ func Run(opt Options) error {
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
}
var st *store.Store
var err error
if opt.StorageDSN != "" {
artifacts := opt.ArtifactsDir
if artifacts == "" {
artifacts = filepath.Join(opt.DataDir, "artifacts")
}
// 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享);
// 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。
blobs, berr := blobstore.NewS3FromEnv()
if berr != nil {
return fmt.Errorf("blobstore s3: %w", berr)
}
if blobs != nil {
log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix)
}
st, err = store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs)
if err != nil && opt.Tier == "team" {
// team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。
return fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err)
}
} else {
st, err = store.OpenSQLite(filepath.Join(opt.DataDir, "agent-system.db"))
}
st, releaseStore, err := openServiceStore(opt)
if err != nil {
return fmt.Errorf("open store: %w", err)
}
defer st.Close()
// U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" {
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
if dbref == "" {
if opt.StorageDSN != "" {
dbref = "pg"
} else {
dbref = "sqlite"
}
}
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
return fmt.Errorf("system identity: %w", err)
}
return err
}
defer releaseStore()
pool := st.Pool()
if versions, verr := st.MigrationStatus(); verr == nil {
log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)",
@@ -654,7 +617,7 @@ func Run(opt Options) error {
return err
}
contextSlot := &projectContextSlot{}
caps := hostcaps{projects: projectSlot, workObserver: workObserverSlot, contexts: contextSlot, projectAudit: &projectAuditSlot{}, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn,
caps := hostcaps{projects: projectSlot, workObserver: workObserverSlot, contexts: contextSlot, projectAudit: &projectAuditSlot{}, directory: directorySlot, regulations: &regulationsSlot{}, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn,
accountGuard: func(owner string) error {
valid, err := effectiveAccount(context.Background(), owner)
if err != nil {
+19
View File
@@ -129,6 +129,17 @@ func (r pluginRegistry) RegisterAccountDirectoryScopeResolver(fn extension.Accou
return r.caps.directory.register(r.pluginID, fn)
}
func (r compiledRegistry) RegisterRegulationsProvider(extension.RegulationsProvider) error {
return errors.New("extension: declarative plugins cannot register a regulations provider")
}
func (r pluginRegistry) RegisterRegulationsProvider(p extension.RegulationsProvider) error {
if r.caps.regulations == nil {
return errors.New("extension: regulations provider registration unavailable")
}
return r.caps.regulations.register(r.pluginID, p)
}
// RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
@@ -171,6 +182,14 @@ func (r recordRegistry) RegisterAccountDirectoryScopeResolver(fn extension.Accou
return registrar.RegisterAccountDirectoryScopeResolver(fn)
}
func (r recordRegistry) RegisterRegulationsProvider(p extension.RegulationsProvider) error {
registrar, ok := r.Registry.(extension.RegulationsRegistrar)
if !ok {
return errors.New("extension: regulations provider registration unavailable")
}
return registrar.RegisterRegulationsProvider(p)
}
func (r recordRegistry) AddCommand(spec json.RawMessage) error {
if err := r.Registry.AddCommand(spec); err != nil {
return err
+201
View File
@@ -0,0 +1,201 @@
//go:build unix
package app
import (
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"agent-system-backend/internal/instanceguard"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func TestSecondProcessRejectedBeforeOpen(t *testing.T) {
if os.Getenv("U8_APP_ROLE") == "hold" {
holdService(t)
return
}
dir := t.TempDir()
ready := filepath.Join(dir, "ready")
cmd := exec.Command(os.Args[0], "-test.run=TestSecondProcessRejectedBeforeOpen")
cmd.Env = append(os.Environ(), "U8_APP_ROLE=hold", "U8_APP_DIR="+dir, "U8_APP_READY="+ready)
cmd.Dir = t.TempDir()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
})
waitReady(t, ready)
_, _, err := openServiceStore(Options{DataDir: dir, Addr: ":1"})
if !errors.Is(err, instanceguard.ErrBusy) {
t.Fatalf("second open: %v", err)
}
if err := Migrate(Options{DataDir: dir}); !errors.Is(err, instanceguard.ErrBusy) {
t.Fatalf("migrate while held: %v", err)
}
cli, err := store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
run, err := cli.GetRunByID("run_u8_hold")
if err != nil {
t.Fatal(err)
}
if run.Status != core.RunWaitingHITL {
t.Fatalf("run changed while second process was refused: %s", run.Status)
}
if _, err := cli.CreateProject("owner", "short"); err != nil {
t.Fatalf("account-style write while service holds the guard: %v", err)
}
if err := cli.Close(); err != nil {
t.Fatal(err)
}
if err := cmd.Process.Kill(); err != nil {
t.Fatal(err)
}
_, _ = cmd.Process.Wait()
st, release, err := openServiceStore(Options{DataDir: dir, Addr: ":2"})
if err != nil {
t.Fatal(err)
}
defer release()
run, err = st.GetRunByID("run_u8_hold")
if err != nil {
t.Fatal(err)
}
if run.Status != core.RunWaitingHITL {
t.Fatalf("restart reconciled a run the second process never opened: %s", run.Status)
}
}
func TestCopiedBinaryStillSharesGuard(t *testing.T) {
if os.Getenv("U8_APP_ROLE") == "copy-hold" {
holdService(t)
return
}
bin, err := os.Executable()
if err != nil {
t.Fatal(err)
}
copyPath := filepath.Join(t.TempDir(), "agent-system-test-copy")
raw, err := os.ReadFile(bin)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(copyPath, raw, 0o700); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
ready := filepath.Join(dir, "ready")
cmd := exec.Command(copyPath, "-test.run=TestCopiedBinaryStillSharesGuard")
cmd.Env = append(os.Environ(), "U8_APP_ROLE=copy-hold", "U8_APP_DIR="+dir, "U8_APP_READY="+ready)
cmd.Dir = t.TempDir()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
})
waitReady(t, ready)
if _, _, err := openServiceStore(Options{DataDir: dir}); !errors.Is(err, instanceguard.ErrBusy) {
t.Fatalf("copied binary: %v", err)
}
}
func holdService(t *testing.T) {
t.Helper()
dir := os.Getenv("U8_APP_DIR")
st, _, err := openServiceStore(Options{DataDir: dir})
if err != nil {
t.Fatal(err)
}
run := &core.Run{
ID: "run_u8_hold", ConversationID: "c", ProjectID: "p", Owner: "o",
Kind: core.RunConversation, Status: core.RunWaitingHITL,
}
if err := st.CreateRun(run); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(os.Getenv("U8_APP_READY"), []byte("ready"), 0o600); err != nil {
t.Fatal(err)
}
time.Sleep(30 * time.Second)
}
func waitReady(t *testing.T, ready string) {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(ready); err == nil {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("child did not become ready")
}
func TestAdoptDoesNotCreateMissingDatabase(t *testing.T) {
dir := t.TempDir()
_, release, err := openServiceStore(Options{DataDir: dir})
if err != nil {
t.Fatal(err)
}
release()
db := filepath.Join(dir, "agent-system.db")
if err := os.Remove(db); err != nil {
t.Fatal(err)
}
_, _, err = openServiceStore(Options{DataDir: dir, AdoptInstanceGuard: true})
if !errors.Is(err, instanceguard.ErrDBMissing) {
t.Fatalf("adopt missing: %v", err)
}
if _, err := os.Stat(db); !os.IsNotExist(err) {
t.Fatalf("adopt created a database: %v", err)
}
}
func TestIdentityFailureDoesNotStick(t *testing.T) {
dir := t.TempDir()
t.Setenv("AGENT_SYSTEM_INSTANCE_ID", "wrong")
if _, _, err := openServiceStore(Options{DataDir: dir}); err == nil {
t.Fatal("missing identity row was accepted")
}
t.Setenv("AGENT_SYSTEM_INSTANCE_ID", "")
st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
marker, ok, err := st.LoadInstanceGuard()
if err != nil || ok || marker.InstanceID != "" {
t.Fatalf("failed start wrote a guard id: ok=%v id=%q err=%v", ok, marker.InstanceID, err)
}
if err := st.EnsureSystemIdentity("right", "sqlite"); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
t.Setenv("AGENT_SYSTEM_INSTANCE_ID", "right")
opened, release, err := openServiceStore(Options{DataDir: dir})
if err != nil {
t.Fatal(err)
}
release()
_ = opened
}
+1
View File
@@ -28,6 +28,7 @@ type hostcaps struct {
directory *accountDirectorySlot
contexts *projectContextSlot
projectAudit *projectAuditSlot
regulations *regulationsSlot // EXT-20 法规提供方槽(共享指针;nil/未注册=不可用)
accountGuard func(string) error
allowImplicitLocal bool
st *store.Store
+220
View File
@@ -0,0 +1,220 @@
package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"sync"
"unicode/utf8"
"agent-system-backend/pkg/extension"
)
// EXT-20:共享法规检索与网络证据。提供方由受信插件 kbreg 进程内注册;基座只
// 填写调用方身份、做查询最小化校验并审计,不改写提供方返回的状态。
const (
regulationsPluginID = "kbreg"
maxWebQueryRunes = 256
auditRegulationsSearch = "regulations.search"
auditRegulationsEvidence = "regulations.evidence"
auditWebSearch = "web.search"
auditWebCapture = "web.capture"
regulationsAuditInvalid = "invalid"
regulationsAuditError = "error"
regulationsAuditOK = "ok"
)
var (
errRegulationsSchema = errors.New("extension: unsupported regulations schema version")
errWebQueryInvalid = errors.New("extension: web search query must be 1-256 characters")
)
type regulationsSlot struct {
mu sync.RWMutex
provider extension.RegulationsProvider
}
func (s *regulationsSlot) register(pluginID string, p extension.RegulationsProvider) error {
if p == nil || pluginID != regulationsPluginID {
return errors.New("extension: regulations provider requires trusted kbreg registration")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.provider != nil {
return errors.New("extension: regulations provider already registered")
}
s.provider = p
return nil
}
func (s *regulationsSlot) get() extension.RegulationsProvider {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.provider
}
// regulationsAudit is the only content written to the audit trail: never the
// query text, excerpts or captured page text.
type regulationsAudit struct {
Revision string `json:"revision,omitempty"`
Count int `json:"count"`
State string `json:"state"`
QuerySHA256 string `json:"querySha256,omitempty"`
}
func regulationsQueryDigest(query string) string {
if query == "" {
return ""
}
sum := sha256.Sum256([]byte(query))
return hex.EncodeToString(sum[:])
}
// regulations resolves the provider and the trusted caller. Without an audit
// store the capability is unavailable: calls must never run unaudited.
func (h inprocHost) regulations() (extension.RegulationsProvider, extension.RegulationsCaller, error) {
if err := h.requireEffective(); err != nil {
return nil, extension.RegulationsCaller{}, err
}
p := h.caps.regulations.get()
if p == nil || h.caps.st == nil {
return nil, extension.RegulationsCaller{}, extension.ErrRegulationsUnavailable
}
return p, extension.RegulationsCaller{PluginID: h.pluginID, Actor: h.actor, Owner: h.owner}, nil
}
// auditRegulations appends one event per call. A call error takes precedence;
// otherwise an audit failure withholds the result.
func (h inprocHost) auditRegulations(action string, rec regulationsAudit, callErr error) error {
if callErr != nil && rec.State == "" {
rec.State = regulationsAuditError
}
evidence, err := json.Marshal(rec)
if err == nil {
err = h.Audit(action, regulationsPluginID, string(evidence))
}
if callErr != nil {
return callErr
}
return err
}
func (h inprocHost) SearchRegulations(ctx context.Context, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) {
p, caller, err := h.regulations()
if err != nil {
return extension.RegulationsSearchResult{}, err
}
rec := regulationsAudit{Revision: req.CorpusRevision, QuerySHA256: regulationsQueryDigest(req.Query)}
if req.SchemaVersion != extension.RegulationsSchemaVersion {
rec.State = regulationsAuditInvalid
return extension.RegulationsSearchResult{}, h.auditRegulations(auditRegulationsSearch, rec, errRegulationsSchema)
}
res, err := p.SearchRegulations(ctx, caller, req)
if err == nil {
rec.Revision, rec.Count, rec.State = res.CorpusRevision, len(res.Hits), res.State
}
if err := h.auditRegulations(auditRegulationsSearch, rec, err); err != nil {
return extension.RegulationsSearchResult{}, err
}
return res, nil
}
func (h inprocHost) ResolveRegulationEvidence(ctx context.Context, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) {
p, caller, err := h.regulations()
if err != nil {
return extension.RegulationEvidence{}, err
}
rec := regulationsAudit{Revision: req.CorpusRevision}
if req.SchemaVersion != extension.RegulationsSchemaVersion {
rec.State = regulationsAuditInvalid
return extension.RegulationEvidence{}, h.auditRegulations(auditRegulationsEvidence, rec, errRegulationsSchema)
}
ev, err := p.ResolveRegulationEvidence(ctx, caller, req)
if err == nil {
rec.Count, rec.State = 1, regulationsAuditOK
if ev.CorpusRevision != "" {
rec.Revision = ev.CorpusRevision
}
}
if err := h.auditRegulations(auditRegulationsEvidence, rec, err); err != nil {
return extension.RegulationEvidence{}, err
}
return ev, nil
}
// webQueryExcluded reports whether query contains any non-empty exclusion
// term, case-insensitively.
func webQueryExcluded(query string, exclusions []string) bool {
lower := strings.ToLower(query)
for _, term := range exclusions {
term = strings.ToLower(strings.TrimSpace(term))
if term != "" && strings.Contains(lower, term) {
return true
}
}
return false
}
func (h inprocHost) SearchWeb(ctx context.Context, req extension.WebSearchRequest) (extension.WebSearchResult, error) {
p, caller, err := h.regulations()
if err != nil {
return extension.WebSearchResult{}, err
}
req.Query = strings.TrimSpace(req.Query)
rec := regulationsAudit{QuerySHA256: regulationsQueryDigest(req.Query)}
if req.SchemaVersion != extension.RegulationsSchemaVersion {
rec.State = regulationsAuditInvalid
return extension.WebSearchResult{}, h.auditRegulations(auditWebSearch, rec, errRegulationsSchema)
}
if req.Query == "" || !utf8.ValidString(req.Query) || utf8.RuneCountInString(req.Query) > maxWebQueryRunes {
rec.State = regulationsAuditInvalid
return extension.WebSearchResult{}, h.auditRegulations(auditWebSearch, rec, errWebQueryInvalid)
}
if webQueryExcluded(req.Query, req.Exclusions) {
res := extension.WebSearchResult{State: extension.WebSearchStateRejected, Reason: "query contains an excluded term"}
rec.State = res.State
if err := h.auditRegulations(auditWebSearch, rec, nil); err != nil {
return extension.WebSearchResult{}, err
}
return res, nil
}
res, err := p.SearchWeb(ctx, caller, req)
if err == nil {
rec.Count, rec.State = len(res.Results), res.State
}
if err := h.auditRegulations(auditWebSearch, rec, err); err != nil {
return extension.WebSearchResult{}, err
}
return res, nil
}
func (h inprocHost) CaptureWeb(ctx context.Context, req extension.WebCaptureRequest) (extension.WebCapture, error) {
p, caller, err := h.regulations()
if err != nil {
return extension.WebCapture{}, err
}
rec := regulationsAudit{QuerySHA256: regulationsQueryDigest(strings.TrimSpace(req.SearchQuery))}
if req.SchemaVersion != extension.RegulationsSchemaVersion {
rec.State = regulationsAuditInvalid
return extension.WebCapture{}, h.auditRegulations(auditWebCapture, rec, errRegulationsSchema)
}
capture, err := p.CaptureWeb(ctx, caller, req)
if err == nil {
rec.Count, rec.State = len(capture.Candidates), capture.State
}
if err := h.auditRegulations(auditWebCapture, rec, err); err != nil {
return extension.WebCapture{}, err
}
return capture, nil
}
var _ extension.RegulationsHost = inprocHost{}
+134
View File
@@ -0,0 +1,134 @@
package app
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
// compatRegulationsPlugin stands in for the external kbreg repository: it uses
// only pkg/extension types and registers through the optional Registrar.
type compatRegulationsPlugin struct{ seen []extension.RegulationsCaller }
func (p *compatRegulationsPlugin) Register(reg extension.Registry) error {
registrar, ok := reg.(extension.RegulationsRegistrar)
if !ok {
return errors.New("regulations registrar unavailable")
}
return registrar.RegisterRegulationsProvider(p)
}
func (p *compatRegulationsPlugin) SearchRegulations(_ context.Context, c extension.RegulationsCaller, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) {
p.seen = append(p.seen, c)
return extension.RegulationsSearchResult{CorpusRevision: "rev-compat", State: extension.RegulationsStateOK,
Hits: []extension.RegulationHit{{RegulationID: "r", EditionID: "e", NodeID: "n", Disclosure: extension.RegulationDisclosureExcerpt}}}, nil
}
func (p *compatRegulationsPlugin) ResolveRegulationEvidence(_ context.Context, c extension.RegulationsCaller, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) {
p.seen = append(p.seen, c)
return extension.RegulationEvidence{CorpusRevision: req.CorpusRevision, NodeID: req.NodeID, Disclosure: extension.RegulationDisclosureFull, HashVerified: true}, nil
}
func (p *compatRegulationsPlugin) SearchWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebSearchRequest) (extension.WebSearchResult, error) {
p.seen = append(p.seen, c)
return extension.WebSearchResult{Provider: "compat", SentQuery: req.Query, State: extension.WebSearchStateEmpty}, nil
}
func (p *compatRegulationsPlugin) CaptureWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebCaptureRequest) (extension.WebCapture, error) {
p.seen = append(p.seen, c)
return extension.WebCapture{FinalURL: req.URL, State: extension.WebCaptureStateFailed, Reason: "offline"}, nil
}
// compatRegulationsConsumer is a second compiled plugin that reaches the
// provider only through the dispatch Host type assertion.
func compatRegulationsConsumer(reg extension.Registry) error {
return reg.AddCommandHandler(json.RawMessage(`{"id":"consumer.regs","title":"regs"}`),
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
regs, ok := h.(extension.RegulationsHost)
if !ok {
return nil, errors.New("regulations host capability missing")
}
search, err := regs.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "注册检验"})
if err != nil {
return nil, err
}
web, err := regs.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "注册检验 指导原则"})
if err != nil {
return nil, err
}
return map[string]any{"revision": search.CorpusRevision, "webState": web.State}, nil
})
}
func newRegulationsCompatApp(t *testing.T, plugins ...extension.InProc) (*cmdbus.Bus, *store.Store, hostcaps) {
t.Helper()
extension.ResetCompiled()
t.Cleanup(extension.ResetCompiled)
for _, p := range plugins {
extension.RegisterCompiled(p)
}
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "compat.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
caps := hostcaps{st: st, regulations: &regulationsSlot{}}
applyCompiled("", bus, caps)
return bus, st, caps
}
func TestRegulationsCompiledPluginCompatibility(t *testing.T) {
provider := &compatRegulationsPlugin{}
impostor := &compatRegulationsPlugin{}
bus, st, caps := newRegulationsCompatApp(t,
extension.InProc{ID: "impostor", Register: impostor.Register},
extension.InProc{ID: "kbreg", Register: provider.Register},
extension.InProc{ID: "consumer", Register: compatRegulationsConsumer},
)
if caps.regulations.get() != extension.RegulationsProvider(provider) {
t.Fatal("compiled kbreg provider was not the registered provider")
}
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "consumer.regs", Owner: "alice", Actor: "alice", Entrance: "button"})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("dispatch = %+v", resp.Receipt)
}
payload, _ := resp.Payload.(map[string]any)
if payload["revision"] != "rev-compat" || payload["webState"] != extension.WebSearchStateEmpty {
t.Fatalf("payload = %+v", resp.Payload)
}
want := extension.RegulationsCaller{PluginID: "consumer", Actor: "alice", Owner: "alice"}
if len(provider.seen) != 2 || provider.seen[0] != want || provider.seen[1] != want || len(impostor.seen) != 0 {
t.Fatalf("provider callers = %+v impostor = %+v", provider.seen, impostor.seen)
}
evs, err := st.ListAuditEvents("alice", 20)
if err != nil {
t.Fatal(err)
}
actions := map[string]bool{}
for _, ev := range evs {
actions[ev.Action] = true
}
if !actions[auditRegulationsSearch] || !actions[auditWebSearch] {
t.Fatalf("host audit missing: %+v", evs)
}
}
func TestRegulationsCompiledConsumerWithoutProvider(t *testing.T) {
bus, _, _ := newRegulationsCompatApp(t, extension.InProc{ID: "consumer", Register: compatRegulationsConsumer})
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "consumer.regs", Owner: "alice", Actor: "alice", Entrance: "button"})
if resp.Receipt.State == agentcommand.StateCommitted {
t.Fatalf("consumer succeeded without a regulations provider: %+v", resp.Payload)
}
}
+347
View File
@@ -0,0 +1,347 @@
package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
type fakeRegulationsProvider struct {
callers []extension.RegulationsCaller
webQueries []string
search extension.RegulationsSearchResult
evidence extension.RegulationEvidence
web extension.WebSearchResult
capture extension.WebCapture
err error
}
func (f *fakeRegulationsProvider) SearchRegulations(_ context.Context, c extension.RegulationsCaller, _ extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) {
f.callers = append(f.callers, c)
return f.search, f.err
}
func (f *fakeRegulationsProvider) ResolveRegulationEvidence(_ context.Context, c extension.RegulationsCaller, _ extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) {
f.callers = append(f.callers, c)
return f.evidence, f.err
}
func (f *fakeRegulationsProvider) SearchWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebSearchRequest) (extension.WebSearchResult, error) {
f.callers = append(f.callers, c)
f.webQueries = append(f.webQueries, req.Query)
return f.web, f.err
}
func (f *fakeRegulationsProvider) CaptureWeb(_ context.Context, c extension.RegulationsCaller, _ extension.WebCaptureRequest) (extension.WebCapture, error) {
f.callers = append(f.callers, c)
return f.capture, f.err
}
func newRegulationsTestHost(t *testing.T) (inprocHost, *store.Store) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "regulations.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
caps := hostcaps{st: st, regulations: &regulationsSlot{}}
return inprocHost{caps: caps, pluginID: "rdm", owner: "alice", actor: "alice"}, st
}
func registerFakeRegulations(t *testing.T, h inprocHost) *fakeRegulationsProvider {
t.Helper()
p := &fakeRegulationsProvider{}
if err := (pluginRegistry{pluginID: "kbreg", caps: h.caps}).RegisterRegulationsProvider(p); err != nil {
t.Fatal(err)
}
return p
}
const regV = extension.RegulationsSchemaVersion
func callAllRegulations(h inprocHost, version int) []error {
ctx := context.Background()
_, e1 := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: version, Query: "医疗器械 临床评价"})
_, e2 := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: version, CorpusRevision: "rev-1", NodeID: "n1"})
_, e3 := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: version, Query: "临床评价 指导原则"})
_, e4 := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: version, URL: "https://www.nmpa.gov.cn/x", SearchQuery: "临床评价 指导原则"})
return []error{e1, e2, e3, e4}
}
func TestRegulationsUnavailableWithoutProvider(t *testing.T) {
h, st := newRegulationsTestHost(t)
for i, err := range callAllRegulations(h, regV) {
if !errors.Is(err, extension.ErrRegulationsUnavailable) {
t.Fatalf("method %d without provider: %v", i, err)
}
}
h.caps.regulations = nil
for i, err := range callAllRegulations(h, regV) {
if !errors.Is(err, extension.ErrRegulationsUnavailable) {
t.Fatalf("method %d without slot: %v", i, err)
}
}
if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 0 {
t.Fatalf("unavailable calls audited: %+v %v", evs, err)
}
}
func TestRegulationsRegistrationOnlyTrustedKbregOnce(t *testing.T) {
h, _ := newRegulationsTestHost(t)
first, second := &fakeRegulationsProvider{}, &fakeRegulationsProvider{}
if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterRegulationsProvider(first); err == nil {
t.Fatal("non-kbreg plugin registered a regulations provider")
}
kbreg := pluginRegistry{pluginID: "kbreg", caps: h.caps}
if err := kbreg.RegisterRegulationsProvider(nil); err == nil {
t.Fatal("nil provider registered")
}
if err := kbreg.RegisterRegulationsProvider(first); err != nil {
t.Fatal(err)
}
if err := kbreg.RegisterRegulationsProvider(second); err == nil {
t.Fatal("duplicate registration accepted")
}
if h.caps.regulations.get() != first {
t.Fatal("duplicate registration replaced the first provider")
}
if err := (pluginRegistry{pluginID: "kbreg"}).RegisterRegulationsProvider(first); err == nil {
t.Fatal("registration without a host slot accepted")
}
}
func TestRegulationsDeclarativeRegistrationRejected(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := &fakeRegulationsProvider{}
if err := (compiledRegistry{}).RegisterRegulationsProvider(p); err == nil {
t.Fatal("declarative registry accepted a regulations provider")
}
if err := (recordRegistry{Registry: pluginRegistry{pluginID: "kbreg", caps: h.caps}}).RegisterRegulationsProvider(p); err != nil {
t.Fatalf("record registry did not forward kbreg registration: %v", err)
}
if h.caps.regulations.get() != p {
t.Fatal("forwarded registration did not fill the slot")
}
}
func TestRegulationsCallerFilledFromDispatchContext(t *testing.T) {
h, _ := newRegulationsTestHost(t)
h.actor = "alice-session"
p := registerFakeRegulations(t, h)
p.web.State = extension.WebSearchStateOK
for i, err := range callAllRegulations(h, regV) {
if err != nil {
t.Fatalf("method %d: %v", i, err)
}
}
want := extension.RegulationsCaller{PluginID: "rdm", Actor: "alice-session", Owner: "alice"}
if len(p.callers) != 4 {
t.Fatalf("provider calls = %d", len(p.callers))
}
for i, got := range p.callers {
if got != want {
t.Fatalf("call %d caller = %+v, want %+v", i, got, want)
}
}
}
func TestRegulationsRejectsOtherSchemaVersions(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
for _, version := range []int{0, regV + 1} {
for i, err := range callAllRegulations(h, version) {
if !errors.Is(err, errRegulationsSchema) {
t.Fatalf("schema %d method %d: %v", version, i, err)
}
}
}
if len(p.callers) != 0 {
t.Fatalf("provider reached with unsupported schema: %d calls", len(p.callers))
}
}
func TestRegulationsWebQueryBounds(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
ctx := context.Background()
for _, q := range []string{"", " \t\n", strings.Repeat("法", maxWebQueryRunes+1)} {
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: q}); !errors.Is(err, errWebQueryInvalid) {
t.Fatalf("query of %d runes accepted: %v", len([]rune(q)), err)
}
}
if len(p.callers) != 0 {
t.Fatal("provider reached with an invalid query")
}
maxQuery := strings.Repeat("法", maxWebQueryRunes)
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: " " + maxQuery + " "}); err != nil {
t.Fatalf("256-rune query rejected: %v", err)
}
if len(p.webQueries) != 1 || p.webQueries[0] != maxQuery {
t.Fatalf("provider query was not trimmed: %q", p.webQueries)
}
}
func TestRegulationsExclusionRejectedBeforeProvider(t *testing.T) {
h, st := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
ctx := context.Background()
query := "Project-Falcon 临床评价 指导原则"
res, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: query,
Exclusions: []string{"", " ", "project-falcon"}})
if err != nil || res.State != extension.WebSearchStateRejected || res.SentQuery != "" || len(res.Results) != 0 {
t.Fatalf("excluded query result = %+v %v", res, err)
}
if len(p.callers) != 0 {
t.Fatal("provider received a query containing an excluded term")
}
evs, err := st.ListAuditEvents("alice", 10)
if err != nil || len(evs) != 1 || evs[0].Action != auditWebSearch {
t.Fatalf("rejection audit: %+v %v", evs, err)
}
if strings.Contains(strings.ToLower(evs[0].Evidence), "falcon") || !strings.Contains(evs[0].Evidence, `"state":"rejected"`) {
t.Fatalf("rejection audit evidence = %s", evs[0].Evidence)
}
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: query, Exclusions: []string{"", " "}}); err != nil {
t.Fatal(err)
}
if len(p.callers) != 1 {
t.Fatal("blank exclusions blocked an allowed query")
}
}
func TestRegulationsProviderStatesPassThroughUnchanged(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
at := time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-7", State: extension.RegulationsStateDegraded,
DegradedReasons: []string{"vector_index_unavailable"},
Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1",
Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights"},
{RegulationID: "r2", EditionID: "e2", NodeID: "n2", Disclosure: extension.RegulationDisclosureMetadataOnly}}}
p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-7", NodeID: "n1",
Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", HashVerified: false}
p.web = extension.WebSearchResult{Provider: "fake", SentQuery: "q", SearchedAt: at,
State: extension.WebSearchStateRateLimited, Reason: "quota"}
p.capture = extension.WebCapture{CaptureID: "c1", FetchedAt: at, Retention: extension.WebRetentionMetadataOnly,
State: extension.WebCaptureStateRejected, Reason: "robots"}
ctx := context.Background()
search, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "q"})
if err != nil || !reflect.DeepEqual(search, p.search) {
t.Fatalf("search = %+v %v", search, err)
}
evidence, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-7", NodeID: "n1"})
if err != nil || !reflect.DeepEqual(evidence, p.evidence) {
t.Fatalf("evidence = %+v %v", evidence, err)
}
web, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: "q"})
if err != nil || !reflect.DeepEqual(web, p.web) {
t.Fatalf("web = %+v %v", web, err)
}
p.web.State = extension.WebSearchStateRejected
web, err = h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: "q"})
if err != nil || web.State != extension.WebSearchStateRejected {
t.Fatalf("provider rejection rewritten: %+v %v", web, err)
}
capture, err := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: regV, URL: "https://example.org"})
if err != nil || !reflect.DeepEqual(capture, p.capture) {
t.Fatalf("capture = %+v %v", capture, err)
}
p.err = errors.New("provider failure")
if _, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "q"}); !errors.Is(err, p.err) {
t.Fatalf("provider failure wrapped as success: %v", err)
}
}
func TestRegulationsAuditEvidenceOmitsQueryAndText(t *testing.T) {
h, st := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
const (
secretQuery = "第十七条 secret-query-text"
secretExcerpt = "SECRET-EXCERPT 原文片段"
secretPage = "SECRET-PAGE 网页全文"
)
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK,
Hits: []extension.RegulationHit{{NodeID: "n1", Excerpt: secretExcerpt, Disclosure: extension.RegulationDisclosureExcerpt}}}
p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-9", NodeID: "n1", Excerpt: secretExcerpt,
Disclosure: extension.RegulationDisclosureFull, HashVerified: true}
p.web = extension.WebSearchResult{SentQuery: secretQuery, State: extension.WebSearchStateOK,
Results: []extension.WebSearchHit{{Rank: 1, URL: "https://example.org", Snippet: secretExcerpt}}}
p.capture = extension.WebCapture{State: extension.WebCaptureStateCaptured, Retention: extension.WebRetentionFull,
Text: secretPage, Candidates: []extension.WebClauseCandidate{{Excerpt: secretExcerpt}}}
ctx := context.Background()
if _, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: secretQuery}); err != nil {
t.Fatal(err)
}
if _, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-9", NodeID: "n1"}); err != nil {
t.Fatal(err)
}
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: " " + secretQuery + " "}); err != nil {
t.Fatal(err)
}
if _, err := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: regV, URL: "https://example.org", SearchQuery: secretQuery}); err != nil {
t.Fatal(err)
}
evs, err := st.ListAuditEvents("alice", 10)
if err != nil || len(evs) != 4 {
t.Fatalf("audit events = %+v %v", evs, err)
}
sum := sha256.Sum256([]byte(secretQuery))
digest := hex.EncodeToString(sum[:])
want := map[string]regulationsAudit{
auditRegulationsSearch: {Revision: "rev-9", Count: 1, State: "ok", QuerySHA256: digest},
auditRegulationsEvidence: {Revision: "rev-9", Count: 1, State: "ok"},
auditWebSearch: {Count: 1, State: "ok", QuerySHA256: digest},
auditWebCapture: {Count: 1, State: "captured", QuerySHA256: digest},
}
for _, ev := range evs {
for _, secret := range []string{"secret-query-text", "第十七条", "SECRET-EXCERPT", "原文片段", "SECRET-PAGE", "网页全文"} {
if strings.Contains(ev.Evidence, secret) || strings.Contains(ev.Target, secret) {
t.Fatalf("%s audit leaks %q: %s", ev.Action, secret, ev.Evidence)
}
}
var fields map[string]any
if err := json.Unmarshal([]byte(ev.Evidence), &fields); err != nil {
t.Fatal(err)
}
for k := range fields {
if k != "revision" && k != "count" && k != "state" && k != "querySha256" {
t.Fatalf("%s audit has unexpected field %q", ev.Action, k)
}
}
var got regulationsAudit
if err := json.Unmarshal([]byte(ev.Evidence), &got); err != nil {
t.Fatal(err)
}
if w, ok := want[ev.Action]; !ok || got != w || ev.Owner != "alice" {
t.Fatalf("%s audit = %+v owner=%s, want %+v", ev.Action, got, ev.Owner, w)
}
delete(want, ev.Action)
}
}
func TestRegulationsRequireEffectiveAccount(t *testing.T) {
h, _ := newRegulationsTestHost(t)
p := registerFakeRegulations(t, h)
h.caps.accountGuard = func(string) error { return store.ErrAccountForbidden }
for i, err := range callAllRegulations(h, regV) {
if !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("method %d ignored the account guard: %v", i, err)
}
}
if len(p.callers) != 0 {
t.Fatal("provider reached for an ineffective account")
}
}
+111
View File
@@ -0,0 +1,111 @@
package app
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"agent-system-backend/internal/blobstore"
"agent-system-backend/internal/instanceguard"
"agent-system-backend/internal/store"
)
// openServiceStore 打开活动服务使用的存储。
// SQLite 档先取活动服务锁,再打开(打开即迁移),然后核对守卫标记。
// 返回的函数先关存储再放锁。
func openServiceStore(opt Options) (*store.Store, func(), error) {
nop := func() {}
if opt.StorageDSN != "" {
artifacts := opt.ArtifactsDir
if artifacts == "" {
artifacts = filepath.Join(opt.DataDir, "artifacts")
}
// 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享);
// 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。
blobs, berr := blobstore.NewS3FromEnv()
if berr != nil {
return nil, nop, fmt.Errorf("blobstore s3: %w", berr)
}
if blobs != nil {
log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix)
}
st, err := store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs)
if err != nil && opt.Tier == "team" {
// team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。
return nil, nop, fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err)
}
if err != nil {
return nil, nop, fmt.Errorf("open store: %w", err)
}
if err := verifyConfiguredIdentity(st, "pg"); err != nil {
_ = st.Close()
return nil, nop, err
}
return st, func() { _ = st.Close() }, nil
}
lease, err := instanceguard.Acquire(opt.DataDir)
if err != nil {
return nil, nop, fmt.Errorf("instance guard: %w", err)
}
releaseLease := func() { lease.Release() }
if err := lease.AutomaticRefusal(); err != nil {
adoptable := opt.AdoptInstanceGuard && (errors.Is(err, instanceguard.ErrDBReplaced) || errors.Is(err, instanceguard.ErrLockCorrupt))
if !adoptable {
releaseLease()
return nil, nop, fmt.Errorf("instance guard: %w", err)
}
}
st, err := store.OpenSQLite(lease.DBPath())
if err != nil {
releaseLease()
return nil, nop, fmt.Errorf("open store: %w", err)
}
if err := verifyConfiguredIdentity(st, "sqlite"); err != nil {
_ = st.Close()
releaseLease()
return nil, nop, err
}
if err := instanceguard.Bind(st, lease, instanceguard.BindOptions{
Adopt: opt.AdoptInstanceGuard,
InstanceID: strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")),
}); err != nil {
_ = st.Close()
releaseLease()
return nil, nop, fmt.Errorf("instance guard: %w", err)
}
return st, func() {
_ = st.Close()
lease.Release()
}, nil
}
// verifyConfiguredIdentity U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
func verifyConfiguredIdentity(st *store.Store, defaultRef string) error {
sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID"))
if sysID == "" {
return nil
}
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
if dbref == "" {
dbref = defaultRef
}
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
return fmt.Errorf("system identity: %w", err)
}
return nil
}
// Migrate 执行显式迁移。SQLite 必须先取得活动服务锁,有服务占用则拒绝。
func Migrate(opt Options) error {
_, release, err := openServiceStore(opt)
if err != nil {
return err
}
release()
return nil
}
+199
View File
@@ -0,0 +1,199 @@
// Package instanceguard 强制同一 SQLite 数据文件只有一个活动服务。
// 锁在打开数据库(迁移发生在打开内部)之前取得。账户 CLI 不使用本包。
package instanceguard
import (
"crypto/rand"
"encoding/hex"
"errors"
"strconv"
"strings"
"agent-system-backend/internal/store"
)
var (
ErrBusy = errors.New("instance guard: sqlite service already running")
ErrHardlink = errors.New("instance guard: sqlite database has a hardlink alias")
ErrDBReplaced = errors.New("instance guard: sqlite database replaced")
ErrDBMissing = errors.New("instance guard: recorded sqlite database is missing")
ErrLockRebuilt = errors.New("instance guard: lock file was rebuilt")
ErrLockCorrupt = errors.New("instance guard: lock record is corrupt")
ErrInstanceMismatch = errors.New("instance guard: instance id mismatch")
ErrUnsupported = errors.New("instance guard: sqlite activity lock is not available on this platform")
ErrLockAliased = errors.New("instance guard: lock file aliases the sqlite database")
)
// Record 是锁文件里记下的数据库身份。没有记录表示锁文件是新建的。
type Record struct {
DBDev uint64
DBIno uint64
InstanceID string
LockGen string
}
// Lease 持有活动服务锁,直到 Release。关闭顺序由调用方保证:先关存储,再 Release。
type Lease struct {
fd int
dbPath string
lockDev uint64
lockIno uint64
dbDev uint64
dbIno uint64
dbExists bool
record *Record
parseErr error
release func()
}
// Release 放开锁并关闭锁文件描述符。锁文件留在磁盘上。
func (l *Lease) Release() {
if l == nil || l.release == nil {
return
}
l.release()
l.release = nil
}
// DBPath 是解析后的数据库路径。符号链接指向真实文件。
func (l *Lease) DBPath() string {
if l == nil {
return ""
}
return l.dbPath
}
// AutomaticRefusal 在打开数据库之前调用。
// 尚无记录时返回 nil(首次建立,含旧库升级)。硬链接在 Acquire 阶段已经拒绝。
func (l *Lease) AutomaticRefusal() error {
if l.parseErr != nil {
return l.parseErr
}
if l.record == nil {
return nil
}
if !l.dbExists {
return ErrDBMissing
}
if l.record.DBDev != l.dbDev || l.record.DBIno != l.dbIno {
return ErrDBReplaced
}
return nil
}
// BindOptions 控制守卫建立后的比对。
type BindOptions struct {
Adopt bool
InstanceID string
}
// Bind 在数据库已经打开并完成迁移之后调用。
// 锁文件 inode 与表不一致则拒绝,除非 Adopt。通过时重写锁文件内容与表。
func Bind(st *store.Store, lease *Lease, opt BindOptions) error {
if lease == nil {
return errors.New("instance guard: nil lease")
}
marker, ok, err := st.LoadInstanceGuard()
if err != nil {
return err
}
if ok && !opt.Adopt {
inodeMismatch := marker.LockDev != lease.lockDev || marker.LockIno != lease.lockIno
genMismatch := marker.LockGen != "" && (lease.record == nil || lease.record.LockGen != marker.LockGen)
if inodeMismatch || genMismatch {
return ErrLockRebuilt
}
}
id := opt.InstanceID
if ok && marker.InstanceID != "" && id != "" && marker.InstanceID != id && !opt.Adopt {
return ErrInstanceMismatch
}
if id == "" && ok {
id = marker.InstanceID
}
dev, ino, exists, err := lease.currentDB()
if err != nil {
return err
}
if !exists {
return ErrDBMissing
}
lease.dbDev, lease.dbIno, lease.dbExists = dev, ino, true
gen := ""
if ok && marker.LockGen != "" && !opt.Adopt {
gen = marker.LockGen
} else {
gen, err = newLockGen()
if err != nil {
return err
}
}
next := store.InstanceGuardMarker{
DBDev: dev, DBIno: ino, LockDev: lease.lockDev, LockIno: lease.lockIno,
InstanceID: id, LockGen: gen,
}
if err := lease.writeRecord(dev, ino, id, gen); err != nil {
return err
}
if !ok || opt.Adopt || marker != next {
if err := st.SaveInstanceGuard(next); err != nil {
return err
}
}
return nil
}
func newLockGen() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func parseRecord(raw string) (*Record, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, "\t")
if parts[0] != "v1" {
return nil, ErrLockCorrupt
}
rec := &Record{}
seen := map[string]bool{}
for _, part := range parts[1:] {
key, val, ok := strings.Cut(part, "=")
if !ok || seen[key] || strings.ContainsAny(val, " \t") {
return nil, ErrLockCorrupt
}
seen[key] = true
switch key {
case "db_dev":
n, err := strconv.ParseUint(val, 10, 64)
if err != nil {
return nil, ErrLockCorrupt
}
rec.DBDev = n
case "db_ino":
n, err := strconv.ParseUint(val, 10, 64)
if err != nil {
return nil, ErrLockCorrupt
}
rec.DBIno = n
case "instance_id":
rec.InstanceID = val
case "lock_gen":
if val == "" {
return nil, ErrLockCorrupt
}
rec.LockGen = val
default:
return nil, ErrLockCorrupt
}
}
if !seen["db_dev"] || !seen["db_ino"] || !seen["instance_id"] {
return nil, ErrLockCorrupt
}
return rec, nil
}
+18
View File
@@ -0,0 +1,18 @@
//go:build !unix
package instanceguard
import "fmt"
// Acquire 在非 unix 平台拒绝 SQLite 活动服务。PostgreSQL 档不调用本函数。
func Acquire(string) (*Lease, error) {
return nil, ErrUnsupported
}
func (l *Lease) writeRecord(uint64, uint64, string, string) error {
return ErrUnsupported
}
func (l *Lease) currentDB() (uint64, uint64, bool, error) {
return 0, 0, false, fmt.Errorf("%w", ErrUnsupported)
}
+406
View File
@@ -0,0 +1,406 @@
//go:build unix
package instanceguard
import (
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"agent-system-backend/internal/store"
)
func TestAcquireSameResourceAcrossAliases(t *testing.T) {
base := t.TempDir()
real := filepath.Join(base, "real")
if err := os.Mkdir(real, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(base, "link")
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}
held, err := Acquire(link)
if err != nil {
t.Fatal(err)
}
defer held.Release()
if _, err := Acquire(real); !errors.Is(err, ErrBusy) {
t.Fatalf("symlink alias: %v", err)
}
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(base); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(wd) })
if _, err := Acquire(filepath.Join(".", "real")); !errors.Is(err, ErrBusy) {
t.Fatalf("relative path: %v", err)
}
}
func TestHardlinkRejected(t *testing.T) {
dir := t.TempDir()
db := filepath.Join(dir, "agent-system.db")
st, err := store.OpenSQLite(db)
if err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
if err := os.Link(db, filepath.Join(dir, "alias.db")); err != nil {
t.Fatal(err)
}
if _, err := Acquire(dir); !errors.Is(err, ErrHardlink) {
t.Fatalf("hardlink: %v", err)
}
}
func TestDatabaseReplacementRefusedBeforeOpen(t *testing.T) {
dir := t.TempDir()
st, release, err := openAndBind(t, dir, "")
if err != nil {
t.Fatal(err)
}
db := filepath.Join(dir, "agent-system.db")
release()
_ = st
if err := os.Remove(db); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(db + ".lock")
if err != nil {
t.Fatal(err)
}
rec, err := parseRecord(string(raw))
if err != nil || rec == nil {
t.Fatal(err)
}
for i := 0; i < 16; i++ {
if err := os.WriteFile(db, []byte("replaced"), 0o600); err != nil {
t.Fatal(err)
}
_, ino, exists, err := statFile(db)
if err != nil {
t.Fatal(err)
}
if exists && ino != rec.DBIno {
break
}
if err := os.Remove(db); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "inode-burn-"+string(rune('a'+i))), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
lease, err := Acquire(dir)
if err != nil {
t.Fatal(err)
}
defer lease.Release()
if err := lease.AutomaticRefusal(); !errors.Is(err, ErrDBReplaced) {
t.Fatalf("replaced db: %v", err)
}
if _, err := os.Stat(db); err != nil {
t.Fatal(err)
}
}
func TestMissingRecordedDatabaseRefused(t *testing.T) {
dir := t.TempDir()
_, release, err := openAndBind(t, dir, "")
if err != nil {
t.Fatal(err)
}
release()
db := filepath.Join(dir, "agent-system.db")
if err := os.Remove(db); err != nil {
t.Fatal(err)
}
lease, err := Acquire(dir)
if err != nil {
t.Fatal(err)
}
defer lease.Release()
if err := lease.AutomaticRefusal(); !errors.Is(err, ErrDBMissing) {
t.Fatalf("missing db: %v", err)
}
}
func TestRebuiltLockRefusedUntilAdopt(t *testing.T) {
dir := t.TempDir()
st, release, err := openAndBind(t, dir, "inst-a")
if err != nil {
t.Fatal(err)
}
release()
_ = st.Close()
if err := os.Remove(filepath.Join(dir, "agent-system.db.lock")); err != nil {
t.Fatal(err)
}
lease, err := Acquire(dir)
if err != nil {
t.Fatal(err)
}
if err := lease.AutomaticRefusal(); err != nil {
lease.Release()
t.Fatal(err)
}
opened, err := store.OpenSQLite(lease.DBPath())
if err != nil {
lease.Release()
t.Fatal(err)
}
err = Bind(opened, lease, BindOptions{InstanceID: "inst-a"})
_ = opened.Close()
lease.Release()
if !errors.Is(err, ErrLockRebuilt) {
t.Fatalf("rebuilt lock: %v", err)
}
adopted, err := Acquire(dir)
if err != nil {
t.Fatal(err)
}
defer adopted.Release()
opened, err = store.OpenSQLite(adopted.DBPath())
if err != nil {
t.Fatal(err)
}
defer opened.Close()
if err := Bind(opened, adopted, BindOptions{Adopt: true, InstanceID: "inst-a"}); err != nil {
t.Fatal(err)
}
}
func TestClearedLockRecordRefusedWhenInodeStays(t *testing.T) {
dir := t.TempDir()
st, release, err := openAndBind(t, dir, "inst-a")
if err != nil {
t.Fatal(err)
}
release()
_ = st.Close()
lockPath := filepath.Join(dir, "agent-system.db.lock")
if err := os.Truncate(lockPath, 0); err != nil {
t.Fatal(err)
}
lease, err := Acquire(dir)
if err != nil {
t.Fatal(err)
}
opened, err := store.OpenSQLite(lease.DBPath())
if err != nil {
lease.Release()
t.Fatal(err)
}
err = Bind(opened, lease, BindOptions{InstanceID: "inst-a"})
_ = opened.Close()
lease.Release()
if !errors.Is(err, ErrLockRebuilt) {
t.Fatalf("cleared lock: %v", err)
}
}
func TestInstanceMismatchPreservesEmpty(t *testing.T) {
dir := t.TempDir()
st, release, err := openAndBind(t, dir, "inst-a")
if err != nil {
t.Fatal(err)
}
release()
_ = st.Close()
lease, err := Acquire(dir)
if err != nil {
t.Fatal(err)
}
opened, err := store.OpenSQLite(lease.DBPath())
if err != nil {
lease.Release()
t.Fatal(err)
}
err = Bind(opened, lease, BindOptions{InstanceID: "inst-b"})
_ = opened.Close()
lease.Release()
if !errors.Is(err, ErrInstanceMismatch) {
t.Fatalf("mismatch: %v", err)
}
lease, err = Acquire(dir)
if err != nil {
t.Fatal(err)
}
defer lease.Release()
opened, err = store.OpenSQLite(lease.DBPath())
if err != nil {
t.Fatal(err)
}
defer opened.Close()
if err := Bind(opened, lease, BindOptions{}); err != nil {
t.Fatal(err)
}
marker, ok, err := opened.LoadInstanceGuard()
if err != nil || !ok || marker.InstanceID != "inst-a" {
t.Fatalf("empty id should keep marker: ok=%v id=%q err=%v", ok, marker.InstanceID, err)
}
}
func TestLockReleasedAfterKill(t *testing.T) {
if os.Getenv("U8_GUARD_ROLE") == "hold" {
lease, err := Acquire(os.Getenv("U8_GUARD_DIR"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(os.Getenv("U8_GUARD_READY"), []byte("ready"), 0o600); err != nil {
t.Fatal(err)
}
time.Sleep(30 * time.Second)
lease.Release()
return
}
dir := t.TempDir()
ready := filepath.Join(dir, "ready")
cmd := exec.Command(os.Args[0], "-test.run=TestLockReleasedAfterKill")
cmd.Env = append(os.Environ(), "U8_GUARD_ROLE=hold", "U8_GUARD_DIR="+dir, "U8_GUARD_READY="+ready)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
})
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(ready); err == nil {
break
}
time.Sleep(20 * time.Millisecond)
}
if _, err := os.Stat(ready); err != nil {
t.Fatal(err)
}
if _, err := Acquire(dir); !errors.Is(err, ErrBusy) {
t.Fatalf("held: %v", err)
}
if err := cmd.Process.Kill(); err != nil {
t.Fatal(err)
}
_, _ = cmd.Process.Wait()
lease, err := Acquire(dir)
if err != nil {
t.Fatal(err)
}
lease.Release()
}
func TestMissingDataDirIsCreatedBeforeLock(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested", "data")
lease, err := Acquire(dir)
if err != nil {
t.Fatal(err)
}
lease.Release()
if _, err := os.Stat(dir); err != nil {
t.Fatal(err)
}
}
func TestLockSymlinkCannotAliasDatabase(t *testing.T) {
dir := t.TempDir()
db := filepath.Join(dir, "agent-system.db")
st, err := store.OpenSQLite(db)
if err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
if err := os.Remove(db + ".lock"); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
if err := os.Symlink(db, db+".lock"); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(db)
if err != nil {
t.Fatal(err)
}
if _, err := Acquire(dir); !errors.Is(err, ErrLockAliased) {
t.Fatalf("symlink lock: %v", err)
}
after, err := os.ReadFile(db)
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatal("aliased lock wrote into the database")
}
}
func TestSymlinkedDatabaseLocksResolvedFile(t *testing.T) {
base := t.TempDir()
realDir := filepath.Join(base, "real")
aliasDir := filepath.Join(base, "alias")
if err := os.Mkdir(realDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(aliasDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(realDir, "agent-system.db"), []byte{}, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(realDir, "agent-system.db"), filepath.Join(aliasDir, "agent-system.db")); err != nil {
t.Fatal(err)
}
held, err := Acquire(aliasDir)
if err != nil {
t.Fatal(err)
}
defer held.Release()
if _, err := os.Stat(filepath.Join(realDir, "agent-system.db.lock")); err != nil {
t.Fatal(err)
}
if _, err := Acquire(realDir); !errors.Is(err, ErrBusy) {
t.Fatalf("resolved db lock: %v", err)
}
}
func openAndBind(t *testing.T, dir, instanceID string) (*store.Store, func(), error) {
t.Helper()
lease, err := Acquire(dir)
if err != nil {
return nil, nil, err
}
if err := lease.AutomaticRefusal(); err != nil {
lease.Release()
return nil, nil, err
}
st, err := store.OpenSQLite(lease.DBPath())
if err != nil {
lease.Release()
return nil, nil, err
}
if err := Bind(st, lease, BindOptions{InstanceID: instanceID}); err != nil {
_ = st.Close()
lease.Release()
return nil, nil, err
}
return st, func() {
_ = st.Close()
lease.Release()
}, nil
}
+201
View File
@@ -0,0 +1,201 @@
//go:build unix
package instanceguard
import (
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
)
// Acquire 取得 SQLite 活动服务锁。失败时不返回 Lease。
// 硬链接别名直接拒绝。锁文件被并发替换时重试数次后拒绝。
func Acquire(dataDir string) (*Lease, error) {
dir, err := normalizeDataDir(dataDir)
if err != nil {
return nil, err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
_, dbPath, err := resolveDB(dir)
if err != nil {
return nil, err
}
if err := rejectHardlink(dbPath); err != nil {
return nil, err
}
lockPath := dbPath + ".lock"
var last error
for attempt := 0; attempt < 5; attempt++ {
lease, retry, err := acquireOnce(dbPath, lockPath)
if err == nil && !retry {
return lease, nil
}
if retry {
last = err
continue
}
return nil, err
}
if last == nil {
last = errorsRetry()
}
return nil, last
}
func errorsRetry() error {
return fmt.Errorf("instance guard: lock file inode changed while locking")
}
func acquireOnce(dbPath, lockPath string) (*Lease, bool, error) {
fd, err := syscall.Open(lockPath, syscall.O_RDWR|syscall.O_CREAT|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0o600)
if err != nil {
if err == syscall.ELOOP {
return nil, false, ErrLockAliased
}
return nil, false, err
}
fail := func(err error) (*Lease, bool, error) {
syscall.Close(fd)
return nil, false, err
}
if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
syscall.Close(fd)
if err == syscall.EWOULDBLOCK || err == syscall.EAGAIN {
return nil, false, ErrBusy
}
return nil, false, err
}
var held syscall.Stat_t
if err := syscall.Fstat(fd, &held); err != nil {
return fail(err)
}
var named syscall.Stat_t
if err := syscall.Stat(lockPath, &named); err != nil {
return fail(err)
}
if held.Ino != named.Ino || held.Dev != named.Dev {
syscall.Flock(fd, syscall.LOCK_UN)
syscall.Close(fd)
return nil, true, errorsRetry()
}
buf := make([]byte, 512)
n, err := syscall.Read(fd, buf)
if err != nil {
return fail(err)
}
rec, perr := parseRecord(string(buf[:n]))
dbDev, dbIno, dbExists, err := statFile(dbPath)
if err != nil {
return fail(err)
}
if dbExists && dbDev == uint64(held.Dev) && dbIno == uint64(held.Ino) {
syscall.Flock(fd, syscall.LOCK_UN)
syscall.Close(fd)
return nil, false, ErrLockAliased
}
lease := &Lease{
fd: fd,
dbPath: dbPath,
lockDev: uint64(held.Dev),
lockIno: uint64(held.Ino),
dbDev: dbDev,
dbIno: dbIno,
dbExists: dbExists,
record: rec,
parseErr: perr,
release: func() {
syscall.Flock(fd, syscall.LOCK_UN)
syscall.Close(fd)
},
}
return lease, false, nil
}
func (l *Lease) writeRecord(dev, ino uint64, instanceID, lockGen string) error {
if strings.ContainsAny(instanceID, " \t\r\n") {
return fmt.Errorf("instance guard: instance id contains whitespace")
}
line := fmt.Sprintf("v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=%s\tlock_gen=%s\n", dev, ino, instanceID, lockGen)
if _, err := syscall.Seek(l.fd, 0, 0); err != nil {
return err
}
if _, err := syscall.Write(l.fd, []byte(line)); err != nil {
return err
}
return syscall.Ftruncate(l.fd, int64(len(line)))
}
func (l *Lease) currentDB() (dev, ino uint64, exists bool, err error) {
return statFile(l.dbPath)
}
func normalizeDataDir(dataDir string) (string, error) {
if strings.TrimSpace(dataDir) == "" {
dataDir = "."
}
abs, err := filepath.Abs(dataDir)
if err != nil {
return "", err
}
if _, err := os.Lstat(abs); err == nil {
return filepath.EvalSymlinks(abs)
} else if !os.IsNotExist(err) {
return "", err
}
parent, base := filepath.Split(abs)
parent = filepath.Clean(parent)
if _, err := os.Lstat(parent); err == nil {
resolved, err := filepath.EvalSymlinks(parent)
if err != nil {
return "", err
}
return filepath.Join(resolved, base), nil
}
return abs, nil
}
func resolveDB(dataDir string) (logical, resolved string, err error) {
logical = filepath.Join(dataDir, "agent-system.db")
fi, err := os.Lstat(logical)
if err != nil {
if os.IsNotExist(err) {
return logical, logical, nil
}
return "", "", err
}
if fi.Mode()&os.ModeSymlink != 0 {
resolved, err = filepath.EvalSymlinks(logical)
return logical, resolved, err
}
return logical, logical, nil
}
func rejectHardlink(dbPath string) error {
_, _, exists, err := statFile(dbPath)
if err != nil || !exists {
return err
}
var st syscall.Stat_t
if err := syscall.Stat(dbPath, &st); err != nil {
return err
}
if st.Nlink > 1 {
return ErrHardlink
}
return nil
}
func statFile(path string) (dev, ino uint64, exists bool, err error) {
var st syscall.Stat_t
if err = syscall.Stat(path, &st); err != nil {
if os.IsNotExist(err) {
return 0, 0, false, nil
}
return 0, 0, false, err
}
return uint64(st.Dev), uint64(st.Ino), true, nil
}
+95 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
"agent-system-backend/internal/store"
@@ -113,7 +114,6 @@ func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) {
h := srv.Handler()
adminCookie := bootAdmin(t, h, st)
for _, body := range []string{
`{"owner":"blocked-password","password":"old-pass-9"}`,
`{"owner":"blocked-admin","isAdmin":true}`,
} {
if rec := do(h, "POST", "/api/admin/users", body, adminCookie); rec.Code != http.StatusBadRequest {
@@ -152,3 +152,97 @@ func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) {
t.Fatalf("state/profile after disable: %+v %v", account, err)
}
}
func TestAdminPasswordCreateAndResetHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
created := do(h, "POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"爱丽丝"},"password":"alice-initial-9"}`, admin)
if created.Code != http.StatusOK {
t.Fatalf("password create: %d %s", created.Code, created.Body.String())
}
if strings.Contains(created.Body.String(), "alice-initial-9") {
t.Fatal("supplied password was echoed")
}
var createdBody struct {
UserID string `json:"userId"`
PasswordSet bool `json:"passwordSet"`
MustChangePassword bool `json:"mustChangePassword"`
InitialPassword string `json:"initialPassword"`
}
if err := json.Unmarshal(created.Body.Bytes(), &createdBody); err != nil {
t.Fatal(err)
}
if createdBody.UserID == "" || !createdBody.PasswordSet || !createdBody.MustChangePassword || createdBody.InitialPassword != "" {
t.Fatalf("supplied password projection: %+v", createdBody)
}
account, err := st.GetAccountByOwner("alice")
if err != nil || !account.HasPassword || !account.MustChangePassword || account.InitializationState != store.InitializationInitialized {
t.Fatalf("stored account state: %+v %v", account, err)
}
if state, ok := st.VerifyLogin("alice", "alice-initial-9"); !ok || !state.MustChangePassword {
t.Fatalf("supplied password login: ok=%v state=%+v", ok, state)
}
generated := do(h, "POST", "/api/admin/users", `{"owner":"bob"}`, admin)
if generated.Code != http.StatusOK {
t.Fatalf("generated password create: %d %s", generated.Code, generated.Body.String())
}
var generatedBody struct {
InitialPassword string `json:"initialPassword"`
MustChangePassword bool `json:"mustChangePassword"`
}
if err := json.Unmarshal(generated.Body.Bytes(), &generatedBody); err != nil {
t.Fatal(err)
}
if generatedBody.InitialPassword == "" || !generatedBody.MustChangePassword {
t.Fatalf("generated password projection: %+v", generatedBody)
}
if state, ok := st.VerifyLogin("bob", generatedBody.InitialPassword); !ok || !state.MustChangePassword {
t.Fatalf("generated password login: ok=%v state=%+v", ok, state)
}
login := do(h, "POST", "/api/auth/login", `{"owner":"alice","password":"alice-initial-9"}`, "")
if login.Code != http.StatusOK {
t.Fatalf("alice login: %d %s", login.Code, login.Body.String())
}
aliceSession := cookieOf(login)
key, _, err := st.IssueLoginKey("alice", store.LoginKeyPurposeRecover, "test", 0)
if err != nil {
t.Fatal(err)
}
if rejected := do(h, "POST", "/api/admin/users/alice/password", `{"password":"client-supplied"}`, admin); rejected.Code != http.StatusBadRequest {
t.Fatalf("client reset password accepted: %d %s", rejected.Code, rejected.Body.String())
}
if _, ok := st.VerifyLogin("alice", "alice-initial-9"); !ok {
t.Fatal("rejected reset changed the password")
}
reset := do(h, "POST", "/api/admin/users/alice/password", `{}`, admin)
if reset.Code != http.StatusOK || reset.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("admin reset: %d %s", reset.Code, reset.Body.String())
}
var resetBody struct {
TemporaryPassword string `json:"temporaryPassword"`
MustChangePassword bool `json:"mustChangePassword"`
}
if err := json.Unmarshal(reset.Body.Bytes(), &resetBody); err != nil {
t.Fatal(err)
}
if resetBody.TemporaryPassword == "" || resetBody.TemporaryPassword == "alice-initial-9" || !resetBody.MustChangePassword {
t.Fatalf("reset projection: %+v", resetBody)
}
if rec := do(h, "GET", "/api/me", "", aliceSession); rec.Code != http.StatusUnauthorized {
t.Fatalf("reset did not revoke session: %d %s", rec.Code, rec.Body.String())
}
if _, ok := st.VerifyLogin("alice", "alice-initial-9"); ok {
t.Fatal("old password remained usable after reset")
}
if state, ok := st.VerifyLogin("alice", resetBody.TemporaryPassword); !ok || !state.MustChangePassword {
t.Fatalf("temporary password login: ok=%v state=%+v", ok, state)
}
if _, _, err := st.RedeemLoginKey(key); err == nil {
t.Fatal("admin reset left unused login key redeemable")
}
}
+50 -10
View File
@@ -324,8 +324,9 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Owner string `json:"owner"`
Profile store.AccountProfile `json:"profile"`
Owner string `json:"owner"`
Profile store.AccountProfile `json:"profile"`
Password *string `json:"password"`
}
if err := decodeStrictJSON(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -336,12 +337,31 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest)
return
}
account, err := s.st.CreatePendingAccount(actor, req.Owner, req.Profile)
password := RandomPassword()
generated := req.Password == nil
if req.Password != nil {
password = *req.Password
if err := validPassword(password); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
hash, err := store.HashPassword(password)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
account, err := s.st.CreateAdminPasswordAccount(actor, req.Owner, req.Profile, hash)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, accountProfileJSON(account))
out := accountProfileJSON(account)
if generated {
w.Header().Set("Cache-Control", "no-store")
out["initialPassword"] = password
}
writeJSON(w, http.StatusOK, out)
}
func accountProfileJSON(account store.Account) map[string]any {
@@ -349,7 +369,8 @@ func accountProfileJSON(account store.Account) map[string]any {
"userId": account.ID, "owner": account.Owner, "profile": account.Profile,
"profileVersion": account.ProfileVersion, "enabled": account.Enabled,
"stateVersion": account.StateVersion, "isAdmin": account.IsAdmin,
"passwordSet": account.HasPassword, "initializationState": account.InitializationState,
"passwordSet": account.HasPassword, "mustChangePassword": account.MustChangePassword,
"initializationState": account.InitializationState,
"initializationGeneration": account.InitializationGeneration,
}
}
@@ -462,13 +483,32 @@ func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复
// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用
// 登录 Key、吊销全部会话、审计。
// resetAdminUserPassword:管理员生成随机临时密码。单事务:must_change=1、bump
// auth_version、作废未用登录 Key、吊销全部会话、审计。
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); ok {
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
var req struct{}
if err := decodeStrictJSON(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
password := RandomPassword()
hash, err := store.HashPassword(password)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.AdminResetUserPassword(r.PathValue("owner"), hash, actor, "admin-http"); err != nil {
writeStoreErr(w, err)
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{
"owner": r.PathValue("owner"), "temporaryPassword": password, "mustChangePassword": true,
})
}
// ---- 管理员:一次性登录 Key(恢复流程主入口)----
+21 -7
View File
@@ -423,19 +423,33 @@ func TestAdminAccountFlow(t *testing.T) {
t.Fatalf("non-admin create must 403: %d", rec.Code)
}
// 旧管理员重置入口关闭,不能动 alice 的密码与当前会话。
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusGone {
t.Fatalf("admin reset must be closed: %d %s", rec.Code, rec.Body.String())
// 管理员重置总是生成临时密码,销毁 alice 的旧会话并强制首登改密。
reset := session("POST", "/api/admin/users/alice/password", `{}`)
if reset.Code != http.StatusOK || reset.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("admin reset: %d %s", reset.Code, reset.Body.String())
}
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK {
t.Fatalf("closed reset must preserve alice session: %d", rec.Code)
var resetBody struct {
TemporaryPassword string `json:"temporaryPassword"`
}
if err := json.Unmarshal(reset.Body.Bytes(), &resetBody); err != nil || resetBody.TemporaryPassword == "" {
t.Fatalf("admin reset response: %+v %v", resetBody, err)
}
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("reset must revoke alice session: %d", rec.Code)
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("closed reset must preserve normal login: %d %s", rec.Code, rec.Body.String())
if rec.Code != http.StatusUnauthorized {
t.Fatalf("old password remained usable after reset: %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"`+resetBody.TemporaryPassword+`"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("temporary password login: %d %s", rec.Code, rec.Body.String())
}
// API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。
+1 -2
View File
@@ -99,7 +99,7 @@ func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) {
func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) }
func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) {
func TestLegacyAdministratorLoginKeyIssuanceEntrancesAreClosed(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
@@ -114,7 +114,6 @@ func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) {
t.Fatal(err)
}
for _, route := range []struct{ path, body string }{
{"/api/admin/users/victim/password", `{"password":"attacker-pass"}`},
{"/api/admin/users/victim/login-keys", `{"purpose":"recover"}`},
{"/api/admin/users/victim/login-keys", `{"purpose":"activate"}`},
} {
+47
View File
@@ -299,6 +299,53 @@ func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountPr
return a, nil
}
// CreateAdminPasswordAccount creates an ordinary user with an administrator-
// supplied password hash. The account row, hash, and audit entries commit
// together so a failed write cannot leave a credential without its account.
func (s *Store) CreateAdminPasswordAccount(actorOwner, owner string, profile AccountProfile, passwordHash string) (Account, error) {
if passwordHash == "" {
return Account{}, ErrConflict
}
tx, err := s.db.Begin()
if err != nil {
return Account{}, err
}
defer tx.Rollback()
locked, err := s.lockAccountUsers(tx, actorOwner)
if errors.Is(err, ErrNotFound) {
return Account{}, ErrAccountForbidden
}
if err != nil {
return Account{}, err
}
actor := locked[actorOwner]
if !actor.Enabled || !actor.IsAdmin {
return Account{}, ErrAccountForbidden
}
a, err := s.insertPendingAccountTx(tx, owner, profile)
if err != nil {
return Account{}, err
}
if _, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,credential_origin=?,initialization_state=? WHERE id=?`),
passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, a.ID); err != nil {
return Account{}, err
}
a, err = s.accountByOwner(tx, owner)
if err != nil {
return Account{}, err
}
if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_password", a.ID); err != nil {
return Account{}, err
}
if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_password"); err != nil {
return Account{}, err
}
if err := tx.Commit(); err != nil {
return Account{}, err
}
return a, nil
}
// insertPendingAccountTx lets the trusted local initializer create and grant in
// one transaction without impersonating an administrator. The caller owns its
// authorization, audit and commit; this helper always inserts an ordinary user.
+5 -5
View File
@@ -95,7 +95,7 @@ func TestCandidateV35RawScopeColumnsAreScrubbedOnUpgrade(t *testing.T) {
t.Fatal(err)
}
for _, query := range []string{
`DELETE FROM schema_migrations WHERE version=40`,
`DELETE FROM schema_migrations WHERE version=41`,
`ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`,
`UPDATE context_manifests SET resolve_goal='OLD_RESTRICTED_BODY',resolve_target_id='doc-private'`,
@@ -114,7 +114,7 @@ func TestCandidateV35RawScopeColumnsAreScrubbedOnUpgrade(t *testing.T) {
defer upgraded.Close()
var columns int
if err := upgraded.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('context_manifests') WHERE name IN ('resolve_goal','resolve_target_id')`).Scan(&columns); err != nil || columns != 0 {
t.Fatalf("candidate raw scope columns survived v40: columns=%d err=%v", columns, err)
t.Fatalf("candidate raw scope columns survived v41: columns=%d err=%v", columns, err)
}
var count int
if err := upgraded.db.QueryRow(`SELECT COUNT(*) FROM context_manifests WHERE attempt_id='old-model'`).Scan(&count); err != nil || count != 1 {
@@ -139,7 +139,7 @@ func TestCandidateV35CommittedDropKeepsPendingScrubAcrossRestart(t *testing.T) {
t.Fatal(err)
}
for _, query := range []string{
`DELETE FROM schema_migrations WHERE version=40`,
`DELETE FROM schema_migrations WHERE version=41`,
`ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`,
`UPDATE context_manifests SET resolve_goal='OLD_RESTRICTED_BODY',resolve_target_id='doc-private'`,
@@ -155,7 +155,7 @@ func TestCandidateV35CommittedDropKeepsPendingScrubAcrossRestart(t *testing.T) {
}
var pending int
if err := st.db.QueryRow(`SELECT pending FROM context_scope_scrub_state WHERE id=1`).Scan(&pending); err != nil || pending != 1 {
t.Fatalf("committed v40 lost physical scrub obligation: pending=%d err=%v", pending, err)
t.Fatalf("committed v41 lost physical scrub obligation: pending=%d err=%v", pending, err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
@@ -235,7 +235,7 @@ func TestCandidateV35PostgresRawScopeScrubAndFreshSchema(t *testing.T) {
t.Fatal(err)
}
for _, query := range []string{
`DELETE FROM schema_migrations WHERE version=40`,
`DELETE FROM schema_migrations WHERE version=41`,
`ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`,
`UPDATE context_manifests SET resolve_goal='OLD_RESTRICTED_BODY',resolve_target_id='doc-private'`,
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"fmt"
"strings"
)
// DurabilityRefusal 判断 PostgreSQL 耐久参数。off 或缺失即拒绝。
// synchronous_commit=local 不是 off,不拒绝。
func DurabilityRefusal(settings map[string]string) error {
for _, key := range []string{"fsync", "full_page_writes", "synchronous_commit"} {
value, ok := settings[key]
if !ok || strings.EqualFold(strings.TrimSpace(value), "off") {
return fmt.Errorf("durability: %s=%q", key, value)
}
}
return nil
}
func (s *Store) verifyDurability() error {
switch s.dialect.name() {
case "sqlite":
return s.verifySQLiteDurability()
case "postgres":
return s.verifyPostgresDurability()
default:
return fmt.Errorf("durability: unknown dialect %s", s.dialect.name())
}
}
func (s *Store) verifySQLiteDurability() error {
var journal string
if err := s.db.QueryRow(`PRAGMA journal_mode`).Scan(&journal); err != nil {
return fmt.Errorf("durability: journal_mode: %w", err)
}
var syncLevel int64
if err := s.db.QueryRow(`PRAGMA synchronous`).Scan(&syncLevel); err != nil {
return fmt.Errorf("durability: synchronous: %w", err)
}
if !strings.EqualFold(journal, "wal") || syncLevel != 2 {
return fmt.Errorf("durability: journal_mode=%s synchronous=%d", journal, syncLevel)
}
return nil
}
func (s *Store) verifyPostgresDurability() error {
rows, err := s.db.Query(`SELECT name, setting FROM pg_settings WHERE name IN ('fsync','full_page_writes','synchronous_commit')`)
if err != nil {
return fmt.Errorf("durability: pg_settings: %w", err)
}
defer rows.Close()
got := map[string]string{}
for rows.Next() {
var name, setting string
if err := rows.Scan(&name, &setting); err != nil {
return err
}
got[name] = setting
}
if err := rows.Err(); err != nil {
return err
}
if err := DurabilityRefusal(got); err != nil {
return err
}
return nil
}
+95
View File
@@ -0,0 +1,95 @@
package store
import (
"os"
"path/filepath"
"strings"
"testing"
core "agent-system-core"
)
func TestDurabilityRefusal(t *testing.T) {
ok := map[string]string{
"fsync": "on", "full_page_writes": "on", "synchronous_commit": "local",
}
if err := DurabilityRefusal(ok); err != nil {
t.Fatal(err)
}
for _, key := range []string{"fsync", "full_page_writes", "synchronous_commit"} {
bad := map[string]string{
"fsync": "on", "full_page_writes": "on", "synchronous_commit": "on",
}
bad[key] = "off"
if err := DurabilityRefusal(bad); err == nil {
t.Fatalf("%s off should refuse", key)
}
delete(bad, key)
if err := DurabilityRefusal(bad); err == nil {
t.Fatalf("missing %s should refuse", key)
}
}
}
func TestSQLitePragmaReadback(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
var journal string
if err := st.db.QueryRow(`PRAGMA journal_mode`).Scan(&journal); err != nil {
t.Fatal(err)
}
var syncLevel int64
if err := st.db.QueryRow(`PRAGMA synchronous`).Scan(&syncLevel); err != nil {
t.Fatal(err)
}
if !strings.EqualFold(journal, "wal") || syncLevel != 2 {
t.Fatalf("journal=%s synchronous=%d", journal, syncLevel)
}
marker, ok, err := st.LoadInstanceGuard()
if err != nil || ok || marker.InstanceID != "" {
t.Fatalf("fresh db has no guard row: ok=%v err=%v", ok, err)
}
}
func TestOpenExistingDoesNotCreateMissingParent(t *testing.T) {
missing := filepath.Join(t.TempDir(), "absent", "agent-system.db")
if _, err := OpenExistingSQLite(missing); err == nil {
t.Fatal("missing file opened")
}
if _, err := os.Stat(filepath.Dir(missing)); !os.IsNotExist(err) {
t.Fatalf("parent created: %v", err)
}
}
func TestRunSurvivesReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent-system.db")
st, err := OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
run := &core.Run{
ID: "run_keep", ConversationID: "c", ProjectID: "p", Owner: "o",
Kind: core.RunConversation, Status: core.RunWaitingHITL,
}
if err := st.CreateRun(run); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
st, err = OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
got, err := st.GetRunByID("run_keep")
if err != nil {
t.Fatal(err)
}
if got.Status != core.RunWaitingHITL {
t.Fatalf("status %s", got.Status)
}
}
+35
View File
@@ -0,0 +1,35 @@
//go:build unix
package store
import (
"os/signal"
"path/filepath"
"strings"
"syscall"
"testing"
)
func TestWriteFailureIsReturned(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
signal.Ignore(syscall.SIGXFSZ)
t.Cleanup(func() { signal.Reset(syscall.SIGXFSZ) })
var lim syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_FSIZE, &lim); err != nil {
t.Fatal(err)
}
old := lim
lim.Cur = 4096
if err := syscall.Setrlimit(syscall.RLIMIT_FSIZE, &lim); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = syscall.Setrlimit(syscall.RLIMIT_FSIZE, &old) })
_, err = st.CreateProject("owner", strings.Repeat("x", 64*1024))
if err == nil {
t.Fatal("oversized write returned success")
}
}
+47
View File
@@ -0,0 +1,47 @@
package store
import (
"database/sql"
"errors"
)
// InstanceGuardMarker 是 instance_guard 表的唯一一行。
type InstanceGuardMarker struct {
DBDev uint64
DBIno uint64
LockDev uint64
LockIno uint64
InstanceID string
LockGen string
}
// LoadInstanceGuard 读取守卫标记。没有行时 ok 为 false。
func (s *Store) LoadInstanceGuard() (InstanceGuardMarker, bool, error) {
var m InstanceGuardMarker
var dbDev, dbIno, lockDev, lockIno int64
err := s.db.QueryRow(`SELECT db_dev, db_ino, lock_dev, lock_ino, instance_id, lock_gen FROM instance_guard WHERE id=1`).
Scan(&dbDev, &dbIno, &lockDev, &lockIno, &m.InstanceID, &m.LockGen)
if errors.Is(err, sql.ErrNoRows) {
return InstanceGuardMarker{}, false, nil
}
if err != nil {
return InstanceGuardMarker{}, false, err
}
m.DBDev = uint64(dbDev)
m.DBIno = uint64(dbIno)
m.LockDev = uint64(lockDev)
m.LockIno = uint64(lockIno)
return m, true, nil
}
// SaveInstanceGuard 写入或覆盖唯一一行。
func (s *Store) SaveInstanceGuard(m InstanceGuardMarker) error {
_, err := s.db.Exec(s.q(`INSERT INTO instance_guard(id, db_dev, db_ino, lock_dev, lock_ino, instance_id, lock_gen)
VALUES(1,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
db_dev=excluded.db_dev, db_ino=excluded.db_ino,
lock_dev=excluded.lock_dev, lock_ino=excluded.lock_ino,
instance_id=excluded.instance_id, lock_gen=excluded.lock_gen`),
int64(m.DBDev), int64(m.DBIno), int64(m.LockDev), int64(m.LockIno), m.InstanceID, m.LockGen)
return err
}
+47 -19
View File
@@ -902,28 +902,56 @@ func init() {
up: map[string][]string{"sqlite": execEnvUp, "postgres": execEnvUp},
down: map[string][]string{"sqlite": execEnvDown, "postgres": execEnvDown},
},
migration{version: 36, name: "work_task_bindings",
// U8:活动服务锁的库内标记。锁文件 inode 与本行不一致表示锁文件被重建。
migration{version: 36, name: "instance_guard",
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS instance_guard (
id INTEGER PRIMARY KEY CHECK (id = 1),
db_dev INTEGER NOT NULL,
db_ino INTEGER NOT NULL,
lock_dev INTEGER NOT NULL,
lock_ino INTEGER NOT NULL,
instance_id TEXT NOT NULL DEFAULT '',
lock_gen TEXT NOT NULL DEFAULT ''
)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS instance_guard (
id INTEGER PRIMARY KEY CHECK (id = 1),
db_dev BIGINT NOT NULL,
db_ino BIGINT NOT NULL,
lock_dev BIGINT NOT NULL,
lock_ino BIGINT NOT NULL,
instance_id TEXT NOT NULL DEFAULT '',
lock_gen TEXT NOT NULL DEFAULT ''
)`,
}},
down: map[string][]string{"sqlite": {
`DROP TABLE IF EXISTS instance_guard`,
}, "postgres": {
`DROP TABLE IF EXISTS instance_guard`,
}}},
migration{version: 37, name: "work_task_bindings",
up: map[string][]string{
"sqlite": {`CREATE TABLE work_task_bindings(task_id TEXT PRIMARY KEY,owner TEXT NOT NULL,plugin_id TEXT NOT NULL,conversation_id TEXT NOT NULL,project_id TEXT NOT NULL,created_at TEXT NOT NULL,FOREIGN KEY(task_id) REFERENCES background_tasks(id) ON DELETE CASCADE)`},
"postgres": {`CREATE TABLE work_task_bindings(task_id TEXT PRIMARY KEY,owner TEXT NOT NULL,plugin_id TEXT NOT NULL,conversation_id TEXT NOT NULL,project_id TEXT NOT NULL,created_at TEXT NOT NULL,FOREIGN KEY(task_id) REFERENCES background_tasks(id) ON DELETE CASCADE)`},
},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS work_task_bindings`}, "postgres": {`DROP TABLE IF EXISTS work_task_bindings`}},
},
migration{version: 37, name: "trusted_context_manifests",
migration{version: 38, name: "trusted_context_manifests",
up: map[string][]string{
"sqlite": {`CREATE TABLE context_manifests(id TEXT PRIMARY KEY,attempt_id TEXT NOT NULL,owner TEXT NOT NULL,target_id TEXT NOT NULL,project_id TEXT NOT NULL,input_sha256 TEXT NOT NULL,delivery TEXT NOT NULL CHECK(delivery IN ('attempted','responded','unknown')),manifest_json TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(attempt_id,delivery))`, `CREATE INDEX idx_context_manifests_owner_target ON context_manifests(owner,target_id,created_at)`, `CREATE UNIQUE INDEX uniq_context_manifest_terminal ON context_manifests(attempt_id) WHERE delivery IN ('responded','unknown')`},
"postgres": {`CREATE TABLE context_manifests(id TEXT PRIMARY KEY,attempt_id TEXT NOT NULL,owner TEXT NOT NULL,target_id TEXT NOT NULL,project_id TEXT NOT NULL,input_sha256 TEXT NOT NULL,delivery TEXT NOT NULL CHECK(delivery IN ('attempted','responded','unknown')),manifest_json TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(attempt_id,delivery))`, `CREATE INDEX idx_context_manifests_owner_target ON context_manifests(owner,target_id,created_at)`, `CREATE UNIQUE INDEX uniq_context_manifest_terminal ON context_manifests(attempt_id) WHERE delivery IN ('responded','unknown')`},
},
down: map[string][]string{"sqlite": {`DROP INDEX IF EXISTS uniq_context_manifest_terminal`, `DROP INDEX IF EXISTS idx_context_manifests_owner_target`, `DROP TABLE IF EXISTS context_manifests`}, "postgres": {`DROP INDEX IF EXISTS uniq_context_manifest_terminal`, `DROP INDEX IF EXISTS idx_context_manifests_owner_target`, `DROP TABLE IF EXISTS context_manifests`}},
},
migration{version: 38, name: "workflow_step_sources",
migration{version: 39, name: "workflow_step_sources",
up: map[string][]string{
"sqlite": {`CREATE TABLE workflow_step_sources(run_id TEXT NOT NULL,step_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,step_attempt_id TEXT NOT NULL,output_sha256 TEXT NOT NULL,attempt_id TEXT NOT NULL,authorization_epoch TEXT NOT NULL,publication_epoch TEXT NOT NULL,watermark TEXT NOT NULL,version TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,step_id),FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE)`},
"postgres": {`CREATE TABLE workflow_step_sources(run_id TEXT NOT NULL,step_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,step_attempt_id TEXT NOT NULL,output_sha256 TEXT NOT NULL,attempt_id TEXT NOT NULL,authorization_epoch TEXT NOT NULL,publication_epoch TEXT NOT NULL,watermark TEXT NOT NULL,version TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,step_id),FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE)`},
},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS workflow_step_sources`}, "postgres": {`DROP TABLE IF EXISTS workflow_step_sources`}},
},
migration{version: 39, name: "context_manifest_resolve_scope",
migration{version: 40, name: "context_manifest_resolve_scope",
up: map[string][]string{
"sqlite": {`ALTER TABLE context_manifests ADD COLUMN scope_bound INTEGER NOT NULL DEFAULT 0`},
"postgres": {`ALTER TABLE context_manifests ADD COLUMN scope_bound INTEGER NOT NULL DEFAULT 0`},
@@ -933,14 +961,14 @@ func init() {
"postgres": {`ALTER TABLE context_manifests DROP COLUMN scope_bound`},
},
},
migration{version: 40, name: "scrub_candidate_context_scope_text",
migration{version: 41, name: "scrub_candidate_context_scope_text",
up: map[string][]string{
"sqlite": {`CREATE TABLE IF NOT EXISTS context_scope_scrub_state(id INTEGER PRIMARY KEY CHECK(id=1),pending INTEGER NOT NULL CHECK(pending IN (0,1)))`},
"postgres": {`SELECT 1`},
},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS context_scope_scrub_state`}, "postgres": {`SELECT 1`}},
},
migration{version: 41, name: "command_audit_attribution_snapshot",
migration{version: 42, name: "command_audit_attribution_snapshot",
up: map[string][]string{
"sqlite": {`ALTER TABLE command_audit ADD COLUMN actor_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE command_audit ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`, `CREATE INDEX idx_command_audit_project_owner_offset ON command_audit(project_id,owner,id)`},
"postgres": {`ALTER TABLE command_audit ADD COLUMN actor_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE command_audit ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`, `CREATE INDEX idx_command_audit_project_owner_offset ON command_audit(project_id,owner,id)`},
@@ -949,7 +977,7 @@ func init() {
"sqlite": {`DROP INDEX IF EXISTS idx_command_audit_project_owner_offset`, `ALTER TABLE command_audit DROP COLUMN project_id`, `ALTER TABLE command_audit DROP COLUMN actor_name`},
"postgres": {`DROP INDEX IF EXISTS idx_command_audit_project_owner_offset`, `ALTER TABLE command_audit DROP COLUMN project_id`, `ALTER TABLE command_audit DROP COLUMN actor_name`},
}},
migration{version: 42, name: "project_audit_committed_links",
migration{version: 43, name: "project_audit_committed_links",
up: map[string][]string{
"sqlite": {`CREATE TABLE project_audit_links (id INTEGER PRIMARY KEY AUTOINCREMENT,host_audit_offset INTEGER NOT NULL REFERENCES command_audit(id),project_id TEXT NOT NULL,owner TEXT NOT NULL,source_kind TEXT NOT NULL,source_id TEXT NOT NULL,operation_id TEXT NOT NULL,resource_kind TEXT NOT NULL,resource_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',disclosure_class TEXT NOT NULL,idempotency_key TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(owner,idempotency_key),UNIQUE(host_audit_offset,source_kind,source_id))`, `CREATE INDEX idx_project_audit_links_source ON project_audit_links(project_id,source_kind,source_id,host_audit_offset)`},
"postgres": {`CREATE TABLE project_audit_links (id BIGSERIAL PRIMARY KEY,host_audit_offset BIGINT NOT NULL REFERENCES command_audit(id),project_id TEXT NOT NULL,owner TEXT NOT NULL,source_kind TEXT NOT NULL,source_id TEXT NOT NULL,operation_id TEXT NOT NULL,resource_kind TEXT NOT NULL,resource_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',disclosure_class TEXT NOT NULL,idempotency_key TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(owner,idempotency_key),UNIQUE(host_audit_offset,source_kind,source_id))`, `CREATE INDEX idx_project_audit_links_source ON project_audit_links(project_id,source_kind,source_id,host_audit_offset)`},
@@ -958,7 +986,7 @@ func init() {
"sqlite": {`DROP TABLE IF EXISTS project_audit_links`},
"postgres": {`DROP TABLE IF EXISTS project_audit_links`},
}},
migration{version: 43, name: "project_audit_committed_prefix",
migration{version: 44, name: "project_audit_committed_prefix",
up: map[string][]string{
// SQLite has one writer and one connection per Store; this migration
// only changes PostgreSQL's implicit command_audit ID allocation.
@@ -1011,7 +1039,7 @@ func init() {
`DROP FUNCTION project_audit_cutoff_safe(regclass)`,
},
}},
migration{version: 44, name: "project_audit_all_writers_guard",
migration{version: 45, name: "project_audit_all_writers_guard",
up: map[string][]string{
"sqlite": {`SELECT 1`},
"postgres": {
@@ -1089,7 +1117,7 @@ func init() {
`DROP FUNCTION project_audit_writers_guarded(regclass)`,
},
}},
migration{version: 45, name: "project_work_observation_outbox",
migration{version: 46, name: "project_work_observation_outbox",
up: map[string][]string{
"sqlite": {`CREATE TABLE project_work_outbox (event_id TEXT PRIMARY KEY,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',source_kind TEXT NOT NULL,source_id TEXT NOT NULL,source_version TEXT NOT NULL,source_sha256 TEXT NOT NULL,part_index INTEGER NOT NULL,span_start INTEGER NOT NULL,span_end INTEGER NOT NULL,delivery TEXT NOT NULL,scope TEXT NOT NULL,observed_at TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'pending',receipt_id TEXT NOT NULL DEFAULT '',payload_text TEXT NOT NULL DEFAULT '',metadata_json TEXT NOT NULL DEFAULT '{}')`, `CREATE INDEX idx_project_work_outbox_pending ON project_work_outbox(state,observed_at,event_id)`},
"postgres": {`CREATE TABLE project_work_outbox (event_id TEXT PRIMARY KEY,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',source_kind TEXT NOT NULL,source_id TEXT NOT NULL,source_version TEXT NOT NULL,source_sha256 TEXT NOT NULL,part_index INTEGER NOT NULL,span_start INTEGER NOT NULL,span_end INTEGER NOT NULL,delivery TEXT NOT NULL,scope TEXT NOT NULL,observed_at TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'pending',receipt_id TEXT NOT NULL DEFAULT '',payload_text TEXT NOT NULL DEFAULT '',metadata_json TEXT NOT NULL DEFAULT '{}')`, `CREATE INDEX idx_project_work_outbox_pending ON project_work_outbox(state,observed_at,event_id)`},
@@ -1098,7 +1126,7 @@ func init() {
"sqlite": {`DROP INDEX IF EXISTS idx_project_work_outbox_pending`, `DROP TABLE IF EXISTS project_work_outbox`},
"postgres": {`DROP INDEX IF EXISTS idx_project_work_outbox_pending`, `DROP TABLE IF EXISTS project_work_outbox`},
}},
migration{version: 46, name: "human_decision_proofs",
migration{version: 47, name: "human_decision_proofs",
up: map[string][]string{
"sqlite": {`CREATE TABLE human_decisions (
id TEXT PRIMARY KEY,
@@ -1145,7 +1173,7 @@ func init() {
"sqlite": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`},
"postgres": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`},
}},
migration{version: 47, name: "document_render_deliveries",
migration{version: 48, name: "document_render_deliveries",
up: map[string][]string{
"sqlite": {`CREATE TABLE document_render_deliveries (
operation_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,source_run TEXT NOT NULL DEFAULT '',
@@ -1376,7 +1404,7 @@ func (s *Store) migrate() error {
if pending {
// The prior candidate schema may have stored full prompts. VACUUM is
// outside the DDL transaction; it compacts the isolated SQLite file
// after v40 drops those columns. Refuse to serve if compaction fails.
// after v41 drops those columns. Refuse to serve if compaction fails.
if _, err := s.db.Exec(`PRAGMA secure_delete=ON`); err != nil {
return fmt.Errorf("candidate context scrub: %w", err)
}
@@ -1411,7 +1439,7 @@ func (s *Store) ensureContextScrubState() (bool, error) {
return false, err
}
// An absent row may be a partially initialized earlier candidate; mark it
// pending before any physical cleanup. A fresh v40 inserted pending=0.
// pending before any physical cleanup. A fresh v41 inserted pending=0.
if _, err := s.db.Exec(`INSERT OR IGNORE INTO context_scope_scrub_state(id,pending) VALUES(1,1)`); err != nil {
return false, err
}
@@ -1504,10 +1532,10 @@ func (s *Store) migratePass() error {
continue
}
candidateRawScope := false
if m.version == 40 {
if m.version == 41 {
candidateRawScope, err = scrubCandidateContextScope(tx, s.dialect.name())
if err != nil {
return fmt.Errorf("migrate v40: %w", err)
return fmt.Errorf("migrate v41: %w", err)
}
}
stmts := m.up[s.dialect.name()]
@@ -1519,13 +1547,13 @@ func (s *Store) migratePass() error {
return fmt.Errorf("migrate v%d (%s): %w", m.version, m.name, err)
}
}
if m.version == 40 && s.dialect.name() == "sqlite" {
if m.version == 41 && s.dialect.name() == "sqlite" {
pending := 0
if candidateRawScope {
pending = 1
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO context_scope_scrub_state(id,pending) VALUES(1,?)`, pending); err != nil {
return fmt.Errorf("migrate v40 scrub marker: %w", err)
return fmt.Errorf("migrate v41 scrub marker: %w", err)
}
}
// 故障注入点:DDL 已执行、版本未登记——历史实现在此处崩溃会留下
@@ -1541,7 +1569,7 @@ func (s *Store) migratePass() error {
}
// Earlier candidate databases were never released but may contain raw Resolve
// goals. Fresh v39 creates no such columns; v40 removes them conditionally
// goals. Fresh v40 creates no such columns; v41 removes them conditionally
// from an earlier candidate schema before serving.
func scrubCandidateContextScope(tx *sql.Tx, dialect string) (bool, error) {
found := false
+3 -3
View File
@@ -161,15 +161,15 @@ func TestMigrateRejectsLegacyFeatureNumberingBeforeApplyingCurrentTail(t *testin
func TestMigrateDownRejectsLedgerNameDriftBeforeChangingSchema(t *testing.T) {
path := filepath.Join(t.TempDir(), "down-name-drift.db")
st := openSQLiteAt(t, path)
if _, err := st.db.Exec(`UPDATE schema_migrations SET name='wrong-candidate' WHERE version=44`); err != nil {
if _, err := st.db.Exec(`UPDATE schema_migrations SET name='wrong-candidate' WHERE version=45`); err != nil {
t.Fatal(err)
}
err := st.MigrateDown(43)
err := st.MigrateDown(44)
if err == nil || !strings.Contains(err.Error(), "migration identity mismatch") {
t.Fatalf("rollback accepted a changed migration identity: %v", err)
}
var remain int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=44`).Scan(&remain); err != nil || remain != 1 {
if err := st.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=45`).Scan(&remain); err != nil || remain != 1 {
t.Fatalf("rollback changed ledger before refusing: remaining=%d err=%v", remain, err)
}
}
+13 -12
View File
@@ -42,18 +42,19 @@ var migrationBreaksOldReaders = map[int]bool{
33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段
35: false, // exec_env 新表;旧读者不读这些表
36: false, // work_task_bindings:新增绑定表,旧后台任务读面不受影响
37: false, // trusted_context_manifests:独立可信回执表,旧读者不受影响
38: false, // workflow_step_sources:新增逐步骤来源绑定表,旧读者不受影响
39: false, // context_manifest_resolve_scope:可信回执增加范围标记,旧读者不受影响
40: false, // scrub_candidate_context_scope_text:候选原文列清理,旧读者不受影响
41: false, // command_audit_attribution_snapshot:仅加列/索引,旧读者不受影响
42: false, // project_audit_committed_links:新表,旧读者不受影响
43: false, // project_audit_committed_prefix:PG 审计 ID 默认函数,旧读者兼容
44: false, // project_audit_all_writers_guard:PG 在线审计写入门禁,旧读者兼容
45: false, // project_work_observation_outbox:独立私域投递表,旧读者不读取
46: false, // human_decision_proofs:独立真人确认表,旧读者不读取
47: false, // document_render_deliveries:新增普通工作区文档交付回执表
36: false, // instance_guard 是新表,旧制品不读取它
37: false, // work_task_bindings:新增绑定表,旧后台任务读面不受影响
38: false, // trusted_context_manifests:独立可信回执表,旧读者不受影响
39: false, // workflow_step_sources:新增逐步骤来源绑定表,旧读者不受影响
40: false, // context_manifest_resolve_scope:可信回执增加范围标记,旧读者不受影响
41: false, // scrub_candidate_context_scope_text:候选原文列清理,旧读者不受影响
42: false, // command_audit_attribution_snapshot:仅加列/索引,旧读者不受影响
43: false, // project_audit_committed_links:新表,旧读者不受影响
44: false, // project_audit_committed_prefix:PG 审计 ID 默认函数,旧读者兼容
45: false, // project_audit_all_writers_guard:PG 在线审计写入门禁,旧读者兼容
46: false, // project_work_observation_outbox:独立私域投递表,旧读者不读取
47: false, // human_decision_proofs:独立真人确认表,旧读者不读取
48: false, // document_render_deliveries:新增普通工作区文档交付回执表
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
+13 -12
View File
@@ -28,18 +28,19 @@ func TestMigrationTailKeepsAdoptedExecEnvBeforeFeatureHistory(t *testing.T) {
name string
}{
{35, "exec_env_layers"},
{36, "work_task_bindings"},
{37, "trusted_context_manifests"},
{38, "workflow_step_sources"},
{39, "context_manifest_resolve_scope"},
{40, "scrub_candidate_context_scope_text"},
{41, "command_audit_attribution_snapshot"},
{42, "project_audit_committed_links"},
{43, "project_audit_committed_prefix"},
{44, "project_audit_all_writers_guard"},
{45, "project_work_observation_outbox"},
{46, "human_decision_proofs"},
{47, "document_render_deliveries"},
{36, "instance_guard"},
{37, "work_task_bindings"},
{38, "trusted_context_manifests"},
{39, "workflow_step_sources"},
{40, "context_manifest_resolve_scope"},
{41, "scrub_candidate_context_scope_text"},
{42, "command_audit_attribution_snapshot"},
{43, "project_audit_committed_links"},
{44, "project_audit_committed_prefix"},
{45, "project_audit_all_writers_guard"},
{46, "project_work_observation_outbox"},
{47, "human_decision_proofs"},
{48, "document_render_deliveries"},
}
for _, item := range want {
if got, ok := MigrationName(item.version); !ok || got != item.name {
@@ -123,7 +123,7 @@ func commandAuditSequenceName(t *testing.T, st *Store) string {
func TestProjectAuditSequenceMigrationRejectsCacheTen(t *testing.T) {
st := openProjectAuditPG(t)
if err := st.MigrateDown(42); err != nil {
if err := st.MigrateDown(43); err != nil {
t.Fatal(err)
}
seq := commandAuditSequenceName(t, st)
@@ -284,7 +284,7 @@ func TestProjectAuditSequenceCacheTenDefeatsAdvisoryLock(t *testing.T) {
func TestProjectAuditSequenceNeverCommitsPreUpgradeCachedLowID(t *testing.T) {
st := openProjectAuditPG(t)
if err := st.MigrateDown(42); err != nil {
if err := st.MigrateDown(43); err != nil {
t.Fatal(err)
}
seq := commandAuditSequenceName(t, st)
@@ -354,8 +354,8 @@ func TestProjectAuditSequenceMigrationRoundTripPreservesLinks(t *testing.T) {
if _, err := st.SaveProjectAuditLink(link); err != nil {
t.Fatal(err)
}
if err := st.MigrateDown(42); err != nil {
t.Fatalf("down to v42: %v", err)
if err := st.MigrateDown(43); err != nil {
t.Fatalf("down to v43: %v", err)
}
if _, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10); err == nil {
t.Fatal("old default exposed a strong cutoff after down")
@@ -364,7 +364,7 @@ func TestProjectAuditSequenceMigrationRoundTripPreservesLinks(t *testing.T) {
t.Fatalf("link changed during down: %+v %v", row, err)
}
if err := st.migrate(); err != nil {
t.Fatalf("up to v43: %v", err)
t.Fatalf("up to v44: %v", err)
}
rows, _, _, err = st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil || len(rows) != 1 || rows[0].Offset != offset {
@@ -484,7 +484,7 @@ func TestProjectAuditSequenceConcurrentLoad(t *testing.T) {
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
return elapsed, durations[len(durations)*95/100]
}
if err := st.MigrateDown(42); err != nil {
if err := st.MigrateDown(43); err != nil {
t.Fatal(err)
}
oldQuery := st.q(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id)
@@ -515,7 +515,7 @@ func TestProjectAuditSequenceConcurrentLoad(t *testing.T) {
statusMode = "test-only locked status wrapper"
}
newElapsed, newP95 := run("guarded", func(env agentcommand.AuditEnvelope) error { return st.AppendAudit("alice", env) })
t.Logf("isolated PostgreSQL %s audit append: %d writes per mode / %d workers; v42 direct INSERT=%s p95=%s (%.0f/s), v44 Store.AppendAudit with %s=%s p95=%s (%.0f/s); hardware/load dependent SELF only",
t.Logf("isolated PostgreSQL %s audit append: %d writes per mode / %d workers; v43 direct INSERT=%s p95=%s (%.0f/s), v45 Store.AppendAudit with %s=%s p95=%s (%.0f/s); hardware/load dependent SELF only",
serverVersion, writers*each, writers, oldElapsed, oldP95, float64(writers*each)/oldElapsed.Seconds(), statusMode, newElapsed, newP95, float64(writers*each)/newElapsed.Seconds())
seen := map[string]bool{}
var before, cutoff int64
+6 -1
View File
@@ -193,7 +193,8 @@ func openSQLiteFile(path string, createArtifacts, migrateSchema bool) (*Store, e
// 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。
// busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s
// (实测 job 2285 SQLITE_BUSY);本地档宁可排队不报错。
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate", path)
// synchronous(FULL) 是恢复方案 KTD14 的可靠档。打开后在同一连接读回,见 verifyDurability。
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)&_pragma=foreign_keys(1)&_txlock=immediate", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
@@ -297,6 +298,10 @@ func finishOpenMode(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.
db.Close()
return nil, err
}
if err := s.verifyDurability(); err != nil {
db.Close()
return nil, err
}
if createArtifacts {
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
db.Close()
+5 -5
View File
@@ -76,12 +76,12 @@ type AccountDirectoryScopeRequest struct {
// CandidateIDs or CandidateOwners are applied before search and pagination;
// domain membership stores may use owners while the provider returns stable IDs.
type AccountDirectoryScope struct {
AllowedFields []string
MatchFields []string
AllCandidates bool
CandidateIDs []string
AllowedFields []string
MatchFields []string
AllCandidates bool
CandidateIDs []string
CandidateOwners []string
ScopeVersion string
ScopeVersion string
}
type AccountDirectoryScopeResolver func(context.Context, AccountDirectoryScopeRequest) (AccountDirectoryScope, error)
+3 -1
View File
@@ -59,7 +59,9 @@ import (
// It does not consume the proof; the caller does that in its own transaction.
// 2.23.0-experimental: InProc.HostOptions / CLIHook, plus MergeHostOptions,
// RegisterCLIFlags、RunCLIHooks(组合二进制把插件声明并进基座启动,不覆盖显式参数)。
const SurfaceVersion = "2.23.0-experimental"
// 2.24.0-experimental:EXT-20 可选 RegulationsHost 与受信进程内 RegulationsRegistrar
// (共享法规检索与网络证据;仅插件 kbreg 可注册提供方)。
const SurfaceVersion = "2.24.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
+209
View File
@@ -0,0 +1,209 @@
package extension
import (
"context"
"errors"
"time"
)
// RegulationsSchemaVersion is the only request schema the host accepts for
// EXT-20; any other value is rejected before reaching the provider.
const RegulationsSchemaVersion = 1
const (
RegulationsStateOK = "ok"
RegulationsStateEmpty = "empty"
RegulationsStateDegraded = "degraded"
RegulationsStateUnavailable = "unavailable"
RegulationDisclosureFull = "full"
RegulationDisclosureExcerpt = "excerpt"
RegulationDisclosureMetadataOnly = "metadata_only"
RegulationDisclosureWithheld = "withheld"
WebSearchStateOK = "ok"
WebSearchStateEmpty = "empty"
WebSearchStateUnavailable = "unavailable"
WebSearchStateRateLimited = "rate_limited"
WebSearchStateRejected = "rejected"
WebSearchStateError = "error"
WebCaptureStateCaptured = "captured"
WebCaptureStateRejected = "rejected"
WebCaptureStateFailed = "failed"
WebRetentionFull = "full"
WebRetentionExcerpt = "excerpt"
WebRetentionMetadataOnly = "metadata_only"
)
// ErrRegulationsUnavailable is returned by every RegulationsHost method when no
// trusted provider is registered. Callers must not fall back to KnowledgeSearch.
var ErrRegulationsUnavailable = errors.New("extension: regulations provider unavailable")
// RegulationsCaller is filled by the host from the trusted dispatch context.
// It deliberately has no project ID: shared regulation access neither widens
// nor narrows with a project.
type RegulationsCaller struct {
PluginID string `json:"pluginId"`
Actor string `json:"actor,omitempty"`
Owner string `json:"owner"`
}
// RegulationsSearchRequest searches the controlled regulation corpus. An empty
// CorpusRevision means the current published revision; the result echoes the
// revision actually searched.
type RegulationsSearchRequest struct {
SchemaVersion int `json:"schemaVersion"`
Query string `json:"query"`
CorpusRevision string `json:"corpusRevision,omitempty"`
Kinds []string `json:"kinds,omitempty"`
CurrentOnly bool `json:"currentOnly,omitempty"`
DocNo string `json:"docNo,omitempty"`
ClauseNo string `json:"clauseNo,omitempty"`
Limit int `json:"limit,omitempty"`
}
type RegulationsSearchResult struct {
CorpusRevision string `json:"corpusRevision"`
State string `json:"state"` // ok|empty|degraded|unavailable
DegradedReasons []string `json:"degradedReasons,omitempty"`
Hits []RegulationHit `json:"hits"`
}
type RegulationHit struct {
RegulationID string `json:"regulationId"`
EditionID string `json:"editionId"`
NodeID string `json:"nodeId"`
DocNo string `json:"docNo,omitempty"`
Title string `json:"title,omitempty"`
EditionLabel string `json:"editionLabel,omitempty"`
LegalStatus string `json:"legalStatus,omitempty"`
ClauseNo string `json:"clauseNo,omitempty"`
HeadingPath []string `json:"headingPath,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
ExcerptStart int `json:"excerptStart,omitempty"`
ExcerptEnd int `json:"excerptEnd,omitempty"`
NodeSHA256 string `json:"nodeSha256,omitempty"`
SourceAssetSHA256 string `json:"sourceAssetSha256,omitempty"`
PageStart int `json:"pageStart,omitempty"`
PageEnd int `json:"pageEnd,omitempty"`
Disclosure string `json:"disclosure"` // full|excerpt|metadata_only|withheld
WithheldReason string `json:"withheldReason,omitempty"`
IsSummary bool `json:"isSummary,omitempty"`
MatchKinds []string `json:"matchKinds,omitempty"`
Score float64 `json:"score,omitempty"`
}
// RegulationEvidenceRequest retrieves the original text span of a node pinned
// to a corpus revision.
type RegulationEvidenceRequest struct {
SchemaVersion int `json:"schemaVersion"`
CorpusRevision string `json:"corpusRevision"`
NodeID string `json:"nodeId"`
Start int `json:"start,omitempty"`
End int `json:"end,omitempty"`
}
type RegulationEvidence struct {
CorpusRevision string `json:"corpusRevision"`
RegulationID string `json:"regulationId,omitempty"`
EditionID string `json:"editionId,omitempty"`
NodeID string `json:"nodeId"`
ClauseNo string `json:"clauseNo,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
Start int `json:"start,omitempty"`
End int `json:"end,omitempty"`
NodeSHA256 string `json:"nodeSha256,omitempty"`
SourceAssetSHA256 string `json:"sourceAssetSha256,omitempty"`
PageStart int `json:"pageStart,omitempty"`
PageEnd int `json:"pageEnd,omitempty"`
Disclosure string `json:"disclosure"`
WithheldReason string `json:"withheldReason,omitempty"`
HashVerified bool `json:"hashVerified"`
}
// WebSearchRequest performs a real web search. Exclusions are caller-declared
// confidential terms (project names, numbers, people); a query containing any
// of them is rejected by the host before the provider sees it.
type WebSearchRequest struct {
SchemaVersion int `json:"schemaVersion"`
Query string `json:"query"`
Exclusions []string `json:"exclusions,omitempty"`
PreferOfficial bool `json:"preferOfficial,omitempty"`
Limit int `json:"limit,omitempty"`
}
type WebSearchResult struct {
Provider string `json:"provider,omitempty"`
SentQuery string `json:"sentQuery,omitempty"`
SearchedAt time.Time `json:"searchedAt"`
State string `json:"state"` // ok|empty|unavailable|rate_limited|rejected|error
Reason string `json:"reason,omitempty"`
Results []WebSearchHit `json:"results,omitempty"`
}
type WebSearchHit struct {
Rank int `json:"rank"`
URL string `json:"url"`
Title string `json:"title,omitempty"`
Snippet string `json:"snippet,omitempty"`
Publisher string `json:"publisher,omitempty"`
Official bool `json:"official,omitempty"`
}
// WebCaptureRequest fetches a page found by a prior SearchWeb call.
type WebCaptureRequest struct {
SchemaVersion int `json:"schemaVersion"`
URL string `json:"url"`
SearchProvider string `json:"searchProvider,omitempty"`
SearchQuery string `json:"searchQuery,omitempty"`
SearchRank int `json:"searchRank,omitempty"`
}
type WebCapture struct {
CaptureID string `json:"captureId,omitempty"`
FinalURL string `json:"finalUrl,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
ContentType string `json:"contentType,omitempty"`
ByteSize int64 `json:"byteSize,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Retention string `json:"retention,omitempty"` // full|excerpt|metadata_only
// Text is present only when the source rights allow full retention.
Text string `json:"text,omitempty"`
Candidates []WebClauseCandidate `json:"candidates,omitempty"`
State string `json:"state"` // captured|rejected|failed
Reason string `json:"reason,omitempty"`
}
type WebClauseCandidate struct {
ClauseNo string `json:"clauseNo,omitempty"`
Excerpt string `json:"excerpt"`
Start int `json:"start"`
End int `json:"end"`
}
// RegulationsProvider is implemented and registered by the trusted regulations
// knowledge plugin (plugin ID "kbreg"); the host ships no implementation.
type RegulationsProvider interface {
SearchRegulations(context.Context, RegulationsCaller, RegulationsSearchRequest) (RegulationsSearchResult, error)
ResolveRegulationEvidence(context.Context, RegulationsCaller, RegulationEvidenceRequest) (RegulationEvidence, error)
SearchWeb(context.Context, RegulationsCaller, WebSearchRequest) (WebSearchResult, error)
CaptureWeb(context.Context, RegulationsCaller, WebCaptureRequest) (WebCapture, error)
}
// RegulationsRegistrar is optional on a compiled in-process Registry. Only
// plugin ID "kbreg" may register, and only once.
type RegulationsRegistrar interface {
RegisterRegulationsProvider(RegulationsProvider) error
}
// RegulationsHost is optional on dispatch Host handles (obtained by type
// assertion). A missing capability means unavailable; callers must not fall
// back to KnowledgeSearch.
type RegulationsHost interface {
SearchRegulations(context.Context, RegulationsSearchRequest) (RegulationsSearchResult, error)
ResolveRegulationEvidence(context.Context, RegulationEvidenceRequest) (RegulationEvidence, error)
SearchWeb(context.Context, WebSearchRequest) (WebSearchResult, error)
CaptureWeb(context.Context, WebCaptureRequest) (WebCapture, error)
}
+78
View File
@@ -0,0 +1,78 @@
package extension_test
import (
"context"
"encoding/json"
"strings"
"testing"
"agent-system-backend/pkg/extension"
)
// publicRegulationsProvider is a downstream provider built only from the
// public extension surface.
type publicRegulationsProvider struct{}
func (publicRegulationsProvider) SearchRegulations(context.Context, extension.RegulationsCaller, extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) {
return extension.RegulationsSearchResult{State: extension.RegulationsStateEmpty}, nil
}
func (publicRegulationsProvider) ResolveRegulationEvidence(context.Context, extension.RegulationsCaller, extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) {
return extension.RegulationEvidence{Disclosure: extension.RegulationDisclosureMetadataOnly}, nil
}
func (publicRegulationsProvider) SearchWeb(context.Context, extension.RegulationsCaller, extension.WebSearchRequest) (extension.WebSearchResult, error) {
return extension.WebSearchResult{State: extension.WebSearchStateUnavailable}, nil
}
func (publicRegulationsProvider) CaptureWeb(context.Context, extension.RegulationsCaller, extension.WebCaptureRequest) (extension.WebCapture, error) {
return extension.WebCapture{State: extension.WebCaptureStateFailed}, nil
}
var _ extension.RegulationsProvider = publicRegulationsProvider{}
func TestRegulationsContractIsOptional(t *testing.T) {
var host extension.Host
if _, ok := host.(extension.RegulationsHost); ok {
t.Fatal("nil host must not expose regulations")
}
var registry extension.Registry
if _, ok := registry.(extension.RegulationsRegistrar); ok {
t.Fatal("nil registry must not expose regulations registration")
}
if extension.RegulationsSchemaVersion != 1 {
t.Fatalf("schema version = %d", extension.RegulationsSchemaVersion)
}
if extension.ErrRegulationsUnavailable == nil {
t.Fatal("unavailable error must be defined")
}
}
func TestRegulationsWireNames(t *testing.T) {
states := []string{extension.RegulationsStateOK, extension.RegulationsStateEmpty, extension.RegulationsStateDegraded,
extension.RegulationsStateUnavailable, extension.RegulationDisclosureFull, extension.RegulationDisclosureExcerpt,
extension.RegulationDisclosureMetadataOnly, extension.RegulationDisclosureWithheld, extension.WebSearchStateOK,
extension.WebSearchStateEmpty, extension.WebSearchStateUnavailable, extension.WebSearchStateRateLimited,
extension.WebSearchStateRejected, extension.WebSearchStateError, extension.WebCaptureStateCaptured,
extension.WebCaptureStateRejected, extension.WebCaptureStateFailed, extension.WebRetentionFull,
extension.WebRetentionExcerpt, extension.WebRetentionMetadataOnly}
want := "ok empty degraded unavailable full excerpt metadata_only withheld ok empty unavailable rate_limited " +
"rejected error captured rejected failed full excerpt metadata_only"
if got := strings.Join(states, " "); got != want {
t.Fatalf("wire states = %q", got)
}
raw, err := json.Marshal(extension.WebSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "q"})
if err != nil {
t.Fatal(err)
}
if string(raw) != `{"schemaVersion":1,"query":"q"}` {
t.Fatalf("optional fields not omitted: %s", raw)
}
raw, err = json.Marshal(extension.RegulationsCaller{PluginID: "rdm", Owner: "alice"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.ToLower(string(raw)), "project") {
t.Fatalf("caller must not carry a project: %s", raw)
}
}
+46
View File
@@ -34,6 +34,7 @@ ErrProjectAuditDenied
ErrProjectAuditLinkConflict
ErrProjectAuditPending
ErrProjectAuditUnsupported
ErrRegulationsUnavailable
Handler
Host
Host.ArtifactContent
@@ -147,6 +148,33 @@ Registry.AddSkill
Registry.Note
Registry.RegisterResourceAuthorizer
Registry.RegisterResourceAuthorizerV2
RegulationDisclosureExcerpt
RegulationDisclosureFull
RegulationDisclosureMetadataOnly
RegulationDisclosureWithheld
RegulationEvidence
RegulationEvidenceRequest
RegulationHit
RegulationsCaller
RegulationsHost
RegulationsHost.CaptureWeb
RegulationsHost.ResolveRegulationEvidence
RegulationsHost.SearchRegulations
RegulationsHost.SearchWeb
RegulationsProvider
RegulationsProvider.CaptureWeb
RegulationsProvider.ResolveRegulationEvidence
RegulationsProvider.SearchRegulations
RegulationsProvider.SearchWeb
RegulationsRegistrar
RegulationsRegistrar.RegisterRegulationsProvider
RegulationsSchemaVersion
RegulationsSearchRequest
RegulationsSearchResult
RegulationsStateDegraded
RegulationsStateEmpty
RegulationsStateOK
RegulationsStateUnavailable
Request
ResetCompiled
ResourceAuthorizer
@@ -162,6 +190,24 @@ SurfaceHost.SurfaceList
SurfaceHost.SurfaceSave
SurfaceSaveRequest
SurfaceVersion
WebCapture
WebCaptureRequest
WebCaptureStateCaptured
WebCaptureStateFailed
WebCaptureStateRejected
WebClauseCandidate
WebRetentionExcerpt
WebRetentionFull
WebRetentionMetadataOnly
WebSearchHit
WebSearchRequest
WebSearchResult
WebSearchStateEmpty
WebSearchStateError
WebSearchStateOK
WebSearchStateRateLimited
WebSearchStateRejected
WebSearchStateUnavailable
WorkCompletionHost
WorkCompletionHost.CompleteForWork
WorkCompletionRequest