"""Safely synchronize the local Codex MCP entry without rewriting user policies.""" from __future__ import annotations import json import os from pathlib import Path import re import shutil import tempfile import tomllib class McpSyncValidationError(ValueError): """A readable configuration could not be safely updated.""" def _atomic_write(path: Path, text: str) -> None: if path.is_file() and path.read_text(encoding="utf-8") == text: return backup = path.with_name(path.name + ".artemis-update.bak") if path.is_file() and not backup.exists(): shutil.copy2(path, backup) fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream: stream.write(text) os.replace(name, path) finally: Path(name).unlink(missing_ok=True) def _is_standard_local_artemis(config: dict) -> bool: if config.get("url") or config.get("type") not in (None, "stdio"): return False args = config.get("args", []) # Running this launcher selects this checkout, including after a user moves # a release ZIP to a new directory. Do not take over custom wrappers/SSE. return args in (["-m", "mcp_server"], ["-m", "mcp_server.server"]) def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, ...]) -> dict: updated = {**config, "command": template["command"], "args": template["args"]} if "cwd" in template or "cwd" in config: updated["cwd"] = str(root) environment = config.get("env", {}) if not isinstance(environment, dict): raise ValueError("The existing Artemis MCP environment is not an object") updated["env"] = {**environment, **template["env"]} # Earlier releases made Artemis mandatory for every Codex session. Migrate # that setting while preserving enabled=false and all tool approval policies. updated["required"] = False if "enabled_tools" not in config and "enabled_tools" in template: updated["enabled_tools"] = list(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", "enabled", "required", "startup_timeout_sec", "connectionTimeoutMs"): if key in template and key not in updated: updated[key] = template[key] return updated def _toml_value(value: object) -> str: if isinstance(value, bool): return "true" if value else "false" return json.dumps(value, ensure_ascii=False) def _toml_inline_table(values: dict) -> str: return "{ " + ", ".join(f"{key} = {_toml_value(value)}" for key, value in values.items()) + " }" def _replace_toml_tables(raw: str, assignments: dict[str, dict]) -> str: """Update only Artemis TOML tables; preserve every unrelated section and setting.""" lines = raw.splitlines(keepends=True) output: list[str] = [] section = "" found: dict[str, set[str]] = {name: set() for name in assignments} seen: set[str] = set() def append_missing() -> None: if section in assignments: for key, value in assignments[section].items(): if key not in found[section]: rendered = _toml_inline_table(value) if key == "env" else _toml_value(value) output.append(f"{key} = {rendered}\n") found[section].add(key) index = 0 while index < len(lines): line = lines[index] if line.lstrip().startswith("["): append_missing() header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip() section = header[1:-1] if header.startswith("[") and header.endswith("]") else "" seen.add(section) match = re.match(r"\s*(\w+)\s*=", line) if section in assignments else None if match and match[1] in assignments[section]: key = match[1] original = line while True: try: tomllib.loads(original) break except tomllib.TOMLDecodeError: index += 1 if index >= len(lines): raise ValueError("Cannot safely parse existing MCP setting") original += lines[index] value = assignments[section][key] rendered = _toml_inline_table(value) if key == "env" else _toml_value(value) output.append(f"{key} = {rendered}\n") found[section].add(key) else: output.append(line) index += 1 append_missing() for name, values in assignments.items(): if name not in seen: if output and not output[-1].endswith("\n"): output.append("\n") output.append(f"\n[{name}]\n") for key, value in values.items(): rendered = _toml_inline_table(value) if key == "env" else _toml_value(value) output.append(f"{key} = {rendered}\n") return "".join(output) def _refresh_codex_unlocked( path: Path, root: Path, python: str, names: tuple[str, ...], template: dict | None = None, install_missing: bool = False, ) -> bool | None: if not path.is_file() and not install_missing: return False raw = path.read_text(encoding="utf-8") if path.is_file() else "" parsed = tomllib.loads(raw) config = parsed.get("mcp_servers", {}).get("artemis") if config is None and install_missing: if template is None: raise ValueError("A standard MCP template is required for installation") updated = template elif isinstance(config, dict) and _is_standard_local_artemis(config): if template is None: template = { "command": python, "args": ["-m", "mcp_server"], "cwd": str(root), "env": {"PYTHONPATH": str(root)}, "enabled_tools": list(names), } updated = _updated_entry(config, template, root, names) else: return None top = { key: value for key, value in updated.items() if key != "env" and (not config or config.get(key) != value) } environment = updated["env"] if config and config.get("env") == environment: env_updates = {} else: env_updates = { key: value for key, value in environment.items() if not config or config.get("env", {}).get(key) != value } env_header = "mcp_servers.artemis.env" has_env_table = re.search(r"(?m)^\s*\[mcp_servers\.artemis\.env\]\s*(?:#.*)?$", raw) is not None if config and "env" in config and not has_env_table and env_updates: top["env"] = environment env_updates = {} assignments = {"mcp_servers.artemis": top} if env_updates: assignments[env_header] = env_updates if not top and not env_updates: return False 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 def refresh_codex( path: Path, root: Path, python: str, names: tuple[str, ...], template: dict | None = None, install_missing: bool = False, ) -> bool | None: from filelock import FileLock if not path.is_file() and not install_missing: return False path.parent.mkdir(parents=True, exist_ok=True) with FileLock(str(path) + ".artemis.lock", timeout=5): return _refresh_codex_unlocked(path, root, python, names, template, install_missing)