"""共享 codex-home 的 MCP 脚本自动补齐。 背景(2026-10-30 实测):租户 config 的 ``mcp_servers.*`` 指向 共享路径 ``/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}