diff --git a/packages/local-runtime-v2/src/service/miniapp/contracts.ts b/packages/local-runtime-v2/src/service/miniapp/contracts.ts index 01e7d3c74..b7c5d439d 100644 --- a/packages/local-runtime-v2/src/service/miniapp/contracts.ts +++ b/packages/local-runtime-v2/src/service/miniapp/contracts.ts @@ -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[] }; @@ -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; stop(): Promise<{ readonly proven: boolean }>; diff --git a/packages/local-runtime-v2/src/service/miniapp/errors.ts b/packages/local-runtime-v2/src/service/miniapp/errors.ts index 90fb1437d..e389d2b40 100644 --- a/packages/local-runtime-v2/src/service/miniapp/errors.ts +++ b/packages/local-runtime-v2/src/service/miniapp/errors.ts @@ -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' @@ -34,6 +36,8 @@ const MINIAPP_RUNTIME_FAILURE_REASON_CODES: ReadonlySet = new Set { 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(); @@ -404,6 +415,77 @@ async function startWithStdioConnector( } } +const GUARDED_REQUEST_EVENTS: ReadonlySet = new Set([ + 'request', + 'checkContinue', + 'checkExpectation', +]); +const GUARDED_SOCKET_EVENTS: ReadonlySet = 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 = [ @@ -416,6 +498,7 @@ export function isMiniAppRunnerInitMessage(value: unknown): value is MiniAppRunn 'dataDir', 'listen', 'readiness', + 'loopbackToken', 'hostConnectorPolicy', ]; if (!hasExactKeys(value, keys)) return false; @@ -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): boolean { return ['pluginId', 'processGeneration', 'packageRoot', 'nodeEntry', 'dataDir'].every( (field) => typeof value[field] === 'string', diff --git a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-readiness.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-readiness.ts new file mode 100644 index 000000000..3d388ebc5 --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-readiness.ts @@ -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; + 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 { + 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 { + 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 { + 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; +} diff --git a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts index 03297dc59..ca9dd5a63 100644 --- a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts +++ b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts @@ -1,5 +1,5 @@ import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; -import { createHash, randomUUID } from 'node:crypto'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { lstat, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; import { createConnection } from 'node:net'; import os from 'node:os'; @@ -14,10 +14,12 @@ import type { MiniAppRuntimeExit, ProcessMiniAppCandidate, } from '../../contracts.js'; +import { MINIAPP_LOOPBACK_HEALTH_PATH, MINIAPP_LOOPBACK_TOKEN_HEADER } from '../../contracts.js'; import { selectFailureAfterCleanup } from '../../supervisor/cleanup-settlement.js'; import { MiniAppCleanupUnprovenError, markMiniAppRootTerminationProven } from '../../errors.js'; import { sanitizeMiniAppRuntimeLogEvent } from '../../runtime-log.js'; import type { MiniAppRunnerInitMessage } from './host-runner.js'; +import { waitForLoopbackReadiness } from './loopback-readiness.js'; import { createDirectNodeRootTerminator, type DirectNodeRootTerminator, @@ -83,6 +85,7 @@ interface NodeRuntimeOptions { readonly ensureDirectory?: (directory: string) => Promise; readonly resolveRealPath?: (target: string) => Promise; readonly makeStartupToken?: () => string; + readonly makeLoopbackToken?: () => string; readonly nowMs?: () => number; readonly wait?: (ms: number, signal: AbortSignal) => Promise; readonly runnerStartTimeoutMs?: number; @@ -111,6 +114,7 @@ type ResolvedOptions = Required< | 'ensureDirectory' | 'resolveRealPath' | 'makeStartupToken' + | 'makeLoopbackToken' | 'nowMs' | 'wait' | 'runnerStartTimeoutMs' @@ -223,6 +227,8 @@ async function prepareRuntime(input: { readonly dataDir: string; readonly runtimeDir: string; }): Promise { + // One credential per process generation: every port attempt reuses it. + const loopbackToken = input.options.makeLoopbackToken(); let lastError: unknown; for (let index = 0; index < input.options.maxPortAttempts; index += 1) { const port = candidatePort(input, index); @@ -235,18 +241,22 @@ async function prepareRuntime(input: { const deadline = input.options.nowMs() + input.options.runnerStartTimeoutMs; let attempt: Attempt | undefined; try { - attempt = await spawnAttempt(input, port); + attempt = await spawnAttempt(input, port, loopbackToken); const readinessSignal = AbortSignal.any([input.signal, attempt.fatal.signal]); await waitForStarted( attempt, readinessSignal, Math.min(input.options.runnerStartTimeoutMs, remaining(deadline, input.options.nowMs)), ); - await waitForTcp({ - options: input.options, + await waitForLoopbackReadiness({ port, - signal: readinessSignal, + token: loopbackToken, deadline, + nowMs: input.options.nowMs, + probeTimeoutMs: input.options.tcpConnectTimeoutMs, + retryMs: input.options.tcpRetryMs, + wait: input.options.wait, + signal: readinessSignal, }); attempt.startupErrorCapture.complete(); return preparedRuntime({ @@ -254,6 +264,7 @@ async function prepareRuntime(input: { attempt, port, reservation, + loopbackToken, }); } catch (error) { const failure = await selectFailureAfterCleanup(error, () => @@ -298,6 +309,7 @@ async function cleanupFailedPreparation( async function spawnAttempt( input: Parameters[0], port: number, + loopbackToken: string, ): Promise { const init: MiniAppRunnerInitMessage = { type: 'miniapp:init', @@ -312,6 +324,7 @@ async function spawnAttempt( startupToken: input.options.makeStartupToken(), nodeDigest: input.candidate.nodeDigest, }, + loopbackToken, hostConnectorPolicy: input.candidate.hostConnectorPolicy, }; const physicalRoot = input.physicalRoots.acquire(input.candidate.pluginId); @@ -607,47 +620,12 @@ function waitForStarted(attempt: Attempt, signal: AbortSignal, timeoutMs: number }); } -async function waitForTcp(input: { - readonly options: ResolvedOptions; - readonly port: number; - readonly signal: AbortSignal; - readonly deadline: number; -}): Promise { - let lastError: unknown; - for (;;) { - throwIfAborted(input.signal); - try { - await input.options.connectPort({ - host: LOOPBACK_HOST, - port: input.port, - timeoutMs: Math.min( - input.options.tcpConnectTimeoutMs, - remaining(input.deadline, input.options.nowMs), - ), - signal: input.signal, - }); - return; - } catch (error) { - lastError = error; - } - const delay = Math.min( - input.options.tcpRetryMs, - remaining(input.deadline, input.options.nowMs), - ); - await input.options.wait(delay, input.signal); - if (input.options.nowMs() >= input.deadline) { - throw new MiniAppNodeRuntimeError('READINESS_TIMEOUT', 'TCP readiness timed out', { - cause: lastError, - }); - } - } -} - function preparedRuntime( input: Parameters[0] & { readonly attempt: Attempt; readonly port: number; readonly reservation?: { release(): void }; + readonly loopbackToken: string; }, ): MiniAppNodeRuntime { let stopPromise: Promise<{ readonly proven: boolean }> | undefined; @@ -698,6 +676,7 @@ function preparedRuntime( nodeDigest: input.candidate.nodeDigest, port: input.port, origin: `http://${LOOPBACK_HOST}:${input.port}`, + loopbackToken: input.loopbackToken, ...(input.attempt.connector ? { hostConnectorSession: input.attempt.connector } : {}), closed: input.attempt.closed, stop() { @@ -789,6 +768,7 @@ function resolveOptions(options: NodeRuntimeOptions): ResolvedOptions { }, resolveRealPath: realpath, makeStartupToken: randomUUID, + makeLoopbackToken: () => randomBytes(32).toString('base64url'), nowMs: Date.now, wait: defaultWait, runnerStartTimeoutMs: 10_000, diff --git a/packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts b/packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts new file mode 100644 index 000000000..1574a99e0 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts @@ -0,0 +1,321 @@ +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import { createServer as createNetServer, connect as connectNet } from 'node:net'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + MINIAPP_LOOPBACK_HEALTH_PATH, + MINIAPP_LOOPBACK_TOKEN_HEADER, +} from '../../../src/service/miniapp/contracts.js'; +import { waitForLoopbackReadiness } from '../../../src/service/miniapp/process/node-runtime/loopback-readiness.js'; +import { + isMiniAppRunnerInitMessage, + startMiniAppModule, + type MiniAppRunnerInitMessage, + type RunningMiniAppModule, +} from '../../../src/service/miniapp/process/node-runtime/host-runner.js'; + +/** + * The guard patches `http.Server.prototype.emit` process-wide and has no uninstall, which is + * exactly how it behaves inside a Mini App process: one credential, one port, every server in + * that process. So this file installs one guarded plugin server and interrogates it, rather than + * starting a fresh one per case. + */ +const TOKEN = 'test-loopback-credential-0123456789'; + +interface ObservedRequest { + readonly headers: http.IncomingHttpHeaders; + readonly rawHeaders: readonly string[]; + readonly url: string; +} + +const observed: ObservedRequest[] = []; +let server: RunningMiniAppModule | undefined; +let port = 0; +let packageRoot = ''; + +async function freePort(): Promise { + const probe = createNetServer(); + await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve)); + const address = probe.address(); + const allocated = typeof address === 'object' && address ? address.port : 0; + await new Promise((resolve) => probe.close(() => resolve())); + return allocated; +} + +function initMessage(): MiniAppRunnerInitMessage { + return { + type: 'miniapp:init', + protocolVersion: 1, + pluginId: 'fixture.miniapp', + processGeneration: 'generation-1', + packageRoot, + nodeEntry: 'miniapp/node/server.js', + dataDir: packageRoot, + listen: { host: '127.0.0.1', port }, + readiness: { startupToken: 'startup-token', nodeDigest: 'node-digest' }, + loopbackToken: TOKEN, + hostConnectorPolicy: { kind: 'deny-all' }, + }; +} + +beforeAll(async () => { + packageRoot = await mkdtemp(join(tmpdir(), 'miniapp guard ')); + // The runner resolves the declared entry for real before the injected loader runs. + const entry = join(packageRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(entry), { recursive: true }); + await writeFile(entry, 'export const start = async () => undefined;\n'); + port = await freePort(); + server = await startMiniAppModule(initMessage(), { + loadModule: async () => ({ + start: async (context: { readonly listen: { host: string; port: number } }) => { + const pluginServer = http.createServer((request, response) => { + observed.push({ + headers: { ...request.headers }, + rawHeaders: [...request.rawHeaders], + url: request.url ?? '', + }); + response.writeHead(200, { 'content-type': 'text/plain' }).end('plugin'); + }); + const upgraded = new Set(); + pluginServer.on('upgrade', (request, socket) => { + upgraded.add(socket); + socket.once('close', () => upgraded.delete(socket)); + observed.push({ + headers: { ...request.headers }, + rawHeaders: [...request.rawHeaders], + url: request.url ?? '', + }); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n', + ); + }); + await new Promise((resolve, reject) => { + pluginServer.once('error', reject); + pluginServer.listen(context.listen.port, context.listen.host, resolve); + }); + return { + async dispose() { + // An upgraded socket leaves the server's own tracking; the fixture ends it here. + for (const socket of upgraded) socket.destroy(); + pluginServer.closeAllConnections(); + await new Promise((resolve) => pluginServer.close(() => resolve())); + }, + }; + }, + }), + }); +}); + +afterAll(async () => { + await server?.stop(); + await rm(packageRoot, { recursive: true, force: true }); +}); + +interface Response { + readonly status: number; + readonly body: string; +} + +function request(input: { + readonly path: string; + readonly token?: string; + readonly host?: string; +}): Promise { + return new Promise((resolve, reject) => { + const outgoing = http.request( + { + host: '127.0.0.1', + port, + method: 'GET', + path: input.path, + // A kept-alive socket would hold the fixture server open past the last assertion. + agent: false, + headers: { + ...(input.token === undefined ? {} : { [MINIAPP_LOOPBACK_TOKEN_HEADER]: input.token }), + ...(input.host === undefined ? {} : { host: input.host }), + }, + }, + (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + body += chunk; + }); + response.on('end', () => resolve({ status: response.statusCode ?? 0, body })); + }, + ); + outgoing.on('error', reject); + outgoing.end(); + }); +} + +/** Resolves with the first bytes the server sends, or 'closed' when the socket is destroyed. */ +function upgradeOutcome(token?: string): Promise { + return new Promise((resolve, reject) => { + const socket = connectNet({ host: '127.0.0.1', port }); + let received = ''; + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(received === '' ? 'closed' : received); + }; + socket.once('connect', () => { + const extra = token === undefined ? '' : `${MINIAPP_LOOPBACK_TOKEN_HEADER}: ${token}\r\n`; + socket.write( + `GET /ws HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n${extra}\r\n`, + ); + }); + socket.on('data', (chunk) => { + received += chunk.toString('utf8'); + finish(); + }); + socket.once('error', finish); + socket.once('end', finish); + socket.once('close', finish); + socket.setTimeout(2_000, () => { + socket.destroy(); + reject(new Error('upgrade outcome timed out')); + }); + }); +} + +describe('Mini App loopback guard', () => { + it('answers an unauthenticated request with 401 and no body', async () => { + const response = await request({ path: '/' }); + + expect(response.status).toBe(401); + expect(response.body).toBe(''); + }); + + it('answers a request carrying the wrong credential with 401', async () => { + const response = await request({ path: '/', token: 'not-the-credential' }); + + expect(response.status).toBe(401); + expect(response.body).toBe(''); + }); + + it('rejects the right credential on the wrong host', async () => { + const response = await request({ path: '/', token: TOKEN, host: `localhost:${port}` }); + + expect(response.status).toBe(401); + }); + + it('reaches the plugin handler with the right credential and hides the header from it', async () => { + observed.length = 0; + const response = await request({ path: '/dashboard', token: TOKEN }); + + expect(response.status).toBe(200); + expect(response.body).toBe('plugin'); + expect(observed).toHaveLength(1); + expect(observed[0]?.url).toBe('/dashboard'); + expect(observed[0]?.headers).not.toHaveProperty(MINIAPP_LOOPBACK_TOKEN_HEADER); + expect( + observed[0]?.rawHeaders.some( + (value) => value.toLowerCase() === MINIAPP_LOOPBACK_TOKEN_HEADER, + ), + ).toBe(false); + }); + + it('answers the reserved health path itself, before the plugin handler', async () => { + observed.length = 0; + const authenticated = await request({ path: MINIAPP_LOOPBACK_HEALTH_PATH, token: TOKEN }); + + expect(authenticated.status).toBe(204); + expect(observed).toHaveLength(0); + + const anonymous = await request({ path: MINIAPP_LOOPBACK_HEALTH_PATH }); + expect(anonymous.status).toBe(401); + expect(observed).toHaveLength(0); + }); + + it('destroys an upgrade without the credential and forwards one that carries it', async () => { + observed.length = 0; + expect(await upgradeOutcome()).toBe('closed'); + expect(observed).toHaveLength(0); + + const forwarded = await upgradeOutcome(TOKEN); + expect(forwarded).toContain('101'); + expect(observed).toHaveLength(1); + expect(observed[0]?.headers).not.toHaveProperty(MINIAPP_LOOPBACK_TOKEN_HEADER); + }); +}); + +describe('runner init message', () => { + it('requires a non-empty loopback credential', () => { + expect(isMiniAppRunnerInitMessage(initMessage())).toBe(true); + + const withoutToken: Record = { ...initMessage() }; + delete withoutToken.loopbackToken; + expect(isMiniAppRunnerInitMessage(withoutToken)).toBe(false); + expect(isMiniAppRunnerInitMessage({ ...initMessage(), loopbackToken: '' })).toBe(false); + expect(isMiniAppRunnerInitMessage({ ...initMessage(), loopbackToken: 42 })).toBe(false); + expect(isMiniAppRunnerInitMessage({ ...initMessage(), loopbackToken: ['token'] })).toBe(false); + }); +}); + +describe('Mini App loopback readiness', () => { + function readiness(port: number, token: string, timeoutMs = 2_000) { + return waitForLoopbackReadiness({ + port, + token, + deadline: Date.now() + timeoutMs, + nowMs: Date.now, + probeTimeoutMs: 500, + retryMs: 25, + wait: (ms, signal) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(signal.reason); + }); + }), + signal: new AbortController().signal, + }); + } + + it('passes when the guard rejects the anonymous probe and answers the credentialed one', async () => { + await expect(readiness(port, TOKEN)).resolves.toBeUndefined(); + }); + + it('fails when the credentialed probe is rejected', async () => { + await expect(readiness(port, 'wrong-credential')).rejects.toMatchObject({ + code: 'READINESS_PROBE_REJECTED', + message: 'authenticated health probe expected 204, received 401', + }); + }); + + it('fails when an anonymous probe is not rejected', async () => { + const plainPort = await freePort(); + const script = [ + "const http = require('node:http');", + 'http', + ' .createServer((_request, response) => response.writeHead(200).end())', + ` .listen(${plainPort}, '127.0.0.1');`, + ].join('\n'); + const child = spawn(process.execPath, ['-e', script], { stdio: 'ignore' }); + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + await expect(readiness(plainPort, TOKEN)).rejects.toMatchObject({ + code: 'READINESS_PROBE_REJECTED', + message: 'unauthenticated health probe expected 401, received 200', + }); + } finally { + child.kill('SIGKILL'); + } + }); + + it('times out when nothing is listening', async () => { + const closedPort = await freePort(); + await expect(readiness(closedPort, TOKEN, 150)).rejects.toMatchObject({ + code: 'READINESS_TIMEOUT', + }); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts b/packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts new file mode 100644 index 000000000..71e13520b --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts @@ -0,0 +1,230 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { EventEmitter } from 'node:events'; +import type { ChildProcess } from 'node:child_process'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { MiniAppNodeRuntime, ProcessMiniAppCandidate } from '../../../src/service/miniapp/contracts.js'; +import { createMiniAppNodeRuntimeAdapter } from '../../../src/service/miniapp/process/node-runtime/node-runtime.js'; +import { + startMiniAppModule, + type MiniAppRunnerInitMessage, + type RunningMiniAppModule, +} from '../../../src/service/miniapp/process/node-runtime/host-runner.js'; + +/** + * A scripted child: it is the real runner running in this process, so the readiness probe meets the + * real guard. Everything else about the child process is scripted, which lets the test observe the + * credential the runtime mints and hands down without spawning a node process per case. + */ +class ScriptedChild extends EventEmitter { + pid = 4242; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + connected = true; + readonly stdout = new EventEmitter(); + readonly stderr = new EventEmitter(); + readonly stdin = new EventEmitter() as unknown as ChildProcess['stdin']; + + readonly received: MiniAppRunnerInitMessage[] = []; + private runner: RunningMiniAppModule | undefined; + + constructor( + private readonly onExit: () => void, + /** A child that accepts the init message and never reports readiness. */ + private readonly silent = false, + ) { + super(); + } + + async send(message: unknown, callback?: (error: Error | null) => void): Promise { + const init = message as MiniAppRunnerInitMessage; + if (init?.type === 'miniapp:init') { + this.received.push(init); + if (this.silent) return true; + try { + this.runner = await startMiniAppModule(init, { + loadModule: async () => ({ + start: async (context: { readonly listen: { host: string; port: number } }) => { + const { createServer } = await import('node:http'); + const server = createServer((_request, response) => { + response.writeHead(200).end('plugin'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(context.listen.port, context.listen.host, resolve); + }); + return { + async dispose() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; + }, + }), + }); + } catch (error) { + callback?.(error as Error); + this.emit('message', { type: 'miniapp:error', code: 'RUNNER_START_FAILED', errorText: String(error) }); + return false; + } + callback?.(null); + this.emit('message', { + type: 'miniapp:started', + pluginId: init.pluginId, + processGeneration: init.processGeneration, + readiness: init.readiness, + }); + return true; + } + if (init?.type === 'miniapp:stop') { + await this.runner?.stop(); + this.exit(0); + } + return true; + } + + exit(code: number): void { + if (this.exitCode !== null) return; + this.exitCode = code; + this.connected = false; + this.onExit(); + this.emit('exit', code, null); + } + + kill(): boolean { + this.exit(0); + return true; + } +} + +let packageRoot = ''; + +const candidate = (): ProcessMiniAppCandidate => ({ + pluginId: 'fixture.miniapp', + packageRoot, + packageDigest: 'package-digest', + clientDigest: 'client-digest', + nodeDigest: 'node-digest', + surfacePath: '/dashboard', + lifecycle: 'on-demand', + nodeEntry: 'miniapp/node/server.js', + mcpEndpoints: [], + hostConnectorPolicy: { kind: 'deny-all' }, +}); + +beforeAll(async () => { + packageRoot = await mkdtemp(join(tmpdir(), 'miniapp runtime ')); + const entry = join(packageRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(entry), { recursive: true }); + await writeFile(entry, 'export const start = async () => undefined;\n'); +}); + +afterAll(async () => { + await rm(packageRoot, { recursive: true, force: true }); +}); + +describe('Mini App loopback credential', () => { + it('mints one credential per preparation and hands that exact value down', async () => { + let minted = 0; + const child = new ScriptedChild(() => undefined); + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp data ')); + const adapter = createMiniAppNodeRuntimeAdapter({ + resolvePluginDataDir: () => dataDir, + verifyCandidate: async () => true, + materializePackage: async ({ targetRoot }) => { + const target = join(targetRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'export const start = async () => undefined;\n'); + }, + isPortReserved: () => false, + makeLoopbackToken: () => { + minted += 1; + return `credential-${minted}`; + }, + spawn: () => child as unknown as ChildProcess, + createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never, + }); + + let runtime: MiniAppNodeRuntime | undefined; + try { + runtime = await adapter.prepare({ + candidate: candidate(), + processGeneration: 'generation-1', + signal: new AbortController().signal, + onLog: () => undefined, + }); + + expect(minted).toBe(1); + expect(child.received).toHaveLength(1); + expect(child.received[0]?.loopbackToken).toBe('credential-1'); + // The handle carries the same value the guard was installed with, and readiness passed only + // because the guard answered 204 for it. + expect(runtime.loopbackToken).toBe('credential-1'); + } finally { + await runtime?.stop(); + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); + +describe('Mini App process generations', () => { + it('gives a new process generation a different credential', async () => { + let minted = 0; + const tokens: string[] = []; + const token = () => { + minted += 1; + const value = `credential-${minted}`; + tokens.push(value); + return value; + }; + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp data ')); + const build = (child: ScriptedChild) => + createMiniAppNodeRuntimeAdapter({ + resolvePluginDataDir: () => dataDir, + verifyCandidate: async () => true, + materializePackage: async ({ targetRoot }) => { + const target = join(targetRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'export const start = async () => undefined;\n'); + }, + isPortReserved: () => false, + makeLoopbackToken: token, + spawn: () => child as unknown as ChildProcess, + createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never, + // Fails fast: this case only needs the init message a new generation would receive. + runnerStartTimeoutMs: 40, + nowMs: Date.now, + }); + + try { + const first = new ScriptedChild(() => undefined); + const firstRuntime = await build(first).prepare({ + candidate: { ...candidate(), pluginId: 'fixture.first' }, + processGeneration: 'generation-1', + signal: new AbortController().signal, + onLog: () => undefined, + }); + expect(first.received[0]?.loopbackToken).toBe(tokens[0]); + await firstRuntime.stop(); + + const second = new ScriptedChild(() => undefined, true); + await expect( + build(second).prepare({ + candidate: { ...candidate(), pluginId: 'fixture.first' }, + processGeneration: 'generation-2', + signal: new AbortController().signal, + onLog: () => undefined, + }), + ).rejects.toBeDefined(); + + expect(second.received).toHaveLength(1); + expect(second.received[0]?.loopbackToken).toBe(tokens[1]); + expect(new Set(tokens).size).toBe(2); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 33ae078de..854b95aa8 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1056,6 +1056,7 @@ "packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector.ts", "packages/local-runtime-v2/src/service/miniapp/process/node-runtime/direct-root.ts", "packages/local-runtime-v2/src/service/miniapp/process/node-runtime/host-runner.ts", + "packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-readiness.ts", "packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime-startup-error.ts", "packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts", "packages/local-runtime-v2/src/service/miniapp/process/node-runtime/port-registry.ts", @@ -1661,6 +1662,8 @@ "packages/local-runtime-v2/test/unit/agent/storage/agent.repository.test.ts", "packages/local-runtime-v2/test/unit/agent/storage/canonical-agent-config.test.ts", "packages/local-runtime-v2/test/unit/compat/v1/runtime.test.ts", + "packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts", + "packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts", "packages/local-runtime-v2/test/unit/plugin-system/miniapp-manifest.test.ts", "packages/local-runtime/assets/prompts/code-review/review-candidates.md", "packages/local-runtime/assets/prompts/code-review/reviewer-system.md", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index d199a76d1..814f6fba8 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -43,6 +43,8 @@ "packages/local-runtime-v2/src/service/plugin-system/cloud-transport.test.ts", "packages/local-runtime-v2/src/service/plugin-system/app/runtime.test.ts", "packages/local-runtime-v2/test/unit/plugin-system/miniapp-manifest.test.ts", + "packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts", + "packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts", "packages/local-runtime-v2/src/service/mcp/runtime/builtin-matrix.test.ts", "packages/local-runtime-v2/src/service/workspace/snapshot/watcher.test.ts", "packages/tui/test/unit/tui-codex-login.test.ts",