fix: preserve Codex tool policies when synchronizing MCP

This commit is contained in:
chenmingxuan committed 2026-10-18 20:56:06 +08:00
1 parent bdc75d0c65
commit 351b2fc905
3 files changed
+97 -12

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ Python 按 `uv.lock` 同步缺失/变化的依赖;前端依据源码、资源
scrcpy 固定版本的国内下载采用多个来源,失败或 SHA256 不符时自动尝试下一来源,全部失败会显示 HTTP 状态、超时或校验原因。`config/downloads.json` 的 `overrides.scrcpy_url` 指定首选 HTTPS 地址,`overrides.scrcpy_fallback_urls` 指定备用地址数组;`null` 使用默认备用源,`[]` 禁用备用源。国内模式不自动回退到 GitHub 直连。
MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 配置无法安全解析时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。
MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态、`tools` 下的工具审批策略及其他服务;不会重建权限表、添加 `eager` 或删除旧工具策略。自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 原配置无法解析,或同步程序生成的更新未通过校验时,均停止启动且不覆盖原文件;日志会区分这两种原因,不把同步生成错误归因于用户配置。遇到后者请更新 ARTEMIS,不要删除有效的 `config.toml`;可使用 `-SkipMcpSync` 临时仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
+24 -11
View File
@@ -18,6 +18,10 @@ from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
class McpSyncValidationError(ValueError):
"""The synchronizer could not safely update an already parsed configuration."""
def launch_text(english: str, chinese: str) -> str:
return chinese if os.environ.get("ARTEMIS_LAUNCH_LANG") == "zh-CN" else english
@@ -170,11 +174,10 @@ def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, .
updated["env"] = {**environment, **template["env"]}
if "enabled_tools" in template or "enabled_tools" in config:
updated["enabled_tools"] = list(names)
if "tools" in template or "tools" in config:
current_tools = config.get("tools", {})
if not isinstance(current_tools, dict):
raise ValueError("The existing Artemis MCP tools setting is not an object")
updated["tools"] = {name: {"eager": True, **current_tools.get(name, {})} for name in names}
# Per-tool approval/enablement policies belong to the user. Leave `tools`
# untouched, including entries outside the current exported tool list.
# Rebuilding it both changes policy and sends nested tables to the scalar
# serializer, which produces JSON syntax rather than valid TOML.
for key in ("type", "required", "startup_timeout_sec", "connectionTimeoutMs"):
if key in template and key not in updated:
updated[key] = template[key]
@@ -300,12 +303,17 @@ def refresh_codex(
assignments[env_header] = env_updates
if not top and not env_updates:
return False
result = _replace_toml_tables(raw, assignments)
actual = tomllib.loads(result)["mcp_servers"]["artemis"]
if any(actual.get(key) != value for key, value in top.items()) or any(
actual.get("env", {}).get(key) != value for key, value in env_updates.items()
):
raise ValueError("Could not safely refresh the Artemis MCP table")
try:
result = _replace_toml_tables(raw, assignments)
actual = tomllib.loads(result)["mcp_servers"]["artemis"]
if any(actual.get(key) != value for key, value in top.items()) or any(
actual.get("env", {}).get(key) != value for key, value in env_updates.items()
):
raise ValueError("Updated MCP settings did not match the requested values")
except (ValueError, KeyError, TypeError) as exc:
raise McpSyncValidationError(
"Could not safely generate the MCP configuration update"
) from exc
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, result)
return result != raw
@@ -339,6 +347,11 @@ def refresh_mcp(root: Path) -> int:
"Cannot read/write the configuration; check file permissions.",
"无法读写配置,请检查文件权限。",
)
elif isinstance(exc, McpSyncValidationError):
reason = launch_text(
"The synchronizer generated an invalid update from a readable configuration. This is an ARTEMIS sync error; do not delete your Codex configuration.",
"原配置可正常解析,但同步程序生成的更新未通过校验。这是 ARTEMIS 同步错误,请勿删除 Codex 配置。",
)
else:
reason = launch_text(
"Invalid TOML or Artemis configuration structure; check the file in Codex.",
+72
View File
@@ -136,6 +136,78 @@ command = "leave-alone"
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
@pytest.mark.parametrize(
"policies",
[
'# Keep my tool policies\ntools = { mobile_run_task = { enabled = false, approval_policy = "on-request" }, retired_tool = { enabled = false } }\n',
'# Keep my tool policies\ntools.mobile_run_task.enabled = false\ntools.mobile_run_task.approval_policy = "on-request"\ntools.retired_tool.enabled = false\n',
'# Keep my tool policies\n[mcp_servers.artemis.tools.mobile_run_task]\nenabled = false\napproval_policy = "on-request"\n[mcp_servers.artemis.tools.retired_tool]\nenabled = false\n',
],
ids=["inline-table", "dotted-keys", "tool-subtables"],
)
def test_codex_sync_preserves_tool_policies_verbatim(tmp_path, monkeypatch, policies):
from artemis.interfaces.cli.commands import mcp as mcp_command
codex_home = tmp_path / "中文用户" / ".codex"
codex_home.mkdir(parents=True)
path = codex_home / "config.toml"
original = (
'[mcp_servers.artemis]\ncommand = "old-python"\n'
'args = ["-m", "mcp_server"]\n'
'enabled_tools = ["mobile_run_task"]\n'
'disabled_tools = ["mobile_config"]\n'
'env = { PYTHONPATH = "C:/old", OPENAI_API_KEY = "test-secret" }\n'
+ policies
+ '[mcp_servers.other]\ncommand = "leave-alone"\n'
)
path.write_text(original, encoding="utf-8")
before = tomllib.loads(original)
monkeypatch.setenv("CODEX_HOME", str(codex_home))
root = tmp_path / "新版项目"
assert update.refresh_mcp(root) == 0
refreshed = path.read_text(encoding="utf-8")
after = tomllib.loads(refreshed)
entry = after["mcp_servers"]["artemis"]
assert entry["command"] == update.sys.executable
assert entry["cwd"] == str(root)
assert entry["env"] == {
"PYTHONPATH": str(root),
"OPENAI_API_KEY": "test-secret",
"PYTHONUNBUFFERED": "1",
"ARTEMIS_DESKTOP_NOTIFY": "true",
}
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
assert entry["disabled_tools"] == ["mobile_config"]
assert entry["tools"] == before["mcp_servers"]["artemis"]["tools"]
assert policies.split("\n", 1)[1] in refreshed
assert refreshed.count("# Keep my tool policies") == 1
assert after["mcp_servers"]["other"] == before["mcp_servers"]["other"]
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
assert update.refresh_mcp(root) == 0
assert path.read_text(encoding="utf-8") == refreshed
def test_codex_sync_reports_generated_toml_error_without_blame_or_overwrite(
tmp_path, monkeypatch, capsys
):
codex_home = tmp_path / ".codex"
codex_home.mkdir()
path = codex_home / "config.toml"
original = '[mcp_servers.artemis]\ncommand = "old"\nargs = ["-m", "mcp_server"]\n'
path.write_text(original, encoding="utf-8")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN")
monkeypatch.setattr(update, "_replace_toml_tables", lambda *_: 'tools = {"secret": true}\n')
assert update.refresh_mcp(tmp_path / "project") == 1
assert path.read_text(encoding="utf-8") == original
assert not path.with_name(path.name + ".artemis-update.bak").exists()
output = capsys.readouterr().out
assert "原配置可正常解析" in output
assert "ARTEMIS 同步错误" in output
assert "配置结构无效" not in output
assert "secret" not in output
def test_mcp_sync_updates_only_codex_and_preserves_other_clients(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command