From 087cb9e385d340fd01380480eb74855a9dec082c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 17 Jun 2026 02:19:44 +0100 Subject: [PATCH 1/2] feat(userspace): first-party reference .taosapp + boot-seeding (#89 P4a) Adds tinyagentos/userspace/seed/welcome/ (manifest.yaml + index.html), a minimal first-party reference app that exercises the SDK theme API (get + subscribe), a kv round-trip, and a notify call. Adds tinyagentos/userspace/seed.py exposing seed_bundled_apps, which builds each seed subdirectory into an in-memory .taosapp zip, validates it through extract_package, and calls store.install with trust="first-party". Idempotent: skips apps already installed at the same version; re-seeds on a version bump. A seeding error is caught and logged -- it does not crash startup. Wires seed_bundled_apps into the app.py lifespan immediately after the userspace store and data store are initialised. The call is wrapped in a try/except so a seeding failure only emits a warning. Adds tests/userspace/test_seed.py covering: install as first-party, idempotency, version-bump re-seed, missing seed_dir is silent, bundle route returns first-party CSP, and the real bundled taos-welcome app for all of the above. --- tests/userspace/test_seed.py | 207 ++++++++++++++++++ tinyagentos/app.py | 5 + tinyagentos/userspace/seed.py | 79 +++++++ tinyagentos/userspace/seed/welcome/index.html | 111 ++++++++++ .../userspace/seed/welcome/manifest.yaml | 9 + 5 files changed, 411 insertions(+) create mode 100644 tests/userspace/test_seed.py create mode 100644 tinyagentos/userspace/seed.py create mode 100644 tinyagentos/userspace/seed/welcome/index.html create mode 100644 tinyagentos/userspace/seed/welcome/manifest.yaml diff --git a/tests/userspace/test_seed.py b/tests/userspace/test_seed.py new file mode 100644 index 00000000..8f04f568 --- /dev/null +++ b/tests/userspace/test_seed.py @@ -0,0 +1,207 @@ +"""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 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..cfd32018 --- /dev/null +++ b/tinyagentos/userspace/seed.py @@ -0,0 +1,79 @@ +"""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 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: + logger.debug("bundled app %s v%s already installed, skipping", app_id, version) + continue + + 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 From 82f4d8ceef6e182ddb513c12c54030d5372d072d Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 17 Jun 2026 02:28:13 +0100 Subject: [PATCH 2/2] fix(userspace): seed idempotency requires first-party trust + clears stale files (CodeRabbit/gitar #973) - the skip-if-same-version check now also requires trust=first-party, so a community row claiming a seeded id is re-seeded to first-party rather than skipped (CodeRabbit Major) - a re-seed rmtree's the extracted app dir before extracting, so a smaller new version cannot inherit stale files from the old one (gitar edge case) - tests for both --- tests/userspace/test_seed.py | 43 +++++++++++++++++++++++++++++++++++ tinyagentos/userspace/seed.py | 11 +++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/tests/userspace/test_seed.py b/tests/userspace/test_seed.py index 8f04f568..694db136 100644 --- a/tests/userspace/test_seed.py +++ b/tests/userspace/test_seed.py @@ -205,3 +205,46 @@ async def test_real_welcome_bundle_served_with_first_party_csp(client, app, tmp_ 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/userspace/seed.py b/tinyagentos/userspace/seed.py index cfd32018..76a2ef81 100644 --- a/tinyagentos/userspace/seed.py +++ b/tinyagentos/userspace/seed.py @@ -8,6 +8,7 @@ import io import logging +import shutil import zipfile from pathlib import Path @@ -58,10 +59,16 @@ async def seed_bundled_apps(store, apps_root: Path, seed_dir: Path | None = None version = manifest["version"] existing = await store.get(app_id) - if existing is not None and existing.get("version") == version: - logger.debug("bundled app %s v%s already installed, skipping", app_id, version) + 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(