Closes #1795. Enforce localhost-only binding in code (the "never 0.0.0.0" rule was only a spec note): refuse an all-interfaces HOST — across every spelling the OS binds as the wildcard (inet_aton decimal/octal/hex, IPv6, IPv4-mapped, zone-scoped, IDNA/Unicode) — unless DANGEROUSLY_BIND_ALL_INTERFACES=true (Docker's sanctioned opt-in). Fixes the reported stdio-connect 403 by expanding the default loopback origin allow-list to all three interchangeable forms. Hardening surfaced across an extended review: closes an IDNA/fullwidth wildcard bypass, a blank-ALLOWED_ORIGINS fail-open, and the CLI/TUI OAuth-callback wildcard bind (loopback-only now, RFC 8252); canonicalizes the origin allow-list the way a browser does; derives frame-ancestors from the real embedder list (IPv6-literal exclusion verified in headless Chromium); strictPort on all three bind points; and preserves the CLI exit-code map + JSON envelope through the mcp-inspector --cli launcher path. Shared host normalization lives in core/node/hostUrl.ts. Docs + tests + smokes throughout. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
155 lines
4.8 KiB
JavaScript
155 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Boot smoke test for the prod TUI launcher path (#1347).
|
|
*
|
|
* `npm run smoke:launcher` only checks `--tui --help`; it never actually starts
|
|
* the Ink app, so the launcher → TUI → core path and `--catalog` loading went
|
|
* unverified by the launcher smokes. This script launches the built launcher in
|
|
* `--tui` mode against a temp `--catalog`, waits for the app to render its first
|
|
* frame (the "MCP Servers" panel) within a timeout, then sends SIGTERM and
|
|
* exits.
|
|
*
|
|
* It asserts the TUI *boots and renders* without crashing — not full
|
|
* interaction (driving an Ink UI deterministically in CI is flaky, so this is
|
|
* intentionally a shallow render check). Exits non-zero on a crash-before-render
|
|
* or a render timeout.
|
|
*
|
|
* Expects `clients/launcher/build` and `clients/tui/build` to be built first
|
|
* (the validate / CI ordering guarantees this). The bundled stdio test server
|
|
* (`test-servers/build`) is built on demand here if missing.
|
|
*/
|
|
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
|
|
const repoRoot = resolve(import.meta.dirname, "..");
|
|
const launcher = join(repoRoot, "clients", "launcher", "build", "index.js");
|
|
const testServer = join(
|
|
repoRoot,
|
|
"test-servers",
|
|
"build",
|
|
"test-server-stdio.js",
|
|
);
|
|
const RENDER_MARKER = "MCP Servers";
|
|
const TIMEOUT_MS = Number(process.env.SMOKE_TUI_TIMEOUT_MS ?? 15000);
|
|
|
|
function fail(message) {
|
|
console.error(`smoke:tui FAILED — ${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function ensureTestServer() {
|
|
if (existsSync(testServer)) return;
|
|
console.log("smoke:tui — building test-servers (missing build output)...");
|
|
const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], {
|
|
cwd: repoRoot,
|
|
stdio: "inherit",
|
|
});
|
|
if (r.status !== 0 || !existsSync(testServer)) {
|
|
fail(
|
|
"could not build the stdio test server (test-servers/build/test-server-stdio.js). " +
|
|
"Run `npm run test-servers:build` from clients/cli.",
|
|
);
|
|
}
|
|
}
|
|
|
|
// The Ink TUI requires a real TTY for raw-mode keyboard input. Headless CI has
|
|
// none, so the app throws "Raw mode is not supported" on mount and exits before
|
|
// it can render its first frame — making this boot/render check inherently a
|
|
// LOCAL-only smoke (its own design notes call driving Ink in CI flaky). Skip it
|
|
// under CI rather than fail spuriously: the TUI is still built and unit-tested
|
|
// there; only this terminal-dependent render check is local-only.
|
|
if (process.env.CI) {
|
|
console.log(
|
|
"smoke:tui SKIPPED — Ink needs a real TTY (raw mode), unavailable in CI; run it locally",
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!existsSync(launcher)) {
|
|
fail(`launcher build not found at ${launcher} — run \`npm run build\` first`);
|
|
}
|
|
ensureTestServer();
|
|
|
|
const work = mkdtempSync(join(tmpdir(), "smoke-tui-"));
|
|
const catalogPath = join(work, "catalog.json");
|
|
writeFileSync(
|
|
catalogPath,
|
|
JSON.stringify({
|
|
mcpServers: {
|
|
test: { type: "stdio", command: process.execPath, args: [testServer] },
|
|
},
|
|
}),
|
|
);
|
|
|
|
const child = spawn(
|
|
process.execPath,
|
|
[launcher, "--tui", "--catalog", catalogPath],
|
|
{
|
|
cwd: repoRoot,
|
|
// Redirect HOME so the TUI's storage never touches the real ~/.mcp-inspector.
|
|
// Pin MCP_OAUTH_CALLBACK_URL="" (empty reads as unset) so an ambient
|
|
// non-loopback value can't crash the TUI before render via the loopback
|
|
// callback guard — same class smoke-cli.mjs's SMOKE_BASE_ENV neutralizes.
|
|
env: {
|
|
...process.env,
|
|
MCP_OAUTH_CALLBACK_URL: "",
|
|
HOME: work,
|
|
USERPROFILE: work,
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
|
|
let output = "";
|
|
let settled = false;
|
|
|
|
function cleanup() {
|
|
rmSync(work, { recursive: true, force: true });
|
|
}
|
|
|
|
function done(code, message) {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (!child.killed) child.kill("SIGTERM");
|
|
cleanup();
|
|
if (code === 0) {
|
|
console.log(`smoke:tui OK — ${message}`);
|
|
} else {
|
|
console.error(`smoke:tui FAILED — ${message}`);
|
|
}
|
|
process.exit(code);
|
|
}
|
|
|
|
function onData(chunk) {
|
|
output += chunk.toString();
|
|
if (output.includes(RENDER_MARKER)) {
|
|
done(0, `rendered "${RENDER_MARKER}" panel from --catalog within timeout`);
|
|
}
|
|
}
|
|
|
|
child.stdout.on("data", onData);
|
|
child.stderr.on("data", onData);
|
|
|
|
child.on("exit", (code) => {
|
|
if (settled) return;
|
|
// Exiting before the render marker appeared is a failure (crash on boot).
|
|
done(
|
|
1,
|
|
`TUI exited (code ${code}) before rendering "${RENDER_MARKER}"\n${output.slice(0, 800)}`,
|
|
);
|
|
});
|
|
|
|
child.on("error", (err) => {
|
|
done(1, `failed to spawn TUI: ${err.message}`);
|
|
});
|
|
|
|
const timer = setTimeout(() => {
|
|
done(
|
|
1,
|
|
`TUI did not render "${RENDER_MARKER}" within ${TIMEOUT_MS}ms\n${output.slice(0, 800)}`,
|
|
);
|
|
}, TIMEOUT_MS);
|