-
-
Notifications
You must be signed in to change notification settings - Fork 21
feat(userspace): web app-runtime foundation (#89 P3a) #970
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 @@ | ||
| /Volumes/NVMe/Users/jay/Development/tinyagentos/desktop/node_modules |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| // desktop/src/apps/SandboxedAppWindow.tsx | ||
| import { useEffect, useRef } from "react"; | ||
|
|
||
| interface Props { | ||
| windowId: string; | ||
| appId: string; | ||
| } | ||
|
|
||
| interface BrokerRequest { | ||
| taosApp: string; | ||
| id: number; | ||
| capability: string; | ||
| args?: Record<string, unknown>; | ||
| } | ||
|
|
||
| export function SandboxedAppWindow({ appId }: Props) { | ||
| const iframeRef = useRef<HTMLIFrameElement>(null); | ||
|
|
||
| useEffect(() => { | ||
| async function onMessage(e: MessageEvent) { | ||
| const iframe = iframeRef.current; | ||
| // Only handle messages from THIS app's sandboxed iframe. | ||
| if (!iframe || e.source !== iframe.contentWindow) return; | ||
| const msg = e.data as BrokerRequest; | ||
| if (!msg || msg.taosApp !== appId || typeof msg.id !== "number" || !msg.capability) return; | ||
| // Validate args: must be a plain object (not an array, null, or primitive). | ||
| // Non-conforming values are coerced to {} rather than forwarded as-is into | ||
| // backend capability handling. | ||
| const rawArgs = msg.args; | ||
| const safeArgs: Record<string, unknown> = | ||
| rawArgs !== null && typeof rawArgs === "object" && !Array.isArray(rawArgs) | ||
| ? rawArgs | ||
| : {}; | ||
| let result: Record<string, unknown>; | ||
| try { | ||
| const res = await fetch(`/api/userspace-apps/${encodeURIComponent(appId)}/broker`, { | ||
| method: "POST", | ||
| // Carry the taos_session cookie so the broker authenticates the | ||
| // caller -- matches every other SPA API call, and stays correct | ||
| // under the Vite dev proxy (SPA :5173 -> API :6969). | ||
| credentials: "include", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ capability: msg.capability, args: safeArgs }), | ||
| }); | ||
| result = res.ok ? await res.json() : { error: `broker_${res.status}` }; | ||
| } catch { | ||
| result = { error: "broker_unreachable" }; | ||
| } | ||
| iframe.contentWindow?.postMessage({ taosAppReply: msg.id, ...result }, "*"); | ||
|
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. WARNING: Broker replies are posted with The parent sends capability results to the iframe with |
||
| } | ||
| window.addEventListener("message", onMessage); | ||
| return () => window.removeEventListener("message", onMessage); | ||
| }, [appId]); | ||
|
|
||
| return ( | ||
| <iframe | ||
| ref={iframeRef} | ||
| title={appId} | ||
| src={`/api/userspace-apps/${encodeURIComponent(appId)}/bundle/index.html?app=${encodeURIComponent(appId)}`} | ||
|
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. WARNING: Launcher hard-codes The manifest stores |
||
| sandbox="allow-scripts" | ||
| className="w-full h-full border-0 bg-white" | ||
| /> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| // desktop/src/apps/__tests__/SandboxedAppWindow.test.tsx | ||
| import { describe, it, expect, vi, afterEach } from "vitest"; | ||
| import { render, screen, waitFor } from "@testing-library/react"; | ||
| import { SandboxedAppWindow } from "../SandboxedAppWindow"; | ||
|
|
||
| afterEach(() => vi.unstubAllGlobals()); | ||
|
|
||
| describe("SandboxedAppWindow", () => { | ||
| it("renders a locked-down sandbox iframe pointed at the bundle", () => { | ||
| render(<SandboxedAppWindow windowId="w1" appId="todo" />); | ||
| const iframe = screen.getByTitle("todo") as HTMLIFrameElement; | ||
| expect(iframe.getAttribute("sandbox")).toBe("allow-scripts"); | ||
| expect(iframe.getAttribute("src")).toBe("/api/userspace-apps/todo/bundle/index.html?app=todo"); | ||
| }); | ||
|
|
||
| it("bridges a capability message to the broker and posts the reply back", async () => { | ||
| const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ result: 42 }) }); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
| render(<SandboxedAppWindow windowId="w1" appId="todo" />); | ||
| const iframe = screen.getByTitle("todo") as HTMLIFrameElement; | ||
| const post = vi.fn(); | ||
| Object.defineProperty(iframe, "contentWindow", { value: { postMessage: post }, configurable: true }); | ||
|
|
||
| window.dispatchEvent(new MessageEvent("message", { | ||
| source: iframe.contentWindow as Window, | ||
| data: { taosApp: "todo", id: 1, capability: "app.kv.get", args: { key: "k" } }, | ||
| })); | ||
|
|
||
| await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( | ||
| "/api/userspace-apps/todo/broker", | ||
| expect.objectContaining({ method: "POST" }), | ||
| )); | ||
| await waitFor(() => expect(post).toHaveBeenCalledWith( | ||
| expect.objectContaining({ taosAppReply: 1, result: 42 }), "*")); | ||
| }); | ||
|
|
||
| it("ignores messages from other sources", async () => { | ||
| const fetchMock = vi.fn(); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
| render(<SandboxedAppWindow windowId="w1" appId="todo" />); | ||
| window.dispatchEvent(new MessageEvent("message", { | ||
| source: window, // not the iframe | ||
| data: { taosApp: "todo", id: 9, capability: "app.kv.get", args: {} }, | ||
| })); | ||
| expect(fetchMock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("coerces non-object args to {} before forwarding to broker", async () => { | ||
| // Finding 1: array, null, or scalar args must not be forwarded as-is. | ||
| const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ result: "ok" }) }); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
| render(<SandboxedAppWindow windowId="w1" appId="todo" />); | ||
| const iframe = screen.getByTitle("todo") as HTMLIFrameElement; | ||
| Object.defineProperty(iframe, "contentWindow", { | ||
| value: { postMessage: vi.fn() }, configurable: true | ||
| }); | ||
|
|
||
| for (const badArgs of [["array"], null, "string", 42]) { | ||
| fetchMock.mockClear(); | ||
| window.dispatchEvent(new MessageEvent("message", { | ||
| source: iframe.contentWindow as Window, | ||
| data: { taosApp: "todo", id: 2, capability: "app.kv.get", args: badArgs }, | ||
| })); | ||
| await waitFor(() => expect(fetchMock).toHaveBeenCalled()); | ||
| const body = JSON.parse(fetchMock.mock.calls[0][1].body); | ||
| expect(body.args).toEqual({}); | ||
| } | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import { describe, it, expect, vi, afterEach } from "vitest"; | ||
| import { fetchUserspaceApps, toAppManifest, installUserspaceApp, grantUserspacePermissions } from "../userspace-apps"; | ||
|
|
||
| describe("userspace apps", () => { | ||
| it("maps a userspace app row to an AppManifest in the 'userspace' category", () => { | ||
| const m = toAppManifest({ app_id: "todo", name: "Todo", icon: "", app_type: "web", version: "1", enabled: 1, permissions_requested: [], permissions_granted: [] }); | ||
| expect(m.id).toBe("todo"); | ||
| expect(m.name).toBe("Todo"); | ||
| expect(m.category).toBe("userspace"); | ||
| expect(typeof m.component).toBe("function"); | ||
| }); | ||
|
|
||
| it("fetchUserspaceApps returns only enabled apps as manifests", async () => { | ||
| vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [ | ||
| { app_id: "a", name: "A", icon: "", app_type: "web", version: "1", enabled: 1, permissions_requested: [], permissions_granted: [] }, | ||
| { app_id: "b", name: "B", icon: "", app_type: "web", version: "1", enabled: 0, permissions_requested: [], permissions_granted: [] }, | ||
| ]})); | ||
| const apps = await fetchUserspaceApps(); | ||
| expect(apps.map(a => a.id)).toEqual(["a"]); | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("fetchUserspaceApps returns [] on fetch failure", async () => { | ||
| vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false })); | ||
| expect(await fetchUserspaceApps()).toEqual([]); | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("installUserspaceApp", () => { | ||
| afterEach(() => vi.unstubAllGlobals()); | ||
|
|
||
| it("posts multipart to install endpoint and returns parsed InstallResult", async () => { | ||
| const mockResult = { app_id: "todo", permissions_requested: ["app.net"], needs_consent: false, new_permissions: [] }; | ||
| const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResult }); | ||
| vi.stubGlobal("fetch", mockFetch); | ||
|
|
||
| const file = new File(["data"], "todo.taosapp"); | ||
| const result = await installUserspaceApp(file); | ||
|
|
||
| expect(result).toEqual(mockResult); | ||
| expect(mockFetch).toHaveBeenCalledWith( | ||
| "/api/userspace-apps/install", | ||
| expect.objectContaining({ method: "POST", credentials: "include" }) | ||
| ); | ||
| }); | ||
|
|
||
| it("throws with server error string when res.ok is false", async () => { | ||
| vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: "bundle too large" }) })); | ||
|
|
||
| const file = new File(["data"], "todo.taosapp"); | ||
| await expect(installUserspaceApp(file)).rejects.toThrow("bundle too large"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("grantUserspacePermissions", () => { | ||
| afterEach(() => vi.unstubAllGlobals()); | ||
|
|
||
| it("posts JSON granted list to the permissions URL with credentials include", async () => { | ||
| const mockFetch = vi.fn().mockResolvedValue({ ok: true }); | ||
| vi.stubGlobal("fetch", mockFetch); | ||
|
|
||
| await grantUserspacePermissions("todo", ["app.net"]); | ||
|
|
||
| expect(mockFetch).toHaveBeenCalledWith( | ||
| "/api/userspace-apps/todo/permissions", | ||
| expect.objectContaining({ method: "POST", credentials: "include" }) | ||
| ); | ||
| const callArgs = mockFetch.mock.calls[0][1]; | ||
| expect(JSON.parse(callArgs.body)).toEqual({ granted: ["app.net"] }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import type { AppManifest } from "@/registry/app-registry"; | ||
|
|
||
| export interface UserspaceAppRow { | ||
| app_id: string; | ||
| name: string; | ||
| icon: string; | ||
| app_type: "web" | "container"; | ||
| version: string; | ||
| enabled: number; | ||
| permissions_requested: string[]; | ||
| permissions_granted: string[]; | ||
| } | ||
|
|
||
| export function toAppManifest(row: UserspaceAppRow): AppManifest { | ||
| return { | ||
| id: row.app_id, | ||
| name: row.name, | ||
| icon: "layout-grid", | ||
| category: "userspace", | ||
| component: () => | ||
| import("@/apps/SandboxedAppWindow").then((m) => ({ | ||
| default: (props: { windowId: string }) => | ||
| m.SandboxedAppWindow({ ...props, appId: row.app_id }), | ||
| })), | ||
| defaultSize: { w: 900, h: 600 }, | ||
| minSize: { w: 360, h: 280 }, | ||
| singleton: true, | ||
| pinned: false, | ||
| launchpadOrder: 100, | ||
| }; | ||
| } | ||
|
|
||
| export interface InstallResult { | ||
| app_id: string; | ||
| permissions_requested: string[]; | ||
| needs_consent: boolean; | ||
| new_permissions: string[]; | ||
| } | ||
|
|
||
| export async function installUserspaceApp(file: File): Promise<InstallResult> { | ||
| const form = new FormData(); | ||
| form.append("package", file); | ||
| const res = await fetch("/api/userspace-apps/install", { | ||
| method: "POST", | ||
| credentials: "include", | ||
| body: form, | ||
| }); | ||
| if (!res.ok) { | ||
| let detail = `install failed (${res.status})`; | ||
| try { | ||
| const body = await res.json(); | ||
| if (body?.error) detail = body.error; | ||
| } catch { | ||
| // non-JSON error body; keep the status-based message | ||
| } | ||
| throw new Error(detail); | ||
| } | ||
| return (await res.json()) as InstallResult; | ||
| } | ||
|
|
||
| export async function grantUserspacePermissions(appId: string, granted: string[]): Promise<void> { | ||
| const res = await fetch(`/api/userspace-apps/${encodeURIComponent(appId)}/permissions`, { | ||
| method: "POST", | ||
| credentials: "include", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ granted }), | ||
| }); | ||
| if (!res.ok) throw new Error(`granting permissions failed (${res.status})`); | ||
| } | ||
|
|
||
| export async function fetchUserspaceApps(): Promise<AppManifest[]> { | ||
| let rows: UserspaceAppRow[]; | ||
| try { | ||
| const res = await fetch("/api/userspace-apps"); | ||
| if (!res.ok) return []; | ||
| rows = (await res.json()) as UserspaceAppRow[]; | ||
| } catch { | ||
| return []; | ||
| } | ||
| return rows.filter((r) => r.enabled).map(toAppManifest); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| """Shared fixtures for tests/userspace/ route-level tests.""" | ||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
| import pytest_asyncio | ||
| import yaml | ||
| from httpx import ASGITransport, AsyncClient | ||
|
|
||
| from tinyagentos.app import create_app | ||
| from tinyagentos.userspace.store import UserspaceAppStore | ||
| from tinyagentos.userspace.data_store import UserspaceDataStore | ||
|
|
||
|
|
||
| def _make_app(tmp_path): | ||
| config = { | ||
| "server": {"host": "0.0.0.0", "port": 6969}, | ||
| "backends": [], | ||
| "qmd": {"url": "http://localhost:7832"}, | ||
| "agents": [], | ||
| "metrics": {"poll_interval": 30, "retention_days": 30}, | ||
| } | ||
| config_path = tmp_path / "config.yaml" | ||
| config_path.write_text(yaml.dump(config)) | ||
| (tmp_path / ".setup_complete").touch() | ||
| app = create_app(data_dir=tmp_path) | ||
| from tinyagentos.routes.desktop_browser.vapid import load_or_create_vapid_keypair | ||
| app.state.vapid_keypair = load_or_create_vapid_keypair(tmp_path) | ||
| return app | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def app(tmp_path): | ||
| return _make_app(tmp_path) | ||
|
|
||
|
|
||
| @pytest_asyncio.fixture | ||
| async def client(app, tmp_path): | ||
| """Async client with userspace stores initialised and auth set up.""" | ||
| # Initialise the minimal set of stores that routes require. | ||
| await app.state.metrics.init() | ||
| await app.state.notifications.init() | ||
| await app.state.qmd_client.init() | ||
| await app.state.secrets.init() | ||
|
|
||
| # Userspace stores -- created fresh per test in tmp_path. | ||
| userspace_apps = UserspaceAppStore(tmp_path / "userspace_apps.db") | ||
| await userspace_apps.init() | ||
| app.state.userspace_apps = userspace_apps | ||
|
|
||
| userspace_data = UserspaceDataStore(tmp_path / "userspace_data.db") | ||
| await userspace_data.init() | ||
| app.state.userspace_data = userspace_data | ||
|
|
||
| # Auth setup. | ||
| app.state.auth.setup_user("admin", "Test Admin", "", "testpass") | ||
| record = app.state.auth.find_user("admin") | ||
| uid = record["id"] if record else "" | ||
| token = app.state.auth.create_session(user_id=uid, long_lived=True) | ||
| app.state._startup_complete = True | ||
|
|
||
| transport = ASGITransport(app=app) | ||
| async with AsyncClient( | ||
| transport=transport, | ||
| base_url="http://test", | ||
| cookies={"taos_session": token}, | ||
| ) as c: | ||
| yield c | ||
|
|
||
| await userspace_data.close() | ||
| await userspace_apps.close() | ||
| await app.state.secrets.close() | ||
| await app.state.qmd_client.close() | ||
| await app.state.notifications.close() | ||
| await app.state.metrics.close() | ||
| await app.state.http_client.aclose() |
Uh oh!
There was an error while loading. Please reload this page.