774 lines
27 KiB
Python
774 lines
27 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Atomic per-user installer for Codex Multi-Model Orchestrator."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import contextlib
|
||
|
|
import datetime as dt
|
||
|
|
import json
|
||
|
|
import math
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import shlex
|
||
|
|
import shutil
|
||
|
|
import stat
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import traceback
|
||
|
|
from collections.abc import Iterable, Mapping
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
sys.path.insert(0, str(PACKAGE_ROOT / "libexec"))
|
||
|
|
|
||
|
|
from mmo_version import ( # noqa: E402
|
||
|
|
APP_SERVER_PROTOCOL_CODEX_VERSION,
|
||
|
|
MMO_SCHEMA_VERSION,
|
||
|
|
PACKAGE_VERSION,
|
||
|
|
SWITCHYARD_BASELINE_VERSION,
|
||
|
|
)
|
||
|
|
|
||
|
|
VERSION = PACKAGE_VERSION
|
||
|
|
|
||
|
|
PAYLOAD_FILES = (
|
||
|
|
"LICENSE",
|
||
|
|
"VERSION",
|
||
|
|
"README.md",
|
||
|
|
"CHANGELOG.md",
|
||
|
|
"VALIDATION.md",
|
||
|
|
"PACKAGE-MANIFEST.json",
|
||
|
|
)
|
||
|
|
PAYLOAD_DIRECTORIES = (
|
||
|
|
"config",
|
||
|
|
"docs",
|
||
|
|
"evals",
|
||
|
|
"libexec",
|
||
|
|
"profiles",
|
||
|
|
"scripts",
|
||
|
|
"tests",
|
||
|
|
)
|
||
|
|
|
||
|
|
RETAINED_OWNER_MANIFEST = ".codex-mmo-install-owner.json"
|
||
|
|
|
||
|
|
|
||
|
|
def filtered_environment(*, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||
|
|
sensitive = re.compile(
|
||
|
|
r"(?:^|_)(?:API_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|COOKIE|AUTH|PRIVATE)(?:_|$)",
|
||
|
|
re.IGNORECASE,
|
||
|
|
)
|
||
|
|
result = {
|
||
|
|
key: value
|
||
|
|
for key, value in os.environ.items()
|
||
|
|
if not key.startswith("MMO_") and not sensitive.search(key)
|
||
|
|
}
|
||
|
|
if extra:
|
||
|
|
result.update(extra)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def toml_quote(value: str) -> str:
|
||
|
|
try:
|
||
|
|
value.encode("utf-8")
|
||
|
|
except UnicodeEncodeError as exc:
|
||
|
|
raise ValueError("generated TOML strings must contain valid Unicode scalar values") from exc
|
||
|
|
return json.dumps(value, ensure_ascii=False, allow_nan=False).replace("\x7f", "\\u007F")
|
||
|
|
|
||
|
|
|
||
|
|
def xdg_path(variable: str, fallback: Path) -> Path:
|
||
|
|
"""Resolve an XDG base directory, ignoring empty or relative values."""
|
||
|
|
|
||
|
|
raw = os.environ.get(variable)
|
||
|
|
if raw:
|
||
|
|
candidate = Path(raw)
|
||
|
|
if candidate.is_absolute():
|
||
|
|
return candidate
|
||
|
|
return fallback
|
||
|
|
|
||
|
|
|
||
|
|
def default_install_root() -> Path:
|
||
|
|
return xdg_path("XDG_DATA_HOME", Path.home() / ".local" / "share") / "codex-mmo"
|
||
|
|
|
||
|
|
|
||
|
|
def default_config_root() -> Path:
|
||
|
|
return xdg_path("XDG_CONFIG_HOME", Path.home() / ".config") / "codex-mmo"
|
||
|
|
|
||
|
|
|
||
|
|
def default_state_root() -> Path:
|
||
|
|
return xdg_path("XDG_STATE_HOME", Path.home() / ".local" / "state") / "codex-mmo"
|
||
|
|
|
||
|
|
|
||
|
|
def default_bin_dir() -> Path:
|
||
|
|
return xdg_path("XDG_BIN_HOME", Path.home() / ".local" / "bin")
|
||
|
|
|
||
|
|
|
||
|
|
def atomic_write(path: Path, data: str, mode: int = 0o600) -> None:
|
||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||
|
|
try:
|
||
|
|
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||
|
|
handle.write(data)
|
||
|
|
handle.flush()
|
||
|
|
os.fsync(handle.fileno())
|
||
|
|
os.chmod(temporary, mode)
|
||
|
|
os.replace(temporary, path)
|
||
|
|
finally:
|
||
|
|
with contextlib.suppress(FileNotFoundError):
|
||
|
|
os.unlink(temporary)
|
||
|
|
|
||
|
|
|
||
|
|
def remove_tree(path: Path) -> None:
|
||
|
|
"""Remove a tree that may contain immutable snapshot permissions."""
|
||
|
|
|
||
|
|
try:
|
||
|
|
root_mode = path.lstat().st_mode
|
||
|
|
except FileNotFoundError:
|
||
|
|
return
|
||
|
|
if stat.S_ISLNK(root_mode):
|
||
|
|
path.unlink()
|
||
|
|
return
|
||
|
|
if not stat.S_ISDIR(root_mode):
|
||
|
|
raise RuntimeError(f"refusing to recursively remove non-directory: {path}")
|
||
|
|
for item in path.rglob("*"):
|
||
|
|
with contextlib.suppress(OSError):
|
||
|
|
mode = item.lstat().st_mode
|
||
|
|
if not stat.S_ISLNK(mode):
|
||
|
|
os.chmod(item, 0o700 if stat.S_ISDIR(mode) else 0o600)
|
||
|
|
with contextlib.suppress(OSError):
|
||
|
|
os.chmod(path, 0o700)
|
||
|
|
shutil.rmtree(path)
|
||
|
|
|
||
|
|
|
||
|
|
def validate_destination_paths(paths: Iterable[Path]) -> None:
|
||
|
|
values = list(paths)
|
||
|
|
protected = {Path("/"), Path.home().resolve()}
|
||
|
|
for path in values:
|
||
|
|
if path in protected or len(path.parts) <= 2:
|
||
|
|
raise ValueError(f"refusing to install into broad protected path: {path}")
|
||
|
|
for index, left in enumerate(values):
|
||
|
|
for right in values[index + 1 :]:
|
||
|
|
if left == right or left in right.parents or right in left.parents:
|
||
|
|
raise ValueError(f"installation paths must not overlap: {left} and {right}")
|
||
|
|
|
||
|
|
|
||
|
|
def _reject_json_constant(value: str) -> None:
|
||
|
|
raise ValueError(f"non-standard JSON constant: {value}")
|
||
|
|
|
||
|
|
|
||
|
|
def _finite_json_float(value: str) -> float:
|
||
|
|
parsed = float(value)
|
||
|
|
if not math.isfinite(parsed):
|
||
|
|
raise ValueError(f"JSON number is outside the supported finite range: {value}")
|
||
|
|
return parsed
|
||
|
|
|
||
|
|
|
||
|
|
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||
|
|
result: dict[str, object] = {}
|
||
|
|
for key, item in pairs:
|
||
|
|
if key in result:
|
||
|
|
raise ValueError(f"duplicate JSON object member: {key!r}")
|
||
|
|
result[key] = item
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def _validate_json_unicode(value: object) -> None:
|
||
|
|
if isinstance(value, str):
|
||
|
|
try:
|
||
|
|
value.encode("utf-8")
|
||
|
|
except UnicodeEncodeError as exc:
|
||
|
|
raise ValueError("JSON strings must contain valid Unicode scalar values") from exc
|
||
|
|
elif isinstance(value, list):
|
||
|
|
for item in value:
|
||
|
|
_validate_json_unicode(item)
|
||
|
|
elif isinstance(value, dict):
|
||
|
|
for key, item in value.items():
|
||
|
|
_validate_json_unicode(key)
|
||
|
|
_validate_json_unicode(item)
|
||
|
|
|
||
|
|
|
||
|
|
def _strict_json_loads(value: str) -> object:
|
||
|
|
parsed = json.loads(
|
||
|
|
value,
|
||
|
|
parse_constant=_reject_json_constant,
|
||
|
|
parse_float=_finite_json_float,
|
||
|
|
object_pairs_hook=_unique_json_object,
|
||
|
|
)
|
||
|
|
_validate_json_unicode(parsed)
|
||
|
|
return parsed
|
||
|
|
|
||
|
|
|
||
|
|
def validate_existing_install(path: Path) -> None:
|
||
|
|
"""Refuse to replace a nonempty tree without this installer's ownership record."""
|
||
|
|
|
||
|
|
if not path.exists():
|
||
|
|
return
|
||
|
|
if path.is_symlink() or not path.is_dir():
|
||
|
|
raise RuntimeError(f"existing install root is not a real directory: {path}")
|
||
|
|
if next(path.iterdir(), None) is None:
|
||
|
|
return
|
||
|
|
manifest_path = path / "config" / "install-manifest.json"
|
||
|
|
if manifest_path.is_symlink() or not manifest_path.is_file():
|
||
|
|
raise RuntimeError(
|
||
|
|
f"refusing to replace a nonempty install root without its ownership manifest: {path}"
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
data = _strict_json_loads(manifest_path.read_text(encoding="utf-8"))
|
||
|
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||
|
|
raise RuntimeError(
|
||
|
|
f"existing install ownership manifest is invalid: {manifest_path}"
|
||
|
|
) from exc
|
||
|
|
recorded = data.get("install_root") if isinstance(data, dict) else None
|
||
|
|
if (
|
||
|
|
not isinstance(data, dict)
|
||
|
|
or not isinstance(data.get("schema_version"), int)
|
||
|
|
or isinstance(data.get("schema_version"), bool)
|
||
|
|
or data.get("schema_version") != MMO_SCHEMA_VERSION
|
||
|
|
or data.get("package") != "codex-multimodel-orchestrator"
|
||
|
|
or not isinstance(recorded, str)
|
||
|
|
or not Path(recorded).expanduser().is_absolute()
|
||
|
|
or Path(recorded).expanduser().resolve(strict=False) != path
|
||
|
|
):
|
||
|
|
raise RuntimeError(f"existing install ownership manifest does not match: {path}")
|
||
|
|
|
||
|
|
|
||
|
|
def validate_wrapper_targets(bin_dir: Path) -> None:
|
||
|
|
marker = "Installed by codex-multimodel-orchestrator"
|
||
|
|
for name in ("codex-mmoctl", "codex-mmo", "codex-mmo-uninstall"):
|
||
|
|
path = bin_dir / name
|
||
|
|
if not path.exists() and not path.is_symlink():
|
||
|
|
continue
|
||
|
|
if path.is_symlink() or not path.is_file():
|
||
|
|
raise FileExistsError(f"refusing to replace non-regular wrapper target: {path}")
|
||
|
|
try:
|
||
|
|
owned = marker in path.read_text(encoding="utf-8", errors="replace")[:500]
|
||
|
|
except OSError as exc:
|
||
|
|
raise FileExistsError(f"unable to verify existing wrapper ownership: {path}") from exc
|
||
|
|
if not owned:
|
||
|
|
raise FileExistsError(f"refusing to replace unowned executable: {path}")
|
||
|
|
|
||
|
|
|
||
|
|
def _ignore(_directory: str, names: list[str]) -> set[str]:
|
||
|
|
ignored = {
|
||
|
|
name
|
||
|
|
for name in names
|
||
|
|
if name in {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
|
||
|
|
}
|
||
|
|
ignored.update(name for name in names if name.endswith((".pyc", ".pyo")))
|
||
|
|
return ignored
|
||
|
|
|
||
|
|
|
||
|
|
def validate_payload_source() -> None:
|
||
|
|
"""Require a complete, static source tree before copying an installer payload."""
|
||
|
|
|
||
|
|
for name in PAYLOAD_FILES:
|
||
|
|
source = PACKAGE_ROOT / name
|
||
|
|
try:
|
||
|
|
mode = source.lstat().st_mode
|
||
|
|
except FileNotFoundError as exc:
|
||
|
|
raise FileNotFoundError(f"required installer payload file is missing: {name}") from exc
|
||
|
|
if stat.S_ISLNK(mode) or not stat.S_ISREG(mode):
|
||
|
|
raise ValueError(f"installer payload file must be regular and not a symlink: {name}")
|
||
|
|
for name in PAYLOAD_DIRECTORIES:
|
||
|
|
source = PACKAGE_ROOT / name
|
||
|
|
try:
|
||
|
|
mode = source.lstat().st_mode
|
||
|
|
except FileNotFoundError as exc:
|
||
|
|
raise FileNotFoundError(
|
||
|
|
f"required installer payload directory is missing: {name}"
|
||
|
|
) from exc
|
||
|
|
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
||
|
|
raise ValueError(f"installer payload directory must be real and not a symlink: {name}")
|
||
|
|
for item in source.rglob("*"):
|
||
|
|
relative = item.relative_to(PACKAGE_ROOT)
|
||
|
|
item_mode = item.lstat().st_mode
|
||
|
|
if stat.S_ISLNK(item_mode):
|
||
|
|
raise ValueError(f"symbolic links are not allowed in installer payload: {relative}")
|
||
|
|
if not (stat.S_ISREG(item_mode) or stat.S_ISDIR(item_mode)):
|
||
|
|
raise ValueError(f"special files are not allowed in installer payload: {relative}")
|
||
|
|
|
||
|
|
|
||
|
|
def copy_payload(destination: Path) -> None:
|
||
|
|
validate_payload_source()
|
||
|
|
destination.mkdir(parents=True, exist_ok=False)
|
||
|
|
for name in PAYLOAD_FILES:
|
||
|
|
source = PACKAGE_ROOT / name
|
||
|
|
shutil.copy2(source, destination / name)
|
||
|
|
for name in PAYLOAD_DIRECTORIES:
|
||
|
|
source = PACKAGE_ROOT / name
|
||
|
|
shutil.copytree(source, destination / name, ignore=_ignore)
|
||
|
|
for path in destination.rglob("*"):
|
||
|
|
if path.is_dir():
|
||
|
|
os.chmod(path, 0o755)
|
||
|
|
elif path.suffix == ".py":
|
||
|
|
os.chmod(path, 0o755 if os.access(path, os.X_OK) else 0o644)
|
||
|
|
else:
|
||
|
|
os.chmod(path, 0o644)
|
||
|
|
|
||
|
|
|
||
|
|
def wrapper(
|
||
|
|
python: Path,
|
||
|
|
entrypoint: Path,
|
||
|
|
fixed: Iterable[str] = (),
|
||
|
|
*,
|
||
|
|
environment: Mapping[str, str] | None = None,
|
||
|
|
) -> str:
|
||
|
|
words = [
|
||
|
|
shlex.quote(str(python)),
|
||
|
|
shlex.quote(str(entrypoint)),
|
||
|
|
*[shlex.quote(item) for item in fixed],
|
||
|
|
]
|
||
|
|
command = " ".join(words)
|
||
|
|
exports = "".join(
|
||
|
|
f"export {name}={shlex.quote(value)}\n"
|
||
|
|
for name, value in sorted((environment or {}).items())
|
||
|
|
)
|
||
|
|
return f"""#!/usr/bin/env bash
|
||
|
|
# Installed by codex-multimodel-orchestrator.
|
||
|
|
set -euo pipefail
|
||
|
|
{exports}exec {command} "$@"
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def main_wrapper(python: Path, entrypoint: Path) -> str:
|
||
|
|
return wrapper(
|
||
|
|
python,
|
||
|
|
entrypoint,
|
||
|
|
environment={"MMO_CLI_ENTRYPOINT": "codex-mmo"},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def install_optional_tools(args: argparse.Namespace) -> None:
|
||
|
|
if args.install_codex:
|
||
|
|
codex = shutil.which(args.codex_bin)
|
||
|
|
observed = ""
|
||
|
|
if codex:
|
||
|
|
result = subprocess.run(
|
||
|
|
[codex, "--version"],
|
||
|
|
text=True,
|
||
|
|
capture_output=True,
|
||
|
|
timeout=10,
|
||
|
|
check=False,
|
||
|
|
env=filtered_environment(),
|
||
|
|
)
|
||
|
|
if result.returncode == 0:
|
||
|
|
observed = result.stdout.strip().removeprefix("codex-cli ").strip()
|
||
|
|
if observed != APP_SERVER_PROTOCOL_CODEX_VERSION:
|
||
|
|
npm = shutil.which("npm")
|
||
|
|
if not npm:
|
||
|
|
raise RuntimeError("--install-codex requires npm")
|
||
|
|
subprocess.run(
|
||
|
|
[
|
||
|
|
npm,
|
||
|
|
"install",
|
||
|
|
"-g",
|
||
|
|
f"@openai/codex@{APP_SERVER_PROTOCOL_CODEX_VERSION}",
|
||
|
|
],
|
||
|
|
check=True,
|
||
|
|
env=filtered_environment(),
|
||
|
|
)
|
||
|
|
codex = shutil.which(args.codex_bin)
|
||
|
|
if not codex:
|
||
|
|
raise RuntimeError(
|
||
|
|
f"npm installation did not provide requested Codex binary {args.codex_bin!r}"
|
||
|
|
)
|
||
|
|
result = subprocess.run(
|
||
|
|
[codex, "--version"],
|
||
|
|
text=True,
|
||
|
|
capture_output=True,
|
||
|
|
timeout=10,
|
||
|
|
check=False,
|
||
|
|
env=filtered_environment(),
|
||
|
|
)
|
||
|
|
observed = (
|
||
|
|
result.stdout.strip().removeprefix("codex-cli ").strip()
|
||
|
|
if result.returncode == 0
|
||
|
|
else ""
|
||
|
|
)
|
||
|
|
if observed != APP_SERVER_PROTOCOL_CODEX_VERSION:
|
||
|
|
raise RuntimeError(
|
||
|
|
"npm installed Codex but the requested binary does not expose the reviewed "
|
||
|
|
f"version {APP_SERVER_PROTOCOL_CODEX_VERSION}: observed "
|
||
|
|
f"{observed or 'unavailable'} at {codex}"
|
||
|
|
)
|
||
|
|
if args.install_switchyard and not shutil.which(args.switchyard_bin):
|
||
|
|
cargo = shutil.which("cargo")
|
||
|
|
if not cargo:
|
||
|
|
raise RuntimeError("--install-switchyard requires Cargo")
|
||
|
|
subprocess.run(
|
||
|
|
[
|
||
|
|
cargo,
|
||
|
|
"install",
|
||
|
|
"--locked",
|
||
|
|
"--version",
|
||
|
|
SWITCHYARD_BASELINE_VERSION,
|
||
|
|
"switchyard-server",
|
||
|
|
],
|
||
|
|
check=True,
|
||
|
|
env=filtered_environment(),
|
||
|
|
)
|
||
|
|
if not shutil.which(args.switchyard_bin):
|
||
|
|
raise RuntimeError(
|
||
|
|
"Cargo installation did not provide requested Switchyard binary "
|
||
|
|
f"{args.switchyard_bin!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def validate_stage(stage: Path, config_root: Path, state_root: Path) -> None:
|
||
|
|
validation_state = Path(tempfile.mkdtemp(prefix=".install-validation-", dir=state_root))
|
||
|
|
env = filtered_environment(
|
||
|
|
extra={
|
||
|
|
"MMO_INSTALL_ROOT": str(stage),
|
||
|
|
"MMO_CONFIG_ROOT": str(config_root),
|
||
|
|
"MMO_STATE_ROOT": str(validation_state),
|
||
|
|
"PYTHONDONTWRITEBYTECODE": "1",
|
||
|
|
"PYTHONPATH": str(stage / "libexec"),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
code = r"""
|
||
|
|
from mmo_catalog import local_inventory_report
|
||
|
|
from mmo_profiles import builtin_profiles_root, resolve_profile
|
||
|
|
from mmo_snapshot import compile_profile
|
||
|
|
profile_directories = sorted(
|
||
|
|
path
|
||
|
|
for path in builtin_profiles_root().iterdir()
|
||
|
|
if path.is_dir() and (path / "profile.toml").is_file()
|
||
|
|
)
|
||
|
|
profiles = {}
|
||
|
|
for directory in profile_directories:
|
||
|
|
resolved = resolve_profile(directory)
|
||
|
|
profile_id = resolved["profile"]["id"]
|
||
|
|
if profile_id in profiles:
|
||
|
|
raise RuntimeError(f"duplicate bundled profile id: {profile_id}")
|
||
|
|
profiles[profile_id] = (directory, resolved)
|
||
|
|
expected_profiles = {
|
||
|
|
"adaptive-engineering",
|
||
|
|
"access-efficient-escalation-lab",
|
||
|
|
"bounded-research-organization-lab",
|
||
|
|
"codex-harness-team",
|
||
|
|
"competing-implementations-lab",
|
||
|
|
"contract-first-refactoring",
|
||
|
|
"high-confidence-debugging",
|
||
|
|
"incident-hypothesis-triage",
|
||
|
|
"research-backed-engineering",
|
||
|
|
"route-resilience-lab",
|
||
|
|
"secure-change",
|
||
|
|
"visual-engineering",
|
||
|
|
}
|
||
|
|
if set(profiles) != expected_profiles:
|
||
|
|
raise RuntimeError(
|
||
|
|
f"bundled profile set differs from the release contract: {sorted(profiles)}"
|
||
|
|
)
|
||
|
|
for profile_id in sorted(profiles):
|
||
|
|
directory, resolved = profiles[profile_id]
|
||
|
|
if resolved["profile"]["root"] not in resolved["agents"]:
|
||
|
|
raise RuntimeError(f"profile root is missing from agents: {profile_id}")
|
||
|
|
compile_profile(directory)
|
||
|
|
report = local_inventory_report()
|
||
|
|
if not report["passed"]:
|
||
|
|
raise RuntimeError(f"local inventory validation failed: {report}")
|
||
|
|
print(len(profiles))
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
result = subprocess.run(
|
||
|
|
[sys.executable, "-c", code],
|
||
|
|
env=env,
|
||
|
|
cwd=stage,
|
||
|
|
text=True,
|
||
|
|
capture_output=True,
|
||
|
|
check=False,
|
||
|
|
)
|
||
|
|
finally:
|
||
|
|
if validation_state.exists():
|
||
|
|
remove_tree(validation_state)
|
||
|
|
if result.returncode:
|
||
|
|
raise RuntimeError("staged package validation failed:\n" + result.stdout + result.stderr)
|
||
|
|
|
||
|
|
|
||
|
|
def parser() -> argparse.ArgumentParser:
|
||
|
|
options: dict[str, Any] = {
|
||
|
|
"description": "Install or atomically replace one per-user Codex MMO runtime.",
|
||
|
|
"epilog": (
|
||
|
|
"Existing schema-8 configuration and state are retained. Run the uninstaller "
|
||
|
|
"with explicit purge flags when permanent removal is intended."
|
||
|
|
),
|
||
|
|
"allow_abbrev": False,
|
||
|
|
}
|
||
|
|
if sys.version_info >= (3, 14):
|
||
|
|
options["color"] = False
|
||
|
|
value = argparse.ArgumentParser(**options)
|
||
|
|
value.add_argument(
|
||
|
|
"--version", action="version", version=VERSION, help="print the package version"
|
||
|
|
)
|
||
|
|
value.add_argument(
|
||
|
|
"--install-root",
|
||
|
|
type=Path,
|
||
|
|
default=default_install_root(),
|
||
|
|
help="runtime payload destination (default: XDG data home/codex-mmo)",
|
||
|
|
)
|
||
|
|
value.add_argument(
|
||
|
|
"--config-root",
|
||
|
|
type=Path,
|
||
|
|
default=default_config_root(),
|
||
|
|
help="operator configuration destination (default: XDG config home/codex-mmo)",
|
||
|
|
)
|
||
|
|
value.add_argument(
|
||
|
|
"--state-root",
|
||
|
|
type=Path,
|
||
|
|
default=default_state_root(),
|
||
|
|
help="session and job state destination (default: XDG state home/codex-mmo)",
|
||
|
|
)
|
||
|
|
value.add_argument(
|
||
|
|
"--bin-dir",
|
||
|
|
type=Path,
|
||
|
|
default=default_bin_dir(),
|
||
|
|
help="installed command directory (default: XDG_BIN_HOME or ~/.local/bin)",
|
||
|
|
)
|
||
|
|
value.add_argument(
|
||
|
|
"--codex-bin", default="codex", help="Codex executable name or path (default: codex)"
|
||
|
|
)
|
||
|
|
value.add_argument(
|
||
|
|
"--switchyard-bin",
|
||
|
|
default="switchyard-server",
|
||
|
|
help="Switchyard executable name or path (default: switchyard-server)",
|
||
|
|
)
|
||
|
|
value.add_argument(
|
||
|
|
"--install-codex",
|
||
|
|
action="store_true",
|
||
|
|
help="install the reviewed Codex CLI with npm when the requested binary is missing",
|
||
|
|
)
|
||
|
|
value.add_argument(
|
||
|
|
"--install-switchyard",
|
||
|
|
action="store_true",
|
||
|
|
help="install the pinned Switchyard release with Cargo when missing",
|
||
|
|
)
|
||
|
|
value.add_argument(
|
||
|
|
"--no-validate",
|
||
|
|
action="store_true",
|
||
|
|
help="skip staged package validation (development use only)",
|
||
|
|
)
|
||
|
|
value.add_argument(
|
||
|
|
"--debug", action="store_true", help="show a Python traceback when installation fails"
|
||
|
|
)
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
if sys.version_info < (3, 11): # noqa: UP036 - bootstrap a clear unsupported-Python error
|
||
|
|
raise RuntimeError("Python 3.11 or newer is required")
|
||
|
|
args = parser().parse_args()
|
||
|
|
for name in ("install_root", "config_root", "state_root", "bin_dir"):
|
||
|
|
setattr(args, name, getattr(args, name).expanduser().resolve())
|
||
|
|
validate_destination_paths((args.install_root, args.config_root, args.state_root, args.bin_dir))
|
||
|
|
validate_existing_install(args.install_root)
|
||
|
|
validate_wrapper_targets(args.bin_dir)
|
||
|
|
install_optional_tools(args)
|
||
|
|
|
||
|
|
args.config_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||
|
|
args.state_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||
|
|
args.bin_dir.mkdir(parents=True, exist_ok=True, mode=0o755)
|
||
|
|
(args.config_root / "profiles.d").mkdir(mode=0o700, exist_ok=True)
|
||
|
|
(args.config_root / "catalog.d").mkdir(mode=0o700, exist_ok=True)
|
||
|
|
(args.config_root / "tool-mcp.d").mkdir(mode=0o700, exist_ok=True)
|
||
|
|
|
||
|
|
retained_owner_markers = tuple(
|
||
|
|
root / RETAINED_OWNER_MANIFEST for root in (args.config_root, args.state_root)
|
||
|
|
)
|
||
|
|
for marker in retained_owner_markers:
|
||
|
|
if marker.is_symlink():
|
||
|
|
raise RuntimeError(f"refusing retained ownership marker symlink: {marker}")
|
||
|
|
if marker.exists() and not marker.is_file():
|
||
|
|
raise RuntimeError(f"retained ownership marker is not a regular file: {marker}")
|
||
|
|
|
||
|
|
settings_path = args.config_root / "settings.toml"
|
||
|
|
if not settings_path.exists():
|
||
|
|
settings = (PACKAGE_ROOT / "config" / "settings.toml").read_text(encoding="utf-8")
|
||
|
|
settings = settings.replace(
|
||
|
|
'codex_bin = "codex"',
|
||
|
|
f"codex_bin = {toml_quote(args.codex_bin)}",
|
||
|
|
)
|
||
|
|
settings = settings.replace(
|
||
|
|
'switchyard_bin = "switchyard-server"',
|
||
|
|
f"switchyard_bin = {toml_quote(args.switchyard_bin)}",
|
||
|
|
)
|
||
|
|
atomic_write(settings_path, settings, 0o600)
|
||
|
|
|
||
|
|
credentials_path = args.config_root / "credentials.env"
|
||
|
|
credentials_existed = credentials_path.exists()
|
||
|
|
if not credentials_existed:
|
||
|
|
atomic_write(
|
||
|
|
credentials_path,
|
||
|
|
(PACKAGE_ROOT / "config" / "credentials.env.example").read_text(encoding="utf-8"),
|
||
|
|
0o600,
|
||
|
|
)
|
||
|
|
|
||
|
|
stage_parent = args.install_root.parent
|
||
|
|
stage_parent.mkdir(parents=True, exist_ok=True, mode=0o755)
|
||
|
|
stage = Path(tempfile.mkdtemp(prefix=f".{args.install_root.name}.staging-", dir=stage_parent))
|
||
|
|
backup: Path | None = None
|
||
|
|
old_install: Path | None = None
|
||
|
|
wrapper_paths = tuple(
|
||
|
|
args.bin_dir / name for name in ("codex-mmoctl", "codex-mmo", "codex-mmo-uninstall")
|
||
|
|
)
|
||
|
|
previous_wrappers: dict[Path, tuple[str, int] | None] = {}
|
||
|
|
try:
|
||
|
|
remove_tree(stage)
|
||
|
|
copy_payload(stage)
|
||
|
|
runtime = {
|
||
|
|
"schema_version": MMO_SCHEMA_VERSION,
|
||
|
|
"package": "codex-multimodel-orchestrator",
|
||
|
|
"package_version": VERSION,
|
||
|
|
"install_root": str(args.install_root),
|
||
|
|
"config_root": str(args.config_root),
|
||
|
|
"state_root": str(args.state_root),
|
||
|
|
"bin_dir": str(args.bin_dir),
|
||
|
|
"python_bin": str(Path(sys.executable).resolve()),
|
||
|
|
"installed_at": dt.datetime.now(dt.UTC).isoformat(timespec="seconds"),
|
||
|
|
}
|
||
|
|
atomic_write(
|
||
|
|
stage / "config" / "runtime.json",
|
||
|
|
json.dumps(runtime, indent=2, sort_keys=True, allow_nan=False) + "\n",
|
||
|
|
0o644,
|
||
|
|
)
|
||
|
|
manifest = {
|
||
|
|
**runtime,
|
||
|
|
"source_root": str(PACKAGE_ROOT),
|
||
|
|
"wrappers": [
|
||
|
|
str(args.bin_dir / "codex-mmo"),
|
||
|
|
str(args.bin_dir / "codex-mmoctl"),
|
||
|
|
str(args.bin_dir / "codex-mmo-uninstall"),
|
||
|
|
],
|
||
|
|
}
|
||
|
|
atomic_write(
|
||
|
|
stage / "config" / "install-manifest.json",
|
||
|
|
json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n",
|
||
|
|
0o644,
|
||
|
|
)
|
||
|
|
if not args.no_validate:
|
||
|
|
validate_stage(stage, args.config_root, args.state_root)
|
||
|
|
|
||
|
|
python = Path(sys.executable).resolve()
|
||
|
|
wrapper_contents = {
|
||
|
|
args.bin_dir / "codex-mmoctl": wrapper(
|
||
|
|
python,
|
||
|
|
args.install_root / "libexec" / "mmoctl.py",
|
||
|
|
environment={"MMO_CLI_ENTRYPOINT": "codex-mmoctl"},
|
||
|
|
),
|
||
|
|
args.bin_dir / "codex-mmo": main_wrapper(
|
||
|
|
python, args.install_root / "libexec" / "mmoctl.py"
|
||
|
|
),
|
||
|
|
args.bin_dir / "codex-mmo-uninstall": wrapper(
|
||
|
|
python,
|
||
|
|
args.install_root / "scripts" / "uninstall.py",
|
||
|
|
(
|
||
|
|
"--install-root",
|
||
|
|
str(args.install_root),
|
||
|
|
"--config-root",
|
||
|
|
str(args.config_root),
|
||
|
|
"--state-root",
|
||
|
|
str(args.state_root),
|
||
|
|
"--bin-dir",
|
||
|
|
str(args.bin_dir),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
}
|
||
|
|
for path in wrapper_paths:
|
||
|
|
previous_wrappers[path] = (
|
||
|
|
(path.read_text(encoding="utf-8"), path.stat().st_mode & 0o777)
|
||
|
|
if path.is_file()
|
||
|
|
else None
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Wrapper targets use the stable install path, so they can be
|
||
|
|
# staged before the brief atomic payload swap and rolled back if
|
||
|
|
# any write or activation step fails.
|
||
|
|
for path, contents in wrapper_contents.items():
|
||
|
|
atomic_write(path, contents, 0o755)
|
||
|
|
if args.install_root.exists():
|
||
|
|
stamp = dt.datetime.now(dt.UTC).strftime("%Y%m%d-%H%M%S-%f")
|
||
|
|
backup_path = args.state_root / "backups" / f"install-{stamp}"
|
||
|
|
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
shutil.copytree(args.install_root, backup_path, symlinks=True)
|
||
|
|
backup = backup_path
|
||
|
|
old_path = args.install_root.with_name(
|
||
|
|
f".{args.install_root.name}.previous-{os.getpid()}-{stamp}"
|
||
|
|
)
|
||
|
|
os.replace(args.install_root, old_path)
|
||
|
|
old_install = old_path
|
||
|
|
try:
|
||
|
|
os.replace(stage, args.install_root)
|
||
|
|
except BaseException:
|
||
|
|
if old_install and old_install.exists() and not args.install_root.exists():
|
||
|
|
os.replace(old_install, args.install_root)
|
||
|
|
raise
|
||
|
|
except BaseException:
|
||
|
|
for path, previous in previous_wrappers.items():
|
||
|
|
if previous is None:
|
||
|
|
path.unlink(missing_ok=True)
|
||
|
|
else:
|
||
|
|
atomic_write(path, previous[0], previous[1])
|
||
|
|
raise
|
||
|
|
if old_install and old_install.exists():
|
||
|
|
try:
|
||
|
|
remove_tree(old_install)
|
||
|
|
except (OSError, RuntimeError) as exc:
|
||
|
|
print(
|
||
|
|
f"warning: installed successfully but could not remove {old_install}: {exc}",
|
||
|
|
file=sys.stderr,
|
||
|
|
)
|
||
|
|
# Retained ownership markers authorize a later purge only while the
|
||
|
|
# payload is absent. Once a fresh payload is active its embedded
|
||
|
|
# manifest is authoritative, and leaving an older marker behind would
|
||
|
|
# retain an obsolete MMO schema in the active config/state roots.
|
||
|
|
for marker in retained_owner_markers:
|
||
|
|
marker.unlink(missing_ok=True)
|
||
|
|
finally:
|
||
|
|
if stage.exists():
|
||
|
|
with contextlib.suppress(OSError):
|
||
|
|
remove_tree(stage)
|
||
|
|
|
||
|
|
print(f"Codex Multi-Model Orchestrator {VERSION} installed.")
|
||
|
|
print(f" executable: {args.bin_dir / 'codex-mmo'}")
|
||
|
|
print(f" control: {args.bin_dir / 'codex-mmoctl'}")
|
||
|
|
print(f" install: {args.install_root}")
|
||
|
|
print(f" config: {args.config_root}")
|
||
|
|
print(f" state: {args.state_root}")
|
||
|
|
if backup:
|
||
|
|
print(f" backup: {backup}")
|
||
|
|
print()
|
||
|
|
print(f"Configure provider credentials in {credentials_path}")
|
||
|
|
print("Authenticate built-in Codex models with: codex-mmo auth login")
|
||
|
|
print("Validate with: codex-mmo validate --all-profiles")
|
||
|
|
print("Live-check with: codex-mmo doctor --live")
|
||
|
|
print("Launch with: codex-mmo")
|
||
|
|
path_entries = {
|
||
|
|
Path(item).expanduser().resolve(strict=False)
|
||
|
|
for item in os.environ.get("PATH", "").split(os.pathsep)
|
||
|
|
if item
|
||
|
|
}
|
||
|
|
if args.bin_dir not in path_entries:
|
||
|
|
print(
|
||
|
|
f"warning: {args.bin_dir} is not on PATH; add it before invoking codex-mmo",
|
||
|
|
file=sys.stderr,
|
||
|
|
)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def cli_main() -> int:
|
||
|
|
try:
|
||
|
|
return main()
|
||
|
|
except KeyboardInterrupt:
|
||
|
|
return 130
|
||
|
|
except Exception as exc:
|
||
|
|
print(f"error: {exc}", file=sys.stderr)
|
||
|
|
if "--debug" in sys.argv[1:]:
|
||
|
|
print(f"exception: {type(exc).__name__}", file=sys.stderr)
|
||
|
|
traceback.print_exc(file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(cli_main())
|