Files
Agent-System-Backend/internal/config/config.go
T
weichenandCursor b85a1e1ca3 Add immutable layered Python environments for the sandbox.
Why: The sandbox hides the host home directory and has no network, so pip install --user cannot supply python-docx, and the service PATH resolved python3 to a linuxbrew path that does not exist inside the sandbox.
Evidence: SELF go test of execenv, tools, store, app, server, runmgr, config, and cmd/agent-system on this worktree. CI has not run.
Impact: Sessions inherit a read-only base layer. Extra packages are requested through python_env, auto-approved only from the allowlist, and mounted per conversation. Schema 35 adds the request and binding tables.
Refs: .task/feature-layered-python-env.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 13:52:48 +00:00

576 lines
16 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 config 提供 agent-system 启动层配置的单一解析管道:
// 内置默认 → 显式配置文件 → 环境变量 → 显式 CLI。
// 规范依据:Development-Guidelines standards/configuration.md(单一解析管道/启动依赖);
// 运行时业务设置(/api/settings)是独立的"产品业务策略"层,不在此合并。
// 只读诊断见 Resolved.Explain:不建数据库、不写业务目录,DSN 脱敏输出。
package config
import (
"encoding/json"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
)
// Config 启动部署连接类字段(与 app.Options 一一对应)。
type Config struct {
Addr string `json:"addr"`
DataDir string `json:"data"`
PluginsDir string `json:"plugins"`
StorageDSN string `json:"dsn"`
ArtifactsDir string `json:"artifacts"`
AuthMode string `json:"auth"`
Mock bool `json:"mock"`
Instance string `json:"instance"`
InsecureListen bool `json:"insecureListen"`
AllowedHostsRaw string `json:"allowedHosts"`
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
DisableExecTools bool `json:"disableExecTools"`
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
// 隔离档)。DisableExecTools 优先级高于后端选择。
ExecBackend string `json:"execBackend"`
// ExecDepsDir sandbox 档只读依赖层目录(空 = 不挂载)。
ExecDepsDir string `json:"execDepsDir"`
// ExecWorkspace sandbox 档工作区模式:空=直写;overlay=冻结基底+upper。
ExecWorkspace string `json:"execWorkspace"`
// ExecEnvRoot 分层 Python 环境根。空 = 不启用申请代理。
ExecEnvRoot string `json:"execEnvRoot"`
// Tier 部署档(协作存储方案 KTD1):空=auto(DSN→PG,否则 SQLite);
// local=强制 SQLite(不允许 DSN);team=强制 PG(缺 DSN/未开认证拒绝启动,
// PG 故障拒绝就绪,绝不回落 SQLite)。
Tier string `json:"tier"`
// PG 连接预算(KTD12):零值走默认(16/8/300s/不限/不限);负值非法。
// PGQueryTimeoutSec>0 时以 statement_timeout 附着到 PG DSN。
PGMaxOpen int `json:"pgMaxOpen"`
PGMaxIdle int `json:"pgMaxIdle"`
PGConnMaxIdleSec int `json:"pgConnMaxIdleSec"`
PGConnMaxLifetimeSec int `json:"pgConnMaxLifetimeSec"`
PGQueryTimeoutSec int `json:"pgQueryTimeoutSec"`
}
var defaults = Config{
Addr: "127.0.0.1:8787",
DataDir: "./data",
PluginsDir: "./plugins",
AuthMode: "auto",
}
// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。
var envKey = map[string]string{
"addr": "AGENT_SYSTEM_ADDR",
"data": "AGENT_SYSTEM_DATA",
"plugins": "AGENT_SYSTEM_PLUGINS",
"dsn": "AGENT_SYSTEM_DSN",
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
"auth": "AGENT_SYSTEM_AUTH",
"mock": "AGENT_SYSTEM_MOCK",
"instance": "AGENT_SYSTEM_INSTANCE",
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
"execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR",
"execWorkspace": "AGENT_SYSTEM_EXEC_WORKSPACE",
"execEnvRoot": "AGENT_SYSTEM_EXEC_ENV_ROOT",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
"tier": "AGENT_SYSTEM_TIER",
"pgMaxOpen": "AGENT_SYSTEM_PG_MAX_OPEN",
"pgMaxIdle": "AGENT_SYSTEM_PG_MAX_IDLE",
"pgConnMaxIdleSec": "AGENT_SYSTEM_PG_CONN_MAX_IDLE_SEC",
"pgConnMaxLifetimeSec": "AGENT_SYSTEM_PG_CONN_MAX_LIFETIME_SEC",
"pgQueryTimeoutSec": "AGENT_SYSTEM_PG_QUERY_TIMEOUT_SEC",
}
// Source 配置来源层级(数值越大优先级越高)。
type Source int
const (
SrcDefault Source = iota
SrcFile
SrcEnv
SrcCLI
)
func (s Source) String() string {
switch s {
case SrcFile:
return "file"
case SrcEnv:
return "env"
case SrcCLI:
return "cli"
}
return "default"
}
// Resolved 解析结果:配置快照 + 每字段来源(供 explain 与测试断言)。
type Resolved struct {
Config
sources map[string]Source
}
// SourceOf 返回字段来源;未知字段名返回 SrcDefault。
func (r Resolved) SourceOf(field string) Source { return r.sources[field] }
// Load 合并四源。file 为空表示无配置文件层;非空而缺失/未知键/类型错误均为错误,
// 错误信息须定位到字段。cliSet 记录显式传入的 CLI 字段(flag.Visit 语义):
// 未显式传入的 CLI 字段不参与覆盖(区分"未设置"与零值)。
func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet map[string]bool) (Resolved, error) {
cfg := defaults
src := map[string]Source{}
for f := range envKey {
src[f] = SrcDefault
}
if file != "" {
fc, present, err := loadFile(file)
if err != nil {
return Resolved{}, err
}
// 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。
for f := range present {
setField(&cfg, f, fc)
src[f] = SrcFile
}
}
// 环境层:变量存在即参与覆盖(空串按显式空值处理,字段 schema 自行判定)。
for f, key := range envKey {
if v, ok := envLookup(key); ok {
if isBoolField(f) {
b, err := parseBool(key, v)
if err != nil {
return Resolved{}, err
}
setBool(&cfg, f, b)
} else if isIntField(f) {
n, err := parseInt(key, v)
if err != nil {
return Resolved{}, err
}
setInt(&cfg, f, n)
} else {
setString(&cfg, f, v)
}
src[f] = SrcEnv
}
}
// CLI 层:仅显式传入。
for f := range cliSet {
src[f] = SrcCLI
setField(&cfg, f, cli)
}
return Resolved{Config: cfg, sources: src}, nil
}
// setField 从同类型快照覆盖单个字段(文件/CLI 层复用;字段类型由字段名决定)。
func setField(dst *Config, f string, src Config) {
if isBoolField(f) {
setBool(dst, f, boolField(src, f))
} else if isIntField(f) {
setInt(dst, f, intField(src, f))
} else {
setString(dst, f, stringField(src, f))
}
}
func loadFile(path string) (Config, map[string]bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Config{}, nil, fmt.Errorf("config file %s: %w", path, err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
return Config{}, nil, fmt.Errorf("config file %s: %w", path, err)
}
present := map[string]bool{}
var c Config
for k, v := range m {
var dst any
switch k {
case "addr":
dst = &c.Addr
case "data":
dst = &c.DataDir
case "plugins":
dst = &c.PluginsDir
case "dsn":
dst = &c.StorageDSN
case "artifacts":
dst = &c.ArtifactsDir
case "auth":
dst = &c.AuthMode
case "mock":
dst = &c.Mock
case "instance":
dst = &c.Instance
case "insecureListen":
dst = &c.InsecureListen
case "allowedHosts":
dst = &c.AllowedHostsRaw
case "disableExecTools":
dst = &c.DisableExecTools
case "execBackend":
dst = &c.ExecBackend
case "execDepsDir":
dst = &c.ExecDepsDir
case "execWorkspace":
dst = &c.ExecWorkspace
case "execEnvRoot":
dst = &c.ExecEnvRoot
case "tier":
dst = &c.Tier
case "pgMaxOpen":
dst = &c.PGMaxOpen
case "pgMaxIdle":
dst = &c.PGMaxIdle
case "pgConnMaxIdleSec":
dst = &c.PGConnMaxIdleSec
case "pgConnMaxLifetimeSec":
dst = &c.PGConnMaxLifetimeSec
case "pgQueryTimeoutSec":
dst = &c.PGQueryTimeoutSec
default:
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
}
if err := json.Unmarshal(v, dst); err != nil {
return Config{}, nil, fmt.Errorf("config file %s: key %q: %w", path, k, err)
}
present[k] = true
}
return c, present, nil
}
func stringField(c Config, f string) string {
switch f {
case "addr":
return c.Addr
case "data":
return c.DataDir
case "plugins":
return c.PluginsDir
case "dsn":
return c.StorageDSN
case "artifacts":
return c.ArtifactsDir
case "auth":
return c.AuthMode
case "instance":
return c.Instance
case "allowedHosts":
return c.AllowedHostsRaw
case "execBackend":
return c.ExecBackend
case "execDepsDir":
return c.ExecDepsDir
case "execWorkspace":
return c.ExecWorkspace
case "execEnvRoot":
return c.ExecEnvRoot
case "tier":
return c.Tier
}
return ""
}
func isBoolField(f string) bool {
return f == "mock" || f == "insecureListen" || f == "disableExecTools"
}
func isIntField(f string) bool {
switch f {
case "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec":
return true
}
return false
}
func intField(c Config, f string) int {
switch f {
case "pgMaxOpen":
return c.PGMaxOpen
case "pgMaxIdle":
return c.PGMaxIdle
case "pgConnMaxIdleSec":
return c.PGConnMaxIdleSec
case "pgConnMaxLifetimeSec":
return c.PGConnMaxLifetimeSec
case "pgQueryTimeoutSec":
return c.PGQueryTimeoutSec
}
return 0
}
func setInt(c *Config, f string, v int) {
switch f {
case "pgMaxOpen":
c.PGMaxOpen = v
case "pgMaxIdle":
c.PGMaxIdle = v
case "pgConnMaxIdleSec":
c.PGConnMaxIdleSec = v
case "pgConnMaxLifetimeSec":
c.PGConnMaxLifetimeSec = v
case "pgQueryTimeoutSec":
c.PGQueryTimeoutSec = v
}
}
func boolField(c Config, f string) bool {
switch f {
case "insecureListen":
return c.InsecureListen
case "disableExecTools":
return c.DisableExecTools
default:
return c.Mock
}
}
func setBool(c *Config, f string, v bool) {
switch f {
case "insecureListen":
c.InsecureListen = v
case "disableExecTools":
c.DisableExecTools = v
default:
c.Mock = v
}
}
func setString(c *Config, f, v string) {
switch f {
case "addr":
c.Addr = v
case "data":
c.DataDir = v
case "plugins":
c.PluginsDir = v
case "dsn":
c.StorageDSN = v
case "artifacts":
c.ArtifactsDir = v
case "auth":
c.AuthMode = v
case "instance":
c.Instance = v
case "allowedHosts":
c.AllowedHostsRaw = v
case "execBackend":
c.ExecBackend = v
case "execDepsDir":
c.ExecDepsDir = v
case "execWorkspace":
c.ExecWorkspace = v
case "execEnvRoot":
c.ExecEnvRoot = v
case "tier":
c.Tier = v
}
}
func parseBool(name, v string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1":
return true, nil
case "false", "0":
return false, nil
}
return false, fmt.Errorf("env %s: invalid bool %q", name, v)
}
func parseInt(name, v string) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, fmt.Errorf("env %s: invalid int %q", name, v)
}
return n, nil
}
// Explain 只读诊断:逐字段打印解析值与来源层级,DSN 脱敏。
func (r Resolved) Explain(w io.Writer, file string) {
if file != "" {
fmt.Fprintf(w, "config file: %s\n", file)
} else {
fmt.Fprintln(w, "config file: (none)")
}
fmt.Fprintln(w, "field value source")
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "execDepsDir", "execWorkspace", "execEnvRoot", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} {
var v string
if isBoolField(f) {
v = fmt.Sprintf("%t", boolField(r.Config, f))
} else if isIntField(f) {
n := intField(r.Config, f)
if d := intDefault(f); n == 0 && d != 0 {
v = fmt.Sprintf("%d (默认)", d)
} else {
v = fmt.Sprintf("%d", n)
}
} else {
v = stringField(r.Config, f)
}
if f == "dsn" {
v = redact(v)
}
fmt.Fprintf(w, "%-16s %-56s %s\n", f, v, r.sources[f])
}
if msgs := r.TierProblems(); len(msgs) > 0 {
for _, msg := range msgs {
fmt.Fprintf(w, "tier guard: 拒绝启动(%s)\n", msg)
}
} else {
fmt.Fprintln(w, "tier guard: 放行")
}
if msg := r.ListenRefusal(); msg != "" {
fmt.Fprintf(w, "listen guard: 拒绝启动(%s)\n", msg)
} else if r.InsecureListen && !IsLoopbackListen(r.Addr) && !r.AuthEnabled() {
fmt.Fprintln(w, "listen guard: 放行(已显式 insecureListen,无认证监听非回环地址)")
} else {
fmt.Fprintln(w, "listen guard: 放行")
}
}
// AuthEnabled 是认证开关的唯一判定:显式 on,或 auto 且配置了 PostgreSQL。
func (r Resolved) AuthEnabled() bool {
return r.AuthMode == "on" || (r.AuthMode != "off" && r.StorageDSN != "")
}
// intDefault 返回 int 字段的默认值(0 表示零值合法、无默认替换)。
func intDefault(f string) int {
switch f {
case "pgMaxOpen":
return DefaultPGMaxOpen
case "pgMaxIdle":
return DefaultPGMaxIdle
case "pgConnMaxIdleSec":
return DefaultPGConnMaxIdleSec
}
return 0
}
// 连接预算默认值(KTD12;与 store 层默认保持一致,两处同时修改时以测试对齐)。
const (
DefaultPGMaxOpen = 16
DefaultPGMaxIdle = 8
DefaultPGConnMaxIdleSec = 300
)
// TierProblems 部署档语义校验:返回阻止启动的问题列表(空 = 放行)。
// team 档 fail-closed:缺 DSN、未开认证直接拒绝;local 档不允许 DSN(KTD1)。
// 执行隔离在 team 档是启动警告而非拒绝(本增量先可见、U3/U5 再硬化)。
func (r Resolved) TierProblems() []string {
var msgs []string
switch r.Tier {
case "", "local", "team":
default:
msgs = append(msgs, fmt.Sprintf("未知部署档 %q(local | team)", r.Tier))
}
if r.Tier == "team" {
if r.StorageDSN == "" {
msgs = append(msgs, "team 档需要 PostgreSQL DSN(dsn / AGENT_SYSTEM_DSN);不会回落 SQLite")
}
if !r.AuthEnabled() {
msgs = append(msgs, "team 档必须开启认证(auth=on)")
}
}
if r.Tier == "local" && r.StorageDSN != "" {
msgs = append(msgs, "local 档不使用 PostgreSQL DSN(改用 team 档,或去掉 dsn)")
}
if v := r.PGMaxOpen; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgMaxOpen 不能为负(当前 %d)", v))
}
if v := r.PGMaxIdle; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgMaxIdle 不能为负(当前 %d)", v))
}
if v := r.PGConnMaxIdleSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgConnMaxIdleSec 不能为负(当前 %d)", v))
}
if v := r.PGConnMaxLifetimeSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgConnMaxLifetimeSec 不能为负(当前 %d)", v))
}
if v := r.PGQueryTimeoutSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgQueryTimeoutSec 不能为负(当前 %d)", v))
}
if idle, open := r.EffectivePGMaxIdle(), r.EffectivePGMaxOpen(); idle > open {
msgs = append(msgs, fmt.Sprintf("pgMaxIdle(%d) 大于 pgMaxOpen(%d)", idle, open))
}
return msgs
}
// EffectivePGMaxOpen/Idle/ConnMaxIdleSec 返回连接预算有效值(零值落默认,KTD12)。
func (r Resolved) EffectivePGMaxOpen() int {
if r.PGMaxOpen > 0 {
return r.PGMaxOpen
}
return DefaultPGMaxOpen
}
func (r Resolved) EffectivePGMaxIdle() int {
if r.PGMaxIdle > 0 {
return r.PGMaxIdle
}
return DefaultPGMaxIdle
}
func (r Resolved) EffectivePGConnMaxIdleSec() int {
if r.PGConnMaxIdleSec > 0 {
return r.PGConnMaxIdleSec
}
return DefaultPGConnMaxIdleSec
}
// AllowedHosts 把逗号分隔的主机名拆开,忽略空段。
func (r Resolved) AllowedHosts() []string {
var out []string
for _, part := range strings.Split(r.AllowedHostsRaw, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
// ListenRefusal 在非回环、未开认证、且没有显式放行时返回拒绝原因。空串表示可以启动。
func (r Resolved) ListenRefusal() string {
if r.AuthEnabled() || r.InsecureListen || IsLoopbackListen(r.Addr) {
return ""
}
return "非回环且未开认证。改用 127.0.0.1、加上 --auth on,或显式 --insecure-listen"
}
// IsLoopbackListen 判断监听地址是否只绑回环。空主机、0.0.0.0、:: 视为所有网卡。
func IsLoopbackListen(addr string) bool {
host := addr
if h, _, err := net.SplitHostPort(addr); err == nil {
host = h
}
host = strings.Trim(strings.ToLower(host), "[]")
switch host {
case "", "0.0.0.0", "::":
return false
case "localhost":
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// redact 脱敏 DSN:scheme://user:password@… → scheme://user:***@…。
func redact(dsn string) string {
i := strings.Index(dsn, "://")
if i < 0 {
return dsn
}
rest := dsn[i+3:]
at := strings.Index(rest, "@")
if at < 0 {
return dsn
}
userpart := rest[:at]
if c := strings.Index(userpart, ":"); c >= 0 {
return dsn[:i+3] + userpart[:c] + ":***" + rest[at:]
}
return dsn
}