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:
1 parent
2328a4df0c
commit
e1ea099592
128 files changed
+4944
-700
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Optional client integrations; never start the ARTEMIS service."""
|
||||
@@ -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)
|
||||
@@ -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__)
|
||||
Reference in new issue
Block a user