diff --git a/changelog.d/tsk-aa5qck-agent-avatars-module.md b/changelog.d/tsk-aa5qck-agent-avatars-module.md new file mode 100644 index 000000000..997c20826 --- /dev/null +++ b/changelog.d/tsk-aa5qck-agent-avatars-module.md @@ -0,0 +1,3 @@ +### Changed + +- Extracted avatar slug and avatar directory logic from `tinyagentos/routes/auth.py` into a new `tinyagentos/agent_avatars.py` module, and added `avatar_source_path` and `avatar_hash` helpers. diff --git a/tests/test_agent_avatars.py b/tests/test_agent_avatars.py new file mode 100644 index 000000000..00fa37936 --- /dev/null +++ b/tests/test_agent_avatars.py @@ -0,0 +1,45 @@ +"""Tests for tinyagentos.agent_avatars module (avatar_source_path + avatar_hash).""" +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +from tinyagentos import agent_avatars as aa +from tinyagentos.agent_avatars import avatar_hash, avatar_source_path +from tinyagentos.routes.auth import _avatar_slug + + +class TestAvatarSourcePathAndHash: + + def test_no_jpg_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setattr(aa, "LOCK_AVATAR_DIR", str(tmp_path)) + assert avatar_source_path("Some Agent") is None + assert avatar_hash("Some Agent") is None + + def test_existing_jpg_returns_path_and_hash(self, tmp_path, monkeypatch): + monkeypatch.setattr(aa, "LOCK_AVATAR_DIR", str(tmp_path)) + name = "Some Agent" + slug = _avatar_slug(name) + (tmp_path / f"{slug}.jpg").write_bytes(b"one") + assert avatar_source_path(name) == tmp_path / f"{slug}.jpg" + h = avatar_hash(name) + assert isinstance(h, str) + assert len(h) == 16 + assert h == hashlib.sha256(b"one").hexdigest()[:16] + + def test_hash_changes_when_file_changes(self, tmp_path, monkeypatch): + monkeypatch.setattr(aa, "LOCK_AVATAR_DIR", str(tmp_path)) + name = "Some Agent" + slug = _avatar_slug(name) + p = tmp_path / f"{slug}.jpg" + p.write_bytes(b"one") + first = avatar_hash(name) + p.write_bytes(b"two") + second = avatar_hash(name) + assert first != second + + def test_imported_slug_matches_module_slug(self): + name = "Some Agent" + assert _avatar_slug(name) == aa._avatar_slug(name) diff --git a/tinyagentos/agent_avatars.py b/tinyagentos/agent_avatars.py new file mode 100644 index 000000000..f8ad5182b --- /dev/null +++ b/tinyagentos/agent_avatars.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path + +LOCK_AVATAR_DIR = os.environ.get("TAOS_LOCK_AVATAR_DIR", "/var/lib/taos/lock-avatars") + + +def _avatar_slug(name: str) -> str: + """Slug for an agent name, restricted to characters that cannot traverse. + + Anything outside [a-z0-9-] is dropped rather than escaped: this value is + used to build a filesystem path, so a conservative whitelist is the control + that keeps "../" and absolute paths out, not a sanitiser that tries to spot + bad input. + """ + out = [] + for ch in name.strip().lower(): + if ch.isalnum() and ch.isascii(): + out.append(ch) + elif out and out[-1] != "-": + out.append("-") + return "".join(out).strip("-") + + +def avatar_source_path(name: str) -> Path | None: + """Path to the avatar image file for *name*, or None if it is not installed.""" + path = Path(LOCK_AVATAR_DIR) / f"{_avatar_slug(name)}.jpg" + return path if path.is_file() else None + + +def avatar_hash(name: str) -> str | None: + """SHA-256 hex of the avatar image bytes, first 16 chars, or None.""" + path = avatar_source_path(name) + if path is None: + return None + return hashlib.sha256(path.read_bytes()).hexdigest()[:16] diff --git a/tinyagentos/routes/auth.py b/tinyagentos/routes/auth.py index 4d3720908..7182d3e37 100644 --- a/tinyagentos/routes/auth.py +++ b/tinyagentos/routes/auth.py @@ -27,6 +27,7 @@ Response, StreamingResponse, ) +from tinyagentos.agent_avatars import LOCK_AVATAR_DIR, _avatar_slug from tinyagentos.auth import ( PIN_MAX_LEN, PIN_MIN_LEN, @@ -9603,30 +9604,6 @@ def _attach(agent: dict) -> None: -#: Where lock-screen agent avatars are read from. One flat directory of -#: ".jpg" files, slug being the agent name lowercased with non-alphanumerics -#: collapsed to "-". Overridable so a packaged install can point it at its own -#: data dir rather than this default. -LOCK_AVATAR_DIR = os.environ.get("TAOS_LOCK_AVATAR_DIR", "/var/lib/taos/lock-avatars") - - -def _avatar_slug(name: str) -> str: - """Slug for an agent name, restricted to characters that cannot traverse. - - Anything outside [a-z0-9-] is dropped rather than escaped: this value is - used to build a filesystem path, so a conservative whitelist is the control - that keeps "../" and absolute paths out, not a sanitiser that tries to spot - bad input. - """ - out = [] - for ch in name.strip().lower(): - if ch.isalnum() and ch.isascii(): - out.append(ch) - elif out and out[-1] != "-": - out.append("-") - return "".join(out).strip("-") - - @router.get("/lock-avatar/{slug}") async def lock_avatar(slug: str, request: Request): """Serve one lock-screen avatar. Console-only, same reasoning as the widgets.