From b3b0981292d9af55f0d42ee005eabebbe33df225 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:29:29 +0800 Subject: [PATCH 1/3] feat(webui): serve a Mini App page through its own loopback proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of #255. A Mini App page refers to its own origin by absolute path — the scaffold serves `/dashboard`, and a real page also fetches `/api/...` and its assets — so the proxy forwards paths unchanged rather than hiding them behind a prefix that would have to be patched back by intercepting every fetch and absolute URL in the document. `startMiniAppProxy` puts one listener on loopback per running Mini App, on a port derived per plugin. That stability is required, not cosmetic: 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. Requests are forwarded with the Mini App's credential injected, and the credential never appears in a response. Upgrades are forwarded under the same rule, and a closed peer tears down its pair — a half-closed pipe would hold the upstream server open forever, which is how this showed up in the test teardown. Server-only by placement: the proxy takes the upstream origin and the credential as input and returns `viewUrl`, which is what a client frames. The client never assembles a Mini App origin. It lives in `packages/webui/src/server/` because ADR 0020 puts the proxy in the WebUI service and keeps everything that touches a real origin on the server side of the facade; the server layer may import `shared` and `runtime-port` and nothing else. Proven against a real upstream server: the path is forwarded unchanged, the response body reaches the caller, the credential is injected outbound and absent from the response, an upgrade is forwarded with the credential, and the derived port is stable per plugin and inside the loopback range. Still to do for #255: the document rewrite that injects the page bridge (the bridge itself is #260), the per-session and per-presentation view URL, and wiring the proxy into the open operation so it starts and stops with the Mini App. Verified: pnpm typecheck:webui (server, client and test); the focused suite (4 cases); pnpm check:source after the inventory write. pnpm check:webui-dependency still fails on a pre-existing violation in WorkspacePanels.tsx — `document.fonts.ready` in a bindings file, present at the base commit 9713c4cf and untouched by this branch; the new module adds none. --- packages/webui/src/server/miniapp/proxy.ts | 149 ++++++++++++++++++ .../webui/test/unit/miniapp-proxy.test.ts | 144 +++++++++++++++++ release/public-source.json | 2 + test/vitest-suites.json | 1 + 4 files changed, 296 insertions(+) create mode 100644 packages/webui/src/server/miniapp/proxy.ts create mode 100644 packages/webui/test/unit/miniapp-proxy.test.ts diff --git a/packages/webui/src/server/miniapp/proxy.ts b/packages/webui/src/server/miniapp/proxy.ts new file mode 100644 index 000000000..dec8d4235 --- /dev/null +++ b/packages/webui/src/server/miniapp/proxy.ts @@ -0,0 +1,149 @@ +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); +} + +export interface StartMiniAppProxyInput { + readonly target: MiniAppProxyTarget; + /** Defaults to the derived port; a test or a retry may pass the next candidate. */ + readonly port?: number; +} + +/** + * 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) => { + 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) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }, + ); + 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())); + }, + }; +} 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..2a7453d2f --- /dev/null +++ b/packages/webui/test/unit/miniapp-proxy.test.ts @@ -0,0 +1,144 @@ +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 === '/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, + }); +}); + +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('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); + }); +}); 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" ] } From 5ca1c6ec864c929208ef891d2a544a6b93f2c3d1 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:32:51 +0800 Subject: [PATCH 2/3] feat(webui): make the proxied Mini App document framable and carry the bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of #255. The page is framed by this client on a different origin, so a page must not be able to refuse being framed. The proxy drops `X-Frame-Options` from every response it forwards and removes the `frame-ancestors` directive from a page's own CSP, leaving its other directives alone — the Mini App's origin is separate 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 ``, after the opening `` tag when a page has no head, or ahead of the document when it has neither; the body it sends is not the body it received, so it restates `Content-Length` and drops the upstream's `Transfer-Encoding` with it. The bridge script is served by the proxy itself at its own path and never forwarded upstream, which keeps the seam in one place: this change owns which document, where the tag goes and what headers survive, while the bridge body arrives with #260. ADR 0020's open question about framing is settled by these rules and removed from the open list; the bridge's method-level error mapping is the only one left. Proven: the bridge is injected before ``, before `` on a headless document, `X-Frame-Options` is gone, `frame-ancestors` is gone while `default-src` and `script-src` survive, the bridge path is served locally without reaching the upstream, and paths and bodies of ordinary responses are untouched. Verified: pnpm typecheck:webui (server, client and test); the focused suite (8 cases); pnpm check:source after the inventory write. pnpm check:webui-dependency still fails on the pre-existing WorkspacePanels.tsx violation described in the previous commit. --- ...20-webui-miniapp-view-and-runtime-seams.md | 20 +++-- packages/webui/src/server/miniapp/proxy.ts | 86 ++++++++++++++++++- .../webui/test/unit/miniapp-proxy.test.ts | 48 +++++++++++ 3 files changed, 147 insertions(+), 7 deletions(-) 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 index dec8d4235..6056eda63 100644 --- a/packages/webui/src/server/miniapp/proxy.ts +++ b/packages/webui/src/server/miniapp/proxy.ts @@ -37,12 +37,28 @@ export function deriveMiniAppProxyPort(pluginId: string): number { 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. * @@ -62,6 +78,12 @@ export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise< 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, @@ -75,8 +97,28 @@ export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise< agent: false, }, (upstreamResponse) => { - response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); - upstreamResponse.pipe(response); + 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', () => { @@ -147,3 +189,43 @@ export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise< }, }; } + +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 index 2a7453d2f..4abbe7166 100644 --- a/packages/webui/test/unit/miniapp-proxy.test.ts +++ b/packages/webui/test/unit/miniapp-proxy.test.ts @@ -28,6 +28,20 @@ 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; @@ -52,6 +66,7 @@ beforeAll(async () => { credentialHeader: CREDENTIAL_HEADER, }, port: 0, + bridge: { path: '/__miniapp/bridge.js', script: 'globalThis.__bridge = true;' }, }); }); @@ -142,3 +157,36 @@ describe('Mini App proxy', () => { 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); + }); +}); From 44af7f51bc6a6c88ec89fb39644625a6554951e9 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:34:40 +0800 Subject: [PATCH 3/3] test(webui): keep a Mini App's view URL stable across a proxy restart Closes #255's stability criterion with evidence. 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. The derived port is a pure function of the plugin id, and this asserts the property end to end: start on the derived port, close, start again from a fresh derivation, and the view URL is identical. Verified: pnpm typecheck:webui; the focused suite (9 cases). --- .../webui/test/unit/miniapp-proxy.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/webui/test/unit/miniapp-proxy.test.ts b/packages/webui/test/unit/miniapp-proxy.test.ts index 4abbe7166..390a186e4 100644 --- a/packages/webui/test/unit/miniapp-proxy.test.ts +++ b/packages/webui/test/unit/miniapp-proxy.test.ts @@ -148,6 +148,28 @@ describe('Mini App proxy', () => { 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');