diff --git a/.task/fix-model-key-secret-overlay.md b/.task/fix-model-key-secret-overlay.md index 7cc2e312..0e0fe919 100644 --- a/.task/fix-model-key-secret-overlay.md +++ b/.task/fix-model-key-secret-overlay.md @@ -7,45 +7,50 @@ - 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= 919f75cf7b39ca357e212a08a0ba5ba3d9a79b51 - 工作区:/home/weichen/projects/Agent-System-Dev-wt-fix-model-key-secret-overlay(worktree);分支:fix/model-key-secret-overlay -- 授权与交付终点:<本次允许的动作与终点;未授权事项写明> -- 交付模式:manual(前序人工决策,智能体 Review/合入);其他阶段明确授权后可记为 authorized-continuous +- 授权与交付终点:修复 8788 对话 401。允许改后端密钥迁移/播种、401 诊断、svc 播种校验与文档;交付终点是后端 PR 与 Dev gitlink PR 合入 main。不改生产密钥,不清理 genui-smoke / u7-creator 的遗留密钥行。 +- 交付模式:authorized-continuous(用户要求按已确认方案连续实现到合入) - 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入) -- 自动推进授权:无;授权范围:无;授权来源:无;授权终点:无;停止条件:无(未授权) -- 当前阶段:需求拆解;当前增量:I1(待定义) +- 自动推进授权:有;授权范围:阶段 1–9,本任务卡所列需求与方案;授权来源:用户消息「Implement the plan as specified... Don't stop until you have completed all the to-dos」(2026-10-15);授权终点:后端与 Dev 的 PR 合入 main;停止条件:CI 失败、冲突无法安全解决、或需要改动方案外的密钥/生产数据 +- 当前阶段:实施与自检;当前增量:I1 - 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位) ## 目标与非目标 -<预期结果、受影响入口/模块/契约;明确不做什么。> +对话使用的模型密钥以 secret_keys 的生效行为准。环境变量播种的新 key 必须成为系统行生效密钥,不能被旧行盖住。401 要带出来源层级和脱敏指纹。 + +不做:不直接改 8788 的数据库文件;不删除其他账号的遗留密钥;不更换 .env.dev。 ## 关联方向与规划 -- 适用状态:待核对(无适用规划时说明原因) -- 主记录路径/版本:待核对(路径按仓库根目录定位,归档后不变;只引用唯一正文) -- 对应里程碑:待核对 +- 适用状态:无单独长期规划。这是 8788 实测 401 的修复,方案在本次任务卡。 +- 主记录路径/版本:本任务卡 +- 对应里程碑:不适用 ## 需求分析 | ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 | |---|---|---|---|---|---|---| -| R1 | 待填写 | 待评估 | 待定 | 待定义 | 待确认 | 待评审 | +| R1 | 已有生效密钥与设置明文不同时,迁移保留明文并告警 | 符合:secret_keys 优先于设置明文 | P0 | 旧行仍生效,明文仍在,日志含服务名且不含明文 | 无 | 纳入本增量 | +| R2 | 系统设置写入非掩码密钥时,同步为系统行生效密钥;相同值不追加行 | 符合:PUT /api/settings/system 是播种入口 | P0 | 旧生效值被新值替换,label 为播种;再写同一值不增加行 | R1 | 纳入本增量 | +| R3 | 401 的 run.failed 带密钥来源和脱敏指纹 | 符合:错误要能定位用了哪一层 key | P1 | 文本含 source 与 sha256 前 8 位,不含明文 | R2 | 纳入本增量 | +| R4 | 播种后核对嵌入式库生效密钥指纹,并写明密钥表优先 | 符合:svc 播种是换 key 的运维入口 | P1 | 不一致时告警且不含明文;一致时旧行被替换 | R2 | 纳入本增量 | ## 方案与计划 -- 方案引用:待填写(含对象版本) -- 实施计划引用:待填写(含增量与需求 ID) -- 测试计划引用:待填写(含需求 ID 与验收条件) +- 方案:迁移按生效密钥比较;系统设置写入调用 AddSecret;401 附 source 与指纹;svc 只比较指纹。对象版本:后端 origin/main c7b4ad7 之上。 +- 实施计划:I1 后端 R1–R3,I2 Dev 仓 R4 与 gitlink。 +- 测试计划:settings 迁移/播种测试、runtime 401 指纹测试、tests/test_svc.py 两条播种用例。 ## 阶段评审 | 阶段 | 对象版本 | 结论 | 评审人 | 时间 | 依据 | |---|---|---|---|---|---| -| 需求拆解 | 待定 | 未评审 | — | — | — | -| 需求评估 | 待定 | 未评审 | — | — | — | -| 需求表确认 | 待定 | 未评审 | — | — | — | -| 方案设计 | 待定 | 未评审 | — | — | — | -| 实施计划 | 待定 | 未评审 | — | — | — | -| 实施与自检 | 待定 | 未评审 | — | — | — | +| 需求拆解 | 方案确认稿 | 通过 | 用户 | 2026-10-15 | 连续实现授权,范围即本任务卡 R1–R4 | +| 需求评估 | 方案确认稿 | 通过 | 用户 | 2026-10-15 | 同上 | +| 需求表确认 | 方案确认稿 | 通过 | 用户 | 2026-10-15 | 同上 | +| 方案设计 | 方案确认稿 | 通过 | 用户 | 2026-10-15 | 同上 | +| 实施计划 | 方案确认稿 | 通过 | 用户 | 2026-10-15 | 同上 | +| 实施与自检 | 进行中 | 未评审 | — | — | 局部测试见证据表 | | 正式测试与验收 | 待定 | 未评审 | — | — | — | | 代码 Review | 待定 | 未评审 | — | — | — | | 合入 | 待定 | 未评审 | — | — | — | diff --git a/docs/svc.md b/docs/svc.md index 129b1e80..94628f5a 100644 --- a/docs/svc.md +++ b/docs/svc.md @@ -144,6 +144,9 @@ scripts/svc.sh 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**: diff --git a/modules/backend b/modules/backend index 7d6490fe..24bbb7b7 160000 --- a/modules/backend +++ b/modules/backend @@ -1 +1 @@ -Subproject commit 7d6490feee11143ca93129530f3d57a79e41b48a +Subproject commit 24bbb7b777d860195ae79e51a60454b9e1ffd6ae diff --git a/scripts/svc.sh b/scripts/svc.sh index 9b09fc56..60932c87 100755 --- a/scripts/svc.sh +++ b/scripts/svc.sh @@ -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() { diff --git a/tests/test_svc.py b/tests/test_svc.py index 9fc88f52..60ebf1cb 100644 --- a/tests/test_svc.py +++ b/tests/test_svc.py @@ -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):物化/产物/工作台/播种跳过 ----