-
-
Notifications
You must be signed in to change notification settings - Fork 21
feat(userspace): first-party reference .taosapp + boot-seeding (#89 P4a) #973
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() |
| 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) | ||
| zip_bytes = _build_zip_from_dir(app_dir) | ||
| extract_package(zip_bytes, apps_root) | ||
|
Comment on lines
+72
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Edge Case: Version-bump re-seed leaves stale files from old versionOn a version bump, 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Quality: Redundant exception tuple catches everything via Exception
Drop the redundant PackageError from the tuple.: Was this helpful? React with 👍 / 👎 |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CRITICAL: Validate
app_idbefore deleting the target bundle directoryapp_idcomes from the seed manifest and is used directly inshutil.rmtree(apps_root / app_id). A manifest declaring an absolute or traversal id can delete outsideapps_rootbeforeextract_packagehas 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 itto have Kilo Code address this issue.