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
7 changes: 7 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"psutil>=5.9.0",
"python-multipart>=0.0.9",
# Project canvas board renders low-fidelity PNG snapshots for vision
Expand Down
12 changes: 6 additions & 6 deletions scripts/install-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,26 +45,26 @@ 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
# present, because apt's npm conflicts with NodeSource's bundled npm.
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
Expand Down
Empty file.
148 changes: 148 additions & 0 deletions tests/routes/desktop_browser/test_cookie_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""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()

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).
# 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((sqlcipher.DatabaseError, sqlcipher.OperationalError, MemoryError)):
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")
49 changes: 49 additions & 0 deletions tests/routes/desktop_browser/test_crypto.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +1 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Suppress Ruff S106 for this test module.

This file intentionally feeds literal test passwords into a password= parameter, and Ruff already flags the new hunk repeatedly as S106. If lint gates the PR, CI will fail on this file even though the cases are test-only. A file-level ignore is the lowest-noise fix here.

Suggested fix
+# ruff: noqa: S106
 """Tests for browser-cookie key derivation (Argon2id)."""
 from __future__ import annotations
🧰 Tools
🪛 Ruff (0.15.12)

[error] 11-11: Possible hardcoded password assigned to argument: "password"

(S106)


[error] 21-21: Possible hardcoded password assigned to argument: "password"

(S106)


[error] 22-22: Possible hardcoded password assigned to argument: "password"

(S106)


[error] 28-28: Possible hardcoded password assigned to argument: "password"

(S106)


[error] 29-29: Possible hardcoded password assigned to argument: "password"

(S106)


[error] 35-35: Possible hardcoded password assigned to argument: "password"

(S106)


[error] 36-36: Possible hardcoded password assigned to argument: "password"

(S106)


[error] 43-43: Possible hardcoded password assigned to argument: "password"

(S106)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/desktop_browser/test_crypto.py` around lines 1 - 49, Add a
file-level Ruff suppression for the S106 rule to the top of the test module that
calls derive_cookie_key (tests/routes/desktop_browser/test_crypto.py) so the
literal test passwords don't trigger lint failures; place a noqa comment
immediately after the module docstring (for example a top-line comment like "#
noqa: S106" or the equivalent Ruff file-level disable) to silence S106 while
leaving the tests and derive_cookie_key references unchanged.

71 changes: 71 additions & 0 deletions tests/routes/desktop_browser/test_router_mounted.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""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):
"""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

# 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():
"""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,
])
57 changes: 57 additions & 0 deletions tests/routes/desktop_browser/test_store_schema.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading