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
3 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