mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(gateway): preserve standalone MCP app HEAD response metadata (#120905)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { createServer, type IncomingMessage } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { Readable } from "node:stream";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -221,6 +222,84 @@ describe("MCP App standalone host", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "public shell", path: "/__openclaw__/mcp-app", expectedStatus: 200 },
|
||||
{
|
||||
label: "authenticated multibyte view",
|
||||
path: "/__openclaw__/mcp-app/view",
|
||||
expectedStatus: 200,
|
||||
authorized: true,
|
||||
},
|
||||
{
|
||||
label: "unauthorized view",
|
||||
path: "/__openclaw__/mcp-app/view",
|
||||
expectedStatus: 401,
|
||||
},
|
||||
{
|
||||
label: "saturated view",
|
||||
path: "/__openclaw__/mcp-app/view",
|
||||
expectedStatus: 429,
|
||||
authorized: true,
|
||||
saturated: true,
|
||||
},
|
||||
])(
|
||||
"keeps GET and HEAD metadata aligned over HTTP for $label",
|
||||
async ({ path, expectedStatus, authorized, saturated }) => {
|
||||
const originalHtml = view.html;
|
||||
view.html = "<!doctype html><p>caf\u00e9 \ud83e\udd9e</p>";
|
||||
const ticket = authorized
|
||||
? issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret }).ticket
|
||||
: undefined;
|
||||
view.activeRequests = saturated ? 4 : 0;
|
||||
const server = createServer((req, res) => {
|
||||
void handleMcpAppStandaloneHttpRequest(req, res, {
|
||||
sandboxPort: 18_790,
|
||||
nowMs,
|
||||
ticketSecret: secret,
|
||||
}).catch((error: unknown) => {
|
||||
res.statusCode = 500;
|
||||
res.end(String(error));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
|
||||
try {
|
||||
const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
const headers = ticket ? { Authorization: `MCP-App ${ticket}` } : undefined;
|
||||
const get = await fetch(`${origin}${path}`, { headers });
|
||||
const body = Buffer.from(await get.arrayBuffer());
|
||||
const head = await fetch(`${origin}${path}`, { method: "HEAD", headers });
|
||||
|
||||
expect(get.status).toBe(expectedStatus);
|
||||
expect(head.status).toBe(expectedStatus);
|
||||
expect(get.headers.get("content-length")).toBe(String(body.byteLength));
|
||||
expect(head.headers.get("content-length")).toBe(String(body.byteLength));
|
||||
expect((await head.arrayBuffer()).byteLength).toBe(0);
|
||||
expect(head.headers.get("cache-control")).toBe("no-store");
|
||||
|
||||
if (path.endsWith("/view")) {
|
||||
expect(head.headers.get("vary")).toBe("Authorization");
|
||||
}
|
||||
if (expectedStatus === 401) {
|
||||
expect(head.headers.get("www-authenticate")).toBe("MCP-App");
|
||||
}
|
||||
if (authorized && !saturated) {
|
||||
expect(body.toString()).toContain("caf\u00e9 \ud83e\udd9e");
|
||||
expect(JSON.parse(body.toString())).toMatchObject({ operationTimeoutMs: 65_000 });
|
||||
}
|
||||
} finally {
|
||||
view.html = originalHtml;
|
||||
view.activeRequests = 0;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("executes serialized fetch deadlines with visible outcomes", async () => {
|
||||
const shell = await request({ url: "/__openclaw__/mcp-app" });
|
||||
const html = String(shell.end.mock.calls[0]?.[0]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { buildMcpAppSandboxPath, resolveMcpAppSandboxPort } from "../agents/mcp-
|
||||
import { getMcpAppViewLease, type McpAppViewLease } from "../agents/mcp-ui-resource.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { safeEqualSecret } from "../security/secret-equal.js";
|
||||
import { respondPlainText } from "./control-ui-http-utils.js";
|
||||
import {
|
||||
classifyMcpAppStandalonePath,
|
||||
MCP_APP_STANDALONE_PATH,
|
||||
@@ -210,10 +211,17 @@ function supportsStandaloneToolOperations(
|
||||
return view.allowedAppToolNames !== undefined && view.readOnly !== true;
|
||||
}
|
||||
|
||||
function sendText(res: ServerResponse, statusCode: number, body: string): void {
|
||||
function sendJsonRepresentation(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
statusCode: number,
|
||||
body: unknown,
|
||||
): void {
|
||||
const serialized = JSON.stringify(body);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(body);
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.setHeader("Content-Length", String(Buffer.byteLength(serialized)));
|
||||
res.end(req.method === "HEAD" ? undefined : serialized);
|
||||
}
|
||||
|
||||
function runStandaloneMcpAppHost(config: {
|
||||
@@ -593,20 +601,20 @@ export async function handleMcpAppStandaloneHttpRequest(
|
||||
req.method !== "HEAD" &&
|
||||
!(url.pathname === MCP_APP_STANDALONE_VIEW_PATH && req.method === "POST")
|
||||
) {
|
||||
sendText(res, 404, "Not Found");
|
||||
respondPlainText(res, 404, "Not Found");
|
||||
return true;
|
||||
}
|
||||
|
||||
const gatewayPort = options.gatewayPort ?? req.socket.localPort;
|
||||
if (!gatewayPort) {
|
||||
sendText(res, 503, "MCP App host unavailable");
|
||||
respondPlainText(res, 503, "MCP App host unavailable");
|
||||
return true;
|
||||
}
|
||||
let sandboxPort: number;
|
||||
try {
|
||||
sandboxPort = resolveMcpAppSandboxPort(gatewayPort, options.sandboxPort);
|
||||
} catch {
|
||||
sendText(res, 503, "MCP App host unavailable");
|
||||
respondPlainText(res, 503, "MCP App host unavailable");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -622,6 +630,7 @@ export async function handleMcpAppStandaloneHttpRequest(
|
||||
const shell = standaloneHostHtml();
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Content-Length", String(Buffer.byteLength(shell.html)));
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
`default-src 'none'; script-src 'sha256-${shell.scriptHash}'; style-src 'unsafe-inline'; connect-src 'self'; frame-src ${frameOrigin}; base-uri 'none'; form-action 'none'; object-src 'none'`,
|
||||
@@ -639,7 +648,7 @@ export async function handleMcpAppStandaloneHttpRequest(
|
||||
const active = ticket ? resolveTicketActiveView(ticket, nowMs, secret) : undefined;
|
||||
if (!active) {
|
||||
res.setHeader("WWW-Authenticate", "MCP-App");
|
||||
sendText(res, 401, "Unauthorized");
|
||||
respondPlainText(res, 401, "Unauthorized");
|
||||
return true;
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
@@ -678,36 +687,28 @@ export async function handleMcpAppStandaloneHttpRequest(
|
||||
try {
|
||||
return await withMcpAppActiveView(active, "read", () => {
|
||||
const { runtime, view } = active;
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
req.method === "HEAD"
|
||||
? undefined
|
||||
: JSON.stringify({
|
||||
sandboxUrl: buildMcpAppSandboxPath(view.csp),
|
||||
sandboxPort,
|
||||
...(options.sandboxOrigin
|
||||
? { sandboxOrigin: new URL(options.sandboxOrigin).origin }
|
||||
: {}),
|
||||
html: view.html,
|
||||
...(view.csp ? { csp: view.csp } : {}),
|
||||
toolInput: view.toolInput,
|
||||
toolResult: view.toolResult,
|
||||
serverTools: supportsStandaloneToolOperations(view),
|
||||
serverResources: runtime.readResource !== undefined,
|
||||
...(view.requestTimeoutMs !== undefined
|
||||
? {
|
||||
// Keep the browser's outer deadline behind the SDK request
|
||||
// so a valid near-deadline response can reach the App.
|
||||
operationTimeoutMs: addTimerTimeoutGraceMs(view.requestTimeoutMs),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
sendJsonRepresentation(req, res, 200, {
|
||||
sandboxUrl: buildMcpAppSandboxPath(view.csp),
|
||||
sandboxPort,
|
||||
...(options.sandboxOrigin ? { sandboxOrigin: new URL(options.sandboxOrigin).origin } : {}),
|
||||
html: view.html,
|
||||
...(view.csp ? { csp: view.csp } : {}),
|
||||
toolInput: view.toolInput,
|
||||
toolResult: view.toolResult,
|
||||
serverTools: supportsStandaloneToolOperations(view),
|
||||
serverResources: runtime.readResource !== undefined,
|
||||
...(view.requestTimeoutMs !== undefined
|
||||
? {
|
||||
// Keep the browser's outer deadline behind the SDK request
|
||||
// so a valid near-deadline response can reach the App.
|
||||
operationTimeoutMs: addTimerTimeoutGraceMs(view.requestTimeoutMs),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return true;
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 429, { ok: false, error: formatErrorMessage(error) });
|
||||
sendJsonRepresentation(req, res, 429, { ok: false, error: formatErrorMessage(error) });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user