diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index b74bb7439f87..dd3aaac15b4d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -39,6 +39,7 @@ const clientSettings: ClientSettings = { // T3-CUSTOM(expbkt3): plan mode is available by default in the fork. planModeAvailable: true, nativePlanReviewEnabled: true, + agentUiSurfacesEnabled: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, providerRateLimitsEnabled: true, diff --git a/apps/server/src/agentui/AgentUiService.test.ts b/apps/server/src/agentui/AgentUiService.test.ts new file mode 100644 index 000000000000..bdef5421c796 --- /dev/null +++ b/apps/server/src/agentui/AgentUiService.test.ts @@ -0,0 +1,138 @@ +/** + * T3-CUSTOM(expbkt3): coverage for agent-rendered UI surfaces. + * + * The validation here is the security boundary an agent's input crosses, so the + * tests pin the rejections as tightly as the happy path: an agent that can + * choose the framed URL scheme, or push an unbounded document into the database, + * is a different feature from the one we shipped. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AGENT_UI_MAX_HTML_CHARS, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as AgentUiRenders from "../persistence/AgentUiRenders.ts"; +import * as AgentUiServiceModule from "./AgentUiService.ts"; +import { AgentUiService } from "./AgentUiService.ts"; + +const threadId = ThreadId.make("thread-agent-ui"); +const otherThreadId = ThreadId.make("thread-agent-ui-other"); + +const layer = AgentUiServiceModule.layer.pipe( + Layer.provide(AgentUiRenders.layer), + Layer.provide(MigrationsLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provide(NodeServices.layer), +); + +const withService = ( + body: (service: AgentUiService["Service"]) => Effect.Effect, +) => + Effect.gen(function* () { + const service = yield* AgentUiService; + return yield* body(service); + }).pipe(Effect.provide(layer)); + +describe("AgentUiService", () => { + it.effect("stores an inline document and reads it back by handle", () => + withService((service) => + Effect.gen(function* () { + const handle = yield* service.show({ + threadId, + title: " Latency ", + html: "

hello

", + }); + expect(handle.kind).toBe("html"); + expect(handle.renderId.startsWith("aui_")).toBe(true); + + const render = yield* service.getRender({ threadId, renderId: handle.renderId }); + expect(render?.html).toBe("

hello

"); + expect(render?.title).toBe("Latency"); + expect(render?.url).toBeNull(); + }), + ), + ); + + it.effect("scopes a render to its own thread", () => + withService((service) => + Effect.gen(function* () { + const handle = yield* service.show({ threadId, title: "Chart", html: "x" }); + const leaked = yield* service.getRender({ + threadId: otherThreadId, + renderId: handle.renderId, + }); + expect(leaked).toBeNull(); + }), + ), + ); + + it.effect("clamps the height into the embeddable range", () => + withService((service) => + Effect.gen(function* () { + const tall = yield* service.show({ threadId, title: "t", html: "

", height: 5_000 }); + const short = yield* service.show({ threadId, title: "t", html: "

", height: 1 }); + const absent = yield* service.show({ threadId, title: "t", html: "

" }); + expect(tall.height).toBe(900); + expect(short.height).toBe(120); + expect(absent.height).toBe(360); + }), + ), + ); + + it.effect("accepts an https URL and rejects every other scheme", () => + withService((service) => + Effect.gen(function* () { + const ok = yield* service.show({ threadId, title: "Docs", url: "https://example.com/a" }); + expect(ok.kind).toBe("url"); + + for (const url of [ + "http://example.com", + "javascript:alert(1)", + "file:///etc/passwd", + "data:text/html,", + "not a url", + ]) { + const failure = yield* service.show({ threadId, title: "x", url }).pipe(Effect.flip); + expect(failure.message).toContain("https://"); + } + }), + ), + ); + + it.effect("requires exactly one of html or url", () => + withService((service) => + Effect.gen(function* () { + const neither = yield* service.show({ threadId, title: "x" }).pipe(Effect.flip); + expect(neither.message).toContain("Provide either"); + + const both = yield* service + .show({ threadId, title: "x", html: "

", url: "https://example.com" }) + .pipe(Effect.flip); + expect(both.message).toContain("only one"); + }), + ), + ); + + it.effect("refuses a document past the size cap", () => + withService((service) => + Effect.gen(function* () { + const failure = yield* service + .show({ threadId, title: "x", html: "a".repeat(AGENT_UI_MAX_HTML_CHARS + 1) }) + .pipe(Effect.flip); + expect(failure.message).toContain("the limit is"); + }), + ), + ); + + it.effect("returns null for a render that was never stored", () => + withService((service) => + Effect.gen(function* () { + const missing = yield* service.getRender({ threadId, renderId: "aui_nope" }); + expect(missing).toBeNull(); + }), + ), + ); +}); diff --git a/apps/server/src/agentui/AgentUiService.ts b/apps/server/src/agentui/AgentUiService.ts new file mode 100644 index 000000000000..e203fd5f4334 --- /dev/null +++ b/apps/server/src/agentui/AgentUiService.ts @@ -0,0 +1,153 @@ +/** + * T3-CUSTOM(expbkt3): agent-rendered UI surfaces shown inline in the chat. + * + * The `t3_show_ui` MCP tool records a render here and gets back a short handle; + * the chat later fetches the body by that handle. Keeping the two halves in one + * fork-owned service means the validation an agent's input goes through — size + * caps, height clamping, URL scheme — is the same validation the reader relies + * on, and none of it lives in an upstream file. + */ +import { + AGENT_UI_DEFAULT_HEIGHT, + AGENT_UI_MAX_HEIGHT, + AGENT_UI_MAX_HTML_CHARS, + AGENT_UI_MIN_HEIGHT, + AgentUiError, + type AgentUiRenderHandle, + type AgentUiRenderRecord, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { AgentUiRepository } from "../persistence/AgentUiRenders.ts"; + +export interface ShowUiInput { + readonly threadId: ThreadId; + readonly title: string; + readonly html?: string | undefined; + readonly url?: string | undefined; + readonly height?: number | undefined; +} + +export interface AgentUiServiceShape { + readonly show: (input: ShowUiInput) => Effect.Effect; + readonly getRender: (input: { + readonly threadId: ThreadId; + readonly renderId: string; + }) => Effect.Effect; +} + +export class AgentUiService extends Context.Service()( + "t3/agentui/AgentUiService", +) {} + +function clampHeight(height: number | undefined): number { + if (height === undefined || !Number.isFinite(height)) return AGENT_UI_DEFAULT_HEIGHT; + return Math.max(AGENT_UI_MIN_HEIGHT, Math.min(Math.round(height), AGENT_UI_MAX_HEIGHT)); +} + +/** + * Only https is embeddable. http would be blocked as mixed content on every + * hosted deployment, and the non-network schemes are how a framed document + * would reach back at the app. + */ +function normalizeUrl(raw: string): string | null { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + return parsed.protocol === "https:" ? parsed.toString() : null; +} + +export const make = Effect.gen(function* () { + const repository = yield* AgentUiRepository; + const crypto = yield* Crypto.Crypto; + + // A failing CSPRNG is a defect, not something a caller can recover from. + const uuid = crypto.randomUUIDv4.pipe(Effect.orDie); + + const show: AgentUiServiceShape["show"] = (input) => + Effect.gen(function* () { + const title = input.title.trim() || "Agent view"; + const html = input.html?.trim() ?? ""; + const rawUrl = input.url?.trim() ?? ""; + + if (html.length === 0 && rawUrl.length === 0) { + return yield* new AgentUiError({ + operation: "show", + message: "Provide either `html` or `url`.", + }); + } + if (html.length > 0 && rawUrl.length > 0) { + return yield* new AgentUiError({ + operation: "show", + message: "Provide only one of `html` or `url`, not both.", + }); + } + if (html.length > AGENT_UI_MAX_HTML_CHARS) { + return yield* new AgentUiError({ + operation: "show", + message: `The document is ${html.length} characters; the limit is ${AGENT_UI_MAX_HTML_CHARS}. Render a smaller view, or link to a URL.`, + }); + } + + const url = rawUrl.length > 0 ? normalizeUrl(rawUrl) : null; + if (rawUrl.length > 0 && url === null) { + return yield* new AgentUiError({ + operation: "show", + message: "`url` must be an absolute https:// URL.", + }); + } + + const kind = url === null ? ("html" as const) : ("url" as const); + const height = clampHeight(input.height); + const renderId = `aui_${(yield* uuid).replaceAll("-", "").slice(0, 20)}`; + const createdAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + + yield* repository + .insertRender({ + renderId, + threadId: input.threadId, + title, + kind, + html: kind === "html" ? html : null, + url, + height, + createdAt, + }) + .pipe( + Effect.mapError( + (cause) => + new AgentUiError({ + operation: "show", + message: `Could not store the view: ${String(cause)}`, + }), + ), + ); + + return { renderId, kind, height, title } satisfies AgentUiRenderHandle; + }); + + const getRender: AgentUiServiceShape["getRender"] = (input) => + repository.getRender(input).pipe( + Effect.map(Option.getOrNull), + Effect.mapError( + (cause) => + new AgentUiError({ + operation: "get-render", + message: `Could not read the view: ${String(cause)}`, + }), + ), + ); + + return AgentUiService.of({ show, getRender }); +}); + +export const layer = Layer.effect(AgentUiService, make); diff --git a/apps/server/src/auth/rpcForkScopes.ts b/apps/server/src/auth/rpcForkScopes.ts index 42f4fc587f1f..5862e2256e58 100644 --- a/apps/server/src/auth/rpcForkScopes.ts +++ b/apps/server/src/auth/rpcForkScopes.ts @@ -52,4 +52,7 @@ export const FORK_RPC_REQUIRED_SCOPES = { [WS_FORK_METHODS.sessionArchiveBackfill]: AuthOrchestrationOperateScope, // T3-CUSTOM(expbkt3): context handoff renders a string and writes nothing. [WS_FORK_METHODS.threadContextExport]: AuthOrchestrationReadScope, + // T3-CUSTOM(expbkt3): agent-rendered UI surfaces. A read of thread content, + // and the handler additionally gates on per-thread access. + [WS_FORK_METHODS.agentUiGetRender]: AuthOrchestrationReadScope, } as const; diff --git a/apps/server/src/mcp/toolkits/control/handlers.ts b/apps/server/src/mcp/toolkits/control/handlers.ts index 7e16a017d37e..245f08050c80 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.ts @@ -33,6 +33,8 @@ import { ProjectionSnapshotQuery } from "../../../orchestration/Services/Project // T3-CUSTOM(expbkt3): bounded catch-up detail for t3_list_sessions. import type { ProjectionSessionListDetail } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { PlannotatorManager } from "../../../plannotator/PlannotatorManager.ts"; +// T3-CUSTOM(expbkt3): agent-rendered UI surfaces in chat. +import { AgentUiService } from "../../../agentui/AgentUiService.ts"; import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "../../../serverSettings.ts"; import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts"; @@ -1363,6 +1365,35 @@ const handlers = { }, ), + // T3-CUSTOM(expbkt3): BEGIN — agent-rendered UI surfaces in chat. + // + // Always renders into the caller's own session: the box appears where the tool + // call appears, so there is no cross-session target to authorize. + t3_show_ui: Effect.fn("T3ControlToolkit.showUi")(function* (input) { + const operation = "show-ui"; + const scope = yield* requireCapability(operation, "t3.read"); + const agentUi = yield* AgentUiService; + const handle = yield* agentUi + .show({ + threadId: scope.threadId, + title: input.title, + html: input.html, + url: input.url, + height: input.height, + }) + .pipe(mapControlError(operation)); + // Small and single-line on purpose: activity projection summarizes an MCP + // result to one short line, and this handle has to survive that trip to + // reach the chat client. + return { + t3UiRender: true, + renderId: handle.renderId, + kind: handle.kind, + height: handle.height, + }; + }), + // T3-CUSTOM(expbkt3): END + t3_dispatch_command: Effect.fn("T3ControlToolkit.dispatchCommand")(function* (input) { const operation = "dispatch-command"; yield* requireExternalOperator(operation); diff --git a/apps/server/src/mcp/toolkits/control/tools.ts b/apps/server/src/mcp/toolkits/control/tools.ts index 64c314658b8c..fffda16430ab 100644 --- a/apps/server/src/mcp/toolkits/control/tools.ts +++ b/apps/server/src/mcp/toolkits/control/tools.ts @@ -30,6 +30,8 @@ import { PlannotatorManager, PlannotatorPlanFormat, } from "../../../plannotator/PlannotatorManager.ts"; +// T3-CUSTOM(expbkt3): agent-rendered UI surfaces in chat. +import { AgentUiService } from "../../../agentui/AgentUiService.ts"; import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts"; import { ServerSettingsService } from "../../../serverSettings.ts"; import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts"; @@ -52,6 +54,7 @@ const dependencies = [ Crypto.Crypto, ]; const plannotatorDependencies = [...dependencies, PlannotatorManager]; +const agentUiDependencies = [...dependencies, AgentUiService]; const configurationDependencies = [...dependencies, ProviderRegistry, ServerSettingsService]; const ownershipDependencies = [ClerkDirectory, ServerConfig]; const projectDependencies = [ @@ -612,6 +615,43 @@ export const T3UnlinkSessionTool = mutatingTool( ); // T3-CUSTOM(expbkt3): END +// T3-CUSTOM(expbkt3): BEGIN — agent-rendered UI surfaces in chat. +// +// Named to stay clear of the provider adapters' substring classifier: anything +// containing "create", "file", "agent" or "command" would be tagged as a file +// change or subagent row instead of an MCP tool call. +export const T3ShowUiTool = readonlyTool( + Tool.make("t3_show_ui", { + description: + "Render an interactive view inside the chat transcript: a sandboxed box the user sees inline, right where the tool call happened. Pass a self-contained HTML document in `html` (inline ", + "", + html, + "", + ].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: { + readonly threadRef: ScopedThreadRef; + readonly renderId: string; + readonly onTitle?: ((title: string) => void) | undefined; +}) { + const { environmentId, threadId } = props.threadRef; + const query = useEnvironmentQuery( + agentUiEnvironment.render({ + environmentId, + input: { threadId, renderId: props.renderId }, + }), + ); + const render = query.data?.render ?? null; + + // An unmodified sandbox gives the document an opaque origin: its scripts run, + // so charts and interactions work, but it cannot reach this page, our cookies, + // or the network as the signed-in user. + const srcDoc = useMemo( + () => (render?.kind === "html" && render.html ? toSrcDoc(render.html) : null), + [render?.kind, render?.html], + ); + + const onTitle = props.onTitle; + const title = render?.title; + useEffect(() => { + if (onTitle && title) onTitle(title); + }, [onTitle, title]); + + if (query.isPending && render === null) { + return ( +

+ Loading view… +
+ ); + } + if (render === null) { + return ( +
+ {query.error ?? "This view is no longer available."} +
+ ); + } + if (srcDoc !== null) { + return ( +