Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/local-runtime-v2/src/service/miniapp/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
/** Largest `agentConcurrency` a Mini App manifest may declare. */
export const MINIAPP_AGENT_MAX_CONCURRENCY = 32;

/** Every Mini App HTTP server answers this path from the guard, never from plugin code. */
export const MINIAPP_LOOPBACK_HEALTH_PATH = '/__mavis/health';

/** Request header carrying the Host-issued loopback credential. */
export const MINIAPP_LOOPBACK_TOKEN_HEADER = 'x-mavis-miniapp-token';

export type MiniAppHostConnectorPolicy =
| { readonly kind: 'deny-all' }
| { readonly kind: 'allowlist'; readonly providers: readonly string[] };
Expand Down Expand Up @@ -94,6 +100,8 @@ export interface MiniAppNodeRuntime {
readonly nodeDigest: string;
readonly port: number;
readonly origin: string;
/** Server-only: the proxy injects it. Never forwarded to a renderer or a published view. */
readonly loopbackToken: string;
readonly hostConnectorSession?: MiniAppHostConnectorSession;
readonly closed: Promise<MiniAppRuntimeExit>;
stop(): Promise<{ readonly proven: boolean }>;
Expand Down
4 changes: 4 additions & 0 deletions packages/local-runtime-v2/src/service/miniapp/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ type MiniAppFailureReasonCode =
| 'HOST_CONNECTOR_PROTOCOL_VIOLATION'
| 'HOST_CONNECTOR_UNAVAILABLE'
| 'NO_CANDIDATE_PORT'
| 'READINESS_PROBE_REJECTED'
| 'READINESS_PROBE_TIMEOUT'
| 'READINESS_TIMEOUT'
| 'RUNNER_LIFECYCLE_INVALID'
| 'RUNNER_START_FAILED'
Expand All @@ -34,6 +36,8 @@ const MINIAPP_RUNTIME_FAILURE_REASON_CODES: ReadonlySet<string> = new Set<MiniAp
'HOST_CONNECTOR_PROTOCOL_VIOLATION',
'HOST_CONNECTOR_UNAVAILABLE',
'NO_CANDIDATE_PORT',
'READINESS_PROBE_REJECTED',
'READINESS_PROBE_TIMEOUT',
'READINESS_TIMEOUT',
'RUNNER_LIFECYCLE_INVALID',
'RUNNER_START_FAILED',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { timingSafeEqual } from 'node:crypto';
import { realpath } from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
import type { Duplex } from 'node:stream';
import { fileURLToPath, pathToFileURL } from 'node:url';

import type { MiniAppHostConnectorPolicy } from '../../contracts.js';
import {
MINIAPP_LOOPBACK_HEALTH_PATH,
MINIAPP_LOOPBACK_TOKEN_HEADER,
type MiniAppHostConnectorPolicy,
} from '../../contracts.js';
import { boundMiniAppRuntimeErrorText } from '../../errors.js';
import {
createMiniAppHostConnectorRuntimeClient,
Expand Down Expand Up @@ -43,6 +50,8 @@ export interface MiniAppRunnerInitMessage {
readonly dataDir: string;
readonly listen: { readonly host: '127.0.0.1'; readonly port: number };
readonly readiness: { readonly startupToken: string; readonly nodeDigest: string };
/** Per-process loopback credential. Consumed by the HTTP guard; never enters plugin context. */
readonly loopbackToken: string;
readonly hostConnectorPolicy: MiniAppHostConnectorPolicy;
}

Expand Down Expand Up @@ -115,6 +124,8 @@ export async function startMiniAppModule(
): Promise<RunningMiniAppModule> {
const resolveRealPath = options.resolveRealPath ?? realpath;
const packageRoot = await resolveRealPath(init.packageRoot);
// Must precede loading the entry: plugin servers inherit the guard from the patched prototype.
installMiniAppLoopbackGuard({ port: init.listen.port, token: init.loopbackToken });
const start = await loadStartFunction(init, packageRoot, resolveRealPath, options.loadModule);
assertConnectorAvailable(init, options.createHostConnector);
const abortController = new AbortController();
Expand Down Expand Up @@ -404,6 +415,77 @@ async function startWithStdioConnector(
}
}

const GUARDED_REQUEST_EVENTS: ReadonlySet<string> = new Set([
'request',
'checkContinue',
'checkExpectation',
]);
const GUARDED_SOCKET_EVENTS: ReadonlySet<string> = new Set(['upgrade', 'connect']);

/**
* Authenticates every `node:http` server in this process before plugin code can observe a request.
* Only the Host-issued credential on the exact loopback Host passes; `GET /__mavis/health` is
* answered here so readiness proves the guard is in the path rather than merely that a socket accepts.
*/
function installMiniAppLoopbackGuard(input: { readonly port: number; readonly token: string }): void {
const prototype = http.Server.prototype;
const originalEmit = prototype.emit;
const expectedHost = `127.0.0.1:${input.port}`;
const expectedToken = Buffer.from(input.token, 'utf8');
const authorized = (request: http.IncomingMessage) =>
request.headers.host === expectedHost &&
tokenMatches(request.headers[MINIAPP_LOOPBACK_TOKEN_HEADER], expectedToken);
prototype.emit = function guardedEmit(
this: http.Server,
event: string | symbol,
...args: unknown[]
) {
if (typeof event === 'string' && GUARDED_REQUEST_EVENTS.has(event)) {
const [request, response] = args as [http.IncomingMessage, http.ServerResponse];
if (!authorized(request)) {
response.writeHead(401).end();
return true;
}
if (isHealthRequest(request)) {
response.writeHead(204).end();
return true;
}
stripLoopbackToken(request);
} else if (typeof event === 'string' && GUARDED_SOCKET_EVENTS.has(event)) {
const [request, socket] = args as [http.IncomingMessage, Duplex];
if (!authorized(request)) {
socket.destroy();
return true;
}
stripLoopbackToken(request);
}
return Reflect.apply(originalEmit, this, [event, ...args]) as boolean;
} as typeof prototype.emit;
}

function tokenMatches(value: string | string[] | undefined, expected: Buffer): boolean {
if (typeof value !== 'string') return false;
const actual = Buffer.from(value, 'utf8');
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected);
}

/** Application code never needs the credential; removing it keeps proxies and header dumps clean. */
function stripLoopbackToken(request: http.IncomingMessage): void {
delete request.headers[MINIAPP_LOOPBACK_TOKEN_HEADER];
const raw = request.rawHeaders;
for (let index = raw.length - 2; index >= 0; index -= 2) {
if (raw[index]?.toLowerCase() === MINIAPP_LOOPBACK_TOKEN_HEADER) raw.splice(index, 2);
}
}

function isHealthRequest(request: http.IncomingMessage): boolean {
const url = request.url ?? '';
return (
request.method === 'GET' &&
(url === MINIAPP_LOOPBACK_HEALTH_PATH || url.startsWith(`${MINIAPP_LOOPBACK_HEALTH_PATH}?`))
);
}

export function isMiniAppRunnerInitMessage(value: unknown): value is MiniAppRunnerInitMessage {
if (!isRecord(value)) return false;
const keys = [
Expand All @@ -416,6 +498,7 @@ export function isMiniAppRunnerInitMessage(value: unknown): value is MiniAppRunn
'dataDir',
'listen',
'readiness',
'loopbackToken',
'hostConnectorPolicy',
];
if (!hasExactKeys(value, keys)) return false;
Expand All @@ -425,10 +508,15 @@ export function isMiniAppRunnerInitMessage(value: unknown): value is MiniAppRunn
hasStringIdentityFields(value) &&
isListenAddress(value.listen) &&
isReadinessIdentity(value.readiness) &&
isLoopbackToken(value.loopbackToken) &&
isConnectorPolicy(value.hostConnectorPolicy)
);
}

function isLoopbackToken(value: unknown): boolean {
return typeof value === 'string' && value.length > 0;
}

function hasStringIdentityFields(value: Record<string, unknown>): boolean {
return ['pluginId', 'processGeneration', 'packageRoot', 'nodeEntry', 'dataDir'].every(
(field) => typeof value[field] === 'string',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { request as httpRequest } from 'node:http';

import { MINIAPP_LOOPBACK_HEALTH_PATH, MINIAPP_LOOPBACK_TOKEN_HEADER } from '../../contracts.js';
import { MiniAppNodeRuntimeError } from './node-runtime-startup-error.js';

export interface LoopbackReadinessInput {
readonly port: number;
/** The credential the runtime minted for this process generation. */
readonly token: string;
/** Absolute wall-clock deadline for readiness as a whole. */
readonly deadline: number;
readonly nowMs: () => number;
readonly probeTimeoutMs: number;
readonly retryMs: number;
readonly wait: (ms: number, signal: AbortSignal) => Promise<void>;
readonly signal: AbortSignal;
}

/**
* Readiness for a Mini App's own server: an anonymous health request must be rejected with 401 and the
* credentialed one answered 204 by the guard itself. A socket that merely accepts proves nothing,
* because the port is derived from the plugin id and any local process can hold it.
*
* A port that answers wrongly is not retried — it keeps answering wrongly — only a failed connection is,
* while the child is still binding.
*/
export async function waitForLoopbackReadiness(input: LoopbackReadinessInput): Promise<void> {
let lastError: unknown;
for (;;) {
throwIfAborted(input.signal);
try {
await probeLoopbackAuth({
port: input.port,
timeoutMs: Math.min(input.probeTimeoutMs, remaining(input)),
signal: input.signal,
token: input.token,
});
return;
} catch (error) {
if (isProbeRejection(error)) throw error;
lastError = error;
}
await input.wait(Math.min(input.retryMs, remaining(input)), input.signal);
if (input.nowMs() >= input.deadline) {
throw new MiniAppNodeRuntimeError('READINESS_TIMEOUT', 'Loopback readiness timed out', {
cause: lastError,
});
}
}
}

async function probeLoopbackAuth(input: {
readonly port: number;
readonly token: string;
readonly timeoutMs: number;
readonly signal: AbortSignal;
}): Promise<void> {
const anonymous = await requestLoopbackHealth(input, undefined);
if (anonymous !== 401) {
throw new MiniAppNodeRuntimeError(
'READINESS_PROBE_REJECTED',
`unauthenticated health probe expected 401, received ${anonymous}`,
);
}
const authenticated = await requestLoopbackHealth(input, input.token);
if (authenticated !== 204) {
throw new MiniAppNodeRuntimeError(
'READINESS_PROBE_REJECTED',
`authenticated health probe expected 204, received ${authenticated}`,
);
}
}

function isProbeRejection(error: unknown): boolean {
return error instanceof MiniAppNodeRuntimeError && error.code === 'READINESS_PROBE_REJECTED';
}

function requestLoopbackHealth(
input: {
readonly port: number;
readonly timeoutMs: number;
readonly signal: AbortSignal;
},
token: string | undefined,
): Promise<number> {
if (input.signal.aborted) return Promise.reject(input.signal.reason);
return new Promise((resolve, reject) => {
let settled = false;
const finish = (outcome: { readonly status: number } | { readonly error: unknown }) => {
if (settled) return;
settled = true;
clearTimeout(timer);
input.signal.removeEventListener('abort', onAbort);
if ('status' in outcome) {
resolve(outcome.status);
return;
}
request.destroy();
reject(outcome.error);
};
const request = httpRequest(
{
host: '127.0.0.1',
port: input.port,
method: 'GET',
path: MINIAPP_LOOPBACK_HEALTH_PATH,
agent: false,
headers: token ? { [MINIAPP_LOOPBACK_TOKEN_HEADER]: token } : {},
},
(response) => {
response.on('error', () => undefined);
response.resume();
finish({ status: response.statusCode ?? 0 });
},
);
const onAbort = () => finish({ error: input.signal.reason });
const timer = setTimeout(
() =>
finish({
error: new MiniAppNodeRuntimeError(
'READINESS_PROBE_TIMEOUT',
'Loopback auth probe request timed out',
),
}),
Math.max(1, input.timeoutMs),
);
timer.unref();
request.on('error', (error) => finish({ error }));
input.signal.addEventListener('abort', onAbort, { once: true });
request.end();
});
}

function remaining(input: LoopbackReadinessInput): number {
const value = input.deadline - input.nowMs();
if (value <= 0) {
throw new MiniAppNodeRuntimeError('READINESS_TIMEOUT', 'Readiness deadline expired');
}
return value;
}

function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) throw signal.reason;
}
Loading
Loading