fix(cli): validate runtimes before uninstall

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 16:36:32 -04:00
1 parent c234801be6
commit 0c823e73e7
5 files changed
+199 -53

No files matched your search

+106 -51
View File
@@ -7,17 +7,18 @@ Uses only stdlib argparse — zero external dependencies for the CLI itself.
from __future__ import annotations
import argparse
import glob
import json
import os
import shutil
import stat
import sys
from typing import Dict, NoReturn, Type
from typing import Dict, NamedTuple, NoReturn, Optional, Type
from ._version import __version__
from ._runtime_paths import (
ensure_private_directory,
require_physical_directory,
require_physical_file,
resolve_agent_directory,
)
from .crypto import get_public_key_base64url
@@ -76,7 +77,27 @@ def _resolve_agent_directory_or_exit(agent_id: str) -> str:
_exit_with_error(str(error))
def _read_runtime_config_or_exit(config_path: str) -> dict:
class _InstalledAgent(NamedTuple):
agent_id: str
agent_name: str
config_path: str
def _require_physical_directory_or_exit(path: str) -> bool:
try:
return require_physical_directory(path)
except OSError as error:
_exit_with_error(str(error))
def _require_physical_file_or_exit(path: str) -> bool:
try:
return require_physical_file(path)
except OSError as error:
_exit_with_error(str(error))
def _read_runtime_config_or_exit(config_path: str) -> Dict[str, object]:
try:
with open(config_path, "r", encoding="utf-8") as file:
config = json.load(file)
@@ -87,6 +108,52 @@ def _read_runtime_config_or_exit(config_path: str) -> dict:
return config
def _read_installed_agent_or_exit(agent_id: str) -> Optional[_InstalledAgent]:
agent_directory = _resolve_agent_directory_or_exit(agent_id)
if not _require_physical_directory_or_exit(agent_directory):
return None
config_path = os.path.join(agent_directory, "config.json")
if not _require_physical_file_or_exit(config_path):
return None
config = _read_runtime_config_or_exit(config_path)
stored_agent_id = config.get("agent_id")
stored_agent_name = config.get("agent_name")
if not isinstance(stored_agent_id, str) or not isinstance(
stored_agent_name, str
):
_exit_with_error(f"Agent config at {config_path} has an invalid identity")
if stored_agent_id != agent_id:
_exit_with_error(
f"Agent config at {config_path} crosses its runtime directory"
)
_resolve_agent_directory_or_exit(stored_agent_id)
return _InstalledAgent(agent_id, stored_agent_name, config_path)
def _discover_installed_agents_or_exit() -> list[_InstalledAgent]:
runtime_root = _runtime_root()
if not _require_physical_directory_or_exit(runtime_root):
return []
installed_agents: list[_InstalledAgent] = []
try:
with os.scandir(runtime_root) as entries:
for entry in entries:
if entry.is_symlink():
_exit_with_error(
f"Agent runtime path is not a physical directory: {entry.path}"
)
if not entry.is_dir(follow_symlinks=False):
continue
installed_agent = _read_installed_agent_or_exit(entry.name)
if installed_agent is not None:
installed_agents.append(installed_agent)
except OSError as error:
_exit_with_error(f"Scan agent runtime root at {runtime_root}: {error}")
return installed_agents
def _get_plugin(agent_name: str) -> AgentPlugin:
"""Instantiate the plugin for the given agent name."""
cls = PLUGIN_MAP.get(agent_name)
@@ -120,11 +187,8 @@ def cmd_install(args: argparse.Namespace) -> None:
guard_script = generate_guard_script(agent_name, args.agent_id)
with open(guard_script_path, "w", encoding="utf-8") as f:
f.write(guard_script)
try:
import stat
if os.name != "nt":
os.chmod(guard_script_path, os.stat(guard_script_path).st_mode | stat.S_IEXEC)
except Exception:
pass # chmod may fail on Windows
print(f" Guard script: {guard_script_path}")
config: InstallConfig = {
@@ -146,57 +210,48 @@ def cmd_install(args: argparse.Namespace) -> None:
def cmd_uninstall(args: argparse.Namespace) -> None:
"""Handle the 'uninstall' subcommand."""
explicit_agent_id = getattr(args, "agent_id", None)
if explicit_agent_id:
_resolve_agent_directory_or_exit(explicit_agent_id)
# Determine which agent directory to remove
elydora_dir = _runtime_root()
require_physical_directory(elydora_dir)
agent_id = explicit_agent_id
if not agent_id:
# Scan ~/.elydora/*/config.json for matching agent_name
pattern = os.path.join(elydora_dir, "*", "config.json")
for config_path in glob.glob(pattern):
cfg = _read_runtime_config_or_exit(config_path)
if cfg.get("agent_name") != args.agent:
continue
stored_agent_id = cfg.get("agent_id")
if not isinstance(stored_agent_id, str):
_exit_with_error(
f"Agent config at {config_path} has an invalid agent_id"
)
stored_directory = _resolve_agent_directory_or_exit(stored_agent_id)
if os.path.normcase(stored_directory) != os.path.normcase(
os.path.dirname(config_path)
):
_exit_with_error(
f"Agent config at {config_path} crosses its runtime directory"
)
agent_id = stored_agent_id
break
agent_dir = None
agent_directory_exists = False
if agent_id:
agent_dir = _resolve_agent_directory_or_exit(agent_id)
agent_directory_exists = require_physical_directory(agent_dir)
runtime_root_exists = _require_physical_directory_or_exit(elydora_dir)
agent_directory_exists = (
_require_physical_directory_or_exit(agent_dir)
if runtime_root_exists
else False
)
if agent_directory_exists:
installed_agent = _read_installed_agent_or_exit(agent_id)
if installed_agent and installed_agent.agent_name != args.agent:
_exit_with_error(
f"Agent runtime {agent_id} belongs to "
f"{installed_agent.agent_name}, not {args.agent}"
)
else:
matches = [
installed_agent
for installed_agent in _discover_installed_agents_or_exit()
if installed_agent.agent_name == args.agent
]
if not matches:
_exit_with_error(
f"No installed agent found for {args.agent!r}; pass --agent_id explicitly"
)
if len(matches) > 1:
_exit_with_error(
f"Multiple installed agents found for {args.agent!r}; "
"pass --agent_id explicitly"
)
agent_id = matches[0].agent_id
agent_dir = _resolve_agent_directory_or_exit(agent_id)
agent_directory_exists = True
plugin = _get_plugin(args.agent)
plugin.uninstall(agent_id=agent_id or "")
plugin.uninstall(agent_id=agent_id)
if agent_dir:
if agent_directory_exists:
shutil.rmtree(agent_dir)
print(f" Removed agent directory: {agent_dir}")
else:
# Fallback: try to remove legacy guard script location
guard_script_path = os.path.join(
elydora_dir, "hooks", f"{args.agent}-guard.py"
)
if os.path.exists(guard_script_path):
os.remove(guard_script_path)
print(f" Removed guard script: {guard_script_path}")
if agent_directory_exists:
shutil.rmtree(agent_dir)
print(f" Removed agent directory: {agent_dir}")
def cmd_status(args: argparse.Namespace) -> None: