Files
Artemis-Eapil/artemis/integrations/codex_mcp.py
T
chenmingxuan e1ea099592 feat: add official Codex skill and harden task execution
Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
2026-10-18 14:18:37 +08:00

215 lines
8.2 KiB
Python

"""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)