From 28b5eb8c8756b9af4b762bdb392c52bcdba85fb3 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 14:56:09 +0800 Subject: [PATCH 1/3] feat(webui): probe a derived Mini App port before spawning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of #254. The desktop client writes no ownership record, so for anything already running — including a Mini App the desktop client started — the only signal is the Mini App's own derived port. This adds the probe that reads it. `probeMiniAppPortOwnership` answers with three outcomes rather than a boolean, because the runtime reacts differently to each: `free` (the port is this client's to take), `guarded` (a Mini App's guard rejected the anonymous request, so another owner is running this Mini App and this client must refuse), and `foreign` (something else holds the port and does not answer like the guard, so another port is fine). The answer is the guard's, not the socket's: a port that accepts but answers 200 is `foreign`, not `guarded`. That is the distinction that keeps a stray listener from being mistaken for a Mini App, and it is what the ownership record's liveness check cannot cover on its own. Proven against real servers: a closed port reports free, the guarded fixture reports guarded, and a plain `node:http` server in a child process reports foreign. Still to do for #254: the ownership record with an OS-released liveness proof, the refusal surfaced in the listing and the picker, per-Mini-App granularity across two clients, and refusing before spawn. Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5827 passed, 18 skipped); pnpm check:source after the inventory write. --- .../node-runtime/loopback-ownership.ts | 81 +++++++++++++++++++ .../test/unit/miniapp/loopback-guard.test.ts | 35 ++++++++ release/public-source.json | 1 + 3 files changed, 117 insertions(+) create mode 100644 packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-ownership.ts diff --git a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-ownership.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-ownership.ts new file mode 100644 index 000000000..91d971f1d --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-ownership.ts @@ -0,0 +1,81 @@ +import { request as httpRequest } from 'node:http'; + +import { MINIAPP_LOOPBACK_HEALTH_PATH } from '../../contracts.js'; + +/** + * What is holding a derived Mini App port. + * + * - `free` — nothing is listening; the port is this client's to take. + * - `guarded` — a Mini App's own guard answered: another client is running this Mini App, or a + * previous generation of this one outlived its owner. Only the process that minted the credential + * can talk to it, so this client refuses rather than starting a second process over the same + * plugin data. + * - `foreign` — something else holds the port and does not answer like the guard. Not this Mini App; + * the runtime may pick another port. + */ +export type MiniAppPortOwnership = 'free' | 'guarded' | 'foreign'; + +export interface MiniAppPortProbeInput { + readonly port: number; + readonly timeoutMs: number; + readonly signal: AbortSignal; +} + +/** + * Probes a derived port before a Mini App is spawned. The shipped desktop client writes no ownership + * record, so for anything already running this is the only signal there is, and it has to be the + * guard's own answer rather than a bare connect: a socket that accepts proves nothing. + */ +export async function probeMiniAppPortOwnership( + input: MiniAppPortProbeInput, +): Promise { + 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/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/release/public-source.json b/release/public-source.json index 854b95aa8..4aaeababb 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-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", From 2a462c4ec020b93e7907c22b38d03329b1a2f3ac Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:05:50 +0800 Subject: [PATCH 2/3] feat(webui): take Mini App ownership through an exclusively bound endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #254. ADR 0020 required a liveness proof the operating system releases when the owner dies rather than a timestamp, and did not name one. This picks it and records the choice in the ADR's ownership section: a socket file on POSIX, a named pipe on Windows, named from the plugin id the way the OAuth lease endpoint is named from its data directory. Binding is the ownership. A second bind fails while the owner lives and the operating system releases it when the owner dies, so a record beside it states who holds the Mini App and since when, while the endpoint remains the truth. Nothing is unlinked before the first bind attempt — doing that would steal a live owner's endpoint, which is why this does not copy the OAuth lease server's path cleanup. The case that matters is the crashed owner: it leaves a socket file with nothing listening on it. A failed bind is therefore told apart from a leftover by connecting — a refused connection proves the file is stale, and only then is it removed and the bind retried once. An endpoint that still answers is somebody's, and the acquisition reports the recorded holder instead. Proven: acquire and re-acquire after release; a second client is refused with the holder's name; a socket file left by a dead owner is reclaimed; two different Mini Apps are owned at once; the endpoint is named from the plugin data directory rather than a global path. Still to do for #254: wire the acquisition into `prepareRuntime` before the port loop so the refusal precedes spawn, and surface the held state in the listing and the picker. Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5832 passed, 18 skipped); pnpm check:source after the inventory write. --- .../process/node-runtime/owner-lease.ts | 169 ++++++++++++++++++ .../test/unit/miniapp/owner-lease.test.ts | 94 ++++++++++ ...20-webui-miniapp-view-and-runtime-seams.md | 8 + release/public-source.json | 2 + test/vitest-suites.json | 1 + 5 files changed, 274 insertions(+) create mode 100644 packages/local-runtime-v2/src/service/miniapp/process/node-runtime/owner-lease.ts create mode 100644 packages/local-runtime-v2/test/unit/miniapp/owner-lease.test.ts 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/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 4aaeababb..e568cd8dd 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1060,6 +1060,7 @@ "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", @@ -1665,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", From fb5696794d94b62cf63f04c8ff1d03cd0d14e395 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:11:34 +0800 Subject: [PATCH 3/3] feat(webui): refuse a Mini App another client owns before spawning it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #254. Ownership is now taken in `prepareRuntime`, before the port loop and before any spawn, so a second client cannot reach the point of starting a process over the same plugin data. The refusal is a `MiniAppNodeRuntimeError` with code `OWNED_ELSEWHERE` whose text names the holder (`client` and `pid` from the record), so the listing has something readable to report rather than a silent failure. The acquisition is released where the rest of the preparation is cleaned up: on stop, on a fatal abort, and on any failure between taking it and returning a runtime — so a failed start does not leave the Mini App locked to this client. Proven through the scripted-child harness: with one client holding the Mini App, a second preparation for the same plugin data directory rejects with `OWNED_ELSEWHERE` and the second child receives no init message at all, which is what "before spawn" means in observable terms. Still to do for #254: surface the held state in the WebUI listing and picker (that is the client half), and decide whether the desktop client's presence needs a record the port probe cannot see — it writes none, so today it is detected only by its port answering like the guard. Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5833 passed, 18 skipped); pnpm check:source. --- .../src/service/miniapp/errors.ts | 2 + .../process/node-runtime/node-runtime.ts | 44 +++++++++++++++++++ .../miniapp/node-runtime-credential.test.ts | 44 +++++++++++++++++++ 3 files changed, 90 insertions(+) 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 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/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 }); + } + }); +});