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
119 changes: 110 additions & 9 deletions desktop/src/apps/SandboxedAppWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,102 @@
// desktop/src/apps/SandboxedAppWindow.tsx
import { useEffect, useRef } from "react";
import { ShieldAlert } from "lucide-react";
import { useThemeStore } from "@/stores/theme-store";
import { ALLOWED_TOKENS } from "@/theme/theme-config";
import { defaultProvenanceForTrust, type AppProvenance } from "@/lib/userspace-apps";

interface Props {
windowId: string;
appId: string;
trust?: "community" | "first-party";
/** Where the app's code came from. Falls back to defaultProvenanceForTrust(trust)
* when omitted, so a caller that only knows the legacy `trust` field still
* gets a sensible classification. */
provenance?: AppProvenance;
/** Capabilities the user has explicitly granted this app -- drives the
* network-blocked badge and mirrors what the broker actually enforces. */
grantedCapabilities?: string[];
}

/** Mirrors tinyagentos/userspace/capabilities.py PROVENANCE_CEILINGS -- the
* default (no-consent) capability ceiling per tier. Only used here to decide
* what the badge shows; the backend broker is the actual enforcement point. */
const PROVENANCE_CEILINGS: Record<AppProvenance, readonly string[]> = {
"first-party": ["app.kv", "app.table", "app.files", "app.notify", "app.window",
"app.net", "app.agent", "app.llm", "app.memory"],
"ai-generated": ["app.notify", "app.window"],
"user-uploaded": ["app.notify", "app.window"],
unknown: [],
};

const PROVENANCE_LABELS: Record<AppProvenance, string> = {
"first-party": "Built-in",
"ai-generated": "AI-generated",
"user-uploaded": "User-uploaded",
unknown: "Unknown origin",
};

/** The `a.b` namespace of a capability, e.g. `app.kv.get` -> `app.kv`.
* Mirrors _namespace in tinyagentos/userspace/capabilities.py. */
function namespaceOf(capability: string): string {
const parts = capability.split(".");
return parts.length >= 2 ? parts.slice(0, 2).join(".") : capability;
}

/** True if `capability`'s namespace is free for this tier or was explicitly
* granted -- the same namespace-based "ceiling OR grant" rule the backend's
* capability_allowed enforces (so a sub-capability like `app.kv.get` matches
* the `app.kv` ceiling entry, not just an exact string). */
function hasCapability(
provenance: AppProvenance,
capability: string,
granted: readonly string[],
): boolean {
const ns = namespaceOf(capability);
return (
PROVENANCE_CEILINGS[provenance].includes(ns) ||
granted.includes(ns) ||
granted.includes(capability)
);
}

/**
* The iframe `sandbox` attribute, derived from provenance. Every tier
* resolves to the same minimal "allow-scripts" today: it is already the
* tightest value that still lets the app's own script run, and adding tokens
* like allow-forms/allow-popups/allow-same-origin would LOOSEN the existing
* restriction for whichever tier got them (allow-same-origin in particular
* would let a sandboxed app execute on the core origin -- never acceptable).
* The function exists (rather than a bare constant) so the derivation is a
* single, provenance-aware, testable place to tighten further per tier later.
*/
function computeSandboxAttr(_provenance: AppProvenance): string {
return "allow-scripts";
}

/** Small corner badge showing an app's provenance + whether it can reach the
* network. Skipped for first-party apps (built-in, nothing to warn about). */
function ProvenanceBadge({
provenance,
networkAllowed,
}: {
provenance: AppProvenance;
networkAllowed: boolean;
}) {
if (provenance === "first-party") return null;
return (
<div
data-testid="provenance-badge"
data-provenance={provenance}
className="pointer-events-none absolute right-2 top-2 z-10 flex items-center gap-1 rounded-full bg-black/60 px-2 py-1 text-[10px] font-semibold text-white/90 backdrop-blur-sm"
>
<ShieldAlert size={11} />
<span>{PROVENANCE_LABELS[provenance]}</span>
<span className="text-white/60">
{networkAllowed ? "· Network allowed" : "· Network blocked"}
</span>
</div>
);
}

interface BrokerRequest {
Expand All @@ -28,9 +118,16 @@ function readThemeTokens(): Record<string, string> {
return tokens;
}

export function SandboxedAppWindow({ appId, trust = "community" }: Props) {
export function SandboxedAppWindow({
appId,
trust = "community",
provenance,
grantedCapabilities = [],
}: Props) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const isFirstParty = trust === "first-party";
const resolvedProvenance: AppProvenance = provenance ?? defaultProvenanceForTrust(trust);
const networkAllowed = hasCapability(resolvedProvenance, "app.net", grantedCapabilities);
// Subscribe to scheme changes (which fire on any applyThemeConfig call) so we
// can push updated tokens when the theme changes. The selector is minimal to
// avoid re-renders on unrelated store updates.
Expand Down Expand Up @@ -90,13 +187,17 @@ export function SandboxedAppWindow({ appId, trust = "community" }: Props) {
}, [appId]);

return (
<iframe
ref={iframeRef}
title={appId}
src={`/api/userspace-apps/${encodeURIComponent(appId)}/bundle/index.html?app=${encodeURIComponent(appId)}`}
sandbox="allow-scripts"
className="w-full h-full border-0 bg-white"
onLoad={handleLoad}
/>
<div className="relative h-full w-full">
<ProvenanceBadge provenance={resolvedProvenance} networkAllowed={networkAllowed} />
<iframe
ref={iframeRef}
title={appId}
src={`/api/userspace-apps/${encodeURIComponent(appId)}/bundle/index.html?app=${encodeURIComponent(appId)}`}
sandbox={computeSandboxAttr(resolvedProvenance)}
data-provenance={resolvedProvenance}
className="w-full h-full border-0 bg-white"
onLoad={handleLoad}
/>
</div>
);
}
62 changes: 62 additions & 0 deletions desktop/src/apps/__tests__/SandboxedAppWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,68 @@ describe("SandboxedAppWindow -- theme injection", () => {
});
});

describe("SandboxedAppWindow -- provenance tiers", () => {
it("never widens the sandbox attribute beyond allow-scripts for any tier", () => {
for (const provenance of ["first-party", "ai-generated", "user-uploaded", "unknown"] as const) {
const { unmount } = render(
<SandboxedAppWindow windowId="w1" appId={`app-${provenance}`} provenance={provenance} />
);
const iframe = screen.getByTitle(`app-${provenance}`) as HTMLIFrameElement;
expect(iframe.getAttribute("sandbox")).toBe("allow-scripts");
expect(iframe.getAttribute("data-provenance")).toBe(provenance);
unmount();
}
});

it("shows no badge for a first-party app", () => {
render(<SandboxedAppWindow windowId="w1" appId="fp" provenance="first-party" />);
expect(screen.queryByTestId("provenance-badge")).toBeNull();
});

it("shows an AI-generated badge with network blocked by default", () => {
render(<SandboxedAppWindow windowId="w1" appId="agent-app" provenance="ai-generated" />);
const badge = screen.getByTestId("provenance-badge");
expect(badge.textContent).toContain("AI-generated");
expect(badge.textContent).toContain("Network blocked");
});

it("shows network allowed once app.net is granted", () => {
render(
<SandboxedAppWindow
windowId="w1"
appId="agent-app"
provenance="ai-generated"
grantedCapabilities={["app.net"]}
/>
);
const badge = screen.getByTestId("provenance-badge");
expect(badge.textContent).toContain("Network allowed");
});

it("treats network as allowed for a first-party app via its ceiling namespace", () => {
// app.net is in the first-party ceiling, so the namespace check reports
// network allowed with no explicit grant -- exercises the ceiling branch
// of the namespace-aware hasCapability.
render(<SandboxedAppWindow windowId="w1" appId="fp" provenance="first-party" />);
// First-party shows no badge, but the underlying capability check is what
// drives it; assert the badge is absent (network-allowed first-party path).
expect(screen.queryByTestId("provenance-badge")).toBeNull();
});

it("shows the unknown-origin badge as network blocked", () => {
render(<SandboxedAppWindow windowId="w1" appId="mystery" provenance="unknown" />);
const badge = screen.getByTestId("provenance-badge");
expect(badge.textContent).toContain("Unknown origin");
expect(badge.textContent).toContain("Network blocked");
});

it("falls back to defaultProvenanceForTrust when provenance is omitted", () => {
render(<SandboxedAppWindow windowId="w1" appId="legacy-fp" trust="first-party" />);
expect(screen.getByTitle("legacy-fp").getAttribute("data-provenance")).toBe("first-party");
expect(screen.queryByTestId("provenance-badge")).toBeNull();
});
});

describe("SDK theme API (mirrors taos-app-sdk.js handler)", () => {
// The SDK ships as a plain IIFE loaded inside the sandbox iframe, so it cannot
// be imported here without eval/new-Function (a flagged pattern). These tests
Expand Down
14 changes: 12 additions & 2 deletions desktop/src/apps/appstudio/PublishView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Folder, Bell, Users, Shield, Upload, Share2, Download, CheckSquare } from "lucide-react";
import { Folder, Bell, Users, Shield, Sparkles, Upload, Share2, Download, CheckSquare } from "lucide-react";

/* ------------------------------------------------------------------ */
/* PublishView -- app identity + capabilities + side publish panel */
Expand Down Expand Up @@ -66,7 +66,17 @@ export function PublishView() {
<CheckSquare size={30} />
</div>
<div>
<div className="text-[19px] font-extrabold tracking-[-0.02em]">Chore Quest</div>
<div className="flex items-center gap-[8px]">
<span className="text-[19px] font-extrabold tracking-[-0.02em]">Chore Quest</span>
<span
data-testid="provenance-badge"
data-provenance="ai-generated"
className="flex items-center gap-1 rounded-full border border-shell-border bg-shell-surface px-[8px] py-[2px] text-[10px] font-semibold text-shell-text-secondary"
>
<Sparkles size={11} />
AI-generated
</span>
</div>
<div className="mt-[3px] text-[12.5px] text-shell-text-secondary">
A weekly chore tracker with points and a family leaderboard.
</div>
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/apps/appstudio/__tests__/PublishView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PublishView } from "../PublishView";

describe("PublishView -- provenance badge", () => {
it("shows an AI-generated provenance badge next to the app name", () => {
render(<PublishView />);
const badge = screen.getByTestId("provenance-badge");
expect(badge.getAttribute("data-provenance")).toBe("ai-generated");
expect(badge.textContent).toContain("AI-generated");
});
});
21 changes: 20 additions & 1 deletion desktop/src/lib/userspace-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import type { AppManifest } from "@/registry/app-registry";
*/
export const USERSPACE_APPS_CHANGED = "taos:userspace-apps-changed";

/** Where an app's code came from. See tinyagentos/userspace/capabilities.py
* PROVENANCE_TIERS for the single source of truth this mirrors. */
export type AppProvenance = "first-party" | "ai-generated" | "user-uploaded" | "unknown";

export interface UserspaceAppRow {
app_id: string;
name: string;
Expand All @@ -16,10 +20,19 @@ export interface UserspaceAppRow {
permissions_requested: string[];
permissions_granted: string[];
trust?: "community" | "first-party";
provenance?: AppProvenance;
}

/** Back-compat default for a row with no provenance recorded: legacy
* first-party built-ins classify first-party, everything else user-uploaded
* -- mirrors default_provenance_for_trust in the backend. */
export function defaultProvenanceForTrust(trust?: string): AppProvenance {
return trust === "first-party" ? "first-party" : "user-uploaded";
}

export function toAppManifest(row: UserspaceAppRow): AppManifest {
const trust = row.trust ?? "community";
const provenance = row.provenance ?? defaultProvenanceForTrust(trust);
return {
// Registry id is namespaced (mirrors "service:") so a community app cannot
// shadow a built-in app id. The broker/bundle still use the raw app_id.
Expand All @@ -30,7 +43,13 @@ export function toAppManifest(row: UserspaceAppRow): AppManifest {
component: () =>
import("@/apps/SandboxedAppWindow").then((m) => ({
default: (props: { windowId: string }) =>
m.SandboxedAppWindow({ ...props, appId: row.app_id, trust }),
m.SandboxedAppWindow({
...props,
appId: row.app_id,
trust,
provenance,
grantedCapabilities: row.permissions_granted,
}),
})),
defaultSize: { w: 900, h: 600 },
minSize: { w: 360, h: 280 },
Expand Down
Loading
Loading