😏
This commit is contained in:
Executable
+333
@@ -0,0 +1,333 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user