diff --git a/tests/userspace/test_seed.py b/tests/userspace/test_seed.py new file mode 100644 index 00000000..694db136 --- /dev/null +++ b/tests/userspace/test_seed.py @@ -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("

hello

") + 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() diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 6f6e3f21..6c2d8980 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -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 diff --git a/tinyagentos/userspace/seed.py b/tinyagentos/userspace/seed.py new file mode 100644 index 00000000..76a2ef81 --- /dev/null +++ b/tinyagentos/userspace/seed.py @@ -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) + zip_bytes = _build_zip_from_dir(app_dir) + extract_package(zip_bytes, apps_root) + 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) diff --git a/tinyagentos/userspace/seed/welcome/index.html b/tinyagentos/userspace/seed/welcome/index.html new file mode 100644 index 00000000..f404383e --- /dev/null +++ b/tinyagentos/userspace/seed/welcome/index.html @@ -0,0 +1,111 @@ + + + + + + Welcome to taOS + + + + +

Welcome to taOS

+ loading... + +
+
SDK checks
+
kv round-trip: pending
+
notify: pending
+
+ + + + diff --git a/tinyagentos/userspace/seed/welcome/manifest.yaml b/tinyagentos/userspace/seed/welcome/manifest.yaml new file mode 100644 index 00000000..d61fe2b8 --- /dev/null +++ b/tinyagentos/userspace/seed/welcome/manifest.yaml @@ -0,0 +1,9 @@ +id: taos-welcome +name: Welcome +version: 1.0.0 +app_type: web +entry: index.html +icon: "" +permissions: + - app.kv + - app.notify