ci / vet-and-test (pull_request) Successful in 51s
Why: The login recovery contract leftovers and KTD12/KTD14 require: concurrent same-name account creation must not overwrite the winner, first-admin bootstrap must serialize across processes, the redeem endpoint backoff must be shared across instances, revoked API keys must close established SSE streams, and CLI credentials need an env-DSN path plus a non-interactive 0600 file delivery mode. Evidence: go test ./... green; new tests cover 16-way concurrent create (1 win/15 conflict, winner credentials intact), 8-way concurrent bootstrap (single admin, refuse takeover after), redeem guard shared across two store handles on one DB, SSE stream closed after key revocation within the recheck window, and HTTP 429 after the failure cap (SELF worktree run; CI is the GATE, PG via test-pg). Impact: Migration v29 (redeem_attempts); CreateAccount becomes a single-row INSERT with unique-violation conflict; BootstrapFirstAdmin advisory-locks (PG) / rechecks (SQLite) and commits admin+key+audit in one transaction; SSE revalidation covers API-key streams; redeem backoff moves to the shared DB; CLI gains AGENT_SYSTEM_DSN fallback and --out file delivery for create/reset-password/issue-login-key/ bootstrap-admin. Refs: docs/svc-deploy/2026-10-13-login-recovery-contract.md leftovers; proposal r2 KTD12/KTD14/R5/R11.
1053 lines
41 KiB
Go
1053 lines
41 KiB
Go
// Package app 组合根:装配存储(嵌入式/PG 档)、总线、注册表、服务、runmgr、
|
||
// 认证、插件宿主与 HTTP 服务。
|
||
package app
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"error-code-system/errcode"
|
||
"logging-system/logsys"
|
||
"logging-system/sink"
|
||
|
||
"agent-native/runtime/checkpoint"
|
||
agentllm "agent-native/runtime/llm"
|
||
agentmcp "agent-native/runtime/mcp"
|
||
"agent-native/runtime/policy"
|
||
"agent-native/runtime/telemetry"
|
||
agenttools "agent-native/runtime/tools"
|
||
nativeadp "agent-squad/adapters/native"
|
||
"agent-system-backend/internal/blobstore"
|
||
"agent-system-backend/internal/bus"
|
||
"agent-system-backend/internal/cardsvc"
|
||
"agent-system-backend/internal/cmdbus"
|
||
"agent-system-backend/internal/config"
|
||
"agent-system-backend/internal/genui"
|
||
"agent-system-backend/internal/hostapi"
|
||
"agent-system-backend/internal/intentgate"
|
||
"agent-system-backend/internal/intentsem"
|
||
"agent-system-backend/internal/pgbus"
|
||
"agent-system-backend/internal/plugins"
|
||
"agent-system-backend/internal/profiles"
|
||
"agent-system-backend/internal/prompts"
|
||
"agent-system-backend/internal/rag"
|
||
"agent-system-backend/internal/ratelimit"
|
||
"agent-system-backend/internal/runmgr"
|
||
"agent-system-backend/internal/runtime"
|
||
"agent-system-backend/internal/server"
|
||
"agent-system-backend/internal/settings"
|
||
"agent-system-backend/internal/skills"
|
||
"agent-system-backend/internal/squadadapter"
|
||
"agent-system-backend/internal/store"
|
||
"agent-system-backend/internal/surfacesvc"
|
||
"agent-system-backend/internal/tools"
|
||
"agent-system-backend/internal/workflow"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
type Options struct {
|
||
Addr string
|
||
DataDir string
|
||
PluginsDir string
|
||
ForceMock bool
|
||
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
|
||
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
|
||
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
|
||
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
|
||
InsecureListen bool
|
||
AllowedHosts []string
|
||
// DisableUploadIndexing EXT-9:true 时上传端点不自动写检索索引(发布决定
|
||
// 移交下游命令层);零值=false=现状。由 link 层自 extension.Options 透传。
|
||
DisableUploadIndexing bool
|
||
// DisableConvCardMirror EXT-12:true 时会话卡不镜像进项目公共流(不发布
|
||
// 即不持久,重放不含会话卡);零值=false=现状全量镜像。
|
||
DisableConvCardMirror bool
|
||
// DisableExecTools 启动级硬关闭执行原语(run_shell / run_python /
|
||
// register_artifact 与小队内核执行配置):用户设置 runtime.exec 无法覆盖;
|
||
// 零值=false=现状。
|
||
DisableExecTools bool
|
||
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
|
||
// 硬关闭 DisableExecTools 优先级高于后端选择。
|
||
ExecBackend string
|
||
// ExecDepsDir sandbox 档只读依赖层(site-packages 等,空=不挂):修复
|
||
// --tmpfs /home 遮蔽 ~/.local 导致交付物生成链路不可用(F3)。
|
||
ExecDepsDir string
|
||
// ExecWorkspace = "overlay" 时 sandbox 档启用 S0 工作区(冻结基底+私有
|
||
// upper+CAS 回收);空 = EXT-16 现状直写会话目录。
|
||
ExecWorkspace string
|
||
// Tier 部署档(协作存储 KTD1):空=auto(DSN→PG 否则 SQLite);local=SQLite;
|
||
// team=PG fail-closed(缺 DSN/未认证拒绝启动;PG 故障拒绝就绪,不回落 SQLite)。
|
||
Tier string
|
||
// PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。
|
||
PGPool store.PoolOptions
|
||
}
|
||
|
||
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
|
||
// 本地档退化为 local(单实例语义)。
|
||
func instanceTag(opt Options) string {
|
||
if opt.Tier == "team" || opt.StorageDSN != "" {
|
||
return hostTag() + ":" + opt.Addr
|
||
}
|
||
return "local"
|
||
}
|
||
|
||
func hostTag() string {
|
||
h, err := os.Hostname()
|
||
if err != nil || h == "" {
|
||
return "host"
|
||
}
|
||
return h
|
||
}
|
||
|
||
// tierName 输出部署档名(诊断统一拼写:空 = auto)。
|
||
func tierName(opt Options) string {
|
||
if opt.Tier == "" {
|
||
return "auto"
|
||
}
|
||
return opt.Tier
|
||
}
|
||
|
||
// TierRefusal 部署档门禁(KTD1):返回阻止启动的问题(空 = 放行)。
|
||
// team 档 fail-closed:缺 DSN、未开认证拒绝;PG 故障的拒绝发生在其后的
|
||
// OpenPostgres(Run 对 team 档的错误显式声明不回落 SQLite)。
|
||
func TierRefusal(opt Options) []string {
|
||
return config.Resolved{Config: config.Config{
|
||
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, Tier: opt.Tier,
|
||
PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
|
||
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
|
||
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
|
||
}}.TierProblems()
|
||
}
|
||
|
||
// latestVersion 返回已应用迁移的最高版本(0 = 空账本)。
|
||
func latestVersion(versions []int) int {
|
||
max := 0
|
||
for _, v := range versions {
|
||
if v > max {
|
||
max = v
|
||
}
|
||
}
|
||
return max
|
||
}
|
||
|
||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||
func Run(opt Options) error {
|
||
logger := logsys.New(logsys.Options{
|
||
Service: "agent-system", Module: "app", Level: logsys.LevelInfo,
|
||
Sinks: []logsys.Sink{&sink.Console{Out: os.Stderr, Format: "json"}},
|
||
})
|
||
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir, "tier", tierName(opt))
|
||
resolved := config.Resolved{Config: config.Config{
|
||
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, InsecureListen: opt.InsecureListen,
|
||
Tier: opt.Tier, PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
|
||
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
|
||
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
|
||
}}
|
||
if msg := resolved.ListenRefusal(); msg != "" {
|
||
return fmt.Errorf("listen guard: %s", msg)
|
||
}
|
||
// 部署档门禁(KTD1 fail-closed):team 档缺 DSN/未开认证直接拒绝启动;
|
||
// local 档带 DSN 是配置错误。此处与 config.TierProblems 同规则(纵深防御,
|
||
// 服务入口不依赖 CLI 是否校验)。
|
||
if msgs := TierRefusal(opt); len(msgs) > 0 {
|
||
return fmt.Errorf("tier guard: %s", strings.Join(msgs, ";"))
|
||
}
|
||
if opt.Tier == "team" && !opt.DisableExecTools && opt.ExecBackend == "" {
|
||
log.Printf("warning: team 档未配置执行隔离(disableExecTools 或 execBackend);向真实多用户开放前必须补齐(方案 KTD1)")
|
||
}
|
||
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
|
||
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
|
||
}
|
||
var st *store.Store
|
||
var err error
|
||
if opt.StorageDSN != "" {
|
||
artifacts := opt.ArtifactsDir
|
||
if artifacts == "" {
|
||
artifacts = filepath.Join(opt.DataDir, "artifacts")
|
||
}
|
||
// 共享工件(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 {
|
||
return fmt.Errorf("open store: %w", err)
|
||
}
|
||
defer st.Close()
|
||
// U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
|
||
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
|
||
if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" {
|
||
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
|
||
if dbref == "" {
|
||
if opt.StorageDSN != "" {
|
||
dbref = "pg"
|
||
} else {
|
||
dbref = "sqlite"
|
||
}
|
||
}
|
||
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
|
||
return fmt.Errorf("system identity: %w", err)
|
||
}
|
||
}
|
||
pool := st.Pool()
|
||
if versions, verr := st.MigrationStatus(); verr == nil {
|
||
log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)",
|
||
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, pool.ConnMaxIdleTimeSec,
|
||
pool.ConnMaxLifetimeSec, latestVersion(versions))
|
||
} else {
|
||
log.Printf("storage tier: %s (dialect=%s pool=%d/%d schema=? migration status unavailable: %v)",
|
||
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, verr)
|
||
}
|
||
|
||
b := bus.New()
|
||
reg := tools.NewRegistry()
|
||
if err := reg.RegisterBuiltin(); err != nil {
|
||
return fmt.Errorf("builtin tools: %w", err)
|
||
}
|
||
// fs 预留能力:工具族实现但 Hidden(runtime.fsTools 未开时模型与 /api/tools 均不可见)。
|
||
if err := tools.RegisterFSTools(reg); err != nil {
|
||
return fmt.Errorf("fs tools: %w", err)
|
||
}
|
||
// 执行原语(agent-native 交付):shell/python 在会话工作区执行 + 产物登记桥,
|
||
// runtime.exec=false 可整体禁用(fail-closed)。生成文档/表格/演示稿的方法
|
||
// 在提示词库 base.deliverables,不在系统工具里。启动参数 DisableExecTools
|
||
// 为不受用户设置影响的硬关闭(多用户部署的默认安全档)。
|
||
tools.SetExecDisabled(opt.DisableExecTools)
|
||
if opt.DisableExecTools {
|
||
log.Printf("exec tools: disabled by startup option (runtime.exec settings are ignored)")
|
||
}
|
||
// EXT-16:执行后端选择(direct|sandbox);未知名字启动即失败(fail-closed)。
|
||
if err := tools.SetExecBackend(opt.ExecBackend); err != nil {
|
||
return fmt.Errorf("exec backend: %w", err)
|
||
}
|
||
if tools.ExecBackendName() == "sandbox" {
|
||
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
|
||
}
|
||
if opt.ExecDepsDir != "" {
|
||
tools.SetExecDepsDir(opt.ExecDepsDir)
|
||
log.Printf("exec tools: sandbox deps layer=%s", opt.ExecDepsDir)
|
||
}
|
||
if opt.ExecWorkspace == "overlay" {
|
||
tools.SetWorkspaceOverlay(true)
|
||
if tools.ExecBackendName() == "sandbox" {
|
||
log.Printf("exec tools: workspace=overlay (frozen base + private upper + CAS apply)")
|
||
} else {
|
||
log.Printf("exec tools: workspace=overlay ignored (backend is not sandbox)")
|
||
}
|
||
} else if opt.ExecWorkspace != "" {
|
||
return fmt.Errorf("exec workspace: unknown mode %q (overlay)", opt.ExecWorkspace)
|
||
}
|
||
if err := tools.RegisterExecTools(reg); err != nil {
|
||
return fmt.Errorf("exec tools: %w", err)
|
||
}
|
||
if err := tools.RegisterInterpreterTools(reg); err != nil {
|
||
return fmt.Errorf("interpreter tools: %w", err)
|
||
}
|
||
if err := tools.RegisterCodeTool(reg); err != nil {
|
||
return fmt.Errorf("code orchestration tool: %w", err)
|
||
}
|
||
setSvc := settings.NewService(st)
|
||
effBoot := setSvc.Effective(store.OwnerDefault, "")
|
||
traceFn := telemetry.StartFunc(logger,
|
||
telemetry.WithServiceName(effBoot.GetString("telemetry.serviceName", "agent-system-backend")),
|
||
telemetry.WithEndpoint(effBoot.GetString("telemetry.otlpEndpoint", "")))
|
||
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
|
||
// 点路径键展开必须在读 tools.tavily.apiKeys 之前,否则启动仍注册旧池。
|
||
norm, err := setSvc.NormalizeStored()
|
||
if err != nil {
|
||
return fmt.Errorf("normalize settings: %w", err)
|
||
}
|
||
for _, p := range norm.MaskPaths {
|
||
log.Printf("settings: warning: stored value is the mask placeholder at %s", p)
|
||
}
|
||
if norm.DottedKeys > 0 {
|
||
log.Printf("settings: normalized %d dotted key(s) in %d scope(s)", norm.DottedKeys, norm.Scopes)
|
||
}
|
||
if err := setSvc.SnapshotAndMigrateSecrets(); err != nil {
|
||
return fmt.Errorf("migrate secrets: %w", err)
|
||
}
|
||
runtime.SecretLookup = func(owner, service string) (string, bool) {
|
||
if owner == "" {
|
||
owner = store.OwnerDefault
|
||
}
|
||
if v, ok := st.ActiveSecret(owner, service); ok {
|
||
return v, true
|
||
}
|
||
// P2 受管:密钥统一迁入 secrets 表后,系统层受管密钥挂在系统行
|
||
//(owner=settings.OwnerSystemRow);租户未单独配置时回退系统密钥。
|
||
return st.ActiveSecret(settings.OwnerSystemRow, service)
|
||
}
|
||
rag.SecretLookup = func(service, fallback string) string {
|
||
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
|
||
}
|
||
// 网络搜索(Tavily):始终注册,密钥按调用时读取(设置页/svc 播种改了
|
||
// 即刻生效——此前启动时快照密钥,播种后必须重启工具才注册)。
|
||
// 未配置时调用返回明确的「未配置」提示,不静默降级为空结果。
|
||
if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider {
|
||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||
tavilyNow = active
|
||
}
|
||
keys := splitKeyPool(tavilyNow)
|
||
if len(keys) == 0 {
|
||
return nil
|
||
}
|
||
return agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
|
||
}); err != nil {
|
||
return fmt.Errorf("search tools: %w", err)
|
||
}
|
||
promptLib, err := prompts.DefaultLibrary()
|
||
if err != nil {
|
||
return fmt.Errorf("prompt library: %w", err)
|
||
}
|
||
promptSvc, err := prompts.NewService(st, promptLib)
|
||
if err != nil {
|
||
return fmt.Errorf("prompt service: %w", err)
|
||
}
|
||
// 小队角色指令接提示词库(agent.squad.<kind>;未命中回落内联文案)。
|
||
squadadapter.RoleInstructions = func(kind string) (string, bool) {
|
||
body, err := promptSvc.AgentPrompt(store.OwnerDefault, "agent.squad."+kind)
|
||
return body, err == nil
|
||
}
|
||
// 技能种子:交付配方与 house-style 落 <data>/skills/。
|
||
// 未改过的内置文件随版本升级;用户改过的保留并记 drift。
|
||
skillsRoot := filepath.Join(opt.DataDir, "skills")
|
||
if res, serr := skills.Seed(skillsRoot); serr != nil {
|
||
log.Printf("skills: seed builtin recipes failed: %v", serr)
|
||
} else if res.Written > 0 || res.Upgraded > 0 || len(res.Drifted) > 0 {
|
||
log.Printf("skills: wrote %d, upgraded %d into %s", res.Written, res.Upgraded, skillsRoot)
|
||
for _, path := range res.Drifted {
|
||
log.Printf("skills: kept user revision (drift) %s", path)
|
||
}
|
||
}
|
||
tools.SetSkillsRoot(skillsRoot)
|
||
// 小队的 agent-native 内核工厂(组合根装配,逐次运行时从 settings 解析):
|
||
// 模型走 settings.models.chat 同一路由;搜索/解释器/MCP/技能根复用产品配置;
|
||
// 委派桥接产品 SpawnSubagent。主模型未配置时返回未启用(不阻塞产品)。
|
||
var pluginHost *plugins.Host
|
||
squadadapter.AgentNativeOptions = func(rc squadadapter.Ctx) (nativeadp.Config, bool) {
|
||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||
route := runtime.ResolveChatModel(core.AgentProfile{}, effNow)
|
||
if route.Model == "" || route.Base == "" {
|
||
return nativeadp.Config{}, false
|
||
}
|
||
mode := "default"
|
||
if src, ok := rc.(interface{ PermissionMode() string }); ok {
|
||
mode = src.PermissionMode()
|
||
}
|
||
cfg := nativeadp.Config{
|
||
Client: &agentllm.Client{BaseURL: route.Base, APIKey: route.Key, HTTPClient: &http.Client{}},
|
||
DefaultModel: route.Model,
|
||
MaxTurns: 0,
|
||
Timeout: 0,
|
||
Subagent: squadadapter.SubagentFactory(rc),
|
||
RepeatGuard: true,
|
||
BeforeTool: func(call agentllm.ToolCall) error {
|
||
effect := policy.Decide(policy.Request{
|
||
Mode: policy.Normalize(mode), Name: call.Name, Args: call.Arguments,
|
||
Traits: policy.TraitsFor(call.Name),
|
||
}, nil)
|
||
if effect == policy.EffectDeny {
|
||
return fmt.Errorf("计划模式拒绝了工具 %s", call.Name)
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||
tavilyNow = active
|
||
}
|
||
if keys := splitKeyPool(tavilyNow); len(keys) > 0 {
|
||
cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
|
||
}
|
||
if effNow.GetBool("runtime.exec", true) {
|
||
cfg.Exec = tools.InterpreterExecConfig()
|
||
}
|
||
if st2, err := os.Stat(skillsRoot); err == nil && st2.IsDir() {
|
||
cfg.SkillsRoots = []string{skillsRoot}
|
||
}
|
||
rawMCP := strings.TrimSpace(effNow.GetString("tools.mcp.servers", ""))
|
||
if pluginHost != nil {
|
||
rawMCP = mergeMCPServers(rawMCP, pluginHost.MCPServers())
|
||
}
|
||
if mgr := mcpManagerFor(rawMCP, strings.TrimSpace(effNow.GetString("tools.mcp.overrides", ""))); mgr != nil {
|
||
cfg.MCP = mgr
|
||
}
|
||
cfg.CodeOrchestration = effNow.GetBool("runtime.codeOrchestration", false)
|
||
if ask, ok := rc.(interface {
|
||
AskUser(card core.HITLCard) (core.HITLResponse, error)
|
||
}); ok {
|
||
cfg.Ask = func(_ context.Context, question string) (string, error) {
|
||
resp, err := ask.AskUser(core.HITLCard{
|
||
Question: question, Kind: core.HITLKindInput, AllowFreeInput: true,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if strings.TrimSpace(resp.FreeText) != "" {
|
||
return strings.TrimSpace(resp.FreeText), nil
|
||
}
|
||
return resp.ChoiceID, nil
|
||
}
|
||
}
|
||
cfg.Trace = traceFn
|
||
// N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达
|
||
// runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。
|
||
cfg.PermissionAsk = runtime.PermissionAskOf(rc)
|
||
if host, ok := rc.(interface {
|
||
OpenCheckpoints() (*checkpoint.Log, error)
|
||
CheckpointAnchor() string
|
||
}); ok {
|
||
if lg, err := host.OpenCheckpoints(); err == nil {
|
||
cfg.Checkpoints = lg
|
||
anchor := host.CheckpointAnchor()
|
||
cfg.CheckpointAnchor = func() string { return anchor }
|
||
}
|
||
}
|
||
return cfg, true
|
||
}
|
||
profSvc := profiles.New(st)
|
||
if err := profSvc.EnsureDefault(); err != nil {
|
||
return fmt.Errorf("default profile: %w", err)
|
||
}
|
||
if opt.ForceMock {
|
||
// runtime.* 自 #188 起为系统受管项:启动选项属受信操作员输入,
|
||
// 播种走系统层(对所有租户生效);用户层写入会被受管拦截拒绝,
|
||
// 导致 serve --mock 直接启动失败(multi-instance 集成回归)。
|
||
if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
convRunner := &runtime.LLMRunner{Prompts: promptSvc, SkillsDir: skillsRoot, Trace: traceFn}
|
||
subRunner := &runtime.SubLLMRunner{Prompts: promptSvc, Trace: traceFn}
|
||
mockRunner := &runtime.MockRunner{}
|
||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{})
|
||
instanceID := opt.InstanceID
|
||
if instanceID == "" {
|
||
host, err := os.Hostname()
|
||
if err != nil || host == "" {
|
||
host = "local"
|
||
}
|
||
instanceID = host + ":" + opt.Addr
|
||
}
|
||
st.SetInstanceID(instanceID)
|
||
mgr.SetInstanceID(instanceID)
|
||
if err := mgr.ReconcileInterrupted(); err != nil {
|
||
return fmt.Errorf("reconcile interrupted runs: %w", err)
|
||
}
|
||
// 后台子代理通知重启重投(N2 WP5):未投递结果装回队列并按空闲语义投递。
|
||
mgr.LoadPendingNotices()
|
||
mgr.SetSquadRunner(&runtime.SquadRunner{})
|
||
|
||
// 意图闸门:插件意图 overlay → agent-intent 注册表(未装载意图 = 行为回退)。
|
||
gate := intentgate.New()
|
||
mgr.SetGate(gate)
|
||
|
||
pluginHost = plugins.New(st, reg, setSvc, opt.PluginsDir)
|
||
pluginHost.SetSkillsRoot(skillsRoot)
|
||
hostTok := make([]byte, 16)
|
||
_, _ = rand.Read(hostTok)
|
||
hostSrv := &hostapi.Server{Token: hex.EncodeToString(hostTok), Store: st}
|
||
hostLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||
if err != nil {
|
||
return fmt.Errorf("host api: %w", err)
|
||
}
|
||
defer hostLn.Close()
|
||
go func() { _ = http.Serve(hostLn, hostSrv.Handler()) }()
|
||
pluginHost.SetHostAPI("http://"+hostLn.Addr().String(), hostSrv.Token)
|
||
pluginHost.OnIntents = func(overlay []json.RawMessage) error { return gate.Rebuild(context.Background(), overlay) }
|
||
wfReg := workflow.NewRegistry()
|
||
pluginHost.OnWorkflows = func(defs []core.WorkflowDef, scripts map[string]string) error {
|
||
wfReg.Set(defs, scripts)
|
||
return nil
|
||
}
|
||
for id, perr := range pluginHost.LoadDir() {
|
||
if perr != nil {
|
||
log.Printf("[plugins] %s: %s: %v", id, errcode.SysUnavailable, perr) // fail-closed:单个失败不阻断启动
|
||
logger.Error("plugin load failed", perr, "plugin", id, "code", string(errcode.SysUnavailable))
|
||
}
|
||
}
|
||
// P2 受管:插件声明里带 scope=system 的设置项同样纳入受管路径
|
||
//(user/project 写入拒绝、生效链剥除)。
|
||
setSvc.SetExtraSchema(pluginHost.SettingsSchema())
|
||
|
||
// 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。
|
||
pipe := rag.NewPipeline(st, setSvc, nil, nil)
|
||
embedder := rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
|
||
return setSvc.Effective(store.OwnerDefault, "").GetString("models.embedding.provider", "")
|
||
}}
|
||
pipe.SetEmbedder(embedder)
|
||
pipe.SetReranker(rag.NewHTTPReranker(pipe.RerankRoute))
|
||
mgr.SetRetriever(pipe)
|
||
mgr.SetIndexer(pipe)
|
||
|
||
// 意图慢通道(I2):语义向量裁决 + 轻量模型命令生成。
|
||
// 两个开关都默认关——此时识别行为与 I1 完全一致(fail-closed 分层);
|
||
// 开启语义需已配置 embedding 端点,开启生成需可用的轻量/主模型档。
|
||
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentSemantic", false) {
|
||
gate.SetSemantic(intentsem.EmbeddingScorer{Provider: embedder}, 0)
|
||
}
|
||
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentGenerator", false) {
|
||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||
light := runtime.ResolveLightweightModel(core.AgentProfile{}, effNow)
|
||
client := &agentllm.Client{BaseURL: light.Base, APIKey: light.Key, HTTPClient: &http.Client{}}
|
||
gate.SetGenerator(intentsem.LightGenerator{Stream: client.Stream, Model: light.Model})
|
||
}
|
||
// fs 预留能力沙箱根:<DataDir>/workspaces/<owner>/<conversationID>/。
|
||
mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces"))
|
||
// 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。
|
||
mgr.ResumeInterruptedChildren()
|
||
|
||
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
|
||
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
|
||
surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent)
|
||
go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。
|
||
if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 {
|
||
log.Printf("surface: recovered %d pending events", n)
|
||
}
|
||
}()
|
||
mgr.SetCards(cardSvc)
|
||
// 执行身份与命令消费(协作存储 U5):PG 档启动跨实例命令轮询;
|
||
// SQLite 本地档无跨实例命令(StartCommandConsumer 自行短路)。
|
||
mgr.SetInstanceTag(instanceTag(opt), opt.Addr)
|
||
stopCommands := mgr.StartCommandConsumer(context.Background())
|
||
defer stopCommands()
|
||
// 执行预算(协作存储 U3,R8):runtime.execBudgetGlobal / runtime.execBudgetPerUser
|
||
// 全零=不限(本地档默认,行为不变);开启后执行许可共享账本生效,
|
||
// 待执行队列上限 runtime.execQueueMax(默认 200,容量合同数值待 U7 实测调整)。
|
||
mgr.SetExecutionBudgetFn(func() store.ExecutionBudget {
|
||
eff := setSvc.Effective(store.OwnerDefault, "")
|
||
return store.ExecutionBudget{
|
||
Global: int(eff.GetNumber("runtime.execBudgetGlobal", 0)),
|
||
PerUser: int(eff.GetNumber("runtime.execBudgetPerUser", 0)),
|
||
}
|
||
}, int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.execQueueMax", 200)))
|
||
|
||
// 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。
|
||
bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() })
|
||
// U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发
|
||
//(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。
|
||
surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
|
||
resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params,
|
||
Entrance: "button", Owner: d.Owner, ConversationID: d.ConversationID})
|
||
state := "committed"
|
||
if resp.Receipt.State != "" {
|
||
state = string(resp.Receipt.State)
|
||
}
|
||
cardID := ""
|
||
if resp.Card != nil {
|
||
cardID = string(resp.Card.ID)
|
||
}
|
||
return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
|
||
})
|
||
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
|
||
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
|
||
bus.SetToolGate(func(owner, commandID string) bool {
|
||
if !strings.HasPrefix(commandID, genui.PluginID+".") {
|
||
return true
|
||
}
|
||
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
|
||
return err == nil && row.Enabled
|
||
})
|
||
// 自动建议页面 opt-in:settings ∧ activation;预算/冷却在 genui.Auto。
|
||
mgr.SetGenUIAuto(genui.NewAuto(
|
||
func(owner string) bool {
|
||
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
|
||
return err == nil && row.Enabled
|
||
},
|
||
func(owner string) bool {
|
||
return setSvc.Effective(owner, "").GetBool(genui.SettingAutoPresent, false)
|
||
},
|
||
func(owner, kind, detail string) {
|
||
_ = st.AppendAuditEvent(store.AuditEvent{Owner: owner, Action: kind, Evidence: detail})
|
||
},
|
||
))
|
||
|
||
bus.SetPendingStore(st)
|
||
pluginHost.OnCommands = bus.RegisterPluginCommands
|
||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||
authzSlot := &projectAuthz{}
|
||
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
|
||
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
|
||
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||
ok, err := authzSlot.Authorize(owner, projectID)
|
||
return err == nil && ok
|
||
})
|
||
// EXT-14:命令即工具——带 exposeAsTool 的命令投影进回合工具清单,模型调用
|
||
// 经命令总分发(Entrance=agent,同审计信封与高危内联确认卡)。
|
||
mgr.SetCommandSource(busAdapter{bus: bus})
|
||
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
|
||
wireScopedHostFactory(caps)
|
||
compiledSummary := applyCompiled(skillsRoot, bus, caps)
|
||
mgr.OnUsage = func(owner string, tokens int) {
|
||
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
|
||
status, aerr := st.AccountTokens(owner, tokens, budget)
|
||
if aerr != nil {
|
||
logger.Error("llm budget", aerr, "code", string(errcode.SysInternal))
|
||
return
|
||
}
|
||
if status.Over {
|
||
_ = st.AppendAuditEvent(store.AuditEvent{Owner: owner, Action: "llm.budget_exceeded", Evidence: fmt.Sprintf(`{"total":%d,"budget":%d}`, status.Total, budget)})
|
||
logger.Warn("llm daily budget exceeded", "owner", owner, "total", status.Total, "budget", budget)
|
||
}
|
||
}
|
||
hostSrv.Dispatch = func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error {
|
||
resp := bus.Dispatch(ctx, cmdbus.Request{ID: commandID, Params: params, Entrance: "api", Actor: pluginID, Owner: owner})
|
||
if resp.Receipt.State == "rejected" || resp.Receipt.State == "unknown" {
|
||
return fmt.Errorf("%s", resp.Receipt.Reason)
|
||
}
|
||
return nil
|
||
}
|
||
hostSrv.Search = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||
return caps.searchDefault(ctx, owner, text)
|
||
}
|
||
// EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。
|
||
hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||
own, err := caps.searchDefault(ctx, owner, text)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool {
|
||
ok, aerr := authzSlot.Authorize(owner, projectID)
|
||
return aerr == nil && ok
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return append(own, shared...), nil
|
||
}
|
||
hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) {
|
||
return caps.complete(ctx, owner, prompt)
|
||
}
|
||
|
||
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
|
||
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
|
||
srv.SetProjectAuthorizer(authzSlot.Authorize)
|
||
// EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。
|
||
srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource)
|
||
// EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。
|
||
srv.DisableUploadIndexing = opt.DisableUploadIndexing
|
||
// EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。
|
||
cardSvc.MirrorConvCards = !opt.DisableConvCardMirror
|
||
|
||
srv.Workflows = wfReg
|
||
mgr.SetWorkflows(wfReg)
|
||
srv.Cards = cardSvc
|
||
srv.Surfaces = surfaceSvc
|
||
// WP3-I4:编译插件摘要进 /api/plugins 管理清单(注册面即清单面)。
|
||
srv.CompiledPlugins = compiledSummary
|
||
srv.Bus = bus
|
||
srv.Gate = gate
|
||
srv.Prompts = promptSvc
|
||
srv.WireBuiltinCommands()
|
||
srv.Retriever = pipe
|
||
srv.Indexer = pipe
|
||
|
||
// 限流(阶段 3):默认进程内档;设置 runtime.redisAddr 切原生 Redis 共享档。
|
||
var limiter ratelimit.Limiter = ratelimit.NewMemory()
|
||
if addr := setSvc.Effective(store.OwnerDefault, "").GetString("runtime.redisAddr", ""); addr != "" {
|
||
rl := ratelimit.NewRedis(addr, "", 0)
|
||
if err := rl.Ping(context.Background()); err != nil {
|
||
return fmt.Errorf("redis %s: %w", addr, err)
|
||
}
|
||
defer rl.Close()
|
||
limiter = rl
|
||
log.Printf("rate limit: shared via redis %s", addr)
|
||
}
|
||
srv.Limiter = limiter
|
||
srv.LimitLookup = cachedInt(5*time.Second, func() int {
|
||
return int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.rateLimitPerMin", 0))
|
||
})
|
||
// 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。
|
||
authOn := resolved.AuthEnabled()
|
||
srv.Auth = server.NewAuthenticator(st, authOn)
|
||
if authOn {
|
||
log.Printf("auth: API key required (create with: agent-system apikey create --owner <name>)")
|
||
}
|
||
srv.Subscribe = func(conversationID string) (<-chan core.StreamEvent, func()) {
|
||
return b.Subscribe(conversationID)
|
||
}
|
||
|
||
httpSrv := &http.Server{Addr: opt.Addr, ReadHeaderTimeout: 10 * time.Second, Handler: server.RequestGuard{
|
||
AuthOn: authOn, AllowedHosts: opt.AllowedHosts, Next: srv.Handler(),
|
||
}}
|
||
|
||
go func() {
|
||
log.Printf("agent-system serving on http://%s (data: %s, plugins: %s)", opt.Addr, opt.DataDir, opt.PluginsDir)
|
||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
log.Fatalf("http: %v", err)
|
||
}
|
||
}()
|
||
|
||
// PG 档:事件跨实例扇出(NOTIFY/LISTEN;SQLite 档不需要)。
|
||
if st.Dialect() == "postgres" {
|
||
mgr.SetEventNotifier(pgbus.NewNotifier(st).Notify)
|
||
listener, err := pgbus.Start(context.Background(), opt.StorageDSN, st, b)
|
||
if err != nil {
|
||
return fmt.Errorf("pgbus listen: %w", err)
|
||
}
|
||
defer listener.Close(context.Background())
|
||
log.Printf("pgbus: LISTEN agent_events(跨实例事件扇出)")
|
||
}
|
||
|
||
stop := make(chan os.Signal, 1)
|
||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||
<-stop
|
||
log.Printf("shutting down…")
|
||
mgr.InterruptRunning()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if err := httpSrv.Shutdown(ctx); err != nil {
|
||
return err
|
||
}
|
||
mgr.Shutdown() // 冲净事件缓冲后再关存储
|
||
return nil
|
||
}
|
||
|
||
// CreateUserAccount CLI 建号。
|
||
// password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。
|
||
// generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。
|
||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) {
|
||
var out string
|
||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||
if password != "" || generatePassword {
|
||
pw := password
|
||
if pw == "" {
|
||
pw = server.RandomPassword()
|
||
}
|
||
hash, err := store.HashPassword(pw)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := st.CreateAccount(owner, name, hash, isAdmin); err != nil {
|
||
return err
|
||
}
|
||
out = pw
|
||
return nil
|
||
}
|
||
if err := st.CreateAccount(owner, name, "", isAdmin); err != nil {
|
||
return err
|
||
}
|
||
plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out = plain
|
||
return nil
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// IssueUserLoginKey CLI 签发激活/恢复 Key。
|
||
func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) {
|
||
var plain string
|
||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||
if revokeAllCreds {
|
||
if err := st.RevokeAllAPIKeys(owner); err != nil {
|
||
return err
|
||
}
|
||
st.DeleteUserSessions(owner, "")
|
||
}
|
||
p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl)
|
||
plain = p
|
||
return err
|
||
})
|
||
return plain, err
|
||
}
|
||
|
||
// RevokeUserLoginKey CLI 撤销未使用登录 Key。
|
||
func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error {
|
||
return withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||
return st.RevokeLoginKey(id)
|
||
})
|
||
}
|
||
|
||
// BootstrapAdmin 首位管理员引导:单事务串行(KTD12——PG 咨询锁/SQLite 插入后复查),
|
||
// 建号、激活 Key 与审计共同提交;库内已有用户即拒绝接管。
|
||
func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) {
|
||
var plain string
|
||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||
p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap")
|
||
plain = p
|
||
return err
|
||
})
|
||
return plain, err
|
||
}
|
||
|
||
// ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、
|
||
// 作废未用登录 Key、吊销全部会话并审计。
|
||
func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) {
|
||
if password == "" {
|
||
password = server.RandomPassword()
|
||
}
|
||
hash, err := store.HashPassword(password)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||
return st.AdminResetUserPassword(owner, hash, "cli", "cli")
|
||
}); err != nil {
|
||
return "", err
|
||
}
|
||
return password, nil
|
||
}
|
||
|
||
// ListUserAccounts CLI 用户列表。
|
||
func ListUserAccounts(storageDSN, dataDir string, openExisting bool) ([]store.AdminUser, error) {
|
||
var out []store.AdminUser
|
||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||
var e error
|
||
out, e = st.ListUsersAdmin()
|
||
return e
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
|
||
return withStoreMode(storageDSN, dataDir, false, fn)
|
||
}
|
||
|
||
// withIdentityStore 只打开既有库:不建文件、不建目录、不迁移。
|
||
func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
|
||
st, err := store.OpenIdentity(storageDSN, dataDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer st.Close()
|
||
return fn(st)
|
||
}
|
||
|
||
func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error {
|
||
var st *store.Store
|
||
var err error
|
||
// DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。
|
||
if storageDSN == "" {
|
||
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
|
||
}
|
||
if storageDSN != "" {
|
||
if openExisting {
|
||
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||
} else {
|
||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||
}
|
||
} else {
|
||
dbPath := filepath.Join(dataDir, "agent-system.db")
|
||
if openExisting {
|
||
st, err = store.OpenExistingSQLite(dbPath)
|
||
} else {
|
||
st, err = store.OpenSQLite(dbPath)
|
||
}
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer st.Close()
|
||
return fn(st)
|
||
}
|
||
|
||
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
|
||
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
|
||
var st *store.Store
|
||
var err error
|
||
if storageDSN != "" {
|
||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||
} else {
|
||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer st.Close()
|
||
return st.CreateAPIKey(owner, name)
|
||
}
|
||
|
||
// ListAPIKeys 列出租户密钥(摘要视图)。
|
||
func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) {
|
||
var st *store.Store
|
||
var err error
|
||
if storageDSN != "" {
|
||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||
} else {
|
||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer st.Close()
|
||
if _, err := st.GetUser(owner); err != nil {
|
||
return nil, fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
|
||
}
|
||
return st.ListAPIKeys(owner)
|
||
}
|
||
|
||
// RevokeAPIKey 撤销指定名称密钥。
|
||
func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
|
||
var st *store.Store
|
||
var err error
|
||
if storageDSN != "" {
|
||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||
} else {
|
||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||
}
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
defer st.Close()
|
||
return st.RevokeAPIKeyByName(owner, name)
|
||
}
|
||
|
||
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
|
||
// 只打开既有库,不建库、不迁移。
|
||
func EnsureSystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
|
||
return withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
|
||
return st.EnsureSystemIdentity(instanceID, dbref)
|
||
})
|
||
}
|
||
|
||
// VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。
|
||
// 只打开既有库,不建库、不迁移。schemaVersion 取身份行上的值。
|
||
func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, error) {
|
||
var schemaVersion int
|
||
err := withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
|
||
if err := st.VerifySystemIdentity(instanceID, dbref); err != nil {
|
||
return err
|
||
}
|
||
row, err := st.GetSystemIdentity()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
schemaVersion = row.SchemaVersion
|
||
return nil
|
||
})
|
||
return schemaVersion, err
|
||
}
|
||
|
||
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
|
||
func cachedInt(ttl time.Duration, fn func() int) func() int {
|
||
var mu sync.Mutex
|
||
var val int
|
||
var at time.Time
|
||
return func() int {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if time.Since(at) > ttl {
|
||
val = fn()
|
||
at = time.Now()
|
||
}
|
||
return val
|
||
}
|
||
}
|
||
|
||
// splitKeyPool 拆逗号分隔密钥池(trim 空段)。
|
||
// mcpCache 按配置串缓存 MCP Manager:配置不变复用同一实例(后端解析器
|
||
// per-role 缓存会长期持有它,进程型 server 不重复拉起);配置变更时关闭旧
|
||
// Manager(杀掉其进程型 server)再重建。配置错误返回 nil 并留日志——
|
||
// MCP 是可选能力,坏配置禁用该能力而不是阻塞产品启动。
|
||
var (
|
||
mcpMu sync.Mutex
|
||
mcpKey string
|
||
mcpManager *agentmcp.Manager
|
||
)
|
||
|
||
func mcpManagerFor(raw, overrides string) *agentmcp.Manager {
|
||
mcpMu.Lock()
|
||
defer mcpMu.Unlock()
|
||
key := raw + "\n" + overrides
|
||
if raw == "" {
|
||
if mcpManager != nil {
|
||
mcpManager.Close()
|
||
mcpManager = nil
|
||
}
|
||
mcpKey = ""
|
||
return nil
|
||
}
|
||
if key == mcpKey && mcpManager != nil {
|
||
return mcpManager
|
||
}
|
||
var servers []agentmcp.ServerConfig
|
||
if err := json.Unmarshal([]byte(raw), &servers); err != nil {
|
||
log.Printf("squad agent-native: tools.mcp.servers is not valid JSON, MCP disabled: %v", err)
|
||
return nil
|
||
}
|
||
mgr, err := agentmcp.NewManager(servers)
|
||
if err != nil {
|
||
log.Printf("squad agent-native: MCP config invalid, MCP disabled: %v", err)
|
||
return nil
|
||
}
|
||
if err := mgr.ApplyOverrides(overrides); err != nil {
|
||
log.Printf("squad agent-native: MCP overrides invalid, MCP disabled: %v", err)
|
||
return nil
|
||
}
|
||
if mcpManager != nil {
|
||
mcpManager.Close()
|
||
}
|
||
mcpKey, mcpManager = key, mgr
|
||
return mgr
|
||
}
|
||
|
||
func splitKeyPool(s string) []string {
|
||
var out []string
|
||
for _, k := range strings.Split(s, ",") {
|
||
if k = strings.TrimSpace(k); k != "" {
|
||
out = append(out, k)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口
|
||
// (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。
|
||
type busAdapter struct{ bus *cmdbus.Bus }
|
||
|
||
func (a busAdapter) CommandTools() []runmgr.CommandTool {
|
||
return a.CommandToolsFor("")
|
||
}
|
||
|
||
func (a busAdapter) CommandToolsFor(owner string) []runmgr.CommandTool {
|
||
src := a.bus.CommandToolsFor(owner)
|
||
out := make([]runmgr.CommandTool, len(src))
|
||
for i, ct := range src {
|
||
out[i] = runmgr.CommandTool{ID: ct.ID, Description: ct.Description, Params: ct.Params, Risk: ct.Risk}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (a busAdapter) DispatchCommand(ctx context.Context, req runmgr.CommandRequest) runmgr.CommandResponse {
|
||
resp := a.bus.Dispatch(ctx, cmdbus.Request{
|
||
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
|
||
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
|
||
})
|
||
return runmgr.CommandResponse{
|
||
State: string(resp.Receipt.State), Reason: resp.Receipt.Reason,
|
||
Payload: resp.Payload, Card: resp.Card != nil,
|
||
}
|
||
}
|