Files
Agent-System-Dev/tests/test_svc_precheck.py
T
weichen 7acf3186ca 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).
2026-10-11 02:10:21 +00:00

217 lines
8.6 KiB
Python
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.
"""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()