Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/node_modules
64 changes: 64 additions & 0 deletions desktop/src/apps/SandboxedAppWindow.tsx
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 }),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
result = res.ok ? await res.json() : { error: `broker_${res.status}` };
} catch {
result = { error: "broker_unreachable" };
}
iframe.contentWindow?.postMessage({ taosAppReply: msg.id, ...result }, "*");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Broker replies are posted with targetOrigin "*"

The parent sends capability results to the iframe with "*". If the iframe navigates away, the new child origin could receive broker results. Use a fixed target origin for the known bundle origin when possible, or otherwise tighten the reply channel.

}
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)}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Launcher hard-codes index.html despite manifest entry

The manifest stores entry, but SandboxedAppWindow always loads /bundle/index.html. Packages with a different entry file will install successfully but fail to launch. Pass the app entry path through the registry/manifest and use it in the iframe src.

sandbox="allow-scripts"
className="w-full h-full border-0 bg-white"
/>
);
}
69 changes: 69 additions & 0 deletions desktop/src/apps/__tests__/SandboxedAppWindow.test.tsx
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({});
}
});
});
72 changes: 72 additions & 0 deletions desktop/src/lib/__tests__/userspace-apps.test.ts
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"] });
});
});
81 changes: 81 additions & 0 deletions desktop/src/lib/userspace-apps.ts
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);
}
2 changes: 1 addition & 1 deletion desktop/src/registry/app-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export interface AppManifest {
id: string;
name: string;
icon: string;
category: "platform" | "os" | "streaming" | "game";
category: "platform" | "os" | "streaming" | "game" | "userspace";
component: () => Promise<{ default: ComponentType<{ windowId: string }> }>;
defaultSize: { w: number; h: number };
minSize: { w: number; h: number };
Expand Down
Empty file added tests/userspace/__init__.py
Empty file.
75 changes: 75 additions & 0 deletions tests/userspace/conftest.py
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()
Loading
Loading