334 lines
11 KiB
Python
Executable File
334 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Uninstall Codex MMO without deleting user state unless explicitly requested."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
import math
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import traceback
|
|
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 MMO_SCHEMA_VERSION, PACKAGE_VERSION # noqa: E402
|
|
|
|
OWNER_MANIFEST = ".codex-mmo-install-owner.json"
|
|
|
|
|
|
def _reject_json_constant(value: str) -> Any:
|
|
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, Any]]) -> dict[str, Any]:
|
|
result: dict[str, Any] = {}
|
|
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: Any) -> 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) -> Any:
|
|
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 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 defaults() -> tuple[Path, Path, Path, Path]:
|
|
install = xdg_path("XDG_DATA_HOME", Path.home() / ".local" / "share") / "codex-mmo"
|
|
config = xdg_path("XDG_CONFIG_HOME", Path.home() / ".config") / "codex-mmo"
|
|
state = xdg_path("XDG_STATE_HOME", Path.home() / ".local" / "state") / "codex-mmo"
|
|
bin_dir = xdg_path("XDG_BIN_HOME", Path.home() / ".local" / "bin")
|
|
return install, config, state, bin_dir
|
|
|
|
|
|
def owned_wrapper(path: Path) -> bool:
|
|
if not path.is_file():
|
|
return False
|
|
try:
|
|
return (
|
|
"Installed by codex-multimodel-orchestrator"
|
|
in path.read_text(encoding="utf-8", errors="replace")[:500]
|
|
)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def absolute_path(path: Path) -> Path:
|
|
"""Return a lexical absolute path without following the deletion target."""
|
|
|
|
return Path(os.path.abspath(path.expanduser()))
|
|
|
|
|
|
def remove_tree(path: Path) -> None:
|
|
"""Remove an owned tree even when compiled snapshots are read-only."""
|
|
|
|
try:
|
|
mode = path.lstat().st_mode
|
|
except FileNotFoundError:
|
|
return
|
|
if stat.S_ISLNK(mode):
|
|
path.unlink()
|
|
return
|
|
if not stat.S_ISDIR(mode):
|
|
raise RuntimeError(f"refusing to recursively remove non-directory: {path}")
|
|
if path.resolve(strict=False) != path:
|
|
raise RuntimeError(f"refusing to remove a path reached through a symlink: {path}")
|
|
for item in path.rglob("*"):
|
|
with contextlib.suppress(OSError):
|
|
item_mode = item.lstat().st_mode
|
|
if not stat.S_ISLNK(item_mode):
|
|
os.chmod(item, 0o700 if stat.S_ISDIR(item_mode) else 0o600)
|
|
with contextlib.suppress(OSError):
|
|
os.chmod(path, 0o700)
|
|
shutil.rmtree(path)
|
|
|
|
|
|
def validate_removal_target(path: Path, label: str) -> None:
|
|
protected = {Path("/"), Path.home().resolve()}
|
|
if path in protected or len(path.parts) <= 2:
|
|
raise ValueError(f"refusing to remove broad protected {label}: {path}")
|
|
if path.resolve(strict=False) != path:
|
|
raise ValueError(f"refusing {label} reached through a symlink: {path}")
|
|
|
|
|
|
def _option_was_supplied(name: str) -> bool:
|
|
return any(item == name or item.startswith(f"{name}=") for item in sys.argv[1:])
|
|
|
|
|
|
def _trusted_manifest(path: Path, install_root: Path) -> dict[str, Any] | None:
|
|
if path.is_symlink() or not path.is_file():
|
|
return None
|
|
try:
|
|
data = _strict_json_loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
return None
|
|
schema_version = data.get("schema_version") if isinstance(data, dict) else None
|
|
if (
|
|
not isinstance(data, dict)
|
|
or not isinstance(schema_version, int)
|
|
or isinstance(schema_version, bool)
|
|
or schema_version != MMO_SCHEMA_VERSION
|
|
):
|
|
return None
|
|
recorded = data.get("install_root")
|
|
if (
|
|
not isinstance(recorded, str)
|
|
or not Path(recorded).expanduser().is_absolute()
|
|
or absolute_path(Path(recorded)) != install_root
|
|
):
|
|
return None
|
|
if data.get("package") != "codex-multimodel-orchestrator":
|
|
return None
|
|
return data
|
|
|
|
|
|
def _write_owner_manifest(root: Path, data: dict[str, Any]) -> None:
|
|
"""Persist deletion ownership when config/state survives package removal."""
|
|
|
|
if not root.is_dir() or root.is_symlink():
|
|
return
|
|
destination = root / OWNER_MANIFEST
|
|
fd, raw_temporary = tempfile.mkstemp(prefix=f".{OWNER_MANIFEST}.", dir=root)
|
|
temporary = Path(raw_temporary)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
json.dump(data, handle, indent=2, sort_keys=True, allow_nan=False)
|
|
handle.write("\n")
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.chmod(temporary, 0o600)
|
|
os.replace(temporary, destination)
|
|
finally:
|
|
with contextlib.suppress(FileNotFoundError):
|
|
temporary.unlink()
|
|
|
|
|
|
def main() -> int:
|
|
install, config, state, bin_dir = defaults()
|
|
parser_options: dict[str, Any] = {
|
|
"description": "Remove the owned Codex MMO runtime while retaining user data by default.",
|
|
"epilog": (
|
|
"Configuration, credentials, sessions, jobs, evaluations, and logs remain unless "
|
|
"their explicit purge flags are supplied. Ownership manifests guard every removal."
|
|
),
|
|
"allow_abbrev": False,
|
|
}
|
|
if sys.version_info >= (3, 14):
|
|
parser_options["color"] = False
|
|
parser = argparse.ArgumentParser(**parser_options)
|
|
parser.add_argument(
|
|
"--version", action="version", version=PACKAGE_VERSION, help="print the package version"
|
|
)
|
|
parser.add_argument(
|
|
"--install-root", type=Path, default=install, help="owned runtime payload to remove"
|
|
)
|
|
parser.add_argument(
|
|
"--config-root",
|
|
type=Path,
|
|
default=config,
|
|
help="configuration root recorded by the installer",
|
|
)
|
|
parser.add_argument(
|
|
"--state-root", type=Path, default=state, help="state root recorded by the installer"
|
|
)
|
|
parser.add_argument(
|
|
"--bin-dir", type=Path, default=bin_dir, help="directory containing installed wrappers"
|
|
)
|
|
parser.add_argument(
|
|
"--purge-config",
|
|
action="store_true",
|
|
help="also permanently remove configuration and credentials",
|
|
)
|
|
parser.add_argument(
|
|
"--purge-state",
|
|
action="store_true",
|
|
help="also permanently remove sessions, jobs, evaluations, and logs",
|
|
)
|
|
parser.add_argument(
|
|
"--debug", action="store_true", help="show a Python traceback when removal fails"
|
|
)
|
|
args = parser.parse_args()
|
|
for name in ("install_root", "config_root", "state_root", "bin_dir"):
|
|
setattr(args, name, absolute_path(getattr(args, name)))
|
|
|
|
install_manifest = args.install_root / "config" / "install-manifest.json"
|
|
# A retained config/state marker authorizes a later purge only after the
|
|
# payload root is gone. If anything has appeared at the install path again,
|
|
# require that live tree's own manifest so a stale marker cannot authorize
|
|
# deletion of unrelated replacement content.
|
|
manifests: tuple[Path, ...]
|
|
if args.install_root.exists() or args.install_root.is_symlink():
|
|
manifests = (install_manifest,)
|
|
else:
|
|
manifests = (
|
|
args.config_root / OWNER_MANIFEST,
|
|
args.state_root / OWNER_MANIFEST,
|
|
)
|
|
data = next(
|
|
(
|
|
candidate
|
|
for path in manifests
|
|
if (candidate := _trusted_manifest(path, args.install_root)) is not None
|
|
),
|
|
None,
|
|
)
|
|
if data is None:
|
|
raise RuntimeError(
|
|
"refusing to remove an install root without its valid current-generation "
|
|
"ownership manifest: "
|
|
f"{args.install_root}"
|
|
)
|
|
for option, attribute in (
|
|
("--config-root", "config_root"),
|
|
("--state-root", "state_root"),
|
|
("--bin-dir", "bin_dir"),
|
|
):
|
|
value = data.get(attribute)
|
|
if not isinstance(value, str) or not Path(value).expanduser().is_absolute():
|
|
raise RuntimeError(f"ownership manifest is missing {attribute}")
|
|
recorded = absolute_path(Path(value))
|
|
if _option_was_supplied(option):
|
|
if getattr(args, attribute) != recorded:
|
|
raise RuntimeError(
|
|
f"refusing {option} outside the ownership manifest: "
|
|
f"{getattr(args, attribute)} != {recorded}"
|
|
)
|
|
else:
|
|
setattr(args, attribute, recorded)
|
|
|
|
validate_removal_target(args.install_root, "install root")
|
|
validate_removal_target(args.config_root, "configuration root")
|
|
validate_removal_target(args.state_root, "state root")
|
|
if args.bin_dir.resolve(strict=False) != args.bin_dir:
|
|
raise ValueError(f"refusing binary directory reached through a symlink: {args.bin_dir}")
|
|
|
|
# Preserve a location-bound ownership record before removing the only
|
|
# installed copy. This supports a later explicit purge without relaxing
|
|
# deletion checks for arbitrary directories.
|
|
if not args.purge_config:
|
|
_write_owner_manifest(args.config_root, data)
|
|
if not args.purge_state:
|
|
_write_owner_manifest(args.state_root, data)
|
|
|
|
for name in ("codex-mmo", "codex-mmoctl", "codex-mmo-uninstall"):
|
|
path = args.bin_dir / name
|
|
if owned_wrapper(path):
|
|
path.unlink()
|
|
remove_tree(args.install_root)
|
|
if args.purge_config:
|
|
remove_tree(args.config_root)
|
|
if args.purge_state:
|
|
remove_tree(args.state_root)
|
|
|
|
print("Codex Multi-Model Orchestrator removed.")
|
|
if not args.purge_config and args.config_root.exists():
|
|
print(f"Configuration retained at {args.config_root}; use --purge-config to remove it.")
|
|
if not args.purge_state and args.state_root.exists():
|
|
print(f"State retained at {args.state_root}; use --purge-state to remove it.")
|
|
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())
|