test(web): prove the dedicated app origin end to end

`frame-ancestors` is checked against every ancestor, not just the parent.
A published app document is framed by the sandbox proxy, which is framed
by the Inspector page — so admitting only the proxy's origin blocked the
frame outright (it rendered as a chrome-error frame that never reached
the bridge). `appDocumentEmbedders` now admits both.

That failure was invisible to every unit test, so `smoke:web:app` grows a
second phase: the same demo app declaring `_meta.ui.domain`, driven
through the same connect → open → ready chain, then asserting the two
facts that only hold on the dedicated path — the inner frame was
navigated to a published document, and its own `location.origin` is a
real http origin rather than "null". Both phases end at
`data-app-status="ready"`, so nothing short of reading the frame's origin
can tell them apart.

Adds `test-servers/configs/mcp-app-domain-http.json` (and a `domain`
param on the `mcp_app_demo_widget` preset) as the showcase server for
both the smoke and hand testing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 23:21:41 -04:00 • committed by GitHub
commit 8e8c1bbad9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 200 additions and 34 deletions

View file

@ -75,23 +75,33 @@ const MAX_DOCUMENTS = 32;
const DOCUMENT_TTL_MS = 60 * 60 * 1000;
/**
* The sandbox proxy's origin as a one-entry list for
* {@link AppOriginControllerOptions.embedderOrigins}, or `undefined` when the
* sandbox never bound (its controller degrades to a null URL). Passing
* `undefined` is what makes the app-origin `frame-ancestors` fall back to
* loopback rather than to `'none'`, which would block the frame outright.
* The origins that may appear in a published document's ancestor chain, for
* {@link AppOriginControllerOptions.embedderOrigins}.
*
* There are **two**, and getting this wrong blocks the frame outright: CSP's
* `frame-ancestors` is checked against *every* ancestor, not just the immediate
* parent. A published document is framed by the sandbox proxy, which is itself
* framed by the Inspector app page — so the directive has to admit the proxy's
* origin **and** the app's, which is exactly the backend's origin allow-list.
*
* `undefined` when neither is known, which makes the directive fall back to the
* loopback family rather than to `'none'`.
*/
export function sandboxOriginList(
export function appDocumentEmbedders(
sandboxUrl: string | null | undefined,
allowedOrigins?: string[],
): string[] | undefined {
if (!sandboxUrl) return undefined;
try {
return [new URL(sandboxUrl).origin];
} catch {
/* v8 ignore next -- the sandbox controller only ever emits a URL it built
itself; an unparseable value would be a bug there, not input. */
return undefined;
const origins: string[] = [];
if (sandboxUrl) {
try {
origins.push(new URL(sandboxUrl).origin);
} catch {
/* v8 ignore next -- the sandbox controller only ever emits a URL it built
itself; an unparseable value would be a bug there, not input. */
}
}
origins.push(...(allowedOrigins ?? []));
return origins.length > 0 ? origins : undefined;
}
export interface AppOriginControllerOptions {

View file

@ -18,7 +18,7 @@ import { formatHostForUrl } from "../../../core/node/hostUrl.ts";
import { createSandboxController } from "./sandbox-controller.js";
import {
createAppOriginController,
sandboxOriginList,
appDocumentEmbedders,
} from "./app-origin-controller.js";
import { injectAuthToken } from "./inject-auth-token.js";
import type { WebServerConfig } from "./web-server-config.js";
@ -54,7 +54,10 @@ export async function startHonoServer(
const appOriginController = createAppOriginController({
port: config.appOriginPort,
host: config.sandboxHost,
embedderOrigins: sandboxOriginList(sandboxController.getUrl()),
embedderOrigins: appDocumentEmbedders(
sandboxController.getUrl(),
config.allowedOrigins,
),
});
await appOriginController.start();

View file

@ -18,7 +18,7 @@ import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
import { createSandboxController } from "./sandbox-controller.js";
import {
createAppOriginController,
sandboxOriginList,
appDocumentEmbedders,
} from "./app-origin-controller.js";
import { injectAuthToken } from "./inject-auth-token.js";
import type { WebServerConfig } from "./web-server-config.js";
@ -77,7 +77,10 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
const appOriginController = createAppOriginController({
port: config.appOriginPort,
host: config.sandboxHost,
embedderOrigins: sandboxOriginList(sandboxController.getUrl()),
embedderOrigins: appDocumentEmbedders(
sandboxController.getUrl(),
config.allowedOrigins,
),
});
await appOriginController.start();
// Resolved before the API is built so `/api/config` and the banner

View file

@ -19,7 +19,7 @@ import {
DEFAULT_APP_ORIGIN_PORT,
parseDocumentId,
resolveAppOriginPort,
sandboxOriginList,
appDocumentEmbedders,
} from "../../../../server/app-origin-controller.js";
describe("resolveAppOriginPort", () => {
@ -85,20 +85,37 @@ describe("parseDocumentId", () => {
});
});
describe("sandboxOriginList", () => {
it("reduces the sandbox URL to its origin", () => {
expect(sandboxOriginList("http://127.0.0.1:6275/sandbox")).toEqual([
describe("appDocumentEmbedders", () => {
it("admits the sandbox proxy AND the Inspector page", () => {
// `frame-ancestors` is checked against EVERY ancestor, not just the parent:
// the document is framed by the proxy, which is framed by the app page.
// Omitting the app page's origin blocks the frame outright (it renders as a
// chrome-error frame that never reaches the bridge).
expect(
appDocumentEmbedders("http://127.0.0.1:6275/sandbox", [
"http://127.0.0.1:6274",
"http://localhost:6274",
]),
).toEqual([
"http://127.0.0.1:6275",
"http://127.0.0.1:6274",
"http://localhost:6274",
]);
});
it("still returns the allow-list when the sandbox never bound", () => {
expect(appDocumentEmbedders(null, ["http://127.0.0.1:6274"])).toEqual([
"http://127.0.0.1:6274",
]);
});
it.each([[null], [undefined], [""]])(
"returns undefined for %j so frame-ancestors falls back to loopback",
"returns undefined for %j with no allow-list, so frame-ancestors falls back to loopback",
(value) => {
// Not `[]`: an empty list would make the directive `'none'` and block the
// app frame outright, which is worse than a permissive loopback fallback.
expect(
sandboxOriginList(value as string | null | undefined),
appDocumentEmbedders(value as string | null | undefined),
).toBeUndefined();
},
);

View file

@ -8,6 +8,13 @@
* unexercised by any smoke. This closes that gap: it drives the full
* **connect → open app → widget ready** chain against a real MCP App server.
*
* A second phase (#2056) drives the same app declaring `_meta.ui.domain`, and
* asserts the thing that field exists for: the app document is served from the
* backend's dedicated app-origin listener and runs at a REAL origin, not the
* opaque one whose requests carry `Origin: null`. Both phases end at
* `data-app-status="ready"`, so only reading the inner frame's own
* `location.origin` can tell them apart.
*
* The assertion is the `data-app-status="ready"` contract documented in
* clients/web/README.md ("MCP Apps screen automation contract"): the renderer
* only reports `ready` once the widget has loaded inside the sandbox iframe and
@ -72,6 +79,14 @@ const appConfig = join(
"configs",
"mcp-app-http.json",
);
// The same demo app, with `_meta.ui.domain` declared (#2056). Phase 2 drives it
// to prove the dedicated-origin path end to end.
const appDomainConfig = join(
repoRoot,
"test-servers",
"configs",
"mcp-app-domain-http.json",
);
// The path clients/web/server/sandbox-controller.ts resolves at runtime, from
// the built runner at clients/web/build/. Kept in sync with the `join(__dirname,
// "../static/sandbox_proxy.html")` there.
@ -104,6 +119,7 @@ const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/;
let mcpUrl = null;
let mcpServer = null;
let domainMcpServer = null;
let browser = null;
const server = startProdWebServer({
host: HOST,
@ -127,6 +143,14 @@ async function shutdown() {
mcpServer = null;
await stopChild(child, { label: "smoke:web:app", what: "MCP test server" });
}
if (domainMcpServer) {
const child = domainMcpServer;
domainMcpServer = null;
await stopChild(child, {
label: "smoke:web:app",
what: "MCP test server (domain)",
});
}
}
async function fail(message) {
@ -176,15 +200,18 @@ function ensureTestServer() {
* createTestServerHttp resolves through findAvailablePort(), which walks upward
* when the configured value is taken.
*/
async function startMcpServer() {
async function startMcpServer(
config = appConfig,
publish = (child) => {
mcpServer = child;
},
) {
const { match } = await startAnnouncedChild({
command: process.execPath,
args: [composableServer, "--config", appConfig],
args: [composableServer, "--config", config],
cwd: repoRoot,
pattern: /listening at (http:\/\/\S+)/i,
onSpawn: (child) => {
mcpServer = child;
},
onSpawn: publish,
what: `MCP test server (${composableServer})`,
});
return { url: match[1] };
@ -256,10 +283,11 @@ try {
// Deep link: connect, switch to the Apps tab, pre-select the app tool, and
// fire "Open App". autoConnect/autoOpen must equal the session token (CSRF
// gate). Shape owned by clients/web/README.md#deep-link-auto-connect.
const url =
`${server.baseUrl}/?serverUrl=${encodeURIComponent(mcpUrl)}` +
const deepLink = (target) =>
`${server.baseUrl}/?serverUrl=${encodeURIComponent(target)}` +
`&transport=http&autoConnect=${TOKEN}&openApp=${APP_TOOL}` +
`&appArgs=${encodeAppArgs({ title: "smoke:web:app" })}&autoOpen=${TOKEN}`;
const url = deepLink(mcpUrl);
const drive = async () => {
const response = await page.goto(url, {
@ -309,10 +337,88 @@ try {
}
};
/**
* Phase 2 (#2056): the same app, this time declaring `_meta.ui.domain`.
*
* Phase 1 proves the default render works; it cannot distinguish it from the
* dedicated-origin render, because both end at `data-app-status="ready"`. The
* thing #2056 is actually about is the *origin the app document runs at* — an
* opaque origin sends `Origin: null`, which no CORS / OAuth-callback /
* API-key allowlist can admit. So this phase asserts the two facts that only
* hold on the dedicated path: the inner frame was navigated to a published
* document on the backend's app-origin listener, and the document's own
* `location.origin` is a real origin rather than the literal "null".
*
* It reuses the running web server and browser page — only a second MCP
* server and a second deep-link navigate are added.
*/
const driveDedicatedOrigin = async () => {
const { url: domainUrl } = await startMcpServer(
appDomainConfig,
(child) => {
domainMcpServer = child;
},
);
await page.goto(deepLink(domainUrl), {
waitUntil: "domcontentloaded",
timeout: 30_000,
});
await page
.locator('[data-testid="connection-status"][data-status="connected"]')
.waitFor({ state: "attached", timeout: 45_000 });
try {
await page
.locator('[data-testid="apps-form"][data-app-status="ready"]')
.waitFor({ state: "attached", timeout: 45_000 });
} catch {
const form = page.locator('[data-testid="apps-form"]');
const appStatus = (await form.count())
? await form.getAttribute("data-app-status")
: "(no apps-form)";
const appError = (await form.count())
? await form.getAttribute("data-app-error")
: null;
throw new Error(
`dedicated-origin app never reached data-app-status="ready" (last: ` +
`"${appStatus}"${appError ? `, data-app-error="${appError}"` : ""}) — ` +
`frames: ${page
.frames()
.map((f) => f.url() || "(blank)")
.join(", ")}`,
);
}
const appFrame = page
.frames()
.find((f) => /\/app-document\/[0-9a-f]{32}$/.test(f.url()));
if (!appFrame) {
throw new Error(
"app declaring _meta.ui.domain was not served from the dedicated app " +
`origin — no frame at /app-document/<id> (frames: ${page
.frames()
.map((f) => f.url() || "(blank)")
.join(", ")})`,
);
}
const origin = await appFrame.evaluate(() => window.location.origin);
if (!/^http:\/\/[^/]+$/.test(origin)) {
throw new Error(
`app document ran at origin "${origin}" — expected a real http origin. ` +
"An opaque origin sends `Origin: null`, which is the bug #2056 fixes.",
);
}
return { origin, url: appFrame.url() };
};
// Race against launcher death so a mid-run server crash is reported as the
// real cause instead of a downstream timeout.
let dedicated = null;
try {
await Promise.race([server.whenChildExits(), drive()]);
dedicated = await Promise.race([
server.whenChildExits(),
driveDedicatedOrigin(),
]);
} catch (err) {
const diagnostics = [
...pageErrors,
@ -345,7 +451,9 @@ try {
console.log(
`smoke:web:app OK — connected to ${mcpUrl}, opened "${APP_TOOL}", ` +
`widget reached data-app-status="ready" through the sandbox proxy`,
`widget reached data-app-status="ready" through the sandbox proxy; ` +
`and an app declaring _meta.ui.domain rendered from the dedicated origin ` +
`${dedicated?.origin} (${dedicated?.url})`,
);
await shutdown();
process.exit(0);

View file

@ -0,0 +1,17 @@
{
"serverInfo": {
"name": "mcp-app-domain-showcase",
"version": "1.0.0"
},
"tools": [{ "preset": "echo" }, { "preset": "mcp_app_demo" }],
"resources": [
{
"preset": "mcp_app_demo_widget",
"params": { "domain": "my-app-domain.example.com" }
}
],
"transport": {
"type": "streamable-http",
"port": 3134
}
}

View file

@ -253,7 +253,7 @@ function resolveResourcePreset(
case "numbered_resources":
return createNumberedResources(Number(get("count")) || 3);
case "mcp_app_demo_widget":
return createMcpAppDemoResource();
return createMcpAppDemoResource(get("domain") as string | undefined);
case "choose_option_app":
return createAppElicitationResource();
case "rfc6570_base":

View file

@ -1617,8 +1617,15 @@ export function createMcpAppDemoTool(): ToolDefinition {
* `_meta.ui.csp` (no external connect/resource domains) and a sample
* `permissions` block so `--app-info` and the host's CSP enforcement both have
* something to read.
*
* `domain` is the spec field by which a server asks its host for a stable,
* dedicated origin (#2056). Omitted by default so the default opaque-origin
* render stays the thing this fixture exercises; pass one to drive the
* dedicated-origin path instead. Its *value* is not an address the Inspector
* serves — the spec makes the format host-dependent, and the Inspector reads
* any non-empty string as "give me a real origin".
*/
export function createMcpAppDemoResource(): ResourceDefinition {
export function createMcpAppDemoResource(domain?: string): ResourceDefinition {
return {
name: "mcp_app_demo_widget",
uri: MCP_APP_DEMO_URI,
@ -1630,6 +1637,7 @@ export function createMcpAppDemoResource(): ResourceDefinition {
csp: { connectDomains: [], resourceDomains: [] },
permissions: { clipboard: false },
prefersBorder: true,
...(domain ? { domain } : {}),
},
},
};