# Conflicts: # .github/workflows/js-tests.yml # agent/model_metadata.py # apps/desktop/electron/main.ts # apps/desktop/scripts/bundle-electron-main.mjs # apps/desktop/src/app/settings/about-settings.tsx # apps/desktop/src/app/settings/gateway-settings.test.tsx # apps/desktop/src/app/settings/gateway-settings.tsx # apps/desktop/src/app/updates-overlay.tsx # gateway/shutdown_flush.py # hermes_bootstrap.py # hermes_cli/local_runtime/binaries.py # hermes_cli/main.py # hermes_cli/managed_uv.py # hermes_cli/update_cmd.py # hermes_cli/update_cmd_deps.py # hermes_cli/update_cmd_fleet.py # hermes_cli/update_cmd_maint.py # hermes_cli/update_receipt.py # hermes_cli/update_serve_obligations.py # hermes_constants.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_pending_supervisor_recovery.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_update_desktop_stale_warning.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_state/test_hermes_state.py # tests/tools/test_tirith_security.py # tools/bot_relay.py # tools/checkpoint_manager.py # tools/write_approval.py # website/docs/getting-started/updating.md # website/docs/reference/environment-variables.md
338 lines
14 KiB
Python
338 lines
14 KiB
Python
"""On-disk pet store — install / list / resolve pets.
|
|
|
|
Pets live under ``get_hermes_home()/pets/<slug>/`` (profile-scoped; NOT petdex's
|
|
``~/.codex/pets``, which its CLI owns): ``pet.json`` ({id, displayName,
|
|
description, spritesheetPath}) plus ``spritesheet.webp`` (or .png). The active
|
|
pet comes from the caller-supplied ``display.pet.slug`` (no config loader here).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import io
|
|
import itertools
|
|
import json
|
|
import logging
|
|
import re
|
|
import shutil
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DOWNLOAD_TIMEOUT = 60.0
|
|
_HTTP_HEADERS = {"User-Agent": "hermes-agent-petdex"}
|
|
_THUMB_FRAME_W = 192
|
|
_THUMB_FRAME_H = 208
|
|
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
|
|
|
|
|
|
class PetStoreError(RuntimeError):
|
|
"""Raised on install/IO failures."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class InstalledPet:
|
|
"""A pet present on disk."""
|
|
|
|
slug: str
|
|
display_name: str
|
|
description: str
|
|
directory: Path
|
|
spritesheet: Path
|
|
created_by: str = "" # "generator" for pets hatched locally; "" for petdex installs
|
|
|
|
@property
|
|
def exists(self) -> bool:
|
|
return self.spritesheet.is_file()
|
|
|
|
@property
|
|
def generated(self) -> bool:
|
|
return self.created_by == "generator"
|
|
|
|
|
|
def _ensure_dir(path: Path) -> Path:
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def pets_dir() -> Path:
|
|
"""Return the profile-scoped pets directory (created on demand)."""
|
|
return _ensure_dir(get_hermes_home() / "pets")
|
|
|
|
|
|
def _thumb_path(slug: str) -> Path:
|
|
"""Cached thumbnail for *slug* (lives OUTSIDE the pet dir, under ``pets/.thumbs/``)."""
|
|
return _ensure_dir(pets_dir() / ".thumbs") / f"{slug}.png"
|
|
|
|
|
|
def _read_pet_json(directory: Path) -> dict:
|
|
pet_json = directory / "pet.json"
|
|
try:
|
|
return json.loads(pet_json.read_text(encoding="utf-8-sig")) if pet_json.is_file() else {}
|
|
except (OSError, ValueError) as exc:
|
|
logger.debug("unreadable pet.json in %s: %s", directory, exc)
|
|
return {}
|
|
|
|
|
|
def _write_pet_json(directory: Path, meta: dict) -> None:
|
|
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
|
|
|
|
def _resolve_spritesheet(directory: Path, meta: dict) -> Path:
|
|
"""Honor ``spritesheetPath``, else probe conventional names (incl. petdex R2's ``sprite.webp``)."""
|
|
declared = str(meta.get("spritesheetPath", "") or "").strip()
|
|
names = ([declared] if declared else []) + ["spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"]
|
|
return next((directory / n for n in names if (directory / n).is_file()), directory / "spritesheet.webp") # stable default
|
|
|
|
|
|
def _safe_slug(slug: str) -> str:
|
|
"""Normalize a slug to one bare path segment so ``pets_dir()/<slug>`` can never escape the pets directory."""
|
|
segment = Path(str(slug).strip()).name
|
|
return "" if segment in ("", ".", "..") else segment
|
|
|
|
|
|
def load_pet(slug: str) -> InstalledPet | None:
|
|
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
|
|
if not (slug := _safe_slug(slug)) or not (directory := pets_dir() / slug).is_dir():
|
|
return None
|
|
meta = _read_pet_json(directory)
|
|
name, desc, by = (str(meta.get(k, "") or d) for k, d in (("displayName", slug), ("description", ""), ("createdBy", "")))
|
|
return InstalledPet(slug, name, desc, directory, _resolve_spritesheet(directory, meta), by)
|
|
|
|
|
|
def _usable_pet(slug: str, error: str = "") -> InstalledPet | None:
|
|
""":func:`load_pet` result only when its spritesheet is on disk; raises :class:`PetStoreError` *error* if given."""
|
|
if (pet := load_pet(slug)) and pet.exists:
|
|
return pet
|
|
if error:
|
|
raise PetStoreError(error)
|
|
return None
|
|
|
|
|
|
def installed_pets() -> list[InstalledPet]:
|
|
"""Return every installed pet (dirs containing a usable spritesheet)."""
|
|
return [pet for child in sorted(pets_dir().iterdir()) if child.is_dir() and (pet := _usable_pet(child.name))]
|
|
|
|
|
|
def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None:
|
|
"""The configured slug (``display.pet.slug``) if installed, else the first pet alphabetically."""
|
|
if configured_slug and (pet := _usable_pet(configured_slug.strip())):
|
|
return pet
|
|
return next(iter(installed_pets()), None)
|
|
|
|
|
|
def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet:
|
|
"""Download *slug* from the manifest; idempotent unless *force*. Raises :class:`PetStoreError` / ``ManifestError``."""
|
|
from agent.pet.manifest import find_entry
|
|
|
|
slug = _safe_slug(slug)
|
|
if not slug:
|
|
raise PetStoreError("invalid pet slug")
|
|
if not force and (existing := _usable_pet(slug)):
|
|
return existing
|
|
entry = find_entry(slug, timeout=timeout)
|
|
if entry is None:
|
|
raise PetStoreError(f"pet '{slug}' is not in the petdex manifest")
|
|
# Host-pin asset URLs so a compromised/spoofed manifest can't redirect the
|
|
# download to an arbitrary host (matches thumbnail_png).
|
|
if not _is_petdex_host(entry.spritesheet_url):
|
|
raise PetStoreError(f"refusing non-petdex spritesheet host for '{slug}'")
|
|
|
|
directory = _ensure_dir(pets_dir() / slug)
|
|
sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp"
|
|
sprite_path = directory / f"spritesheet{sprite_ext}"
|
|
_download(entry.spritesheet_url, sprite_path, timeout=timeout)
|
|
|
|
# Prefer the upstream pet.json; else synthesize one so the layout is self-describing.
|
|
meta: dict = {}
|
|
if entry.pet_json_url and _is_petdex_host(entry.pet_json_url):
|
|
try:
|
|
meta = data if isinstance(data := _http_get(entry.pet_json_url, timeout).json(), dict) else {}
|
|
except Exception as exc: # noqa: BLE001 - non-fatal, fall back below
|
|
logger.debug("pet.json fetch failed for %s: %s", slug, exc)
|
|
meta = meta or {"id": slug, "displayName": entry.display_name, "description": ""}
|
|
meta["spritesheetPath"] = sprite_path.name # key order matters: pet.json is written verbatim
|
|
meta.setdefault("id", slug)
|
|
meta.setdefault("displayName", entry.display_name)
|
|
_write_pet_json(directory, meta)
|
|
return _usable_pet(slug, f"install of '{slug}' did not produce a spritesheet")
|
|
|
|
|
|
def slugify(name: str) -> str:
|
|
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
|
|
return re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-") or "pet"
|
|
|
|
|
|
def unique_slug(name: str) -> str:
|
|
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
|
|
base = slugify(name)
|
|
candidates = (base if i == 1 else f"{base}-{i}" for i in itertools.count(1))
|
|
return next(slug for slug in candidates if not (pets_dir() / slug).exists())
|
|
|
|
|
|
def register_local_pet(spritesheet, *, slug: str, display_name: str = "", description: str = "") -> InstalledPet:
|
|
"""Write a locally-generated pet (PIL image, WebP/PNG bytes, or path) into the store as lossless WebP.
|
|
|
|
Appears in :func:`installed_pets` immediately (no manifest entry needed).
|
|
"""
|
|
slug = slugify(slug)
|
|
directory = _ensure_dir(pets_dir() / slug)
|
|
sprite_path = directory / "spritesheet.webp"
|
|
try:
|
|
if isinstance(spritesheet, (bytes, bytearray)):
|
|
sprite_path.write_bytes(bytes(spritesheet))
|
|
else:
|
|
from agent.pet.generate.atlas import _load_rgba
|
|
|
|
_load_rgba(spritesheet).save(sprite_path, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
|
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
|
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
|
|
meta = {"id": slug, "displayName": display_name or slug, "description": description or "", "spritesheetPath": sprite_path.name}
|
|
_write_pet_json(directory, {**meta, "createdBy": "generator"})
|
|
return _usable_pet(slug, f"register of generated pet '{slug}' did not produce a spritesheet")
|
|
|
|
|
|
def export_pet(slug: str) -> tuple[str, bytes]:
|
|
"""Zip an installed pet's folder → ``(filename, bytes)``; dotfiles (thumbs, backups) skipped."""
|
|
root = pets_dir()
|
|
directory = root / slug.strip()
|
|
# Traversal guard: the target must be a direct child of pets_dir.
|
|
if directory.resolve().parent != root.resolve() or not directory.is_dir():
|
|
raise PetStoreError(f"pet '{slug}' is not installed")
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
for path in sorted(directory.iterdir()):
|
|
if path.is_file() and not path.name.startswith("."):
|
|
archive.write(path, f"{directory.name}/{path.name}")
|
|
return f"{directory.name}.zip", buf.getvalue()
|
|
|
|
|
|
def _is_petdex_host(url: str) -> bool:
|
|
"""True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF)."""
|
|
try:
|
|
host = (urlparse(url).hostname or "").lower()
|
|
except ValueError:
|
|
return False
|
|
return host == "petdex.dev" or host.endswith(".petdex.dev")
|
|
|
|
|
|
def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None:
|
|
"""Small idle-frame (top-left cell) PNG for *slug*, cached on disk; ``None`` on any failure.
|
|
|
|
Source: the installed sheet, else *source_url* only when petdex-hosted (the gateway
|
|
never fetches arbitrary client URLs). Server-side so it rides the authenticated
|
|
gateway as a same-origin data URL, sidestepping CSP/hotlink limits.
|
|
"""
|
|
if not (slug := slug.strip()):
|
|
return None
|
|
if (cache := _thumb_path(slug)).is_file():
|
|
with contextlib.suppress(OSError):
|
|
return cache.read_bytes()
|
|
sheet_bytes = None
|
|
if pet := _usable_pet(slug):
|
|
with contextlib.suppress(OSError):
|
|
sheet_bytes = pet.spritesheet.read_bytes()
|
|
if sheet_bytes is None and source_url and _is_petdex_host(source_url):
|
|
try:
|
|
sheet_bytes = _http_get(source_url, timeout).content
|
|
except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder
|
|
logger.debug("thumb fetch failed for %s: %s", slug, exc)
|
|
if not sheet_bytes:
|
|
return None
|
|
try:
|
|
from PIL import Image
|
|
|
|
with Image.open(io.BytesIO(sheet_bytes)) as im:
|
|
frame = im.convert("RGBA").crop((0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height)))
|
|
frame = frame.resize((_THUMB_W, round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W)), Image.NEAREST)
|
|
buf = io.BytesIO()
|
|
frame.save(buf, format="PNG")
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("thumb crop failed for %s: %s", slug, exc)
|
|
return None
|
|
with contextlib.suppress(OSError):
|
|
cache.write_bytes(buf.getvalue())
|
|
return buf.getvalue()
|
|
|
|
|
|
def remove_pet(slug: str) -> bool:
|
|
"""Delete an installed pet directory. Returns True if anything was removed."""
|
|
slug = _safe_slug(slug)
|
|
if not slug:
|
|
return False
|
|
# Drop the cached thumb too or a later pet reusing this slug shows the stale one.
|
|
with contextlib.suppress(OSError):
|
|
_thumb_path(slug).unlink(missing_ok=True)
|
|
if not (directory := pets_dir() / slug).is_dir():
|
|
return False
|
|
shutil.rmtree(directory, ignore_errors=True)
|
|
return not directory.exists()
|
|
|
|
|
|
def rename_pet(slug: str, display_name: str) -> str | None:
|
|
"""Rename a pet's ``displayName`` AND move its dir/thumb to ``slugify(name)`` when that's a free, different slug.
|
|
|
|
Generated pets hatch under a provisional slug; naming makes that the identity. Returns the resulting slug, or ``None``.
|
|
"""
|
|
slug = _safe_slug(slug)
|
|
display_name = (display_name or "").strip()
|
|
directory = pets_dir() / slug
|
|
if not slug or not display_name or not (directory / "pet.json").is_file():
|
|
return None
|
|
meta = _read_pet_json(directory)
|
|
meta = meta if isinstance(meta, dict) else {}
|
|
meta["displayName"] = display_name
|
|
new_slug = slug
|
|
desired = slugify(display_name)
|
|
if desired and desired != slug and not (pets_dir() / desired).exists():
|
|
with contextlib.suppress(OSError): # keep the provisional slug if the move fails
|
|
directory.rename(pets_dir() / desired)
|
|
with contextlib.suppress(OSError):
|
|
_thumb_path(slug).rename(_thumb_path(desired))
|
|
directory = pets_dir() / desired
|
|
new_slug = meta["id"] = desired
|
|
try:
|
|
_write_pet_json(directory, meta)
|
|
except OSError:
|
|
return None
|
|
return new_slug
|
|
|
|
|
|
def _http_get(url: str, timeout: float):
|
|
"""GET *url* with the petdex UA, following redirects; raises on HTTP errors.
|
|
Manifest-derived URLs are remote-party-controlled — SSRF-checked first."""
|
|
from tools.url_safety import create_ssrf_safe_client, is_safe_url
|
|
|
|
if not is_safe_url(url):
|
|
raise ValueError(f"Pet store URL failed the SSRF safety check: {url}")
|
|
with create_ssrf_safe_client(timeout=timeout, follow_redirects=True) as client:
|
|
resp = client.get(url, headers=_HTTP_HEADERS)
|
|
resp.raise_for_status()
|
|
return resp
|
|
|
|
|
|
def _download(url: str, dest: Path, *, timeout: float) -> None:
|
|
"""Stream *url* to *dest* via a ``.part`` temp file so a failed download never leaves a truncated sheet."""
|
|
import httpx
|
|
|
|
try:
|
|
from tools.url_safety import create_ssrf_safe_client, is_safe_url
|
|
|
|
if not is_safe_url(url):
|
|
raise ValueError(f"Pet store URL failed the SSRF safety check: {url}")
|
|
with create_ssrf_safe_client(timeout=timeout, follow_redirects=True) as client, \
|
|
client.stream("GET", url, headers=_HTTP_HEADERS) as resp:
|
|
resp.raise_for_status()
|
|
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
with tmp.open("wb") as fh:
|
|
for chunk in resp.iter_bytes():
|
|
fh.write(chunk)
|
|
tmp.replace(dest)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise PetStoreError(f"download failed for {url}: {exc}") from exc
|