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.
This commit is contained in:
chenmingxuan committed 2026-10-18 14:18:37 +08:00
1 parent 2328a4df0c
commit e1ea099592
128 files changed
+4944 -700

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Optional client integrations; never start the ARTEMIS service."""
+214
View File
@@ -0,0 +1,214 @@
"""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)
+199
View File
@@ -0,0 +1,199 @@
"""Install the authoritative artemis-use bundle on user-initiated service startup."""
import hashlib
import json
import logging
import os
from pathlib import Path
import re
import shutil
import tempfile
from uuid import uuid4
from filelock import FileLock
from packaging.version import Version, InvalidVersion
from artemis import __version__
from artemis.integrations.codex_mcp import _atomic_write
NAME = "artemis-use"
STAMP = ".artemis-managed.json"
logger = logging.getLogger(__name__)
def skill_source(root: Path) -> Path:
for candidate in (
root / "skills" / NAME,
Path(__file__).resolve().parents[2] / "skills" / NAME,
Path(__file__).resolve().parents[1] / "resources" / "skills" / NAME,
):
if (candidate / "SKILL.md").is_file():
return candidate
raise FileNotFoundError("安装包缺少官方 artemis-use Skill,请修复 ARTEMIS 安装包")
def _links(path: Path) -> bool:
return path.is_symlink() or path.is_junction()
def _files(path: Path) -> dict[str, str]:
files = {}
if _links(path):
return files
for item in sorted(path.rglob("*")):
if _links(item):
raise ValueError("Skill 目录包含链接,需要替换为官方文件")
if "__pycache__" in item.parts or item.suffix == ".pyc":
continue
if item.is_file() and item.name != STAMP:
files[item.relative_to(path).as_posix()] = hashlib.sha256(item.read_bytes()).hexdigest()
return files
def _remove_owned(path: Path) -> None:
"""Only call for an exact managed child; never traverse a junction/symlink."""
if path.is_symlink():
path.unlink()
elif path.is_junction():
path.rmdir()
elif path.is_dir():
shutil.rmtree(path)
elif path.exists():
path.unlink()
def _migrate_rules(codex_home: Path) -> None:
pattern = re.compile(
r"(?ms)^<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->.*?^<!-- END ARTEMIS MOBILE TESTING RULES -->[^\S\n]*\n?"
)
for name in ("AGENTS.md", "AGENTS.override.md"):
path = codex_home / name
if path.is_file() and not _links(path):
raw = path.read_text(encoding="utf-8")
updated = pattern.sub("", raw)
if updated != raw:
_atomic_write(path, updated)
def sync_official_skill(root: Path) -> dict:
source = skill_source(root)
expected = _files(source)
if not expected or "SKILL.md" not in expected:
raise ValueError("官方 Skill 内容不完整")
custom_home = os.environ.get("CODEX_HOME")
codex_home = Path(custom_home).expanduser() if custom_home else Path.home() / ".codex"
# Respect explicitly isolated Codex profiles. Otherwise use the documented
# user discovery location, reusing a pre-existing legacy Codex installation.
locations = (
[codex_home / "skills"]
if custom_home
else [Path.home() / ".agents" / "skills", codex_home / "skills"]
)
parent = next((p for p in locations if (p / NAME).exists()), locations[0])
parent.mkdir(parents=True, exist_ok=True)
target = parent / NAME
# One lock for both supported paths, shared by all installations of this user.
codex_home.mkdir(parents=True, exist_ok=True)
with FileLock(str(codex_home / ".artemis-skill.lock"), timeout=5):
old = {}
if not _links(target) and (target / STAMP).is_file():
try:
old = json.loads((target / STAMP).read_text("utf-8"))
if not isinstance(old, dict):
old = {}
except (ValueError, OSError):
pass
try:
if (
old.get("name") == NAME
and old.get("files") == _files(target)
and Version(str(old.get("artemis_version", "0"))) > Version(__version__)
):
return {"status": "newer_installed", "path": str(target)}
except (InvalidVersion, ValueError):
pass
try:
unchanged = target.is_dir() and _files(target) == expected
except ValueError:
unchanged = False
if not unchanged:
staging = Path(tempfile.mkdtemp(prefix=".artemis-use-stage-", dir=parent))
previous = parent / f".artemis-use-old-{uuid4().hex}"
try:
shutil.copytree(
source,
staging,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
)
if _files(staging) != expected:
raise ValueError("官方 Skill 复制校验失败")
(staging / STAMP).write_text(
json.dumps(
{
"name": NAME,
"artemis_version": __version__,
"source": str(root.resolve()),
"files": expected,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
if target.exists() or _links(target):
target.rename(previous)
try:
staging.rename(target)
except OSError:
if previous.exists() or _links(previous):
previous.rename(target)
raise
finally:
_remove_owned(staging)
# Previous content is temporary rollback data, not a customization backup.
if target.exists():
_remove_owned(previous)
for location in locations:
duplicate = location / NAME
if duplicate != target and (duplicate.exists() or _links(duplicate)):
_remove_owned(duplicate)
_migrate_rules(codex_home)
return {"status": "unchanged" if unchanged else "updated", "path": str(target)}
def sync_skill_safely(root: Path) -> dict:
try:
result = sync_official_skill(root)
logger.info("Codex 官方 Skill %s: %s", result["status"], result["path"])
return result
except Exception as exc:
# Do not print config/exception bodies which may contain credentials.
logger.warning(
"官方 artemis-use Skill 同步失败(%s);保留可用版本,网页继续启动。",
type(exc).__name__,
)
return {"status": "error", "error_type": type(exc).__name__}
def sync_on_startup(root: Path) -> None:
if os.environ.get("ARTEMIS_SKIP_CODEX_SYNC") == "1":
return
sync_skill_safely(root)
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_config_snippet
from artemis.integrations.codex_mcp import refresh_codex
import sys
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
template = _get_config_snippet("codex", sys.executable, str(root))["mcp_servers"]["artemis"]
try:
refresh_codex(
codex_home / "config.toml",
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
except Exception as exc:
logger.warning("Codex MCP 同步失败(%s);原配置已保留,网页继续启动。", type(exc).__name__)