((resolve) => setImmediate(resolve));
+ vi.unstubAllGlobals();
+ });
+
+ 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");
+ const root = createRoot(container as unknown as Element);
+
+ try {
+ await render(
+ root,
+
+ ordinary tool row
+ ,
+ );
+
+ expect(renderedText(container)).toBe("ordinary tool row");
+ expect(iframeNodes(container)).toHaveLength(0);
+ expect(testState.queryCalls).toEqual([]);
+ } finally {
+ flushSync(() => root.unmount());
+ }
+ });
+
+ 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, );
+ expect(renderedText(container)).toBe("");
+ expect(iframeNodes(container)).toHaveLength(0);
+ expect(testState.queryCalls).toEqual([]);
+ } finally {
+ flushSync(() => root.unmount());
+ }
+ });
+
+ 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);
+ 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, );
+ expect(iframeNodes(container)).toHaveLength(0);
+ expect(renderedText(container)).toContain("URL Agent views are temporarily disabled");
+
+ 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 9114da206390..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, resolveEmbedSandbox } from "./agentUiSurface";
+import { resolveAgentUiSurface } from "./agentUiSurface";
+import { AGENT_UI_SURFACES_RUNTIME_ENABLED } from "./agentUiRuntime";
describe("resolveAgentUiSurface", () => {
it("reads a well-formed handle", () => {
@@ -43,31 +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");
- });
-
- 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");
- }
- });
-
- 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 7186fb396812..11e77659577a 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 { AGENT_UI_SURFACES_RUNTIME_ENABLED } from "./agentUiRuntime";
/** The handle `ActivityPayloadProjection` keeps on an MCP tool-call payload. */
export interface AgentUiSurfaceHandle {
@@ -70,37 +71,12 @@ 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`;
-}
-
/**
* 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 onTitle?: ((title: string) => void) | undefined;
@@ -158,14 +134,10 @@ const AgentUiRenderFrame = memo(function AgentUiRenderFrame(props: {
}
if (render.url) {
return (
-
+
+ URL Agent views are temporarily disabled because framed apps cannot always render the
+ requested live state reliably.
+
);
}
return null;
@@ -207,6 +179,7 @@ function AgentUiSurfaceCardImpl({ threadRef, surface }: AgentUiSurfaceCardProps)
{collapsed ? null : (
settings.agentUiSurfacesEnabled);
- if (!enabled || props.threadRef === null) {
+ if (!AGENT_UI_SURFACES_RUNTIME_ENABLED || !enabled || props.threadRef === null) {
return props.children;
}
return (
@@ -257,7 +230,7 @@ export const AgentUiExpandedSurface = memo(function AgentUiExpandedSurface() {
const collapse = useAgentUiExpandedStore((state) => state.collapse);
const [title, setTitle] = useState("Agent view");
- const open = enabled && expanded !== null;
+ const open = AGENT_UI_SURFACES_RUNTIME_ENABLED && enabled && expanded !== null;
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
@@ -296,6 +269,7 @@ export const AgentUiExpandedSurface = memo(function AgentUiExpandedSurface() {
{
+ 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/operations/expbkt3-customizations.md b/docs/operations/expbkt3-customizations.md
index 5538ec955012..616e4dd9be4b 100644
--- a/docs/operations/expbkt3-customizations.md
+++ b/docs/operations/expbkt3-customizations.md
@@ -112,6 +112,12 @@ turn-settlement rewrite in `state/threadReducer.ts`, the restart predicate in
| Agent views in chat | `apps/server/src/agentui/`, `persistence/AgentUiRenders.ts`, migration 1022, `packages/contracts/src/agentUi.ts`, `packages/client-runtime/src/state/agentUi.ts`, `apps/web/src/fork/agentUiSurface.tsx`, `apps/web/src/state/agentUi.ts` | `t3_show_ui` in the MCP control toolkit, fork RPC group + scopes + handlers, one `ws.ts` dep, one `server.ts` layer, marked handle passthrough in `ActivityPayloadProjection.ts`, `agentUi` field + read in `session-logic.ts`, one import and early return in `MessagesTimeline.tsx`, Experiments toggle + `settingsSearch.ts` entry |
| Experimental deployment | `.github/workflows/deploy-expbkt3.yml`, `deploy/expbkt3/` | none |
+Agent views currently have an enforced runtime-off gate in
+`apps/web/src/fork/agentUiRuntime.ts`. Framed collaboration apps can decline to
+join the URL-selected room and render unrelated origin-local state instead, so
+the persisted client preference cannot enable the surface until the generic URL
+contract can render truthfully.
+
Generated files such as `apps/web/src/routeTree.gen.ts` do not receive hand-written
markers; they are regenerated from marked route sources.
diff --git a/docs/user/agent-views.md b/docs/user/agent-views.md
index b0299d243955..9c106763111c 100644
--- a/docs/user/agent-views.md
+++ b/docs/user/agent-views.md
@@ -4,37 +4,30 @@
> upstream-isolated extension. See the
> [customization boundary registry](../operations/expbkt3-customizations.md).
-An agent can render a small interactive view directly in the conversation: a
-chart, a diagram, a table, a form, a preview. The view appears inline, in the
-place where the agent produced it, rather than as a file you have to open.
+Agent views are temporarily disabled in the experimental client. Calls to
+`t3_show_ui` remain visible as ordinary collapsed tool rows, but the client does
+not mount their documents or URL targets.
-Ask for something visual — "chart the request latency by endpoint", "show me the
-migration order as a diagram", "lay these options out side by side" — and the
-agent decides whether a view says it better than text.
+This is an enforced runtime safety gate. It overrides clients that previously
+persisted **Agent views in chat** as enabled.
## What a view is
-Each view is a box in the transcript with a title bar you can collapse. Inside
-is a document the agent wrote, rendered in a sandbox:
+Some live collaboration apps deliberately disable room bootstrap whenever they
+run inside an iframe. In that state, a room URL can display data cached for the
+app's origin instead of joining the room named by the URL. Storage partitioning
+can turn that stale content into an empty canvas, but it cannot make the framed
+app join. T3 therefore cannot truthfully render those URLs inline without a
+change owned by the embedded app or a different browser-level integration.
-- Its styles and scripts run, so charts, tabs and hover states work.
-- 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
-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.
-
-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.
+Disabling the surface is the generic fail-closed behavior: T3 does not
+special-case an app or domain, rewrite URL fragments, proxy encryption keys, or
+weaken the iframe sandbox.
## Turning it off
-**Settings → Experiments → Agent views in chat.** While it is off, an agent that
-tries to show a view leaves an ordinary collapsed tool row instead, and nothing
-is rendered. The setting is per client, so turning it off on your laptop does not
-change what you see on your phone.
+**Settings → Experiments → Agent views in chat** shows the runtime-disabled
+state. The switch cannot re-enable the surface while this mitigation is active.
## Limits
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index 467cf6365dc9..eb2177ddd879 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -262,6 +262,10 @@ export function applyThreadDetailEvent(
...(event.payload.linearIssueUrl !== undefined
? { linearIssueUrl: event.payload.linearIssueUrl }
: {}),
+ // T3-CUSTOM(expbkt3): durable Mattermost conversation link.
+ ...(event.payload.mattermostThreadUrl !== undefined
+ ? { mattermostThreadUrl: event.payload.mattermostThreadUrl }
+ : {}),
// T3-CUSTOM(expbkt3): session lineage re-parent / detach.
...(event.payload.parentThreadId !== undefined
? { parentThreadId: event.payload.parentThreadId }
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 5a1be8fccb39..ebc9fa3d9e38 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -76,6 +76,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
threadPriority: Schema.optionalKey(Schema.Boolean),
/** T3-CUSTOM(expbkt3): durable manual Linear tags on thread metadata. */
threadLinearIssue: Schema.optionalKey(Schema.Boolean),
+ /** T3-CUSTOM(expbkt3): durable Mattermost conversation link on thread metadata. */
+ threadMattermostLink: Schema.optionalKey(Schema.Boolean),
/** T3-CUSTOM(expbkt3): server understands externalSession on thread.create
(attach a new thread to an existing provider session). */
threadExternalSessionAttach: Schema.optionalKey(Schema.Boolean),
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 96cecbcb53a2..67fc8bb2f23e 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -797,6 +797,8 @@ export const OrchestrationThread = Schema.Struct({
priority: Schema.optional(Schema.NullOr(ThreadPriority)),
// T3-CUSTOM(expbkt3): optional so payloads from pre-manual-tag servers decode.
linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ // T3-CUSTOM(expbkt3): optional so payloads from pre-Mattermost servers decode.
+ mattermostThreadUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
// T3-CUSTOM(expbkt3): session lineage. Optional so payloads from
// pre-lineage servers decode; null means this is a root session.
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
@@ -892,6 +894,8 @@ export const OrchestrationThreadShell = Schema.Struct({
priority: Schema.optional(Schema.NullOr(ThreadPriority)),
// T3-CUSTOM(expbkt3): durable manual Linear issue URL.
linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ // T3-CUSTOM(expbkt3): durable Mattermost conversation permalink.
+ mattermostThreadUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
// T3-CUSTOM(expbkt3): session lineage (see the ThreadPriority block above).
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (see ThreadWorkSummary).
@@ -1387,6 +1391,9 @@ const ThreadMetaUpdateCommand = Schema.Struct({
priority: Schema.optional(Schema.NullOr(ThreadPriority)),
// T3-CUSTOM(expbkt3): manual Linear tag. undefined = unchanged, null = clear.
linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ // T3-CUSTOM(expbkt3): Mattermost conversation this session is bound to.
+ // undefined = unchanged, null = clear.
+ mattermostThreadUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
// T3-CUSTOM(expbkt3): session lineage. undefined = unchanged, null = detach
// to a root session. The decider rejects a value that would form a cycle.
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
@@ -2129,6 +2136,9 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
priority: Schema.optional(Schema.NullOr(ThreadPriority)),
// T3-CUSTOM(expbkt3): manual Linear tag. undefined = unchanged, null = clear.
linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ // T3-CUSTOM(expbkt3): Mattermost conversation this session is bound to.
+ // undefined = unchanged, null = clear.
+ mattermostThreadUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
// T3-CUSTOM(expbkt3): session lineage. undefined = unchanged, null = detach.
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),