diff --git a/apps/web/src/components/settings/ExperimentsSettingsPanel.tsx b/apps/web/src/components/settings/ExperimentsSettingsPanel.tsx index 541838b7126c..f8f90dbc0317 100644 --- a/apps/web/src/components/settings/ExperimentsSettingsPanel.tsx +++ b/apps/web/src/components/settings/ExperimentsSettingsPanel.tsx @@ -19,6 +19,8 @@ import { } from "./settingsLayout"; // T3-CUSTOM(expbkt3): native plan review (moved here from the removed Beta panel). import { searchableSetting } from "./settingsSearch"; +// T3-CUSTOM(expbkt3): Agent views are runtime-disabled after URL frames proved untruthful. +import { AGENT_UI_SURFACES_RUNTIME_ENABLED } from "../../fork/agentUiRuntime"; export function ExperimentsSettingsPanel() { const phaseGroupedSidebarEnabled = useClientSettings( @@ -55,10 +57,11 @@ export function ExperimentsSettingsPanel() { {/* T3-CUSTOM(expbkt3): BEGIN — agent-rendered UI surfaces in chat. */} updateSettings({ agentUiSurfacesEnabled: Boolean(checked) }) } diff --git a/apps/web/src/fork/agentUiRuntime.ts b/apps/web/src/fork/agentUiRuntime.ts new file mode 100644 index 000000000000..801eb64ab478 --- /dev/null +++ b/apps/web/src/fork/agentUiRuntime.ts @@ -0,0 +1,9 @@ +/** + * T3-CUSTOM(expbkt3): emergency gate for agent-rendered chat surfaces. + * + * URL targets can intentionally disable live behavior whenever they run in an + * iframe, then fall back to origin-local state that is unrelated to the URL the + * agent supplied. Until T3 has a truthful generic URL-surface contract, no + * persisted client preference may turn Agent views back on. + */ +export const AGENT_UI_SURFACES_RUNTIME_ENABLED = false; diff --git a/apps/web/src/fork/agentUiSurface.dom.test.tsx b/apps/web/src/fork/agentUiSurface.dom.test.tsx index 5137f3031af3..caa56e03f7b1 100644 --- a/apps/web/src/fork/agentUiSurface.dom.test.tsx +++ b/apps/web/src/fork/agentUiSurface.dom.test.tsx @@ -3,30 +3,37 @@ import type { ReactNode } from "react"; import { flushSync } from "react-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const queryStates = vi.hoisted( - () => - new Map< - string, - { - data?: { render: Record }; - isPending: boolean; - error?: string; - } - >(), -); +const testState = vi.hoisted(() => ({ + queryCalls: [] as string[], + queries: new Map< + string, + { + data?: { render: Record }; + isPending: boolean; + error?: string; + } + >(), +})); +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: (selector: (settings: { agentUiSurfacesEnabled: boolean }) => unknown) => + selector({ agentUiSurfacesEnabled: true }), +})); vi.mock("../state/agentUi", () => ({ agentUiEnvironment: { - render: ({ input }: { input: { renderId: string } }) => input, + render: ({ input }: { input: { renderId: string } }) => { + testState.queryCalls.push(input.renderId); + return input; + }, }, })); vi.mock("../state/query", () => ({ useEnvironmentQuery: ({ renderId }: { renderId: string }) => - queryStates.get(renderId) ?? { isPending: true }, + testState.queries.get(renderId) ?? { isPending: true }, })); -import { AgentUiRenderFrame, AgentUiUrlFrame } from "./agentUiSurface"; -import { useAgentUiUrlFrameCoordinator } from "./agentUiUrlFrameCoordinator"; +import { useAgentUiExpandedStore } from "../agentUiExpandedStore"; +import { AgentUiExpandedSurface, AgentUiRenderFrame, AgentUiSurfaceRow } from "./agentUiSurface"; const THREAD_REF = { environmentId: EnvironmentId.make("environment-fixture"), @@ -34,11 +41,7 @@ const THREAD_REF = { } as const; const FIRST_URL = "https://fixture.example.test/board#room=alpha,safe-key-a"; const SECOND_URL = "https://fixture.example.test/board#room=beta,safe-key-b"; -const mutations: string[] = []; -// ReactDOM needs a host, but this focused lifecycle suite intentionally has no -// browser dependency. The host records iframe attachment order so a switch can -// prove that the old browsing context disconnected before the new one mounted. class TestNode { parentNode: TestNode | null = null; childNodes: TestNode[] = []; @@ -70,7 +73,6 @@ class TestNode { appendChild(child: TestNode) { child.parentNode = this; this.childNodes.push(child); - if (child.tagName === "IFRAME") mutations.push(`attach:${child.getAttribute("src")}`); return child; } @@ -79,12 +81,10 @@ class TestNode { const index = this.childNodes.indexOf(before); child.parentNode = this; this.childNodes.splice(index, 0, child); - if (child.tagName === "IFRAME") mutations.push(`attach:${child.getAttribute("src")}`); return child; } removeChild(child: TestNode) { - if (child.tagName === "IFRAME") mutations.push(`detach:${child.getAttribute("src")}`); this.childNodes.splice(this.childNodes.indexOf(child), 1); child.parentNode = null; return child; @@ -120,7 +120,6 @@ function installTestDom() { const document = new TestNode("#document", null, 9); const window = { document, - location: { origin: "https://t3.example.test" }, HTMLIFrameElement: TestNode, setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout, @@ -134,16 +133,6 @@ function installTestDom() { return document; } -function urlFrame(renderId: string, url: string, createdAt: string, placement = "inline") { - return ( - - ); -} - function iframeNodes(root: TestNode): TestNode[] { return root.childNodes.flatMap((child) => [ ...(child.tagName === "IFRAME" ? [child] : []), @@ -151,28 +140,32 @@ function iframeNodes(root: TestNode): TestNode[] { ]); } +function renderedText(root: TestNode): string { + return [ + ...(root.childNodes.length === 0 && root.nodeValue !== null ? [root.nodeValue] : []), + ...root.childNodes.map(renderedText), + ].join(""); +} + async function render(root: { render: (children: ReactNode) => void }, children: ReactNode) { flushSync(() => root.render(children)); await Promise.resolve(); flushSync(() => undefined); } -describe("AgentUiUrlFrame DOM lifecycle", () => { +describe("Agent view runtime mitigation", () => { beforeEach(() => { - mutations.length = 0; - queryStates.clear(); - useAgentUiUrlFrameCoordinator.getState().reset(); + testState.queryCalls.length = 0; + testState.queries.clear(); + useAgentUiExpandedStore.getState().collapse(); }); afterEach(async () => { - // React's development scheduler posts an Immediate after a root commits. - // Let it drain while the fake window still exists so parallel CI cannot - // observe a callback after Vitest restores the Node globals. await new Promise((resolve) => setImmediate(resolve)); vi.unstubAllGlobals(); }); - it("keeps exact same-origin URLs distinct and replaces the iframe on A to B to A", async () => { + it("ignores a persisted enabled preference and leaves only the ordinary tool row", async () => { const document = installTestDom(); const { createRoot } = await import("react-dom/client"); const container = document.createElement("div"); @@ -181,128 +174,72 @@ describe("AgentUiUrlFrame DOM lifecycle", () => { try { await render( root, - <> - {urlFrame("aui_alpha", FIRST_URL, "2026-08-29T10:00:00.000Z")} - {urlFrame("aui_beta", SECOND_URL, "2026-08-29T10:01:00.000Z")} - , + + ordinary tool row + , ); - const [betaNode] = iframeNodes(container); - expect(betaNode?.getAttribute("src")).toBe(SECOND_URL); - expect(betaNode?.getAttribute("credentialless")).toBe(""); - expect(betaNode?.getAttribute("sandbox")).toContain("allow-same-origin"); - - flushSync(() => - useAgentUiUrlFrameCoordinator - .getState() - .activate("inline:environment-fixture:thread-fixture:aui_alpha"), - ); - await Promise.resolve(); - flushSync(() => undefined); - const [alphaNode] = iframeNodes(container); - expect(betaNode?.parentNode).toBeNull(); - expect(alphaNode).not.toBe(betaNode); - expect(alphaNode?.getAttribute("src")).toBe(FIRST_URL); - - flushSync(() => - useAgentUiUrlFrameCoordinator - .getState() - .activate("inline:environment-fixture:thread-fixture:aui_beta"), - ); - await Promise.resolve(); - flushSync(() => undefined); - const [nextBetaNode] = iframeNodes(container); - expect(alphaNode?.parentNode).toBeNull(); - expect(nextBetaNode).not.toBe(alphaNode); - expect(nextBetaNode).not.toBe(betaNode); - expect(nextBetaNode?.getAttribute("src")).toBe(SECOND_URL); - expect(mutations).toEqual([ - `attach:${SECOND_URL}`, - `detach:${SECOND_URL}`, - `attach:${FIRST_URL}`, - `detach:${FIRST_URL}`, - `attach:${SECOND_URL}`, - ]); + expect(renderedText(container)).toBe("ordinary tool row"); + expect(iframeNodes(container)).toHaveLength(0); + expect(testState.queryCalls).toEqual([]); } finally { flushSync(() => root.unmount()); } }); - it("gives an expanded frame exclusive priority and restores inline after it closes", async () => { + it("keeps an already-populated expanded store closed", async () => { const document = installTestDom(); const { createRoot } = await import("react-dom/client"); const container = document.createElement("div"); const root = createRoot(container as unknown as Element); + useAgentUiExpandedStore.getState().expand({ threadRef: THREAD_REF, renderId: "aui_alpha" }); try { - await render( - root, - <> - {urlFrame("aui_beta", SECOND_URL, "2026-08-29T10:01:00.000Z")} - {urlFrame("aui_alpha", FIRST_URL, "2026-08-29T10:00:00.000Z", "expanded")} - , - ); - const [expandedNode] = iframeNodes(container); - expect(iframeNodes(container)).toHaveLength(1); - expect(expandedNode?.getAttribute("src")).toBe(FIRST_URL); - - await render(root, urlFrame("aui_beta", SECOND_URL, "2026-08-29T10:01:00.000Z")); - const [inlineNode] = iframeNodes(container); - expect(expandedNode?.parentNode).toBeNull(); - expect(iframeNodes(container)).toHaveLength(1); - expect(inlineNode).not.toBe(expandedNode); - expect(inlineNode?.getAttribute("src")).toBe(SECOND_URL); + await render(root, ); + expect(renderedText(container)).toBe(""); + expect(iframeNodes(container)).toHaveLength(0); + expect(testState.queryCalls).toEqual([]); } finally { flushSync(() => root.unmount()); } }); - it("disconnects an expanded iframe while the replacement query is pending", async () => { + it("never mounts either exact same-origin room URL if the inner frame is called directly", async () => { const document = installTestDom(); const { createRoot } = await import("react-dom/client"); const container = document.createElement("div"); const root = createRoot(container as unknown as Element); - const firstRender = { - renderId: "aui_alpha", - title: "First", - kind: "url", - url: FIRST_URL, - createdAt: "2026-08-29T10:00:00.000Z", - }; - const secondRender = { - renderId: "aui_beta", - title: "Second", - kind: "url", - url: SECOND_URL, - createdAt: "2026-08-29T10:01:00.000Z", - }; - queryStates.set(firstRender.renderId, { data: { render: firstRender }, isPending: false }); - queryStates.set(secondRender.renderId, { isPending: true }); - - const expandedFrame = (renderId: string) => ( - undefined} - /> - ); + for (const [renderId, url] of [ + ["aui_alpha", FIRST_URL], + ["aui_beta", SECOND_URL], + ] as const) { + testState.queries.set(renderId, { + data: { + render: { + renderId, + title: renderId, + kind: "url", + url, + createdAt: "2026-08-29T10:00:00.000Z", + }, + }, + isPending: false, + }); + } try { - await render(root, expandedFrame(firstRender.renderId)); - const [firstNode] = iframeNodes(container); - expect(firstNode?.getAttribute("src")).toBe(FIRST_URL); - - await render(root, expandedFrame(secondRender.renderId)); - expect(firstNode?.parentNode).toBeNull(); + await render(root, ); expect(iframeNodes(container)).toHaveLength(0); + expect(renderedText(container)).toContain("URL Agent views are temporarily disabled"); - queryStates.set(secondRender.renderId, { data: { render: secondRender }, isPending: false }); - await render(root, expandedFrame(secondRender.renderId)); - const [secondNode] = iframeNodes(container); - expect(secondNode).not.toBe(firstNode); - expect(secondNode?.getAttribute("src")).toBe(SECOND_URL); + await render(root, ); + expect(iframeNodes(container)).toHaveLength(0); + expect(renderedText(container)).toContain("URL Agent views are temporarily disabled"); + expect(FIRST_URL).not.toBe(SECOND_URL); + expect(testState.queryCalls).toEqual(["aui_alpha", "aui_beta"]); } finally { flushSync(() => root.unmount()); } diff --git a/apps/web/src/fork/agentUiSurface.test.ts b/apps/web/src/fork/agentUiSurface.test.ts index 61715abbab64..5ce9d88f012c 100644 --- a/apps/web/src/fork/agentUiSurface.test.ts +++ b/apps/web/src/fork/agentUiSurface.test.ts @@ -7,7 +7,8 @@ */ import { describe, expect, it } from "vite-plus/test"; -import { resolveAgentUiSurface, resolveEmbedPolicy, resolveEmbedSandbox } from "./agentUiSurface"; +import { resolveAgentUiSurface } from "./agentUiSurface"; +import { AGENT_UI_SURFACES_RUNTIME_ENABLED } from "./agentUiRuntime"; describe("resolveAgentUiSurface", () => { it("reads a well-formed handle", () => { @@ -43,36 +44,8 @@ describe("resolveAgentUiSurface", () => { }); }); -describe("resolveEmbedSandbox", () => { - const page = "https://bkt3.dev.beknown.live"; - - it("gives a cross-origin app its own origin back so storage works", () => { - const sandbox = resolveEmbedSandbox("https://draw-canvas.dev.beknown.live/", page); - // Without this, localStorage and IndexedDB throw and real apps never boot. - expect(sandbox).toContain("allow-same-origin"); - expect(sandbox).toContain("allow-scripts"); - expect(resolveEmbedPolicy("https://draw-canvas.dev.beknown.live/", page)).toEqual({ - sandbox, - credentialless: true, - }); - }); - - it("withholds allow-same-origin from a self-referential embed", () => { - // allow-scripts + allow-same-origin on our OWN origin is a sandbox escape: - // the frame could reach the signed-in session directly. - for (const url of [page, `${page}/settings`, `${page}/?x=1#y`]) { - expect(resolveEmbedSandbox(url, page)).not.toContain("allow-same-origin"); - expect(resolveEmbedPolicy(url, page).credentialless).toBe(false); - } - }); - - it("treats a different port or scheme on the same host as cross-origin", () => { - expect(resolveEmbedSandbox("https://bkt3.dev.beknown.live:8443/", page)).toContain( - "allow-same-origin", - ); - }); - - it("falls back to the locked-down sandbox for an unparseable url", () => { - expect(resolveEmbedSandbox("not a url", page)).not.toContain("allow-same-origin"); +describe("Agent view runtime gate", () => { + it("is fail-closed independently of persisted client settings", () => { + expect(AGENT_UI_SURFACES_RUNTIME_ENABLED).toBe(false); }); }); diff --git a/apps/web/src/fork/agentUiSurface.tsx b/apps/web/src/fork/agentUiSurface.tsx index 076a1d4d9f20..11e77659577a 100644 --- a/apps/web/src/fork/agentUiSurface.tsx +++ b/apps/web/src/fork/agentUiSurface.tsx @@ -24,7 +24,7 @@ import { agentUiEnvironment } from "../state/agentUi"; import { useAgentUiExpandedStore } from "../agentUiExpandedStore"; import { useEnvironmentQuery } from "../state/query"; import { cn } from "../lib/utils"; -import { useAgentUiUrlFrameCoordinator } from "./agentUiUrlFrameCoordinator"; +import { AGENT_UI_SURFACES_RUNTIME_ENABLED } from "./agentUiRuntime"; /** The handle `ActivityPayloadProjection` keeps on an MCP tool-call payload. */ export interface AgentUiSurfaceHandle { @@ -71,129 +71,6 @@ function toSrcDoc(html: string): string { ].join(""); } -const EMBED_SANDBOX_BASE = "allow-scripts allow-forms allow-popups allow-downloads"; - -/** - * Sandbox for a framed URL. - * - * A real app needs its own origin back: without `allow-same-origin` the document - * is opaque, so `localStorage`, IndexedDB and cookies all throw. Excalidraw and - * anything else that persists state simply fails to boot without it. - * - * Pairing `allow-same-origin` with `allow-scripts` is only an escape when the - * framed document is same-origin with *this* page — then it can reach our DOM, - * our storage and the user's session directly. So it is withheld exactly there, - * which leaves a self-referential embed opaque and harmless instead of handing - * an agent arbitrary script in the signed-in app. - */ -export function resolveEmbedSandbox(url: string, pageOrigin: string): string { - let origin: string; - try { - origin = new URL(url).origin; - } catch { - return EMBED_SANDBOX_BASE; - } - return origin === pageOrigin ? EMBED_SANDBOX_BASE : `${EMBED_SANDBOX_BASE} allow-same-origin`; -} - -export interface AgentUiEmbedPolicy { - readonly sandbox: string; - readonly credentialless: boolean; -} - -/** Cross-origin apps get an ephemeral credential shelf when the browser supports it. */ -export function resolveEmbedPolicy(url: string, pageOrigin: string): AgentUiEmbedPolicy { - const sandbox = resolveEmbedSandbox(url, pageOrigin); - return { - sandbox, - credentialless: sandbox.split(" ").includes("allow-same-origin"), - }; -} - -function resolveUrlOrigin(url: string, renderId: string): string { - try { - return new URL(url).origin; - } catch { - // The server rejects this shape, but a corrupt legacy row should remain - // locked down and must not share ownership with another bad URL. - return `opaque:${renderId}`; - } -} - -const CREDENTIALLESS_IFRAME_PROPS = { credentialless: "" } as const; - -export const AgentUiUrlFrame = memo(function AgentUiUrlFrame(props: { - readonly render: { - readonly renderId: string; - readonly title: string; - readonly url: string; - readonly createdAt: string; - }; - readonly threadRef: ScopedThreadRef; - readonly placement: "inline" | "expanded"; -}) { - const { render, threadRef, placement } = props; - const origin = useMemo( - () => resolveUrlOrigin(render.url, render.renderId), - [render.renderId, render.url], - ); - const slotId = `${placement}:${threadRef.environmentId}:${threadRef.threadId}:${render.renderId}`; - const register = useAgentUiUrlFrameCoordinator((state) => state.register); - const unregister = useAgentUiUrlFrameCoordinator((state) => state.unregister); - const activate = useAgentUiUrlFrameCoordinator((state) => state.activate); - const settle = useAgentUiUrlFrameCoordinator((state) => state.settle); - const activeSlot = useAgentUiUrlFrameCoordinator( - (state) => state.activeSlotByOrigin[origin] ?? null, - ); - const pendingSlot = useAgentUiUrlFrameCoordinator( - (state) => state.pendingSlotByOrigin[origin] ?? null, - ); - - useEffect(() => { - register({ - slotId, - renderId: render.renderId, - origin, - createdAt: render.createdAt, - priority: placement, - }); - return () => unregister(slotId); - }, [origin, placement, register, render.createdAt, render.renderId, slotId, unregister]); - - // `activate` first commits an empty owner. Settling from an effect makes the - // requested iframe a later commit, after the previous DOM node disconnected. - useEffect(() => { - if (activeSlot === null && pendingSlot === slotId) settle(origin); - }, [activeSlot, origin, pendingSlot, settle, slotId]); - - if (activeSlot !== slotId) { - return ( -
- -
- ); - } - - const policy = resolveEmbedPolicy(render.url, window.location.origin); - return ( -