Merge pull request 'Warn when a seeded model key does not become active' (#569) from fix/model-key-secret-overlay into main
ci / svc-realcombo (push) Successful in 1m48s
ci / test-pg (push) Failing after 2m0s
ci / test (push) Successful in 6m21s

Why: seeding a new model key left the old secret row in force.
Evidence: CI run 3341 jobs test, test-pg, and svc-realcombo succeeded on 34152f740f. Backend #205 is 11c337d3.
Impact: seed warns on fingerprint mismatch; backend gitlink keeps the adoption fix.
Refs: fix/model-key-secret-overlay
This commit was merged in pull request #569.
This commit is contained in:
2026-10-15 11:52:25 +00:00
5 changed files with 390 additions and 6 deletions
File diff suppressed because one or more lines are too long
+3
View File
@@ -144,6 +144,9 @@ scripts/svc.sh <name> deploy [--kind agent|static] [--from 源] [--tag T]... [
**agent**:来源见 [§5](#5-命令参考部署来源);启动参数组装见 [§9 配置系统](#9-配置系统);
健康后按 `--model-*` / `--light-*` / `--tavily-keys` 播种(幂等,设置存在 agent 数据目录)。
密钥表 `secret_keys` 的生效行优先于设置明文;换 key 后以 `/api/secrets` 为准。
当前后端在 `PUT /api/settings/system` 收到非掩码密钥时,会把它写成系统行的生效密钥。
嵌入式库中生效密钥与环境变量指纹不一致时,播种后告警,不打印明文。
**static**:
+45
View File
@@ -581,6 +581,9 @@ dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只
SVC_TAVILY_KEYS Tavily 密钥池
SVC_SEED_OWNER 播种目标账号(缺省取库内首个管理员)
播种幂等:设置持久化在 agent 数据目录,播过一次后续启动无需再传。
密钥表 secret_keys 的生效行优先于设置明文。换 key 后以 /api/secrets 为准;
播种写入系统设置时,当前后端会把非掩码密钥同步成系统行的生效密钥。
嵌入式库里生效密钥与环境变量指纹不一致时,启动会告警且不打印明文。
认证开启(PG 档默认)时播种自动铸临时密钥(name=svc-seed,用后即回收),
不依赖手动到设置页配置。
@@ -3564,11 +3567,53 @@ for u in users:
[ -n "${SVC_MINIMAX_BASE:-}${SVC_MINIMAX_KEY:-}${SVC_MINIMAX_CHAT:-}${SVC_MINIMAX_FAST:-}" ] && did="${did:+$did、}MiniMax 备用"
[ -n "${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}" ] && did="${did:+$did、}Tavily"
c_ok "[$ENV_NAME] 已播种设置:$did(持久化于 $ENV_DATA,后续启动无需再传)"
verify_seeded_secrets
else
c_warn "[$ENV_NAME] 设置播种未成功:${resp:-<无响应>}(认证开启时需库内已有账号以铸临时密钥,或用 --seed-owner 指定;也可在设置页手动配置)"
fi
}
# verify_seeded_secrets:嵌入式库里,系统行生效密钥要和环境变量是同一把。
# 只比较 SHA-256 前 8 位,不一致就告警。没有库、没有密钥表或还没有生效行时跳过。
verify_seeded_secrets() {
local db="${ENV_DATA:-}/agent-system.db"
[ -f "$db" ] || return 0
local chat="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" light="${CLI_LKEY:-${SVC_LIGHT_KEY:-}}"
[ -n "$chat$light" ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
local report
report="$(python3 - "$db" "$chat" "$light" <<'PY'
import hashlib, sqlite3, sys
db, chat, light = sys.argv[1:4]
con = sqlite3.connect("file:%s?mode=ro" % db, uri=True)
tables = {row[0] for row in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if "secret_keys" not in tables:
raise SystemExit(0)
def active(service):
row = con.execute(
"SELECT value FROM secret_keys WHERE owner=? AND service=? AND active=1 ORDER BY created_at DESC LIMIT 1",
("", service),
).fetchone()
return row[0] if row else None
def fp(value):
return hashlib.sha256(value.encode()).hexdigest()[:8]
parts = []
for service, expect in (("models.chat", chat), ("models.lightweight", light)):
if not expect:
continue
got = active(service)
if got is None or fp(got) == fp(expect):
continue
parts.append("%s fp=%s expected=%s" % (service, fp(got), fp(expect)))
if parts:
print("; ".join(parts))
PY
)" || return 0
if [ -n "$report" ]; then
c_warn "[$ENV_NAME] 播种后的生效密钥与环境变量不一致:$report。密钥表优先于设置明文,换 key 后以 /api/secrets 为准"
fi
}
# seed_settings_db:rdm 产物不能铸 API key 时,把同一份配置合并进嵌入式系统设置。
# 库还不存在时不创建,避免在空数据目录里写出半套表。
seed_settings_db() {
+80 -5
View File
@@ -58,9 +58,13 @@ case "${1:-}" in
[ -n "${AGENT_SYSTEM_DSN:-}" ] && printf 'AGENT_SYSTEM_DSN=%s\n' "$AGENT_SYSTEM_DSN"
[ -n "${AGENT_SYSTEM_AUTH:-}" ] && printf 'AGENT_SYSTEM_AUTH=%s\n' "$AGENT_SYSTEM_AUTH"
} > "$trace_dir/serve.txt"
addr=""
addr="" data=""
while [ $# -gt 0 ]; do
case "$1" in --addr) addr="$2"; shift 2 ;; *) shift ;; esac
case "$1" in
--addr) addr="$2"; shift 2 ;;
--data) data="$2"; shift 2 ;;
*) shift ;;
esac
done
port="${addr##*:}"; host="${addr%:*}"
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
@@ -68,10 +72,10 @@ case "${1:-}" in
# 请求体到 trace/put.txt 供播种断言(settings 播种验证,2026-10-11)。
# HTTP 子进程轮询 ppid:父进程被 SIGKILL 后自行退出,避免与 recover 套件
# 共用 1880x 端口时泄漏占用(与 test_svc_recover 桩同规)。
python3 - "$port" "$host" "$trace_dir" <<'PYSRV' >/dev/null 2>&1 &
import os, sys, threading, time
python3 - "$port" "$host" "$trace_dir" "$data" <<'PYSRV' >/dev/null 2>&1 &
import json, os, sqlite3, sys, threading, time
from http.server import BaseHTTPRequestHandler, HTTPServer
port, host, trace = int(sys.argv[1]), sys.argv[2], sys.argv[3]
port, host, trace, data = int(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4]
class H(BaseHTTPRequestHandler):
def _ok(self, body=b'{"ok":true}'):
self.send_response(200)
@@ -88,7 +92,35 @@ class H(BaseHTTPRequestHandler):
body = self.rfile.read(n).decode('utf-8', 'replace')
with open(trace + '/put.txt', 'a') as f:
f.write('AUTH=%s\nBODY=%s\n--\n' % (self.headers.get('Authorization') or '-', body))
self._adopt(body)
self._ok()
def _adopt(self, body):
# 模拟当前后端:系统设置里的非掩码主模型 key 成为系统行生效密钥。
if os.environ.get('SVC_STUB_KEEP_OLD_SECRET') == '1':
return
db = os.path.join(data, 'agent-system.db')
if not db or not os.path.exists(db):
return
try:
incoming = json.loads(body)
chat = incoming.get('models', {}).get('chat', {}).get('apiKey')
except Exception:
return
if not chat:
return
con = sqlite3.connect(db, timeout=5)
try:
tables = {row[0] for row in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if 'secret_keys' not in tables:
return
con.execute("UPDATE secret_keys SET active=0 WHERE owner='' AND service='models.chat'")
con.execute(
"INSERT INTO secret_keys(id,owner,service,label,value,active,created_at) VALUES(?,?,?,?,?,1,?)",
('key_seeded', '', 'models.chat', '播种', chat, '2026-10-15T00:00:00Z'),
)
con.commit()
finally:
con.close()
def log_message(self, *args):
pass
ppid = os.getppid()
@@ -759,6 +791,49 @@ class SvcDbRecoveryTests(unittest.TestCase):
self.assertIn('"model": "stub-model"', put.split("BODY=", 1)[1])
self.assertNotIn("apikey", self.cli_record()) # 认证关闭不铸 key
def plant_old_chat_secret(self, name, value):
import sqlite3
db = self.runtime(name) / "data" / "agent-system.db"
con = sqlite3.connect(db)
con.execute(
"CREATE TABLE secret_keys(id TEXT, owner TEXT, service TEXT, label TEXT, value TEXT, active INTEGER, created_at TEXT)")
con.execute(
"INSERT INTO secret_keys VALUES('key_old','','models.chat','迁入',?,1,'2026-10-11T00:00:00Z')",
(value,))
con.commit()
con.close()
return db
def test_seed_replaces_old_system_secret(self):
d = self.runtime("dev")
self.seed_agent("dev", 18836,
"--addr 127.0.0.1:18836 --data %s --plugins %s --auth auto"
% (d / "data", d / "plugins"))
db = self.plant_old_chat_secret("dev", "sk-old-secret")
result = self.svc("dev", "start", env=self.model_env())
self.assertIn("已播种设置:主模型", result.stdout)
self.assertNotIn("生效密钥与环境变量不一致", result.stdout)
self.assertNotIn("sk-old-secret", result.stdout)
self.assertNotIn("sk-stub-model", result.stdout)
import sqlite3
active = sqlite3.connect(db).execute(
"SELECT value FROM secret_keys WHERE owner='' AND service='models.chat' AND active=1"
).fetchone()
self.assertEqual(active[0], "sk-stub-model")
def test_seed_warns_when_old_secret_stays_active(self):
d = self.runtime("dev")
self.seed_agent("dev", 18837,
"--addr 127.0.0.1:18837 --data %s --plugins %s --auth auto"
% (d / "data", d / "plugins"))
self.plant_old_chat_secret("dev", "sk-old-secret")
result = self.svc("dev", "start", env=self.model_env(SVC_STUB_KEEP_OLD_SECRET="1"))
self.assertIn("已播种设置:主模型", result.stdout)
self.assertIn("生效密钥与环境变量不一致", result.stdout)
self.assertIn("models.chat fp=", result.stdout)
self.assertNotIn("sk-old-secret", result.stdout)
self.assertNotIn("sk-stub-model", result.stdout)
# ---- 13. rdm 部署源(下游组合仓 Agent-System-RDM):物化/产物/工作台/播种跳过 ----