Merge Backend main into the shared renderer and two-trees work
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:
@@ -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 节一致,无阻塞项。
|
||||||
|
- 影响与剩余项:新增可选能力,未注册提供方时返回不可用,既有行为不变。
|
||||||
@@ -48,9 +48,11 @@ func main() {
|
|||||||
os.Exit(runCapabilities(os.Args[2:]))
|
os.Exit(runCapabilities(os.Args[2:]))
|
||||||
case "env":
|
case "env":
|
||||||
os.Exit(runEnv(os.Args[2:]))
|
os.Exit(runEnv(os.Args[2:]))
|
||||||
|
case "migrate":
|
||||||
|
os.Exit(runMigrate(os.Args[2:]))
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1])
|
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)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,6 +79,7 @@ func main() {
|
|||||||
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
|
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
|
||||||
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
|
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
|
||||||
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
|
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
|
||||||
|
adoptGuard := fs.Bool("adopt-instance-guard", false, "数据库被替换或锁文件被重建后,显式重写 SQLite 活动服务守卫")
|
||||||
extension.RegisterCLIFlags(fs)
|
extension.RegisterCLIFlags(fs)
|
||||||
fs.Parse(os.Args[1:])
|
fs.Parse(os.Args[1:])
|
||||||
handled, hookErr := extension.RunCLIHooks(extension.Options{
|
handled, hookErr := extension.RunCLIHooks(extension.Options{
|
||||||
@@ -91,6 +94,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cliSet := visited(fs)
|
cliSet := visited(fs)
|
||||||
|
delete(cliSet, "adopt-instance-guard")
|
||||||
|
|
||||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||||
@@ -141,7 +145,8 @@ func main() {
|
|||||||
DisableConvCardMirror: extOpt.DisableConvCardMirror,
|
DisableConvCardMirror: extOpt.DisableConvCardMirror,
|
||||||
DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
|
DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
|
||||||
ExecWorkspace: resolved.ExecWorkspace, ExecEnvRoot: resolved.ExecEnvRoot,
|
ExecWorkspace: resolved.ExecWorkspace, ExecEnvRoot: resolved.ExecEnvRoot,
|
||||||
Tier: resolved.Tier,
|
Tier: resolved.Tier,
|
||||||
|
AdoptInstanceGuard: *adoptGuard,
|
||||||
PGPool: store.PoolOptions{
|
PGPool: store.PoolOptions{
|
||||||
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
|
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
|
||||||
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
|
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -304,6 +304,8 @@ var copyOrder = []string{
|
|||||||
"recovery_proposals", "recovery_proposal_proofs",
|
"recovery_proposals", "recovery_proposal_proofs",
|
||||||
// v35 分层 Python 环境(无外键;申请与会话绑定要随库迁走)
|
// v35 分层 Python 环境(无外键;申请与会话绑定要随库迁走)
|
||||||
"exec_env_requests", "exec_env_bindings",
|
"exec_env_requests", "exec_env_bindings",
|
||||||
|
// U8:活动服务锁的库内标记(无外键;迁移须保留以免目标库丢掉锁根)
|
||||||
|
"instance_guard",
|
||||||
}
|
}
|
||||||
|
|
||||||
const controlDDL = `
|
const controlDDL = `
|
||||||
|
|||||||
@@ -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 {
|
if _, err := db.Exec(`ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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 {
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := db.Close(); err != nil {
|
if err := db.Close(); err != nil {
|
||||||
|
|||||||
Vendored
+1
-1
Submodule deps/development-manager updated: bd3a7de57a...67784f43eb
+6
-43
@@ -31,7 +31,6 @@ import (
|
|||||||
"agent-native/runtime/telemetry"
|
"agent-native/runtime/telemetry"
|
||||||
agenttools "agent-native/runtime/tools"
|
agenttools "agent-native/runtime/tools"
|
||||||
nativeadp "agent-squad/adapters/native"
|
nativeadp "agent-squad/adapters/native"
|
||||||
"agent-system-backend/internal/blobstore"
|
|
||||||
"agent-system-backend/internal/bus"
|
"agent-system-backend/internal/bus"
|
||||||
"agent-system-backend/internal/cardsvc"
|
"agent-system-backend/internal/cardsvc"
|
||||||
"agent-system-backend/internal/cmdbus"
|
"agent-system-backend/internal/cmdbus"
|
||||||
@@ -99,6 +98,8 @@ type Options struct {
|
|||||||
Tier string
|
Tier string
|
||||||
// PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。
|
// PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。
|
||||||
PGPool store.PoolOptions
|
PGPool store.PoolOptions
|
||||||
|
// AdoptInstanceGuard 在数据库被替换或锁文件被重建后,显式重写守卫记录。
|
||||||
|
AdoptInstanceGuard bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
|
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
|
||||||
@@ -177,49 +178,11 @@ func Run(opt Options) error {
|
|||||||
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
|
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
|
||||||
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
|
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
|
||||||
}
|
}
|
||||||
var st *store.Store
|
st, releaseStore, err := openServiceStore(opt)
|
||||||
var err error
|
|
||||||
if opt.StorageDSN != "" {
|
|
||||||
artifacts := opt.ArtifactsDir
|
|
||||||
if artifacts == "" {
|
|
||||||
artifacts = filepath.Join(opt.DataDir, "artifacts")
|
|
||||||
}
|
|
||||||
// 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享);
|
|
||||||
// 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。
|
|
||||||
blobs, berr := blobstore.NewS3FromEnv()
|
|
||||||
if berr != nil {
|
|
||||||
return fmt.Errorf("blobstore s3: %w", berr)
|
|
||||||
}
|
|
||||||
if blobs != nil {
|
|
||||||
log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix)
|
|
||||||
}
|
|
||||||
st, err = store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs)
|
|
||||||
if err != nil && opt.Tier == "team" {
|
|
||||||
// team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。
|
|
||||||
return fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
st, err = store.OpenSQLite(filepath.Join(opt.DataDir, "agent-system.db"))
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open store: %w", err)
|
return err
|
||||||
}
|
|
||||||
defer st.Close()
|
|
||||||
// U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
|
|
||||||
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
|
|
||||||
if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" {
|
|
||||||
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
|
|
||||||
if dbref == "" {
|
|
||||||
if opt.StorageDSN != "" {
|
|
||||||
dbref = "pg"
|
|
||||||
} else {
|
|
||||||
dbref = "sqlite"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
|
|
||||||
return fmt.Errorf("system identity: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
defer releaseStore()
|
||||||
pool := st.Pool()
|
pool := st.Pool()
|
||||||
if versions, verr := st.MigrationStatus(); verr == nil {
|
if versions, verr := st.MigrationStatus(); verr == nil {
|
||||||
log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)",
|
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
|
return err
|
||||||
}
|
}
|
||||||
contextSlot := &projectContextSlot{}
|
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: ®ulationsSlot{}, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn,
|
||||||
accountGuard: func(owner string) error {
|
accountGuard: func(owner string) error {
|
||||||
valid, err := effectiveAccount(context.Background(), owner)
|
valid, err := effectiveAccount(context.Background(), owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -129,6 +129,17 @@ func (r pluginRegistry) RegisterAccountDirectoryScopeResolver(fn extension.Accou
|
|||||||
return r.caps.directory.register(r.pluginID, fn)
|
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 适配器槽
|
// RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽
|
||||||
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
|
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
|
||||||
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
|
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
|
||||||
@@ -171,6 +182,14 @@ func (r recordRegistry) RegisterAccountDirectoryScopeResolver(fn extension.Accou
|
|||||||
return registrar.RegisterAccountDirectoryScopeResolver(fn)
|
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 {
|
func (r recordRegistry) AddCommand(spec json.RawMessage) error {
|
||||||
if err := r.Registry.AddCommand(spec); err != nil {
|
if err := r.Registry.AddCommand(spec); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type hostcaps struct {
|
|||||||
directory *accountDirectorySlot
|
directory *accountDirectorySlot
|
||||||
contexts *projectContextSlot
|
contexts *projectContextSlot
|
||||||
projectAudit *projectAuditSlot
|
projectAudit *projectAuditSlot
|
||||||
|
regulations *regulationsSlot // EXT-20 法规提供方槽(共享指针;nil/未注册=不可用)
|
||||||
accountGuard func(string) error
|
accountGuard func(string) error
|
||||||
allowImplicitLocal bool
|
allowImplicitLocal bool
|
||||||
st *store.Store
|
st *store.Store
|
||||||
|
|||||||
@@ -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{}
|
||||||
@@ -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: ®ulationsSlot{}}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ®ulationsSlot{}}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"agent-system-backend/internal/store"
|
"agent-system-backend/internal/store"
|
||||||
@@ -113,7 +114,6 @@ func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) {
|
|||||||
h := srv.Handler()
|
h := srv.Handler()
|
||||||
adminCookie := bootAdmin(t, h, st)
|
adminCookie := bootAdmin(t, h, st)
|
||||||
for _, body := range []string{
|
for _, body := range []string{
|
||||||
`{"owner":"blocked-password","password":"old-pass-9"}`,
|
|
||||||
`{"owner":"blocked-admin","isAdmin":true}`,
|
`{"owner":"blocked-admin","isAdmin":true}`,
|
||||||
} {
|
} {
|
||||||
if rec := do(h, "POST", "/api/admin/users", body, adminCookie); rec.Code != http.StatusBadRequest {
|
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)
|
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
@@ -324,8 +324,9 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Profile store.AccountProfile `json:"profile"`
|
Profile store.AccountProfile `json:"profile"`
|
||||||
|
Password *string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := decodeStrictJSON(r, &req); err != nil {
|
if err := decodeStrictJSON(r, &req); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
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)
|
http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
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 {
|
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,
|
"userId": account.ID, "owner": account.Owner, "profile": account.Profile,
|
||||||
"profileVersion": account.ProfileVersion, "enabled": account.Enabled,
|
"profileVersion": account.ProfileVersion, "enabled": account.Enabled,
|
||||||
"stateVersion": account.StateVersion, "isAdmin": account.IsAdmin,
|
"stateVersion": account.StateVersion, "isAdmin": account.IsAdmin,
|
||||||
"passwordSet": account.HasPassword, "initializationState": account.InitializationState,
|
"passwordSet": account.HasPassword, "mustChangePassword": account.MustChangePassword,
|
||||||
|
"initializationState": account.InitializationState,
|
||||||
"initializationGeneration": account.InitializationGeneration,
|
"initializationGeneration": account.InitializationGeneration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,13 +483,32 @@ func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复
|
// resetAdminUserPassword:管理员生成随机临时密码。单事务:must_change=1、bump
|
||||||
// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用
|
// auth_version、作废未用登录 Key、吊销全部会话、审计。
|
||||||
// 登录 Key、吊销全部会话、审计。
|
|
||||||
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, ok := s.requireAdmin(w, r); ok {
|
actor, ok := s.requireAdmin(w, r)
|
||||||
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
|
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(恢复流程主入口)----
|
// ---- 管理员:一次性登录 Key(恢复流程主入口)----
|
||||||
|
|||||||
@@ -423,19 +423,33 @@ func TestAdminAccountFlow(t *testing.T) {
|
|||||||
t.Fatalf("non-admin create must 403: %d", rec.Code)
|
t.Fatalf("non-admin create must 403: %d", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 旧管理员重置入口关闭,不能动 alice 的密码与当前会话。
|
// 管理员重置总是生成临时密码,销毁 alice 的旧会话并强制首登改密。
|
||||||
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusGone {
|
reset := session("POST", "/api/admin/users/alice/password", `{}`)
|
||||||
t.Fatalf("admin reset must be closed: %d %s", rec.Code, rec.Body.String())
|
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 {
|
var resetBody struct {
|
||||||
t.Fatalf("closed reset must preserve alice session: %d", rec.Code)
|
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 = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
t.Fatalf("closed reset must preserve normal login: %d %s", rec.Code, rec.Body.String())
|
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 身份)。
|
// API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) {
|
|||||||
|
|
||||||
func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) }
|
func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) }
|
||||||
|
|
||||||
func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) {
|
func TestLegacyAdministratorLoginKeyIssuanceEntrancesAreClosed(t *testing.T) {
|
||||||
srv, st := newAuthTestServer(t)
|
srv, st := newAuthTestServer(t)
|
||||||
h := srv.Handler()
|
h := srv.Handler()
|
||||||
admin := bootAdmin(t, h, st)
|
admin := bootAdmin(t, h, st)
|
||||||
@@ -114,7 +114,6 @@ func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, route := range []struct{ path, body string }{
|
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":"recover"}`},
|
||||||
{"/api/admin/users/victim/login-keys", `{"purpose":"activate"}`},
|
{"/api/admin/users/victim/login-keys", `{"purpose":"activate"}`},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -299,6 +299,53 @@ func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountPr
|
|||||||
return a, nil
|
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
|
// insertPendingAccountTx lets the trusted local initializer create and grant in
|
||||||
// one transaction without impersonating an administrator. The caller owns its
|
// one transaction without impersonating an administrator. The caller owns its
|
||||||
// authorization, audit and commit; this helper always inserts an ordinary user.
|
// authorization, audit and commit; this helper always inserts an ordinary user.
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func TestCandidateV35RawScopeColumnsAreScrubbedOnUpgrade(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, query := range []string{
|
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_goal TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE context_manifests ADD COLUMN resolve_target_id 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'`,
|
`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()
|
defer upgraded.Close()
|
||||||
var columns int
|
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 {
|
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
|
var count int
|
||||||
if err := upgraded.db.QueryRow(`SELECT COUNT(*) FROM context_manifests WHERE attempt_id='old-model'`).Scan(&count); err != nil || count != 1 {
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, query := range []string{
|
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_goal TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE context_manifests ADD COLUMN resolve_target_id 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'`,
|
`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
|
var pending int
|
||||||
if err := st.db.QueryRow(`SELECT pending FROM context_scope_scrub_state WHERE id=1`).Scan(&pending); err != nil || pending != 1 {
|
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 {
|
if err := st.Close(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -235,7 +235,7 @@ func TestCandidateV35PostgresRawScopeScrubAndFreshSchema(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, query := range []string{
|
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_goal TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE context_manifests ADD COLUMN resolve_target_id 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'`,
|
`UPDATE context_manifests SET resolve_goal='OLD_RESTRICTED_BODY',resolve_target_id='doc-private'`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -902,28 +902,56 @@ func init() {
|
|||||||
up: map[string][]string{"sqlite": execEnvUp, "postgres": execEnvUp},
|
up: map[string][]string{"sqlite": execEnvUp, "postgres": execEnvUp},
|
||||||
down: map[string][]string{"sqlite": execEnvDown, "postgres": execEnvDown},
|
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{
|
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)`},
|
"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)`},
|
"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`}},
|
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{
|
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')`},
|
"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')`},
|
"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`}},
|
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{
|
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)`},
|
"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)`},
|
"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`}},
|
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{
|
up: map[string][]string{
|
||||||
"sqlite": {`ALTER TABLE context_manifests ADD COLUMN scope_bound INTEGER NOT NULL DEFAULT 0`},
|
"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`},
|
"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`},
|
"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{
|
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)))`},
|
"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`},
|
"postgres": {`SELECT 1`},
|
||||||
},
|
},
|
||||||
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS context_scope_scrub_state`}, "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{
|
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)`},
|
"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)`},
|
"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`},
|
"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`},
|
"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{
|
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)`},
|
"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)`},
|
"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`},
|
"sqlite": {`DROP TABLE IF EXISTS project_audit_links`},
|
||||||
"postgres": {`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{
|
up: map[string][]string{
|
||||||
// SQLite has one writer and one connection per Store; this migration
|
// SQLite has one writer and one connection per Store; this migration
|
||||||
// only changes PostgreSQL's implicit command_audit ID allocation.
|
// only changes PostgreSQL's implicit command_audit ID allocation.
|
||||||
@@ -1011,7 +1039,7 @@ func init() {
|
|||||||
`DROP FUNCTION project_audit_cutoff_safe(regclass)`,
|
`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{
|
up: map[string][]string{
|
||||||
"sqlite": {`SELECT 1`},
|
"sqlite": {`SELECT 1`},
|
||||||
"postgres": {
|
"postgres": {
|
||||||
@@ -1089,7 +1117,7 @@ func init() {
|
|||||||
`DROP FUNCTION project_audit_writers_guarded(regclass)`,
|
`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{
|
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)`},
|
"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)`},
|
"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`},
|
"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`},
|
"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{
|
up: map[string][]string{
|
||||||
"sqlite": {`CREATE TABLE human_decisions (
|
"sqlite": {`CREATE TABLE human_decisions (
|
||||||
id TEXT PRIMARY KEY,
|
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`},
|
"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`},
|
"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{
|
up: map[string][]string{
|
||||||
"sqlite": {`CREATE TABLE document_render_deliveries (
|
"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 '',
|
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 {
|
if pending {
|
||||||
// The prior candidate schema may have stored full prompts. VACUUM is
|
// The prior candidate schema may have stored full prompts. VACUUM is
|
||||||
// outside the DDL transaction; it compacts the isolated SQLite file
|
// 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 {
|
if _, err := s.db.Exec(`PRAGMA secure_delete=ON`); err != nil {
|
||||||
return fmt.Errorf("candidate context scrub: %w", err)
|
return fmt.Errorf("candidate context scrub: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1411,7 +1439,7 @@ func (s *Store) ensureContextScrubState() (bool, error) {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
// An absent row may be a partially initialized earlier candidate; mark it
|
// 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 {
|
if _, err := s.db.Exec(`INSERT OR IGNORE INTO context_scope_scrub_state(id,pending) VALUES(1,1)`); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -1504,10 +1532,10 @@ func (s *Store) migratePass() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
candidateRawScope := false
|
candidateRawScope := false
|
||||||
if m.version == 40 {
|
if m.version == 41 {
|
||||||
candidateRawScope, err = scrubCandidateContextScope(tx, s.dialect.name())
|
candidateRawScope, err = scrubCandidateContextScope(tx, s.dialect.name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("migrate v40: %w", err)
|
return fmt.Errorf("migrate v41: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stmts := m.up[s.dialect.name()]
|
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)
|
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
|
pending := 0
|
||||||
if candidateRawScope {
|
if candidateRawScope {
|
||||||
pending = 1
|
pending = 1
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(`INSERT OR REPLACE INTO context_scope_scrub_state(id,pending) VALUES(1,?)`, pending); err != nil {
|
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 已执行、版本未登记——历史实现在此处崩溃会留下
|
// 故障注入点:DDL 已执行、版本未登记——历史实现在此处崩溃会留下
|
||||||
@@ -1541,7 +1569,7 @@ func (s *Store) migratePass() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Earlier candidate databases were never released but may contain raw Resolve
|
// 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.
|
// from an earlier candidate schema before serving.
|
||||||
func scrubCandidateContextScope(tx *sql.Tx, dialect string) (bool, error) {
|
func scrubCandidateContextScope(tx *sql.Tx, dialect string) (bool, error) {
|
||||||
found := false
|
found := false
|
||||||
|
|||||||
@@ -161,15 +161,15 @@ func TestMigrateRejectsLegacyFeatureNumberingBeforeApplyingCurrentTail(t *testin
|
|||||||
func TestMigrateDownRejectsLedgerNameDriftBeforeChangingSchema(t *testing.T) {
|
func TestMigrateDownRejectsLedgerNameDriftBeforeChangingSchema(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "down-name-drift.db")
|
path := filepath.Join(t.TempDir(), "down-name-drift.db")
|
||||||
st := openSQLiteAt(t, path)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
err := st.MigrateDown(43)
|
err := st.MigrateDown(44)
|
||||||
if err == nil || !strings.Contains(err.Error(), "migration identity mismatch") {
|
if err == nil || !strings.Contains(err.Error(), "migration identity mismatch") {
|
||||||
t.Fatalf("rollback accepted a changed migration identity: %v", err)
|
t.Fatalf("rollback accepted a changed migration identity: %v", err)
|
||||||
}
|
}
|
||||||
var remain int
|
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)
|
t.Fatalf("rollback changed ledger before refusing: remaining=%d err=%v", remain, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,18 +42,19 @@ var migrationBreaksOldReaders = map[int]bool{
|
|||||||
33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
|
33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
|
||||||
34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段
|
34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段
|
||||||
35: false, // exec_env 新表;旧读者不读这些表
|
35: false, // exec_env 新表;旧读者不读这些表
|
||||||
36: false, // work_task_bindings:新增绑定表,旧后台任务读面不受影响
|
36: false, // instance_guard 是新表,旧制品不读取它
|
||||||
37: false, // trusted_context_manifests:独立可信回执表,旧读者不受影响
|
37: false, // work_task_bindings:新增绑定表,旧后台任务读面不受影响
|
||||||
38: false, // workflow_step_sources:新增逐步骤来源绑定表,旧读者不受影响
|
38: false, // trusted_context_manifests:独立可信回执表,旧读者不受影响
|
||||||
39: false, // context_manifest_resolve_scope:可信回执增加范围标记,旧读者不受影响
|
39: false, // workflow_step_sources:新增逐步骤来源绑定表,旧读者不受影响
|
||||||
40: false, // scrub_candidate_context_scope_text:候选原文列清理,旧读者不受影响
|
40: false, // context_manifest_resolve_scope:可信回执增加范围标记,旧读者不受影响
|
||||||
41: false, // command_audit_attribution_snapshot:仅加列/索引,旧读者不受影响
|
41: false, // scrub_candidate_context_scope_text:候选原文列清理,旧读者不受影响
|
||||||
42: false, // project_audit_committed_links:新表,旧读者不受影响
|
42: false, // command_audit_attribution_snapshot:仅加列/索引,旧读者不受影响
|
||||||
43: false, // project_audit_committed_prefix:PG 审计 ID 默认函数,旧读者兼容
|
43: false, // project_audit_committed_links:新表,旧读者不受影响
|
||||||
44: false, // project_audit_all_writers_guard:PG 在线审计写入门禁,旧读者兼容
|
44: false, // project_audit_committed_prefix:PG 审计 ID 默认函数,旧读者兼容
|
||||||
45: false, // project_work_observation_outbox:独立私域投递表,旧读者不读取
|
45: false, // project_audit_all_writers_guard:PG 在线审计写入门禁,旧读者兼容
|
||||||
46: false, // human_decision_proofs:独立真人确认表,旧读者不读取
|
46: false, // project_work_observation_outbox:独立私域投递表,旧读者不读取
|
||||||
47: false, // document_render_deliveries:新增普通工作区文档交付回执表
|
47: false, // human_decision_proofs:独立真人确认表,旧读者不读取
|
||||||
|
48: false, // document_render_deliveries:新增普通工作区文档交付回执表
|
||||||
}
|
}
|
||||||
|
|
||||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||||
|
|||||||
@@ -28,18 +28,19 @@ func TestMigrationTailKeepsAdoptedExecEnvBeforeFeatureHistory(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
}{
|
}{
|
||||||
{35, "exec_env_layers"},
|
{35, "exec_env_layers"},
|
||||||
{36, "work_task_bindings"},
|
{36, "instance_guard"},
|
||||||
{37, "trusted_context_manifests"},
|
{37, "work_task_bindings"},
|
||||||
{38, "workflow_step_sources"},
|
{38, "trusted_context_manifests"},
|
||||||
{39, "context_manifest_resolve_scope"},
|
{39, "workflow_step_sources"},
|
||||||
{40, "scrub_candidate_context_scope_text"},
|
{40, "context_manifest_resolve_scope"},
|
||||||
{41, "command_audit_attribution_snapshot"},
|
{41, "scrub_candidate_context_scope_text"},
|
||||||
{42, "project_audit_committed_links"},
|
{42, "command_audit_attribution_snapshot"},
|
||||||
{43, "project_audit_committed_prefix"},
|
{43, "project_audit_committed_links"},
|
||||||
{44, "project_audit_all_writers_guard"},
|
{44, "project_audit_committed_prefix"},
|
||||||
{45, "project_work_observation_outbox"},
|
{45, "project_audit_all_writers_guard"},
|
||||||
{46, "human_decision_proofs"},
|
{46, "project_work_observation_outbox"},
|
||||||
{47, "document_render_deliveries"},
|
{47, "human_decision_proofs"},
|
||||||
|
{48, "document_render_deliveries"},
|
||||||
}
|
}
|
||||||
for _, item := range want {
|
for _, item := range want {
|
||||||
if got, ok := MigrationName(item.version); !ok || got != item.name {
|
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) {
|
func TestProjectAuditSequenceMigrationRejectsCacheTen(t *testing.T) {
|
||||||
st := openProjectAuditPG(t)
|
st := openProjectAuditPG(t)
|
||||||
if err := st.MigrateDown(42); err != nil {
|
if err := st.MigrateDown(43); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
seq := commandAuditSequenceName(t, st)
|
seq := commandAuditSequenceName(t, st)
|
||||||
@@ -284,7 +284,7 @@ func TestProjectAuditSequenceCacheTenDefeatsAdvisoryLock(t *testing.T) {
|
|||||||
|
|
||||||
func TestProjectAuditSequenceNeverCommitsPreUpgradeCachedLowID(t *testing.T) {
|
func TestProjectAuditSequenceNeverCommitsPreUpgradeCachedLowID(t *testing.T) {
|
||||||
st := openProjectAuditPG(t)
|
st := openProjectAuditPG(t)
|
||||||
if err := st.MigrateDown(42); err != nil {
|
if err := st.MigrateDown(43); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
seq := commandAuditSequenceName(t, st)
|
seq := commandAuditSequenceName(t, st)
|
||||||
@@ -354,8 +354,8 @@ func TestProjectAuditSequenceMigrationRoundTripPreservesLinks(t *testing.T) {
|
|||||||
if _, err := st.SaveProjectAuditLink(link); err != nil {
|
if _, err := st.SaveProjectAuditLink(link); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := st.MigrateDown(42); err != nil {
|
if err := st.MigrateDown(43); err != nil {
|
||||||
t.Fatalf("down to v42: %v", err)
|
t.Fatalf("down to v43: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10); err == nil {
|
if _, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10); err == nil {
|
||||||
t.Fatal("old default exposed a strong cutoff after down")
|
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)
|
t.Fatalf("link changed during down: %+v %v", row, err)
|
||||||
}
|
}
|
||||||
if err := st.migrate(); err != nil {
|
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)
|
rows, _, _, err = st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
|
||||||
if err != nil || len(rows) != 1 || rows[0].Offset != offset {
|
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] })
|
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
|
||||||
return elapsed, durations[len(durations)*95/100]
|
return elapsed, durations[len(durations)*95/100]
|
||||||
}
|
}
|
||||||
if err := st.MigrateDown(42); err != nil {
|
if err := st.MigrateDown(43); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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"
|
statusMode = "test-only locked status wrapper"
|
||||||
}
|
}
|
||||||
newElapsed, newP95 := run("guarded", func(env agentcommand.AuditEnvelope) error { return st.AppendAudit("alice", env) })
|
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())
|
serverVersion, writers*each, writers, oldElapsed, oldP95, float64(writers*each)/oldElapsed.Seconds(), statusMode, newElapsed, newP95, float64(writers*each)/newElapsed.Seconds())
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var before, cutoff int64
|
var before, cutoff int64
|
||||||
|
|||||||
@@ -193,7 +193,8 @@ func openSQLiteFile(path string, createArtifacts, migrateSchema bool) (*Store, e
|
|||||||
// 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。
|
// 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。
|
||||||
// busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s
|
// busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s
|
||||||
// (实测 job 2285 SQLITE_BUSY);本地档宁可排队不报错。
|
// (实测 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)
|
db, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -297,6 +298,10 @@ func finishOpenMode(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.verifyDurability(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if createArtifacts {
|
if createArtifacts {
|
||||||
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
|
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
|
|||||||
@@ -76,12 +76,12 @@ type AccountDirectoryScopeRequest struct {
|
|||||||
// CandidateIDs or CandidateOwners are applied before search and pagination;
|
// CandidateIDs or CandidateOwners are applied before search and pagination;
|
||||||
// domain membership stores may use owners while the provider returns stable IDs.
|
// domain membership stores may use owners while the provider returns stable IDs.
|
||||||
type AccountDirectoryScope struct {
|
type AccountDirectoryScope struct {
|
||||||
AllowedFields []string
|
AllowedFields []string
|
||||||
MatchFields []string
|
MatchFields []string
|
||||||
AllCandidates bool
|
AllCandidates bool
|
||||||
CandidateIDs []string
|
CandidateIDs []string
|
||||||
CandidateOwners []string
|
CandidateOwners []string
|
||||||
ScopeVersion string
|
ScopeVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountDirectoryScopeResolver func(context.Context, AccountDirectoryScopeRequest) (AccountDirectoryScope, error)
|
type AccountDirectoryScopeResolver func(context.Context, AccountDirectoryScopeRequest) (AccountDirectoryScope, error)
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ import (
|
|||||||
// It does not consume the proof; the caller does that in its own transaction.
|
// It does not consume the proof; the caller does that in its own transaction.
|
||||||
// 2.23.0-experimental: InProc.HostOptions / CLIHook, plus MergeHostOptions,
|
// 2.23.0-experimental: InProc.HostOptions / CLIHook, plus MergeHostOptions,
|
||||||
// RegisterCLIFlags、RunCLIHooks(组合二进制把插件声明并进基座启动,不覆盖显式参数)。
|
// 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 无法启动服务。
|
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ ErrProjectAuditDenied
|
|||||||
ErrProjectAuditLinkConflict
|
ErrProjectAuditLinkConflict
|
||||||
ErrProjectAuditPending
|
ErrProjectAuditPending
|
||||||
ErrProjectAuditUnsupported
|
ErrProjectAuditUnsupported
|
||||||
|
ErrRegulationsUnavailable
|
||||||
Handler
|
Handler
|
||||||
Host
|
Host
|
||||||
Host.ArtifactContent
|
Host.ArtifactContent
|
||||||
@@ -147,6 +148,33 @@ Registry.AddSkill
|
|||||||
Registry.Note
|
Registry.Note
|
||||||
Registry.RegisterResourceAuthorizer
|
Registry.RegisterResourceAuthorizer
|
||||||
Registry.RegisterResourceAuthorizerV2
|
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
|
Request
|
||||||
ResetCompiled
|
ResetCompiled
|
||||||
ResourceAuthorizer
|
ResourceAuthorizer
|
||||||
@@ -162,6 +190,24 @@ SurfaceHost.SurfaceList
|
|||||||
SurfaceHost.SurfaceSave
|
SurfaceHost.SurfaceSave
|
||||||
SurfaceSaveRequest
|
SurfaceSaveRequest
|
||||||
SurfaceVersion
|
SurfaceVersion
|
||||||
|
WebCapture
|
||||||
|
WebCaptureRequest
|
||||||
|
WebCaptureStateCaptured
|
||||||
|
WebCaptureStateFailed
|
||||||
|
WebCaptureStateRejected
|
||||||
|
WebClauseCandidate
|
||||||
|
WebRetentionExcerpt
|
||||||
|
WebRetentionFull
|
||||||
|
WebRetentionMetadataOnly
|
||||||
|
WebSearchHit
|
||||||
|
WebSearchRequest
|
||||||
|
WebSearchResult
|
||||||
|
WebSearchStateEmpty
|
||||||
|
WebSearchStateError
|
||||||
|
WebSearchStateOK
|
||||||
|
WebSearchStateRateLimited
|
||||||
|
WebSearchStateRejected
|
||||||
|
WebSearchStateUnavailable
|
||||||
WorkCompletionHost
|
WorkCompletionHost
|
||||||
WorkCompletionHost.CompleteForWork
|
WorkCompletionHost.CompleteForWork
|
||||||
WorkCompletionRequest
|
WorkCompletionRequest
|
||||||
|
|||||||
Reference in New Issue
Block a user