+ Describe what it should do. An agent builds it against the taOS SDK, sandboxed and safe,
+ and you can publish it to your Store or share it with family.
+
+
+
+ a shared shopping list the whole house can add to from their phones...
+
"
+ assert manifest["id"] == "todo"
+
+
+def test_extract_rejects_path_traversal(tmp_path):
+ data = _zip(WEB_MANIFEST, {"../evil.txt": "pwned"})
+ with pytest.raises(PackageError, match="unsafe path"):
+ extract_package(data, apps_root=tmp_path)
+
+
+def test_parse_manifest_rejects_yaml_list():
+ # Finding 5: safe_load returns a list -- must raise PackageError, not AttributeError.
+ with pytest.raises(PackageError, match="mapping"):
+ parse_manifest("- item1\n- item2\n")
+
+
+def test_parse_manifest_rejects_yaml_scalar():
+ # Finding 5: safe_load returns a scalar -- must raise PackageError.
+ with pytest.raises(PackageError, match="mapping"):
+ parse_manifest("just a string")
+
+
+def test_extract_rejects_dot_member(tmp_path):
+ # Finding 7: a zip member "." resolves to app_dir itself -- must raise PackageError,
+ # not an IsADirectoryError when write_bytes is called on a directory.
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as z:
+ z.writestr("manifest.yaml", WEB_MANIFEST.strip())
+ z.writestr("index.html", "
ok
")
+ # Add a member whose name is just "." -- resolves to app_dir on extraction.
+ z.writestr(".", "bad")
+ with pytest.raises(PackageError, match="unsafe path"):
+ extract_package(buf.getvalue(), apps_root=tmp_path)
+
+
+def test_extract_rejects_zip_bomb(tmp_path, monkeypatch):
+ # Zip-bomb defense: cap the declared uncompressed total low and confirm an
+ # over-cap package is rejected before extraction.
+ import tinyagentos.userspace.package as pkg
+ monkeypatch.setattr(pkg, "_MAX_UNCOMPRESSED_BYTES", 8)
+ data = _zip(WEB_MANIFEST, {"index.html": "
")
+ z.writestr("icon.png", "x")
+ return buf.getvalue()
+
+
+@pytest.mark.asyncio
+async def test_install_list_bundle_uninstall(client):
+ r = await client.post("/api/userspace-apps/install",
+ files={"package": ("todo.taosapp", _zip(), "application/zip")})
+ assert r.status_code == 200, r.text
+ assert r.json()["app_id"] == "todo"
+ assert r.json()["permissions_requested"] == ["app.net"]
+
+ r = await client.get("/api/userspace-apps")
+ assert any(a["app_id"] == "todo" for a in r.json())
+
+ r = await client.get("/api/userspace-apps/todo/bundle/index.html")
+ assert r.status_code == 200
+ assert "todo" in r.text
+ csp = r.headers.get("content-security-policy", "").lower()
+ assert "frame-ancestors" in csp or "default-src" in csp
+
+ r = await client.delete("/api/userspace-apps/todo")
+ assert r.status_code == 200
+ rows = (await client.get("/api/userspace-apps")).json()
+ assert all(a["app_id"] != "todo" for a in rows)
+
+
+@pytest.mark.asyncio
+async def test_bundle_path_traversal_404(client):
+ await client.post("/api/userspace-apps/install",
+ files={"package": ("todo.taosapp", _zip(), "application/zip")})
+ # Use percent-encoded traversal so URL normalization cannot collapse it
+ # before the route handler, ensuring the bundle path guard is exercised.
+ r = await client.get("/api/userspace-apps/todo/bundle/%2e%2e%2f%2e%2e%2fetc%2fpasswd")
+ assert r.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_install_malformed_json_returns_400(client):
+ # Finding 2: a request body that is not valid JSON must return 400, not 500.
+ r = await client.post(
+ "/api/userspace-apps/install",
+ content=b"{not valid json",
+ headers={"Content-Type": "application/json"},
+ )
+ assert r.status_code == 400
+ assert "invalid" in r.json()["error"].lower()
+
+
+@pytest.mark.asyncio
+async def test_install_upstream_fetch_failure_returns_502(client):
+ # Finding 2: httpx connectivity failure on source_url must return 502.
+ with patch("tinyagentos.routes.userspace_apps.resolve_safe_public_ip",
+ return_value="93.184.216.34"):
+ with patch("httpx.AsyncClient.get", new_callable=AsyncMock,
+ side_effect=httpx.ConnectError("refused")):
+ r = await client.post(
+ "/api/userspace-apps/install",
+ json={"source_url": "http://example.com/app.taosapp"},
+ )
+ assert r.status_code == 502
+ assert "upstream" in r.json()["error"].lower() or "fetch" in r.json()["error"].lower()
+
+
+@pytest.mark.asyncio
+async def test_install_pins_connection_to_resolved_ip(client):
+ # SSRF #971: the fetch must connect to the validated IP (closing the
+ # DNS-rebind TOCTOU window), keeping the original Host header + TLS SNI so
+ # the client never re-resolves the hostname to a different (internal) IP.
+ captured = {}
+
+ async def _fake_get(self, url, **kwargs):
+ captured["url"] = url
+ captured["headers"] = kwargs.get("headers")
+ captured["extensions"] = kwargs.get("extensions")
+ resp = Mock()
+ resp.raise_for_status = Mock(return_value=None)
+ resp.content = _zip()
+ return resp
+
+ with patch("tinyagentos.routes.userspace_apps.resolve_safe_public_ip",
+ return_value="93.184.216.34"):
+ with patch("httpx.AsyncClient.get", new=_fake_get):
+ r = await client.post(
+ "/api/userspace-apps/install",
+ json={"source_url": "http://example.com/app.taosapp"},
+ )
+ assert r.status_code == 200, r.text
+ # connected to the pinned IP, NOT the hostname
+ assert "93.184.216.34" in captured["url"]
+ assert "example.com" not in captured["url"]
+ # original host preserved for vhost routing + cert validation
+ assert captured["headers"]["Host"] == "example.com"
+ assert captured["extensions"]["sni_hostname"] == "example.com"
+
+
+def _container_zip():
+ manifest = (
+ "id: ctapp\nname: ContainerApp\nversion: 1.0.0\napp_type: container\n"
+ "entry: index.html\nicon: \npermissions: []\n"
+ "container:\n image: myimage:latest\n ports: [8080]\n"
+ )
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as z:
+ z.writestr("manifest.yaml", manifest)
+ z.writestr("index.html", "
ct
")
+ return buf.getvalue()
+
+
+@pytest.mark.asyncio
+async def test_container_install_rejected_with_no_stored_state(client):
+ # Finding 3: installing a container package must return 501 before
+ # persisting anything -- no app row and no extracted directory must remain.
+ r = await client.post(
+ "/api/userspace-apps/install",
+ files={"package": ("ctapp.taosapp", _container_zip(), "application/zip")},
+ )
+ assert r.status_code == 501
+ assert "container" in r.json()["error"].lower()
+
+ # No app row stored.
+ rows = (await client.get("/api/userspace-apps")).json()
+ assert all(a["app_id"] != "ctapp" for a in rows)
diff --git a/tests/userspace/test_sdk_route.py b/tests/userspace/test_sdk_route.py
new file mode 100644
index 000000000..b6d1e26d1
--- /dev/null
+++ b/tests/userspace/test_sdk_route.py
@@ -0,0 +1,9 @@
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_sdk_served(client):
+ r = await client.get("/api/userspace-apps/sdk.js")
+ assert r.status_code == 200
+ assert "application/javascript" in r.headers["content-type"]
+ assert "window.taos" in r.text
diff --git a/tests/userspace/test_seed.py b/tests/userspace/test_seed.py
new file mode 100644
index 000000000..694db136a
--- /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/tests/userspace/test_store.py b/tests/userspace/test_store.py
new file mode 100644
index 000000000..2d1fffd0a
--- /dev/null
+++ b/tests/userspace/test_store.py
@@ -0,0 +1,83 @@
+import pytest
+from tinyagentos.userspace.store import UserspaceAppStore
+
+
+@pytest.mark.asyncio
+async def test_install_list_and_uninstall(tmp_path):
+ store = UserspaceAppStore(tmp_path / "userspace_apps.db")
+ await store.init()
+ await store.install(
+ app_id="todo", name="Todo", version="1.0.0", app_type="web",
+ entry="index.html", icon="icon.png", permissions_requested=["app.net"],
+ )
+ rows = await store.list_installed()
+ assert len(rows) == 1
+ assert rows[0]["app_id"] == "todo"
+ assert rows[0]["app_type"] == "web"
+ assert rows[0]["enabled"] == 1
+ assert rows[0]["permissions_granted"] == []
+ assert rows[0]["permissions_requested"] == ["app.net"]
+ assert await store.uninstall("todo") is True
+ assert await store.list_installed() == []
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_set_permissions_and_enabled(tmp_path):
+ store = UserspaceAppStore(tmp_path / "u.db")
+ await store.init()
+ await store.install(app_id="a", name="A", version="1", app_type="web",
+ entry="index.html", icon="i.png",
+ permissions_requested=["app.net", "app.memory"])
+ await store.set_permissions_granted("a", ["app.net"])
+ await store.set_enabled("a", False)
+ row = await store.get("a")
+ assert row["permissions_granted"] == ["app.net"]
+ assert row["enabled"] == 0
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_fresh_install_has_no_runtime_location(tmp_path):
+ store = UserspaceAppStore(tmp_path / "rt.db")
+ await store.init()
+ await store.install(app_id="ctr", name="Ctr", version="1.0.0",
+ app_type="container", entry="index.html", icon="",
+ permissions_requested=[])
+ row = await store.get("ctr")
+ assert row["container_host"] is None
+ assert row["container_port"] is None
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_set_runtime_location(tmp_path):
+ store = UserspaceAppStore(tmp_path / "rt2.db")
+ await store.init()
+ await store.install(app_id="ctr", name="Ctr", version="1.0.0",
+ app_type="container", entry="index.html", icon="",
+ permissions_requested=[])
+ await store.set_runtime_location("ctr", "127.0.0.1", 13042)
+ row = await store.get("ctr")
+ assert row["container_host"] == "127.0.0.1"
+ assert row["container_port"] == 13042
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_reinstall_preserves_runtime_location(tmp_path):
+ store = UserspaceAppStore(tmp_path / "rt3.db")
+ await store.init()
+ await store.install(app_id="ctr", name="Ctr", version="1.0.0",
+ app_type="container", entry="index.html", icon="",
+ permissions_requested=[])
+ await store.set_runtime_location("ctr", "127.0.0.1", 13042)
+ # Re-install (upsert) should not wipe the runtime location
+ await store.install(app_id="ctr", name="Ctr v2", version="1.0.1",
+ app_type="container", entry="index.html", icon="",
+ permissions_requested=[])
+ row = await store.get("ctr")
+ assert row["container_host"] == "127.0.0.1"
+ assert row["container_port"] == 13042
+ assert row["version"] == "1.0.1"
+ await store.close()
diff --git a/tests/userspace/test_trust.py b/tests/userspace/test_trust.py
new file mode 100644
index 000000000..c74d812bd
--- /dev/null
+++ b/tests/userspace/test_trust.py
@@ -0,0 +1,295 @@
+"""Tests for P3b trust-aware CSP, broker capabilities, and install endpoint.
+
+Security invariant: the public /install endpoint MUST always write trust='community'.
+First-party trust is only reachable through an internal/trusted path (store.install
+with trust='first-party'), simulating what P4 boot-seeding and P2 signature
+verification will do.
+"""
+import io
+import zipfile
+
+import pytest
+
+from tinyagentos.userspace.broker import GATED_CAPS, handle_capability
+from tinyagentos.userspace.data_store import UserspaceDataStore
+from tinyagentos.userspace.store import UserspaceAppStore
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+WEB_MANIFEST = (
+ "id: studio\nname: Studio\nversion: 1.0.0\napp_type: web\n"
+ "entry: index.html\nicon: icon.png\npermissions: []\n"
+)
+
+
+def _zip(manifest: str = WEB_MANIFEST) -> bytes:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as z:
+ z.writestr("manifest.yaml", manifest)
+ z.writestr("index.html", "
studio
")
+ z.writestr("icon.png", "x")
+ return buf.getvalue()
+
+
+async def _data_store(tmp_path):
+ s = UserspaceDataStore(tmp_path / "d.db")
+ await s.init()
+ return s
+
+
+# ---------------------------------------------------------------------------
+# Store -- trust column
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_install_default_trust_is_community(tmp_path):
+ store = UserspaceAppStore(tmp_path / "u.db")
+ await store.init()
+ await store.install(
+ app_id="a", name="A", version="1", app_type="web",
+ entry="index.html", icon="", permissions_requested=[],
+ )
+ row = await store.get("a")
+ assert row["trust"] == "community"
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_install_first_party_trust(tmp_path):
+ store = UserspaceAppStore(tmp_path / "u.db")
+ await store.init()
+ await store.install(
+ app_id="studio", name="Studio", version="1", app_type="web",
+ entry="index.html", icon="", permissions_requested=[], trust="first-party",
+ )
+ row = await store.get("studio")
+ assert row["trust"] == "first-party"
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_migration_adds_trust_column_to_existing_db(tmp_path):
+ """Existing databases (pre-trust column) get the column added with 'community' default."""
+ import aiosqlite
+ db_path = tmp_path / "old.db"
+ # Create a database that looks like it was created before the trust column existed.
+ async with aiosqlite.connect(str(db_path)) as db:
+ await db.executescript("""
+ CREATE TABLE IF NOT EXISTS userspace_apps (
+ app_id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ version TEXT NOT NULL DEFAULT '',
+ app_type TEXT NOT NULL,
+ entry TEXT NOT NULL DEFAULT 'index.html',
+ icon TEXT NOT NULL DEFAULT '',
+ permissions_requested TEXT NOT NULL DEFAULT '[]',
+ permissions_granted TEXT NOT NULL DEFAULT '[]',
+ enabled INTEGER NOT NULL DEFAULT 1,
+ installed_at INTEGER NOT NULL,
+ container_host TEXT,
+ container_port INTEGER
+ );
+ """)
+ await db.execute(
+ "INSERT INTO userspace_apps "
+ "(app_id, name, version, app_type, entry, icon, "
+ "permissions_requested, permissions_granted, enabled, installed_at) "
+ "VALUES ('old', 'Old', '1', 'web', 'index.html', '', '[]', '[]', 1, 0)"
+ )
+ await db.commit()
+
+ # Opening via UserspaceAppStore should run the migration.
+ store = UserspaceAppStore(db_path)
+ await store.init()
+ row = await store.get("old")
+ assert row is not None
+ assert row["trust"] == "community"
+ await store.close()
+
+
+# ---------------------------------------------------------------------------
+# Public install endpoint -- always community
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_public_install_endpoint_always_community(client):
+ r = await client.post(
+ "/api/userspace-apps/install",
+ files={"package": ("studio.taosapp", _zip(), "application/zip")},
+ )
+ assert r.status_code == 200, r.text
+ # Verify the stored record has community trust regardless of any manifest content.
+ rows = (await client.get("/api/userspace-apps")).json()
+ row = next((a for a in rows if a["app_id"] == "studio"), None)
+ assert row is not None
+ assert row["trust"] == "community"
+
+
+# ---------------------------------------------------------------------------
+# serve_bundle -- CSP by trust
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_serve_bundle_community_gets_tight_csp(client):
+ await client.post(
+ "/api/userspace-apps/install",
+ files={"package": ("studio.taosapp", _zip(), "application/zip")},
+ )
+ r = await client.get("/api/userspace-apps/studio/bundle/index.html")
+ assert r.status_code == 200
+ csp = r.headers.get("content-security-policy", "")
+ # Community CSP must include the sandbox directive and tight defaults.
+ assert "sandbox" in csp
+ assert "default-src 'none'" in csp
+
+
+@pytest.mark.asyncio
+async def test_serve_bundle_first_party_gets_relaxed_csp(client, app, tmp_path):
+ # Seed a first-party app directly into the store (bypassing the public install
+ # endpoint, which only ever writes community -- this is the trusted path).
+ apps_dir = tmp_path / "apps" / "studio"
+ apps_dir.mkdir(parents=True)
+ (apps_dir / "index.html").write_text("
studio
")
+
+ store = app.state.userspace_apps
+ await store.install(
+ app_id="studio", name="Studio", version="1", app_type="web",
+ entry="index.html", icon="", permissions_requested=[], trust="first-party",
+ )
+
+ r = await client.get("/api/userspace-apps/studio/bundle/index.html")
+ assert r.status_code == 200
+ csp = r.headers.get("content-security-policy", "")
+ # First-party CSP must still sandbox (no allow-same-origin -- critical).
+ assert "sandbox" in csp
+ assert "allow-same-origin" not in csp
+ assert "default-src 'none'" in csp
+
+
+# ---------------------------------------------------------------------------
+# Broker route -- capability grants by trust
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_broker_first_party_gated_cap_succeeds_without_explicit_grant(client, app, tmp_path):
+ """A first-party app can call any gated cap without a prior /permissions grant."""
+ apps_dir = tmp_path / "apps" / "studio"
+ apps_dir.mkdir(parents=True)
+ (apps_dir / "index.html").write_text("
studio
")
+
+ store = app.state.userspace_apps
+ await store.install(
+ app_id="studio", name="Studio", version="1", app_type="web",
+ entry="index.html", icon="", permissions_requested=[], trust="first-party",
+ )
+
+ # app.memory.search is gated. No /permissions call was made -- should still succeed.
+ # The memory service is not wired in the test app so it returns an empty list.
+ r = await client.post(
+ "/api/userspace-apps/studio/broker",
+ json={"capability": "app.memory.search", "args": {"q": "x"}},
+ )
+ assert r.status_code == 200
+ assert "error" not in r.json() or r.json().get("error") != "permission_denied"
+
+
+@pytest.mark.asyncio
+async def test_broker_community_gated_cap_denied_without_grant(client):
+ """Community apps still require explicit permission grants for gated capabilities."""
+ await client.post(
+ "/api/userspace-apps/install",
+ files={"package": ("studio.taosapp", _zip(), "application/zip")},
+ )
+ r = await client.post(
+ "/api/userspace-apps/studio/broker",
+ json={"capability": "app.net", "args": {"path": "/ping"}},
+ )
+ assert r.json()["error"] == "permission_denied"
+
+
+@pytest.mark.asyncio
+async def test_broker_community_gated_cap_with_grant(client):
+ """Community app with explicit grant can reach a gated cap (existing behaviour preserved)."""
+ # The package must REQUEST app.memory: set_permissions only grants caps the
+ # manifest declared (an app cannot be escalated to caps it never requested).
+ manifest = WEB_MANIFEST.replace("permissions: []", "permissions: [app.memory]")
+ await client.post(
+ "/api/userspace-apps/install",
+ files={"package": ("studio.taosapp", _zip(manifest), "application/zip")},
+ )
+ await client.post(
+ "/api/userspace-apps/studio/permissions",
+ json={"granted": ["app.memory"]},
+ )
+ r = await client.post(
+ "/api/userspace-apps/studio/broker",
+ json={"capability": "app.memory.search", "args": {"q": "x"}},
+ )
+ # memory service not wired in test, so result is [] not an error
+ assert "error" not in r.json() or r.json().get("error") != "permission_denied"
+
+
+# ---------------------------------------------------------------------------
+# broker.py unit -- all GATED_CAPS succeed when granted
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_broker_all_gated_caps_granted_for_first_party(tmp_path):
+ """Passing the full GATED_CAPS set as granted lets every gated namespace through."""
+ ds = await _data_store(tmp_path)
+ for cap_ns in GATED_CAPS:
+ # Each namespace has at least one sub-cap. Use the simplest one that
+ # can be exercised without external services.
+ capability = f"{cap_ns}.search" if cap_ns == "app.memory" else cap_ns
+ out = await handle_capability(
+ "fp-app", capability, {},
+ granted=set(GATED_CAPS),
+ data_store=ds,
+ app_dir=tmp_path / "fp-app",
+ services={},
+ )
+ # Should not be permission_denied (may be another error due to missing
+ # service, but that's fine -- the gate passed).
+ assert out.get("error") != "permission_denied", (
+ f"GATED_CAPS grant did not bypass gate for {capability}: {out}"
+ )
+ await ds.close()
+
+
+@pytest.mark.asyncio
+async def test_public_install_cannot_overwrite_first_party(client, app):
+ """A public install of an id already installed as first-party is rejected,
+ so it cannot overwrite a trusted bundle or inherit first-party privileges."""
+ await app.state.userspace_apps.install(
+ app_id="studio", name="Studio", version="1.0.0", app_type="web",
+ entry="index.html", icon="", permissions_requested=[], trust="first-party",
+ )
+ r = await client.post(
+ "/api/userspace-apps/install",
+ files={"package": ("studio.taosapp", _zip(), "application/zip")},
+ )
+ assert r.status_code == 409
+ row = await app.state.userspace_apps.get("studio")
+ assert row["trust"] == "first-party"
+
+
+@pytest.mark.asyncio
+async def test_upsert_updates_trust_on_reinstall(tmp_path):
+ """The install UPSERT updates trust (not only on first insert), so a later
+ install with a different trust never retains a stale elevated trust."""
+ store = UserspaceAppStore(tmp_path / "u.db")
+ await store.init()
+ await store.install(app_id="a", name="A", version="1", app_type="web",
+ entry="index.html", icon="", permissions_requested=[], trust="first-party")
+ assert (await store.get("a"))["trust"] == "first-party"
+ await store.install(app_id="a", name="A", version="2", app_type="web",
+ entry="index.html", icon="", permissions_requested=[], trust="community")
+ assert (await store.get("a"))["trust"] == "community"
+ await store.close()
diff --git a/tests/userspace/test_update_consent.py b/tests/userspace/test_update_consent.py
new file mode 100644
index 000000000..a3e26a142
--- /dev/null
+++ b/tests/userspace/test_update_consent.py
@@ -0,0 +1,25 @@
+# tests/userspace/test_update_consent.py -- install v1 (app.net), then "update" requesting app.memory too
+import io, zipfile
+import pytest
+
+
+def _zip(perms):
+ m = f"id: todo\nname: Todo\nversion: 1.0.0\napp_type: web\nentry: index.html\nicon: icon.png\npermissions: {perms}\n"
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as z:
+ z.writestr("manifest.yaml", m)
+ z.writestr("index.html", "x")
+ z.writestr("icon.png", "x")
+ return buf.getvalue()
+
+
+@pytest.mark.asyncio
+async def test_update_with_new_permission_flags_consent(client):
+ await client.post("/api/userspace-apps/install",
+ files={"package": ("t.taosapp", _zip("[app.net]"), "application/zip")})
+ await client.post("/api/userspace-apps/todo/permissions", json={"granted": ["app.net"]})
+ r = await client.post("/api/userspace-apps/install",
+ files={"package": ("t.taosapp", _zip("[app.net, app.memory]"), "application/zip")})
+ body = r.json()
+ assert body["needs_consent"] is True
+ assert "app.memory" in body["new_permissions"]
diff --git a/tests/userspace/test_url_guard.py b/tests/userspace/test_url_guard.py
new file mode 100644
index 000000000..417bdf698
--- /dev/null
+++ b/tests/userspace/test_url_guard.py
@@ -0,0 +1,63 @@
+import socket
+from unittest.mock import patch
+
+from tinyagentos.userspace.url_guard import is_safe_public_url, resolve_safe_public_ip
+
+
+def test_blocks_link_local_metadata_endpoint():
+ assert is_safe_public_url("http://169.254.169.254/latest/meta-data") is False
+
+
+def test_blocks_loopback_and_private():
+ assert is_safe_public_url("http://127.0.0.1/x") is False
+ assert is_safe_public_url("http://10.0.0.1/x") is False
+ assert is_safe_public_url("https://192.168.1.1/x") is False
+
+
+def test_blocks_non_http_scheme():
+ assert is_safe_public_url("ftp://example.com/x") is False
+ assert is_safe_public_url("file:///etc/passwd") is False
+
+
+def test_allows_public_ip():
+ # literal public IP -- getaddrinfo returns it without DNS
+ assert is_safe_public_url("https://8.8.8.8/app.taosapp") is True
+
+
+def test_rejects_garbage():
+ assert is_safe_public_url("not a url") is False
+ assert is_safe_public_url("http://") is False
+
+
+def _gai(ip):
+ # mimic socket.getaddrinfo: list of (family, type, proto, canonname, sockaddr)
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0))]
+
+
+def test_resolve_returns_pinned_ip_for_public_literal():
+ assert resolve_safe_public_ip("https://8.8.8.8/app.taosapp") == "8.8.8.8"
+
+
+def test_resolve_rejects_private_and_non_http():
+ assert resolve_safe_public_ip("http://169.254.169.254/latest") is None
+ assert resolve_safe_public_ip("http://127.0.0.1/x") is None
+ assert resolve_safe_public_ip("ftp://8.8.8.8/x") is None
+
+
+def test_resolve_returns_validated_ip_for_public_hostname():
+ with patch("socket.getaddrinfo", return_value=_gai("93.184.216.34")):
+ assert resolve_safe_public_ip("https://example.com/x") == "93.184.216.34"
+ assert is_safe_public_url("https://example.com/x") is True
+
+
+def test_resolve_rejects_hostname_resolving_to_private():
+ # the DNS-rebinding case: a hostname that resolves to an internal IP
+ with patch("socket.getaddrinfo", return_value=_gai("10.1.2.3")):
+ assert resolve_safe_public_ip("https://evil.example/x") is None
+ assert is_safe_public_url("https://evil.example/x") is False
+
+
+def test_resolve_rejects_mixed_public_and_private():
+ # if ANY resolved address is non-public, reject the whole host
+ with patch("socket.getaddrinfo", return_value=_gai("93.184.216.34") + _gai("10.0.0.1")):
+ assert resolve_safe_public_ip("https://example.com/x") is None
diff --git a/tinyagentos/app.py b/tinyagentos/app.py
index fc400b5ce..6c2d89806 100644
--- a/tinyagentos/app.py
+++ b/tinyagentos/app.py
@@ -340,6 +340,10 @@ async def _probe_backend(backend: dict) -> dict:
user_memory = UserMemoryStore(data_dir / "user_memory.db")
user_personas = UserPersonaStore(data_dir / "user_personas.db")
installed_apps = InstalledAppsStore(data_dir / "installed_apps.db")
+ from tinyagentos.userspace.store import UserspaceAppStore
+ from tinyagentos.userspace.data_store import UserspaceDataStore
+ userspace_apps = UserspaceAppStore(data_dir / "userspace_apps.db")
+ userspace_data = UserspaceDataStore(data_dir / "userspace_data.db")
skills = SkillStore(data_dir / "skills.db")
from tinyagentos.themes.store import ThemeStore
themes = ThemeStore(data_dir / "themes.sqlite3")
@@ -416,6 +420,15 @@ async def lifespan(app: FastAPI):
await desktop_settings.init()
await user_memory.init()
await installed_apps.init()
+ await userspace_apps.init()
+ 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
@@ -657,6 +670,8 @@ async def _browser_reap_loop(app: FastAPI) -> None:
app.state.user_memory = user_memory
app.state.user_personas = user_personas
app.state.installed_apps = installed_apps
+ app.state.userspace_apps = userspace_apps
+ app.state.userspace_data = userspace_data
app.state.skills = skills
app.state.benchmark_store = benchmark_store
app.state.score_cache = score_cache
@@ -1109,6 +1124,8 @@ async def _reload_llm_proxy_on_catalog_change() -> None:
await knowledge_graph.close()
await archive.close()
await installed_apps.close()
+ await userspace_apps.close()
+ await userspace_data.close()
await user_memory.close()
await desktop_settings.close()
await canvas_store.close()
@@ -1281,6 +1298,8 @@ async def dispatch(self, request, call_next):
app.state.user_memory = user_memory
app.state.user_personas = user_personas
app.state.installed_apps = installed_apps
+ app.state.userspace_apps = userspace_apps
+ app.state.userspace_data = userspace_data
app.state.skills = skills
app.state.themes = themes
app.state.knowledge_store = knowledge_store
diff --git a/tinyagentos/auto_update.py b/tinyagentos/auto_update.py
index ec1757c64..f97b925c8 100644
--- a/tinyagentos/auto_update.py
+++ b/tinyagentos/auto_update.py
@@ -200,6 +200,52 @@ async def resolve_tracked_branch(settings_store, project_dir: Path) -> str:
return await update_tracking_branch(project_dir)
+_DOC_EXTENSIONS = (".md", ".markdown", ".rst", ".txt")
+_CODE_EXTENSIONS = (
+ ".py", ".ts", ".tsx", ".js", ".jsx", ".json",
+ ".yaml", ".yml", ".sh", ".toml", ".cfg", ".ini",
+)
+
+
+def is_documentation_path(path: str) -> bool:
+ """True when *path* is documentation-only.
+
+ A path counts as documentation if it ends in a doc extension anywhere, or
+ lives under ``docs/`` without a code/config extension. When unsure, False.
+ """
+ p = path.strip().replace("\\", "/")
+ if not p:
+ return False
+ lower = p.lower()
+ if any(lower.endswith(ext) for ext in _DOC_EXTENSIONS):
+ return True
+ if p.startswith("docs/"):
+ if any(lower.endswith(ext) for ext in _CODE_EXTENSIONS):
+ return False
+ return True
+ return False
+
+
+async def changes_are_docs_only(
+ project_dir: Path, current: str, remote: str
+) -> bool:
+ """True when every file changed between *current* and *remote* is documentation."""
+ if not current or not remote or current == remote:
+ return False
+ if project_dir is None or not Path(project_dir).exists():
+ return False
+ rc, out = await _run(
+ ["git", "diff", "--name-only", f"{current}..{remote}"],
+ project_dir,
+ )
+ if rc != 0:
+ return False
+ paths = [line.strip() for line in out.splitlines() if line.strip()]
+ if not paths:
+ return False
+ return all(is_documentation_path(p) for p in paths)
+
+
async def remote_is_strictly_ahead(project_dir: Path, current: str, remote: str) -> bool:
"""True only if ``current`` is a strict ancestor of ``remote`` -- i.e. the
remote is genuinely newer. Prevents offering an older or divergent commit
@@ -299,6 +345,13 @@ async def _run_once(self) -> None:
# flag an older/divergent commit (e.g. master's tip while we run
# ahead on dev) as available.
if await remote_is_strictly_ahead(self._project_dir, current, new_commit):
+ if await changes_are_docs_only(self._project_dir, current, new_commit):
+ logger.debug(
+ "auto-update: skipping docs-only diff %s..%s",
+ (current or "")[:7],
+ new_commit[:7],
+ )
+ return
# Skip re-notifying for a commit we've already flagged.
if prefs.get("last_notified_commit") != new_commit:
await self._notify_available(current, new_commit)
diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py
index 339681d2a..489c045f2 100644
--- a/tinyagentos/routes/__init__.py
+++ b/tinyagentos/routes/__init__.py
@@ -284,8 +284,11 @@ def register_all_routers(app):
from tinyagentos.routes.events import router as events_router
app.include_router(events_router)
- # OTLP/HTTP+JSON receiver — Phase 2 observability.
+ # OTLP/HTTP+JSON receiver -- Phase 2 observability.
# POST /v1/traces accepts ExportTraceServiceRequest JSON and writes spans
# to the per-agent SpanStore (app.state.span_store_registry).
from tinyagentos.otel.receiver import router as otel_receiver_router
app.include_router(otel_receiver_router)
+
+ from tinyagentos.routes.userspace_apps import router as userspace_apps_router
+ app.include_router(userspace_apps_router)
diff --git a/tinyagentos/routes/apps.py b/tinyagentos/routes/apps.py
index 80e2d4094..61a92280b 100644
--- a/tinyagentos/routes/apps.py
+++ b/tinyagentos/routes/apps.py
@@ -1,6 +1,6 @@
"""Desktop service icon API.
-GET /api/apps/installed — list installed services that have a recorded
+GET /api/apps/installed -- list installed services that have a recorded
runtime location (host + port). These are the apps that get desktop icons
and can be opened in a taOS web-app window via the service proxy.
@@ -26,18 +26,67 @@
# runtime location). The frontend owns name/icon/cover; the backend only tracks
# which ids are installed, gated to this allowlist so the endpoint can't be used
# to write arbitrary install rows.
-OPTIONAL_FRONTEND_APPS = {"reddit", "youtube-library", "github-browser", "x-monitor"}
+OPTIONAL_FRONTEND_APPS = {
+ "reddit", "youtube-library", "github-browser", "x-monitor",
+ # Creative Studios install the same way: a frontend-only optional app whose
+ # install row just flips the launcher visibility, no service spawned.
+ "coding-studio", "design-studio", "music-studio", "app-studio", "office-suite",
+}
_FRONTEND_APP_KIND = "frontend-app"
+# In-core version for each optional app. When an app becomes a real .taosapp
+# package, the package version will win instead of this value.
+APP_VERSIONS: dict[str, str] = {
+ "reddit": "1.0.0",
+ "youtube-library": "1.0.0",
+ "github-browser": "1.0.0",
+ "x-monitor": "1.0.0",
+ "coding-studio": "1.0.0",
+ "design-studio": "1.0.0",
+ "music-studio": "1.0.0",
+ "app-studio": "1.0.0",
+ "office-suite": "1.0.0",
+}
+
+# Trust level for each optional app (all current optional apps are first-party).
+APP_TRUST: dict[str, str] = {
+ "reddit": "first-party",
+ "youtube-library": "first-party",
+ "github-browser": "first-party",
+ "x-monitor": "first-party",
+ "coding-studio": "first-party",
+ "design-studio": "first-party",
+ "music-studio": "first-party",
+ "app-studio": "first-party",
+ "office-suite": "first-party",
+}
+
+
+def _semver_tuple(version: str) -> tuple[int, int, int]:
+ """Parse a semver string into a fixed-length (major, minor, patch) tuple.
+
+ Strips a leading 'v' and ignores pre-release/build suffixes for ordering,
+ and pads to three components so "1.0" and "1.0.0" compare equal. Returns
+ (0, 0, 0) on any parse failure so comparisons degrade gracefully without
+ masking a real update.
+ """
+ v = version.lstrip("v").split("-")[0].split("+")[0]
+ try:
+ parts = [int(p) for p in v.split(".")]
+ except ValueError:
+ return (0, 0, 0)
+ parts = (parts + [0, 0, 0])[:3]
+ return (parts[0], parts[1], parts[2])
+
def _resolve_icon(manifest_icon: str, manifest_dir) -> str:
"""Resolve the manifest's icon field to a URL string.
Accepts:
- - Absolute URL paths like /static/app-icons/gitea.svg → returned as-is.
- - http/https URLs → returned as-is.
+ - Absolute URL paths like /static/app-icons/gitea.svg -> returned as-is.
+ - http/https URLs -> returned as-is.
- Relative paths (e.g. icons/gitea.svg) relative to
- the manifest dir — not currently served, so fall back
+ the manifest dir -- not currently served, so fall back
to the generic icon.
Returns the generic icon if the field is empty.
"""
@@ -45,7 +94,7 @@ def _resolve_icon(manifest_icon: str, manifest_dir) -> str:
return _GENERIC_ICON
if manifest_icon.startswith("/") or manifest_icon.startswith("http"):
return manifest_icon
- # Relative path — would need extra static-mount work; use generic for now.
+ # Relative path -- would need extra static-mount work; use generic for now.
return _GENERIC_ICON
@@ -82,10 +131,10 @@ async def list_installed_apps(request: Request):
app_id: str = row["app_id"]
loc = await installed_apps.get_runtime_location(app_id)
if loc is None:
- # No runtime location → not accessible via proxy → skip.
+ # No runtime location -- not accessible via proxy -- skip.
continue
if not loc.get("runtime_host") or not loc.get("runtime_port"):
- # Incomplete runtime record — not proxy-routable yet.
+ # Incomplete runtime record -- not proxy-routable yet.
continue
# Best-effort manifest lookup for display metadata.
@@ -129,7 +178,7 @@ async def list_installed_apps(request: Request):
# --------------------------------------------------------------------------- #
-# Optional frontend apps (Reddit / YouTube / GitHub / X) — Store install state.
+# Optional frontend apps -- Store install state and versioned catalog.
# --------------------------------------------------------------------------- #
@@ -154,9 +203,73 @@ async def list_installed_optional_apps(request: Request):
return {"installed": installed}
+@router.get("/api/apps/optional/catalog")
+async def optional_app_catalog(request: Request):
+ """Return version and install state for every allowlisted optional app.
+
+ Shape::
+
+ {
+ "apps": [
+ {
+ "id": "reddit",
+ "version": "1.0.0",
+ "trust": "first-party",
+ "source": "core",
+ "installed": true,
+ "update_available": false
+ },
+ ...
+ ]
+ }
+
+ ``source`` is always "core" for in-bundle apps. A future .taosapp package
+ source would appear here alongside an independent Update button without any
+ UI rework.
+
+ ``update_available`` is true only when the app is installed AND the version
+ recorded at install time is older than APP_VERSIONS (semver comparison).
+ Freshly installed apps always record the current APP_VERSIONS version, so
+ update_available will be false unless an older install row pre-dates a
+ version bump.
+ """
+ store = getattr(request.app.state, "installed_apps", None)
+
+ # Build an index of installed rows keyed by app_id for O(1) lookup.
+ installed_index: dict[str, dict] = {}
+ if store is not None:
+ rows = await store.list_installed()
+ for r in rows:
+ aid = r["app_id"]
+ if aid in OPTIONAL_FRONTEND_APPS and (r.get("metadata") or {}).get("kind") == _FRONTEND_APP_KIND:
+ installed_index[aid] = r
+
+ result = []
+ for app_id in sorted(OPTIONAL_FRONTEND_APPS):
+ current_version = APP_VERSIONS.get(app_id, "1.0.0")
+ row = installed_index.get(app_id)
+ is_installed = row is not None
+ update_available = False
+ if is_installed and row is not None:
+ recorded = row.get("version") or ""
+ if recorded:
+ update_available = _semver_tuple(recorded) < _semver_tuple(current_version)
+
+ result.append({
+ "id": app_id,
+ "version": current_version,
+ "trust": APP_TRUST.get(app_id, "first-party"),
+ "source": "core",
+ "installed": is_installed,
+ "update_available": update_available,
+ })
+
+ return {"apps": result}
+
+
@router.post("/api/apps/optional/{app_id}/install")
async def install_optional_app(app_id: str, request: Request):
- """Mark an optional frontend app installed. Instant — no service is spawned.
+ """Mark an optional frontend app installed. Instant -- no service is spawned.
Rejected unless app_id is in the OPTIONAL_FRONTEND_APPS allowlist so this
endpoint can't seed arbitrary install rows.
@@ -166,7 +279,11 @@ async def install_optional_app(app_id: str, request: Request):
store = getattr(request.app.state, "installed_apps", None)
if store is None:
return JSONResponse({"error": "install store unavailable"}, status_code=503)
- await store.install(app_id, metadata={"kind": _FRONTEND_APP_KIND})
+ await store.install(
+ app_id,
+ version=APP_VERSIONS.get(app_id, "1.0.0"),
+ metadata={"kind": _FRONTEND_APP_KIND},
+ )
return {"status": "installed", "app_id": app_id}
diff --git a/tinyagentos/routes/settings.py b/tinyagentos/routes/settings.py
index 4c9250bbc..a1b333272 100644
--- a/tinyagentos/routes/settings.py
+++ b/tinyagentos/routes/settings.py
@@ -502,7 +502,7 @@ async def check_for_updates(request: Request):
import asyncio
import re
from tinyagentos import __version__
- from tinyagentos.auto_update import remote_is_strictly_ahead
+ from tinyagentos.auto_update import changes_are_docs_only, remote_is_strictly_ahead
project_dir = str(Path(__file__).parent.parent.parent)
# Track the user's selected branch (Updates → Advanced selector), or the
@@ -537,6 +537,8 @@ async def _rev_parse(ref: str) -> str:
# Only a real update when the remote is strictly ahead of us — never offer
# an older or divergent commit.
has_updates = await remote_is_strictly_ahead(project_dir, local_sha, remote_sha)
+ if has_updates and await changes_are_docs_only(Path(project_dir), local_sha, remote_sha):
+ has_updates = False
async def _log1(ref: str) -> str:
p = await asyncio.create_subprocess_exec(
diff --git a/tinyagentos/routes/userspace_apps.py b/tinyagentos/routes/userspace_apps.py
new file mode 100644
index 000000000..ea567d2d9
--- /dev/null
+++ b/tinyagentos/routes/userspace_apps.py
@@ -0,0 +1,270 @@
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from urllib.parse import urlparse
+
+import httpx
+from fastapi import APIRouter, Request, UploadFile, File
+from fastapi.responses import JSONResponse, FileResponse, Response
+
+from tinyagentos.userspace.broker import handle_capability, GATED_CAPS
+from tinyagentos.userspace.package import extract_package, PackageError
+from tinyagentos.userspace.url_guard import resolve_safe_public_ip
+
+router = APIRouter()
+
+_SDK_PATH = Path(__file__).resolve().parent.parent / "userspace" / "sdk" / "taos-app-sdk.js"
+
+# Bundle CSP for community (untrusted) packages. The `sandbox allow-scripts`
+# directive (no allow-same-origin) forces the document into an OPAQUE origin
+# even on a direct top-level navigation -- so a userspace bundle can never
+# execute on the core origin with the session cookie (defends against stored
+# XSS), while still letting the app run its own scripts inside our sandboxed
+# iframe. `default-src 'none'` plus the explicit self/inline allowances keep
+# it locked down.
+_BUNDLE_CSP = (
+ "sandbox allow-scripts allow-forms allow-popups; "
+ "default-src 'none'; "
+ "script-src 'self' 'unsafe-inline' blob:; "
+ "style-src 'self' 'unsafe-inline'; "
+ "img-src 'self' data: blob:; "
+ "font-src 'self' data:; "
+ "connect-src 'self'; "
+ "frame-ancestors 'self'; base-uri 'none'"
+)
+
+# Relaxed CSP for first-party packages (studios). Still sandboxed -- NEVER
+# add allow-same-origin; that would collapse the opaque-origin isolation and
+# let the frame access session cookies. The relaxations over community:
+# - style-src allows 'unsafe-inline' (community already does, kept the same)
+# - connect-src 'self' (same as community; lets the SDK reach the broker)
+# The intent is that P4 boot-seeding and P2 signature verification are the
+# ONLY paths that write trust='first-party'; this CSP is not itself a trust
+# grant, just a consequence of trust already verified out-of-band.
+_BUNDLE_CSP_FIRST_PARTY = (
+ "sandbox allow-scripts allow-forms allow-popups; "
+ "default-src 'none'; "
+ "script-src 'self' 'unsafe-inline' blob:; "
+ "style-src 'self' 'unsafe-inline'; "
+ "img-src 'self' data: blob:; "
+ "font-src 'self' data:; "
+ "connect-src 'self'; "
+ "frame-ancestors 'self'; base-uri 'none'"
+)
+
+
+def _apps_root(request: Request) -> Path:
+ return Path(request.app.state.data_dir) / "apps"
+
+
+@router.get("/api/userspace-apps/sdk.js")
+async def serve_sdk(request: Request):
+ resp = FileResponse(_SDK_PATH, media_type="application/javascript")
+ resp.headers["Cache-Control"] = "no-cache"
+ return resp
+
+
+@router.get("/api/userspace-apps")
+async def list_apps(request: Request):
+ return await request.app.state.userspace_apps.list_installed()
+
+
+# Cap the package upload / fetch size to bound memory and pre-filter zip bombs.
+_MAX_PACKAGE_BYTES = 64 * 1024 * 1024
+
+
+@router.post("/api/userspace-apps/install")
+async def install_app(request: Request, package: UploadFile | None = File(default=None)):
+ store = request.app.state.userspace_apps
+ if package is not None:
+ data = await package.read(_MAX_PACKAGE_BYTES + 1)
+ else:
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "invalid JSON body"}, status_code=400)
+ url = body.get("source_url")
+ if not url:
+ return JSONResponse({"error": "source_url or package required"}, status_code=400)
+ # SSRF guard: resolve + validate the host ONCE, then pin the connection
+ # to that validated IP. Re-resolving at fetch time would reopen a
+ # DNS-rebinding TOCTOU window. follow_redirects stays off so a 3xx
+ # cannot bounce to a blocked host.
+ pinned_ip = resolve_safe_public_ip(url)
+ if pinned_ip is None:
+ return JSONResponse(
+ {"error": "source_url is not allowed -- only public http(s) hosts "
+ "(no private, loopback, link-local or reserved addresses)"},
+ status_code=400,
+ )
+ _u = urlparse(url)
+ _ip_host = f"[{pinned_ip}]" if ":" in pinned_ip else pinned_ip
+ _netloc = _ip_host if not _u.port else f"{_ip_host}:{_u.port}"
+ _pinned_url = _u._replace(netloc=_netloc).geturl()
+ _host_header = _u.hostname if not _u.port else f"{_u.hostname}:{_u.port}"
+ try:
+ async with httpx.AsyncClient(timeout=120, follow_redirects=False) as c:
+ # Connect to the pinned IP; keep the original Host header + TLS
+ # SNI so vhost routing and certificate validation still work.
+ resp = await c.get(
+ _pinned_url,
+ headers={"Host": _host_header},
+ extensions={"sni_hostname": _u.hostname},
+ )
+ resp.raise_for_status()
+ data = resp.content
+ except httpx.HTTPStatusError as exc:
+ return JSONResponse(
+ {"error": f"upstream returned {exc.response.status_code}"},
+ status_code=502,
+ )
+ except httpx.HTTPError as exc:
+ return JSONResponse({"error": f"upstream fetch failed: {exc}"}, status_code=502)
+ if len(data) > _MAX_PACKAGE_BYTES:
+ return JSONResponse({"error": "package too large"}, status_code=413)
+ try:
+ manifest = extract_package(data, apps_root=_apps_root(request))
+ except PackageError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+ # Reject container packages before persisting anything -- no partial state.
+ if manifest["app_type"] == "container":
+ return JSONResponse(
+ {"error": "container packages are not supported in this release (web-only)"},
+ status_code=501,
+ )
+ existing = await store.get(manifest["id"])
+ # A public install must never replace an app installed as first-party: that
+ # would let an attacker overwrite a trusted studio's bundle (and, before the
+ # UPSERT fix, inherit its first-party privileges).
+ if existing is not None and existing.get("trust") == "first-party":
+ return JSONResponse(
+ {"error": "an app with this id is installed as first-party "
+ "and cannot be replaced by a public install"},
+ status_code=409,
+ )
+ new_perms = [
+ p for p in manifest["permissions"]
+ if existing and p not in existing["permissions_granted"]
+ ]
+ # trust is always 'community' through this public endpoint -- no manifest
+ # field can elevate it. first-party trust is set only through the internal
+ # boot-seeding path (P4) or after package signature verification (P2).
+ await store.install(
+ app_id=manifest["id"], name=manifest["name"], version=manifest["version"],
+ app_type=manifest["app_type"], entry=manifest["entry"], icon=manifest["icon"],
+ permissions_requested=manifest["permissions"],
+ trust="community",
+ )
+ return {
+ "app_id": manifest["id"],
+ "permissions_requested": manifest["permissions"],
+ "needs_consent": bool(existing and new_perms),
+ "new_permissions": new_perms,
+ }
+
+
+@router.post("/api/userspace-apps/{app_id}/permissions")
+async def set_permissions(request: Request, app_id: str):
+ store = request.app.state.userspace_apps
+ app = await store.get(app_id)
+ if app is None:
+ return JSONResponse({"error": "not found"}, status_code=404)
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "invalid JSON body"}, status_code=400)
+ # Only grant permissions the package actually requested -- a caller cannot
+ # escalate an app to capabilities its manifest never declared.
+ requested = set(app.get("permissions_requested") or [])
+ safe = [p for p in body.get("granted", []) if p in requested]
+ await store.set_permissions_granted(app_id, safe)
+ return {"status": "ok", "granted": safe}
+
+
+@router.post("/api/userspace-apps/{app_id}/enable")
+async def enable_app(request: Request, app_id: str):
+ await request.app.state.userspace_apps.set_enabled(app_id, True)
+ return {"status": "ok"}
+
+
+@router.post("/api/userspace-apps/{app_id}/disable")
+async def disable_app(request: Request, app_id: str):
+ await request.app.state.userspace_apps.set_enabled(app_id, False)
+ return {"status": "ok"}
+
+
+@router.delete("/api/userspace-apps/{app_id}")
+async def uninstall_app(request: Request, app_id: str):
+ store = request.app.state.userspace_apps
+ removed = await store.uninstall(app_id)
+ root = _apps_root(request).resolve()
+ app_dir = (root / app_id).resolve()
+ if app_dir.is_relative_to(root) and app_dir != root and app_dir.exists():
+ shutil.rmtree(app_dir, ignore_errors=True)
+ return {"status": "ok", "removed": removed}
+
+
+@router.get("/api/userspace-apps/{app_id}/bundle/{path:path}")
+async def serve_bundle(request: Request, app_id: str, path: str):
+ root = (_apps_root(request) / app_id).resolve()
+ target = (root / path).resolve()
+ if not target.is_relative_to(root) or target == root or not target.is_file():
+ return JSONResponse({"error": "not found"}, status_code=404)
+ app = await request.app.state.userspace_apps.get(app_id)
+ csp = _BUNDLE_CSP_FIRST_PARTY if (app and app.get("trust") == "first-party") else _BUNDLE_CSP
+ resp = FileResponse(target)
+ resp.headers["Content-Security-Policy"] = csp
+ resp.headers["X-Content-Type-Options"] = "nosniff"
+ return resp
+
+
+@router.get("/api/userspace-apps/{app_id}/icon")
+async def serve_icon(request: Request, app_id: str):
+ app = await request.app.state.userspace_apps.get(app_id)
+ if not app or not app["icon"]:
+ return Response(status_code=404)
+ root = (_apps_root(request) / app_id).resolve()
+ icon = (root / app["icon"]).resolve()
+ if not icon.is_relative_to(root) or icon == root or not icon.is_file():
+ return Response(status_code=404)
+ return FileResponse(icon)
+
+
+def _broker_services(request: Request, app: dict) -> dict:
+ """Core services the broker may expose for gated capabilities. Each optional;
+ absence => the gated capability returns a null/empty result."""
+ st = request.app.state
+ backend_url = None
+ if app.get("container_host") and app.get("container_port"):
+ backend_url = f"http://{app['container_host']}:{app['container_port']}"
+ return {
+ "notifications": getattr(st, "notifications", None),
+ "memory": getattr(st, "user_memory", None),
+ "llm": getattr(st, "llm_proxy", None),
+ "agent": None, # agent-invocation adapter wired in a later increment
+ "app_backend_url": backend_url,
+ }
+
+
+@router.post("/api/userspace-apps/{app_id}/broker")
+async def broker(request: Request, app_id: str):
+ store = request.app.state.userspace_apps
+ app = await store.get(app_id)
+ if app is None or not app["enabled"]:
+ return JSONResponse({"error": "app not found or disabled"}, status_code=404)
+ body = await request.json()
+ # First-party apps have all gated capabilities pre-authorised -- no per-cap
+ # consent step is needed. Community apps use only their explicitly granted set.
+ if app.get("trust") == "first-party":
+ granted = set(GATED_CAPS)
+ else:
+ granted = set(app["permissions_granted"])
+ out = await handle_capability(
+ app_id, body.get("capability", ""), body.get("args") or {},
+ granted=granted,
+ data_store=request.app.state.userspace_data,
+ app_dir=_apps_root(request) / app_id,
+ services=_broker_services(request, app),
+ )
+ return out
diff --git a/tinyagentos/userspace/__init__.py b/tinyagentos/userspace/__init__.py
new file mode 100644
index 000000000..889a587cd
--- /dev/null
+++ b/tinyagentos/userspace/__init__.py
@@ -0,0 +1 @@
+# tinyagentos/userspace -- sandboxed .taosapp runtime (web-only)
diff --git a/tinyagentos/userspace/broker.py b/tinyagentos/userspace/broker.py
new file mode 100644
index 000000000..7046b4812
--- /dev/null
+++ b/tinyagentos/userspace/broker.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+import httpx
+from pathlib import Path
+
+# Headers an app may NOT set on a backend-proxy call -- these would let it
+# spoof identity/routing or exfiltrate the session.
+_BLOCKED_PROXY_HEADERS = {"host", "authorization", "cookie",
+ "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto"}
+
+# Capability namespaces granted to every app without consent.
+FREE_CAPS = {"app.kv", "app.table", "app.files", "app.notify", "app.window"}
+# Capability namespaces that require an explicit granted permission.
+GATED_CAPS = {"app.net", "app.agent", "app.llm", "app.memory"}
+
+
+def _namespace(capability: str) -> str:
+ parts = capability.split(".")
+ return ".".join(parts[:2]) if len(parts) >= 2 else capability
+
+
+async def handle_capability(app_id, capability, args, *, granted, data_store, app_dir, services):
+ """Dispatch one capability call. Returns {"result": ...} or {"error": ...}.
+
+ Enforces: gated caps require membership in `granted`; data calls are
+ namespaced by app_id; file calls are jailed to app_dir/files.
+ """
+ ns = _namespace(capability)
+ if ns not in FREE_CAPS and ns not in GATED_CAPS:
+ return {"error": "unknown_capability", "capability": capability}
+ if ns in GATED_CAPS and ns not in granted:
+ return {"error": "permission_denied", "capability": capability}
+
+ args = args or {}
+
+ # Validate required args up front so a missing key returns a clean error
+ # instead of an uncaught KeyError (a 500). Only caps that index args
+ # directly are listed; the rest use args.get with defaults.
+ _required = {
+ "app.kv.get": ("key",), "app.kv.set": ("key",), "app.kv.delete": ("key",),
+ "app.table.insert": ("table",), "app.table.query": ("table",),
+ "app.table.delete": ("table", "id"),
+ }
+ for _arg in _required.get(capability, ()):
+ if _arg not in args:
+ return {"error": "missing_arg", "arg": _arg}
+
+ if capability == "app.kv.get":
+ return {"result": await data_store.kv_get(app_id, args["key"])}
+ if capability == "app.kv.set":
+ await data_store.kv_set(app_id, args["key"], args.get("value"))
+ return {"result": True}
+ if capability == "app.kv.delete":
+ await data_store.kv_delete(app_id, args["key"])
+ return {"result": True}
+ if capability == "app.kv.keys":
+ return {"result": await data_store.kv_keys(app_id)}
+ if capability == "app.table.insert":
+ return {"result": await data_store.table_insert(app_id, args["table"], args.get("row", {}))}
+ if capability == "app.table.query":
+ return {"result": await data_store.table_query(app_id, args["table"], args.get("where"))}
+ if capability == "app.table.delete":
+ await data_store.table_delete(app_id, args["table"], args["id"])
+ return {"result": True}
+
+ if capability in ("app.files.read", "app.files.write"):
+ files_root = (Path(app_dir) / "files").resolve()
+ target = (files_root / args.get("path", "")).resolve()
+ if target != files_root and not target.is_relative_to(files_root):
+ return {"error": "invalid_path"}
+ if capability == "app.files.read":
+ if not target.is_file():
+ return {"error": "not_found"}
+ return {"result": target.read_text()}
+ # write: reject the jail root itself or any existing directory, which
+ # would otherwise raise an uncaught IsADirectoryError (a 500).
+ if target == files_root or target.is_dir():
+ return {"error": "invalid_path"}
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(args.get("content", ""))
+ return {"result": True}
+
+ if capability == "app.notify":
+ notif = services.get("notifications")
+ if notif is not None:
+ await notif.add_notification({
+ "source": app_id, "title": args.get("title", app_id),
+ "body": args.get("body", ""), "level": "info", "icon": "layout-grid"})
+ return {"result": True}
+ if capability == "app.window":
+ return {"result": True} # window ops handled client-side; no-op server result
+
+ # Gated caps (only reached when granted)
+ if capability == "app.memory.search":
+ mem = services.get("memory")
+ _search = getattr(mem, "search", None) if mem is not None else None
+ if _search is None:
+ return {"result": []}
+ try:
+ return {"result": await _search(args.get("q", ""))}
+ except TypeError:
+ return {"result": []}
+ if capability == "app.agent":
+ agent = services.get("agent")
+ return {"result": await agent.ask(args.get("name"), args.get("message")) if agent else None}
+ if capability == "app.llm":
+ llm = services.get("llm")
+ return {"result": await llm.complete(args.get("prompt", "")) if llm else None}
+ if capability == "app.net":
+ base = services.get("app_backend_url")
+ if not base:
+ return {"error": "no_backend"}
+ path = str(args.get("path", "/"))
+ if "://" in path or path.startswith("//") or ".." in path.split("/"):
+ return {"error": "invalid_path"}
+ url = base.rstrip("/") + "/" + path.lstrip("/")
+ method = str(args.get("method", "GET")).upper()
+ _raw = args.get("headers") or {}
+ _headers = {k: v for k, v in _raw.items()
+ if k.lower() not in _BLOCKED_PROXY_HEADERS} or None
+ try:
+ async with httpx.AsyncClient(timeout=30, follow_redirects=False) as c:
+ resp = await c.request(
+ method, url,
+ json=args.get("body") if args.get("body") is not None else None,
+ headers=_headers,
+ )
+ return {"result": {"status": resp.status_code, "body": resp.text}}
+ except httpx.HTTPError as exc:
+ return {"error": "backend_unreachable", "detail": str(exc)}
+
+ return {"error": "unknown_capability", "capability": capability}
diff --git a/tinyagentos/userspace/data_store.py b/tinyagentos/userspace/data_store.py
new file mode 100644
index 000000000..2035d9826
--- /dev/null
+++ b/tinyagentos/userspace/data_store.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+import json
+
+from tinyagentos.base_store import BaseStore
+
+
+class UserspaceDataStore(BaseStore):
+ """Per-app KV + table storage, namespaced by app_id. Every read/write is
+ filtered by app_id so one app can never see another app's data."""
+
+ SCHEMA = """
+ CREATE TABLE IF NOT EXISTS app_kv (
+ app_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value_json TEXT NOT NULL,
+ PRIMARY KEY (app_id, key)
+ );
+ CREATE TABLE IF NOT EXISTS app_rows (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ app_id TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ row_json TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_app_rows ON app_rows (app_id, table_name);
+ """
+
+ async def kv_get(self, app_id: str, key: str):
+ assert self._db is not None
+ cur = await self._db.execute(
+ "SELECT value_json FROM app_kv WHERE app_id=? AND key=?", (app_id, key))
+ row = await cur.fetchone()
+ return json.loads(row[0]) if row else None
+
+ async def kv_set(self, app_id: str, key: str, value) -> None:
+ assert self._db is not None
+ await self._db.execute(
+ "INSERT INTO app_kv (app_id, key, value_json) VALUES (?,?,?) "
+ "ON CONFLICT(app_id, key) DO UPDATE SET value_json=excluded.value_json",
+ (app_id, key, json.dumps(value)))
+ await self._db.commit()
+
+ async def kv_delete(self, app_id: str, key: str) -> None:
+ assert self._db is not None
+ await self._db.execute("DELETE FROM app_kv WHERE app_id=? AND key=?", (app_id, key))
+ await self._db.commit()
+
+ async def kv_keys(self, app_id: str) -> list[str]:
+ assert self._db is not None
+ cur = await self._db.execute(
+ "SELECT key FROM app_kv WHERE app_id=? ORDER BY key", (app_id,))
+ return [r[0] for r in await cur.fetchall()]
+
+ async def table_insert(self, app_id: str, table: str, row: dict) -> int:
+ assert self._db is not None
+ cur = await self._db.execute(
+ "INSERT INTO app_rows (app_id, table_name, row_json) VALUES (?,?,?)",
+ (app_id, table, json.dumps(row)))
+ await self._db.commit()
+ return cur.lastrowid
+
+ async def table_query(self, app_id: str, table: str, where: dict | None) -> list[dict]:
+ assert self._db is not None
+ cur = await self._db.execute(
+ "SELECT id, row_json FROM app_rows WHERE app_id=? AND table_name=? ORDER BY id",
+ (app_id, table))
+ out = []
+ for rid, row_json in await cur.fetchall():
+ data = json.loads(row_json)
+ if where and any(data.get(k) != v for k, v in where.items()):
+ continue
+ out.append({"id": rid, **data})
+ return out
+
+ async def table_delete(self, app_id: str, table: str, row_id: int) -> None:
+ assert self._db is not None
+ await self._db.execute(
+ "DELETE FROM app_rows WHERE app_id=? AND table_name=? AND id=?",
+ (app_id, table, row_id))
+ await self._db.commit()
diff --git a/tinyagentos/userspace/package.py b/tinyagentos/userspace/package.py
new file mode 100644
index 000000000..8075ce8aa
--- /dev/null
+++ b/tinyagentos/userspace/package.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import io
+import zipfile
+from pathlib import Path
+
+import yaml
+
+_ALLOWED_TYPES = {"web", "container"}
+_REQUIRED = ("id", "name", "version", "app_type")
+
+# Zip-bomb defenses: cap the declared uncompressed total, per-member size, and count.
+_MAX_UNCOMPRESSED_BYTES = 256 * 1024 * 1024
+_MAX_MEMBER_BYTES = 64 * 1024 * 1024
+_MAX_MEMBERS = 10000
+
+
+class PackageError(Exception):
+ """Raised when a .taosapp package is invalid or unsafe."""
+
+
+def parse_manifest(text: str) -> dict:
+ try:
+ data = yaml.safe_load(text) or {}
+ except yaml.YAMLError as exc:
+ raise PackageError(f"invalid manifest YAML: {exc}") from exc
+ if not isinstance(data, dict):
+ raise PackageError(
+ f"manifest.yaml must be a YAML mapping, got {type(data).__name__}"
+ )
+ for key in _REQUIRED:
+ if not data.get(key):
+ raise PackageError(f"manifest missing required field: {key}")
+ if data["app_type"] not in _ALLOWED_TYPES:
+ raise PackageError(
+ f"app_type {data['app_type']!r} not allowed for userspace apps "
+ f"(native is reserved for first-party core); use one of {_ALLOWED_TYPES}"
+ )
+ if data["app_type"] == "container":
+ container = data.get("container")
+ if not isinstance(container, dict):
+ raise PackageError("container app requires a 'container' block")
+ if not container.get("image") or not isinstance(container.get("image"), str):
+ raise PackageError("container app requires container.image")
+ ports = container.get("ports")
+ if (
+ not isinstance(ports, list)
+ or len(ports) == 0
+ or not all(isinstance(p, int) and not isinstance(p, bool) for p in ports)
+ ):
+ raise PackageError("container app requires container.ports as a non-empty list of ints")
+ data.setdefault("entry", "index.html")
+ data.setdefault("icon", "")
+ data.setdefault("permissions", [])
+ return data
+
+
+def extract_package(data: bytes, apps_root: Path) -> dict:
+ """Validate + extract a .taosapp zip into apps_root/{id}/. Returns the manifest."""
+ try:
+ zf = zipfile.ZipFile(io.BytesIO(data))
+ except zipfile.BadZipFile as exc:
+ raise PackageError("not a valid .taosapp (zip) archive") from exc
+ infos = zf.infolist()
+ if len(infos) > _MAX_MEMBERS:
+ raise PackageError(f"package has too many files ({len(infos)} > {_MAX_MEMBERS})")
+ total_uncompressed = 0
+ for zi in infos:
+ if zi.file_size > _MAX_MEMBER_BYTES:
+ raise PackageError(f"package member too large: {zi.filename}")
+ total_uncompressed += zi.file_size
+ if total_uncompressed > _MAX_UNCOMPRESSED_BYTES:
+ raise PackageError(
+ f"package uncompressed size too large ({total_uncompressed} bytes)"
+ )
+ try:
+ manifest = parse_manifest(zf.read("manifest.yaml").decode("utf-8"))
+ except KeyError as exc:
+ raise PackageError("manifest.yaml missing from package") from exc
+
+ apps_root = Path(apps_root).resolve()
+ app_dir = (apps_root / manifest["id"]).resolve()
+ # app_dir itself must stay within apps_root (defends against a crafted id)
+ if not app_dir.is_relative_to(apps_root) or app_dir == apps_root:
+ raise PackageError(f"unsafe path in package: id {manifest['id']!r}")
+ app_dir.mkdir(parents=True, exist_ok=True)
+ for member in zf.namelist():
+ if member.endswith("/"):
+ continue
+ dest = (app_dir / member).resolve()
+ # dest must be a file strictly inside app_dir -- reject traversals and
+ # members that resolve to app_dir itself (e.g. "." -> IsADirectoryError)
+ if dest == app_dir or not dest.is_relative_to(app_dir):
+ raise PackageError(f"unsafe path in package: {member}")
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_bytes(zf.read(member))
+ return manifest
diff --git a/tinyagentos/userspace/sdk/taos-app-sdk.js b/tinyagentos/userspace/sdk/taos-app-sdk.js
new file mode 100644
index 000000000..1dcb0c06f
--- /dev/null
+++ b/tinyagentos/userspace/sdk/taos-app-sdk.js
@@ -0,0 +1,85 @@
+// tinyagentos/userspace/sdk/taos-app-sdk.js
+(() => {
+ const APP_ID = new URLSearchParams(location.search).get("app")
+ || (document.currentScript && document.currentScript.dataset.appId) || "";
+ let seq = 0;
+ const pending = new Map();
+
+ // --- Theme API state ---
+ let _themeTokens = {};
+ const _themeSubscribers = [];
+
+ window.addEventListener("message", (e) => {
+ const m = e.data;
+ // Broker replies
+ if (m && m.taosAppReply != null && pending.has(m.taosAppReply)) {
+ const { resolve } = pending.get(m.taosAppReply);
+ pending.delete(m.taosAppReply);
+ resolve(m);
+ }
+ // Theme push from the shell (only first-party apps receive this)
+ if (m && m.taosTheme && typeof m.taosTheme === "object" && !Array.isArray(m.taosTheme)) {
+ _themeTokens = m.taosTheme;
+ for (const cb of _themeSubscribers) {
+ try { cb(_themeTokens); } catch (_) {}
+ }
+ }
+ });
+
+ function call(capability, args) {
+ const id = ++seq;
+ return new Promise((resolve) => {
+ pending.set(id, { resolve });
+ parent.postMessage({ taosApp: APP_ID, id, capability, args: args || {} }, "*");
+ });
+ }
+
+ window.taos = {
+ appId: APP_ID,
+ kv: {
+ get: (k) => call("app.kv.get", { key: k }).then((r) => r.result),
+ set: (k, v) => call("app.kv.set", { key: k, value: v }),
+ delete: (k) => call("app.kv.delete", { key: k }),
+ keys: () => call("app.kv.keys", {}).then((r) => r.result),
+ },
+ table: {
+ insert: (t, row) => call("app.table.insert", { table: t, row }).then((r) => r.result),
+ query: (t, where) => call("app.table.query", { table: t, where }).then((r) => r.result),
+ delete: (t, id) => call("app.table.delete", { table: t, id }),
+ },
+ files: {
+ read: (p) => call("app.files.read", { path: p }).then((r) => r.result),
+ write: (p, content) => call("app.files.write", { path: p, content }),
+ },
+ notify: (title, body) => call("app.notify", { title, body }),
+ // gated -- resolve to {error:"permission_denied"} if not granted
+ net: { fetch: (url, opts) => call("app.net", { path: url, method: (opts && opts.method) || "GET", body: opts && opts.body, headers: opts && opts.headers }) },
+ backend: {
+ fetch: (path, opts) => call("app.net", {
+ path,
+ method: (opts && opts.method) || "GET",
+ body: opts && opts.body,
+ headers: opts && opts.headers,
+ }).then((r) => r.result),
+ },
+ agent: { ask: (name, message) => call("app.agent", { name, message }).then((r) => r.result) },
+ memory: { search: (q) => call("app.memory.search", { q }).then((r) => r.result) },
+ // Theme API -- populated only for first-party apps that receive taosTheme
+ // messages from the shell. Community apps never receive these messages.
+ theme: {
+ /** Returns the last set of CSS variable tokens received from the shell. */
+ get: () => ({ ..._themeTokens }),
+ /**
+ * Register a callback to be called whenever the shell posts new theme
+ * tokens. Returns an unsubscribe function.
+ */
+ subscribe: (cb) => {
+ _themeSubscribers.push(cb);
+ return () => {
+ const i = _themeSubscribers.indexOf(cb);
+ if (i !== -1) _themeSubscribers.splice(i, 1);
+ };
+ },
+ },
+ };
+})();
diff --git a/tinyagentos/userspace/seed.py b/tinyagentos/userspace/seed.py
new file mode 100644
index 000000000..8f7f7bfa5
--- /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, 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 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 000000000..f404383e5
--- /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 000000000..d61fe2b8a
--- /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
diff --git a/tinyagentos/userspace/store.py b/tinyagentos/userspace/store.py
new file mode 100644
index 000000000..5fbe0d643
--- /dev/null
+++ b/tinyagentos/userspace/store.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+import json
+import time
+
+from tinyagentos.base_store import BaseStore
+
+
+class UserspaceAppStore(BaseStore):
+ """Registry of installed userspace apps (sandboxed .taosapp packages).
+ Distinct from InstalledAppsStore (catalog services). Userspace apps are
+ web (iframe) or container; never in-process 'native'.
+
+ BaseStore.init() runs SCHEMA and sets self._db -- do NOT override init().
+ """
+
+ SCHEMA = """
+ CREATE TABLE IF NOT EXISTS userspace_apps (
+ app_id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ version TEXT NOT NULL DEFAULT '',
+ app_type TEXT NOT NULL,
+ entry TEXT NOT NULL DEFAULT 'index.html',
+ icon TEXT NOT NULL DEFAULT '',
+ permissions_requested TEXT NOT NULL DEFAULT '[]',
+ permissions_granted TEXT NOT NULL DEFAULT '[]',
+ enabled INTEGER NOT NULL DEFAULT 1,
+ installed_at INTEGER NOT NULL,
+ container_host TEXT,
+ container_port INTEGER
+ );
+ """
+
+ async def _post_init(self) -> None:
+ """Add the trust column to databases created before it was introduced.
+
+ SQLite has no IF NOT EXISTS for ADD COLUMN prior to 3.37, so we check
+ PRAGMA table_info for broad compatibility (same pattern used by
+ knowledge_store and agent_registry_store).
+ """
+ existing_cols = {
+ row[1]
+ for row in await (
+ await self._db.execute("PRAGMA table_info(userspace_apps)")
+ ).fetchall()
+ }
+ if "trust" not in existing_cols:
+ await self._db.execute(
+ "ALTER TABLE userspace_apps ADD COLUMN trust TEXT NOT NULL DEFAULT 'community'"
+ )
+ await self._db.commit()
+
+ async def install(self, app_id, name, version, app_type, entry, icon,
+ permissions_requested, *, trust: str = "community"):
+ assert self._db is not None
+ await self._db.execute(
+ """INSERT INTO userspace_apps
+ (app_id, name, version, app_type, entry, icon,
+ permissions_requested, permissions_granted, enabled, installed_at, trust)
+ VALUES (?,?,?,?,?,?,?,'[]',1,?,?)
+ ON CONFLICT(app_id) DO UPDATE SET
+ name=excluded.name, version=excluded.version,
+ app_type=excluded.app_type, entry=excluded.entry,
+ icon=excluded.icon,
+ permissions_requested=excluded.permissions_requested,
+ trust=excluded.trust""",
+ (app_id, name, version, app_type, entry, icon,
+ json.dumps(permissions_requested), int(time.time()), trust),
+ )
+ await self._db.commit()
+
+ def _row_to_dict(self, row) -> dict:
+ return {
+ "app_id": row[0], "name": row[1], "version": row[2],
+ "app_type": row[3], "entry": row[4], "icon": row[5],
+ "permissions_requested": json.loads(row[6]),
+ "permissions_granted": json.loads(row[7]),
+ "enabled": row[8], "installed_at": row[9],
+ "container_host": row[10], "container_port": row[11],
+ "trust": row[12] if len(row) > 12 else "community",
+ }
+
+ async def get(self, app_id) -> dict | None:
+ assert self._db is not None
+ cur = await self._db.execute("SELECT * FROM userspace_apps WHERE app_id=?", (app_id,))
+ row = await cur.fetchone()
+ return self._row_to_dict(row) if row else None
+
+ async def list_installed(self) -> list[dict]:
+ assert self._db is not None
+ cur = await self._db.execute("SELECT * FROM userspace_apps ORDER BY installed_at")
+ return [self._row_to_dict(r) for r in await cur.fetchall()]
+
+ async def set_permissions_granted(self, app_id, perms):
+ assert self._db is not None
+ await self._db.execute("UPDATE userspace_apps SET permissions_granted=? WHERE app_id=?",
+ (json.dumps(perms), app_id))
+ await self._db.commit()
+
+ async def set_enabled(self, app_id, enabled: bool):
+ assert self._db is not None
+ await self._db.execute("UPDATE userspace_apps SET enabled=? WHERE app_id=?",
+ (1 if enabled else 0, app_id))
+ await self._db.commit()
+
+ async def set_runtime_location(self, app_id, host: str, port: int):
+ assert self._db is not None
+ await self._db.execute(
+ "UPDATE userspace_apps SET container_host=?, container_port=? WHERE app_id=?",
+ (host, port, app_id),
+ )
+ await self._db.commit()
+
+ async def uninstall(self, app_id) -> bool:
+ assert self._db is not None
+ cur = await self._db.execute("DELETE FROM userspace_apps WHERE app_id=?", (app_id,))
+ await self._db.commit()
+ return cur.rowcount > 0
diff --git a/tinyagentos/userspace/url_guard.py b/tinyagentos/userspace/url_guard.py
new file mode 100644
index 000000000..8a9504727
--- /dev/null
+++ b/tinyagentos/userspace/url_guard.py
@@ -0,0 +1,69 @@
+"""SSRF guard for userspace app installs.
+
+POST /api/userspace-apps/install can fetch a .taosapp from a caller-supplied
+source_url. Without validation that lets an authenticated user make the
+controller fetch internal addresses (cloud metadata at 169.254.169.254,
+localhost services, private LAN ranges) -- a classic SSRF. This module rejects
+non-public hosts before any request is made; the caller should also use
+follow_redirects=False so a 3xx cannot bounce to a blocked host.
+"""
+from __future__ import annotations
+
+import socket
+from ipaddress import ip_address
+from urllib.parse import urlparse
+
+_ALLOWED_SCHEMES = {"http", "https"}
+
+
+def _is_blocked_ip(ip) -> bool:
+ return bool(
+ ip.is_private or ip.is_loopback or ip.is_link_local
+ or ip.is_reserved or ip.is_unspecified or ip.is_multicast
+ )
+
+
+def resolve_safe_public_ip(url: str) -> str | None:
+ """Resolve url's host ONCE and return a validated public IP to connect to,
+ or None if the url is not http(s) or any resolved address is non-public.
+
+ Rejects private, loopback, link-local, reserved, unspecified and multicast
+ addresses (covers 127/8, ::1, 10/8, 172.16/12, 192.168/16, 169.254/16,
+ 0.0.0.0, etc.). The caller MUST connect to this pinned IP (keeping the
+ original Host header and TLS SNI) rather than letting the HTTP client
+ re-resolve the hostname -- re-resolution reopens a DNS-rebinding TOCTOU
+ window where the resolved-and-validated address differs from the one
+ actually connected to.
+ """
+ try:
+ parsed = urlparse(url)
+ except ValueError:
+ return None
+ if parsed.scheme not in _ALLOWED_SCHEMES or not parsed.hostname:
+ return None
+ try:
+ infos = socket.getaddrinfo(parsed.hostname, parsed.port or None)
+ except (socket.gaierror, UnicodeError, OSError):
+ return None
+ if not infos:
+ return None
+ chosen: str | None = None
+ for info in infos:
+ try:
+ ip = ip_address(info[4][0])
+ except ValueError:
+ return None
+ if _is_blocked_ip(ip):
+ return None
+ if chosen is None:
+ chosen = str(ip)
+ return chosen
+
+
+def is_safe_public_url(url: str) -> bool:
+ """True only if url is http(s) and every resolved IP is a public address.
+
+ Thin wrapper over resolve_safe_public_ip; prefer that function at the call
+ site so the connection can be pinned to the validated IP.
+ """
+ return resolve_safe_public_ip(url) is not None