From e55e935da12f382a8d77aa3cf5715f38653833a3 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 14:36:17 +0800 Subject: [PATCH 1/4] feat(webui): guard every Mini App loopback server with the host credential Work in progress on #253. The guard and the credential threading are in place; the test suite and the wiring that hands the credential to the proxy are not. A Mini App's port is derived from its plugin id, so a local process can compute it rather than scan for it. Every `node:http` server the plugin creates is now intercepted at the `http.Server.prototype.emit` seam before the entry module loads, and only a request carrying the Host-issued credential on the exact loopback Host reaches plugin code: anything else is answered 401 with no body, and an `upgrade` or `connect` socket without it is destroyed. The credential is stripped from both `headers` and `rawHeaders` on success, so plugin code never observes it. One reserved health path is answered 204 by the guard itself. The credential is minted once per process generation in the node runtime, rides the runner's init message, and stays on the runtime handle as server-only state. It is absent from `MiniAppPublishedView` and from every renderer contract, so no published view, surface path or client payload can carry it. Readiness stops meaning "the socket accepts": the runtime now requires an anonymous health request to be rejected with 401 and the credentialed one to be answered 204, which is what proves the guard is in the path rather than merely listening. Still to do before #253 can be reviewed: the guard and validator test suites, and the supervisor hand-off of the credential to the proxy that injects it. --- .../src/service/miniapp/contracts.ts | 8 ++ .../src/service/miniapp/errors.ts | 4 + .../process/node-runtime/host-runner.ts | 90 +++++++++++- .../process/node-runtime/node-runtime.ts | 133 ++++++++++++++++-- 4 files changed, 220 insertions(+), 15 deletions(-) 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/node-runtime.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts index 03297dc59..d680f5a20 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,6 +1,7 @@ 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 { request as httpRequest } from 'node:http'; import { createConnection } from 'node:net'; import os from 'node:os'; import path from 'node:path'; @@ -14,6 +15,7 @@ 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'; @@ -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,19 @@ 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({ + await waitForLoopbackAuth({ options: input.options, port, signal: readinessSignal, deadline, + loopbackToken, }); attempt.startupErrorCapture.complete(); return preparedRuntime({ @@ -254,6 +261,7 @@ async function prepareRuntime(input: { attempt, port, reservation, + loopbackToken, }); } catch (error) { const failure = await selectFailureAfterCleanup(error, () => @@ -298,6 +306,7 @@ async function cleanupFailedPreparation( async function spawnAttempt( input: Parameters[0], port: number, + loopbackToken: string, ): Promise { const init: MiniAppRunnerInitMessage = { type: 'miniapp:init', @@ -312,6 +321,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,27 +617,34 @@ function waitForStarted(attempt: Attempt, signal: AbortSignal, timeoutMs: number }); } -async function waitForTcp(input: { +async function waitForLoopbackAuth(input: { readonly options: ResolvedOptions; readonly port: number; readonly signal: AbortSignal; readonly deadline: number; + readonly loopbackToken: string; }): 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, - }); + await probeLoopbackAuth( + { + host: LOOPBACK_HOST, + port: input.port, + timeoutMs: Math.min( + input.options.tcpConnectTimeoutMs, + remaining(input.deadline, input.options.nowMs), + ), + signal: input.signal, + }, + input.loopbackToken, + ); return; } catch (error) { + // A port answered wrongly keeps answering wrongly, so only a failed connection is + // retried while the child is still binding. + if (isProbeRejection(error)) throw error; lastError = error; } const delay = Math.min( @@ -636,18 +653,104 @@ async function waitForTcp(input: { ); await input.options.wait(delay, input.signal); if (input.options.nowMs() >= input.deadline) { - throw new MiniAppNodeRuntimeError('READINESS_TIMEOUT', 'TCP readiness timed out', { + throw new MiniAppNodeRuntimeError('READINESS_TIMEOUT', 'Loopback readiness timed out', { cause: lastError, }); } } } +interface ReadinessProbeInput { + readonly host: typeof LOOPBACK_HOST; + readonly port: number; + readonly timeoutMs: number; + readonly signal: AbortSignal; +} + +/** + * Proves the Host guard owns the port: an anonymous health check must be rejected before the + * credentialed one is answered by the guard itself. Readiness therefore proves the guard is in the + * path, not merely that a socket accepts. + */ +async function probeLoopbackAuth(input: ReadinessProbeInput, token: string): 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, 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: ReadinessProbeInput, + 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: input.host, + 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 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 +801,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 +893,7 @@ function resolveOptions(options: NodeRuntimeOptions): ResolvedOptions { }, resolveRealPath: realpath, makeStartupToken: randomUUID, + makeLoopbackToken: () => randomBytes(32).toString('base64url'), nowMs: Date.now, wait: defaultWait, runnerStartTimeoutMs: 10_000, From cf6c9fb5e48d79caf1f6f4b882187458cb8a2c21 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 14:42:44 +0800 Subject: [PATCH 2/4] feat(webui): guard every Mini App loopback server with the host credential Closes #253. A Mini App's port is derived from its plugin id, so a local process can compute it rather than scan for it. Every `node:http` server the plugin creates is now intercepted at the `http.Server.prototype.emit` seam before the entry module loads, and only a request carrying the Host-issued credential on the exact loopback Host reaches plugin code: anything else is answered 401 with no body, and an `upgrade` or `connect` socket without it is destroyed. One reserved health path is answered 204 by the guard itself, so readiness can prove the guard is in the path rather than merely that a socket accepts. The credential is stripped from both `headers` and `rawHeaders` before plugin code runs, so plugin code never observes it. The credential is minted once per process generation in the node runtime, before the port-attempt loop, rides the runner's init message, and stays on the runtime handle as server-only state. It is absent from `MiniAppPublishedView` and from every renderer contract, so no published view, surface path or client payload can hold it. Handing it to the proxy that injects it belongs to the proxy change, where its consumer exists. Readiness no longer means "the socket accepts": the runtime requires an anonymous health request to be rejected with 401 and the credentialed one to be answered 204, with `READINESS_PROBE_REJECTED` and `READINESS_PROBE_TIMEOUT` as the failure codes. The suite drives a real `http.Server` through the real runner seam rather than a mock: anonymous 401 with an empty body, wrong credential 401, right credential on the wrong Host 401, the plugin handler reached with no credential header visible, the health path answered without reaching the plugin, and an unauthenticated upgrade destroyed while a credentialed one is forwarded. Six of the seven cases fail when the guard install is removed. Verified: pnpm typecheck; pnpm run test:capabilities (236 files, 5818 passed, 18 skipped); pnpm check:source after node scripts/source-inventory.mjs --write. --- .../test/unit/miniapp/loopback-guard.test.ts | 259 ++++++++++++++++++ release/public-source.json | 1 + test/vitest-suites.json | 1 + 3 files changed, 261 insertions(+) create mode 100644 packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts 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..7fa11f6ae --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts @@ -0,0 +1,259 @@ +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 { + 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); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 33ae078de..43cba91aa 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1661,6 +1661,7 @@ "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/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..c933ec8ef 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -43,6 +43,7 @@ "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/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", From de0fc2ef5ba14b3dd1b57382c8bf21743797f7fe Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 14:47:49 +0800 Subject: [PATCH 3/4] refactor(webui): make Mini App loopback readiness its own seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readiness now lives in `loopback-readiness.ts` instead of inside `node-runtime.ts`, so it can be exercised against a real server without faking a child process — and so the proxy and ownership work reuse one definition of "the guard answered". The module keeps the rule the guard exists for: an anonymous health request must be rejected with 401 and the credentialed one answered 204, a port that answers wrongly is not retried, and a stalled probe reports READINESS_PROBE_TIMEOUT. Four cases added to the guard suite, all against real servers: - the guarded fixture passes readiness - a wrong credential fails with "expected 204, received 401" - a plain server in a child process that answers 200 passes nothing: it fails with "expected 401, received 200", which is the case readiness exists to catch - a closed port times out instead of reporting ready Verified: pnpm typecheck; pnpm run test:capabilities (236 files, 5822 passed, 18 skipped); pnpm check:source after the inventory write. --- .../node-runtime/loopback-readiness.ts | 144 ++++++++++++++++++ .../process/node-runtime/node-runtime.ts | 141 +---------------- .../test/unit/miniapp/loopback-guard.test.ts | 62 ++++++++ release/public-source.json | 1 + 4 files changed, 215 insertions(+), 133 deletions(-) create mode 100644 packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-readiness.ts 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 d680f5a20..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,7 +1,6 @@ import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { lstat, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; -import { request as httpRequest } from 'node:http'; import { createConnection } from 'node:net'; import os from 'node:os'; import path from 'node:path'; @@ -20,6 +19,7 @@ import { selectFailureAfterCleanup } from '../../supervisor/cleanup-settlement.j 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, @@ -248,12 +248,15 @@ async function prepareRuntime(input: { readinessSignal, Math.min(input.options.runnerStartTimeoutMs, remaining(deadline, input.options.nowMs)), ); - await waitForLoopbackAuth({ - options: input.options, + await waitForLoopbackReadiness({ port, - signal: readinessSignal, + token: loopbackToken, deadline, - loopbackToken, + nowMs: input.options.nowMs, + probeTimeoutMs: input.options.tcpConnectTimeoutMs, + retryMs: input.options.tcpRetryMs, + wait: input.options.wait, + signal: readinessSignal, }); attempt.startupErrorCapture.complete(); return preparedRuntime({ @@ -617,134 +620,6 @@ function waitForStarted(attempt: Attempt, signal: AbortSignal, timeoutMs: number }); } -async function waitForLoopbackAuth(input: { - readonly options: ResolvedOptions; - readonly port: number; - readonly signal: AbortSignal; - readonly deadline: number; - readonly loopbackToken: string; -}): Promise { - let lastError: unknown; - for (;;) { - throwIfAborted(input.signal); - try { - await probeLoopbackAuth( - { - host: LOOPBACK_HOST, - port: input.port, - timeoutMs: Math.min( - input.options.tcpConnectTimeoutMs, - remaining(input.deadline, input.options.nowMs), - ), - signal: input.signal, - }, - input.loopbackToken, - ); - return; - } catch (error) { - // A port answered wrongly keeps answering wrongly, so only a failed connection is - // retried while the child is still binding. - if (isProbeRejection(error)) throw 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', 'Loopback readiness timed out', { - cause: lastError, - }); - } - } -} - -interface ReadinessProbeInput { - readonly host: typeof LOOPBACK_HOST; - readonly port: number; - readonly timeoutMs: number; - readonly signal: AbortSignal; -} - -/** - * Proves the Host guard owns the port: an anonymous health check must be rejected before the - * credentialed one is answered by the guard itself. Readiness therefore proves the guard is in the - * path, not merely that a socket accepts. - */ -async function probeLoopbackAuth(input: ReadinessProbeInput, token: string): 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, 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: ReadinessProbeInput, - 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: input.host, - 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 preparedRuntime( input: Parameters[0] & { readonly attempt: Attempt; 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 index 7fa11f6ae..1574a99e0 100644 --- a/packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts +++ b/packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts @@ -1,3 +1,4 @@ +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'; @@ -10,6 +11,7 @@ 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, @@ -257,3 +259,63 @@ describe('runner init message', () => { 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/release/public-source.json b/release/public-source.json index 43cba91aa..2245c511c 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", From 40cadee84a952ead8e364eb25461153143c55809 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 14:52:27 +0800 Subject: [PATCH 4/4] test(webui): prove the Mini App credential is per generation The scripted-child harness `node-runtime` was missing: a `ChildProcess` stand-in that runs the real runner in-process, so the readiness probe meets the real guard while the child itself is scripted. With it, the credential stops being construction-only. Two cases: - one preparation mints exactly one credential, the init message carries it, and the runtime handle exposes the same value after readiness passed for it - a second process generation receives a different credential, observed on the init message it was spawned with Together with the guard suite this closes the last unproven acceptance criterion of #253; the credential also stays out of every published view by type. Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5824 passed, 18 skipped); pnpm check:source after the inventory write. --- .../miniapp/node-runtime-credential.test.ts | 230 ++++++++++++++++++ release/public-source.json | 1 + test/vitest-suites.json | 1 + 3 files changed, 232 insertions(+) create mode 100644 packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts 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 2245c511c..854b95aa8 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1663,6 +1663,7 @@ "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 c933ec8ef..814f6fba8 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -44,6 +44,7 @@ "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",