Nine findings (six inline, three suppressed), all taken. Two were serious. **The memory fallback made the migrations destructive.** Both migrations — mcp.json in the remote server, client.json in `node-persistence` — lift a plaintext secret off disk, write it to the secret store, and then delete it from the file. That trade is safe only while the store is at least as durable as the disk, which held by construction while `InMemorySecretStore` was a test double. Making it a production fallback broke it: on an unmounted container a plain `GET /api/servers` would have moved a user's existing secrets into RAM and lost them at exit — destroying data on a read. `SecretStore` gains an optional `isDurable()`, absent meaning durable, and both migrations withhold the disk delete when it answers false (the value is still loaded, so the session behaves normally). The production fallback is now a named `SessionSecretStore` rather than the bare `InMemorySecretStore`, so the suite's many doubles keep standing in for a *working keychain* — which is what they are there to be — and only code that deliberately chooses RAM in production says so. **Round 2's per-call descriptor never reached anyone.** Both web servers resolved it once at startup and baked it into `initialConfig`, which `/api/config` serves unchanged — so the freshness fix was inert for its only consumer. The route now takes a `secretStorageResolver` and calls it per request. The lock needed three more fixes, and they compound: - **Release removed whatever directory was at the path.** A holder whose work outran the stale threshold would delete the *next* owner's lock, letting a third writer into a section two processes believed they held. Ownership is now stamped with a random token and release only removes a lock still carrying it. - **The timeout fell through to an unlocked write** — under contention, the one moment another writer is provably active. It now throws `SecretFileLockTimeoutError`, which `set` is allowed to do. - **The first write of every process bypassed the lock**, because the secrets directory does not exist yet and `mkdir` failed ENOENT. The parent is created first. First writes are exactly when two processes are most likely to start together. Fixing the timeout exposed a fourth: with stale (10s) below timeout, a waiter stole from a holder that was merely *slow* rather than dead — reinstating the lost update. A holder now heartbeats its lock, so "stale" means the holder is gone, and the timeout is what a live-but-slow holder produces. Lock timings are injectable so the tests can exercise contention, staleness, and timeout without spending the real intervals. Also: the loose-permission state is carried into `SecretStorageInfo` as `looseMode` and rendered as the caveat (it outranks the encryption caveat — a file others can read is a live exposure, while "unencrypted" is a property of a file only its owner can open), rather than living in a startup log the browser cannot see; and `usableSecretStorage` validates `pendingEncryption` and `looseMode` as the optional booleans/numbers they are (`pendingEncryption: "false"` is truthy and would have printed advice that is the opposite of the truth) and rejects file-only fields on a keychain or memory descriptor. Tests: both migrations against a session store with a durable control alongside, so the guard is visibly the difference; `/api/config` serving a changing resolver and falling back without one; lock ownership after a steal, the timeout under a live holder, no-steal from a slow holder, and a locked first write; `looseMode` through the descriptor, the caveat ordering, and the tone; and five more rejected payload shapes. Full `npm run ci` green — 5,704 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com>
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
/**
|
|
* Vite plugin that adds Hono middleware for /api/* and the MCP Apps sandbox.
|
|
* Receives WebServerConfig only (from runner or from buildWebServerConfigFromEnv in vite.config).
|
|
*
|
|
* `apply: 'serve'` confines the plugin to `vite dev` / `vite preview` — vitest
|
|
* projects share this config but never run the dev server, so the plugin stays
|
|
* inert there.
|
|
*/
|
|
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import type { Plugin } from "vite";
|
|
// Direct leaf import — the `core/mcp/remote/node/index.ts` barrel re-exports
|
|
// from `../constants.js`, which would otherwise drag the broader
|
|
// `core/mcp/remote/index.ts` barrel (including `createRemoteLogger`, with its
|
|
// `pino/browser.js` import) into Vite's config-time module graph and produce
|
|
// spurious "could not resolve" warnings during build.
|
|
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
|
import { createSandboxController } from "./sandbox-controller.js";
|
|
import { injectAuthToken } from "./inject-auth-token.js";
|
|
import type { WebServerConfig } from "./web-server-config.js";
|
|
import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts";
|
|
import {
|
|
webServerConfigToInitialPayload,
|
|
printServerBanner,
|
|
openBrowser,
|
|
} from "./web-server-config.js";
|
|
|
|
export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|
// Resolved once `configureServer` runs (createRemoteApp generates a token
|
|
// when none is supplied). Captured here so the `transformIndexHtml` hook —
|
|
// which fires per index.html request, after `configureServer` — can embed it
|
|
// into the served page. Stays "" when auth is dangerously omitted or under
|
|
// Vitest (where `configureServer` returns early), making injection a no-op.
|
|
let resolvedAuthToken = "";
|
|
return {
|
|
name: "hono-api-middleware",
|
|
// Embed the API token into the dev-served index.html so a reload at the
|
|
// bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`) still authenticates. The
|
|
// prod server applies the same injection in `server.ts`.
|
|
transformIndexHtml(html) {
|
|
return injectAuthToken(html, resolvedAuthToken);
|
|
},
|
|
// `apply: 'serve'` keeps the plugin out of `vite build`, but Vitest still
|
|
// instantiates a Vite server in middleware mode (no HTTP server) for
|
|
// transforms and invokes `configureServer` regardless. Returning early
|
|
// when `server.httpServer` is missing keeps the plugin inert in that
|
|
// context — only an actual `vite dev` (or `vite preview`) instance has
|
|
// an HTTP server to attach to.
|
|
apply: "serve",
|
|
async configureServer(server) {
|
|
// Skip the plugin entirely under Vitest. The storybook project runs
|
|
// tests in a real headless Chromium and spins up a Vite server with
|
|
// `httpServer` attached — that would otherwise pass the next guard and
|
|
// attach the Hono backend (sandbox HTTP server, banner, auto-open) for
|
|
// every test run. Component stories never hit `/api/*`, so the plugin
|
|
// brings no value to that context and only adds noise / port churn.
|
|
if (process.env.VITEST) {
|
|
return;
|
|
}
|
|
if (!server.httpServer) {
|
|
return;
|
|
}
|
|
|
|
const sandboxController = createSandboxController({
|
|
port: config.sandboxPort,
|
|
host: config.sandboxHost,
|
|
allowedOrigins: config.allowedOrigins,
|
|
});
|
|
await sandboxController.start();
|
|
// Resolved before the API is built so `/api/config` and the banner
|
|
// report the same store (the selection itself is cached process-wide).
|
|
const secretStorage = await getSecretStorageInfo();
|
|
|
|
const {
|
|
app: honoApp,
|
|
authToken: resolvedToken,
|
|
close: closeApi,
|
|
} = createRemoteApp({
|
|
authToken: config.dangerouslyOmitAuth ? undefined : config.authToken,
|
|
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
|
|
storageDir: config.storageDir,
|
|
mcpConfigPath: config.mcpConfigPath,
|
|
writable: config.writable,
|
|
initialServers: config.initialServers ?? undefined,
|
|
allowedOrigins: config.allowedOrigins,
|
|
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
|
logger: config.logger,
|
|
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
|
// The startup value above is what the banner printed; the route
|
|
// re-resolves per request, because the first write under a newly-set
|
|
// passphrase encrypts a pre-existing plaintext file and a captured
|
|
// descriptor would keep reporting the old state until a restart.
|
|
secretStorageResolver: getSecretStorageInfo,
|
|
});
|
|
|
|
// Expose the resolved token to `transformIndexHtml`. Left empty when
|
|
// auth is dangerously omitted so the page carries no token global.
|
|
resolvedAuthToken = config.dangerouslyOmitAuth ? "" : resolvedToken;
|
|
|
|
// Chain the API close (mcp.json watcher) and the sandbox into the
|
|
// Vite server's close so dev-server restarts release both resources.
|
|
const originalClose = server.close.bind(server);
|
|
server.close = async () => {
|
|
await closeApi();
|
|
await sandboxController.close();
|
|
return originalClose();
|
|
};
|
|
|
|
const sandboxUrl = sandboxController.getUrl();
|
|
|
|
const logBanner = () => {
|
|
const address = server.httpServer?.address();
|
|
const actualPort =
|
|
typeof address === "object" && address !== null
|
|
? address.port
|
|
: config.port;
|
|
|
|
const url = printServerBanner(
|
|
config,
|
|
actualPort,
|
|
resolvedToken,
|
|
sandboxUrl ?? undefined,
|
|
secretStorage,
|
|
);
|
|
|
|
if (config.autoOpen) {
|
|
// Never a bare `open(url)`: `logBanner` is synchronous, so a
|
|
// rejection would escape it. See openBrowser.
|
|
openBrowser(url);
|
|
}
|
|
};
|
|
|
|
server.httpServer.once("listening", () => {
|
|
setImmediate(async () => {
|
|
// Pre-bundle the app entry before opening the browser so the first
|
|
// page load does not race Vite's dep optimizer (504 Outdated Optimize Dep).
|
|
try {
|
|
await server.warmupRequest("/src/main.tsx");
|
|
} catch (err) {
|
|
console.warn("[vite] entry warmup failed:", err);
|
|
}
|
|
logBanner();
|
|
});
|
|
});
|
|
|
|
const honoMiddleware = async (
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
next: (err?: unknown) => void,
|
|
) => {
|
|
try {
|
|
const pathname = req.url || "";
|
|
if (!pathname.startsWith("/api")) {
|
|
return next();
|
|
}
|
|
const url = `http://${req.headers.host}${pathname}`;
|
|
const headers = new Headers();
|
|
Object.entries(req.headers).forEach(
|
|
([key, value]: [string, string | string[] | undefined]) => {
|
|
if (value) {
|
|
headers.set(
|
|
key,
|
|
Array.isArray(value) ? value.join(", ") : value,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
const init: RequestInit = { method: req.method, headers };
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
// Listen for `end`, `error`, AND `close`. Without `error`/`close`
|
|
// an aborted upload (browser navigates away mid-POST) leaves this
|
|
// promise pending forever, leaking memory and the connection.
|
|
// We resolve on every terminal event — partial body bytes flow on
|
|
// to Hono; the underlying fetch will fail downstream if the
|
|
// payload is truncated, which is the expected behavior on abort.
|
|
await new Promise<void>((resolve) => {
|
|
req.once("end", () => resolve());
|
|
req.once("error", () => resolve());
|
|
req.once("close", () => resolve());
|
|
});
|
|
if (chunks.length > 0) {
|
|
init.body = Buffer.concat(chunks);
|
|
}
|
|
}
|
|
const response = await honoApp.fetch(new Request(url, init));
|
|
res.statusCode = response.status;
|
|
response.headers.forEach((value, key) => {
|
|
res.setHeader(key, value);
|
|
});
|
|
const isSSE = response.headers
|
|
.get("content-type")
|
|
?.includes("text/event-stream");
|
|
if (isSSE) {
|
|
res.setHeader("X-Accel-Buffering", "no");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
}
|
|
if (response.body) {
|
|
res.flushHeaders?.();
|
|
const reader = response.body.getReader();
|
|
|
|
// The browser can drop a streaming response mid-flight — an SSE
|
|
// connection closing, a navigation, or an HMR reload. Once it does,
|
|
// `res` is destroyed and further writes fail with
|
|
// ERR_STREAM_DESTROYED / EPIPE / ECONNRESET. Treat those as a
|
|
// normal end: cancel the upstream reader and stop quietly rather
|
|
// than logging a spurious error. (Cancelling resolves the awaiting
|
|
// `reader.read()`, so the pump loop exits on its own.)
|
|
const isDisconnect = (code?: string): boolean =>
|
|
code === "ERR_STREAM_DESTROYED" ||
|
|
code === "EPIPE" ||
|
|
code === "ECONNRESET";
|
|
|
|
let clientGone = false;
|
|
const onClose = (): void => {
|
|
clientGone = true;
|
|
reader.cancel().catch(() => {});
|
|
};
|
|
// Without an `error` listener a failed write becomes an uncaught
|
|
// exception; absorb benign disconnects and log anything else.
|
|
const onError = (err: Error): void => {
|
|
clientGone = true;
|
|
reader.cancel().catch(() => {});
|
|
if (!isDisconnect((err as NodeJS.ErrnoException).code)) {
|
|
console.error("[Hono Middleware] Response error:", err);
|
|
}
|
|
};
|
|
res.on("close", onClose);
|
|
res.on("error", onError);
|
|
|
|
const pump = async (): Promise<void> => {
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done || clientGone || res.destroyed) break;
|
|
// Await each write so a slow client applies backpressure
|
|
// instead of the proxy buffering the entire stream.
|
|
await new Promise<void>((resolve, reject) => {
|
|
res.write(Buffer.from(value), (err) =>
|
|
err ? reject(err) : resolve(),
|
|
);
|
|
});
|
|
}
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException | null)?.code;
|
|
if (!clientGone && !res.destroyed && !isDisconnect(code)) {
|
|
console.error("[Hono Middleware] Stream error:", err);
|
|
}
|
|
} finally {
|
|
res.off("close", onClose);
|
|
res.off("error", onError);
|
|
await reader.cancel().catch(() => {});
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
res.end();
|
|
}
|
|
}
|
|
};
|
|
void pump();
|
|
} else {
|
|
res.end();
|
|
}
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
server.middlewares.use(honoMiddleware);
|
|
},
|
|
};
|
|
}
|