Files
codex-app-server/cloud/tests/test_shared_home_mcp.py
host b977a9dee8 feat: sync shared-home MCP scripts on control-plane startup
租户 config 的 mcp_servers.* 指向共享 home 路径,但宿主共享
home 里原先没有 MCP 脚本,宿主 app-server 启动 MCP 即文件
不存在、握手断掉,面板 mcp-servers-list 三服务器 toolsError
全是 handshake failed;14:43 的手工拷入能修一次,但下次
MCP 脚本改动后面板又会跑旧版。

本批只在服务端 main() 启动时同步一次:清单唯一定义在
cloud/shared_home_mcp.py(7 文件:playwright_mcp/browser/
remote、websearch_mcp/websearch、imagegen_mcp/imagegen,含
同目录兄弟模块),解析优先用 host-config-host.json 的
codex_home,兜底用 tenants_root 父目录/codex-home;缺失
拷入、不同刷新(原子替换)、相同不动;源缺失/目标不可写
只记 warning,绝不阻塞启动。create_server() 不做同步,
避免单测写向生产共享路径。

被否决的落点:worker _tenant_dirs(只在下个任务派发时跑,
面板要等下一个任务才更新,且不修共享路径本身);deploy
host_deploy.sh(会在 shell 里复制第二份清单,且重启不
部署时不刷新);面板请求时同步(把读请求变成写请求,
失败会把面板读数变成 502 风险)。
2026-10-30 07:08:51 +00:00

214 lines
8.5 KiB
Python

"""Tests for shared-home MCP script sync (cloud/shared_home_mcp.py).
No production path is ever touched here: every sync target is a
temp dir, and the server-resolution tests monkeypatch the resolver
inputs instead of reading the repo's host-config-host.json (which
points at the production shared home).
Coverage:
- the 7-file list is the single source of truth (exact set);
- missing files are copied;
- differing files are refreshed, same files are untouched
(mtime unchanged);
- missing source files do not crash and are reported;
- an uncreatable target and a per-file unwritable destination
(a directory in place of a file, root-proof) do not crash and
do not block the other files;
- server resolution: local worker home wins, tenants-root
parent is the fallback;
- server main() actually calls the sync at startup and survives
a sync error (fake server, no real port, no real DB).
"""
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from shared_home_mcp import SHARED_HOME_MCP_FILES # noqa: E402
from shared_home_mcp import sync_shared_home_mcp # noqa: E402
try:
from cloud.server import _resolve_shared_home_mcp
from cloud.server import main as _server_main
_SERVER_OK = True
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
try:
from cloud.server import _resolve_shared_home_mcp
from cloud.server import main as _server_main
_SERVER_OK = True
except ImportError:
_resolve_shared_home_mcp = None
_server_main = None
_SERVER_OK = False
def _write_source(source_dir, contents):
for name, data in contents.items():
with open(os.path.join(source_dir, name), "wb") as fh:
fh.write(data)
class SharedHomeMcpSyncTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.source = os.path.join(self._tmp.name, "cloud")
self.target = os.path.join(self._tmp.name, "shared-home")
os.makedirs(self.source)
self.contents = {name: ("# %s v2\n" % name).encode()
for name in SHARED_HOME_MCP_FILES}
def tearDown(self):
self._tmp.cleanup()
def test_file_list_is_exactly_the_seven(self):
self.assertEqual(len(SHARED_HOME_MCP_FILES), 7)
self.assertEqual(len(set(SHARED_HOME_MCP_FILES)), 7)
self.assertEqual(set(SHARED_HOME_MCP_FILES), {
"playwright_mcp.py", "playwright_browser.py",
"playwright_remote.py",
"websearch_mcp.py", "websearch.py",
"imagegen_mcp.py", "imagegen.py",
})
def test_missing_files_are_copied(self):
_write_source(self.source, self.contents)
result = sync_shared_home_mcp(self.target, self.source)
self.assertEqual(sorted(result["copied"]),
sorted(SHARED_HOME_MCP_FILES))
self.assertEqual(result["refreshed"], [])
self.assertEqual(result["unchanged"], [])
self.assertEqual(result["missing"], [])
self.assertIsNone(result["error"])
for name, data in self.contents.items():
with open(os.path.join(self.target, name), "rb") as fh:
self.assertEqual(fh.read(), data)
def test_differing_files_are_refreshed(self):
_write_source(self.source, self.contents)
os.makedirs(self.target)
for name in SHARED_HOME_MCP_FILES:
with open(os.path.join(self.target, name), "wb") as fh:
fh.write(b"# old\n")
result = sync_shared_home_mcp(self.target, self.source)
self.assertEqual(sorted(result["refreshed"]),
sorted(SHARED_HOME_MCP_FILES))
self.assertEqual(result["copied"], [])
self.assertIsNone(result["error"])
def test_same_files_are_untouched(self):
_write_source(self.source, self.contents)
os.makedirs(self.target)
mtimes = {}
for name, data in self.contents.items():
path = os.path.join(self.target, name)
with open(path, "wb") as fh:
fh.write(data)
os.utime(path, (1000000000, 1000000000))
mtimes[name] = os.stat(path).st_mtime
result = sync_shared_home_mcp(self.target, self.source)
self.assertEqual(sorted(result["unchanged"]),
sorted(SHARED_HOME_MCP_FILES))
self.assertEqual(result["copied"], [])
self.assertEqual(result["refreshed"], [])
for name, mtime in mtimes.items():
self.assertEqual(os.stat(os.path.join(self.target, name)).st_mtime,
mtime)
def test_missing_source_does_not_crash(self):
# Source dir exists but is empty: every file is missing.
result = sync_shared_home_mcp(self.target, self.source)
self.assertEqual(sorted(result["missing"]),
sorted(SHARED_HOME_MCP_FILES))
self.assertEqual(result["copied"], [])
self.assertIsNotNone(result["error"])
self.assertIn("missing source", result["error"])
def test_uncreatable_target_does_not_crash(self):
_write_source(self.source, self.contents)
# Target path is an existing file, not a directory:
# makedirs fails even as root, and must not raise.
blocker = os.path.join(self._tmp.name, "blocker")
with open(blocker, "wb") as fh:
fh.write(b"x")
result = sync_shared_home_mcp(blocker, self.source)
self.assertEqual(result["copied"], [])
self.assertIsNotNone(result["error"])
def test_one_unwritable_file_does_not_block_others(self):
_write_source(self.source, self.contents)
os.makedirs(self.target)
bad = SHARED_HOME_MCP_FILES[0]
os.makedirs(os.path.join(self.target, bad))
result = sync_shared_home_mcp(self.target, self.source)
self.assertEqual(len(result["copied"]),
len(SHARED_HOME_MCP_FILES) - 1)
self.assertIn(bad, result["error"])
for name in SHARED_HOME_MCP_FILES[1:]:
self.assertTrue(os.path.isfile(os.path.join(self.target, name)))
def test_no_shared_home_does_not_crash(self):
result = sync_shared_home_mcp(None, self.source)
self.assertIsNotNone(result["error"])
self.assertEqual(result["copied"], [])
@unittest.skipUnless(_SERVER_OK, "cloud.server import unavailable")
class SharedHomeMcpServerTest(unittest.TestCase):
def test_local_worker_home_wins(self):
import cloud.server as server_mod
with mock.patch.object(server_mod, "_local_worker_codex_home",
return_value="/tmp/fake-shared"):
self.assertEqual(_resolve_shared_home_mcp(object()),
"/tmp/fake-shared")
def test_tenants_root_parent_is_fallback(self):
import cloud.server as server_mod
class _FakeServer:
tenants_root = "/tmp/fake-root/tenants"
with mock.patch.object(server_mod, "_local_worker_codex_home",
return_value=None):
self.assertEqual(_resolve_shared_home_mcp(_FakeServer()),
"/tmp/fake-root/codex-home")
def test_main_calls_sync_and_survives_sync_error(self):
import cloud.server as server_mod
class _FakeServer:
rate_limiter = None
server_address = ("127.0.0.1", 0)
def serve_forever(self):
raise KeyboardInterrupt
calls = {}
fake = _FakeServer()
def _fake_sync(target):
calls["target"] = target
return {"copied": [], "refreshed": [], "unchanged": [],
"missing": [], "error": "boom (test)"}
with mock.patch.object(server_mod, "create_server",
return_value=fake), \
mock.patch.object(server_mod, "_resolve_shared_home_mcp",
return_value="/tmp/fake-shared"), \
mock.patch.object(server_mod, "_sync_shared_home_mcp",
side_effect=_fake_sync), \
mock.patch.object(server_mod, "start_rate_limit_sweeper"):
# No exception: sync error is warning-only, and the
# KeyboardInterrupt from serve_forever ends main().
_server_main(["--db", "/tmp/fake.db", "--port", "0"])
self.assertEqual(calls.get("target"), "/tmp/fake-shared")
if __name__ == "__main__":
unittest.main()