Merge origin/main: supersede the PR #317 minimal fix with the unified resolver

Why: main landed fix-svc-dsn-reuse-dbref (reuse-path dbref restore, env-DSN
aware precheck, DSN_ENV scoping) for the same restart-DSN incident; the
approved recovery plan is its superset and replaces those mechanisms with
resolve_db_config + recoverable svc.env keys + refuse-on-invalid semantics.
Evidence: tests/test_svc_precheck.py ported to the new units (18 cases green,
same scenario matrix; broken-dbref expectation updated to refusal per the
approved plan); tests/test_svc.py 11 cases green; bash -n OK.
Impact: conflict hunks resolved toward the unified resolver; main's source
guard at the script tail restored; agent_listen_rejected kept as a pure
predicate (used by the start precheck and the governance tests).
Refs: main 7acf318 (PR #317); task card .task/feature-svc-db-config-recover.md.
This commit is contained in:
2026-10-11 09:21:27 +00:00
9 changed files with 989 additions and 87 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -93,7 +93,8 @@ frontend: contracts
build: frontend
cd $(BACKEND) && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o ../../$(BIN) ./cmd/agent-system
@echo "OK: $(BIN)(版本 $(VERSION);随附插件目录 $(PLUGINS)/)"
cd $(BACKEND) && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o ../../bin/storage-migrate ./cmd/storage-migrate
@echo "OK: $(BIN)(版本 $(VERSION);随附插件目录 $(PLUGINS)/)+ bin/storage-migrate(协作存储 U4 迁移工具)"
test-core:
cd $(CORE) && go test ./...
+80 -78
View File
@@ -1,80 +1,82 @@
[
"backend/app backend/bus",
"backend/app backend/cardsvc",
"backend/app backend/cmdbus",
"backend/app backend/config",
"backend/app backend/hostapi",
"backend/app backend/ingest",
"backend/app backend/intentgate",
"backend/app backend/intentsem",
"backend/app backend/pgbus",
"backend/app backend/plugins",
"backend/app backend/profiles",
"backend/app backend/prompts",
"backend/app backend/rag",
"backend/app backend/ratelimit",
"backend/app backend/runmgr",
"backend/app backend/runtime",
"backend/app backend/server",
"backend/app backend/settings",
"backend/app backend/skills",
"backend/app backend/squadadapter",
"backend/app backend/store",
"backend/app backend/tools",
"backend/app backend/workflow",
"backend/cardsvc backend/store",
"backend/hostapi backend/store",
"backend/pgbus backend/bus",
"backend/pgbus backend/store",
"backend/plugins backend/settings",
"backend/plugins backend/store",
"backend/plugins backend/skills",
"backend/plugins backend/tools",
"backend/plugins backend/workflow",
"backend/profiles backend/store",
"backend/prompts backend/store",
"backend/rag backend/settings",
"backend/rag backend/store",
"backend/route backend/intentgate",
"backend/runmgr backend/bus",
"backend/runmgr backend/cardsvc",
"backend/runmgr backend/cmdbus",
"backend/runmgr backend/ingest",
"backend/runmgr backend/intentgate",
"backend/runmgr backend/profiles",
"backend/runmgr backend/route",
"backend/runmgr backend/runtime",
"backend/runmgr backend/settings",
"backend/runmgr backend/store",
"backend/runmgr backend/tools",
"backend/runmgr backend/workflow",
"backend/runtime backend/ctxasm",
"backend/runtime backend/docgen",
"backend/runtime backend/prompts",
"backend/runtime backend/quality",
"backend/runtime backend/squadadapter",
"backend/runtime backend/wfcompile",
"backend/runtime backend/workflow",
"backend/server backend/cardsvc",
"backend/server backend/cmdbus",
"backend/server backend/ingest",
"backend/server backend/intentgate",
"backend/server backend/plugins",
"backend/server backend/profiles",
"backend/server backend/prompts",
"backend/server backend/ratelimit",
"backend/server backend/route",
"backend/server backend/runmgr",
"backend/server backend/settings",
"backend/server backend/store",
"backend/server backend/tools",
"backend/server backend/workflow",
"backend/settings backend/store",
"backend/squadadapter backend/quality",
"backend/store backend/blobstore",
"backend/tools backend/quality",
"backend/wfcompile backend/tools",
"backend/wfcompile backend/workflow",
"frontend/app frontend/components",
"frontend/components frontend/app"
"backend/app backend/bus",
"backend/app backend/cardsvc",
"backend/app backend/cmdbus",
"backend/app backend/config",
"backend/app backend/hostapi",
"backend/app backend/ingest",
"backend/app backend/intentgate",
"backend/app backend/intentsem",
"backend/app backend/pgbus",
"backend/app backend/plugins",
"backend/app backend/profiles",
"backend/app backend/prompts",
"backend/app backend/rag",
"backend/app backend/ratelimit",
"backend/app backend/runmgr",
"backend/app backend/runtime",
"backend/app backend/server",
"backend/app backend/settings",
"backend/app backend/skills",
"backend/app backend/squadadapter",
"backend/app backend/store",
"backend/app backend/surfacesvc",
"backend/app backend/tools",
"backend/app backend/workflow",
"backend/cardsvc backend/store",
"backend/hostapi backend/store",
"backend/pgbus backend/bus",
"backend/pgbus backend/store",
"backend/plugins backend/settings",
"backend/plugins backend/skills",
"backend/plugins backend/store",
"backend/plugins backend/tools",
"backend/plugins backend/workflow",
"backend/profiles backend/store",
"backend/prompts backend/store",
"backend/rag backend/settings",
"backend/rag backend/store",
"backend/route backend/intentgate",
"backend/runmgr backend/bus",
"backend/runmgr backend/cardsvc",
"backend/runmgr backend/ingest",
"backend/runmgr backend/intentgate",
"backend/runmgr backend/profiles",
"backend/runmgr backend/route",
"backend/runmgr backend/runtime",
"backend/runmgr backend/settings",
"backend/runmgr backend/store",
"backend/runmgr backend/tools",
"backend/runmgr backend/workflow",
"backend/runtime backend/ctxasm",
"backend/runtime backend/docgen",
"backend/runtime backend/prompts",
"backend/runtime backend/quality",
"backend/runtime backend/squadadapter",
"backend/runtime backend/wfcompile",
"backend/runtime backend/workflow",
"backend/server backend/cardsvc",
"backend/server backend/cmdbus",
"backend/server backend/ingest",
"backend/server backend/intentgate",
"backend/server backend/plugins",
"backend/server backend/profiles",
"backend/server backend/prompts",
"backend/server backend/ratelimit",
"backend/server backend/route",
"backend/server backend/runmgr",
"backend/server backend/settings",
"backend/server backend/store",
"backend/server backend/surfacesvc",
"backend/server backend/tools",
"backend/server backend/workflow",
"backend/settings backend/store",
"backend/squadadapter backend/quality",
"backend/store backend/blobstore",
"backend/surfacesvc backend/store",
"backend/tools backend/quality",
"backend/wfcompile backend/tools",
"backend/wfcompile backend/workflow",
"frontend/app frontend/components",
"frontend/components frontend/app"
]
+21 -5
View File
@@ -1476,6 +1476,19 @@ db_auth_enabled() {
esac
}
# agent_listen_rejected EXEC_ARGS DEFAULT_ADDR INSECURE:启动预检整体判定(0=放行,
# 1=拒绝)。与二进制 listen guard 同口径:回环或显式放行(insecure)不拦;认证开
# (db_auth_enabled,依赖解析出的 DB_AUTH/DB_DSN)不拦;仅非回环 + 未放行 + 认证关
# 才拦。抽出纯判定便于治理测试覆盖(tests/test_svc_precheck.py)。
agent_listen_rejected() {
local addr; addr="$(printf '%s' "$1" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
addr="${addr:-$2}"
addr_is_loopback "${addr%:*}" && return 1
[ -n "$3" ] && return 1
db_auth_enabled && return 1
return 0
}
# bin_env_dsn_supported BIN:二进制是否消费环境层 AGENT_SYSTEM_DSN(经 config explain
# 探测:explain 对 DSN 值脱敏但用户名保留,探测标记用户即可)。旧产物不支持环境层时
# 返回 1——调用方拒绝以环境传递 DSN(防静默按内嵌 SQLite 运行)。
@@ -1559,10 +1572,10 @@ agent_do_start() {
# 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。
# 认证判定依据统一解析的最终有效 DSN 与 auth(含环境变量与配置文件来源),
# 与二进制 AuthEnabled 口径一致(2026-10-11 修复:此前只看命令行 --dsn,
# DSN 迁入环境变量后 auto 被误判为未开认证)。
local pf_addr; pf_addr="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
pf_addr="${pf_addr:-$ENV_ADDR}"
if ! addr_is_loopback "${pf_addr%:*}" && [ -z "$insecure" ] && ! db_auth_enabled; then
# DSN 迁入环境变量后 auto 被误判为未开认证;PR #317 同因,本方案为其超集)。
if agent_listen_rejected "$exec_args" "$ENV_ADDR" "$insecure"; then
local pf_addr; pf_addr="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
pf_addr="${pf_addr:-$ENV_ADDR}"
c_err "[$ENV_NAME] 拒绝启动:非回环地址 $pf_addr 且认证判定为关(auto 无 DSN 即关)。"
c_err " 任选其一:--addr 127.0.0.1:${pf_addr##*:};数据库绑定 scripts/svc.sh $ENV_NAME db <pg实例>;"
c_err " 显式 --auth on;或环境 SVC_$(printf '%s' "$ENV_NAME" | tr 'a-z-' 'A-Z_')_INSECURE_LISTEN=1 显式放行。"
@@ -3395,4 +3408,7 @@ main() {
esac
}
main "$@"
# 仅作为脚本直接执行时进入主流程;被 source(治理单测 fixtures)时只定义函数。
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi
+233
View File
@@ -0,0 +1,233 @@
"""svc.sh 启动预检与数据库配置统一解析的治理回归。
背景(2026-10-10/10-11 dev 事故,PR #317 最小修复 → feature/svc-db-config-recover
方案超集):G1-7 把 DSN 从命令行迁到 AGENT_SYSTEM_DSN 环境变量后——
1. 预检只看参数串,「非回环 + auth auto + env DSN」被误判认证关而拒启;二进制
AuthEnabled(config.go:显式 on,或非显式 off 且 DSN 非空——env 为合法 DSN
来源)会放行。现由 db_auth_enabled(依赖 resolve_db_config 产出的 DB_AUTH/
DB_DSN)统一判定,覆盖 on/off/auto 与命令行/环境/配置文件来源。
2. 复用启动路径(无显式参数、复用 exec=)不再有 DSN:dbref 兜底只在重建分支,
纯 start/restart 会静默回落 SQLite。现 start/restart/deploy/update/db 共用
resolve_db_config,且存量 exec= 的 --dsn 迁移为 dbref=/dsn= 专键(幂等)。
3. DSN 跨服务串用(同进程连续启动):现 env_config 按实例重置解析态、DSN 经
局部 guard_env 交付,无跨服务全局通道。
行为变化(经用户 2026-10-11 方案批准,取代 PR #317 的对应语义):dbref 无法重建
DSN 时从「告警后按无绑定启动」改为**拒绝启动**;本测试按新语义断言。
测试通过 source scripts/svc.sh 调用纯函数/fixture 函数(文件尾有 source 守卫),
不启动真实服务、不触碰真实 runtime/。端到端生命周期回归见 tests/test_svc.py。
"""
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
SVC = REPO / "scripts" / "svc.sh"
PG_ADDR = "10.1.2.3:8899"
PG_USER = "svc_test"
PG_PASS = "fixture-only-pass"
PG_DSN = f"postgresql://{PG_USER}:{PG_PASS}@{PG_ADDR}/agentdb"
NONLOOP_EXEC = "--addr 0.0.0.0:8788 --data /tmp/x --auth auto"
def _bash(script: str, *args: str) -> str:
"""source svc.sh 后在 bash 内执行脚本,返回 stdout(stderr 丢弃)。"""
proc = subprocess.run(
["bash", "-c", script, "svc-test", str(SVC), *args],
capture_output=True,
text=True,
cwd=str(REPO),
env={**os.environ, "LC_ALL": "C"},
timeout=30,
)
if proc.returncode != 0:
raise AssertionError(
f"bash 脚本失败 rc={proc.returncode}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
)
return proc.stdout
def _authed(auth_mode: str, dsn: str = "", exec_args: str = "") -> bool:
"""db_auth_enabled 判定:auth 值 + 有效 DSN(模拟 resolve_db_config 产出)。"""
out = _bash(
'source "$1"; shift; DB_AUTH="$1"; DB_DSN="$2"; '
'if db_auth_enabled; then echo authed; else echo denied; fi',
auth_mode or "auto",
dsn,
)
return out.strip() == "authed"
def _rejected(exec_args: str, default_addr: str = "0.0.0.0:8788",
insecure: str = "", auth_mode: str = "auto", dsn: str = "") -> bool:
"""agent_listen_rejected 判定(认证部分经 DB_AUTH/DB_DSN 全局)。"""
out = _bash(
"source \"$1\"; shift; DB_AUTH=\"$4\"; DB_DSN=\"$5\"; "
"if agent_listen_rejected \"$1\" \"$2\" \"$3\"; then echo rejected; else echo allowed; fi",
exec_args,
default_addr,
insecure,
auth_mode or "auto",
dsn,
)
return out.strip() == "rejected"
class DbAuthEnabledMatrix(unittest.TestCase):
"""认证判定与二进制 AuthEnabled 口径一致(config.go,默认 auth=auto)。"""
def test_auth_on_explicit(self):
self.assertTrue(_authed("on"))
def test_auth_off_beats_dsn(self):
# 显式 off 优先:与二进制 AuthMode=="off" → AuthEnabled=false 一致。
self.assertFalse(_authed("off", PG_DSN))
def test_auto_with_effective_dsn_is_authed(self):
# 2026-10-10 误拒主场景:dbref 重建/环境交付后 DSN 只在 AGENT_SYSTEM_DSN。
self.assertTrue(_authed("auto", PG_DSN))
def test_auto_without_dsn_is_denied(self):
self.assertFalse(_authed("auto"))
self.assertFalse(_authed("", ""))
def test_no_auth_no_dsn_denied(self):
self.assertFalse(_authed("auto", "", "--mock"))
class AgentListenRejectedPrecheck(unittest.TestCase):
"""预检整体判定:非回环 + 未放行 + 认证关才拒绝。"""
def test_incident_combo_not_rejected(self):
# 原事故组合:非回环 + auto + DSN 仅在环境变量 → 必须放行(曾误拒)。
self.assertFalse(_rejected(NONLOOP_EXEC, dsn=PG_DSN))
def test_nonloop_no_dsn_rejected(self):
self.assertTrue(_rejected(NONLOOP_EXEC))
def test_nonloop_auth_off_with_dsn_rejected(self):
self.assertTrue(_rejected("--addr 0.0.0.0:8788 --auth off",
dsn=PG_DSN, auth_mode="off"))
def test_loopback_not_rejected(self):
self.assertFalse(_rejected("--addr 127.0.0.1:8788"))
def test_insecure_not_rejected(self):
self.assertFalse(_rejected(NONLOOP_EXEC, insecure="1"))
def test_addr_defaults_to_default_addr(self):
# exec 参数里没有 --addr 时按 DEFAULT_ADDR 判。
self.assertTrue(_rejected("--data /tmp/x", default_addr="0.0.0.0:8788"))
self.assertFalse(_rejected("--data /tmp/x", default_addr="127.0.0.1:8788"))
class ResolveDbConfigFixtures(unittest.TestCase):
"""统一解析:复用/迁移语义(防静默回落 SQLite;坏绑定拒绝)。"""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
rt = Path(self._tmp.name) / "runtime"
(rt / "pg1").mkdir(parents=True)
(rt / "pg1" / "svc.env").write_text(
f"kind=postgres\naddr={PG_ADDR}\nuser={PG_USER}\npassword={PG_PASS}\n"
)
(rt / "dev").mkdir()
self.rt = rt
self.dev_dir = rt / "dev"
def tearDown(self):
self._tmp.cleanup()
def _resolve(self, exec_line: str, dbref: str = "", extra: str = "",
rewrite: bool = True) -> tuple:
"""写 fixture svc.env 后 resolve_db_config;回 (rc, DB_MODE/DSN/REF/SAVE)。
rewrite=False 时不覆写登记(幂等用例:在同一登记上二次解析)。"""
if rewrite:
content = f"kind=agent\nenv=dev\nexec={exec_line}\n"
if dbref:
content += f"dbref={dbref}\n"
if extra:
content += extra + "\n"
(self.dev_dir / "svc.env").write_text(content)
out = _bash(
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=dev; ENV_DIR="$2"; '
'if resolve_db_config; then rc=ok; else rc=fail; fi; '
'printf "RC=%s\\nMODE=%s\\nDSN=%s\\nREF=%s\\nSAVE=%s\\n" '
'"$rc" "$DB_MODE" "$DB_DSN" "$DB_REF" "$DB_DSN_SAVE"',
str(self.rt),
str(self.dev_dir),
)
return dict(part.split("=", 1) for part in out.strip().splitlines())
def test_post_g17_exec_restores_dsn_from_dbref(self):
# G1-7 后 exec= 不含 DSN:解析必须从 dbref= 重建(否则回落 SQLite)。
got = self._resolve("--addr 0.0.0.0:8788 --auth auto", dbref="pg1/agentdb")
self.assertEqual(got["RC"], "ok")
self.assertEqual(got["MODE"], "pg")
self.assertEqual(got["DSN"], PG_DSN)
self.assertEqual(got["REF"], "pg1/agentdb")
self.assertEqual(got["SAVE"], "")
def test_legacy_exec_dsn_migrates_to_recoverable_keys(self):
# 存量 exec= 的明文 --dsn:迁移为可恢复配置(命中实例 → dbref=),
# exec= 不再携带 DSN。
legacy = f"--addr 0.0.0.0:8788 --dsn {PG_DSN}"
got = self._resolve(legacy)
self.assertEqual(got["RC"], "ok")
self.assertEqual(got["DSN"], PG_DSN)
self.assertEqual(got["REF"], "pg1/agentdb")
envfile = (self.dev_dir / "svc.env").read_text()
self.assertNotIn("--dsn", envfile)
self.assertIn("dbref=pg1/agentdb", envfile)
def test_legacy_exec_dsn_without_instance_goes_to_dsn_key(self):
# 未命中本机实例:迁移到受保护 dsn= 字段。
external = "postgresql://u:pw@10.9.9.9:5432/otherdb"
got = self._resolve(f"--addr 127.0.0.1:8788 --dsn {external}")
self.assertEqual(got["RC"], "ok")
self.assertEqual(got["DSN"], external)
self.assertEqual(got["SAVE"], external)
self.assertIn(f"dsn={external}", (self.dev_dir / "svc.env").read_text())
def test_migration_is_idempotent(self):
# 第二次解析:exec 已无 --dsn,登记保持稳定。
self._resolve(f"--addr 0.0.0.0:8788 --dsn {PG_DSN}")
first = (self.dev_dir / "svc.env").read_text()
got = self._resolve("", rewrite=False)
self.assertEqual(got["RC"], "ok")
self.assertEqual(got["DSN"], PG_DSN)
self.assertEqual((self.dev_dir / "svc.env").read_text(), first)
def test_no_dsn_no_dbref_is_sqlite(self):
got = self._resolve("--addr 127.0.0.1:8788")
self.assertEqual(got["RC"], "ok")
self.assertEqual(got["MODE"], "sqlite")
self.assertEqual(got["DSN"], "")
def test_broken_dbref_refuses(self):
# 行为变化(方案批准):实例登记不全 → 拒绝启动,不静默回落 SQLite。
got = self._resolve("--addr 127.0.0.1:8788", dbref="missing/agentdb")
self.assertEqual(got["RC"], "fail")
self.assertEqual(got["DSN"], "")
class ResolverStateIsolation(unittest.TestCase):
"""解析态按实例隔离(env_config 重置,防 update --all 串库)。"""
def test_env_config_resets_resolver_state(self):
# 棘轮:env_config 必须重置 SVC_DB_*(删除即回归「同进程连续启动跨服务
# 泄漏数据库配置」——DSN_ENV 全局时代的事故形态)。
out = _bash(
'source "$1"; declare -f env_config | grep -c \'SVC_DB_MODE=""\''
)
self.assertEqual(out.strip(), "1")
if __name__ == "__main__":
unittest.main()