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
250 changes: 250 additions & 0 deletions tests/userspace/test_seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
"""Tests for the P4a boot-seeding mechanism and the bundled taos-welcome app.

Covers:
- seed installs taos-welcome as first-party
- re-running seed is idempotent (no duplicate row, trust unchanged, version unchanged)
- bumping the bundled version causes a re-seed
- the seeded bundle is served by the bundle route with a first-party CSP
- the real bundled welcome app seeds correctly end-to-end
"""
from __future__ import annotations

import io
import zipfile
from pathlib import Path

import pytest
import pytest_asyncio

from tinyagentos.userspace.data_store import UserspaceDataStore
from tinyagentos.userspace.seed import seed_bundled_apps, _DEFAULT_SEED_DIR
from tinyagentos.userspace.store import UserspaceAppStore


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _write_app(seed_dir: Path, app_id: str, version: str = "1.0.0") -> Path:
"""Write a minimal valid app directory under seed_dir/{app_id}."""
app_dir = seed_dir / app_id
app_dir.mkdir(parents=True, exist_ok=True)
(app_dir / "manifest.yaml").write_text(
f"id: {app_id}\nname: Test App\nversion: {version}\n"
"app_type: web\nentry: index.html\nicon: \"\"\npermissions:\n - app.kv\n"
)
(app_dir / "index.html").write_text("<h1>hello</h1>")
return app_dir


async def _make_store(tmp_path: Path) -> UserspaceAppStore:
store = UserspaceAppStore(tmp_path / "userspace_apps.db")
await store.init()
return store


# ---------------------------------------------------------------------------
# Core seeding behaviour
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_seed_installs_app_as_first_party(tmp_path):
seed_dir = tmp_path / "seed"
apps_root = tmp_path / "apps"
_write_app(seed_dir, "my-app")
store = await _make_store(tmp_path)

await seed_bundled_apps(store, apps_root, seed_dir)

row = await store.get("my-app")
assert row is not None
assert row["trust"] == "first-party"
assert row["version"] == "1.0.0"
assert row["app_type"] == "web"
assert "app.kv" in row["permissions_requested"]
await store.close()


@pytest.mark.asyncio
async def test_seed_idempotent_same_version(tmp_path):
"""Running seed twice with the same version must not duplicate or change the row."""
seed_dir = tmp_path / "seed"
apps_root = tmp_path / "apps"
_write_app(seed_dir, "my-app", version="1.0.0")
store = await _make_store(tmp_path)

await seed_bundled_apps(store, apps_root, seed_dir)
first = await store.get("my-app")
installed_at_first = first["installed_at"]

await seed_bundled_apps(store, apps_root, seed_dir)
second = await store.get("my-app")

# Only one row (get still works), trust is unchanged.
assert second["trust"] == "first-party"
assert second["version"] == "1.0.0"
# installed_at must not have changed on the idempotent run.
assert second["installed_at"] == installed_at_first

# Listing must show exactly one entry for this app.
all_apps = await store.list_installed()
matching = [a for a in all_apps if a["app_id"] == "my-app"]
assert len(matching) == 1
await store.close()


@pytest.mark.asyncio
async def test_seed_reseeds_on_version_bump(tmp_path):
"""When the bundled version changes, seeding updates the installed version."""
seed_dir = tmp_path / "seed"
apps_root = tmp_path / "apps"
_write_app(seed_dir, "my-app", version="1.0.0")
store = await _make_store(tmp_path)

await seed_bundled_apps(store, apps_root, seed_dir)
assert (await store.get("my-app"))["version"] == "1.0.0"

# Bump the bundled version.
(seed_dir / "my-app" / "manifest.yaml").write_text(
"id: my-app\nname: Test App\nversion: 2.0.0\n"
"app_type: web\nentry: index.html\nicon: \"\"\npermissions:\n - app.kv\n"
)
await seed_bundled_apps(store, apps_root, seed_dir)

row = await store.get("my-app")
assert row["version"] == "2.0.0"
assert row["trust"] == "first-party"
await store.close()


@pytest.mark.asyncio
async def test_seed_missing_seed_dir_is_silent(tmp_path):
"""A non-existent seed_dir must not raise."""
apps_root = tmp_path / "apps"
store = await _make_store(tmp_path)
await seed_bundled_apps(store, apps_root, tmp_path / "nonexistent")
assert await store.list_installed() == []
await store.close()


# ---------------------------------------------------------------------------
# Bundle route -- first-party CSP for seeded app
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_seeded_app_served_with_first_party_csp(client, app, tmp_path):
"""The bundle route must return the first-party CSP for a seeded app."""
seed_dir = tmp_path / "seed"
apps_root = tmp_path / "apps"
_write_app(seed_dir, "fp-app")
store = app.state.userspace_apps

await seed_bundled_apps(store, apps_root, seed_dir)

r = await client.get("/api/userspace-apps/fp-app/bundle/index.html")
assert r.status_code == 200
csp = r.headers.get("content-security-policy", "")
# Must still sandbox (no allow-same-origin -- critical security invariant).
assert "sandbox" in csp
assert "allow-same-origin" not in csp
assert "default-src 'none'" in csp


# ---------------------------------------------------------------------------
# Real bundled welcome app
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_real_welcome_app_seeds(tmp_path):
"""The actual bundled taos-welcome app seeds correctly as first-party."""
apps_root = tmp_path / "apps"
store = await _make_store(tmp_path)

await seed_bundled_apps(store, apps_root) # uses _DEFAULT_SEED_DIR

row = await store.get("taos-welcome")
assert row is not None, "taos-welcome not found after seeding"
assert row["trust"] == "first-party"
assert row["version"] == "1.0.0"
assert "app.kv" in row["permissions_requested"]
assert "app.notify" in row["permissions_requested"]
await store.close()


@pytest.mark.asyncio
async def test_real_welcome_app_is_idempotent(tmp_path):
"""Seeding the real welcome app twice must not change the record."""
apps_root = tmp_path / "apps"
store = await _make_store(tmp_path)

await seed_bundled_apps(store, apps_root)
first = await store.get("taos-welcome")

await seed_bundled_apps(store, apps_root)
second = await store.get("taos-welcome")

assert first["installed_at"] == second["installed_at"]
assert second["trust"] == "first-party"
await store.close()


@pytest.mark.asyncio
async def test_real_welcome_bundle_served_with_first_party_csp(client, app, tmp_path):
"""The bundle route serves the real welcome app with first-party CSP."""
apps_root = tmp_path / "apps"
store = app.state.userspace_apps

await seed_bundled_apps(store, apps_root)

r = await client.get("/api/userspace-apps/taos-welcome/bundle/index.html")
assert r.status_code == 200
csp = r.headers.get("content-security-policy", "")
assert "sandbox" in csp
assert "allow-same-origin" not in csp
assert "default-src 'none'" in csp


@pytest.mark.asyncio
async def test_seed_reseeds_non_first_party_id(tmp_path):
"""A community row claiming a seeded id (even at the same version) is re-seeded
to first-party, not skipped by a version-only idempotency check."""
seed_dir = tmp_path / "seed"
apps_root = tmp_path / "apps"
_write_app(seed_dir, "my-app", version="1.0.0")
store = await _make_store(tmp_path)
await store.install(app_id="my-app", name="Impostor", version="1.0.0",
app_type="web", entry="index.html", icon="",
permissions_requested=[], trust="community")
assert (await store.get("my-app"))["trust"] == "community"

await seed_bundled_apps(store, apps_root, seed_dir)

assert (await store.get("my-app"))["trust"] == "first-party"
await store.close()


@pytest.mark.asyncio
async def test_seed_reseed_removes_stale_files(tmp_path):
"""A version bump removes files that no longer exist in the new bundle."""
seed_dir = tmp_path / "seed"
apps_root = tmp_path / "apps"
app_dir = _write_app(seed_dir, "my-app", version="1.0.0")
(app_dir / "old.js").write_text("// v1 only")
store = await _make_store(tmp_path)

await seed_bundled_apps(store, apps_root, seed_dir)
assert (apps_root / "my-app" / "old.js").exists()

(app_dir / "old.js").unlink()
(app_dir / "manifest.yaml").write_text(
"id: my-app\nname: Test App\nversion: 2.0.0\n"
"app_type: web\nentry: index.html\nicon: \"\"\npermissions:\n - app.kv\n"
)
await seed_bundled_apps(store, apps_root, seed_dir)

assert (await store.get("my-app"))["version"] == "2.0.0"
assert not (apps_root / "my-app" / "old.js").exists()
await store.close()
5 changes: 5 additions & 0 deletions tinyagentos/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,11 @@ async def lifespan(app: FastAPI):
app.state.userspace_apps = userspace_apps
await userspace_data.init()
app.state.userspace_data = userspace_data
try:
from tinyagentos.userspace.seed import seed_bundled_apps
await seed_bundled_apps(userspace_apps, data_dir / "apps")
except Exception:
logger.warning("bundled app seeding failed", exc_info=True)
await skills.init()
await themes.init()
app.state.themes = themes
Expand Down
86 changes: 86 additions & 0 deletions tinyagentos/userspace/seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Boot-seeding for first-party bundled .taosapp packages.

Call seed_bundled_apps once during the lifespan, after the userspace store and
apps_root directory are ready. It is idempotent: it only (re)seeds an app when
the entry is missing or the stored version differs from the bundled version.
"""
from __future__ import annotations

import io
import logging
import shutil
import zipfile
from pathlib import Path

from tinyagentos.userspace.package import extract_package, PackageError, parse_manifest

logger = logging.getLogger(__name__)

# Location of the bundled seed apps, relative to this file.
_DEFAULT_SEED_DIR = Path(__file__).resolve().parent / "seed"


def _build_zip_from_dir(source_dir: Path) -> bytes:
"""Build an in-memory .taosapp zip from all files in source_dir."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in sorted(source_dir.rglob("*")):
if path.is_file():
zf.write(path, path.relative_to(source_dir))
return buf.getvalue()


async def seed_bundled_apps(store, apps_root: Path, seed_dir: Path | None = None) -> None:
"""Seed every subdirectory under seed_dir that contains a manifest.yaml.

For each such directory:
1. Parse the manifest to get id + version.
2. Skip if the app is already installed at the same version.
3. Otherwise build a .taosapp zip in memory, extract it, then call
store.install(..., trust="first-party").

All errors for a single app are caught and logged; they do not abort seeding
of subsequent apps or crash startup.
"""
if seed_dir is None:
seed_dir = _DEFAULT_SEED_DIR
seed_dir = Path(seed_dir)
if not seed_dir.is_dir():
logger.debug("seed_dir %s does not exist, skipping", seed_dir)
return

for app_dir in sorted(seed_dir.iterdir()):
manifest_path = app_dir / "manifest.yaml"
if not app_dir.is_dir() or not manifest_path.exists():
continue
try:
manifest = parse_manifest(manifest_path.read_text("utf-8"))
app_id = manifest["id"]
version = manifest["version"]

existing = await store.get(app_id)
if (existing is not None
and existing.get("version") == version
and existing.get("trust") == "first-party"):
logger.debug("bundled app %s v%s already installed first-party, skipping", app_id, version)
continue

# Re-seed (new app, version bump, or a non-first-party row claiming
# this id): remove any previously extracted files first so a smaller
# new version cannot inherit stale files from the old one, then extract.
shutil.rmtree(apps_root / app_id, ignore_errors=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: Validate app_id before deleting the target bundle directory

app_id comes from the seed manifest and is used directly in shutil.rmtree(apps_root / app_id). A manifest declaring an absolute or traversal id can delete outside apps_root before extract_package has a chance to reject the unsafe path. This also deletes the existing bundle before extraction succeeds, so a bad package can leave the app broken.

Validate/resolve the target path before deletion, and prefer extracting to a temporary directory and swapping only after success.

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

zip_bytes = _build_zip_from_dir(app_dir)
extract_package(zip_bytes, apps_root)
Comment on lines +72 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Edge Case: Version-bump re-seed leaves stale files from old version

On a version bump, extract_package is called again for the same app and uses app_dir.mkdir(parents=True, exist_ok=True) then writes the new zip's files on top of the existing directory. Files that existed in the previous bundled version but were removed in the new version are never deleted, so stale assets accumulate in the app directory across version bumps. For the current single-file welcome app this is harmless, but as reference apps grow this can serve dead/obsolete files. Consider clearing the destination directory before extracting a new version (or extracting to a fresh temp dir and swapping) so the on-disk bundle exactly matches the new package.

Was this helpful? React with 👍 / 👎

await store.install(
app_id=app_id,
name=manifest["name"],
version=version,
app_type=manifest["app_type"],
entry=manifest.get("entry", "index.html"),
icon=manifest.get("icon", ""),
permissions_requested=manifest.get("permissions", []),
trust="first-party",
)
logger.info("seeded bundled app %s v%s", app_id, version)
except (PackageError, Exception):
logger.warning("failed to seed bundled app in %s", app_dir, exc_info=True)
Comment on lines +85 to +86

@gitar-bot gitar-bot Bot Jun 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Quality: Redundant exception tuple catches everything via Exception

except (PackageError, Exception): is redundant — PackageError subclasses Exception, so the tuple is equivalent to except Exception:. Listing PackageError first is misleading (it suggests differentiated handling that doesn't exist) and linters (e.g. flake8/pylint) flag the unreachable-by-subset clause. The broad catch-all is intentional here (per-app isolation so one bad seed only warns), so simplify to a plain except Exception: to make the intent explicit.

Drop the redundant PackageError from the tuple.:

except Exception:
    logger.warning("failed to seed bundled app in %s", app_dir, exc_info=True)

Was this helpful? React with 👍 / 👎

Loading
Loading