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
138 changes: 138 additions & 0 deletions apps/server/src/agentui/AgentUiService.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <A, E>(
body: (service: AgentUiService["Service"]) => Effect.Effect<A, E, never>,
) =>
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: "<p>hello</p>",
});
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("<p>hello</p>");
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: "<b>x</b>" });
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: "<p/>", height: 5_000 });
const short = yield* service.show({ threadId, title: "t", html: "<p/>", height: 1 });
const absent = yield* service.show({ threadId, title: "t", html: "<p/>" });
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,<script>alert(1)</script>",
"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: "<p/>", 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();
}),
),
);
});
148 changes: 148 additions & 0 deletions apps/server/src/agentui/AgentUiService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* 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 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<AgentUiRenderHandle, AgentUiError>;
readonly getRender: (input: {
readonly threadId: ThreadId;
readonly renderId: string;
}) => Effect.Effect<AgentUiRenderRecord | null, AgentUiError>;
}

export class AgentUiService extends Context.Service<AgentUiService, AgentUiServiceShape>()(
"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 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_${globalThis.crypto.randomUUID().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);
3 changes: 3 additions & 0 deletions apps/server/src/auth/rpcForkScopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
31 changes: 31 additions & 0 deletions apps/server/src/mcp/toolkits/control/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading