undefined}
+ />
+ );
+
+ 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();
+ expect(iframeNodes(container)).toHaveLength(0);
+
+ 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);
+ } finally {
+ flushSync(() => root.unmount());
+ }
+ });
+});
diff --git a/apps/web/src/fork/agentUiSurface.test.ts b/apps/web/src/fork/agentUiSurface.test.ts
index 9114da206390..61715abbab64 100644
--- a/apps/web/src/fork/agentUiSurface.test.ts
+++ b/apps/web/src/fork/agentUiSurface.test.ts
@@ -7,7 +7,7 @@
*/
import { describe, expect, it } from "vite-plus/test";
-import { resolveAgentUiSurface, resolveEmbedSandbox } from "./agentUiSurface";
+import { resolveAgentUiSurface, resolveEmbedPolicy, resolveEmbedSandbox } from "./agentUiSurface";
describe("resolveAgentUiSurface", () => {
it("reads a well-formed handle", () => {
@@ -51,6 +51,10 @@ describe("resolveEmbedSandbox", () => {
// 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", () => {
@@ -58,6 +62,7 @@ describe("resolveEmbedSandbox", () => {
// 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);
}
});
diff --git a/apps/web/src/fork/agentUiSurface.tsx b/apps/web/src/fork/agentUiSurface.tsx
index 7186fb396812..076a1d4d9f20 100644
--- a/apps/web/src/fork/agentUiSurface.tsx
+++ b/apps/web/src/fork/agentUiSurface.tsx
@@ -24,6 +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";
/** The handle `ActivityPayloadProjection` keeps on an MCP tool-call payload. */
export interface AgentUiSurfaceHandle {
@@ -95,14 +96,113 @@ export function resolveEmbedSandbox(url: string, pageOrigin: string): string {
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 (
+
+ );
+});
+
/**
* Fetches one render and mounts it. Shared by the inline card and the expanded
* overlay so both agree on sandboxing, loading and failure states — the sandbox
* rules in particular must never drift between the two.
*/
-const AgentUiRenderFrame = memo(function AgentUiRenderFrame(props: {
+export const AgentUiRenderFrame = memo(function AgentUiRenderFrame(props: {
readonly threadRef: ScopedThreadRef;
readonly renderId: string;
+ readonly placement: "inline" | "expanded";
readonly onTitle?: ((title: string) => void) | undefined;
}) {
const { environmentId, threadId } = props.threadRef;
@@ -158,13 +258,15 @@ const AgentUiRenderFrame = memo(function AgentUiRenderFrame(props: {
}
if (render.url) {
return (
-
);
}
@@ -207,8 +309,10 @@ function AgentUiSurfaceCardImpl({ threadRef, surface }: AgentUiSurfaceCardProps)
{collapsed ? null : (
@@ -296,8 +400,10 @@ export const AgentUiExpandedSurface = memo(function AgentUiExpandedSurface() {
diff --git a/apps/web/src/fork/agentUiUrlFrameCoordinator.test.ts b/apps/web/src/fork/agentUiUrlFrameCoordinator.test.ts
new file mode 100644
index 000000000000..8022701e967c
--- /dev/null
+++ b/apps/web/src/fork/agentUiUrlFrameCoordinator.test.ts
@@ -0,0 +1,107 @@
+/**
+ * T3-CUSTOM(expbkt3): same-origin URL-frame ownership.
+ *
+ * A switch is deliberately two-phase: first no slot is active, then the next
+ * slot mounts. That committed empty phase is what proves two same-origin apps
+ * cannot overlap while React moves ownership between timeline and overlay.
+ */
+import { beforeEach, describe, expect, it } from "vite-plus/test";
+
+import { useAgentUiUrlFrameCoordinator } from "./agentUiUrlFrameCoordinator";
+
+const first = {
+ slotId: "inline:aui_first",
+ renderId: "aui_first",
+ origin: "https://fixture.example.test",
+ createdAt: "2026-08-29T08:00:00.000Z",
+ priority: "inline" as const,
+};
+const second = {
+ slotId: "inline:aui_second",
+ renderId: "aui_second",
+ origin: first.origin,
+ createdAt: "2026-08-29T09:00:00.000Z",
+ priority: "inline" as const,
+};
+
+describe("useAgentUiUrlFrameCoordinator", () => {
+ beforeEach(() => useAgentUiUrlFrameCoordinator.getState().reset());
+
+ it("activates the newest same-origin render through an empty phase", () => {
+ const store = useAgentUiUrlFrameCoordinator.getState();
+ store.register(first);
+ store.settle(first.origin);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBe(
+ first.slotId,
+ );
+
+ store.register(second);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBeNull();
+ expect(useAgentUiUrlFrameCoordinator.getState().pendingSlotByOrigin[first.origin]).toBe(
+ second.slotId,
+ );
+
+ store.settle(first.origin);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBe(
+ second.slotId,
+ );
+ });
+
+ it("remounts an older render only after releasing the current slot", () => {
+ const store = useAgentUiUrlFrameCoordinator.getState();
+ store.register(first);
+ store.settle(first.origin);
+ store.register(second);
+ store.settle(first.origin);
+
+ store.activate(first.slotId);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBeNull();
+ store.settle(first.origin);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBe(
+ first.slotId,
+ );
+ });
+
+ it("gives an expanded slot priority and restores the newest inline slot", () => {
+ const store = useAgentUiUrlFrameCoordinator.getState();
+ store.register(first);
+ store.settle(first.origin);
+ store.register(second);
+ store.settle(first.origin);
+
+ const expanded = { ...first, slotId: "expanded:thread-1", priority: "expanded" as const };
+ store.register(expanded);
+ store.settle(first.origin);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBe(
+ expanded.slotId,
+ );
+
+ store.unregister(expanded.slotId);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBeNull();
+ store.settle(first.origin);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBe(
+ second.slotId,
+ );
+ });
+
+ it("reactivates the newest inline slot after virtualization remounts it", () => {
+ const store = useAgentUiUrlFrameCoordinator.getState();
+ store.register(first);
+ store.settle(first.origin);
+ store.register(second);
+ store.settle(first.origin);
+
+ store.unregister(second.slotId);
+ store.settle(first.origin);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBe(
+ first.slotId,
+ );
+
+ store.register(second);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBeNull();
+ store.settle(first.origin);
+ expect(useAgentUiUrlFrameCoordinator.getState().activeSlotByOrigin[first.origin]).toBe(
+ second.slotId,
+ );
+ });
+});
diff --git a/apps/web/src/fork/agentUiUrlFrameCoordinator.ts b/apps/web/src/fork/agentUiUrlFrameCoordinator.ts
new file mode 100644
index 000000000000..7b10c320a9b3
--- /dev/null
+++ b/apps/web/src/fork/agentUiUrlFrameCoordinator.ts
@@ -0,0 +1,165 @@
+/**
+ * T3-CUSTOM(expbkt3): exclusive ownership for origin-bearing agent frames.
+ *
+ * Browser storage belongs to an origin, not to an iframe or URL fragment. Keep
+ * one live URL frame per origin and hand ownership over in two commits: first
+ * every candidate unmounts, then the requested slot mounts. That empty commit
+ * prevents two same-origin applications from broadcasting or writing storage
+ * concurrently while React moves a view between timeline and overlay.
+ */
+import { create } from "zustand";
+
+export interface AgentUiUrlFrameRegistration {
+ readonly slotId: string;
+ readonly renderId: string;
+ readonly origin: string;
+ readonly createdAt: string;
+ readonly priority: "inline" | "expanded";
+}
+
+interface AgentUiUrlFrameCoordinatorState {
+ readonly registrations: Readonly>;
+ readonly activeSlotByOrigin: Readonly>;
+ readonly pendingSlotByOrigin: Readonly>;
+ register: (registration: AgentUiUrlFrameRegistration) => void;
+ unregister: (slotId: string) => void;
+ activate: (slotId: string) => void;
+ settle: (origin: string) => void;
+ reset: () => void;
+}
+
+const priority = (registration: AgentUiUrlFrameRegistration): number =>
+ registration.priority === "expanded" ? 1 : 0;
+
+function bestRegistration(
+ registrations: Readonly>,
+ origin: string,
+): AgentUiUrlFrameRegistration | null {
+ let best: AgentUiUrlFrameRegistration | null = null;
+ for (const registration of Object.values(registrations)) {
+ if (registration.origin !== origin) continue;
+ if (
+ best === null ||
+ priority(registration) > priority(best) ||
+ (priority(registration) === priority(best) &&
+ (registration.createdAt > best.createdAt ||
+ (registration.createdAt === best.createdAt && registration.slotId > best.slotId)))
+ ) {
+ best = registration;
+ }
+ }
+ return best;
+}
+
+const initialState = {
+ registrations: {},
+ activeSlotByOrigin: {},
+ pendingSlotByOrigin: {},
+} as const;
+
+export const useAgentUiUrlFrameCoordinator = create((set) => ({
+ ...initialState,
+ register: (registration) =>
+ set((state) => {
+ const previous = state.registrations[registration.slotId];
+ if (
+ previous?.renderId === registration.renderId &&
+ previous.origin === registration.origin &&
+ previous.createdAt === registration.createdAt &&
+ previous.priority === registration.priority
+ ) {
+ return state;
+ }
+
+ const registrations = { ...state.registrations, [registration.slotId]: registration };
+ const selectedSlot =
+ state.pendingSlotByOrigin[registration.origin] ??
+ state.activeSlotByOrigin[registration.origin] ??
+ null;
+ const selected = selectedSlot === null ? null : (registrations[selectedSlot] ?? null);
+ const shouldActivate =
+ selected === null ||
+ registration.priority === "expanded" ||
+ (selected.priority === "inline" && registration.createdAt > selected.createdAt);
+
+ return {
+ ...state,
+ registrations,
+ ...(shouldActivate && selectedSlot !== registration.slotId
+ ? {
+ activeSlotByOrigin: {
+ ...state.activeSlotByOrigin,
+ [registration.origin]: null,
+ },
+ pendingSlotByOrigin: {
+ ...state.pendingSlotByOrigin,
+ [registration.origin]: registration.slotId,
+ },
+ }
+ : {}),
+ };
+ }),
+ unregister: (slotId) =>
+ set((state) => {
+ const registration = state.registrations[slotId];
+ if (!registration) return state;
+ const registrations = { ...state.registrations };
+ delete registrations[slotId];
+ const wasSelected =
+ state.activeSlotByOrigin[registration.origin] === slotId ||
+ state.pendingSlotByOrigin[registration.origin] === slotId;
+ if (!wasSelected) return { ...state, registrations };
+
+ const fallback = bestRegistration(registrations, registration.origin);
+ return {
+ ...state,
+ registrations,
+ activeSlotByOrigin: {
+ ...state.activeSlotByOrigin,
+ [registration.origin]: null,
+ },
+ pendingSlotByOrigin: {
+ ...state.pendingSlotByOrigin,
+ [registration.origin]: fallback?.slotId ?? null,
+ },
+ };
+ }),
+ activate: (slotId) =>
+ set((state) => {
+ const registration = state.registrations[slotId];
+ if (!registration) return state;
+ if (
+ state.activeSlotByOrigin[registration.origin] === slotId &&
+ state.pendingSlotByOrigin[registration.origin] == null
+ ) {
+ return state;
+ }
+ return {
+ ...state,
+ activeSlotByOrigin: {
+ ...state.activeSlotByOrigin,
+ [registration.origin]: null,
+ },
+ pendingSlotByOrigin: {
+ ...state.pendingSlotByOrigin,
+ [registration.origin]: slotId,
+ },
+ };
+ }),
+ settle: (origin) =>
+ set((state) => {
+ const pendingSlot = state.pendingSlotByOrigin[origin] ?? null;
+ const pending = pendingSlot === null ? null : (state.registrations[pendingSlot] ?? null);
+ const next =
+ pending?.origin === origin ? pending : bestRegistration(state.registrations, origin);
+ if (next === null && state.activeSlotByOrigin[origin] == null && pendingSlot === null) {
+ return state;
+ }
+ return {
+ ...state,
+ activeSlotByOrigin: { ...state.activeSlotByOrigin, [origin]: next?.slotId ?? null },
+ pendingSlotByOrigin: { ...state.pendingSlotByOrigin, [origin]: null },
+ };
+ }),
+ reset: () => set(initialState),
+}));
diff --git a/apps/web/vercel.test.ts b/apps/web/vercel.test.ts
new file mode 100644
index 000000000000..992d133bc79f
--- /dev/null
+++ b/apps/web/vercel.test.ts
@@ -0,0 +1,18 @@
+/** T3-CUSTOM(expbkt3): hosted agent-frame redirect protection. */
+import { describe, expect, it } from "vite-plus/test";
+
+import { config } from "./vercel";
+
+describe("hosted web framing headers", () => {
+ it("makes every hosted shell route unframeable", () => {
+ expect(config.headers).toEqual([
+ {
+ source: "/(.*)",
+ headers: [
+ { key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
+ { key: "X-Frame-Options", value: "DENY" },
+ ],
+ },
+ ]);
+ });
+});
diff --git a/apps/web/vercel.ts b/apps/web/vercel.ts
index 12a823a360e4..17c2adfd7341 100644
--- a/apps/web/vercel.ts
+++ b/apps/web/vercel.ts
@@ -29,6 +29,13 @@ export const config: VercelConfig = {
git: {
deploymentEnabled: false,
},
+ // T3-CUSTOM(expbkt3): hosted shells cannot be redirect targets for agent frames.
+ headers: [
+ routes.header("/(.*)", [
+ { key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
+ { key: "X-Frame-Options", value: "DENY" },
+ ]),
+ ],
installCommand:
"npm install -g vite-plus && vp install --ignore-scripts --filter '@t3tools/scripts...' --filter '@t3tools/web...'",
routes: [
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 4cfb01c1593d..a2e66261bc99 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -42,6 +42,11 @@ const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() ||
const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || "";
const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || "";
const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
+// T3-CUSTOM(expbkt3): match the production shell's anti-framing boundary in dev.
+const T3_HTML_FRAME_HEADERS = {
+ "Content-Security-Policy": "frame-ancestors 'none'",
+ "X-Frame-Options": "DENY",
+} as const;
const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
const configuredHostedAppUrl = (() => {
const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim();
@@ -225,6 +230,8 @@ export default defineConfig(() => {
port,
strictPort: true,
allowedHosts,
+ // T3-CUSTOM(expbkt3): prevent an agent frame redirecting into the dev shell.
+ headers: T3_HTML_FRAME_HEADERS,
// Transform the whole module graph at server start instead of on the
// first request. Without this, a cold worktree discovers and transforms
// modules one import-level at a time while the browser waits — which
diff --git a/docs/user/agent-views.md b/docs/user/agent-views.md
index b0299d243955..b969bc86bb6b 100644
--- a/docs/user/agent-views.md
+++ b/docs/user/agent-views.md
@@ -21,7 +21,7 @@ is a document the agent wrote, rendered in a sandbox:
- It cannot reach T3 Code, your session, your cookies, or the network as you.
- It cannot navigate the app or read anything outside its own box.
-Views are a snapshot, not a live surface. When an agent shows you an updated
+Agent-written documents are snapshots. When an agent shows you an updated
version it appears as a new box further down the conversation, so scrolling back
still shows what it produced at that point in the work.
@@ -29,6 +29,25 @@ An agent can also embed a page by its `https` address instead of writing the
document itself. Some sites refuse to be embedded and will show an empty box;
that is the site's decision, not a fault in T3.
+URL views are live pages. T3 keeps only one live URL view from the same origin at
+a time, across both the transcript and the expanded view. Opening another one
+disconnects the previous iframe before loading the selected URL in a fresh one.
+Older cards stay in the transcript and offer **Open this view** when inactive.
+This prevents same-origin apps from concurrently restoring or broadcasting stale
+state between views.
+
+In Chromium, cross-origin URL views also use a credentialless iframe. Its cookies
+and browser storage are temporary and scoped to the current top-level T3 page;
+closing or reloading T3 can discard that state. This is not a separate storage
+partition for every view, which is why T3 also allows only one live iframe per
+origin. Firefox and Safari currently ignore this protection and fall back to the
+same exclusive iframe lifecycle.
+
+Credentialless pages do not receive ambient sign-in cookies. Apps that require
+an existing login, third-party cookies, or an OAuth flow may therefore ask you to
+sign in again or may not work inside a view. T3 does not relax the sandbox for
+those apps.
+
## Turning it off
**Settings → Experiments → Agent views in chat.** While it is off, an agent that
@@ -41,5 +60,6 @@ change what you see on your phone.
- One view is capped at roughly 256,000 characters. An agent that needs more is
told to render something smaller.
- Height is capped, so a view cannot take over the transcript.
+- URL views from the same origin cannot stay open side by side.
- Views are stored with the session and are removed when the session's data is
reclaimed.