From 316455fefac9a248390acd5345ce054b7bd48203 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 21 Aug 2026 11:13:06 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20red-line=20guard=20=E2=80=94=20tests=20?= =?UTF-8?q?never=20run=20the=20real=20auto-upgrade=20chain=20(rant=202026-?= =?UTF-8?q?08-21T10:35:57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A long-running pytest session (PID 72994, 21h) really executed the auto-upgrade chain in the background: real GitHub releases requests, real ~/.emrg/install/version.txt reads, and real emrg-upgrade session writes every 5 minutes — continuing across daemon restarts and even after 'emrg stop'. serve() unconditionally starts the 5-minute _upgrade_tick_loop with a default-enabled UpgradeManager. Fix (mirrors the stop_daemon red-line pattern): - tests/conftest.py: autouse _guard_upgrade_hermeticity fixture blocks every side-effect endpoint — upgrade-module httpx.AsyncClient raises (module-local, global httpx untouched), VERSION_FILE redirected to a per-test tmp path, EmrgServer._get_or_create_session for SESSION_ID 'emrg-upgrade' raises. Tests that legitimately exercise the chain stub these after the fixture as usual. - tests/test_ws_e2e.py: _boot_server forces the upgrade manager disabled (patches emrg.config.load_update_config) so booted test servers' tick loop is a no-op; restored in cleanup. - tests/test_upgrade.py: _run_upgrade_session runner test isolates the session factory; new test proves the guards are active by default. - MANIFESTO.md: 第四条附则三 codifies the permanent red line (same status as the stop_daemon red line, host 2026-08-21 10:35). - Agent.md: documented test count 985 -> 986. --- Agent.md | 2 +- MANIFESTO.md | 8 ++++++ tests/conftest.py | 65 +++++++++++++++++++++++++++++++++++++++++++ tests/test_upgrade.py | 22 +++++++++++++++ tests/test_ws_e2e.py | 14 ++++++++++ 5 files changed, 110 insertions(+), 1 deletion(-) diff --git a/Agent.md b/Agent.md index ca621ba8..701081df 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (985) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (986) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (254: 45 daemon_client + 19 conn-manager + 22 app-commands + 126 renderer smoke + 15 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/MANIFESTO.md b/MANIFESTO.md index 9d8fde74..75f56cd5 100644 --- a/MANIFESTO.md +++ b/MANIFESTO.md @@ -67,6 +67,14 @@ EMRG 的架构明确分为**服务端(Server)**与**客户端(Client)** 此为本系统最高原则之一,**永久有效,不适用任何演化机制**,不可被任何程序修改、删除或弱化。执行演化周期时若发现已有测试违反本条,须立即移除并记录。 +### 第四条附则三 【测试红线·自动升级链不可触发】(最高原则,宿主 2026-08-21 10:35 确立) + +**任何时候、任何实例,禁止编写、恢复或以任何形式引入「执行自动升级触发链」的测试用例、脚本或代码路径**——包括真实请求 GitHub releases API、真实读写 `~/.emrg/install/version.txt`、真实创建/写入 `emrg-upgrade` 会话、直接调用 `UpgradeManager.tick()` / daemon `_run_upgrade_session` 而未经完全隔离的测试。实证:长时间运行的 pytest 曾在后台真实执行自动升级逻辑(真实请求 releases API、真实读取 install/version.txt、真实向 `emrg-upgrade` 会话写入降级 prompt),每 5 分钟一次,跨 daemon 重启、甚至在 `emrg stop` 全部进程停止后依然持续写入。违反将导致升级会话污染、真实网络请求与宿主环境干扰、演化机制被后台进程蚕食。 + +测试必须完全隔离升级链:releases API(httpx AsyncClient)、VERSION_FILE、SESSION_ID(emrg-upgrade)、run_session_cb 全部 mock/打桩;`tests/conftest.py` 设 autouse 兜底守卫,任何触发真实升级链(真实 `UpgradeManager.tick()` / 写 `emrg-upgrade` 会话)的测试直接断言失败。 + +此为本系统最高原则之一,**永久有效,不适用任何演化机制**,不可被任何程序修改、删除或弱化。执行演化周期时若发现已有测试违反本条,须立即移除并记录。 + --- ## 第三章:分化与物种形成 diff --git a/tests/conftest.py b/tests/conftest.py index c92abb62..aaa520a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -92,3 +92,68 @@ def _no_real_stop_daemon(*args, **kwargs): ) monkeypatch.setattr(stop_mod, "stop_daemon", _no_real_stop_daemon) + + +@pytest.fixture(autouse=True) +def _guard_upgrade_hermeticity(monkeypatch, tmp_path): + """⛔ Red line (host 2026-08-21T10:35:57): tests must NEVER trigger the + real auto-upgrade chain — real GitHub releases request, real + ~/.emrg/install/version.txt read/write, real emrg-upgrade session write. + + Empirical evidence: a long-running pytest session (PID 72994, 21h) really + executed the upgrade tick every 5 minutes — real releases API requests, + real install/version.txt reads, real emrg-upgrade session writes with the + downgrade prompt (delay=1440, target=v0.2.57) — continuing across daemon + restarts and even after `emrg stop` stopped all real processes (writes at + 10:23:12 / 10:28:15 / 10:33:17 after the 10:22:56 stop). + + This autouse fixture blocks every side-effect endpoint of the chain: + 1. httpx.AsyncClient in emrg.server.upgrade → AssertionError on + instantiation (module-local: only the upgrade module's reference is + replaced, the global httpx module is untouched). Tests that + legitimately exercise tick() stub it per-test (e.g. test_upgrade.py's + fake client) by patching after this fixture. + 2. upgrade.VERSION_FILE → per-test tmp path (the real + ~/.emrg/install/version.txt must never be read or written). + 3. EmrgServer._get_or_create_session for SESSION_ID ("emrg-upgrade") + → AssertionError (no real emrg-upgrade session may be created or + written). Tests that exercise the session runner isolate the factory + (monkeypatch.setattr(server, "_get_or_create_session", fake)) after + this fixture, overriding it as usual. + """ + import emrg.server.daemon as daemon_mod + import emrg.server.upgrade as up_mod + + # 1. Network — any real GitHub releases request is a loud failure. + class _BlockedHttpx: + class AsyncClient: + def __init__(self, *args, **kwargs): + raise AssertionError( + "test triggered a REAL GitHub releases request through the " + "auto-upgrade chain — ⛔ red-line violation (host " + "2026-08-21T10:35:57); stub emrg.server.upgrade.httpx." + "AsyncClient in your test" + ) + + monkeypatch.setattr(up_mod, "httpx", _BlockedHttpx) + + # 2. Version file — never the real ~/.emrg/install/version.txt. + monkeypatch.setattr(up_mod, "VERSION_FILE", tmp_path / "upgrade-version.txt") + + # 3. Upgrade session — creating/writing the real emrg-upgrade session is a + # loud failure; tests that exercise the runner stub the factory after. + _orig_get_or_create = daemon_mod.EmrgServer._get_or_create_session + + def _guarded_get_or_create(self, session_id, cwd): + if session_id == up_mod.SESSION_ID: + raise AssertionError( + "test attempted to create the REAL emrg-upgrade session — ⛔ " + "red-line violation (host 2026-08-21T10:35:57); isolate the " + "session factory (monkeypatch.setattr(server, " + "'_get_or_create_session', lambda sid, cwd: ))" + ) + return _orig_get_or_create(self, session_id, cwd) + + monkeypatch.setattr( + daemon_mod.EmrgServer, "_get_or_create_session", _guarded_get_or_create + ) diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index ed729694..2a6dfc80 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -274,6 +274,10 @@ def test_daemon_upgrade_session_runner(monkeypatch, tmp_path): server = _make_server() monkeypatch.setattr(server, "_max_tool_rounds", 3) + # ⛔ Red line (host 2026-08-21T10:35:57): tests must never create/write the + # real emrg-upgrade session — isolate the session factory (the conftest + # autouse guard raises on the real one for SESSION_ID). + monkeypatch.setattr(server, "_get_or_create_session", lambda sid, cwd: object()) ran = [] async def fake_loop(req, ws, session, cancel_event, allow_tools=True): @@ -293,6 +297,24 @@ async def fake_loop(req, ws, session, cancel_event, allow_tools=True): assert server._session_busy.get("emrg-upgrade") is False, "busy lock released" +def test_upgrade_chain_hermeticity_guards(): + """⛔ Red line (host 2026-08-21T10:35:57): the conftest autouse guard must + block the real auto-upgrade chain by default — no real GitHub releases + request, no real install/version.txt access. A long-running pytest session + really executed the upgrade chain every 5 minutes (PID 72994, 21h). + """ + from pathlib import Path + + import emrg.server.upgrade as up + + # 1. Network: the upgrade module's httpx.AsyncClient raises by default. + with pytest.raises(AssertionError, match="red-line"): + up.httpx.AsyncClient() + + # 2. Version file: not the real ~/.emrg/install/version.txt. + assert up.VERSION_FILE != Path.home() / ".emrg" / "install" / "version.txt" + + # ── no residual references to the removed mechanism ─────────────────────── diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index ea13321b..5e06e30b 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -100,6 +100,19 @@ async def _boot_server(tmp: Path): sched_mod.config_dir = lambda: tmp # scheduler builds its own projects_file (#738) connect_mod.config_dir = lambda: tmp + # ⛔ Red line (host 2026-08-21T10:35:57): tests must never run the real + # auto-upgrade chain. serve() unconditionally starts the 5-minute + # _upgrade_tick_loop, which builds UpgradeManager(load_update_config(), …) + # with enabled=True by default — over a long session the tick really + # requested the GitHub releases API and wrote real emrg-upgrade sessions + # (21h pytest incident, PID 72994). Force the manager disabled so the tick + # is a no-op; the conftest autouse guard additionally blocks the network. + import emrg.config as cfg_mod + from emrg.config import UpdateConfig as _UpdateConfig + + _orig_load_update_config = cfg_mod.load_update_config + cfg_mod.load_update_config = lambda: _UpdateConfig(enabled=False) + server = daemon_mod.EmrgServer(_make_config()) server.llm = AsyncMock() server.llm.config = _make_config() # real config so _run_tool_loop reads thresholds @@ -130,6 +143,7 @@ async def _cleanup(): connect_mod.config_dir = _orig_connect_cfg daemon_mod.EMRGD_PORT = _orig_daemon_port connect_mod.EMRGD_PORT = _orig_connect_port + cfg_mod.load_update_config = _orig_load_update_config return server, task, _cleanup