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.
215 lines
8.2 KiB
Python
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)
|