From ad0f4522e78a7eb6033b8a17adc475dd63c34e1a Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 5 Oct 2026 02:29:37 +0000 Subject: [PATCH] tsk-aa5qck: extract avatar slug + dir into agent_avatars module RED-FIRST proof: ```text ==================================== ERRORS ==================================== _________________ ERROR collecting tests/test_agent_avatars.py _________________ ImportError while importing test module '/tmp/exec-tsk-aa5qck/tests/test_agent_avatars.py'. Hint: make sure your test modules/packages have valid Python names. Traceback (most recent call last): File "/home/jay/.local/share/uv/python/cpython-3.14.7-linux-x86_64-gnu/lib/python3.14/importlib/__init__.py", line 88, in _gcd_import module = _bootstrap._gcd_import(name, package, level) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ tests/test_agent_avatars.py:9: in from tinyagentos.agent_avatars import avatar_hash, avatar_source_path E ModuleNotFoundError: No module named 'tinyagentos.agent_avatars' !!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! 1 error in 0.32s ``` Green after fix: ```text ==================================== 4 passed in 0.24s ==================================== ``` Demo mode and lock-widgets tests also green: ```text ==================================== 17 passed in 22.48s ==================================== ... ........................................................................ [ 60%] ............................................... [100%] 119 passed in 43.90s ``` Docs-Reviewed: module extraction only, no route or user-facing behavior change, docs/agent-coordination.md not affected --- .../tsk-aa5qck-agent-avatars-module.md | 3 ++ tests/test_agent_avatars.py | 45 +++++++++++++++++++ tinyagentos/agent_avatars.py | 38 ++++++++++++++++ tinyagentos/routes/auth.py | 25 +---------- 4 files changed, 87 insertions(+), 24 deletions(-) create mode 100644 changelog.d/tsk-aa5qck-agent-avatars-module.md create mode 100644 tests/test_agent_avatars.py create mode 100644 tinyagentos/agent_avatars.py 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.