* feat(web): port v1.5 dev backend (vite-hono-plugin + sandbox + remote-server bootstrap) Ports the v1.5 in-process dev backend into v2 so `npm run dev` serves the Hono remote-server (`/api/*`) alongside the Vite-served browser app. Without this, the browser-side `createRemoteTransport` had nothing to talk to during local development. Adds under `clients/web/server/`: - vite-hono-plugin.ts (Hono middleware on the Vite dev server; gated to `apply: 'serve'` and skipped when `process.env.VITEST` is set so it stays inert in vitest/storybook runs) - sandbox-controller.ts (standalone HTTP server for the MCP Apps sandbox) - web-server-config.ts (env parsing + initial-config payload + banner; `autoOpen` defaults off under VITEST so test runs don't shell out to `open`) - server.ts (standalone Hono prod server for the future v2 launcher, #1246) - start-vite-dev-server.ts (in-process Vite starter for the future launcher) - vite-base-config.ts (shared optimizeDeps exclusions) Wires `honoMiddlewarePlugin(buildWebServerConfigFromEnv())` into `vite.config.ts` with `apply: 'serve'`. Adds the `open` dependency and a DOM lib + `@inspector/core/*` path alias to `tsconfig.node.json` so the dev-backend files type-check alongside vite.config.ts. Coverage: web-server-config.ts (100% lines), sandbox-controller.ts (91% lines), and vite-base-config.ts (100%) all clear the per-file gate. The Vite plugin, prod server, and dev-server starter are excluded from coverage as runtime glue exercised by `npm run dev` end-to-end (callers covered separately; their logic lives in core/mcp/remote/node, which integration tests already exercise). Closes #1320. Unblocks #1244 (Wire App.tsx) — its test plan can now run end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web/server): address PR review feedback on the dev-backend port - sandbox-controller: resolve start() with empty values on listen error (e.g. EADDRINUSE on a fixed MCP_SANDBOX_PORT) instead of leaving the promise pending forever. Without this, the Vite plugin's `await sandboxController.start()` could hang the entire dev backend on a port collision. Banner now silently omits the sandbox line; getUrl() keeps returning null. Adds a regression test exercising the EADDRINUSE path against a real socket. - vite-hono-plugin: listen for `error` and `close` on the incoming request stream (in addition to `end`) so an aborted POST/PUT (browser navigates mid-upload) doesn't leak the middleware indefinitely. - vite-hono-plugin: add a `.catch()` to the initial `pump()` kickoff so a sync throw before the first `await reader.read()` can't surface as an unhandled rejection. - sandbox_proxy.html: drop the stale CSP comment — v1.5's sandbox HTTP server never set CSP either; this PR doesn't regress anything. Replace with the actual isolation model (iframe sandbox attribute + origin allowlist) so future readers don't assume a CSP header is enforced. - Hoist `WebServerHandle` to a shared `server/types.ts` so the future launcher port (#1246) doesn't need to pick one of two duplicate declarations. - Extract `resolveAutoOpen()` helper out of the triple-nested ternary in `buildWebServerConfigFromEnv` for legibility, with the three-way logic documented in one place. - Add an inline comment explaining why `webServerConfigToInitialPayload` falls back to streamable-http for unknown discriminators (forward-compat with future SDK transports) rather than throwing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
203 lines
7.7 KiB
HTML
203 lines
7.7 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="color-scheme" content="light dark" />
|
|
<!-- Isolation model: the iframe `sandbox` attribute set on the inner
|
|
frame below ("allow-scripts allow-same-origin allow-forms") is the
|
|
only enforced isolation boundary. Plus the parent-origin allowlist
|
|
(ALLOWED_REFERRER_PATTERN + EXPECTED_HOST_ORIGIN check on incoming
|
|
messages). A CSP header is intentionally NOT set by the sandbox
|
|
controller — if you need one, send it from server.ts when serving
|
|
this file. -->
|
|
<title>MCP-UI Proxy</title>
|
|
<style>
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
/* Transparent background allows parent page to show through */
|
|
background-color: transparent;
|
|
}
|
|
body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
iframe {
|
|
background-color: transparent;
|
|
border: 0px none transparent;
|
|
padding: 0px;
|
|
overflow: hidden;
|
|
flex-grow: 1;
|
|
/* Inherit color scheme from parent for consistent transparency */
|
|
color-scheme: inherit;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<script>
|
|
function buildAllowAttribute(permissions) {
|
|
if (!permissions) return "";
|
|
|
|
const allowList = [];
|
|
if (permissions["camera"]) allowList.push("camera");
|
|
if (permissions["microphone"]) allowList.push("microphone");
|
|
if (permissions["geolocation"]) allowList.push("geolocation");
|
|
if (permissions["clipboardWrite"]) allowList.push("clipboard-write");
|
|
|
|
return allowList.join("; ");
|
|
}
|
|
|
|
const ALLOWED_REFERRER_PATTERN =
|
|
/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
|
|
|
|
if (window.self === window.top) {
|
|
throw new Error("This file is only to be used in an iframe sandbox.");
|
|
}
|
|
|
|
if (!document.referrer) {
|
|
throw new Error("No referrer, cannot validate embedding site.");
|
|
}
|
|
|
|
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
|
|
throw new Error(
|
|
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
|
|
);
|
|
}
|
|
|
|
// Extract the expected host origin from the referrer for origin validation.
|
|
// This is the origin we expect all parent messages to come from.
|
|
const EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
|
|
|
|
const OWN_ORIGIN = new URL(window.location.href).origin;
|
|
|
|
// Security self-test: verify iframe isolation is working correctly.
|
|
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
|
|
// configuration is dangerously broken and untrusted content could escape.
|
|
try {
|
|
window.top?.alert(
|
|
"If you see this, the sandbox is not setup securely.",
|
|
);
|
|
throw "FAIL";
|
|
} catch (e) {
|
|
if (e === "FAIL") {
|
|
throw new Error("The sandbox is not setup securely.");
|
|
}
|
|
|
|
// Expected: SecurityError confirms proper sandboxing.
|
|
}
|
|
|
|
// Double-iframe sandbox architecture: THIS file is the outer sandbox proxy
|
|
// iframe on a separate origin. It creates an inner iframe for untrusted HTML
|
|
// content. Per the specification, the Host and the Sandbox MUST have different
|
|
// origins.
|
|
const inner = document.createElement("iframe");
|
|
inner.style = "width:100%; height:100%; border:none;";
|
|
inner.setAttribute(
|
|
"sandbox",
|
|
"allow-scripts allow-same-origin allow-forms",
|
|
);
|
|
// Note: allow attribute is set later when receiving sandbox-resource-ready notification
|
|
// based on the permissions requested by the app
|
|
document.body.appendChild(inner);
|
|
|
|
const RESOURCE_READY_NOTIFICATION =
|
|
"ui/notifications/sandbox-resource-ready";
|
|
const PROXY_READY_NOTIFICATION = "ui/notifications/sandbox-proxy-ready";
|
|
|
|
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
|
|
// forwarding messages between:
|
|
//
|
|
// Host (parent window) ↔ Sandbox (outer frame) ↔ View (inner iframe)
|
|
//
|
|
// Reason: the parent window and inner iframe have different origins and can't
|
|
// communicate directly, so the outer iframe forwards messages in both
|
|
// directions to connect them.
|
|
//
|
|
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
|
|
// intercepted here (not relayed) because the Sandbox uses it to configure and
|
|
// load the inner iframe with the view HTML content.
|
|
//
|
|
// Security: isolation comes from the iframe `sandbox` attribute and
|
|
// the origin allowlist above. No CSP header is set on this page (see
|
|
// the head comment); the sandbox attribute is the enforced boundary.
|
|
|
|
window.addEventListener("message", async (event) => {
|
|
if (event.source === window.parent) {
|
|
// Validate that messages from parent come from the expected host origin.
|
|
// This prevents malicious pages from sending messages to this sandbox.
|
|
if (event.origin !== EXPECTED_HOST_ORIGIN) {
|
|
console.error(
|
|
"[Sandbox] Rejecting message from unexpected origin:",
|
|
event.origin,
|
|
"expected:",
|
|
EXPECTED_HOST_ORIGIN,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
|
|
const { html, sandbox, permissions } = event.data.params;
|
|
if (typeof sandbox === "string") {
|
|
inner.setAttribute("sandbox", sandbox);
|
|
}
|
|
// Set Permission Policy allow attribute if permissions are requested
|
|
const allowAttribute = buildAllowAttribute(permissions);
|
|
if (allowAttribute) {
|
|
console.log("[Sandbox] Setting allow attribute:", allowAttribute);
|
|
inner.setAttribute("allow", allowAttribute);
|
|
}
|
|
if (typeof html === "string") {
|
|
// Use document.write instead of srcdoc (which the CesiumJS Map won't work with)
|
|
const doc =
|
|
inner.contentDocument || inner.contentWindow?.document;
|
|
if (doc) {
|
|
doc.open();
|
|
doc.write(html);
|
|
doc.close();
|
|
} else {
|
|
// Fallback to srcdoc if document is not accessible
|
|
console.warn(
|
|
"[Sandbox] document.write not available, falling back to srcdoc",
|
|
);
|
|
inner.srcdoc = html;
|
|
}
|
|
}
|
|
} else {
|
|
if (inner && inner.contentWindow) {
|
|
inner.contentWindow.postMessage(event.data, "*");
|
|
}
|
|
}
|
|
} else if (event.source === inner.contentWindow) {
|
|
if (event.origin !== OWN_ORIGIN) {
|
|
console.error(
|
|
"[Sandbox] Rejecting message from inner iframe with unexpected origin:",
|
|
event.origin,
|
|
"expected:",
|
|
OWN_ORIGIN,
|
|
);
|
|
return;
|
|
}
|
|
// Relay messages from inner frame to parent window.
|
|
// Use specific origin instead of "*" to prevent message interception.
|
|
window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
|
|
}
|
|
});
|
|
|
|
// Notify the Host that the Sandbox is ready to receive view HTML.
|
|
// Use specific origin instead of "*" to ensure only the expected host receives this.
|
|
window.parent.postMessage(
|
|
{
|
|
jsonrpc: "2.0",
|
|
method: PROXY_READY_NOTIFICATION,
|
|
params: {},
|
|
},
|
|
EXPECTED_HOST_ORIGIN,
|
|
);
|
|
</script>
|
|
</body>
|
|
</html>
|