From c4384bfba4a0c4cdbc9f90b427929f7502ebaba7 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 3 May 2026 19:53:21 +0100 Subject: [PATCH 01/10] build: add argon2-cffi + sqlcipher3 for browser cookie jar --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e9618c97..5a28699d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "jinja2>=3.1.0", "pyyaml>=6.0", "aiosqlite>=0.20.0", + "argon2-cffi>=23.1.0", + "sqlcipher3>=0.6.2", "psutil>=5.9.0", "python-multipart>=0.0.9", # Project canvas board renders low-fidelity PNG snapshots for vision From ec1555496a5f4e9d8485042505184b1c2b6891ac Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 3 May 2026 19:56:23 +0100 Subject: [PATCH 02/10] docs(getting-started): note system SQLCipher install for browser cookies --- docs/getting-started.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/getting-started.md b/docs/getting-started.md index 26e04395..71c1247b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,6 +37,13 @@ The platform itself uses roughly 345 MB of RAM when idle, so it runs comfortably - **Network:** The device needs internet access to download models and framework packages. - **Browser:** On any other device on the same network (laptop, phone, tablet). The TinyAgentOS web GUI runs on your device; you access it from your browser. +**SQLCipher (for the browser app's encrypted cookie jar)** — the browser app needs the SQLCipher C library installed at the system level before `pip install` can build its `sqlcipher3` Python binding: + +- **macOS:** `brew install sqlcipher` +- **Debian / Ubuntu / Pi OS:** `sudo apt install libsqlcipher-dev` +- **Fedora / RHEL:** `sudo dnf install sqlcipher-devel` +- **Windows:** SQLCipher binaries via [vcpkg](https://vcpkg.io/) or run inside WSL + --- ## 2. Installation From be935c058fc3daefce092f25ddad8d0497774564 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 3 May 2026 20:00:52 +0100 Subject: [PATCH 03/10] feat(browser): add desktop_browser package + SQL schema constants --- .../routes/desktop_browser/__init__.py | 10 ++ tinyagentos/routes/desktop_browser/schema.py | 99 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 tinyagentos/routes/desktop_browser/__init__.py create mode 100644 tinyagentos/routes/desktop_browser/schema.py diff --git a/tinyagentos/routes/desktop_browser/__init__.py b/tinyagentos/routes/desktop_browser/__init__.py new file mode 100644 index 00000000..9ffe94cd --- /dev/null +++ b/tinyagentos/routes/desktop_browser/__init__.py @@ -0,0 +1,10 @@ +"""BrowserApp v2 backend module group. + +Exposes the FastAPI router that future PRs mount routes onto. Stores +live in `store.py`. Schema in `schema.py`. Crypto in `crypto.py`. +""" +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter() diff --git a/tinyagentos/routes/desktop_browser/schema.py b/tinyagentos/routes/desktop_browser/schema.py new file mode 100644 index 00000000..fc204949 --- /dev/null +++ b/tinyagentos/routes/desktop_browser/schema.py @@ -0,0 +1,99 @@ +"""SQL schema constants for the BrowserApp v2 stores. + +Two databases: + +- BROWSER_SCHEMA — applied to the regular SQLite DB (browser.sqlite3). + Holds profiles, history, bookmarks, agent capabilities, push + subscriptions, and persisted browser-window state. + +- COOKIE_SCHEMA — applied to the SQLCipher-encrypted DB + (browser_cookies.sqlite3). Holds the cookie jar. + +Every table keys on user_id for OS-grade multi-user isolation. +""" +from __future__ import annotations + + +BROWSER_SCHEMA = """ +CREATE TABLE IF NOT EXISTS profiles ( + user_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, profile_id) +); + +CREATE TABLE IF NOT EXISTS history ( + user_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + url TEXT NOT NULL, + title TEXT, + visited_at INTEGER NOT NULL, + visit_count INTEGER NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_history_search + ON history (user_id, profile_id, url, title); + +CREATE TABLE IF NOT EXISTS bookmarks ( + user_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + bookmark_id TEXT NOT NULL, + folder_path TEXT NOT NULL DEFAULT '/', + url TEXT NOT NULL, + title TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, profile_id, bookmark_id) +); + +CREATE TABLE IF NOT EXISTS agent_capabilities ( + user_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + host_pattern TEXT NOT NULL, + perms TEXT NOT NULL, + granted_at INTEGER NOT NULL, + PRIMARY KEY (user_id, profile_id, agent_id, host_pattern) +); + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + endpoint TEXT NOT NULL, + p256dh_key TEXT NOT NULL, + auth_key TEXT NOT NULL, + user_agent TEXT, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + PRIMARY KEY (user_id, device_id) +); + +CREATE TABLE IF NOT EXISTS browser_windows ( + user_id TEXT NOT NULL, + window_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + active_tab_id TEXT, + state TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, window_id) +); +""" + + +COOKIE_SCHEMA = """ +CREATE TABLE IF NOT EXISTS cookies ( + user_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + host TEXT NOT NULL, + path TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL, + expires_at INTEGER, + http_only INTEGER NOT NULL, + secure INTEGER NOT NULL, + same_site TEXT, + PRIMARY KEY (user_id, profile_id, host, path, name) +); +CREATE INDEX IF NOT EXISTS idx_cookies_lookup + ON cookies (user_id, profile_id, host); +""" From ddb1ee831c62bb61f25e7b9ca0d6d19913d2c6e5 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 3 May 2026 20:03:51 +0100 Subject: [PATCH 04/10] feat(browser): Argon2id key derivation for cookie DB --- tests/routes/desktop_browser/__init__.py | 0 tests/routes/desktop_browser/test_crypto.py | 49 ++++++++++++++++++++ tinyagentos/routes/desktop_browser/crypto.py | 48 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 tests/routes/desktop_browser/__init__.py create mode 100644 tests/routes/desktop_browser/test_crypto.py create mode 100644 tinyagentos/routes/desktop_browser/crypto.py diff --git a/tests/routes/desktop_browser/__init__.py b/tests/routes/desktop_browser/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/routes/desktop_browser/test_crypto.py b/tests/routes/desktop_browser/test_crypto.py new file mode 100644 index 00000000..ff681eb2 --- /dev/null +++ b/tests/routes/desktop_browser/test_crypto.py @@ -0,0 +1,49 @@ +"""Tests for browser-cookie key derivation (Argon2id).""" +from __future__ import annotations + +import pytest + + +class TestDeriveCookieKey: + def test_returns_64_char_hex_string(self): + from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key + + key = derive_cookie_key(password="hunter2", user_salt=b"u" * 16) + + # SQLCipher needs a 256-bit key, encoded as 64 hex chars + assert isinstance(key, str) + assert len(key) == 64 + assert all(c in "0123456789abcdef" for c in key) + + def test_deterministic_for_same_inputs(self): + from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key + + a = derive_cookie_key(password="hunter2", user_salt=b"u" * 16) + b = derive_cookie_key(password="hunter2", user_salt=b"u" * 16) + assert a == b + + def test_different_passwords_produce_different_keys(self): + from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key + + a = derive_cookie_key(password="hunter2", user_salt=b"u" * 16) + b = derive_cookie_key(password="other", user_salt=b"u" * 16) + assert a != b + + def test_different_salts_produce_different_keys(self): + from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key + + a = derive_cookie_key(password="hunter2", user_salt=b"a" * 16) + b = derive_cookie_key(password="hunter2", user_salt=b"b" * 16) + assert a != b + + def test_rejects_short_salt(self): + from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key + + with pytest.raises(ValueError, match="salt"): + derive_cookie_key(password="hunter2", user_salt=b"short") + + def test_rejects_empty_password(self): + from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key + + with pytest.raises(ValueError, match="password"): + derive_cookie_key(password="", user_salt=b"u" * 16) diff --git a/tinyagentos/routes/desktop_browser/crypto.py b/tinyagentos/routes/desktop_browser/crypto.py new file mode 100644 index 00000000..b189dd71 --- /dev/null +++ b/tinyagentos/routes/desktop_browser/crypto.py @@ -0,0 +1,48 @@ +"""Browser cookie-DB key derivation. + +The cookie database is SQLCipher-encrypted with a 256-bit key. The key +must be reproducible for the same (password, user_salt) pair so the +same user can reopen the DB across sessions, but it must never be +stored on disk in plaintext. + +We use Argon2id (RFC 9106 winner) with parameters tuned for the host +machine: time_cost=3, memory_cost=64 MiB, parallelism=4. These are the +OWASP-recommended baseline for password-derived keys as of 2024 and +take ~150ms on a Pi 5, which is fine because key derivation runs once +per session unlock, not per request. +""" +from __future__ import annotations + +from argon2.low_level import Type, hash_secret_raw + +# OWASP baseline (2024) for Argon2id password-derived keys. +_TIME_COST = 3 +_MEMORY_COST = 64 * 1024 # 64 MiB +_PARALLELISM = 4 +_KEY_BYTES = 32 # 256 bits + +# Per-user salt is generated at user creation and persisted alongside +# the user record. We require >= 16 bytes per RFC 9106. +_MIN_SALT_BYTES = 16 + + +def derive_cookie_key(password: str, user_salt: bytes) -> str: + """Derive a 256-bit SQLCipher key as a 64-char hex string. + + The hex form is what SQLCipher's `PRAGMA key = "x'…'"` expects. + """ + if not password: + raise ValueError("password must be a non-empty string") + if len(user_salt) < _MIN_SALT_BYTES: + raise ValueError(f"user_salt must be at least {_MIN_SALT_BYTES} bytes") + + raw = hash_secret_raw( + secret=password.encode("utf-8"), + salt=user_salt, + time_cost=_TIME_COST, + memory_cost=_MEMORY_COST, + parallelism=_PARALLELISM, + hash_len=_KEY_BYTES, + type=Type.ID, + ) + return raw.hex() From 499b9b6803ded3611da9d9a6b32f153ec1728f96 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 3 May 2026 20:06:34 +0100 Subject: [PATCH 05/10] feat(browser): BrowserStore skeleton with multi-user keyed schema --- .../desktop_browser/test_store_schema.py | 57 +++++++++++++++ tinyagentos/routes/desktop_browser/store.py | 69 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/routes/desktop_browser/test_store_schema.py create mode 100644 tinyagentos/routes/desktop_browser/store.py diff --git a/tests/routes/desktop_browser/test_store_schema.py b/tests/routes/desktop_browser/test_store_schema.py new file mode 100644 index 00000000..8f2cb37e --- /dev/null +++ b/tests/routes/desktop_browser/test_store_schema.py @@ -0,0 +1,57 @@ +"""Tests for BrowserStore — schema applies cleanly, all expected tables exist.""" +from __future__ import annotations + +import sqlite3 + +import pytest +import pytest_asyncio + + +@pytest_asyncio.fixture +async def store(tmp_path): + from tinyagentos.routes.desktop_browser.store import BrowserStore + + s = BrowserStore(tmp_path / "browser.sqlite3") + await s.init() + yield s + await s.close() + + +@pytest.mark.asyncio +class TestBrowserStoreSchema: + async def test_init_creates_db_file(self, store, tmp_path): + assert (tmp_path / "browser.sqlite3").exists() + + async def test_all_tables_exist(self, store, tmp_path): + # Use sync sqlite3 to introspect — cheaper than async for a one-shot read + conn = sqlite3.connect(tmp_path / "browser.sqlite3") + try: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "ORDER BY name" + ).fetchall() + tables = {r[0] for r in rows} + finally: + conn.close() + + expected = { + "profiles", + "history", + "bookmarks", + "agent_capabilities", + "push_subscriptions", + "browser_windows", + } + assert expected <= tables, f"missing: {expected - tables}" + + async def test_idempotent_init(self, tmp_path): + from tinyagentos.routes.desktop_browser.store import BrowserStore + + s1 = BrowserStore(tmp_path / "browser.sqlite3") + await s1.init() + await s1.close() + + # Second init on the same path must not raise + s2 = BrowserStore(tmp_path / "browser.sqlite3") + await s2.init() + await s2.close() diff --git a/tinyagentos/routes/desktop_browser/store.py b/tinyagentos/routes/desktop_browser/store.py new file mode 100644 index 00000000..8fd145bf --- /dev/null +++ b/tinyagentos/routes/desktop_browser/store.py @@ -0,0 +1,69 @@ +"""BrowserApp v2 stores. + +- BrowserStore — regular SQLite, holds profiles/history/bookmarks/caps/push/windows +- BrowserCookieStore — SQLCipher-encrypted, holds cookies; per-user key + +Both stores key every row on user_id for OS-grade multi-user isolation. +The query helpers refuse to operate without a user_id argument. +""" +from __future__ import annotations + +from pathlib import Path + +import aiosqlite + +from tinyagentos.base_store import BaseStore +from tinyagentos.routes.desktop_browser.schema import BROWSER_SCHEMA + + +class BrowserStore(BaseStore): + """Regular SQLite store: profiles, history, bookmarks, capabilities, + push subscriptions, persisted browser-window state. + + Every accessor takes a user_id and refuses to operate without one. + """ + SCHEMA = BROWSER_SCHEMA + + # Profile helpers (just enough for the multi-user tenancy tests in + # Task 8 — the rest of the CRUD lands in PR 3 alongside profile.py). + + async def add_profile( + self, + *, + user_id: str, + profile_id: str, + name: str, + color: str | None = None, + created_at: int, + ) -> None: + if not user_id: + raise ValueError("user_id is required") + if not profile_id: + raise ValueError("profile_id is required") + assert self._db is not None + await self._db.execute( + "INSERT INTO profiles (user_id, profile_id, name, color, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (user_id, profile_id, name, color, created_at), + ) + await self._db.commit() + + async def list_profiles(self, *, user_id: str) -> list[dict]: + if not user_id: + raise ValueError("user_id is required") + assert self._db is not None + cursor = await self._db.execute( + "SELECT profile_id, name, color, created_at " + "FROM profiles WHERE user_id = ? ORDER BY created_at", + (user_id,), + ) + rows = await cursor.fetchall() + return [ + { + "profile_id": r[0], + "name": r[1], + "color": r[2], + "created_at": r[3], + } + for r in rows + ] From b4f0e6f72f9c67547cdc31c8ffa2c893bdfd7ea3 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 3 May 2026 20:08:44 +0100 Subject: [PATCH 06/10] test(browser): multi-user tenancy contract for BrowserStore --- .../desktop_browser/test_store_tenancy.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/routes/desktop_browser/test_store_tenancy.py diff --git a/tests/routes/desktop_browser/test_store_tenancy.py b/tests/routes/desktop_browser/test_store_tenancy.py new file mode 100644 index 00000000..bf4f0b05 --- /dev/null +++ b/tests/routes/desktop_browser/test_store_tenancy.py @@ -0,0 +1,82 @@ +"""Tests proving BrowserStore enforces (user_id, …) isolation between users.""" +from __future__ import annotations + +import sqlite3 as sync_sqlite +import time + +import pytest +import pytest_asyncio + + +@pytest_asyncio.fixture +async def store(tmp_path): + from tinyagentos.routes.desktop_browser.store import BrowserStore + + s = BrowserStore(tmp_path / "browser.sqlite3") + await s.init() + yield s + await s.close() + + +@pytest.mark.asyncio +class TestProfileTenancy: + async def test_user_a_cannot_see_user_b_profiles(self, store): + now = int(time.time()) + await store.add_profile( + user_id="user-a", profile_id="personal", name="Personal", created_at=now, + ) + await store.add_profile( + user_id="user-b", profile_id="personal", name="Personal", created_at=now, + ) + + a_profiles = await store.list_profiles(user_id="user-a") + b_profiles = await store.list_profiles(user_id="user-b") + + assert len(a_profiles) == 1 + assert len(b_profiles) == 1 + # Both users have a profile id of "personal" but they are isolated rows + # in the database — proven by the (user_id, profile_id) primary key. + assert a_profiles[0]["name"] == "Personal" + assert b_profiles[0]["name"] == "Personal" + + async def test_list_profiles_returns_empty_for_new_user(self, store): + # A user who has never written a profile must get an empty list, + # not an error and not other users' rows. Catches regressions + # where the WHERE clause silently fails for empty result sets. + await store.add_profile( + user_id="someone-else", profile_id="personal", + name="Other", created_at=0, + ) + + result = await store.list_profiles(user_id="brand-new-user") + + assert result == [] + + async def test_add_profile_requires_user_id(self, store): + with pytest.raises(ValueError, match="user_id"): + await store.add_profile( + user_id="", profile_id="personal", name="Personal", created_at=0, + ) + + async def test_add_profile_requires_profile_id(self, store): + with pytest.raises(ValueError, match="profile_id"): + await store.add_profile( + user_id="user-a", profile_id="", name="Personal", created_at=0, + ) + + async def test_list_profiles_requires_user_id(self, store): + with pytest.raises(ValueError, match="user_id"): + await store.list_profiles(user_id="") + + async def test_duplicate_profile_in_same_user_rejected(self, store): + # (user_id, profile_id) is a primary key — a second insert with the + # same pair must error + now = int(time.time()) + await store.add_profile( + user_id="user-a", profile_id="personal", name="Personal", created_at=now, + ) + + with pytest.raises(sync_sqlite.IntegrityError): + await store.add_profile( + user_id="user-a", profile_id="personal", name="Personal", created_at=now, + ) From 180cd9d741c43cbcb8a47c719edbe7fa8d6c83c5 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 3 May 2026 20:13:16 +0100 Subject: [PATCH 07/10] feat(browser): SQLCipher-encrypted cookie store with multi-user isolation --- .../desktop_browser/test_cookie_store.py | 142 ++++++++++++++++++ tinyagentos/routes/desktop_browser/store.py | 136 +++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 tests/routes/desktop_browser/test_cookie_store.py diff --git a/tests/routes/desktop_browser/test_cookie_store.py b/tests/routes/desktop_browser/test_cookie_store.py new file mode 100644 index 00000000..3d4c460d --- /dev/null +++ b/tests/routes/desktop_browser/test_cookie_store.py @@ -0,0 +1,142 @@ +"""Tests for BrowserCookieStore — SQLCipher encryption + multi-user isolation.""" +from __future__ import annotations + +import pytest +import pytest_asyncio + + +# A 64-char hex string = 256-bit key. In production this comes from +# derive_cookie_key. Tests use a fixed value so they are deterministic. +TEST_KEY = "a" * 64 +WRONG_KEY = "b" * 64 + + +@pytest_asyncio.fixture +async def cookie_store(tmp_path): + from tinyagentos.routes.desktop_browser.store import BrowserCookieStore + + s = BrowserCookieStore(tmp_path / "browser_cookies.sqlite3", key_hex=TEST_KEY) + await s.init() + yield s + await s.close() + + +@pytest.mark.asyncio +class TestCookieStoreEncryption: + async def test_db_is_not_readable_with_wrong_key(self, tmp_path): + from tinyagentos.routes.desktop_browser.store import BrowserCookieStore + + # Create + close + s = BrowserCookieStore(tmp_path / "c.sqlite3", key_hex=TEST_KEY) + await s.init() + await s.set_cookie( + user_id="u1", profile_id="p1", + host="x.test", path="/", name="sid", value="abc", + expires_at=None, http_only=True, secure=True, same_site="lax", + ) + await s.close() + + # Reopen with wrong key — read must fail (init may also fail + # depending on SQLCipher version; either way, the data must + # not be retrievable with the wrong key) + s2 = BrowserCookieStore(tmp_path / "c.sqlite3", key_hex=WRONG_KEY) + with pytest.raises(Exception): + try: + await s2.init() + except Exception: + # init() rejected the wrong key — that's an acceptable failure mode. + # Re-raise so the outer `pytest.raises` records it. + raise + # If init() succeeded silently, the read MUST fail + await s2.get_cookies(user_id="u1", profile_id="p1", host="x.test") + + async def test_rejects_non_hex_key(self, tmp_path): + from tinyagentos.routes.desktop_browser.store import BrowserCookieStore + + # 64 chars but not valid hex + with pytest.raises(ValueError, match="hex"): + BrowserCookieStore(tmp_path / "c.sqlite3", key_hex="z" * 64) + + async def test_rejects_short_key(self, tmp_path): + from tinyagentos.routes.desktop_browser.store import BrowserCookieStore + + with pytest.raises(ValueError, match="64 hex chars"): + BrowserCookieStore(tmp_path / "c.sqlite3", key_hex="abc") + + async def test_raw_file_is_not_plaintext(self, tmp_path): + from tinyagentos.routes.desktop_browser.store import BrowserCookieStore + + s = BrowserCookieStore(tmp_path / "c.sqlite3", key_hex=TEST_KEY) + await s.init() + await s.set_cookie( + user_id="u1", profile_id="p1", + host="x.test", path="/", name="sid", value="MY-SECRET-VALUE", + expires_at=None, http_only=True, secure=True, same_site="lax", + ) + await s.close() + + raw = (tmp_path / "c.sqlite3").read_bytes() + # The plain cookie value must not appear in the on-disk file + assert b"MY-SECRET-VALUE" not in raw + + +@pytest.mark.asyncio +class TestCookieStoreCRUD: + async def test_set_and_get_single_cookie(self, cookie_store): + await cookie_store.set_cookie( + user_id="u1", profile_id="p1", + host="github.com", path="/", name="user_session", value="xyz", + expires_at=None, http_only=True, secure=True, same_site="lax", + ) + result = await cookie_store.get_cookies( + user_id="u1", profile_id="p1", host="github.com", + ) + assert len(result) == 1 + assert result[0]["name"] == "user_session" + assert result[0]["value"] == "xyz" + + async def test_user_isolation(self, cookie_store): + # u1 sets a github.com cookie; u2 must not see it + await cookie_store.set_cookie( + user_id="u1", profile_id="p1", + host="github.com", path="/", name="user_session", value="from-u1", + expires_at=None, http_only=True, secure=True, same_site="lax", + ) + u2_cookies = await cookie_store.get_cookies( + user_id="u2", profile_id="p1", host="github.com", + ) + assert u2_cookies == [] + + async def test_profile_isolation(self, cookie_store): + # Same user, two profiles, must not cross + await cookie_store.set_cookie( + user_id="u1", profile_id="personal", + host="github.com", path="/", name="user_session", value="personal-token", + expires_at=None, http_only=True, secure=True, same_site="lax", + ) + await cookie_store.set_cookie( + user_id="u1", profile_id="work", + host="github.com", path="/", name="user_session", value="work-token", + expires_at=None, http_only=True, secure=True, same_site="lax", + ) + + personal = await cookie_store.get_cookies( + user_id="u1", profile_id="personal", host="github.com", + ) + work = await cookie_store.get_cookies( + user_id="u1", profile_id="work", host="github.com", + ) + assert personal[0]["value"] == "personal-token" + assert work[0]["value"] == "work-token" + + async def test_set_cookie_requires_user_id(self, cookie_store): + with pytest.raises(ValueError, match="user_id"): + await cookie_store.set_cookie( + user_id="", profile_id="p1", + host="x.test", path="/", name="n", value="v", + expires_at=None, http_only=True, secure=True, same_site=None, + ) + + async def test_get_cookies_requires_user_id(self, cookie_store): + with pytest.raises(ValueError, match="user_id"): + await cookie_store.get_cookies(user_id="", profile_id="p1", host="x.test") diff --git a/tinyagentos/routes/desktop_browser/store.py b/tinyagentos/routes/desktop_browser/store.py index 8fd145bf..e31eacce 100644 --- a/tinyagentos/routes/desktop_browser/store.py +++ b/tinyagentos/routes/desktop_browser/store.py @@ -67,3 +67,139 @@ async def list_profiles(self, *, user_id: str) -> list[dict]: } for r in rows ] + + +class BrowserCookieStore: + """SQLCipher-encrypted cookie store. Per-user 256-bit key. + + Distinct from BaseStore because aiosqlite can't drive sqlcipher3 + natively. We use the sync sqlcipher3 driver inside an asyncio + executor — cookie operations are infrequent enough that the executor + cost is acceptable, and SQLCipher's GIL release on I/O keeps it + concurrent-friendly in practice. + """ + + def __init__(self, db_path: Path, *, key_hex: str): + if len(key_hex) != 64: + raise ValueError("key_hex must be 64 hex chars (256-bit key)") + try: + bytes.fromhex(key_hex) + except ValueError as e: + raise ValueError("key_hex must contain only hex characters") from e + self.db_path = db_path + self._key_hex = key_hex + self._initialised = False + + async def init(self) -> None: + import asyncio + from tinyagentos.routes.desktop_browser.schema import COOKIE_SCHEMA + + def _setup() -> None: + from sqlcipher3 import dbapi2 as sqlcipher + + self.db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlcipher.connect(str(self.db_path)) + try: + # SQLCipher key — hex form requires the x'…' wrapper + conn.execute(f"PRAGMA key = \"x'{self._key_hex}'\";") + conn.executescript(COOKIE_SCHEMA) + conn.commit() + finally: + conn.close() + + await asyncio.get_event_loop().run_in_executor(None, _setup) + self._initialised = True + + async def close(self) -> None: + # Each operation opens + closes its own connection; nothing persistent. + self._initialised = False + + def _connect(self): + from sqlcipher3 import dbapi2 as sqlcipher + + conn = sqlcipher.connect(str(self.db_path)) + conn.execute(f"PRAGMA key = \"x'{self._key_hex}'\";") + return conn + + async def set_cookie( + self, + *, + user_id: str, + profile_id: str, + host: str, + path: str, + name: str, + value: str, + expires_at: int | None, + http_only: bool, + secure: bool, + same_site: str | None, + ) -> None: + if not user_id: + raise ValueError("user_id is required") + if not profile_id: + raise ValueError("profile_id is required") + + import asyncio + + def _do() -> None: + conn = self._connect() + try: + conn.execute( + "INSERT OR REPLACE INTO cookies " + "(user_id, profile_id, host, path, name, value, " + " expires_at, http_only, secure, same_site) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + user_id, profile_id, host, path, name, value, + expires_at, int(http_only), int(secure), same_site, + ), + ) + conn.commit() + finally: + conn.close() + + await asyncio.get_event_loop().run_in_executor(None, _do) + + async def get_cookies( + self, + *, + user_id: str, + profile_id: str, + host: str, + ) -> list[dict]: + if not user_id: + raise ValueError("user_id is required") + if not profile_id: + raise ValueError("profile_id is required") + + import asyncio + + def _do() -> list[dict]: + conn = self._connect() + try: + cursor = conn.execute( + "SELECT host, path, name, value, expires_at, " + " http_only, secure, same_site " + "FROM cookies " + "WHERE user_id = ? AND profile_id = ? AND host = ?", + (user_id, profile_id, host), + ) + rows = cursor.fetchall() + return [ + { + "host": r[0], + "path": r[1], + "name": r[2], + "value": r[3], + "expires_at": r[4], + "http_only": bool(r[5]), + "secure": bool(r[6]), + "same_site": r[7], + } + for r in rows + ] + finally: + conn.close() + + return await asyncio.get_event_loop().run_in_executor(None, _do) From dd0fbac8c1b3432152b0c5c7ba659ac8c476e9ed Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 3 May 2026 20:18:06 +0100 Subject: [PATCH 08/10] feat(browser): mount desktop_browser router into FastAPI app (no routes yet) --- .../desktop_browser/test_router_mounted.py | 43 +++++++++++++++++++ tinyagentos/app.py | 3 ++ 2 files changed, 46 insertions(+) create mode 100644 tests/routes/desktop_browser/test_router_mounted.py diff --git a/tests/routes/desktop_browser/test_router_mounted.py b/tests/routes/desktop_browser/test_router_mounted.py new file mode 100644 index 00000000..276bef99 --- /dev/null +++ b/tests/routes/desktop_browser/test_router_mounted.py @@ -0,0 +1,43 @@ +"""Verify the desktop_browser router is mounted on the FastAPI app. + +PR 1 mounts an empty router so future PRs can add routes against an +established prefix. This test only checks the router object reaches the +app — it does not exercise any endpoints. +""" +from __future__ import annotations + + +def test_desktop_browser_router_present_in_app(app): + from tinyagentos.routes.desktop_browser import router as browser_router + + # Each include_router call wraps the router in a Mount or copies its + # routes into the app. Easiest check: the app's route paths include + # nothing for this prefix yet, but the router object has been imported + # without error and is wired in (see app.py change below). The + # router is empty in PR 1, so we just assert the import works and the + # FastAPI app has been built successfully (the fixture proves that). + assert browser_router is not None + assert app is not None + + +def test_desktop_browser_module_importable(): + """Defensive import test — catches packaging mistakes early.""" + from tinyagentos.routes.desktop_browser import router + from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key + from tinyagentos.routes.desktop_browser.schema import ( + BROWSER_SCHEMA, + COOKIE_SCHEMA, + ) + from tinyagentos.routes.desktop_browser.store import ( + BrowserCookieStore, + BrowserStore, + ) + + assert all([ + router, + derive_cookie_key, + BROWSER_SCHEMA, + COOKIE_SCHEMA, + BrowserCookieStore, + BrowserStore, + ]) diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 07380c3e..70434354 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -915,6 +915,9 @@ async def _reload_llm_proxy_on_catalog_change() -> None: from tinyagentos.routes.secrets import router as secrets_router app.include_router(secrets_router) + from tinyagentos.routes.desktop_browser import router as desktop_browser_router + app.include_router(desktop_browser_router) + from tinyagentos.routes.channels import router as channels_router app.include_router(channels_router) From b09b359157519d0ec0b43f73c9fb2b04248dea2a Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 3 May 2026 20:30:47 +0100 Subject: [PATCH 09/10] refactor(browser): drop unused aiosqlite import in store.py --- tinyagentos/routes/desktop_browser/store.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tinyagentos/routes/desktop_browser/store.py b/tinyagentos/routes/desktop_browser/store.py index e31eacce..df5bb20c 100644 --- a/tinyagentos/routes/desktop_browser/store.py +++ b/tinyagentos/routes/desktop_browser/store.py @@ -10,8 +10,6 @@ from pathlib import Path -import aiosqlite - from tinyagentos.base_store import BaseStore from tinyagentos.routes.desktop_browser.schema import BROWSER_SCHEMA From 42688e48dad09559cac3ed8349053d4a3470a61d Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 3 May 2026 20:53:41 +0100 Subject: [PATCH 10/10] refactor(browser): address PR review feedback (PR 1 follow-up) Combined follow-up addressing CodeRabbit + final-review notes: - install-server.sh: add libsqlcipher-dev / sqlcipher-devel / sqlcipher across apt / dnf / pacman / apk / brew so fresh Linux/macOS installs don't break on the new sqlcipher3 dep. - store.py: replace deprecated asyncio.get_event_loop() with asyncio.get_running_loop() at all 3 executor call sites. - test_cookie_store.py: narrow pytest.raises(Exception) in the wrong-key test to (DatabaseError, OperationalError, MemoryError) so generic regressions don't pass silently. MemoryError added as it is the observed failure mode on sqlcipher3 0.5.x / Python 3.14 when key is wrong (corrupted page allocation). - test_router_mounted.py: upgrade the trivial assert-not-None router test to a route-set subset check that will catch deletion of the include_router call once PR 2 adds real routes. --- scripts/install-server.sh | 12 ++--- .../desktop_browser/test_cookie_store.py | 10 ++++- .../desktop_browser/test_router_mounted.py | 44 +++++++++++++++---- tinyagentos/routes/desktop_browser/store.py | 6 +-- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/scripts/install-server.sh b/scripts/install-server.sh index b43ba83b..c7771c67 100644 --- a/scripts/install-server.sh +++ b/scripts/install-server.sh @@ -45,7 +45,7 @@ log "install_dir=$INSTALL_DIR branch=$BRANCH port=$TAOS_PORT qmd_port=$TAOS_QMD_ ensure_linux_deps() { if command -v apt-get >/dev/null 2>&1; then - log "installing apt deps (python3, venv, git, curl, libtorrent, sqlite3)" + log "installing apt deps (python3, venv, git, curl, libtorrent, sqlite3, sqlcipher)" # nodejs/npm are intentionally excluded here: ensure_node22() installs # Node 22 via NodeSource immediately after. Including apt's nodejs/npm # causes "held broken packages" when NodeSource's nodejs is already @@ -53,18 +53,18 @@ ensure_linux_deps() { sudo apt-get update -qq sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ python3 python3-venv python3-pip git curl ca-certificates \ - libtorrent-rasterbar-dev libboost-python-dev sqlite3 + libtorrent-rasterbar-dev libboost-python-dev sqlite3 libsqlcipher-dev elif command -v dnf >/dev/null 2>&1; then - log "installing dnf deps (python3, git, curl, libtorrent, nodejs)" + log "installing dnf deps (python3, git, curl, libtorrent, nodejs, sqlcipher)" sudo dnf install -y -q python3 python3-pip python3-virtualenv git curl \ - libtorrent-rasterbar-devel boost-python3-devel sqlite nodejs npm + libtorrent-rasterbar-devel boost-python3-devel sqlite nodejs npm sqlcipher-devel elif command -v pacman >/dev/null 2>&1; then log "installing pacman deps" sudo pacman -Sy --noconfirm --needed python python-pip git curl \ - libtorrent-rasterbar boost sqlite nodejs npm + libtorrent-rasterbar boost sqlite nodejs npm sqlcipher elif command -v apk >/dev/null 2>&1; then log "installing apk deps" - sudo apk add --no-cache python3 py3-pip git curl libtorrent-rasterbar sqlite nodejs npm + sudo apk add --no-cache python3 py3-pip git curl libtorrent-rasterbar sqlite nodejs npm sqlcipher-dev else warn "unrecognised package manager — assuming python3/git/curl/libtorrent/nodejs already present" fi diff --git a/tests/routes/desktop_browser/test_cookie_store.py b/tests/routes/desktop_browser/test_cookie_store.py index 3d4c460d..f90f83d2 100644 --- a/tests/routes/desktop_browser/test_cookie_store.py +++ b/tests/routes/desktop_browser/test_cookie_store.py @@ -36,11 +36,17 @@ async def test_db_is_not_readable_with_wrong_key(self, tmp_path): ) await s.close() + from sqlcipher3 import dbapi2 as sqlcipher + # Reopen with wrong key — read must fail (init may also fail # depending on SQLCipher version; either way, the data must - # not be retrievable with the wrong key) + # not be retrievable with the wrong key). + # Failure modes vary by SQLCipher version: + # - DatabaseError / OperationalError: explicit key rejection + # - MemoryError: corrupted page allocation when key is wrong + # (observed on sqlcipher3 0.5.x / Python 3.14) s2 = BrowserCookieStore(tmp_path / "c.sqlite3", key_hex=WRONG_KEY) - with pytest.raises(Exception): + with pytest.raises((sqlcipher.DatabaseError, sqlcipher.OperationalError, MemoryError)): try: await s2.init() except Exception: diff --git a/tests/routes/desktop_browser/test_router_mounted.py b/tests/routes/desktop_browser/test_router_mounted.py index 276bef99..825e1f37 100644 --- a/tests/routes/desktop_browser/test_router_mounted.py +++ b/tests/routes/desktop_browser/test_router_mounted.py @@ -8,16 +8,44 @@ def test_desktop_browser_router_present_in_app(app): + """Verify create_app() actually mounted the desktop_browser router. + + The router is empty in PR 1 (PR 2+ adds routes), so we can't check + for specific paths yet. What we CAN check: the router object reachable + via `from tinyagentos.routes.desktop_browser import router` is the same + object that was passed to one of the app's `include_router` calls. + The cleanest detection is: `router.routes` (empty in PR 1) is iterable, + and after `include_router`, FastAPI copies those routes (currently zero) + into `app.routes`. Without `include_router` the test is no weaker than + the import test below — but with it, future PRs that add routes to the + desktop_browser router will see them appear in `app.routes`, which gives + us a regression marker for "someone deleted the include_router call". + + For PR 1 (empty router), we assert that the router instance has a + `.routes` attribute (FastAPI APIRouter contract) and that the app + accepts our import without error — combined with the fact that PR 2+ + will add routes that will then appear in app.routes, this gives a + real upgrade path for the assertion. + """ from tinyagentos.routes.desktop_browser import router as browser_router - # Each include_router call wraps the router in a Mount or copies its - # routes into the app. Easiest check: the app's route paths include - # nothing for this prefix yet, but the router object has been imported - # without error and is wired in (see app.py change below). The - # router is empty in PR 1, so we just assert the import works and the - # FastAPI app has been built successfully (the fixture proves that). - assert browser_router is not None - assert app is not None + # APIRouter contract: must have a .routes attribute that is a list + assert hasattr(browser_router, "routes") + assert isinstance(browser_router.routes, list) + + # The app must have been built without error — the fixture proves that. + # When PR 2 adds a route to browser_router, the assertion below will + # become non-vacuous because every router-route will be reflected in + # app.routes after include_router runs. + browser_route_paths = { + getattr(r, "path", None) for r in browser_router.routes + } + app_route_paths = {getattr(r, "path", None) for r in app.routes} + + # In PR 1 both sets are likely empty for browser_router, so the + # assertion is trivially true. In PR 2+ this will catch deletion + # of the include_router call. + assert browser_route_paths.issubset(app_route_paths) def test_desktop_browser_module_importable(): diff --git a/tinyagentos/routes/desktop_browser/store.py b/tinyagentos/routes/desktop_browser/store.py index df5bb20c..da817ced 100644 --- a/tinyagentos/routes/desktop_browser/store.py +++ b/tinyagentos/routes/desktop_browser/store.py @@ -105,7 +105,7 @@ def _setup() -> None: finally: conn.close() - await asyncio.get_event_loop().run_in_executor(None, _setup) + await asyncio.get_running_loop().run_in_executor(None, _setup) self._initialised = True async def close(self) -> None: @@ -157,7 +157,7 @@ def _do() -> None: finally: conn.close() - await asyncio.get_event_loop().run_in_executor(None, _do) + await asyncio.get_running_loop().run_in_executor(None, _do) async def get_cookies( self, @@ -200,4 +200,4 @@ def _do() -> list[dict]: finally: conn.close() - return await asyncio.get_event_loop().run_in_executor(None, _do) + return await asyncio.get_running_loop().run_in_executor(None, _do)