Merge pull request #195 from openqj/feat-shared-home-mcp-sync

feat: sync shared-home MCP scripts on control-plane startup
This commit is contained in:
openqj authored and GitHub committed 2026-10-30 15:19:26 +08:00
commit 92157ef421
3 files changed
+401

No files matched your search

+49
View File
@@ -107,6 +107,7 @@ from cloud.wait_ops import poll_task_statuses as _wait_poll # noqa: F401
# /wait 本身走首变轮询(_wait_poll_first),见 _task_wait。 # /wait 本身走首变轮询(_wait_poll_first),见 _task_wait。
from cloud.wait_ops import report_handoff as _handoff_report from cloud.wait_ops import report_handoff as _handoff_report
from cloud.wait_ops import worktree_status as _wait_worktree_status from cloud.wait_ops import worktree_status as _wait_worktree_status
from cloud.shared_home_mcp import sync_shared_home_mcp as _sync_shared_home_mcp
from cloud.automations import ensure_automations_tables as _auto_ensure from cloud.automations import ensure_automations_tables as _auto_ensure
from cloud.automations import list_automations as _auto_list from cloud.automations import list_automations as _auto_list
from cloud.todos import create_todo as _todo_create from cloud.todos import create_todo as _todo_create
@@ -425,6 +426,30 @@ def _local_worker_codex_home():
return None return None
def _resolve_shared_home_mcp(server=None):
"""共享 home 解析:供服务端启动时的 MCP 脚本同步用。
优先级(生产路径绝不硬编码在这里,全部来自已有解析):
1. _local_worker_codex_home():读 host-config-host.json 的
codex_home,生产即共享 home;
2. 兜底:tenants_root 的父目录 + codex-home(tenants_root
本身来自 server 属性 / CODEX_TENANTS_ROOT / 默认值)。
任一解析不出时返回 None,调用方跳过同步、不因此报错。
注意:本函数只解析路径,不做文件同步;文件清单与同步
语义只在 cloud/shared_home_mcp.py 里。
"""
local = _local_worker_codex_home()
if local:
return local
try:
root = _tenants_root_for(server)
except Exception:
return None
if not root:
return None
return os.path.join(os.path.dirname(root), "codex-home")
def _tenant_work_dir(server, project_id): def _tenant_work_dir(server, project_id):
"""项目对应的租户工作目录:tenants_root/<安全项目名>/work, """项目对应的租户工作目录:tenants_root/<安全项目名>/work,
skills/list 的 cwds 过滤用它(不传 cwd 时 app-server 会 skills/list 的 cwds 过滤用它(不传 cwd 时 app-server 会
@@ -10309,6 +10334,30 @@ def main(argv=None):
args = parser.parse_args(argv) args = parser.parse_args(argv)
server = create_server(args.db, args.host, args.port, server = create_server(args.db, args.host, args.port,
backend=args.db_backend) backend=args.db_backend)
# 共享 home MCP 脚本同步:只在 main() 启动时做,不在
# create_server() 里做——create_server 是单测入口,单测若
# 在此同步会按 host-config-host.json 写向生产共享路径。
# systemd 两个控制面都跑 python3 -m cloud.server,走的
# 都是 main(),每次部署重启都会同步一次;同步失败只记
# warning,绝不阻塞启动(sync 本身也不抛异常,外层再
# 兜一层防解析等意外)。Docker 任务不受影响:容器内
# 该共享路径是租户 home 的挂载,宿主补齐不进容器。
try:
_shared_home = _resolve_shared_home_mcp(server)
if _shared_home:
_sync_result = _sync_shared_home_mcp(_shared_home)
if _sync_result.get("copied") or _sync_result.get("refreshed"):
_server_log.info(
"shared home MCP synced: copied=%s refreshed=%s",
_sync_result.get("copied"),
_sync_result.get("refreshed"))
if _sync_result.get("error"):
_server_log.warning(
"shared home MCP sync failed (non-fatal): %s",
_sync_result.get("error"))
except Exception as exc:
_server_log.warning(
"shared home MCP sync failed (non-fatal): %s", exc)
start_rate_limit_sweeper(server.rate_limiter, args.db) start_rate_limit_sweeper(server.rate_limiter, args.db)
print( print(
"cloud-control-plane listening on %s:%d db=%s" "cloud-control-plane listening on %s:%d db=%s"
+139
View File
@@ -0,0 +1,139 @@
"""共享 codex-home 的 MCP 脚本自动补齐。
背景(2026-10-30 实测):租户 config 的 ``mcp_servers.*`` 指向
共享路径 ``<codex-home>/playwright_mcp.py`` 等,但宿主共享 home
里原先一个 MCP 脚本都没有——宿主 app-server 按该路径启动 MCP
时文件不存在、握手断掉,面板 ``mcp-servers-list`` 三服务器的
``toolsError`` 全是 ``handshake failed``。Docker 任务不受影响,
是因为租户 home 被挂载到容器内那个共享路径,容器内能解析。
本模块是这 7 个文件清单的唯一定义处:服务端启动同步、
测试、以及将来任何其它调用方都只从这里取清单,绝不在
server/worker/deploy 三处各写一份 7 文件列表(清单漂移就是
下一次“面板又跑旧版”的根源)。
语义与 playwright 播种一致,而不是 websearch/imagegen 的租户
播种语义:共享 home 里的脚本是系统文件,不是租户数据,租户
不会去改它,所以
- 缺失则拷入;
- 内容不同则刷新(原子替换)并记入 ``refreshed``;
- 内容相同则不动,绝不重写 mtime。
其它文件(config.toml、技能、租户数据)一律不动。
失败语义:本函数绝不抛异常阻塞调用方。源缺失、目标目录建
不出、单个文件不可写时只记入返回值(调用方记 warning),其
余文件照常同步。调用方(服务端启动)因此永远不会因为一次
补齐失败而起不来。
"""
import os
import tempfile
# 唯一清单:MCP 脚本与其同目录兄弟模块。只拷 *_mcp.py 不够,
# 因为 MCP 脚本 fallback 时从同目录 import 兄弟模块
# (playwright_mcp -> playwright_browser/playwright_remote,
# websearch_mcp -> websearch,imagegen_mcp -> imagegen)。
SHARED_HOME_MCP_FILES = (
"playwright_mcp.py",
"playwright_browser.py",
"playwright_remote.py",
"websearch_mcp.py",
"websearch.py",
"imagegen_mcp.py",
"imagegen.py",
)
def _cloud_dir():
"""部署代码的 cloud/ 目录:同步源,绝不是租户目录。"""
return os.path.dirname(os.path.abspath(__file__))
def _write_atomic(dst, data):
"""同目录临时文件 + os.replace 原子替换,避免并发启动的
另一控制面实例读到半写文件。"""
directory = os.path.dirname(dst) or "."
fd, tmp = tempfile.mkstemp(prefix=".mcp-sync-", dir=directory)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
os.replace(tmp, dst)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def sync_shared_home_mcp(shared_home, source_dir=None):
"""把部署版 MCP 脚本同步到共享 home。
参数:
- shared_home:目标目录(测试时传临时目录;生产由服务端
的解析函数给出,见 cloud/server.py 的
``_resolve_shared_home_mcp``)。
- source_dir:源目录,缺省为本模块所在的 cloud/ 目录。
返回 dict,键固定为 copied / refreshed / unchanged /
missing / error,调用方只看它、不捕获异常:
- copied:目标缺失、本次拷入的文件;
- refreshed:目标存在但内容不同、本次刷新的文件;
- unchanged:内容相同、本次未动的文件;
- missing:源目录里不存在的文件(跳过,不阻塞其它文件);
- error:None 或把缺失/写失败拼成的一行文本,调用方记
warning 即可,绝不因此阻塞启动/部署/派发。
"""
copied = []
refreshed = []
unchanged = []
missing = []
errors = []
if not shared_home:
return {"copied": copied, "refreshed": refreshed,
"unchanged": unchanged, "missing": missing,
"error": "no shared home resolved"}
source = os.path.abspath(source_dir) if source_dir else _cloud_dir()
try:
os.makedirs(shared_home, exist_ok=True)
except Exception as exc: # 目录建不出:只记错,不阻塞
return {"copied": copied, "refreshed": refreshed,
"unchanged": unchanged, "missing": missing,
"error": "cannot create shared home %s: %s"
% (shared_home, exc)}
for name in SHARED_HOME_MCP_FILES:
src = os.path.join(source, name)
dst = os.path.join(shared_home, name)
try:
with open(src, "rb") as fh:
data = fh.read()
except OSError:
missing.append(name)
continue
except Exception as exc:
errors.append("%s: cannot read source: %s" % (name, exc))
continue
try:
if os.path.isfile(dst):
with open(dst, "rb") as fh:
if fh.read() == data:
unchanged.append(name)
continue
_write_atomic(dst, data)
refreshed.append(name)
elif os.path.exists(dst):
# 目标是目录等非文件:不删、不覆盖,只记错。
errors.append("%s: destination is not a file" % name)
else:
_write_atomic(dst, data)
copied.append(name)
except Exception as exc:
errors.append("%s: %s" % (name, exc))
continue
parts = []
if missing:
parts.append("missing source files: %s" % ",".join(missing))
parts.extend(errors)
return {"copied": copied, "refreshed": refreshed,
"unchanged": unchanged, "missing": missing,
"error": "; ".join(parts) if parts else None}
+213
View File
@@ -0,0 +1,213 @@
"""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()