Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/tsk-aa5qck-agent-avatars-module.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions tests/test_agent_avatars.py
Original file line number Diff line number Diff line change
@@ -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)
38 changes: 38 additions & 0 deletions tinyagentos/agent_avatars.py
Original file line number Diff line number Diff line change
@@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Missing docstring for LOCK_AVATAR_DIR

The #: Sphinx-style docstring that explained its purpose and overridability was dropped when the definitions were moved from auth.py. This constant is environment-overridable and its documentation should travel with it.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.



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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Unhandled OSError in avatar_hash

path.read_bytes() can raise OSError (e.g., FileNotFoundError) if the file is deleted between avatar_source_path's is_file() check and the read. The original lock_avatar route in auth.py handled this with try/except OSError.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

25 changes: 1 addition & 24 deletions tinyagentos/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -9603,30 +9604,6 @@ def _attach(agent: dict) -> None:



#: Where lock-screen agent avatars are read from. One flat directory of
#: "<slug>.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.
Expand Down
Loading