diff --git a/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md b/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md
index 0295e0997..e1ef25f9d 100644
--- a/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md
+++ b/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md
@@ -435,13 +435,23 @@ above. The plugin catalogue's apps area rides with that change rather than after
that shows what is installed and what is running, so without it the view has nothing to be opened from. The agent capability follows the view: it is the only part that can be reviewed against a
working page, since a plugin's agent calls are only observable from a page this client can display.
+### The proxy makes the document framable
+
+The page is framed by this client, on a different origin, so a page must not be able to refuse. The proxy
+therefore drops `X-Frame-Options` from every response it forwards and removes the `frame-ancestors`
+directive from a page's own CSP while leaving its other directives alone — the Mini App's origin is a
+separate origin from the client's, so the rest of its policy governs a document that cannot reach the
+client anyway. Nothing is added: no header the page did not ask for, no CSP of the proxy's own.
+
+The document is the one response the proxy rewrites. The page bridge has to be in the document before the
+page's own scripts run, so the proxy injects a script tag for it before ``, or after the opening
+`
` tag when a page has no head, or ahead of the document when it has neither, and restates the
+`Content-Length` because the body it sends is not the body it received. Everything else — paths, methods,
+status codes, the page's own headers — passes through unchanged.
+
## Open implementation questions
-1. **Framing the page through the proxy.** One listener per Mini App is decided above; what remains is
- which response headers the proxy must add or drop for the document to be framable at all, and what
- it does with a Mini App that sets framing or CSP headers of its own. The desktop client offers no
- precedent: it never frames the page and writes no response headers of its own.
-2. **The bridge's method-level error mapping.** Which failure code each bridge call answers when the
+1. **The bridge's method-level error mapping.** Which failure code each bridge call answers when the
host cannot serve it — an unsupported capability, a full queue, a runtime that stopped between two
calls — and how a page's request identifier survives the `postMessage` boundary.
diff --git a/packages/webui/src/server/miniapp/proxy.ts b/packages/webui/src/server/miniapp/proxy.ts
new file mode 100644
index 000000000..6056eda63
--- /dev/null
+++ b/packages/webui/src/server/miniapp/proxy.ts
@@ -0,0 +1,231 @@
+import { createHash } from 'node:crypto';
+import http from 'node:http';
+import net from 'node:net';
+
+/**
+ * The Mini App's real origin and the credential its own guard demands.
+ *
+ * Server-only by construction: it arrives from the runtime port capability and never travels further
+ * than this module. The client is given `viewUrl`, which points at this proxy instead.
+ */
+export interface MiniAppProxyTarget {
+ readonly pluginId: string;
+ readonly upstreamOrigin: string;
+ readonly credential: string;
+ /** Request header the upstream guard reads the credential from. */
+ readonly credentialHeader: string;
+}
+
+export interface MiniAppProxy {
+ /** What the client frames. Never the Mini App's own origin. */
+ readonly viewUrl: string;
+ readonly port: number;
+ close(): Promise;
+}
+
+const PROXY_PORT_MIN = 49_152;
+const PROXY_PORT_MAX = 65_535;
+const PROXY_PORT_RANGE = PROXY_PORT_MAX - PROXY_PORT_MIN + 1;
+
+/**
+ * A Mini App's proxy port is stable per plugin, for the same reason the runtime's own is: the page's
+ * `localStorage` and `IndexedDB` are keyed by its origin, so a port that moved between runs would
+ * silently discard the page's own state.
+ */
+export function deriveMiniAppProxyPort(pluginId: string): number {
+ const digest = createHash('sha256').update(`miniapp-proxy:${pluginId}`).digest();
+ return PROXY_PORT_MIN + (digest.readUInt32BE(0) % PROXY_PORT_RANGE);
+}
+
+/**
+ * The page bridge the proxy injects into the Mini App's document.
+ *
+ * The proxy owns the seam — which document, where the tag goes, what headers survive — while the script
+ * itself belongs to the bridge, which knows what `window.mcode.miniapp` exposes.
+ */
+export interface MiniAppPageBridge {
+ /** Absolute path on the proxy origin that serves the script, e.g. `/__miniapp/bridge.js`. */
+ readonly path: string;
+ readonly script: string;
+}
+
+export interface StartMiniAppProxyInput {
+ readonly target: MiniAppProxyTarget;
+ /** Defaults to the derived port; a test or a retry may pass the next candidate. */
+ readonly port?: number;
+ readonly bridge?: MiniAppPageBridge;
+}
+
+const FRAME_BLOCKING_HEADERS = ['x-frame-options'] as const;
+const CSP_HEADER = 'content-security-policy';
+
+/**
+ * One loopback listener per running Mini App.
+ *
+ * A page refers to its own origin by absolute path (`/dashboard`, `/api/...`, its assets), so the proxy
+ * forwards paths unchanged rather than rewriting them behind a prefix: patching them back would mean
+ * intercepting every `fetch`, `XMLHttpRequest` and absolute URL in the document. The credential is
+ * injected on the way out and never reaches a response, and WebSocket upgrades are forwarded under the
+ * same rules.
+ */
+export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise {
+ const upstream = new URL(input.target.upstreamOrigin);
+ if (upstream.protocol !== 'http:') {
+ throw new TypeError('A Mini App proxy forwards to an http origin on loopback');
+ }
+ const port = input.port ?? deriveMiniAppProxyPort(input.target.pluginId);
+ // Anything this listener owns that can be torn down: inbound sockets, forwarded requests, upgrade pairs.
+ const sockets = new Set<{ destroy(): void }>();
+
+ const server = http.createServer((request, response) => {
+ if (input.bridge && request.url === input.bridge.path) {
+ response
+ .writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' })
+ .end(input.bridge.script);
+ return;
+ }
+ const forwarded = http.request(
+ {
+ host: upstream.hostname,
+ port: upstream.port,
+ method: request.method,
+ path: request.url,
+ headers: {
+ ...request.headers,
+ [input.target.credentialHeader]: input.target.credential,
+ },
+ agent: false,
+ },
+ (upstreamResponse) => {
+ const headers = frameableHeaders(upstreamResponse.headers);
+ if (!isHtml(upstreamResponse.headers) || !input.bridge) {
+ response.writeHead(upstreamResponse.statusCode ?? 502, headers);
+ upstreamResponse.pipe(response);
+ return;
+ }
+ // The document is the one response this proxy rewrites: the page bridge has to be in it before
+ // the page's own scripts run, and a page cannot be allowed to decide it will not be framed.
+ const chunks: Buffer[] = [];
+ upstreamResponse.on('data', (chunk: Buffer) => chunks.push(chunk));
+ upstreamResponse.on('end', () => {
+ const body = Buffer.from(
+ injectBridge(Buffer.concat(chunks).toString('utf8'), input.bridge as MiniAppPageBridge),
+ 'utf8',
+ );
+ // The body length changed, so the upstream framing no longer applies.
+ delete headers['transfer-encoding'];
+ headers['content-length'] = String(body.byteLength);
+ response.writeHead(upstreamResponse.statusCode ?? 502, headers);
+ response.end(body);
+ });
+ upstreamResponse.on('error', () => response.destroy());
+ },
+ );
+ forwarded.on('error', () => {
+ if (!response.headersSent) {
+ response.writeHead(502, { 'content-type': 'text/plain' });
+ }
+ response.end();
+ });
+ request.pipe(forwarded);
+ sockets.add(forwarded);
+ forwarded.once('close', () => sockets.delete(forwarded));
+ });
+
+ // Every socket this listener owns, so closing it cannot wait on a client that never said goodbye.
+ server.on('connection', (socket) => {
+ sockets.add(socket);
+ socket.once('close', () => sockets.delete(socket));
+ });
+
+ server.on('upgrade', (request, clientSocket, head) => {
+ const upstreamSocket = net.connect(
+ { host: upstream.hostname, port: Number(upstream.port || 80) },
+ () => {
+ const headers = Object.entries(request.headers)
+ .filter(([name, value]) => value !== undefined)
+ .map(([name, value]) => `${name}: ${Array.isArray(value) ? value.join(', ') : value}`);
+ headers.push(`${input.target.credentialHeader}: ${input.target.credential}`);
+ upstreamSocket.write(
+ `${request.method} ${request.url} HTTP/1.1\r\n${headers.join('\r\n')}\r\n\r\n`,
+ );
+ if (head.length > 0) upstreamSocket.write(head);
+ upstreamSocket.pipe(clientSocket);
+ clientSocket.pipe(upstreamSocket);
+ },
+ );
+ sockets.add(clientSocket);
+ sockets.add(upstreamSocket);
+ // A half-closed pair would leak the other end and keep the upstream server open forever.
+ const teardown = () => {
+ clientSocket.destroy();
+ upstreamSocket.destroy();
+ sockets.delete(clientSocket);
+ sockets.delete(upstreamSocket);
+ };
+ clientSocket.once('close', teardown);
+ upstreamSocket.once('close', teardown);
+ upstreamSocket.once('error', teardown);
+ clientSocket.once('error', teardown);
+ });
+
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(port, '127.0.0.1', () => {
+ server.removeListener('error', reject);
+ resolve();
+ });
+ });
+ const address = server.address();
+ const boundPort = typeof address === 'object' && address ? address.port : port;
+
+ return {
+ port: boundPort,
+ viewUrl: `http://127.0.0.1:${boundPort}`,
+ async close() {
+ for (const socket of sockets) socket.destroy();
+ sockets.clear();
+ await new Promise((resolve) => server.close(() => resolve()));
+ },
+ };
+}
+
+function isHtml(headers: http.IncomingHttpHeaders): boolean {
+ const contentType = headers['content-type'];
+ return typeof contentType === 'string' && contentType.toLowerCase().includes('text/html');
+}
+
+/**
+ * The page is framed by the WebUI client, on a different origin, so a page that asks not to be framed
+ * must not be able to refuse: `X-Frame-Options` is dropped and a `frame-ancestors` directive is removed
+ * from the page's own CSP. Everything else the page declares is left alone — the Mini App's origin is a
+ * separate origin from the client's, so its CSP governs a document that cannot reach the client anyway.
+ */
+function frameableHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
+ const result: http.OutgoingHttpHeaders = {};
+ for (const [name, value] of Object.entries(headers)) {
+ if (FRAME_BLOCKING_HEADERS.includes(name.toLowerCase() as 'x-frame-options')) continue;
+ if (name.toLowerCase() === CSP_HEADER && typeof value === 'string') {
+ const directives = value
+ .split(';')
+ .map((directive) => directive.trim())
+ .filter((directive) => directive.length > 0 && !directive.toLowerCase().startsWith('frame-ancestors'));
+ result[name] = directives.join('; ');
+ continue;
+ }
+ result[name] = value;
+ }
+ return result;
+}
+
+function injectBridge(document: string, bridge: MiniAppPageBridge): string {
+ const tag = ``;
+ const headEnd = document.search(/<\/head\s*>/iu);
+ if (headEnd >= 0) return `${document.slice(0, headEnd)}${tag}${document.slice(headEnd)}`;
+ const bodyStart = document.search(/]*>/iu);
+ if (bodyStart >= 0) {
+ const insertAt = document.indexOf('>', bodyStart) + 1;
+ return `${document.slice(0, insertAt)}${tag}${document.slice(insertAt)}`;
+ }
+ return `${tag}${document}`;
+}
diff --git a/packages/webui/test/unit/miniapp-proxy.test.ts b/packages/webui/test/unit/miniapp-proxy.test.ts
new file mode 100644
index 000000000..390a186e4
--- /dev/null
+++ b/packages/webui/test/unit/miniapp-proxy.test.ts
@@ -0,0 +1,214 @@
+import http from 'node:http';
+import net from 'node:net';
+import type { Duplex } from 'node:stream';
+
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import {
+ deriveMiniAppProxyPort,
+ startMiniAppProxy,
+ type MiniAppProxy,
+} from '../../src/server/miniapp/proxy.js';
+
+const CREDENTIAL = 'upstream-credential-0123456789';
+const CREDENTIAL_HEADER = 'x-mavis-miniapp-token';
+
+interface Seen {
+ readonly method?: string;
+ readonly url?: string;
+ readonly headers: http.IncomingHttpHeaders;
+}
+
+const seen: Seen[] = [];
+const upgraded = new Set();
+let upstream: http.Server;
+let upstreamPort = 0;
+let proxy: MiniAppProxy;
+
+beforeAll(async () => {
+ upstream = http.createServer((request, response) => {
+ seen.push({ method: request.method, url: request.url, headers: { ...request.headers } });
+ if (request.url === '/page') {
+ response
+ .writeHead(200, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'x-frame-options': 'DENY',
+ 'content-security-policy': "default-src 'self'; frame-ancestors 'none'; script-src 'self'",
+ })
+ .end('apppage');
+ return;
+ }
+ if (request.url === '/bare') {
+ response.writeHead(200, { 'content-type': 'text/html' }).end('no head here
');
+ return;
+ }
+ if (request.url === '/echo') {
+ response.writeHead(200, { 'content-type': 'text/plain' }).end('from-upstream');
+ return;
+ }
+ response.writeHead(404).end();
+ });
+ upstream.on('upgrade', (request, socket) => {
+ upgraded.add(socket);
+ socket.once('close', () => upgraded.delete(socket));
+ seen.push({ method: request.method, url: request.url, headers: { ...request.headers } });
+ socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n');
+ });
+ await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve));
+ const address = upstream.address();
+ upstreamPort = typeof address === 'object' && address ? address.port : 0;
+
+ proxy = await startMiniAppProxy({
+ target: {
+ pluginId: 'fixture.miniapp',
+ upstreamOrigin: `http://127.0.0.1:${upstreamPort}`,
+ credential: CREDENTIAL,
+ credentialHeader: CREDENTIAL_HEADER,
+ },
+ port: 0,
+ bridge: { path: '/__miniapp/bridge.js', script: 'globalThis.__bridge = true;' },
+ });
+});
+
+afterAll(async () => {
+ await proxy?.close();
+ // An upgraded socket leaves the server's own tracking, so the fixture ends it explicitly.
+ for (const socket of upgraded) socket.destroy();
+ upgraded.clear();
+ upstream?.closeAllConnections();
+ await new Promise((resolve) => upstream?.close(() => resolve()));
+});
+
+interface Result {
+ readonly status: number;
+ readonly body: string;
+ readonly headers: http.IncomingHttpHeaders;
+}
+
+function get(port: number, path: string): Promise {
+ return new Promise((resolve, reject) => {
+ const request = http.request(
+ { host: '127.0.0.1', port, method: 'GET', path, agent: false },
+ (response) => {
+ let body = '';
+ response.setEncoding('utf8');
+ response.on('data', (chunk: string) => {
+ body += chunk;
+ });
+ response.on('end', () =>
+ resolve({ status: response.statusCode ?? 0, body, headers: response.headers }),
+ );
+ },
+ );
+ request.on('error', reject);
+ request.end();
+ });
+}
+
+describe('Mini App proxy', () => {
+ it('forwards the path unchanged and returns the upstream response', async () => {
+ const result = await get(proxy.port, '/echo');
+
+ expect(result.status).toBe(200);
+ expect(result.body).toBe('from-upstream');
+ expect(seen.at(-1)?.url).toBe('/echo');
+ expect(seen.at(-1)?.method).toBe('GET');
+ });
+
+ it('injects the credential outbound and never returns it to the caller', async () => {
+ const result = await get(proxy.port, '/echo');
+
+ expect(seen.at(-1)?.headers[CREDENTIAL_HEADER]).toBe(CREDENTIAL);
+ expect(result.headers).not.toHaveProperty(CREDENTIAL_HEADER);
+ });
+
+ it('forwards an upgrade with the credential', async () => {
+ const response = await new Promise((resolve, reject) => {
+ const socket = net.connect({ host: '127.0.0.1', port: proxy.port });
+ let received = '';
+ socket.once('connect', () => {
+ socket.write(
+ `GET /socket HTTP/1.1\r\nHost: 127.0.0.1:${proxy.port}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`,
+ );
+ });
+ socket.on('data', (chunk) => {
+ received += chunk.toString('utf8');
+ socket.destroy();
+ resolve(received);
+ });
+ socket.once('error', reject);
+ socket.setTimeout(2_000, () => {
+ socket.destroy();
+ reject(new Error('upgrade timed out'));
+ });
+ });
+
+ expect(response).toContain('101');
+ expect(seen.at(-1)?.url).toBe('/socket');
+ expect(seen.at(-1)?.headers[CREDENTIAL_HEADER]).toBe(CREDENTIAL);
+ });
+
+ it('keeps one plugin on the same port across a restart, so the page storage survives', async () => {
+ const derived = deriveMiniAppProxyPort('fixture.miniapp');
+ const target = {
+ pluginId: 'fixture.miniapp',
+ upstreamOrigin: `http://127.0.0.1:${upstreamPort}`,
+ credential: CREDENTIAL,
+ credentialHeader: CREDENTIAL_HEADER,
+ };
+
+ const first = await startMiniAppProxy({ target, port: derived });
+ const firstUrl = first.viewUrl;
+ await first.close();
+
+ const second = await startMiniAppProxy({ target, port: deriveMiniAppProxyPort('fixture.miniapp') });
+ try {
+ expect(second.viewUrl).toBe(firstUrl);
+ expect(second.port).toBe(derived);
+ } finally {
+ await second.close();
+ }
+ });
+
+ it('derives a stable loopback port per plugin', () => {
+ const first = deriveMiniAppProxyPort('fixture.miniapp');
+
+ expect(first).toBe(deriveMiniAppProxyPort('fixture.miniapp'));
+ expect(first).toBeGreaterThanOrEqual(49_152);
+ expect(first).toBeLessThanOrEqual(65_535);
+ expect(deriveMiniAppProxyPort('another.miniapp')).not.toBe(first);
+ });
+});
+
+describe('Mini App proxy document rewrite', () => {
+ it('injects the bridge before the document head ends', async () => {
+ const result = await get(proxy.port, '/page');
+
+ expect(result.status).toBe(200);
+ expect(result.body).toContain('');
+ expect(result.body).toContain('app');
+ });
+
+ it('makes the document framable: drops X-Frame-Options and the frame-ancestors directive', async () => {
+ const result = await get(proxy.port, '/page');
+
+ expect(result.headers).not.toHaveProperty('x-frame-options');
+ expect(result.headers['content-security-policy']).toBe("default-src 'self'; script-src 'self'");
+ });
+
+ it('injects before the body when the document has no head', async () => {
+ const result = await get(proxy.port, '/bare');
+
+ expect(result.body).toBe('no head here
');
+ });
+
+ it('serves the bridge itself and never forwards that path upstream', async () => {
+ const before = seen.length;
+ const result = await get(proxy.port, '/__miniapp/bridge.js');
+
+ expect(result.status).toBe(200);
+ expect(result.body).toBe('globalThis.__bridge = true;');
+ expect(result.headers['content-type']).toContain('text/javascript');
+ expect(seen.length).toBe(before);
+ });
+});
diff --git a/release/public-source.json b/release/public-source.json
index e568cd8dd..d78989a43 100644
--- a/release/public-source.json
+++ b/release/public-source.json
@@ -4137,6 +4137,7 @@
"packages/webui/src/server/http/session-transfer.ts",
"packages/webui/src/server/http/workspace-file.ts",
"packages/webui/src/server/index.ts",
+ "packages/webui/src/server/miniapp/proxy.ts",
"packages/webui/src/server/operation/account.ts",
"packages/webui/src/server/operation/agent-memory.ts",
"packages/webui/src/server/operation/bind-handlers.ts",
@@ -4342,6 +4343,7 @@
"packages/webui/test/unit/markdown-mermaid.test.tsx",
"packages/webui/test/unit/message-attachments.test.tsx",
"packages/webui/test/unit/message-parts.test.ts",
+ "packages/webui/test/unit/miniapp-proxy.test.ts",
"packages/webui/test/unit/model-effort-scale.test.ts",
"packages/webui/test/unit/model-picker-effort-options.test.ts",
"packages/webui/test/unit/model-picker-groups.test.tsx",
diff --git a/test/vitest-suites.json b/test/vitest-suites.json
index 2e856734f..8bb86b5d9 100644
--- a/test/vitest-suites.json
+++ b/test/vitest-suites.json
@@ -472,6 +472,7 @@
"packages/webui/test/unit/browser-bridge-cdp.test.ts",
"packages/webui/test/unit/browser-bridge-adversarial.test.ts",
"packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts",
+ "packages/webui/test/unit/miniapp-proxy.test.ts",
"packages/webui/test/unit/workspace-write-fence.test.ts"
]
}