diff --git a/docs/ai-automation.md b/docs/ai-automation.md index 3c66d2a..e513065 100644 --- a/docs/ai-automation.md +++ b/docs/ai-automation.md @@ -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)。 diff --git a/scripts/update_installation.py b/scripts/update_installation.py index 2fc94fe..b721411 100644 --- a/scripts/update_installation.py +++ b/scripts/update_installation.py @@ -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.", diff --git a/tests/unit/test_installation_update.py b/tests/unit/test_installation_update.py index af9bc8c..0ec1b89 100644 --- a/tests/unit/test_installation_update.py +++ b/tests/unit/test_installation_update.py @@ -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