Files
Agent-System-Backend/pkg/identity/identity.go
T
weichenandCursor 5c26a86331
ci / vet-and-test (pull_request) Successful in 47s
Publish schema target and compatibility floor
Why: a rollback can only be safe when the candidate says which schema it
will migrate to and whether older binaries can still read that schema.
A new migration that forgets to register that fact must fail the test.
Evidence: go test ./internal/store/ ./pkg/identity/ -run 'TestMigrationCompat|TestCapabilities' passed.
Impact: version --json and identity capabilities include schema.target and
schema.compatFloor. Every migration version is listed in
migrationBreaksOldReaders. Current versions do not break old readers, so
the floor stays 0.
Refs: recovery plan r3.1 D2

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:11:07 +00:00

188 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package identity 提供服务实例库内身份子命令的公共 CLI(U1 I3:
// manifest.instance_id ↔ agent_system_identity 表)。
// 独立可导出包:下游组合仓(Agent-System-RDM 等)与基座二进制共用同一实现。
// 契约:无参/未知子命令打印用法并以非零码退出;只打开既有库,不建库、不迁移。
package identity
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"runtime/debug"
"strings"
"agent-system-backend/internal/app"
"agent-system-backend/internal/store"
)
// Usage 无参/未知子命令时打印到 stderr 的用法行。探测契约:同时含 "identity"
// 与 "ensure" 字样(旧 svc 探测仍按 usage 形态判有能力)。
const Usage = "用法:identity <ensure|verify|capabilities> --instance-id ID [--dbref REF] [--data DIR] [--dsn-file FILE]"
// Product 写入能力文档的产品名。组合仓在调用 Run 前改成自己的名字。
var Product = "agent-system"
// Document 是 version --json 与 identity capabilities 共用的能力文档。
type Document struct {
Protocol int `json:"protocol"`
Product string `json:"product"`
Version string `json:"version"`
Identity IdentityCaps `json:"identity"`
CLI CLICaps `json:"cli"`
Schema *SchemaCaps `json:"schema,omitempty"`
}
// IdentityCaps 描述身份子命令的打开方式。
type IdentityCaps struct {
Ops []string `json:"ops"`
Open string `json:"open"`
DSN string `json:"dsn"`
}
// CLICaps 描述未知子命令的行为。
type CLICaps struct {
UnknownSubcommand string `json:"unknownSubcommand"`
}
// SchemaCaps 由 D2 填入。零值时从 JSON 省略。
type SchemaCaps struct {
Target int `json:"target"`
CompatFloor int `json:"compatFloor"`
}
// NewDocument 构造当前进程的能力文档。
func NewDocument(product, version string) Document {
if product == "" {
product = Product
}
if version == "" {
version = binaryVersion()
}
return Document{
Protocol: 1,
Product: product,
Version: version,
Identity: IdentityCaps{Ops: []string{"ensure", "verify"}, Open: "existing-no-migrate", DSN: "env"},
CLI: CLICaps{UnknownSubcommand: "reject"},
Schema: &SchemaCaps{Target: store.SchemaTarget(), CompatFloor: store.SchemaCompatFloor()},
}
}
// WriteDocument 把能力文档写成一行 JSON。
func WriteDocument(w io.Writer, product, version string) error {
raw, err := json.Marshal(NewDocument(product, version))
if err != nil {
return err
}
_, err = fmt.Fprintf(w, "%s\n", raw)
return err
}
func binaryVersion() string {
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" {
return bi.Main.Version
}
return "(devel)"
}
// Run 执行 identity 子命令;args 不含 "identity" 自身,返回进程退出码。
func Run(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, Usage)
return 2
}
sub := args[0]
if sub == "capabilities" {
if err := WriteDocument(os.Stdout, Product, ""); err != nil {
fmt.Fprintln(os.Stderr, "identity capabilities:", err)
return 1
}
return 0
}
fs := flag.NewFlagSet("identity "+sub, flag.ContinueOnError)
fs.SetOutput(io.Discard)
data := fs.String("data", "./data", "数据目录(SQLite 路径 <data>/agent-system.db)")
dsnFlag := fs.String("dsn", "", "PostgreSQL DSN(会进入进程参数;优先用环境变量或 --dsn-file)")
dsnFile := fs.String("dsn-file", "", "权限恰好 0600 的 DSN 文件")
instanceID := fs.String("instance-id", "", "服务实例 ID(manifest.instance_id)")
dbref := fs.String("dbref", "", "数据库锚点(空 = sqlite 或 pg)")
if err := fs.Parse(args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "identity:", err)
fmt.Fprintln(os.Stderr, Usage)
return 2
}
if *instanceID == "" {
fmt.Fprintln(os.Stderr, "identity: --instance-id 必填")
return 2
}
dsn, err := ResolveDSN(*dsnFlag, *dsnFile)
if err != nil {
fmt.Fprintln(os.Stderr, "identity:", err)
return 2
}
ref := *dbref
if ref == "" {
if dsn != "" {
ref = "pg"
} else {
ref = "sqlite"
}
}
switch sub {
case "ensure":
if err := app.EnsureSystemIdentity(dsn, *data, *instanceID, ref); err != nil {
fmt.Fprintln(os.Stderr, "identity ensure:", err)
return 1
}
fmt.Printf("ensured instance_id=%s dbref=%s\n", *instanceID, ref)
return 0
case "verify":
ver, err := app.VerifySystemIdentity(dsn, *data, *instanceID, ref)
if err != nil {
fmt.Fprintln(os.Stderr, "identity verify:", err)
return 1
}
fmt.Printf("verified instance_id=%s dbref=%s schema_version=%d\n", *instanceID, ref, ver)
return 0
default:
fmt.Fprintln(os.Stderr, "identity: 未知子命令", sub)
fmt.Fprintln(os.Stderr, Usage)
return 2
}
}
// ResolveDSN 按 --dsn-file、环境变量、--dsn 的顺序取值。--dsn 会告警。
// --dsn 与 --dsn-file 同时出现则拒绝。文件权限必须恰好 0600,且不能是符号链接。
func ResolveDSN(flagDSN, flagFile string) (string, error) {
if flagDSN != "" && flagFile != "" {
return "", fmt.Errorf("--dsn 与 --dsn-file 只能选一个")
}
if flagFile != "" {
st, err := os.Lstat(flagFile)
if err != nil {
return "", err
}
if st.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("--dsn-file 不能是符号链接")
}
if st.Mode().Perm() != 0o600 {
return "", fmt.Errorf("--dsn-file 权限必须为 0600(当前 %o)", st.Mode().Perm())
}
raw, err := os.ReadFile(flagFile)
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
if env := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DSN")); env != "" && flagDSN == "" {
return env, nil
}
if flagDSN != "" {
fmt.Fprintln(os.Stderr, "identity: 警告:--dsn 会进入进程参数,请改用 AGENT_SYSTEM_DSN 或 --dsn-file")
return flagDSN, nil
}
return "", nil
}