Align the svc.sh startup precheck with the binary auth semantics and keep the reused DSN

Why: G1-7 moved the DSN into AGENT_SYSTEM_DSN, but the precheck only read
the arg string, so a non-loopback restart with auth auto was wrongly
rejected (2026-10-10 dev incident); the reuse path no longer restored the
dbref-bound DSN and would silently fall back to SQLite; and DSN_ENV leaked
across same-process starts (update --all) into other services' env.
Evidence: tests/test_svc_precheck.py 19 cases green (auth matrix vs
config.go AuthEnabled, reuse migration/dbref restore, scope isolation);
full governance suite 123 green; incident-scenario replay allowed + DSN
carried. Recorded via make evidence.
Impact: scripts/svc.sh precheck and reuse-start DSN handling only; no
binary change, no persistence-format change.
Refs: .task/fix-svc-dsn-reuse-dbref.md; config.go AuthEnabled (on || (not
off && DSN non-empty), env DSN counts).
This commit is contained in:
2026-10-11 02:10:21 +00:00
parent 675b740ce4
commit 7acf3186ca
2 changed files with 279 additions and 17 deletions
+216
View File
@@ -0,0 +1,216 @@
"""svc.sh 启动预检与复用 DSN 解析的治理回归。
背景(2026-10-10 dev 实测,记忆 svc-g17-precheck-dsn-bug):G1-7 把 DSN 从
命令行迁到 AGENT_SYSTEM_DSN 环境变量后——
1. 预检 agent_args_authed 只看参数串,「非回环 + auth auto + env DSN」被误判
认证关而拒启;二进制 AuthEnabled(modules/backend/internal/config/config.go:
显式 on,或非显式 off 且 DSN 非空——env 为合法 DSN 来源)会放行。
2. 复用启动路径(无显式参数、复用 exec=)不再有 DSN:dbref 兜底只在重建分支,
纯 start/restart 会静默回落 SQLite。
3. DSN_ENV 为全局且每次启动不重置:update --all 同进程连续启动会跨服务泄漏 DSN。
测试通过 source scripts/svc.sh 调用纯函数/fixture 函数(文件尾有 source 守卫),
不启动真实服务、不触碰真实 runtime/。
"""
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(args: str, dsn_env: str = "") -> bool:
out = _bash(
'source "$1"; shift; if agent_args_authed "$1" "${2:-}"; then echo authed; else echo denied; fi',
args,
dsn_env,
)
return out.strip() == "authed"
def _rejected(exec_args: str, default_addr: str = "0.0.0.0:8788",
insecure: str = "", dsn_env: str = "") -> bool:
out = _bash(
"source \"$1\"; shift; if agent_listen_rejected \"$1\" \"$2\" \"$3\" \"$4\"; "
"then echo rejected; else echo allowed; fi",
exec_args,
default_addr,
insecure,
dsn_env,
)
return out.strip() == "rejected"
class AgentArgsAuthedMatrix(unittest.TestCase):
"""认证判定与二进制 AuthEnabled 口径一致(config.go:301,默认 auth=auto)。"""
def test_auth_on_explicit(self):
self.assertTrue(_authed("--addr 1.2.3.4:80 --auth on"))
self.assertTrue(_authed("--addr 1.2.3.4:80 --auth=on"))
def test_auth_off_beats_env_dsn(self):
# 显式 off 优先:与二进制 AuthMode=="off" → AuthEnabled=false 一致。
self.assertFalse(_authed("--auth off", PG_DSN))
self.assertFalse(_authed("--auth=off", PG_DSN))
def test_auto_with_env_dsn_is_authed(self):
# 2026-10-10 误拒主场景:dbref 重建/G1-7 迁移后 DSN 只在环境变量里。
self.assertTrue(_authed("--auth auto", PG_DSN))
def test_default_auto_with_env_dsn_is_authed(self):
# exec= 里连 --auth 都没有(config 默认 auto):env DSN 即认证开。
self.assertTrue(_authed(NONLOOP_EXEC, PG_DSN))
def test_auto_without_dsn_is_denied(self):
self.assertFalse(_authed("--auth auto"))
self.assertFalse(_authed(NONLOOP_EXEC))
def test_legacy_cli_dsn_still_authed(self):
# 向后兼容:历史 exec= 的命令行 --dsn 仍是合法 DSN 来源。
self.assertTrue(_authed(f"--addr 1.2.3.4:80 --dsn {PG_DSN}"))
def test_no_auth_no_dsn_denied(self):
self.assertFalse(_authed("--addr 127.0.0.1:80 --mock"))
class AgentListenRejectedPrecheck(unittest.TestCase):
"""预检整体判定:非回环 + 未放行 + 认证关才拒绝。"""
def test_incident_combo_not_rejected(self):
# 原事故组合:非回环 + auto + DSN 仅在环境变量 → 必须放行(曾误拒)。
self.assertFalse(_rejected(NONLOOP_EXEC, dsn_env=PG_DSN))
def test_nonloop_no_dsn_rejected(self):
self.assertTrue(_rejected(NONLOOP_EXEC))
def test_nonloop_auth_off_with_env_dsn_rejected(self):
self.assertTrue(_rejected("--addr 0.0.0.0:8788 --auth off", dsn_env=PG_DSN))
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 AgentReuseExec(unittest.TestCase):
"""复用启动的 DSN 解析:G1-7 迁移 + dbref 重建(防静默回落 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 _reuse(self, exec_line: str) -> tuple:
# 直接调用(非 $()):agent_reuse_exec 经 nameref 回写 exec 参数、
# 全局 DSN_ENV 传 DSN——命令替换会把 DSN_ENV 赋值留在子壳。
out = _bash(
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=dev; ENV_DIR="$2"; DSN_ENV=""; '
'agent_reuse_exec "$3" exec_args; '
'printf "EXEC=%s\\n" "$exec_args"; printf "DSN=%s\\n" "${DSN_ENV:-}"',
str(self.rt),
str(self.dev_dir),
exec_line,
)
lines = dict(part.split("=", 1) for part in out.strip().splitlines())
return lines["EXEC"], lines["DSN"]
def _write_dev_env(self, exec_line: str, dbref: str = "") -> None:
content = f"kind=agent\nenv=dev\nexec={exec_line}\n"
if dbref:
content += f"dbref={dbref}\n"
(self.dev_dir / "svc.env").write_text(content)
def test_post_g17_exec_restores_dsn_from_dbref(self):
# G1-7 后 exec= 不含 DSN:复用启动必须从 dbref= 重建(否则回落 SQLite)。
self._write_dev_env("--addr 0.0.0.0:8788 --auth auto", dbref="pg1/agentdb")
exec_args, dsn = self._reuse("--addr 0.0.0.0:8788 --auth auto")
self.assertEqual(dsn, PG_DSN)
self.assertNotIn("--dsn", exec_args)
def test_legacy_exec_dsn_migrates_to_env(self):
# 存量 exec= 的明文 --dsn:剥到环境变量,命令行不再可见。
legacy = f"--addr 0.0.0.0:8788 --dsn {PG_DSN}"
self._write_dev_env(legacy, dbref="pg1/agentdb")
exec_args, dsn = self._reuse(legacy)
self.assertEqual(dsn, PG_DSN)
self.assertNotIn("--dsn", exec_args)
self.assertEqual(exec_args, "--addr 0.0.0.0:8788")
def test_migration_dsn_wins_over_dbref(self):
# 命令行 --dsn 与 dbref 并存:迁移值优先,dbref 不覆盖。
other = "postgresql://other:pw@10.9.9.9:5432/otherdb"
legacy = f"--addr 0.0.0.0:8788 --dsn {other}"
self._write_dev_env(legacy, dbref="pg1/agentdb")
_, dsn = self._reuse(legacy)
self.assertEqual(dsn, other)
def test_no_dsn_no_dbref_stays_empty(self):
self._write_dev_env("--addr 127.0.0.1:8788")
exec_args, dsn = self._reuse("--addr 127.0.0.1:8788")
self.assertEqual(dsn, "")
self.assertEqual(exec_args, "--addr 127.0.0.1:8788")
def test_broken_dbref_warns_and_starts_unbound(self):
# 实例登记不全:不伪造 DSN,保持为空(启动侧另有告警)。
self._write_dev_env("--addr 127.0.0.1:8788", dbref="missing/agentdb")
_, dsn = self._reuse("--addr 127.0.0.1:8788")
self.assertEqual(dsn, "")
class DsnEnvScopeIsolation(unittest.TestCase):
"""DSN_ENV 每次启动隔离(agent_do_start 内 local 化,防 update --all 串库)。"""
def test_agent_do_start_localizes_dsn_env(self):
# 棘轮:agent_do_start 必须以 local DSN_ENV="" 起始,删除即回归
# 「同进程连续启动跨服务泄漏 AGENT_SYSTEM_DSN」。
out = _bash(
'source "$1"; declare -f agent_do_start | grep -c \'local exec_args DSN_ENV=""\''
)
self.assertEqual(out.strip(), "1")
if __name__ == "__main__":
unittest.main()