diff --git a/packages/local-runtime-v2/src/service/miniapp/errors.ts b/packages/local-runtime-v2/src/service/miniapp/errors.ts index e389d2b40..da33dd1bc 100644 --- a/packages/local-runtime-v2/src/service/miniapp/errors.ts +++ b/packages/local-runtime-v2/src/service/miniapp/errors.ts @@ -22,6 +22,7 @@ type MiniAppFailureReasonCode = | 'HOST_CONNECTOR_PROTOCOL_VIOLATION' | 'HOST_CONNECTOR_UNAVAILABLE' | 'NO_CANDIDATE_PORT' + | 'OWNED_ELSEWHERE' | 'READINESS_PROBE_REJECTED' | 'READINESS_PROBE_TIMEOUT' | 'READINESS_TIMEOUT' @@ -36,6 +37,7 @@ const MINIAPP_RUNTIME_FAILURE_REASON_CODES: ReadonlySet = new Set { + let status: number; + try { + status = await requestHealth(input); + } catch { + return 'free'; + } + return status === 401 ? 'guarded' : 'foreign'; +} + +function requestHealth(input: MiniAppPortProbeInput): 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, + }, + (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 Error('Mini App port probe timed out') }), + Math.max(1, input.timeoutMs), + ); + timer.unref(); + request.on('error', (error) => finish({ error })); + input.signal.addEventListener('abort', onAbort, { once: true }); + request.end(); + }); +} 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 ca9dd5a63..92119afd6 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 @@ -20,6 +20,7 @@ import { MiniAppCleanupUnprovenError, markMiniAppRootTerminationProven } from '. import { sanitizeMiniAppRuntimeLogEvent } from '../../runtime-log.js'; import type { MiniAppRunnerInitMessage } from './host-runner.js'; import { waitForLoopbackReadiness } from './loopback-readiness.js'; +import { acquireMiniAppOwner, type MiniAppOwnerIdentity } from './owner-lease.js'; import { createDirectNodeRootTerminator, type DirectNodeRootTerminator, @@ -86,6 +87,8 @@ interface NodeRuntimeOptions { readonly resolveRealPath?: (target: string) => Promise; readonly makeStartupToken?: () => string; readonly makeLoopbackToken?: () => string; + /** Label this client records when it takes ownership of a Mini App. */ + readonly ownerClient?: string; readonly nowMs?: () => number; readonly wait?: (ms: number, signal: AbortSignal) => Promise; readonly runnerStartTimeoutMs?: number; @@ -115,6 +118,7 @@ type ResolvedOptions = Required< | 'resolveRealPath' | 'makeStartupToken' | 'makeLoopbackToken' + | 'ownerClient' | 'nowMs' | 'wait' | 'runnerStartTimeoutMs' @@ -227,6 +231,38 @@ async function prepareRuntime(input: { readonly dataDir: string; readonly runtimeDir: string; }): Promise { + // Ownership is taken before the port loop and before any spawn: a second client must never reach + // the point of starting a process over the same plugin data. + const ownership = await acquireMiniAppOwner({ + pluginDataDir: input.dataDir, + pluginId: input.candidate.pluginId, + identity: { client: input.options.ownerClient }, + nowMs: input.options.nowMs, + }); + if (ownership.kind === 'held') { + throw new MiniAppNodeRuntimeError( + 'OWNED_ELSEWHERE', + ownerHeldDetail(ownership.owner, input.candidate.pluginId), + ); + } + try { + return await preparedRuntimeWithOwnership(input, ownership); + } catch (error) { + await ownership.release(); + throw error; + } +} + +function ownerHeldDetail(owner: MiniAppOwnerIdentity | undefined, pluginId: string): string { + return owner + ? `Mini App ${pluginId} is already running in ${owner.client} (pid ${owner.pid})` + : `Mini App ${pluginId} is already running in another client`; +} + +async function preparedRuntimeWithOwnership( + input: Parameters[0], + ownership: { release(): Promise }, +): Promise { // One credential per process generation: every port attempt reuses it. const loopbackToken = input.options.makeLoopbackToken(); let lastError: unknown; @@ -265,6 +301,7 @@ async function prepareRuntime(input: { port, reservation, loopbackToken, + ownership, }); } catch (error) { const failure = await selectFailureAfterCleanup(error, () => @@ -626,6 +663,7 @@ function preparedRuntime( readonly port: number; readonly reservation?: { release(): void }; readonly loopbackToken: string; + readonly ownership: { release(): Promise }; }, ): MiniAppNodeRuntime { let stopPromise: Promise<{ readonly proven: boolean }> | undefined; @@ -659,6 +697,11 @@ function preparedRuntime( } catch (removeError) { postStopErrors.push(removeError); } + try { + await input.ownership.release(); + } catch (releaseError) { + postStopErrors.push(releaseError); + } throwPostStopErrors(postStopErrors); return { proven: true }; })(); @@ -769,6 +812,7 @@ function resolveOptions(options: NodeRuntimeOptions): ResolvedOptions { resolveRealPath: realpath, makeStartupToken: randomUUID, makeLoopbackToken: () => randomBytes(32).toString('base64url'), + ownerClient: 'unknown', nowMs: Date.now, wait: defaultWait, runnerStartTimeoutMs: 10_000, diff --git a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/owner-lease.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/owner-lease.ts new file mode 100644 index 000000000..4e5e00814 --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/owner-lease.ts @@ -0,0 +1,169 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import net from 'node:net'; +import path from 'node:path'; + +/** + * Who holds a Mini App, recorded beside the endpoint that proves it. + * + * The binding is the truth and this record is the explanation: a live owner holds its endpoint, and the + * operating system releases that binding when the owner dies. So a record whose endpoint nobody holds is + * stale by definition, whatever its timestamp says. + */ +export interface MiniAppOwnerIdentity { + readonly client: string; + readonly pid: number; + readonly startedAtMs: number; +} + +export type MiniAppOwnerAcquisition = + | { readonly kind: 'acquired'; release(): Promise } + | { readonly kind: 'held'; readonly owner?: MiniAppOwnerIdentity }; + +export interface AcquireMiniAppOwnerInput { + /** The Mini App's own directory in the shared data directory. */ + readonly pluginDataDir: string; + readonly pluginId: string; + readonly identity: Omit; + readonly nowMs?: () => number; + readonly platform?: NodeJS.Platform; +} + +const SOCKET_FILE = 'owner.sock'; +const RECORD_FILE = 'owner.json'; +const WINDOWS_PIPE_PREFIX = '\\\\.\\pipe\\mcode-miniapp-owner-'; + +/** A socket file on POSIX, a named pipe on Windows — named from the plugin id, never from the directory. */ +export function resolveMiniAppOwnerEndpoint( + pluginDataDir: string, + pluginId: string, + platform: NodeJS.Platform = process.platform, +): string { + if (platform === 'win32') { + const digest = createHash('sha256').update(pluginId).digest('hex').slice(0, 16); + return `${WINDOWS_PIPE_PREFIX}${digest}`; + } + return path.join(pluginDataDir, SOCKET_FILE); +} + +/** + * Takes ownership of one Mini App, or reports who holds it. + * + * A failed bind means somebody is listening; an unheld socket file means a previous owner died without + * cleaning up. The two are told apart by trying to connect: a refused connection proves the file is a + * leftover, and only then is it removed. Nothing is unlinked before the first bind attempt, because that + * would steal a live owner's endpoint. + */ +export async function acquireMiniAppOwner( + input: AcquireMiniAppOwnerInput, +): Promise { + const nowMs = input.nowMs ?? Date.now; + const platform = input.platform ?? process.platform; + const endpoint = resolveMiniAppOwnerEndpoint(input.pluginDataDir, input.pluginId, platform); + if (platform !== 'win32') await mkdir(input.pluginDataDir, { recursive: true }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const server = net.createServer((socket) => socket.destroy()); + const bound = await bind(server, endpoint); + if (bound) { + const identity: MiniAppOwnerIdentity = { + client: input.identity.client, + pid: process.pid, + startedAtMs: nowMs(), + }; + await writeOwnerRecord(input.pluginDataDir, identity); + let released = false; + return { + kind: 'acquired', + async release() { + if (released) return; + released = true; + await close(server); + await rm(endpoint, { force: true }); + await rm(path.join(input.pluginDataDir, RECORD_FILE), { force: true }); + }, + }; + } + if (attempt === 0 && (await isLeftoverEndpoint(endpoint))) { + await rm(endpoint, { force: true }); + continue; + } + return { kind: 'held', ...(await readOwnerRecord(input.pluginDataDir)) }; + } + return { kind: 'held', ...(await readOwnerRecord(input.pluginDataDir)) }; +} + +function bind(server: net.Server, endpoint: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException) => { + server.removeListener('listening', onListening); + server.close(); + if (error.code === 'EADDRINUSE') { + resolve(false); + return; + } + reject(error); + }; + const onListening = () => { + server.removeListener('error', onError); + resolve(true); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(endpoint); + }); +} + +/** True only when the endpoint exists and nothing answers on it. */ +function isLeftoverEndpoint(endpoint: string): Promise { + return new Promise((resolve) => { + const socket = net.connect(endpoint); + const finish = (leftover: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(leftover); + }; + socket.once('connect', () => finish(false)); + socket.once('error', (error: NodeJS.ErrnoException) => { + finish(error.code === 'ECONNREFUSED' || error.code === 'ENOENT'); + }); + }); +} + +function close(server: net.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +async function writeOwnerRecord( + pluginDataDir: string, + identity: MiniAppOwnerIdentity, +): Promise { + try { + await writeFile( + path.join(pluginDataDir, RECORD_FILE), + `${JSON.stringify(identity)}\n`, + 'utf8', + ); + } catch { + // The binding already proves ownership; a record that cannot be written changes nothing. + } +} + +async function readOwnerRecord( + pluginDataDir: string, +): Promise<{ readonly owner?: MiniAppOwnerIdentity }> { + try { + const raw = JSON.parse(await readFile(path.join(pluginDataDir, RECORD_FILE), 'utf8')) as unknown; + if ( + raw && + typeof raw === 'object' && + typeof (raw as MiniAppOwnerIdentity).client === 'string' && + typeof (raw as MiniAppOwnerIdentity).pid === 'number' + ) { + return { owner: raw as MiniAppOwnerIdentity }; + } + } catch { + // An absent or unreadable record still leaves the endpoint answering for the owner. + } + return {}; +} 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 1574a99e0..f35df8711 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 @@ -11,6 +11,7 @@ import { MINIAPP_LOOPBACK_HEALTH_PATH, MINIAPP_LOOPBACK_TOKEN_HEADER, } from '../../../src/service/miniapp/contracts.js'; +import { probeMiniAppPortOwnership } from '../../../src/service/miniapp/process/node-runtime/loopback-ownership.js'; import { waitForLoopbackReadiness } from '../../../src/service/miniapp/process/node-runtime/loopback-readiness.js'; import { isMiniAppRunnerInitMessage, @@ -319,3 +320,37 @@ describe('Mini App loopback readiness', () => { }); }); }); + +describe('Mini App port ownership', () => { + const probe = (target: number) => + probeMiniAppPortOwnership({ + port: target, + timeoutMs: 500, + signal: new AbortController().signal, + }); + + it('reports a closed port as free', async () => { + expect(await probe(await freePort())).toBe('free'); + }); + + it('reports a port whose guard rejects an anonymous request as guarded', async () => { + expect(await probe(port)).toBe('guarded'); + }); + + it('reports a port held by something that does not answer like the guard as foreign', 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)); + expect(await probe(plainPort)).toBe('foreign'); + } finally { + child.kill('SIGKILL'); + } + }); +}); 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 index 71e13520b..0b58f8417 100644 --- 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 @@ -228,3 +228,47 @@ describe('Mini App process generations', () => { } }); }); + +describe('Mini App ownership before spawn', () => { + it('refuses a Mini App another client owns, without spawning a process', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp owned ')); + 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, + ownerClient: 'webui', + spawn: () => child as unknown as ChildProcess, + createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never, + }); + + const owner = new ScriptedChild(() => undefined); + const runtime = await build(owner).prepare({ + candidate: candidate(), + processGeneration: 'generation-1', + signal: new AbortController().signal, + onLog: () => undefined, + }); + try { + const second = new ScriptedChild(() => undefined); + await expect( + build(second).prepare({ + candidate: candidate(), + processGeneration: 'generation-2', + signal: new AbortController().signal, + onLog: () => undefined, + }), + ).rejects.toMatchObject({ code: 'OWNED_ELSEWHERE' }); + // The refusal happened before any spawn, so the second client never reached a process. + expect(second.received).toHaveLength(0); + } finally { + await runtime.stop(); + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/owner-lease.test.ts b/packages/local-runtime-v2/test/unit/miniapp/owner-lease.test.ts new file mode 100644 index 000000000..2504734e0 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/owner-lease.test.ts @@ -0,0 +1,94 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import net from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + acquireMiniAppOwner, + resolveMiniAppOwnerEndpoint, + type MiniAppOwnerAcquisition, +} from '../../../src/service/miniapp/process/node-runtime/owner-lease.js'; + +const roots: string[] = []; +const held: MiniAppOwnerAcquisition[] = []; + +afterEach(async () => { + for (const acquisition of held.splice(0)) { + if (acquisition.kind === 'acquired') await acquisition.release(); + } + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); + +async function pluginDataDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'miniapp owner ')); + roots.push(dir); + return dir; +} + +async function acquire(dir: string, client = 'webui'): Promise { + const acquisition = await acquireMiniAppOwner({ + pluginDataDir: dir, + pluginId: 'fixture.miniapp', + identity: { client }, + }); + if (acquisition.kind === 'acquired') held.push(acquisition); + return acquisition; +} + +describe('Mini App ownership', () => { + it('acquires an unowned Mini App and can take it again after releasing', async () => { + const dir = await pluginDataDir(); + + const first = await acquire(dir); + expect(first.kind).toBe('acquired'); + if (first.kind === 'acquired') await first.release(); + + expect((await acquire(dir)).kind).toBe('acquired'); + }); + + it('refuses while another client holds it, and names that client', async () => { + const dir = await pluginDataDir(); + + expect((await acquire(dir, 'desktop')).kind).toBe('acquired'); + + const second = await acquire(dir, 'webui'); + expect(second.kind).toBe('held'); + if (second.kind === 'held') { + expect(second.owner?.client).toBe('desktop'); + expect(second.owner?.pid).toBe(process.pid); + } + }); + + it('reclaims an endpoint left behind by an owner that died', async () => { + const dir = await pluginDataDir(); + const endpoint = resolveMiniAppOwnerEndpoint(dir, 'fixture.miniapp'); + // An owner that died without cleanup leaves the socket file and nothing listening on it. + const stale = net.createServer(); + await new Promise((resolve) => stale.listen(endpoint, resolve)); + await new Promise((resolve) => stale.close(() => resolve())); + + expect((await acquire(dir)).kind).toBe('acquired'); + }); + + it('lets two different Mini Apps be owned at the same time', async () => { + const first = await pluginDataDir(); + const second = await pluginDataDir(); + + expect((await acquire(first)).kind).toBe('acquired'); + expect((await acquire(second)).kind).toBe('acquired'); + }); + + it('names the endpoint after the plugin data directory, not a global path', async () => { + const first = await pluginDataDir(); + const second = await pluginDataDir(); + + expect(resolveMiniAppOwnerEndpoint(first, 'fixture.miniapp')).not.toBe( + resolveMiniAppOwnerEndpoint(second, 'fixture.miniapp'), + ); + expect(resolveMiniAppOwnerEndpoint(first, 'fixture.miniapp', 'win32')).toMatch( + /^\\\\\.\\pipe\\mcode-miniapp-owner-[a-f0-9]{16}$/u, + ); + }); +}); diff --git a/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md b/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md index 37d63dd79..0295e0997 100644 --- a/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md +++ b/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md @@ -405,6 +405,14 @@ different one. The owner is whatever starts the runtime first, recorded in the s with a liveness proof that the operating system releases when the owner dies, rather than a timestamp alone — a stale record must be reclaimable, and a live one must not be taken by mistake. +That proof is an exclusively bound endpoint per Mini App, in its own plugin data directory: a socket +file on POSIX and a named pipe on Windows, named from the plugin id the way the OAuth lease endpoint is +named from its data directory (`packages/oauth-lease-protocol/src/endpoints.ts`). Binding is the +ownership: a second bind fails while the owner lives, the operating system releases it when the owner +dies, and the record beside it states which client holds it and since when. The endpoint is never +unlinked before binding — unlinking first would steal a live owner's endpoint, which is the mistake the +OAuth lease server is entitled to make with its own path and this record is not. + The shipped desktop client writes no such record, so the record cannot be the only signal. For anything already running, including that client, the signal is the Mini App's own port: the client probes the derived port before starting, and a listener that answers an unauthenticated request the way the guard diff --git a/release/public-source.json b/release/public-source.json index 854b95aa8..e568cd8dd 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1056,9 +1056,11 @@ "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-ownership.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/owner-lease.ts", "packages/local-runtime-v2/src/service/miniapp/process/node-runtime/port-registry.ts", "packages/local-runtime-v2/src/service/miniapp/runtime-log.ts", "packages/local-runtime-v2/src/service/miniapp/supervisor/cleanup-settlement.ts", @@ -1664,6 +1666,7 @@ "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/miniapp/owner-lease.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 814f6fba8..2e856734f 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -45,6 +45,7 @@ "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/test/unit/miniapp/owner-lease.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",