Files
Artemis-Eapil/artemis/integrations/codex_skill.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

200 lines
7.3 KiB
Python

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