From 18c006f308ee9985277ede55e3365601f75d54a2 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 13:48:16 +0800 Subject: [PATCH 01/43] docs(webui): settle ADR 0020 for hosting Mini Apps in the WebUI Record the decisions the grilling settled, and shrink the open list to the two implementation details that remain. Taken: the shipped contract minus the window block; the loopback credential guard with readiness proving it is in the path; the postMessage bridge, with the origin-local shim recorded as rejected; the agent capability in scope, since parity with the desktop client's page is the acceptance standard; the connector's frames moving onto the IPC channel that already carries lifecycle, leaving stdout and stderr as logs; the workspace panel's miniapp tab as the inline home, opened from the launcher's tile and listed in the catalogue's apps area; the standalone route with a service-held presentation lease; the agent open call mapped onto the session rail; one proxy listener per Mini App with a per-plugin port; and single-client ownership, where the second client reports the owner and refuses to start. The client's Mini App work is one business, with three crossings one line wide. CONTEXT.md follows: the bridge no longer describes a window handle, the view records that both presentations belong to the client, and the presentation lease is defined. --- CONTEXT.md | 11 +- ...20-webui-miniapp-view-and-runtime-seams.md | 477 +++++++++++++++--- 2 files changed, 420 insertions(+), 68 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index b8cf0a4d6..32d4d46ce 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -535,12 +535,13 @@ _Avoid_: mini program, app, widget **Mini App view**: The client area that renders a Mini App's page. It is either a view inside the workspace panel or -the whole content of a browser tab opened for one Mini App. +the whole content of a browser tab opened for one Mini App. Both are the client's choice: a Mini App +page cannot ask to be shown, moved or detached. _Avoid_: surface (that word names a client identity here), pane, frame **Mini App bridge**: The page-facing API a Mini App page uses to call the client hosting it: appending a draft to the -composer, running or answering an agent turn, and reading the host's window state. +composer, and running or answering an agent turn. Presentation is not part of it. _Avoid_: API, interface, plugin API **Mini App process**: @@ -548,6 +549,12 @@ The local process that serves one Mini App's page on a loopback port. The client and does not own its state. _Avoid_: runtime (that names the harness side), server, container +**Mini App presentation lease**: +The record of which client document is currently showing one Mini App, held by the service because +two presentations of the same Mini App are two documents that share nothing but that service. It is +released when the document's connection closes. +_Avoid_: session, tab state, presence + ### Runtime ownership **Runtime owner**: 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 003cce85e..6cc1041e6 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 @@ -1,16 +1,17 @@ # WebUI Mini App view and its runtime seams -**Status:** Accepted as product direction; implementation pending. -**Date:** 2026-10-20. +**Status:** Product direction accepted and the decisions below settled; implementation is pending and +the questions at the end of this record remain open. +**Date:** 2026-10-20, revised 2026-10-26. The WebUI will host Mini Apps. A Mini App's page is rendered as an iframe the client loads from a loopback origin that the WebUI service proxies. The Mini App's own page bridge — the `window.mcode.miniapp.*` namespace a Mini App page uses to call its host — is provided by a -browser rewrite of the desktop client's preload bridge, injected by that same proxy. +browser build of the desktop client's preload bridge, injected by that same proxy. -There are two presentations. **Inline**, the Mini App view occupies a view inside the WebUI -workspace panel. **Standalone**, a browser tab is opened through a URL deep link; the tab runs the -full WebUI client, but the only content it shows is the Mini App view. The standalone view has no +Both presentations belong to the client. **Inline**, the Mini App view occupies a tab inside the +WebUI workspace panel. **Standalone**, a browser tab is opened through a URL deep link; the tab runs +the full WebUI client, but the only content it shows is the Mini App view. The standalone view has no in-page close control: the tab is the Mini App's own. The deep link selects a session and a Mini App, and the service injects its runtime configuration into every page it serves, so a tab opened this way is an ordinary client with the same credential as any other. @@ -24,10 +25,10 @@ client is three capabilities, and each has a browser-side counterpart. 1. **Injecting the loopback credential.** The desktop client injects the Host-issued credential into the Mini App's own requests with `session.webRequest.onBeforeSendHeaders`. A browser - cannot add a header to an iframe navigation or to the page's subresources, so where the - contract keeps the credential guard, a Mini App reached directly is rejected. The WebUI - service therefore proxies the Mini App's origin, and injects the credential there; whether it - injects one at all follows from the contract baseline below. + cannot add a header to an iframe navigation or to the page's subresources, so a Mini App + reached directly is rejected by the guard. The WebUI service therefore proxies the Mini App's + origin, and injects the credential there. The guard is carried over into this repository as part + of this port, so the proxy injects into a contract that exists here rather than one it invents. 2. **Injecting the page bridge.** The desktop client injects the bridge with a preload (`contextBridge.exposeInMainWorld`). A parent page cannot script a cross-origin iframe, so the bridge cannot be installed from the client. The proxy rewrites the Mini App's HTML and injects @@ -47,6 +48,17 @@ sandboxed; its isolation comes from origin separation, not from a sandbox attrib opposite of the workspace file preview, which deliberately renders an untrusted document in a sandboxed, opaque-origin iframe. +The bridge travels to the page through the proxy's HTML rewrite, and its calls travel from the page +to the client over `postMessage`. The client is the host of that conversation: it validates that the +message's source is the iframe it created and that its origin is that Mini App's origin. The obvious +alternative — the proxy exposing the bridge's endpoints on the Mini App's own origin, so the page can +`fetch` them same-origin — is rejected and recorded here so it is not re-proposed. The guard strips +the credential from the page's own requests before plugin code sees them, so those endpoints would +carry no identity of their own: any local process that computes the Mini App's deterministic port +could drive a host-authorised call through them, which is a strictly larger hole than the one the +guard closes. Closing it would need a second per-page secret, and the page and its host would then +hold two credentials instead of one. + ## What is not carried over - **Native window semantics.** The desktop client's detached presentation is a separate operating @@ -54,16 +66,92 @@ sandboxed, opaque-origin iframe. and persisted window bounds. A browser tab cannot express any of them. The WebUI keeps the part that a browser can honour — the Mini App alone on screen, and a tab the user can drag to a second display — and drops the native window options. -- **The page-side window handle.** `window.mcode.miniapp.window` and its `inline`/`window` mode - switch have no browser equivalent that is worth carrying. The standalone view is a presentation - the client chooses, not a mode a Mini App page can request. +- **The page-side window handle.** Dropped from the contract rather than emulated, because the + reason is stronger than "the browser cannot". The shipped generation adds a third part to the + bridge namespace — `window.mcode.miniapp.window` with `getState`, `update` and `focus`, a manifest + `surface.window` whitelist of size, `frame`, `transparent`, `alwaysOnTop`, `clickThrough` and + `skipTaskbar`, and a Chromium argv surface id — and that block is the page asking for its own + native window, bounds and visibility. Beyond the browser's inability to express any of it, the + block grants the page a say in *how it is presented*, and presentation is this client's decision. + A Mini App appears in its own tab because the client opened one, never because the page asked. - **Host-to-page injection.** The desktop client can inject into an embedded page: the agent cursor overlay and the comment bridge. A cross-origin Mini App page cannot be reached that way, so a Mini App page does not get them. - **Moving one live page between presentations.** The desktop client moves the same `WebContents` into a window and back into the File Panel, so the page is one instance with one state. A browser tab is a separate document. A Mini App shown in two places is two documents: - server-side state is shared, in-page state is not. + server-side state is shared, in-page state is not. The two presentations are therefore two client + documents, and the service keeps the record of which one is live. + +## The contract baseline + +The Mini App contract in this repository is one generation behind the Mini App implementation +shipped in the desktop client: 54 lines here against 1267 there. This record takes the shipped +generation, minus the window block. + +The mechanics are cheap, and the borrow is auditable rather than a blanket import of internal code. +The shipped file's only import is +`./questionnaire.js`, and this repository's copy of that file is byte-identical, so the borrowing is a +content edit to one existing file: no new path, no workspace dependency, no inventory regeneration. +`diff` also shows that the first 54 lines of the shipped file reproduce this repository's file +exactly, so the newer generation is a strict superset from line 7 onward, and the remainder arrives +as one contiguous block rather than as scattered patches. Nothing here has a consumer for the +response types whose shape changes — `MiniAppSurfaceTabSummary`, `MiniAppListResponse`, +`MiniAppOpenResponse` and `MiniAppControlResponse` are declared and unused — so adding required +fields and error codes breaks no call site. + +What the borrowing brings: + +- The split between starting a Mini App and opening its page: `MiniAppStartResponse`, + `MiniAppPageOpenInput`, `MiniAppBackgroundOpenInput` and one `MiniAppOpenApi` with two overloads. + This client needs it as soon as it prewarms a Mini App the way the desktop client does, and the + runtime seam below depends on it. +- `detached` and `presentationRevision` on the tab summary, plus the `detach_failed` and + `attach_failed` codes. In this client they are not dead fields: `detached` reports whether a + standalone presentation is live, and the revision increments on each acquisition. +- `MINIAPP_LOOPBACK_TOKEN_HEADER`, the name of the credential the proxy injects. +- The composer block: the request, the reply, the bounded text limit and the one validator both ends + of the bridge share. +- The agent block: every request and result shape, the nine run events, the failure codes and their + mapping from the runtime's own codes, and the allowlist validators the shipped preload and broker + run on both sides of the boundary. + +What the borrowing leaves behind is the window block: the argv surface id, the `surface.window` +manifest whitelist, and the page's `getState`/`update`/`focus` handle. It is the last contiguous +region of the file, so a future synchronization reads as one omitted tail rather than as a series of +local divergences. + +Two consequences follow for the layers around the contract. The manifest reader must accept the +three keys the newer generation declares and this repository's reader does not know: `surface.window`, +`agentAccess` and `agentConcurrency`. Its field sets are strict, so an unknown key fails the package +as `MANIFEST_SCHEMA_INVALID` — a package built for the shipped client is rejected before any of this +work can matter. Accepting is not supporting: `surface.window` is read and ignored here, because its +effect was just dropped from the contract, while the two agent fields are enforced, because the agent +capability is in scope below. + +## The credential guard is carried over + +The shipped client guards the Mini App's own HTTP server from inside the Mini App's process: the +runner patches `http.Server.prototype.emit` before the plugin entry is imported, so any server the +plugin creates is covered. A request event must carry the exact loopback `Host` and a timing-safe +match of the credential header, or it is answered 401 with no body; `upgrade` and `connect` sockets +are destroyed instead. On success the header is removed from both `headers` and `rawHeaders`, so +plugin code never observes it. One reserved path is answered 204 by the guard itself, which is what +lets the readiness probe prove the guard is in the path rather than merely that a socket accepts: +readiness becomes "anonymous must be 401, credentialed must be 204" instead of a TCP connect. + +This port carries that guard. The previous revision treated it as an option, on the grounds that +having no guard is not worse than having no Mini App service at all. That comparison holds only while +the service is off. The moment this client's row enables it, the exposure is new, and it is not +theoretical: the Mini App's port is deterministic — `49152 + sha256(pluginId)` across the range, with +a persisted preferred port first — so a local process can compute it rather than scan for it. A +credential is also the only thing that can distinguish the proxy's own requests from those of every +other local process, and the proxy is the only party that can inject it. + +The cost is bounded and lands in the runtime: the guard, one credential per process generation +threaded through the runner's init message, the supervisor's launch credential so the proxy has +something to inject, and the readiness change above. The credential never reaches the page, and it +never reaches the client: the proxy holds it. ## Runtime seams @@ -79,76 +167,266 @@ new domain behaviour; they expose what the runtime already owns. already drives the other owner-derived service gates — `wiring.cronService`, `wiring.channelService`, `wiring.schedulerHost` — from that one descriptor; the Mini App gate is decided outside it, which is why a change to this client's row cannot reach it. The Mini App - service becomes a wiring cell, this client's row carries it, and `resolveRuntimeMiniAppOptions` - reads the cell rather than the raw kind string. Two consequences follow. The explicit Mini App - options the function already accepts become reachable for every row that carries the cell, not - only the Electron row. And the two places that read the function — the service composition and - the `miniappAvailable` capability flag — are driven by the same cell, so the flag can no longer - disagree with the service. + service becomes `wiring.miniAppService`, this client's row carries it, and both places that read + the function — the service composition and the `miniappAvailable` capability flag — read the cell + instead, so the flag can no longer disagree with the service. + + The cell carries one trap. Every row must state it as a literal: the existing + `ownsElectronRuntimeCapabilities` predicate answers true for an owner that declared no kind, and a + service enabled for an absent owner is a behaviour change no client asked for. This is also the one + owner-derived gate where the Mini App service is stricter than cron or channels. The explicit + Mini App options the function already accepts stay reachable for every row that carries the cell, + because a test builds a runtime with them and no declared kind and expects the supervisor to exist; + `resolveRuntimeMiniAppOptions` keeps that override and takes the cell as its other input. The owner + policy's own suite pins the wiring key set and the count of cells where this client's row differs + from `tui`, so both move with the cell. - **The control plane enters the harness as a forwarding facade.** The desktop client keeps the Mini App control plane in its Electron main process, behind IPC channels that reach a utility broker; there is no Mini App method on `CliService` there or here. The runtime already owns the behaviour in `packages/local-runtime-v2/src/application/session/process-local-miniapp-application.ts`, which - lists and opens Mini Apps. The client entry is a forwarding method on `CliService` over that + lists, opens and stops Mini Apps. Half of that already crosses: the management operation's `listApps` + action resolves to this application's `list`, so the client receives rows with their runtime phase as + soon as the service exists. Only `open` and `stop` still need a client action. The client entry is a + forwarding method on `CliService` over that application, bound as a WebUI operation in the usual five places. The WebUI does not reimplement Mini App lifecycle logic in its server, and it does not reach around the facade into the application bag. + + Two additions to the application follow from the other decisions here. It needs a start that does + not open a page: this client prewarms a Mini App without showing it, which the contract expresses as + `openPage: false` and which the shipped client reaches through a start-if-cold path rather than + through `open`. And it needs to hand the credential and origin to the + server: `launch` is documented as server-only coordinates that must never be forwarded to a + renderer, and this design honours that literally. The client receives the proxy's own view URL — + built from the session, the plugin and the presentation — and never the Mini App's origin, its + surface path or its credential. Everything that touches a Mini App's real origin stays on the + server side of the facade. - **The presenter stays a host-owned port.** `MiniAppPresenter` is documented as the desktop client's native presenter and is absent outside it. The WebUI supplies the browser implementation: its `open` means the client should show that Mini App, and its `close` means the client stops showing it. The port's shape — open and close by session and plugin — is already platform-neutral, so the browser implementation fits it without changing the contract. -## The contract baseline is an open question + The ordering matters more than it looks. The agent tool's own `open` action throws + `OPEN_UNAVAILABLE` when the runtime has no presenter, while `list`, `init`, `publish`, `restart` and + `stop` start working the moment the service cell is flipped. Enabling the service before the + presenter exists would therefore ship a half-live capability: the agent could publish and start a + Mini App that nothing can display. The presenter, the view it opens and the cell belong to one + change. -The Mini App contract in this repository is one generation behind the Mini App implementation -shipped in the desktop client. The facts, verified against both: - -- `packages/shared/src/miniapp-surface.ts` here is 54 lines. The shipped client's is 1267, and adds - the loopback credential header (`x-mavis-miniapp-token`), the page-bridge contracts for the - composer, the agent and the window handle, and the split between starting a Mini App and - opening its page. -- The shipped client guards the Mini App's own HTTP server: every request must carry the - Host-issued credential and the exact loopback host, or it is answered 401, and the same guard - covers WebSocket upgrades. This repository's runtime has no such guard. -- This repository and the public upstream are identical on these files, and the newer contract - appears nowhere in the upstream tree. It exists only in the internal tree the desktop client was - built from, so no upstream synchronization will deliver it. - -This matters because the WebUI shares the data directory with the desktop client (ADR 0006), so -the Mini App packages installed on a machine are the newer generation, and their pages call the -newer page bridge. The bridge is therefore required for those packages to work. The credential -guard is a separate question: without it the Mini App's loopback port answers any local process -that finds it, which is weaker than the desktop client but is not worse than this repository's -current behaviour, which is to have no Mini App service at all. - -The options are to forward-port the page-bridge contracts and the browser bridge only, leaving the -guard for later; to forward-port the whole contract including the runtime guard, at the cost of -bringing an unpublished internal contract into this repository ahead of upstream; or to build the -carrying surface first and settle the contract when the work starts. The bridge rewrite cannot be -finished before this is settled, because the API surface it must implement is defined by the -contract. This ADR records the question; it does not settle it. +## The agent capability is in scope + +A Mini App page's agent calls are part of this port. They are also the largest piece of it, and the +one place where the work is not a client-side rewrite of something the runtime already does. + +The acceptance standard here is parity with the desktop client's page: an installed Mini App must +render and work in this client as it does there. A page that probes the bridge when it loads must not +find a method missing, because the visible result is a dead panel rather than a graceful degradation. +That is why the composer alone is not a smaller first version of this decision — it is a different, +insufficient one. + +The shipped client's agent path is a runtime capability this repository does not have: a Mini App +gateway that owns per-plugin arenas, queues and turn lineage; a session-ports layer through which a +plugin creates and drives host sessions; a projection that decides what a plugin may see of them; the +application facade that fronts all of it; and the child-side client that the plugin's own process +calls. None of those files exist here, and the plugin manifest fields that gate them — +`agentAccess` (only `session.run` today) and `agentConcurrency` — are rejected by this repository's +reader as unknown keys. + +The connector's wire has to move with it, and the reason is not a preference. In this repository the +host connector *is* the child's stdout: the child's stdout is the request stream and its stdin the +response stream, framed as strict NDJSON. When the connector is disabled, a single byte on stdout +aborts the whole Mini App runtime as a protocol violation; when it is enabled, the same byte corrupts +a frame. The child does already have a parent channel — every Mini App is spawned with a fourth IPC +file descriptor, and the runner's own comment says what it is for: that channel controls lifecycle, +while the standard streams are reserved for the connector. The shipped runtime keeps the channel and +changes what rides on it: connector frames are multiplexed onto the same descriptor by message type, +the child's stdin is dropped entirely, and stdout and stderr become logs. An agent-enabled Mini App +logs — it runs turns, tools and model calls — so the two protocol families cannot keep sharing one +stream. Adding a second channel for agent calls alone while leaving tool calls on NDJSON would still +break the tool channel on the first log line, and it is a shape the shipped client never had, so +nothing upstream would ever confirm it works. The connector therefore moves onto the existing +channel for every Mini App, not only the agent-enabled ones: one channel carries both message +families, told apart by the message type, the child's stdin stops being used at all, and stdout and +stderr become logs. That is this record's decision rather than an assumption, and the +dual-transport alternative stands rejected for the reason just given. + +What that costs, measured against the shipped package rather than estimated: roughly 2900 lines of +runtime service (gateway, session ports, contracts, projection, model selection), a rewrite of the +host connector from 1700-odd lines across four files into 2900 across six, the transport binding +itself, and a new application file. It is the single largest forward port in this record, and it +carries its own review: the connector is shared with tool calls, so a defect there is not confined to +Mini Apps. + +One surface inside it has no counterpart that looks like the shipped one: a page's `open` call. It is +not a cosmetic button. In the shipped runtime the host chat's side panel is what makes a +Mini-App-created session's permission asks answerable — the gateway denies an approval for such a +session unless some host chat is currently showing it. This client has no side panel, and the workspace +panel cannot be one: it hosts the selected session's own resources, while the client renders one +selected session at a time, so a second transcript beside the first would need a second conversation +surface rather than one more tab kind. + +The mapping is the session rail plus the standalone presentation. `open` selects that session through +the client's ordinary selection path; while it is the selected session the service reports the panel as +showing it, and switching away or closing it reports the panel as closed. `hostSessionId` is the +session whose workspace panel carries the inline view — or, for a standalone tab, the session its deep +link carried. The arrangement a person gets is not the shipped client's side-by-side chat and panel: +selecting the Mini App's session replaces the workspace panel, and with it the inline view. That is why +the standalone presentation belongs to this decision rather than following it — with the Mini App in its +own tab, the rail and the page are on screen at once, which is the nearest equivalent of the shipped +arrangement. + +## Where the inline view lives + +The workspace panel's tab content is a closed union: a tab is a stable client id plus a content +record, and the record's kind fixes the renderer. A Mini App becomes one more member — +`{kind: 'miniapp', sessionId, pluginId}` — and the cost is the union's six touch points: the content +union and its kind table, the command that opens a tab with a payload, the reducer case, the render +branch, the tab strip's glyph, and the tile on the launcher page. That last one is the entry point: +this client's "add a tab" surface already is a page of tiles, which is the counterpart of the desktop +client's File Panel "+", and the tile leads to the plugin's picker. + +Two properties of that picker are worth stating. Its rows and the plugin catalogue's apps area are the +same rows: the existing `listApps` action already resolves to the runtime's Mini App listing, phases +included, and answers an empty list today only because the service is gated off. So the catalogue is +missing a surface and a launch action, not data. And the capacity answer is real: three Mini Apps run +at once, so opening a fourth has to name the running ones and let the user stop one. + +The plugin catalogue is the other half of the entry, and it belongs to this version: a person installs a +Mini App as a plugin, so the surface that installs plugins has to show what is installed. That area +owns the list and the lifecycle — install, uninstall, enable, disable, stop, and the open action — while +the workspace panel owns the display. Rows there carry runtime phases, so the same surface names what +is running without a second query. + +The view component itself belongs to the Mini App business, which publishes it through a +`*.public.tsx` module — the same crossing the browser panel uses to mount a live-stream view in the +panel. The panel stays the layout owner and learns nothing about Mini App state. + +Two behaviours follow from the panel's own rules rather than from this decision. The panel's state is +per session and refuses to exist without one, so a Mini App tab belongs to a session, and closing the +panel hides the tab without destroying its place in the state. The iframe does unmount, though, and +with it the page: a tab is a presentation, while the runtime is plugin-scoped. Reopening the panel +points the tab at the same origin again, so the Mini App's server-side state is intact and its +in-page state is gone. That is the same consequence as moving a page between presentations on the +desktop client, and it should be stated in the tab rather than discovered. + +## The client's Mini App is one business + +Everything this client knows about Mini Apps is one business: the view, the picker, the apps list, the +presentation lease and the host side of the bridge. It qualifies on the same grounds every other +business here does — it has a lifetime of its own per session, state of its own (which Mini Apps are +open, what phase each one is in, which presentation lease is live, which bridge requests are pending), +several surfaces, and a protocol of its own. Splitting it would put per-session Mini App state and a +`postMessage` protocol inside the plugin catalogue, whose lifetime is installing and enabling packages, +or inside the workspace panel, whose lifetime is layout. + +Three crossings, each one line wide: + +- The workspace panel mounts the published view for its `miniapp` tab kind, the way it mounts the + browser panel host today. +- The plugin catalogue mounts the published apps list for its apps area and holds no Mini App state of + its own. +- The business dispatches the workspace panel's own tab command to open a view; it never reaches into + the panel's state. + +Two properties keep that boundary honest. A tab's existence stays in the workspace panel state, because +that is layout, while the Mini App business owns the runtime and bridge facts about that tab, because +that is capability. And the view URL is produced by the service and returned by the open operation: the +client never assembles a Mini App origin and never carries its credential, which is the rule the +workspace file preview already follows. + +## The standalone presentation + +The client router resolves exactly two outcomes today — `/` and `/archon` both to the client, anything +else to 404 — and the session travels in the `#session=` hash. A standalone Mini App is +`/miniapp?plugin=&session=`, which the service maps to the same document, so the runtime-configuration +injection applies unchanged and the tab is an ordinary client. The shell needs one new concept to +serve it: a presentation mode in which the full client renders only the Mini App view. That is a +smaller change than the desktop client's, which has to move a native view; here it is which component +the shell mounts. + +The link that opens it is produced by the inline tab, which carries the action that moves this Mini App +into its own browser tab; nothing else can produce a deep link a person would not type. The standalone +view offers no control back to the inline one — that would be the in-page close control it deliberately +does not have — so returning is closing the tab, which releases the lease. + +Two states are settled with the route. A deep link naming a session that has ended still shows the +Mini App, with the session reported as ended, because the runtime is plugin-scoped and the page has no +dependency on the session existing. A runtime that stops while the tab is open clears the view and +reports the stop reason; it does not reconnect on its own, because the tab may be unattended and a +retry loop would restart a Mini App the user did not ask for. A retry is a user action. + +### The standalone presentation is a lease + +Because the two presentations are two client documents that share nothing but the service, the service +holds the record of which one is live. A standalone client declares its plugin, its session and its +presentation when it connects, and the record is released when that client's socket closes. The inline +view reads the record, which is what lets it show that a Mini App is already open elsewhere and offer +to switch instead of opening a second tab. + +That record is also what gives `detached` and `presentationRevision` meaning in this client, rather +than leaving them as fields this port always answers with `false` and `0`. `detached` is "a standalone +lease exists for this plugin"; the revision increments each time a lease is acquired, which is the +same property it has on the desktop client — a fast detach and attach that a poll would otherwise miss. + +## The proxy is one listener per Mini App + +A Mini App's page refers to its own origin by absolute path — the scaffold serves `/dashboard`, and a +real page also fetches `/api/…`, its assets and relative URLs for the rest. A single proxy origin with +a per-Mini-App path prefix would rewrite exactly the URLs the page asks for, and patching them back +means intercepting `fetch`, `XMLHttpRequest` and every absolute URL in the document. The proxy is +therefore one loopback listener per running Mini App: paths are forwarded unchanged to the Mini App's +origin, the credential is injected on the way out, the document is rewritten on the way back to carry +the bridge, and WebSocket upgrades are forwarded under the same rules. At most three Mini Apps run at +once, so at most three listeners exist, which is also how the runtime already gives each Mini App a +port of its own. That listener's port is allocated per plugin rather than in order of opening, for the +same reason the runtime's own is deterministic: the page's origin is what keys its `localStorage` and +`IndexedDB`, so a port that moved between runs would silently discard the page's own state. + +## One owner per Mini App across clients + +The WebUI and the desktop client share a data directory (ADR 0006), and a Mini App's port is derived +from its plugin id, so without a rule both clients compute the same first port and the second one +starts a second process over the same plugin data. This record decides ownership rather than +arbitration: **one client owns a Mini App at a time, and the other refuses to start it.** + +Ownership is per plugin, not global, so one client can own one Mini App while the other owns a +different one. The owner is whatever starts the runtime first, recorded in the shared data directory +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. + +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 +does means the Mini App is already up somewhere. The client then refuses with the same message instead +of spawning a process that cannot bind, or worse, one that can. + +The user-visible result is a row and a picker entry that say another client is running this Mini App, +with the open action disabled. The consequence is accepted and stated rather than hidden: a Mini App +running in the desktop client cannot be shown in the WebUI, because the credential that reaches it +never leaves the process that minted it. What the rule buys is the opposite guarantee — only one process +per Mini App ever writes its plugin data. + +## Delivery order + +The order is fixed by three dependencies rather than by preference. The contract arrives first, +because the bridge and the operations are typed against it. The guard arrives before the proxy +injects anything, because a proxy that injects a credential into a service with no guard proves +nothing. The service cell, the presenter and the view land in one change, for the half-live reason +above. The plugin catalogue's apps area rides with that change rather than after it: it is the surface +that shows what is installed and what is running, so without it the view has nothing to be opened from. The agent capability follows the view: it is the only part that can be reviewed against a +working page, since a plugin's agent calls are only observable from a page this client can display. ## Open implementation questions -1. **The contract baseline.** Which of the three options above is taken, and therefore whether the - proxy injects a credential at all. -2. **The bridge rewrite.** Which page-bridge methods the browser build implements, which - error codes it reproduces exactly, and how the agent request identifiers, the permission reply - and the questionnaire answer path cross the `postMessage` boundary. -3. **The proxy's own shape.** Its loopback origin and route layout, what it forwards, how it - handles the Mini App's WebSocket connections, and which response headers it must drop so the - page can be framed at all. -4. **The entry point.** Where a user opens a Mini App from in this client. The plugin business - already lists installed plugins; a Mini App entry needs its own home, and the composer and - agent paths that open Mini Apps in the desktop client need a counterpart. -5. **The standalone view's session semantics.** Which session the deep link selects, what the view - shows when that session has ended, and how the tab behaves when the Mini App's runtime stops - while the tab is open. -6. **The inline view's home.** Which workspace panel view hosts the Mini App, how it coexists with - the file tree, the preview and the terminal, and what happens to a Mini App open when the panel - is closed. +1. **Framing the page through the proxy.** One listener per Mini App is decided above; what remains is + which response headers the proxy must add or drop for the document to be framable at all, and what + it does with a Mini App that sets framing or CSP headers of its own. The desktop client offers no + precedent: it never frames the page and writes no response headers of its own. +2. **The bridge's method-level error mapping.** Which failure code each bridge call answers when the + host cannot serve it — an unsupported capability, a full queue, a runtime that stopped between two + calls — and how a page's request identifier survives the `postMessage` boundary. ## Research findings @@ -179,3 +457,70 @@ contract. This ADR records the question; it does not settle it. (`packages/local-runtime-v2/src/services.ts`, `packages/local-runtime-v2/src/service/llm-context-inspector/initialize.ts`, `packages/webui/docs/known-gaps.md` §2). +- The shipped contract is a strict superset of this repository's: `diff` reproduces the 54 lines here + as its first 54, its only import is `./questionnaire.js` which is byte-identical here, and its build + output carries no `require` at all. +- The shipped guard patches `http.Server.prototype.emit` before the plugin entry is imported, covers + `request`, `checkContinue`, `checkExpectation` and destroys `upgrade` and `connect` sockets, strips + its own header from `headers` and `rawHeaders` on success, and answers one reserved health path with + 204 so readiness proves the guard is in the path. The shipped readiness probe is + "anonymous must be 401, credentialed must be 204"; this repository's is a TCP connect + (`.../service/miniapp/process/node-runtime/node-runtime.ts`, `host-runner.ts`). +- The credential is minted per process generation and threaded through the runner's init message; the + supervisor exposes it as a launch credential, and the application's `launch` object is documented as + server-only: "Never forward this object to a renderer" + (`packages/local-runtime-v2/src/application/session/process-local-application-contract.ts`). +- The application exposes only `list`, `open` and `stop` here; the shipped generation adds a + read-only `credential` and returns the credential with `open`. +- The shipped runtime's agent path is a whole absent directory here — + `.../service/miniapp/agent/` (contracts, gateway, session ports, projection, model selection, + roughly 2900 lines without tests) — plus `process-local-miniapp-agent-application.ts`, and the + plugin manifest fields that gate it. +- Every Mini App is a separate child process: the node runtime spawns one with a fourth IPC file + descriptor, and at most three run at once. In this repository that descriptor carries lifecycle + messages only (`miniapp:ready`, `miniapp:error`, `miniapp:stopped`, stop and parent-loss + handling) and the runner states in its own comment that the standard streams are reserved for the + connector wire; the connector reads the child's stdout and writes its stdin as strict NDJSON, and + the runtime aborts on any stdout byte while the connector is disabled + (`packages/local-runtime-v2/src/service/miniapp/process/node-runtime/host-runner.ts`, + `node-runtime.ts`). The shipped runtime uses the same descriptor for both, distinguished by + message type, drops the child's stdin, and logs stdout and stderr instead. +- The shipped manifest reader allows `agentAccess`, `agentConcurrency` and `surface.window`; this + repository's allows none of the three and fails unknown keys as `MANIFEST_SCHEMA_INVALID` + (`packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts`). +- A runtime can be built with explicit Mini App options and no declared owner kind, and the supervisor + is expected to exist, so the explicit-options override survives the wiring-cell change + (`packages/local-runtime-v2/src/services.test.ts`). +- The owner policy's suite pins the wiring key set and the count of cells where this client's row + differs from `tui` (`packages/local-runtime/test/unit/runtime-owner-policy.test.ts`). +- The workspace panel's tab content is a closed union, the open-tab command admits three kinds today, + the panel's state is per session and refuses to exist without one, and another business mounts into + the panel only through a `*.public.tsx` module + (`packages/webui/src/client/features/workspace/projection/workspace-panel-state.ts`, + `packages/webui/src/client/features/workspace/contracts/panel-command.ts`, + `packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx`). +- The client router resolves `/` and `/archon` and nothing else, and carries the session in a hash + (`packages/webui/src/client/projection/common/router.ts`); the service maps those paths to the same + document (`packages/webui/src/server/http/router.ts`). +- The server holds no outbound HTTP client, no HTML rewriting except the runtime-configuration + injection into `index.html`, and no response-header policy except the workspace file route's + `Content-Security-Policy: sandbox` (`packages/webui/src/server/http/`). +- The plugin business lists Mini Apps already and has no surface to open one: the listing area exists + in the workflow and is reachable only programmatically, and its `listApps` action already resolves to + the runtime Mini App application's `list`, so rows arrive with a runtime phase the moment the service + exists — the empty answer today is the gate, not the query + (`packages/webui/src/client/features/plugins/application/plugin-workflows.ts`, + `packages/local-runtime-v2/src/application/cli-management-application.ts`). Plugin install, + uninstall, enable and disable are existing management actions, so installing a Mini App adds no + plumbing. +- Only one thing rides the child's standard streams today: the host connector's three frames + (`connector.list`, `connector.call`, `connector.cancel`), which give a Mini App access to + host-provided tools under host-held credentials with an allowlist, a schema-checked call and an + audited result. stderr is already logs — a bounded tail feeds startup-failure diagnostics — and a + byte on stdout aborts the runtime whenever the connector is disabled + (`packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector.ts`, + `process/node-runtime/node-runtime.ts`, `node-runtime-startup-error.ts`). +- The Mini App service here carries no tests of its own: `packages/local-runtime-v2/src/service/miniapp` + holds no `*.test.ts` file and `test/vitest-suites.json` registers no Mini App suite, while the + shipped package carries its connector, gateway and session-port suites with it. A forward port + therefore arrives with a regression net to adopt and register rather than with a gap to fill. From 2ee6a8301344449b4ccfd801062e8643c493cb88 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 14:18:30 +0800 Subject: [PATCH 02/43] feat(webui): forward port the Mini App contract and accept the newer manifest keys The Mini App contract moves to the generation the desktop client ships, minus its window block, and the package reader stops rejecting a package built for that generation. The contract file is now a contiguous copy of the shipped file's leading part (shipped lines 1-964 of 1267): the start/page-open split, detached and presentationRevision, the loopback token header, the composer block, and the whole agent block with its nine run events and their mapping from runtime failure codes. The window block is the omitted tail: the argv surface id, the surface.window whitelist and the page's getState/update/focus handle. One private helper has to come out of that tail. The agent parsers above use isPlainRecord nine times, and the shipped file declares it at line 1156, inside the window block, so it is kept at the seam between the copied part and the omission. It is the one local divergence a future copy has to reconcile. The reader accepts the three keys the newer generation declares. surface.window is accepted and ignored, because the window block is not part of this contract and the value therefore has no effect. agentAccess (only session.run, deduplicated) and agentConcurrency (an integer from 1 to MINIAPP_AGENT_MAX_CONCURRENCY) are validated and preserved on the contribution, so the agent service can enforce them when it lands. Both allowlists stay strict: an unknown top-level key still fails as MINIAPP_MANIFEST_SCHEMA_INVALID, and an unknown key beside surface.window still fails as MINIAPP_SURFACE_INVALID. No runtime behaviour changes: the service still does not start for this client's owner kind, and nothing reads the agent fields yet. ADR 0020's contract-baseline section is corrected from measurement: the shipped leading part is a strict superset of the old 54 lines but not a line-identical copy of them, and the omitted tail is not purely window code. Verified: pnpm typecheck; pnpm typecheck:webui; pnpm run test:capabilities (235 files, 5811 passed, 18 skipped); pnpm build; pnpm check:source after node scripts/source-inventory.mjs --write. The new reader suite fails 13 of 16 cases against the previous reader and passes 16 of 16 here. --- .../src/service/miniapp/contracts.ts | 3 + .../plugin/package/miniapp/validation.test.ts | 115 +++ .../plugin/package/miniapp/validation.ts | 40 +- .../plugin-system/plugin/package/types.ts | 3 + packages/shared/src/miniapp-surface.ts | 922 +++++++++++++++++- ...20-webui-miniapp-view-and-runtime-seams.md | 22 +- release/public-source.json | 1 + test/vitest-suites.json | 1 + 8 files changed, 1096 insertions(+), 11 deletions(-) create mode 100644 packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.test.ts diff --git a/packages/local-runtime-v2/src/service/miniapp/contracts.ts b/packages/local-runtime-v2/src/service/miniapp/contracts.ts index 59d446393..01e7d3c74 100644 --- a/packages/local-runtime-v2/src/service/miniapp/contracts.ts +++ b/packages/local-runtime-v2/src/service/miniapp/contracts.ts @@ -1,3 +1,6 @@ +/** Largest `agentConcurrency` a Mini App manifest may declare. */ +export const MINIAPP_AGENT_MAX_CONCURRENCY = 32; + export type MiniAppHostConnectorPolicy = | { readonly kind: 'deny-all' } | { readonly kind: 'allowlist'; readonly providers: readonly string[] }; diff --git a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.test.ts b/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.test.ts new file mode 100644 index 000000000..1af25d280 --- /dev/null +++ b/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.test.ts @@ -0,0 +1,115 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { readOptionalMiniAppContribution } from './reader.js'; + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); + +async function writeTree(root: string, files: Record): Promise { + for (const [relativePath, content] of Object.entries(files)) { + const target = join(root, relativePath); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); + } +} + +/** The smallest package the reader accepts: a process runtime and its two payload roots. */ +async function readManifest(miniapp: Record) { + const root = await mkdtemp(join(tmpdir(), 'miniapp manifest ')); + roots.push(root); + await writeTree(root, { + 'package.json': JSON.stringify({ + name: 'miniapp-fixture', + version: '0.0.0', + mcode: { schemaVersion: 2, miniApp: './miniapp/miniapp.json' }, + }), + 'miniapp/miniapp.json': JSON.stringify({ + schemaVersion: 1, + artifacts: { client: ['./miniapp/client'], node: ['./miniapp/node'] }, + runtime: { kind: 'process', entry: './miniapp/node/server.js' }, + surface: { path: '/dashboard' }, + ...miniapp, + }), + 'miniapp/client/index.html': '\n', + 'miniapp/node/server.js': 'export {};\n', + }); + return readOptionalMiniAppContribution(root, []); +} + +describe('Mini App manifest compatibility with the shipped generation', () => { + it('reads a manifest written for the shipped client', async () => { + const contribution = await readManifest({ + surface: { path: '/dashboard', window: { width: 800, height: 600, alwaysOnTop: true } }, + agentAccess: ['session.run'], + agentConcurrency: 4, + }); + + expect(contribution).toMatchObject({ + surface: { path: '/dashboard' }, + agentAccess: ['session.run'], + agentConcurrency: 4, + }); + }); + + it.each([ + ['a desktop window declaration', { width: 800, height: 600, frame: false }], + ['a declaration the dropped rule set would reject', { transparent: true }], + ['a value of the wrong type', 'window'], + ])('gives surface.window no effect: %s', async (_label, window) => { + const contribution = await readManifest({ surface: { path: '/dashboard', window } }); + + expect(contribution?.surface).toEqual({ path: '/dashboard' }); + }); + + it('deduplicates agentAccess', async () => { + const contribution = await readManifest({ agentAccess: ['session.run', 'session.run'] }); + + expect(contribution?.agentAccess).toEqual(['session.run']); + }); + + it('omits both agent keys when the manifest does not declare them', async () => { + const contribution = await readManifest({}); + + expect(contribution).not.toHaveProperty('agentAccess'); + expect(contribution).not.toHaveProperty('agentConcurrency'); + }); + + it.each([[['session.observe']], [['session.run', 'session.stop']], ['session.run']])( + 'fails closed for agentAccess %j', + async (agentAccess) => { + await expect(readManifest({ agentAccess })).rejects.toMatchObject({ + code: 'MINIAPP_AGENT_ACCESS_INVALID', + }); + }, + ); + + it.each([[0], [33], [4.5], ['4'], [null]])( + 'fails closed for agentConcurrency %j', + async (agentConcurrency) => { + await expect(readManifest({ agentConcurrency })).rejects.toMatchObject({ + code: 'MINIAPP_AGENT_CONCURRENCY_INVALID', + }); + }, + ); +}); + +describe('Mini App manifest allowlist', () => { + it('fails a manifest with an unknown top-level key', async () => { + await expect(readManifest({ detach: true })).rejects.toMatchObject({ + code: 'MINIAPP_MANIFEST_SCHEMA_INVALID', + }); + }); + + it('fails an unknown key beside surface.window', async () => { + await expect( + readManifest({ surface: { path: '/dashboard', window: {}, surfaceId: 'app' } }), + ).rejects.toMatchObject({ code: 'MINIAPP_SURFACE_INVALID' }); + }); +}); diff --git a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts b/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts index f7b20515e..a28fa209f 100644 --- a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts +++ b/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts @@ -1,5 +1,7 @@ import path from 'node:path'; +import { MINIAPP_AGENT_MAX_CONCURRENCY } from '../../../../miniapp/contracts.js'; + import { isRecord, resolvePluginDirectory, @@ -23,10 +25,12 @@ const MINIAPP_FIELDS = new Set([ 'surface', 'mcpEndpoints', 'hostConnectorAccess', + 'agentAccess', + 'agentConcurrency', ]); const ARTIFACT_FIELDS = new Set(['client', 'node']); const PROCESS_RUNTIME_FIELDS = new Set(['kind', 'entry', 'lifecycle']); -const SURFACE_FIELDS = new Set(['path']); +const SURFACE_FIELDS = new Set(['path', 'window']); const MCP_ENDPOINT_FIELDS = new Set(['server', 'path']); const HOST_CONNECTOR_ACCESS_FIELDS = new Set(['providers']); const CONNECTOR_PROVIDER = /^[a-z0-9_-]{1,64}$/u; @@ -46,12 +50,16 @@ export async function readMiniAppManifest( const surface = readSurface(value.surface); const mcpEndpoints = readMcpEndpoints(value.mcpEndpoints, mcpServerNames); const hostConnectorAccess = readHostConnectorAccess(value.hostConnectorAccess); + const agentAccess = readAgentAccess(value.agentAccess); + const agentConcurrency = readAgentConcurrency(value.agentConcurrency); return { artifacts, runtime, surface, mcpEndpoints, ...(hostConnectorAccess ? { hostConnectorAccess } : {}), + ...(agentAccess ? { agentAccess } : {}), + ...(agentConcurrency === undefined ? {} : { agentConcurrency }), }; } @@ -136,8 +144,11 @@ async function readProcessRuntime( function readSurface(value: unknown): { path: string } { if (!isRecord(value) || hasUnknownField(value, SURFACE_FIELDS)) { - miniAppFail('SURFACE_INVALID', 'surface must contain only path'); + miniAppFail('SURFACE_INVALID', 'surface must contain only path and window'); } + // `surface.window` is accepted and ignored: the window block is not part of this + // client's Mini App contract, so declaring it has no effect here. The key stays in + // the allowlist so a package built for the desktop client is not rejected. return { path: normalizeRoutePath(value.path, 'surface.path') }; } @@ -196,6 +207,31 @@ function readHostConnectorAccess(value: unknown): { providers: string[] } | unde return { providers }; } +function readAgentAccess(value: unknown): 'session.run'[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((capability) => capability !== 'session.run')) { + miniAppFail('AGENT_ACCESS_INVALID', 'agentAccess may only contain session.run'); + } + return [...new Set(value as 'session.run'[])]; +} + +/** Allowed without `agentAccess`, where it has no effect. */ +function readAgentConcurrency(value: unknown): number | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < 1 || + value > MINIAPP_AGENT_MAX_CONCURRENCY + ) { + miniAppFail( + 'AGENT_CONCURRENCY_INVALID', + `agentConcurrency must be an integer from 1 to ${MINIAPP_AGENT_MAX_CONCURRENCY}`, + ); + } + return value; +} + async function resolveFileOrDirectory( root: CanonicalPluginRoot, relativePath: string, diff --git a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/types.ts b/packages/local-runtime-v2/src/service/plugin-system/plugin/package/types.ts index 43344aa15..2a46090ce 100644 --- a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/types.ts +++ b/packages/local-runtime-v2/src/service/plugin-system/plugin/package/types.ts @@ -104,6 +104,9 @@ export interface MiniAppContribution { readonly surface: { readonly path: string }; readonly mcpEndpoints: readonly MiniAppMcpEndpoint[]; readonly hostConnectorAccess?: { readonly providers: readonly string[] }; + readonly agentAccess?: readonly 'session.run'[]; + /** Agent runs of this Mini App at once; absent means the Host default. */ + readonly agentConcurrency?: number; } type ScannedMiniAppContribution = MiniAppContribution & { diff --git a/packages/shared/src/miniapp-surface.ts b/packages/shared/src/miniapp-surface.ts index d4d8d743a..f197a6fb6 100644 --- a/packages/shared/src/miniapp-surface.ts +++ b/packages/shared/src/miniapp-surface.ts @@ -1,3 +1,9 @@ +import type { + AskQuestionnaireMode, + AskQuestionnaireReplyAnswer, + AskQuestionnaireRequest, +} from './questionnaire.js'; + /** Renderer-safe Mini App metadata. Runtime launch coordinates never cross this contract. */ export type MiniAppRuntimeStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; @@ -18,6 +24,15 @@ export interface MiniAppSurfaceTabSummary { readonly title: string; readonly surfaceKind: 'miniapp'; readonly miniApp: MiniAppSurfaceSummary; + /** True while the page is shown in its own window instead of the File Panel. */ + readonly detached: boolean; + /** + * Monotonic, bumped once per completed detach and once per completed attach. + * A fast detach->attach round trip can complete between two state polls, + * leaving `detached` unchanged on both reads; the renderer keys its + * presentation on this counter too so it still forces a fresh `show()`. + */ + readonly presentationRevision: number; } export type MiniAppSurfaceErrorCode = @@ -27,7 +42,9 @@ export type MiniAppSurfaceErrorCode = | 'busy' | 'refresh_failed' | 'stop_failed' - | 'start_failed'; + | 'start_failed' + | 'detach_failed' + | 'attach_failed'; /** Bounded, renderer-safe explanation for a retryable Mini App busy failure. */ export type MiniAppSurfaceBusyReason = 'capacity_busy' | 'operation_busy'; @@ -49,6 +66,909 @@ export type MiniAppOpenResponse = readonly busyReason?: never; }; +export type MiniAppStartResponse = + | { readonly success: true; readonly miniApp: MiniAppSurfaceSummary } + | Extract; + +export interface MiniAppPageOpenInput { + readonly sessionId: string; + readonly pluginId: string; + readonly openPage?: true; +} + +export interface MiniAppBackgroundOpenInput { + readonly pluginId: string; + readonly openPage: false; +} + +/** Keeps existing page-open callers precise while sharing one renderer bridge. */ +export interface MiniAppOpenApi { + (params: MiniAppPageOpenInput): Promise; + (params: MiniAppBackgroundOpenInput): Promise; +} + export type MiniAppControlResponse = | { readonly success: true } | { readonly success: false; readonly code: MiniAppSurfaceErrorCode }; + +/** + * Request header that carries the Host-issued loopback credential to a Mini App's own + * port. Only the name is shared; the value stays in Electron main and local-runtime. + */ +export const MINIAPP_LOOPBACK_TOKEN_HEADER = 'x-mavis-miniapp-token'; + +/** Bounds one untrusted page request; input behavior remains owned by the chat input. */ +export const MINIAPP_COMPOSER_MAX_TEXT_LENGTH = 100_000; + +const MINIAPP_COMPOSER_FAILURE_CODES = [ + 'user_cancelled', + 'invalid_request', + 'text_too_large', + 'not_allowed', + 'target_unavailable', + 'result_unknown', +] as const; + +/** Success acknowledges a draft append, never an Agent submission or a durable disk commit. */ +export type MiniAppComposerAppendResult = + | { readonly success: true } + | { + readonly success: false; + readonly code: (typeof MINIAPP_COMPOSER_FAILURE_CODES)[number]; + }; + +/** Both IPC boundaries use the same allowlist and discard all non-contract fields. */ +export function parseMiniAppComposerAppendResult( + value: unknown, +): MiniAppComposerAppendResult | null { + if (!value || typeof value !== 'object') return null; + const result = value as Record; + if (result.success === true) return { success: true }; + const code = MINIAPP_COMPOSER_FAILURE_CODES.find((candidate) => candidate === result.code); + return result.success === false && code ? { success: false, code } : null; +} + +/** Main-to-Host-renderer only. None of these identity fields are supplied by the MiniApp. */ +export interface MiniAppComposerRequest { + readonly requestId: string; + readonly sessionId: string; + readonly pluginId: string; + readonly pluginName: string; + readonly text: string; +} + +/** Host-to-Main result for one requested append. */ +export interface MiniAppComposerReply { + readonly requestId: string; + readonly result: MiniAppComposerAppendResult; +} + +/** Host-renderer-only confirmation channel; never exposed to an embedded page. */ +export interface MiniAppComposerHostAPI { + onAppendRequest( + handler: (request: MiniAppComposerRequest) => Promise, + ): () => void; +} + +/** Bounds one untrusted page Agent request before it crosses into Main. */ +export const MINIAPP_AGENT_MAX_TEXT_LENGTH = 100_000; + +export const MINIAPP_AGENT_FAILURE_CODES = [ + 'invalid_request', + 'not_permitted', + 'busy', + 'session_not_found', + 'runtime_unavailable', + 'cancelled', + 'result_unknown', + 'request_not_found', +] as const; + +export type MiniAppAgentFailureCode = (typeof MINIAPP_AGENT_FAILURE_CODES)[number]; + +/** + * Why a `run` / `answer` failed as `busy`: its session is already running, or the Mini App's + * queue of waiting calls is full. + */ +export type MiniAppAgentBusyReason = 'session_busy' | 'queue_full'; + +export function isMiniAppAgentBusyReason(value: unknown): value is MiniAppAgentBusyReason { + return value === 'session_busy' || value === 'queue_full'; +} + +/** `reason` is set only with `busy`. */ +export interface MiniAppAgentFailure { + readonly ok: false; + readonly code: MiniAppAgentFailureCode; + readonly reason?: MiniAppAgentBusyReason; +} + +/** Bounds a page-chosen request id; it is only a per-page correlation key. */ +export const MINIAPP_AGENT_MAX_REQUEST_ID_LENGTH = 128; + +/** What a Mini App sends and receives for a model; the composer's selection minus provider internals. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One enabled model of the runtime catalog. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a session without a host chat model is created with. */ + readonly default: MiniAppAgentModelSelection; + /** The host chat's current model; absent for a tab without a host chat. */ + readonly host?: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** The agent's canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +/** Page payload. Plugin and run identity are resolved by Main from the sending tab. */ +export interface MiniAppAgentRunRequest { + readonly text: string; + readonly sessionId?: string; + /** Page-chosen id for `cancel(requestId)`; generated when absent. */ + readonly requestId?: string; + /** Switches the session to this model before the turn. */ + readonly model?: MiniAppAgentModelSelection; + /** Only with a new session (no `sessionId`): the agent it is created for. */ + readonly agent?: string; +} + +/** Page payload for `create`: a session without a turn. */ +export interface MiniAppAgentCreateRequest { + readonly agent?: string; +} + +export interface MiniAppAgentCreateResult { + readonly sessionId: string; +} + +/** Non-empty, bounded and free of control characters; checked on both sides of the IPC. */ +export function isMiniAppAgentRequestId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MINIAPP_AGENT_MAX_REQUEST_ID_LENGTH && + !/[\u0000-\u001f\u007f]/u.test(value) + ); +} + +export type MiniAppAgentPermissionDecision = 'allowOnce' | 'deny'; + +/** Page payload for a pending permission; `requestId` is the Host permission request id. */ +export interface MiniAppAgentReplyRequest { + readonly sessionId: string; + readonly requestId: string; + readonly decision: MiniAppAgentPermissionDecision; +} + +/** + * Page payload answering a questionnaire: `questionId` is the questionnaire request id and + * `requestId` is the page-chosen id for `cancel(requestId)`, as in `MiniAppAgentRunRequest`. + */ +export interface MiniAppAgentAnswerRequest { + readonly sessionId: string; + readonly questionId: string; + readonly answers: readonly AskQuestionnaireReplyAnswer[]; + readonly requestId?: string; + /** Switches the session to this model before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; +} + +/** Bounds one untrusted questionnaire answer list before it crosses into Main. */ +export const MINIAPP_AGENT_MAX_ANSWERS = 64; +export const MINIAPP_AGENT_MAX_ANSWER_TEXT_LENGTH = 4_096; + +export function isMiniAppAgentPermissionDecision( + value: unknown, +): value is MiniAppAgentPermissionDecision { + return value === 'allowOnce' || value === 'deny'; +} + +function isBoundedAnswerText(value: unknown): value is string { + return typeof value === 'string' && value.length <= MINIAPP_AGENT_MAX_ANSWER_TEXT_LENGTH; +} + +const ANSWER_KEYS: ReadonlySet = new Set([ + 'stepId', + 'selectedOptionIds', + 'selectedOther', + 'otherText', + 'skipped', +]); + +function readMiniAppAgentAnswer(value: unknown): AskQuestionnaireReplyAnswer | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const input = value as Record; + if (!Object.keys(input).every((key) => ANSWER_KEYS.has(key))) return null; + const { stepId, selectedOptionIds, selectedOther, otherText, skipped } = input; + if (!isNonEmptyString(stepId) || !isBoundedAnswerText(stepId)) return null; + if ( + !Array.isArray(selectedOptionIds) || + selectedOptionIds.length > MINIAPP_AGENT_MAX_ANSWERS || + !selectedOptionIds.every(isBoundedAnswerText) + ) { + return null; + } + if (typeof selectedOther !== 'boolean') return null; + if (otherText !== undefined && !isBoundedAnswerText(otherText)) return null; + if (skipped !== undefined && typeof skipped !== 'boolean') return null; + return { + stepId, + selectedOptionIds: [...selectedOptionIds], + selectedOther, + ...(otherText !== undefined ? { otherText } : {}), + ...(skipped !== undefined ? { skipped } : {}), + }; +} + +/** Closed-key, bounded copy of questionnaire answers; null when any entry is malformed. */ +export function readMiniAppAgentAnswers(value: unknown): AskQuestionnaireReplyAnswer[] | null { + if (!Array.isArray(value) || value.length > MINIAPP_AGENT_MAX_ANSWERS) return null; + const answers: AskQuestionnaireReplyAnswer[] = []; + for (const entry of value) { + const answer = readMiniAppAgentAnswer(entry); + if (!answer) return null; + answers.push(answer); + } + return answers; +} + +/** Page `reply` payload, checked in the preload and again in Main; null when malformed. */ +export function readMiniAppAgentReplyRequest(value: unknown): MiniAppAgentReplyRequest | null { + if (!value || typeof value !== 'object') return null; + const { sessionId, requestId, decision } = value as Record; + return isNonEmptyString(sessionId) && + isNonEmptyString(requestId) && + isMiniAppAgentPermissionDecision(decision) + ? { sessionId, requestId, decision } + : null; +} + +/** + * Wire-only `observe` flag of a turn-following request (`run`, `answer`): the preload sets it + * when the page passed `onEvent`, and only then does Main subscribe the run to events (which + * the runtime also counts as "someone can answer this run's asks"). Absent means `false`; + * null when present but not a boolean. + */ +export function readMiniAppAgentObserveFlag(value: unknown): boolean | null { + if (value === undefined) return false; + return typeof value === 'boolean' ? value : null; +} + +/** + * Page `answer` payload with its (page-chosen or preload-generated) `requestId` and the + * wire-only `observe` flag, checked in the preload and again in Main; null when malformed. + */ +export function readMiniAppAgentAnswerRequest(value: unknown): MiniAppAgentAnswerPayload | null { + if (!value || typeof value !== 'object') return null; + const input = value as Record; + const answers = readMiniAppAgentAnswers(input.answers); + const observe = readMiniAppAgentObserveFlag(input.observe); + const model = readOptionalModel(input.model); + const { sessionId, questionId, requestId } = input; + return isNonEmptyString(sessionId) && + isNonEmptyString(questionId) && + answers !== null && + observe !== null && + model !== null && + isMiniAppAgentRequestId(requestId) + ? { sessionId, questionId, answers, requestId, observe, ...(model ? { model } : {}) } + : null; +} + +type MiniAppAgentAnswerPayload = Required> & { + readonly model?: MiniAppAgentModelSelection; + readonly observe: boolean; +}; + +type MiniAppAgentRunPayload = Required> & + Pick & { readonly observe: boolean }; + +/** Page `run` payload with its (page-chosen or preload-generated) `requestId`; null when malformed. */ +export function readMiniAppAgentRunRequest(value: unknown): MiniAppAgentRunPayload | null { + if (!value || typeof value !== 'object') return null; + const input = value as Record; + const { text, sessionId, requestId, agent } = input; + const observe = readMiniAppAgentObserveFlag(input.observe); + const model = readOptionalModel(input.model); + if (typeof text !== 'string' || text.length > MINIAPP_AGENT_MAX_TEXT_LENGTH) return null; + if (!isMiniAppAgentRequestId(requestId) || observe === null || model === null) return null; + if (!isOptionalNonEmptyString(sessionId) || !isOptionalNonEmptyString(agent)) return null; + return { + requestId, + text, + ...(sessionId !== undefined ? { sessionId } : {}), + ...(model ? { model } : {}), + ...(agent !== undefined ? { agent } : {}), + observe, + }; +} + +/** `undefined` when absent, `null` when present but malformed. */ +function readOptionalModel(value: unknown): MiniAppAgentModelSelection | null | undefined { + return value === undefined ? undefined : readMiniAppAgentModelSelection(value); +} + +/** + * Copies a model selection from untrusted input, dropping extra keys. Mirrors the runtime's + * reader: `providerId` / `modelId` are non-empty strings, an empty `variant` is kept (thinking explicitly off). + */ +export function readMiniAppAgentModelSelection(value: unknown): MiniAppAgentModelSelection | null { + if (!isPlainRecord(value)) return null; + const { providerId, modelId, variant } = value; + if (!isNonEmptyString(providerId) || !isNonEmptyString(modelId)) return null; + if (variant !== undefined && typeof variant !== 'string') return null; + const thinking = readSelectionThinking(value.thinking); + if (thinking === null) return null; + return { + providerId, + modelId, + ...(variant === undefined ? {} : { variant }), + ...(thinking ? { thinking } : {}), + }; +} + +/** `undefined` when absent, `null` when present but malformed. */ +function readSelectionThinking(value: unknown): MiniAppAgentModelSelection['thinking'] | null { + if (value === undefined) return undefined; + return isPlainRecord(value) && typeof value.effort === 'string' ? { effort: value.effort } : null; +} + +function readStringList(value: unknown): string[] | null { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ? [...(value as string[])] + : null; +} + +/** `undefined` when absent, `null` when present but malformed. */ +function readOptionThinking(value: unknown): MiniAppAgentModelOption['thinking'] | null { + if (value === undefined) return undefined; + if (!isPlainRecord(value)) return null; + const efforts = readStringList(value.efforts); + const defaultEffort = value.default; + if (!efforts || (defaultEffort !== undefined && typeof defaultEffort !== 'string')) return null; + return { efforts, ...(defaultEffort !== undefined ? { default: defaultEffort } : {}) }; +} + +function readModelOption(value: unknown): MiniAppAgentModelOption | null { + if (!isPlainRecord(value)) return null; + const { providerId, modelId, displayName } = value; + if (!isNonEmptyString(providerId) || !isNonEmptyString(modelId)) return null; + if (typeof displayName !== 'string') return null; + const variants = value.variants === undefined ? undefined : readStringList(value.variants); + const thinking = readOptionThinking(value.thinking); + if (variants === null || thinking === null) return null; + return { + providerId, + modelId, + displayName, + ...(variants ? { variants } : {}), + ...(thinking ? { thinking } : {}), + }; +} + +/** Allowlist copy of the model catalog; null when any part is malformed. */ +export function readMiniAppAgentModels(value: unknown): MiniAppAgentModels | null { + if (!isPlainRecord(value) || !Array.isArray(value.models)) return null; + const models: MiniAppAgentModelOption[] = []; + for (const entry of value.models) { + const option = readModelOption(entry); + if (!option) return null; + models.push(option); + } + const fallback = readMiniAppAgentModelSelection(value.default); + const host = readOptionalModel(value.host); + if (!fallback || host === null) return null; + return { models, default: fallback, ...(host ? { host } : {}) }; +} + +function readAgentOption(value: unknown): MiniAppAgentAgentOption | null { + if (!isPlainRecord(value)) return null; + const { name, displayName, primary } = value; + return isNonEmptyString(name) && typeof displayName === 'string' && typeof primary === 'boolean' + ? { name, displayName, primary } + : null; +} + +/** Allowlist copy of the agent list; null when any entry is malformed. */ +export function readMiniAppAgentAgents(value: unknown): MiniAppAgentAgents | null { + if (!isPlainRecord(value) || !Array.isArray(value.agents)) return null; + const agents: MiniAppAgentAgentOption[] = []; + for (const entry of value.agents) { + const option = readAgentOption(entry); + if (!option) return null; + agents.push(option); + } + return { agents }; +} + +/** Page `create` payload (`{ agent? }`), checked in the preload and again in Main. */ +export function readMiniAppAgentCreateRequest(value: unknown): MiniAppAgentCreateRequest | null { + if (!isPlainRecord(value) || !isOptionalNonEmptyString(value.agent)) return null; + return value.agent !== undefined ? { agent: value.agent } : {}; +} + +export function readMiniAppAgentCreateResponse(value: unknown): MiniAppAgentCreateResult | null { + return isPlainRecord(value) && isNonEmptyString(value.sessionId) + ? { sessionId: value.sessionId } + : null; +} + +/** Page payload for showing one of the plugin's own sessions in the host chat's side panel. */ +export interface MiniAppAgentOpenRequest { + readonly sessionId: string; +} + +/** Page `open` payload, checked in the preload and again in Main; null when malformed. */ +export function readMiniAppAgentOpenRequest(value: unknown): MiniAppAgentOpenRequest | null { + if (!value || typeof value !== 'object') return null; + const { sessionId } = value as Record; + return isNonEmptyString(sessionId) ? { sessionId } : null; +} + +/** What the Host UI is asked to show: a Mini App session as a tab in `hostSessionId`'s side panel. */ +export interface MiniAppAgentOpenSessionRequest { + readonly hostSessionId: string; + readonly sessionId: string; +} + +/** Copies a Main `open` request for the Host renderer; null when malformed. */ +export function readMiniAppAgentOpenSessionRequest( + value: unknown, +): MiniAppAgentOpenSessionRequest | null { + if (!value || typeof value !== 'object') return null; + const { hostSessionId, sessionId } = value as Record; + return isNonEmptyString(hostSessionId) && isNonEmptyString(sessionId) + ? { hostSessionId, sessionId } + : null; +} + +/** + * Main-to-Host-renderer request, sent after the runtime confirmed ownership. The Host + * preload answers every message with a `MiniAppAgentOpenSessionReply` carrying the same + * `requestId`, so Main learns whether the tab was actually shown (mirrors the composer + * request/reply channel). + */ +export interface MiniAppAgentOpenSessionMessage extends MiniAppAgentOpenSessionRequest { + readonly requestId: string; +} + +/** + * Host renderer's answer to an open request. `target_unavailable`: no Host UI handler is + * installed yet (startup, reload); `result_unknown`: the handler threw. + */ +export type MiniAppAgentOpenSessionResult = + | { readonly ok: true } + | { readonly ok: false; readonly code: 'target_unavailable' | 'result_unknown' }; + +export interface MiniAppAgentOpenSessionReply { + readonly requestId: string; + readonly result: MiniAppAgentOpenSessionResult; +} + +export function readMiniAppAgentOpenSessionReply( + value: unknown, +): MiniAppAgentOpenSessionReply | null { + if (!value || typeof value !== 'object') return null; + const { requestId, result } = value as Record; + if (!isNonEmptyString(requestId) || !result || typeof result !== 'object') return null; + const { ok, code } = result as Record; + if (ok === true) return { requestId, result: { ok: true } }; + if (ok === false && (code === 'target_unavailable' || code === 'result_unknown')) { + return { requestId, result: { ok: false, code } }; + } + return null; +} + +/** Host-renderer-only side-panel channel; never exposed to an embedded page. */ +export interface MiniAppAgentSessionsHostAPI { + /** + * The handler returns whether the tab is now shown; its result (or a throw) is reported + * back to Main, which reports it to the page's `agent.open` and, on success, to the + * runtime as "a panel can answer this session's asks". + */ + onOpenRequest( + handler: (request: MiniAppAgentOpenSessionRequest) => boolean | Promise, + ): () => void; + /** The user closed the tab; the runtime stops waiting on the panel for this session's asks. */ + notifyClosed(request: MiniAppAgentOpenSessionRequest): void; +} + +export interface MiniAppAgentRunResult { + readonly sessionId: string; + /** Absent when no turn ran (an accepted `answer` without a continuation, or an early cancel). */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + readonly status: 'finished' | 'error' | 'aborted'; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; continue with `answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: AskQuestionnaireMode; + readonly request: AskQuestionnaireRequest; + }; +} + +/** + * Progress of one run, projected by the Host from its own session stream. Every event but + * `run.queued` names its session so `reply` / `answer` work before the run that created the + * session returns. Consumers must ignore unknown `type` values so the vocabulary can grow + * additively. + */ +export type MiniAppAgentRunEvent = + /** + * The call waits for a run slot at 1-based `position`; sent once, before admission, so + * `sessionId` is present only when the call continues an existing session. + */ + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: AskQuestionnaireMode; + readonly request: AskQuestionnaireRequest; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: MiniAppAgentRunResult['status']; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export type MiniAppAgentRunResponse = + | { readonly ok: true; readonly result: MiniAppAgentRunResult } + | MiniAppAgentFailure; + +export type MiniAppAgentReplyResponse = { readonly ok: true } | MiniAppAgentFailure; + +export type MiniAppAgentOpenResponse = MiniAppAgentReplyResponse; + +const MINIAPP_AGENT_RUNTIME_ERROR_CODES: Readonly> = { + NOT_PERMITTED: 'not_permitted', + BUSY: 'busy', + SESSION_NOT_FOUND: 'session_not_found', + INVALID_ARGUMENTS: 'invalid_request', + AGENT_UNAVAILABLE: 'runtime_unavailable', + REQUEST_CANCELLED: 'cancelled', + AGENT_OUTCOME_UNKNOWN: 'result_unknown', + REQUEST_NOT_FOUND: 'request_not_found', +}; + +/** Maps a runtime `MiniAppAgentErrorCode`; unknown codes return undefined. */ +export function toMiniAppAgentFailureCode( + runtimeCode: unknown, +): MiniAppAgentFailureCode | undefined { + return typeof runtimeCode === 'string' && + Object.prototype.hasOwnProperty.call(MINIAPP_AGENT_RUNTIME_ERROR_CODES, runtimeCode) + ? MINIAPP_AGENT_RUNTIME_ERROR_CODES[runtimeCode] + : undefined; +} + +export function isMiniAppAgentFailureCode(value: unknown): value is MiniAppAgentFailureCode { + return MINIAPP_AGENT_FAILURE_CODES.some((code) => code === value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isOptionalNonEmptyString(value: unknown): value is string | undefined { + return value === undefined || isNonEmptyString(value); +} + +/** Allowlist copy of a run result; returns null when a required field is malformed. */ +export function readMiniAppAgentRunResult(value: unknown): MiniAppAgentRunResult | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const input = value as Record; + const { sessionId, turnId } = input; + if (!isNonEmptyString(sessionId) || !isOptionalNonEmptyString(turnId)) return null; + const outcome = readRunOutcome(input); + if (!outcome) return null; + // A malformed model is dropped rather than failing a turn that already ran. + const model = readOptionalModel(input.model); + const pending = input.pendingQuestion; + const pendingQuestion = + pending && typeof pending === 'object' + ? readPendingQuestion(pending as Record) + : null; + if (pending !== undefined && !pendingQuestion) return null; + return { + sessionId, + ...(turnId !== undefined ? { turnId } : {}), + ...(model ? { model } : {}), + ...outcome, + ...(pendingQuestion ? { pendingQuestion } : {}), + }; +} + +type MiniAppAgentPendingQuestion = NonNullable; + +/** Reads `requestId` / `mode` / `request` of a run result's or event's questionnaire. */ +function readPendingQuestion(input: Record): MiniAppAgentPendingQuestion | null { + const { requestId, mode, request } = input; + return isNonEmptyString(requestId) && + isMiniAppAgentQuestionMode(mode) && + isQuestionnaireRequest(request) + ? { requestId, mode, request } + : null; +} + +export function isMiniAppAgentQuestionMode(value: unknown): value is AskQuestionnaireMode { + return value === 'questionnaire' || value === 'feature-enable' || value === 'plan'; +} + +/** Envelope check only; the steps are passed through as the Host chat renders them. */ +function isQuestionnaireRequest(value: unknown): value is AskQuestionnaireRequest { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const request = value as Record; + return ( + request.schemaVersion === 2 && isNonEmptyString(request.id) && Array.isArray(request.steps) + ); +} + +function readRunOutcome( + input: Record, +): Pick | null { + if (input.status !== 'finished' && input.status !== 'error' && input.status !== 'aborted') { + return null; + } + if (input.text !== undefined && typeof input.text !== 'string') return null; + let error: MiniAppAgentRunResult['error']; + if (input.error !== undefined) { + if (!input.error || typeof input.error !== 'object') return null; + const { code, message } = input.error as Record; + if (typeof code !== 'string' || typeof message !== 'string') return null; + error = { code, message }; + } + return { + status: input.status, + ...(input.text !== undefined ? { text: input.text } : {}), + ...(error ? { error } : {}), + }; +} + +/** Allowlist copy of one run event; unknown `type`s and malformed events return null. */ +export function readMiniAppAgentRunEvent(value: unknown): MiniAppAgentRunEvent | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const input = value as Record; + if (input.type === 'run.queued') return readRunQueuedEvent(input); + const { sessionId } = input; + if (!isNonEmptyString(sessionId)) return null; + if (input.type === 'question.requested') return readQuestionRequestedEvent(sessionId, input); + if (input.type === 'permission.requested') { + return readPermissionRequestedEvent(sessionId, input); + } + const { turnId } = input; + if (!isNonEmptyString(turnId)) return null; + switch (input.type) { + case 'turn.started': { + const model = readMiniAppAgentModelSelection(input.model); + return model ? { type: 'turn.started', sessionId, turnId, model } : null; + } + case 'text.delta': + case 'message.committed': + return typeof input.text === 'string' + ? { type: input.type, sessionId, turnId, text: input.text } + : null; + case 'tool.started': + return typeof input.name === 'string' + ? { type: 'tool.started', sessionId, turnId, name: input.name } + : null; + case 'tool.finished': + return typeof input.name === 'string' && typeof input.ok === 'boolean' + ? { type: 'tool.finished', sessionId, turnId, name: input.name, ok: input.ok } + : null; + case 'turn.finished': { + const outcome = readRunOutcome(input); + return outcome ? { type: 'turn.finished', sessionId, turnId, ...outcome } : null; + } + default: + return null; + } +} + +function readRunQueuedEvent(input: Record): MiniAppAgentRunEvent | null { + const { sessionId, position } = input; + if (!isOptionalNonEmptyString(sessionId)) return null; + if (typeof position !== 'number' || !Number.isSafeInteger(position) || position < 1) return null; + return { type: 'run.queued', ...(sessionId !== undefined ? { sessionId } : {}), position }; +} + +function readPermissionRequestedEvent( + sessionId: string, + input: Record, +): MiniAppAgentRunEvent | null { + const { turnId, requestId, toolName, toolInput, reason } = input; + if ( + !isOptionalNonEmptyString(turnId) || + !isNonEmptyString(requestId) || + typeof toolName !== 'string' || + typeof reason !== 'string' || + (toolInput !== undefined && typeof toolInput !== 'string') + ) { + return null; + } + return { + type: 'permission.requested', + sessionId, + ...(turnId !== undefined ? { turnId } : {}), + requestId, + toolName, + ...(toolInput !== undefined ? { toolInput } : {}), + reason, + }; +} + +function readQuestionRequestedEvent( + sessionId: string, + input: Record, +): MiniAppAgentRunEvent | null { + const { turnId } = input; + if (!isOptionalNonEmptyString(turnId)) return null; + const question = readPendingQuestion(input); + if (!question) return null; + return { + type: 'question.requested', + sessionId, + ...(turnId !== undefined ? { turnId } : {}), + ...question, + }; +} + +/** + * Both IPC boundaries parse the same envelope. Anything unrecognised is `result_unknown`: the + * turn may already have run, so callers must not retry it automatically. + */ +export function parseMiniAppAgentRunResult(value: unknown): MiniAppAgentRunResponse { + if (value && typeof value === 'object') { + const response = value as Record; + if (response.ok === true) { + const result = readMiniAppAgentRunResult(response.result); + if (result) return { ok: true, result }; + } else if (response.ok === false) { + return readMiniAppAgentFailure(response); + } + } + return { ok: false, code: 'result_unknown' }; +} + +/** Same envelope rules as `parseMiniAppAgentRunResult`; a reply has no result payload. */ +export function parseMiniAppAgentReplyResult(value: unknown): MiniAppAgentReplyResponse { + if (value && typeof value === 'object') { + const response = value as Record; + if (response.ok === true) return { ok: true }; + if (response.ok === false) return readMiniAppAgentFailure(response); + } + return { ok: false, code: 'result_unknown' }; +} + +/** An unknown code is `result_unknown`; a malformed or non-`busy` `reason` is dropped. */ +function readMiniAppAgentFailure(response: Record): MiniAppAgentFailure { + const { code, reason } = response; + if (!isMiniAppAgentFailureCode(code)) return { ok: false, code: 'result_unknown' }; + return code === 'busy' && isMiniAppAgentBusyReason(reason) + ? { ok: false, code, reason } + : { ok: false, code }; +} + +export type MiniAppAgentModelsResponse = + | { readonly ok: true; readonly result: MiniAppAgentModels } + | MiniAppAgentFailure; +export type MiniAppAgentAgentsResponse = + | { readonly ok: true; readonly result: MiniAppAgentAgents } + | MiniAppAgentFailure; +export type MiniAppAgentCreateResponse = + | { readonly ok: true; readonly result: MiniAppAgentCreateResult } + | MiniAppAgentFailure; + +function parseMiniAppAgentResultEnvelope( + value: unknown, + read: (result: unknown) => T | null, +): { readonly ok: true; readonly result: T } | MiniAppAgentFailure { + if (value && typeof value === 'object') { + const response = value as Record; + if (response.ok === true) { + const result = read(response.result); + if (result) return { ok: true, result }; + } else if (response.ok === false) { + return readMiniAppAgentFailure(response); + } + } + return { ok: false, code: 'result_unknown' }; +} + +/** Envelope of the `models` / `agents` / `create` invokes; malformed is `result_unknown`. */ +export function parseMiniAppAgentModelsResult(value: unknown): MiniAppAgentModelsResponse { + return parseMiniAppAgentResultEnvelope(value, readMiniAppAgentModels); +} + +export function parseMiniAppAgentAgentsResult(value: unknown): MiniAppAgentAgentsResponse { + return parseMiniAppAgentResultEnvelope(value, readMiniAppAgentAgents); +} + +export function parseMiniAppAgentCreateResult(value: unknown): MiniAppAgentCreateResponse { + return parseMiniAppAgentResultEnvelope(value, readMiniAppAgentCreateResponse); +} + +/** Same envelope as a reply acknowledgement. */ +export function parseMiniAppAgentOpenResult(value: unknown): MiniAppAgentOpenResponse { + return parseMiniAppAgentReplyResult(value); +} + +/** + * Hoisted out of the omitted window block, which declares this guard between its + * own rules; the agent parsers above need it. Nothing else from that block is here. + */ +function isPlainRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} 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 6cc1041e6..943d1b21e 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 @@ -85,7 +85,7 @@ hold two credentials instead of one. ## The contract baseline -The Mini App contract in this repository is one generation behind the Mini App implementation +The Mini App contract in this repository was one generation behind the Mini App implementation shipped in the desktop client: 54 lines here against 1267 there. This record takes the shipped generation, minus the window block. @@ -93,12 +93,15 @@ The mechanics are cheap, and the borrow is auditable rather than a blanket impor The shipped file's only import is `./questionnaire.js`, and this repository's copy of that file is byte-identical, so the borrowing is a content edit to one existing file: no new path, no workspace dependency, no inventory regeneration. -`diff` also shows that the first 54 lines of the shipped file reproduce this repository's file -exactly, so the newer generation is a strict superset from line 7 onward, and the remainder arrives -as one contiguous block rather than as scattered patches. Nothing here has a consumer for the -response types whose shape changes — `MiniAppSurfaceTabSummary`, `MiniAppListResponse`, -`MiniAppOpenResponse` and `MiniAppControlResponse` are declared and unused — so adding required -fields and error codes breaks no call site. +`diff` shows the newer generation to be a strict superset of this repository's file from line 7 +onward, and it is taken as one contiguous copy of the shipped leading part rather than as scattered +patches — but that part is not line-for-line identical to today's 54 lines. The shipped file opens +with a six-line type-only import, and it adds `detached` and `presentationRevision` to the tab +summary and `detach_failed` and `attach_failed` to the error codes, which moves +`MiniAppOpenResponse` and `MiniAppControlResponse` below the new start and page-open types. Nothing +here has a consumer for the response types whose shape changes — `MiniAppSurfaceTabSummary`, +`MiniAppListResponse`, `MiniAppOpenResponse` and `MiniAppControlResponse` are declared and unused — +so adding required fields and error codes breaks no call site. What the borrowing brings: @@ -119,7 +122,10 @@ What the borrowing brings: What the borrowing leaves behind is the window block: the argv surface id, the `surface.window` manifest whitelist, and the page's `getState`/`update`/`focus` handle. It is the last contiguous region of the file, so a future synchronization reads as one omitted tail rather than as a series of -local divergences. +local divergences. One private helper has to come out of that tail: `isPlainRecord` is declared +inside the window block but used nine times by the agent parsers above it, so it is kept at the seam +between the copied part and the omission, and it is the one local divergence to reconcile against a +future copy. Two consequences follow for the layers around the contract. The manifest reader must accept the three keys the newer generation declares and this repository's reader does not know: `surface.window`, diff --git a/release/public-source.json b/release/public-source.json index 7f3983478..49a4eab2c 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1171,6 +1171,7 @@ "packages/local-runtime-v2/src/service/plugin-system/plugin/package/hook/reader.ts", "packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/path.ts", "packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/reader.ts", + "packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.test.ts", "packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts", "packages/local-runtime-v2/src/service/plugin-system/plugin/package/minimax-reader.ts", "packages/local-runtime-v2/src/service/plugin-system/plugin/package/package-contract.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index f75003d21..cc6388ff4 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -42,6 +42,7 @@ "packages/agent-modules/permission/test/unit/permission/cloud-classify-client.test.ts", "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/src/service/plugin-system/plugin/package/miniapp/validation.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 b234fc2e96f582b2632bb0e779be924a7f7b2fb9 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 14:24:30 +0800 Subject: [PATCH 03/43] test(webui): move the Mini App reader suite out of src Unit suites for this package live under packages/local-runtime-v2/test, not beside the module they exercise, so the reader suite goes to test/unit/plugin-system/. Registered at its new path in test/vitest-suites.json and the source inventory is regenerated for the rename. Verified at the new path: pnpm typecheck; pnpm run test:capabilities (235 files, 5811 passed, 18 skipped); pnpm check:source. --- .../unit/plugin-system/miniapp-manifest.test.ts} | 2 +- release/public-source.json | 2 +- test/vitest-suites.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename packages/local-runtime-v2/{src/service/plugin-system/plugin/package/miniapp/validation.test.ts => test/unit/plugin-system/miniapp-manifest.test.ts} (97%) diff --git a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.test.ts b/packages/local-runtime-v2/test/unit/plugin-system/miniapp-manifest.test.ts similarity index 97% rename from packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.test.ts rename to packages/local-runtime-v2/test/unit/plugin-system/miniapp-manifest.test.ts index 1af25d280..9b94cdb96 100644 --- a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.test.ts +++ b/packages/local-runtime-v2/test/unit/plugin-system/miniapp-manifest.test.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { readOptionalMiniAppContribution } from './reader.js'; +import { readOptionalMiniAppContribution } from '../../../src/service/plugin-system/plugin/package/miniapp/reader.js'; const roots: string[] = []; diff --git a/release/public-source.json b/release/public-source.json index 49a4eab2c..33ae078de 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1171,7 +1171,6 @@ "packages/local-runtime-v2/src/service/plugin-system/plugin/package/hook/reader.ts", "packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/path.ts", "packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/reader.ts", - "packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.test.ts", "packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts", "packages/local-runtime-v2/src/service/plugin-system/plugin/package/minimax-reader.ts", "packages/local-runtime-v2/src/service/plugin-system/plugin/package/package-contract.ts", @@ -1662,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/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", "packages/local-runtime/assets/skills/code-review/SKILL.md", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index cc6388ff4..d199a76d1 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -42,7 +42,7 @@ "packages/agent-modules/permission/test/unit/permission/cloud-classify-client.test.ts", "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/src/service/plugin-system/plugin/package/miniapp/validation.test.ts", + "packages/local-runtime-v2/test/unit/plugin-system/miniapp-manifest.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 79e92a5a90a4ddc90ecc899c1f088e3077278228 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 16:32:19 +0800 Subject: [PATCH 04/43] fix(webui): name the new manifest rejections and pin their messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from the independent review of #261. The two reader rejection codes this branch introduced were unmapped in the failure diagnostic layer: `PREPARATION_REASON_BY_HOST_CODE` carries every other MINIAPP_* reader code, so a package rejected for `agentAccess` or `agentConcurrency` fell through to `INTERNAL_ERROR` with no reason code and no hint — a worse answer than the `WORKSPACE_PLUGIN_INVALID` it got before the branch started accepting those keys. Both now map to `MINIAPP_MANIFEST_INVALID`, the reason the other manifest rejections use, so the catalog entry that already exists supplies the message. Two test gaps the review found are closed: `agentConcurrency` without `agentAccess` is asserted accepted (1, 4 and 32, the documented no-effect case and both bounds), and the changed surface message is pinned rather than left to drift. Two ADR sentences were wrong about facts: the omitted tail declares the window state and patch contracts, not a page handle — `getState`/`update`/`focus` live in the preload and appear nowhere in the reference file — and the agent fields are validated and preserved here, not enforced. Verified on this branch: pnpm typecheck; pnpm run test:capabilities (5814 passed, 18 skipped); pnpm check:source. --- .../src/application/session/miniapp-actions.ts | 2 ++ .../unit/plugin-system/miniapp-manifest.test.ts | 16 +++++++++++++++- .../0020-webui-miniapp-view-and-runtime-seams.md | 9 ++++++--- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/local-runtime-v2/src/application/session/miniapp-actions.ts b/packages/local-runtime-v2/src/application/session/miniapp-actions.ts index 8073a2315..04939a9bd 100644 --- a/packages/local-runtime-v2/src/application/session/miniapp-actions.ts +++ b/packages/local-runtime-v2/src/application/session/miniapp-actions.ts @@ -444,6 +444,8 @@ const PREPARATION_REASON_BY_HOST_CODE: Readonly { }, ); + it.each([[1], [32], [4]])( + 'accepts agentConcurrency %j without agentAccess, where it has no effect', + async (agentConcurrency) => { + const contribution = await readManifest({ agentConcurrency }); + + expect(contribution?.agentConcurrency).toBe(agentConcurrency); + expect(contribution).not.toHaveProperty('agentAccess'); + }, + ); + it.each([[0], [33], [4.5], ['4'], [null]])( 'fails closed for agentConcurrency %j', async (agentConcurrency) => { @@ -110,6 +120,10 @@ describe('Mini App manifest allowlist', () => { it('fails an unknown key beside surface.window', async () => { await expect( readManifest({ surface: { path: '/dashboard', window: {}, surfaceId: 'app' } }), - ).rejects.toMatchObject({ code: 'MINIAPP_SURFACE_INVALID' }); + ).rejects.toMatchObject({ + // The message is part of the behaviour: a relaxed predicate must not read as a pass. + code: 'MINIAPP_SURFACE_INVALID', + message: 'MINIAPP_SURFACE_INVALID: surface must contain only path and window', + }); }); }); 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 943d1b21e..37d63dd79 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 @@ -120,7 +120,10 @@ What the borrowing brings: run on both sides of the boundary. What the borrowing leaves behind is the window block: the argv surface id, the `surface.window` -manifest whitelist, and the page's `getState`/`update`/`focus` handle. It is the last contiguous +manifest whitelist, and the window state and patch contracts that the page's +`window.mcode.miniapp.window` handle (`getState`, `update`, `focus`, defined in the preload rather than +here) is built on — `MINIAPP_SURFACE_ID_ARGUMENT_PREFIX`, `MINIAPP_SURFACE_WINDOW_*`, +`MiniAppWindowState`, `MiniAppWindowPatch` and their parsers. It is the last contiguous region of the file, so a future synchronization reads as one omitted tail rather than as a series of local divergences. One private helper has to come out of that tail: `isPlainRecord` is declared inside the window block but used nine times by the agent parsers above it, so it is kept at the seam @@ -132,8 +135,8 @@ three keys the newer generation declares and this repository's reader does not k `agentAccess` and `agentConcurrency`. Its field sets are strict, so an unknown key fails the package as `MANIFEST_SCHEMA_INVALID` — a package built for the shipped client is rejected before any of this work can matter. Accepting is not supporting: `surface.window` is read and ignored here, because its -effect was just dropped from the contract, while the two agent fields are enforced, because the agent -capability is in scope below. +effect was just dropped from the contract, while the two agent fields are validated and preserved here +so the agent service can enforce them when it lands. ## The credential guard is carried over From e55e935da12f382a8d77aa3cf5715f38653833a3 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 14:36:17 +0800 Subject: [PATCH 05/43] 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 06/43] 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 07/43] 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 08/43] 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", From 28b5eb8c8756b9af4b762bdb392c52bcdba85fb3 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 14:56:09 +0800 Subject: [PATCH 09/43] 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 10/43] 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 11/43] 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 }); + } + }); +}); From b3b0981292d9af55f0d42ee005eabebbe33df225 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:29:29 +0800 Subject: [PATCH 12/43] feat(webui): serve a Mini App page through its own loopback proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of #255. A Mini App page refers to its own origin by absolute path — the scaffold serves `/dashboard`, and a real page also fetches `/api/...` and its assets — so the proxy forwards paths unchanged rather than hiding them behind a prefix that would have to be patched back by intercepting every fetch and absolute URL in the document. `startMiniAppProxy` puts one listener on loopback per running Mini App, on a port derived per plugin. That stability is required, not cosmetic: the page's `localStorage` and `IndexedDB` are keyed by its origin, so a port that moved between runs would silently discard the page's own state. Requests are forwarded with the Mini App's credential injected, and the credential never appears in a response. Upgrades are forwarded under the same rule, and a closed peer tears down its pair — a half-closed pipe would hold the upstream server open forever, which is how this showed up in the test teardown. Server-only by placement: the proxy takes the upstream origin and the credential as input and returns `viewUrl`, which is what a client frames. The client never assembles a Mini App origin. It lives in `packages/webui/src/server/` because ADR 0020 puts the proxy in the WebUI service and keeps everything that touches a real origin on the server side of the facade; the server layer may import `shared` and `runtime-port` and nothing else. Proven against a real upstream server: the path is forwarded unchanged, the response body reaches the caller, the credential is injected outbound and absent from the response, an upgrade is forwarded with the credential, and the derived port is stable per plugin and inside the loopback range. Still to do for #255: the document rewrite that injects the page bridge (the bridge itself is #260), the per-session and per-presentation view URL, and wiring the proxy into the open operation so it starts and stops with the Mini App. Verified: pnpm typecheck:webui (server, client and test); the focused suite (4 cases); pnpm check:source after the inventory write. pnpm check:webui-dependency still fails on a pre-existing violation in WorkspacePanels.tsx — `document.fonts.ready` in a bindings file, present at the base commit 9713c4cf and untouched by this branch; the new module adds none. --- packages/webui/src/server/miniapp/proxy.ts | 149 ++++++++++++++++++ .../webui/test/unit/miniapp-proxy.test.ts | 144 +++++++++++++++++ release/public-source.json | 2 + test/vitest-suites.json | 1 + 4 files changed, 296 insertions(+) create mode 100644 packages/webui/src/server/miniapp/proxy.ts create mode 100644 packages/webui/test/unit/miniapp-proxy.test.ts diff --git a/packages/webui/src/server/miniapp/proxy.ts b/packages/webui/src/server/miniapp/proxy.ts new file mode 100644 index 000000000..dec8d4235 --- /dev/null +++ b/packages/webui/src/server/miniapp/proxy.ts @@ -0,0 +1,149 @@ +import { createHash } from 'node:crypto'; +import http from 'node:http'; +import net from 'node:net'; + +/** + * The Mini App's real origin and the credential its own guard demands. + * + * Server-only by construction: it arrives from the runtime port capability and never travels further + * than this module. The client is given `viewUrl`, which points at this proxy instead. + */ +export interface MiniAppProxyTarget { + readonly pluginId: string; + readonly upstreamOrigin: string; + readonly credential: string; + /** Request header the upstream guard reads the credential from. */ + readonly credentialHeader: string; +} + +export interface MiniAppProxy { + /** What the client frames. Never the Mini App's own origin. */ + readonly viewUrl: string; + readonly port: number; + close(): Promise; +} + +const PROXY_PORT_MIN = 49_152; +const PROXY_PORT_MAX = 65_535; +const PROXY_PORT_RANGE = PROXY_PORT_MAX - PROXY_PORT_MIN + 1; + +/** + * A Mini App's proxy port is stable per plugin, for the same reason the runtime's own is: the page's + * `localStorage` and `IndexedDB` are keyed by its origin, so a port that moved between runs would + * silently discard the page's own state. + */ +export function deriveMiniAppProxyPort(pluginId: string): number { + const digest = createHash('sha256').update(`miniapp-proxy:${pluginId}`).digest(); + return PROXY_PORT_MIN + (digest.readUInt32BE(0) % PROXY_PORT_RANGE); +} + +export interface StartMiniAppProxyInput { + readonly target: MiniAppProxyTarget; + /** Defaults to the derived port; a test or a retry may pass the next candidate. */ + readonly port?: number; +} + +/** + * One loopback listener per running Mini App. + * + * A page refers to its own origin by absolute path (`/dashboard`, `/api/...`, its assets), so the proxy + * forwards paths unchanged rather than rewriting them behind a prefix: patching them back would mean + * intercepting every `fetch`, `XMLHttpRequest` and absolute URL in the document. The credential is + * injected on the way out and never reaches a response, and WebSocket upgrades are forwarded under the + * same rules. + */ +export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise { + const upstream = new URL(input.target.upstreamOrigin); + if (upstream.protocol !== 'http:') { + throw new TypeError('A Mini App proxy forwards to an http origin on loopback'); + } + const port = input.port ?? deriveMiniAppProxyPort(input.target.pluginId); + // Anything this listener owns that can be torn down: inbound sockets, forwarded requests, upgrade pairs. + const sockets = new Set<{ destroy(): void }>(); + + const server = http.createServer((request, response) => { + const forwarded = http.request( + { + host: upstream.hostname, + port: upstream.port, + method: request.method, + path: request.url, + headers: { + ...request.headers, + [input.target.credentialHeader]: input.target.credential, + }, + agent: false, + }, + (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }, + ); + forwarded.on('error', () => { + if (!response.headersSent) { + response.writeHead(502, { 'content-type': 'text/plain' }); + } + response.end(); + }); + request.pipe(forwarded); + sockets.add(forwarded); + forwarded.once('close', () => sockets.delete(forwarded)); + }); + + // Every socket this listener owns, so closing it cannot wait on a client that never said goodbye. + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + + server.on('upgrade', (request, clientSocket, head) => { + const upstreamSocket = net.connect( + { host: upstream.hostname, port: Number(upstream.port || 80) }, + () => { + const headers = Object.entries(request.headers) + .filter(([name, value]) => value !== undefined) + .map(([name, value]) => `${name}: ${Array.isArray(value) ? value.join(', ') : value}`); + headers.push(`${input.target.credentialHeader}: ${input.target.credential}`); + upstreamSocket.write( + `${request.method} ${request.url} HTTP/1.1\r\n${headers.join('\r\n')}\r\n\r\n`, + ); + if (head.length > 0) upstreamSocket.write(head); + upstreamSocket.pipe(clientSocket); + clientSocket.pipe(upstreamSocket); + }, + ); + sockets.add(clientSocket); + sockets.add(upstreamSocket); + // A half-closed pair would leak the other end and keep the upstream server open forever. + const teardown = () => { + clientSocket.destroy(); + upstreamSocket.destroy(); + sockets.delete(clientSocket); + sockets.delete(upstreamSocket); + }; + clientSocket.once('close', teardown); + upstreamSocket.once('close', teardown); + upstreamSocket.once('error', teardown); + clientSocket.once('error', teardown); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + const address = server.address(); + const boundPort = typeof address === 'object' && address ? address.port : port; + + return { + port: boundPort, + viewUrl: `http://127.0.0.1:${boundPort}`, + async close() { + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} diff --git a/packages/webui/test/unit/miniapp-proxy.test.ts b/packages/webui/test/unit/miniapp-proxy.test.ts new file mode 100644 index 000000000..2a7453d2f --- /dev/null +++ b/packages/webui/test/unit/miniapp-proxy.test.ts @@ -0,0 +1,144 @@ +import http from 'node:http'; +import net from 'node:net'; +import type { Duplex } from 'node:stream'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + deriveMiniAppProxyPort, + startMiniAppProxy, + type MiniAppProxy, +} from '../../src/server/miniapp/proxy.js'; + +const CREDENTIAL = 'upstream-credential-0123456789'; +const CREDENTIAL_HEADER = 'x-mavis-miniapp-token'; + +interface Seen { + readonly method?: string; + readonly url?: string; + readonly headers: http.IncomingHttpHeaders; +} + +const seen: Seen[] = []; +const upgraded = new Set(); +let upstream: http.Server; +let upstreamPort = 0; +let proxy: MiniAppProxy; + +beforeAll(async () => { + upstream = http.createServer((request, response) => { + seen.push({ method: request.method, url: request.url, headers: { ...request.headers } }); + if (request.url === '/echo') { + response.writeHead(200, { 'content-type': 'text/plain' }).end('from-upstream'); + return; + } + response.writeHead(404).end(); + }); + upstream.on('upgrade', (request, socket) => { + upgraded.add(socket); + socket.once('close', () => upgraded.delete(socket)); + seen.push({ method: request.method, url: request.url, headers: { ...request.headers } }); + socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n'); + }); + await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve)); + const address = upstream.address(); + upstreamPort = typeof address === 'object' && address ? address.port : 0; + + proxy = await startMiniAppProxy({ + target: { + pluginId: 'fixture.miniapp', + upstreamOrigin: `http://127.0.0.1:${upstreamPort}`, + credential: CREDENTIAL, + credentialHeader: CREDENTIAL_HEADER, + }, + port: 0, + }); +}); + +afterAll(async () => { + await proxy?.close(); + // An upgraded socket leaves the server's own tracking, so the fixture ends it explicitly. + for (const socket of upgraded) socket.destroy(); + upgraded.clear(); + upstream?.closeAllConnections(); + await new Promise((resolve) => upstream?.close(() => resolve())); +}); + +interface Result { + readonly status: number; + readonly body: string; + readonly headers: http.IncomingHttpHeaders; +} + +function get(port: number, path: string): Promise { + return new Promise((resolve, reject) => { + const request = http.request( + { host: '127.0.0.1', port, method: 'GET', path, agent: false }, + (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + body += chunk; + }); + response.on('end', () => + resolve({ status: response.statusCode ?? 0, body, headers: response.headers }), + ); + }, + ); + request.on('error', reject); + request.end(); + }); +} + +describe('Mini App proxy', () => { + it('forwards the path unchanged and returns the upstream response', async () => { + const result = await get(proxy.port, '/echo'); + + expect(result.status).toBe(200); + expect(result.body).toBe('from-upstream'); + expect(seen.at(-1)?.url).toBe('/echo'); + expect(seen.at(-1)?.method).toBe('GET'); + }); + + it('injects the credential outbound and never returns it to the caller', async () => { + const result = await get(proxy.port, '/echo'); + + expect(seen.at(-1)?.headers[CREDENTIAL_HEADER]).toBe(CREDENTIAL); + expect(result.headers).not.toHaveProperty(CREDENTIAL_HEADER); + }); + + it('forwards an upgrade with the credential', async () => { + const response = await new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port: proxy.port }); + let received = ''; + socket.once('connect', () => { + socket.write( + `GET /socket HTTP/1.1\r\nHost: 127.0.0.1:${proxy.port}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`, + ); + }); + socket.on('data', (chunk) => { + received += chunk.toString('utf8'); + socket.destroy(); + resolve(received); + }); + socket.once('error', reject); + socket.setTimeout(2_000, () => { + socket.destroy(); + reject(new Error('upgrade timed out')); + }); + }); + + expect(response).toContain('101'); + expect(seen.at(-1)?.url).toBe('/socket'); + expect(seen.at(-1)?.headers[CREDENTIAL_HEADER]).toBe(CREDENTIAL); + }); + + it('derives a stable loopback port per plugin', () => { + const first = deriveMiniAppProxyPort('fixture.miniapp'); + + expect(first).toBe(deriveMiniAppProxyPort('fixture.miniapp')); + expect(first).toBeGreaterThanOrEqual(49_152); + expect(first).toBeLessThanOrEqual(65_535); + expect(deriveMiniAppProxyPort('another.miniapp')).not.toBe(first); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index e568cd8dd..d78989a43 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -4137,6 +4137,7 @@ "packages/webui/src/server/http/session-transfer.ts", "packages/webui/src/server/http/workspace-file.ts", "packages/webui/src/server/index.ts", + "packages/webui/src/server/miniapp/proxy.ts", "packages/webui/src/server/operation/account.ts", "packages/webui/src/server/operation/agent-memory.ts", "packages/webui/src/server/operation/bind-handlers.ts", @@ -4342,6 +4343,7 @@ "packages/webui/test/unit/markdown-mermaid.test.tsx", "packages/webui/test/unit/message-attachments.test.tsx", "packages/webui/test/unit/message-parts.test.ts", + "packages/webui/test/unit/miniapp-proxy.test.ts", "packages/webui/test/unit/model-effort-scale.test.ts", "packages/webui/test/unit/model-picker-effort-options.test.ts", "packages/webui/test/unit/model-picker-groups.test.tsx", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 2e856734f..8bb86b5d9 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -472,6 +472,7 @@ "packages/webui/test/unit/browser-bridge-cdp.test.ts", "packages/webui/test/unit/browser-bridge-adversarial.test.ts", "packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts", + "packages/webui/test/unit/miniapp-proxy.test.ts", "packages/webui/test/unit/workspace-write-fence.test.ts" ] } From 5ca1c6ec864c929208ef891d2a544a6b93f2c3d1 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:32:51 +0800 Subject: [PATCH 13/43] feat(webui): make the proxied Mini App document framable and carry the bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of #255. The page is framed by this client on a different origin, so a page must not be able to refuse being framed. The proxy drops `X-Frame-Options` from every response it forwards and removes the `frame-ancestors` directive from a page's own CSP, leaving its other directives alone — the Mini App's origin is separate from the client's, so the rest of its policy governs a document that cannot reach the client anyway. Nothing is added: no header the page did not ask for, no CSP of the proxy's own. The document is the one response the proxy rewrites. The page bridge has to be in the document before the page's own scripts run, so the proxy injects a script tag for it before ``, after the opening `` tag when a page has no head, or ahead of the document when it has neither; the body it sends is not the body it received, so it restates `Content-Length` and drops the upstream's `Transfer-Encoding` with it. The bridge script is served by the proxy itself at its own path and never forwarded upstream, which keeps the seam in one place: this change owns which document, where the tag goes and what headers survive, while the bridge body arrives with #260. ADR 0020's open question about framing is settled by these rules and removed from the open list; the bridge's method-level error mapping is the only one left. Proven: the bridge is injected before ``, before `` on a headless document, `X-Frame-Options` is gone, `frame-ancestors` is gone while `default-src` and `script-src` survive, the bridge path is served locally without reaching the upstream, and paths and bodies of ordinary responses are untouched. Verified: pnpm typecheck:webui (server, client and test); the focused suite (8 cases); pnpm check:source after the inventory write. pnpm check:webui-dependency still fails on the pre-existing WorkspacePanels.tsx violation described in the previous commit. --- ...20-webui-miniapp-view-and-runtime-seams.md | 20 +++-- packages/webui/src/server/miniapp/proxy.ts | 86 ++++++++++++++++++- .../webui/test/unit/miniapp-proxy.test.ts | 48 +++++++++++ 3 files changed, 147 insertions(+), 7 deletions(-) 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 0295e0997..e1ef25f9d 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 @@ -435,13 +435,23 @@ above. The plugin catalogue's apps area rides with that change rather than after that shows what is installed and what is running, so without it the view has nothing to be opened from. The agent capability follows the view: it is the only part that can be reviewed against a working page, since a plugin's agent calls are only observable from a page this client can display. +### The proxy makes the document framable + +The page is framed by this client, on a different origin, so a page must not be able to refuse. The proxy +therefore drops `X-Frame-Options` from every response it forwards and removes the `frame-ancestors` +directive from a page's own CSP while leaving its other directives alone — the Mini App's origin is a +separate origin from the client's, so the rest of its policy governs a document that cannot reach the +client anyway. Nothing is added: no header the page did not ask for, no CSP of the proxy's own. + +The document is the one response the proxy rewrites. The page bridge has to be in the document before the +page's own scripts run, so the proxy injects a script tag for it before ``, or after the opening +`` tag when a page has no head, or ahead of the document when it has neither, and restates the +`Content-Length` because the body it sends is not the body it received. Everything else — paths, methods, +status codes, the page's own headers — passes through unchanged. + ## Open implementation questions -1. **Framing the page through the proxy.** One listener per Mini App is decided above; what remains is - which response headers the proxy must add or drop for the document to be framable at all, and what - it does with a Mini App that sets framing or CSP headers of its own. The desktop client offers no - precedent: it never frames the page and writes no response headers of its own. -2. **The bridge's method-level error mapping.** Which failure code each bridge call answers when the +1. **The bridge's method-level error mapping.** Which failure code each bridge call answers when the host cannot serve it — an unsupported capability, a full queue, a runtime that stopped between two calls — and how a page's request identifier survives the `postMessage` boundary. diff --git a/packages/webui/src/server/miniapp/proxy.ts b/packages/webui/src/server/miniapp/proxy.ts index dec8d4235..6056eda63 100644 --- a/packages/webui/src/server/miniapp/proxy.ts +++ b/packages/webui/src/server/miniapp/proxy.ts @@ -37,12 +37,28 @@ export function deriveMiniAppProxyPort(pluginId: string): number { return PROXY_PORT_MIN + (digest.readUInt32BE(0) % PROXY_PORT_RANGE); } +/** + * The page bridge the proxy injects into the Mini App's document. + * + * The proxy owns the seam — which document, where the tag goes, what headers survive — while the script + * itself belongs to the bridge, which knows what `window.mcode.miniapp` exposes. + */ +export interface MiniAppPageBridge { + /** Absolute path on the proxy origin that serves the script, e.g. `/__miniapp/bridge.js`. */ + readonly path: string; + readonly script: string; +} + export interface StartMiniAppProxyInput { readonly target: MiniAppProxyTarget; /** Defaults to the derived port; a test or a retry may pass the next candidate. */ readonly port?: number; + readonly bridge?: MiniAppPageBridge; } +const FRAME_BLOCKING_HEADERS = ['x-frame-options'] as const; +const CSP_HEADER = 'content-security-policy'; + /** * One loopback listener per running Mini App. * @@ -62,6 +78,12 @@ export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise< const sockets = new Set<{ destroy(): void }>(); const server = http.createServer((request, response) => { + if (input.bridge && request.url === input.bridge.path) { + response + .writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' }) + .end(input.bridge.script); + return; + } const forwarded = http.request( { host: upstream.hostname, @@ -75,8 +97,28 @@ export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise< agent: false, }, (upstreamResponse) => { - response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); - upstreamResponse.pipe(response); + const headers = frameableHeaders(upstreamResponse.headers); + if (!isHtml(upstreamResponse.headers) || !input.bridge) { + response.writeHead(upstreamResponse.statusCode ?? 502, headers); + upstreamResponse.pipe(response); + return; + } + // The document is the one response this proxy rewrites: the page bridge has to be in it before + // the page's own scripts run, and a page cannot be allowed to decide it will not be framed. + const chunks: Buffer[] = []; + upstreamResponse.on('data', (chunk: Buffer) => chunks.push(chunk)); + upstreamResponse.on('end', () => { + const body = Buffer.from( + injectBridge(Buffer.concat(chunks).toString('utf8'), input.bridge as MiniAppPageBridge), + 'utf8', + ); + // The body length changed, so the upstream framing no longer applies. + delete headers['transfer-encoding']; + headers['content-length'] = String(body.byteLength); + response.writeHead(upstreamResponse.statusCode ?? 502, headers); + response.end(body); + }); + upstreamResponse.on('error', () => response.destroy()); }, ); forwarded.on('error', () => { @@ -147,3 +189,43 @@ export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise< }, }; } + +function isHtml(headers: http.IncomingHttpHeaders): boolean { + const contentType = headers['content-type']; + return typeof contentType === 'string' && contentType.toLowerCase().includes('text/html'); +} + +/** + * The page is framed by the WebUI client, on a different origin, so a page that asks not to be framed + * must not be able to refuse: `X-Frame-Options` is dropped and a `frame-ancestors` directive is removed + * from the page's own CSP. Everything else the page declares is left alone — the Mini App's origin is a + * separate origin from the client's, so its CSP governs a document that cannot reach the client anyway. + */ +function frameableHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders { + const result: http.OutgoingHttpHeaders = {}; + for (const [name, value] of Object.entries(headers)) { + if (FRAME_BLOCKING_HEADERS.includes(name.toLowerCase() as 'x-frame-options')) continue; + if (name.toLowerCase() === CSP_HEADER && typeof value === 'string') { + const directives = value + .split(';') + .map((directive) => directive.trim()) + .filter((directive) => directive.length > 0 && !directive.toLowerCase().startsWith('frame-ancestors')); + result[name] = directives.join('; '); + continue; + } + result[name] = value; + } + return result; +} + +function injectBridge(document: string, bridge: MiniAppPageBridge): string { + const tag = ``; + const headEnd = document.search(/<\/head\s*>/iu); + if (headEnd >= 0) return `${document.slice(0, headEnd)}${tag}${document.slice(headEnd)}`; + const bodyStart = document.search(/]*>/iu); + if (bodyStart >= 0) { + const insertAt = document.indexOf('>', bodyStart) + 1; + return `${document.slice(0, insertAt)}${tag}${document.slice(insertAt)}`; + } + return `${tag}${document}`; +} diff --git a/packages/webui/test/unit/miniapp-proxy.test.ts b/packages/webui/test/unit/miniapp-proxy.test.ts index 2a7453d2f..4abbe7166 100644 --- a/packages/webui/test/unit/miniapp-proxy.test.ts +++ b/packages/webui/test/unit/miniapp-proxy.test.ts @@ -28,6 +28,20 @@ let proxy: MiniAppProxy; beforeAll(async () => { upstream = http.createServer((request, response) => { seen.push({ method: request.method, url: request.url, headers: { ...request.headers } }); + if (request.url === '/page') { + response + .writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'x-frame-options': 'DENY', + 'content-security-policy': "default-src 'self'; frame-ancestors 'none'; script-src 'self'", + }) + .end('apppage'); + return; + } + if (request.url === '/bare') { + response.writeHead(200, { 'content-type': 'text/html' }).end('

no head here

'); + return; + } if (request.url === '/echo') { response.writeHead(200, { 'content-type': 'text/plain' }).end('from-upstream'); return; @@ -52,6 +66,7 @@ beforeAll(async () => { credentialHeader: CREDENTIAL_HEADER, }, port: 0, + bridge: { path: '/__miniapp/bridge.js', script: 'globalThis.__bridge = true;' }, }); }); @@ -142,3 +157,36 @@ describe('Mini App proxy', () => { expect(deriveMiniAppProxyPort('another.miniapp')).not.toBe(first); }); }); + +describe('Mini App proxy document rewrite', () => { + it('injects the bridge before the document head ends', async () => { + const result = await get(proxy.port, '/page'); + + expect(result.status).toBe(200); + expect(result.body).toContain(''); + expect(result.body).toContain('app'); + }); + + it('makes the document framable: drops X-Frame-Options and the frame-ancestors directive', async () => { + const result = await get(proxy.port, '/page'); + + expect(result.headers).not.toHaveProperty('x-frame-options'); + expect(result.headers['content-security-policy']).toBe("default-src 'self'; script-src 'self'"); + }); + + it('injects before the body when the document has no head', async () => { + const result = await get(proxy.port, '/bare'); + + expect(result.body).toBe('

no head here

'); + }); + + it('serves the bridge itself and never forwards that path upstream', async () => { + const before = seen.length; + const result = await get(proxy.port, '/__miniapp/bridge.js'); + + expect(result.status).toBe(200); + expect(result.body).toBe('globalThis.__bridge = true;'); + expect(result.headers['content-type']).toContain('text/javascript'); + expect(seen.length).toBe(before); + }); +}); From 44af7f51bc6a6c88ec89fb39644625a6554951e9 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:34:40 +0800 Subject: [PATCH 14/43] test(webui): keep a Mini App's view URL stable across a proxy restart Closes #255's stability criterion with evidence. The page's `localStorage` and `IndexedDB` are keyed by its origin, so a port that moved between runs would silently discard the page's own state. The derived port is a pure function of the plugin id, and this asserts the property end to end: start on the derived port, close, start again from a fresh derivation, and the view URL is identical. Verified: pnpm typecheck:webui; the focused suite (9 cases). --- .../webui/test/unit/miniapp-proxy.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/webui/test/unit/miniapp-proxy.test.ts b/packages/webui/test/unit/miniapp-proxy.test.ts index 4abbe7166..390a186e4 100644 --- a/packages/webui/test/unit/miniapp-proxy.test.ts +++ b/packages/webui/test/unit/miniapp-proxy.test.ts @@ -148,6 +148,28 @@ describe('Mini App proxy', () => { expect(seen.at(-1)?.headers[CREDENTIAL_HEADER]).toBe(CREDENTIAL); }); + it('keeps one plugin on the same port across a restart, so the page storage survives', async () => { + const derived = deriveMiniAppProxyPort('fixture.miniapp'); + const target = { + pluginId: 'fixture.miniapp', + upstreamOrigin: `http://127.0.0.1:${upstreamPort}`, + credential: CREDENTIAL, + credentialHeader: CREDENTIAL_HEADER, + }; + + const first = await startMiniAppProxy({ target, port: derived }); + const firstUrl = first.viewUrl; + await first.close(); + + const second = await startMiniAppProxy({ target, port: deriveMiniAppProxyPort('fixture.miniapp') }); + try { + expect(second.viewUrl).toBe(firstUrl); + expect(second.port).toBe(derived); + } finally { + await second.close(); + } + }); + it('derives a stable loopback port per plugin', () => { const first = deriveMiniAppProxyPort('fixture.miniapp'); From 08ed45c63667ecc68a307143e89a3cffb215108f Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:43:53 +0800 Subject: [PATCH 15/43] feat(webui): make the Mini App service a cell of the runtime owner policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of #256, and behaviour-preserving: electron keeps the service, every other row still has it off. The point is to move the gate out of a kind comparison and into the policy table, so a change to this client's row can reach it. `wiring.miniAppService` is stated as a literal in every row — electron `true`, cli, tui, runtime, webui and absent `false` — and in the fail-closed default. A literal rather than the electron-capability predicate, because that predicate answers true for an owner that declared no kind, and a service enabled for an absent owner is a behaviour change no client asked for. The webui row says `false` with the reason written next to it: it flips only when the presenter and the view land with it, since a cell flipped alone leaves the agent able to start a Mini App that nothing can display. `resolveRuntimeMiniAppOptions` now takes that cell as its owner-derived input instead of comparing the kind, so the two things that read it — the composition and the `miniappAvailable` capability flag in `services.ts` — cannot disagree with each other or with the service. Explicit Mini App options still win unchanged, including with no declared owner kind, which is the override a test already depends on. Proven: the policy suite still pins the wiring key set and the cells where webui differs from tui (84 cases across it and `services.test.ts` pass), and four cases pin the resolver's new rule — the cell starts the service, absence or false leaves it off, explicit options are returned as given, and a kind of `electron` with the cell off no longer gets the service. Verified: pnpm typecheck; pnpm run test:capabilities (238 files, 5837 passed, 18 skipped); pnpm check:source after the inventory write. --- .../src/service/miniapp/composition.ts | 13 +++++--- packages/local-runtime-v2/src/services.ts | 11 +++++-- .../miniapp/runtime-miniapp-options.test.ts | 31 +++++++++++++++++++ .../src/runtime/runtime-owner-policy.ts | 15 +++++++++ .../test/unit/runtime-owner-policy.test.ts | 1 + release/public-source.json | 1 + test/vitest-suites.json | 1 + 7 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 packages/local-runtime-v2/test/unit/miniapp/runtime-miniapp-options.test.ts diff --git a/packages/local-runtime-v2/src/service/miniapp/composition.ts b/packages/local-runtime-v2/src/service/miniapp/composition.ts index ca34226d2..487346793 100644 --- a/packages/local-runtime-v2/src/service/miniapp/composition.ts +++ b/packages/local-runtime-v2/src/service/miniapp/composition.ts @@ -27,13 +27,16 @@ export interface RuntimeMiniAppServiceOptions { } export function resolveRuntimeMiniAppOptions( - input: RuntimeMiniAppServiceOptions & { readonly runtimeOwnerKind?: string }, + input: RuntimeMiniAppServiceOptions & { + readonly runtimeOwnerKind?: string; + /** The runtime owner policy's `wiring.miniAppService` cell for this host. */ + readonly miniAppService?: boolean; + }, ): RuntimeMiniAppOptions | undefined { - if (input.runtimeOwnerKind !== undefined && input.runtimeOwnerKind !== 'electron') { - return undefined; - } + // Explicit options stay reachable: a caller that constructs the runtime with them has asked for the + // service by name, including the case with no declared owner kind. if (input.miniApp) return input.miniApp; - return input.runtimeOwnerKind === 'electron' ? {} : undefined; + return input.miniAppService === true ? {} : undefined; } export function createDeferredRuntimeOwnersLifecycle(): { diff --git a/packages/local-runtime-v2/src/services.ts b/packages/local-runtime-v2/src/services.ts index c3365c01c..2e0d65ec2 100644 --- a/packages/local-runtime-v2/src/services.ts +++ b/packages/local-runtime-v2/src/services.ts @@ -480,7 +480,11 @@ export async function createRuntimeServices( ), inspector, internalTurnPromptReads, - miniappAvailable: resolveRuntimeMiniAppOptions(options) !== undefined, + miniappAvailable: + resolveRuntimeMiniAppOptions({ + ...options, + miniAppService: options.ownerPolicy?.wiring.miniAppService, + }) !== undefined, browserUse: browserUseComposition.service, promptSnapshots, globalInstructions, @@ -699,7 +703,10 @@ async function initializeRuntimeServiceOwners( ); bindTaskAgentBindingCapture(input, { plugin, mcp }, pluginLogger); miniAppComposition = await composeMiniAppRuntimeCapability({ - options: resolveRuntimeMiniAppOptions(input.options), + options: resolveRuntimeMiniAppOptions({ + ...input.options, + miniAppService: input.options.ownerPolicy?.wiring.miniAppService, + }), db: input.options.db, dataDir: input.options.dataDir, nowMs: input.nowMs, diff --git a/packages/local-runtime-v2/test/unit/miniapp/runtime-miniapp-options.test.ts b/packages/local-runtime-v2/test/unit/miniapp/runtime-miniapp-options.test.ts new file mode 100644 index 000000000..533542329 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/runtime-miniapp-options.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveRuntimeMiniAppOptions } from '../../../src/service/miniapp/composition.js'; + +describe('resolveRuntimeMiniAppOptions', () => { + it('starts the service when the owner policy carries the cell', () => { + expect(resolveRuntimeMiniAppOptions({ miniAppService: true })).toEqual({}); + }); + + it('leaves the service off when the cell is absent or false', () => { + expect(resolveRuntimeMiniAppOptions({})).toBeUndefined(); + expect(resolveRuntimeMiniAppOptions({ miniAppService: false })).toBeUndefined(); + }); + + it('keeps explicit options reachable, including with no declared owner kind', () => { + const miniApp = { dataDir: '/tmp/miniapp' } as Parameters< + typeof resolveRuntimeMiniAppOptions + >[0]['miniApp']; + + expect(resolveRuntimeMiniAppOptions({ miniApp })).toBe(miniApp); + expect(resolveRuntimeMiniAppOptions({ miniApp, miniAppService: false })).toBe(miniApp); + }); + + it('no longer reads the owner kind directly, so a row cannot disagree with the cell', () => { + // The cell is the only owner-derived input: an electron runtime whose row does not carry it + // gets no service, which is what makes the capability flag and the service agree. + expect( + resolveRuntimeMiniAppOptions({ runtimeOwnerKind: 'electron', miniAppService: false }), + ).toBeUndefined(); + }); +}); diff --git a/packages/local-runtime/src/runtime/runtime-owner-policy.ts b/packages/local-runtime/src/runtime/runtime-owner-policy.ts index 551763782..5f775c991 100644 --- a/packages/local-runtime/src/runtime/runtime-owner-policy.ts +++ b/packages/local-runtime/src/runtime/runtime-owner-policy.ts @@ -58,6 +58,12 @@ export interface RuntimeWiring { readonly ownsV2Runtime: boolean; /** Replaces the inline `runtimeOwnerKind === 'electron'` scheduler gate. */ readonly schedulerHost: boolean; + /** + * Whether this host runs the Mini App service. A literal per row on purpose: the electron-capability + * predicate answers true for an owner that declared no kind, and a service enabled for an absent owner + * is a behaviour change no client asked for. + */ + readonly miniAppService: boolean; /** Replaces `ownsElectronRuntimeCapabilities` at the `services.ts` cron gate. */ readonly cronService: boolean; /** @@ -200,6 +206,7 @@ const UNKNOWN_OWNER_POLICY: Omit = { wiring: { ownsV2Runtime: true, schedulerHost: false, + miniAppService: false, cronService: false, channelService: false, browserActivation: 'explicit-config', @@ -254,6 +261,7 @@ export function resolveRuntimeOwnerPolicy( wiring: { ownsV2Runtime: isV2RuntimeOwner(kind, electronHost, cliEmbedded), schedulerHost: true, + miniAppService: true, cronService: ownsElectronRuntimeCapabilities(kind), channelService: ownsElectronRuntimeCapabilities(kind) && capabilityProfile !== 'cli', @@ -279,6 +287,7 @@ export function resolveRuntimeOwnerPolicy( wiring: { ownsV2Runtime: isV2RuntimeOwner(kind, electronHost, cliEmbedded), schedulerHost: false, + miniAppService: false, cronService: ownsElectronRuntimeCapabilities(kind), channelService: ownsElectronRuntimeCapabilities(kind) && capabilityProfile !== 'cli', @@ -307,6 +316,7 @@ export function resolveRuntimeOwnerPolicy( wiring: { ownsV2Runtime: isV2RuntimeOwner(kind, electronHost, cliEmbedded), schedulerHost: false, + miniAppService: false, cronService: ownsElectronRuntimeCapabilities(kind), channelService: ownsElectronRuntimeCapabilities(kind) && capabilityProfile !== 'cli', @@ -332,6 +342,8 @@ export function resolveRuntimeOwnerPolicy( // The raw option is undefined, so the compatibility gate rejects it. ownsV2Runtime: isV2RuntimeOwner(kind, electronHost, cliEmbedded), schedulerHost: false, + // A literal, not the predicate below: an absent owner must not inherit the service. + miniAppService: false, // ...while these read the raw undefined and answer true. cronService: ownsElectronRuntimeCapabilities(kind), channelService: @@ -364,6 +376,9 @@ export function resolveRuntimeOwnerPolicy( // layer and with it the questionnaire service. ownsV2Runtime: isV2RuntimeOwner('tui', electronHost, cliEmbedded), schedulerHost: true, + // Stays false until the presenter and the view land with it: a cell flipped on its own + // leaves the agent able to start a Mini App that nothing can display. + miniAppService: false, cronService: true, channelService: false, browserActivation: 'explicit-config', diff --git a/packages/local-runtime/test/unit/runtime-owner-policy.test.ts b/packages/local-runtime/test/unit/runtime-owner-policy.test.ts index 9859670c1..9c1d5ff1d 100644 --- a/packages/local-runtime/test/unit/runtime-owner-policy.test.ts +++ b/packages/local-runtime/test/unit/runtime-owner-policy.test.ts @@ -143,6 +143,7 @@ describe('resolveRuntimeOwnerPolicy', () => { 'browserActivation', 'channelService', 'cronService', + 'miniAppService', 'ownsV2Runtime', 'schedulerHost', ]); diff --git a/release/public-source.json b/release/public-source.json index d78989a43..df197e444 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1667,6 +1667,7 @@ "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/miniapp/runtime-miniapp-options.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 8bb86b5d9..b9282f12a 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -46,6 +46,7 @@ "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/miniapp/runtime-miniapp-options.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 dbaa9796da50fe5e3ec11220f73527fdd595f66f Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:46:57 +0800 Subject: [PATCH 16/43] feat(webui): present a Mini App through the session rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of #256, and the piece that has to exist before the service cell can be flipped: `MiniAppPresenter` is a host-owned port, so outside Electron there is no implementation, and the agent's own `open` action throws OPEN_UNAVAILABLE without one. ADR 0020 maps that `open` onto the session rail: it selects the session through the client's ordinary selection path and records that the panel is showing that Mini App. Selection is what makes a Mini-App-created session's permission asks answerable, so the record follows the selection rather than the page's wishes — which is why `followSelection` exists: when the person selects another session or closes the panel, the Mini App stops being shown and the service reports it closed instead of claiming a presentation that no longer exists. One presentation per service, because the panel shows one session at a time: a second `open` replaces the first, and `close` only clears the record when it names the Mini App currently presented, so a stale close from a stopped runtime cannot clear a live one. Proven: open selects and records; an aborted open neither selects nor records; a second open replaces the first; close ignores another plugin and reports exactly once; and selecting another session reports the closure. Verified: pnpm typecheck:webui (server, client and test); the focused suite (5 cases); pnpm check:source after the inventory write. --- .../webui/src/server/miniapp/presenter.ts | 76 ++++++++++++++++++ .../webui/test/unit/miniapp-presenter.test.ts | 78 +++++++++++++++++++ release/public-source.json | 2 + test/vitest-suites.json | 1 + 4 files changed, 157 insertions(+) create mode 100644 packages/webui/src/server/miniapp/presenter.ts create mode 100644 packages/webui/test/unit/miniapp-presenter.test.ts diff --git a/packages/webui/src/server/miniapp/presenter.ts b/packages/webui/src/server/miniapp/presenter.ts new file mode 100644 index 000000000..bb3fde651 --- /dev/null +++ b/packages/webui/src/server/miniapp/presenter.ts @@ -0,0 +1,76 @@ +/** What the panel is showing: one Mini App of one session. */ +export interface MiniAppPresentation { + readonly sessionId: string; + readonly pluginId: string; +} + +/** + * The browser implementation of the host-owned Mini App presenter. + * + * The desktop client's presenter moves a native view; there is no such view here. ADR 0020 maps the + * page's `open` onto the session rail plus the standalone presentation: `open` selects that session + * through the client's ordinary selection path and records that the panel is showing that Mini App, + * and `close` stops showing it. Selection is what makes a Mini-App-created session's permission asks + * answerable, so the record has to follow the selection rather than the page's wishes. + */ +export interface MiniAppPresenterLike { + open(input: { + readonly sessionId: string; + readonly pluginId: string; + readonly signal?: AbortSignal; + }): Promise; + close(input: { readonly pluginId: string }): Promise; +} + +export interface MiniAppPresenterView { + /** The Mini App the panel is showing, or undefined when it shows none. */ + presented(): MiniAppPresentation | undefined; + readonly presenter: MiniAppPresenterLike; +} + +export interface CreateMiniAppPresenterInput { + /** The client's ordinary selection path: what a person clicking the session rail gets. */ + readonly selectSession: (input: { readonly sessionId: string; readonly pluginId: string }) => void; + /** Reports the panel as closed when its Mini App stops being shown. */ + readonly onClosed?: (input: { readonly pluginId: string }) => void; +} + +/** + * One presenter per service. It holds one presentation, because the panel shows one session at a time: + * a second `open` replaces the first, and `close` only clears the record when it names the Mini App + * currently presented. + */ +export function createMiniAppPresenter(input: CreateMiniAppPresenterInput): MiniAppPresenterView { + let presentation: MiniAppPresentation | undefined; + + return { + presented: () => presentation, + presenter: { + async open(openInput) { + openInput.signal?.throwIfAborted(); + presentation = { sessionId: openInput.sessionId, pluginId: openInput.pluginId }; + input.selectSession({ sessionId: openInput.sessionId, pluginId: openInput.pluginId }); + }, + async close(closeInput) { + if (presentation?.pluginId !== closeInput.pluginId) return; + presentation = undefined; + input.onClosed?.({ pluginId: closeInput.pluginId }); + }, + }, + }; +} + +/** + * The panel stops showing a Mini App when the person selects another session or closes the panel, and + * the record has to follow, or the service would keep reporting a presentation that no longer exists. + */ +export function followSelection(input: { + readonly view: MiniAppPresenterView; + readonly selectedSessionId: string | undefined; +}): void { + const presentation = input.view.presented(); + if (!presentation) return; + if (presentation.sessionId === input.selectedSessionId) return; + // The presenter's own `onClosed` reports the closure; reporting here too would say it twice. + void input.view.presenter.close({ pluginId: presentation.pluginId }); +} diff --git a/packages/webui/test/unit/miniapp-presenter.test.ts b/packages/webui/test/unit/miniapp-presenter.test.ts new file mode 100644 index 000000000..f6b1edae1 --- /dev/null +++ b/packages/webui/test/unit/miniapp-presenter.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createMiniAppPresenter, + followSelection, +} from '../../src/server/miniapp/presenter.js'; + +function build(onClosed?: (input: { readonly pluginId: string }) => void) { + const selected: Array<{ sessionId: string; pluginId: string }> = []; + const view = createMiniAppPresenter({ + selectSession: (input) => selected.push({ ...input }), + ...(onClosed ? { onClosed } : {}), + }); + return { view, selected }; +} + +describe('Mini App presenter', () => { + it('selects the session and records what the panel is showing', async () => { + const { view, selected } = build(); + + expect(view.presented()).toBeUndefined(); + await view.presenter.open({ sessionId: 'session-1', pluginId: 'fixture.miniapp' }); + + expect(selected).toEqual([{ sessionId: 'session-1', pluginId: 'fixture.miniapp' }]); + expect(view.presented()).toEqual({ sessionId: 'session-1', pluginId: 'fixture.miniapp' }); + }); + + it('refuses to open when the caller has already aborted', async () => { + const { view, selected } = build(); + const controller = new AbortController(); + controller.abort(new Error('cancelled')); + + await expect( + view.presenter.open({ sessionId: 'session-1', pluginId: 'p', signal: controller.signal }), + ).rejects.toThrow('cancelled'); + expect(selected).toHaveLength(0); + expect(view.presented()).toBeUndefined(); + }); + + it('keeps one presentation: a second open replaces the first', async () => { + const { view } = build(); + + await view.presenter.open({ sessionId: 'session-1', pluginId: 'first' }); + await view.presenter.open({ sessionId: 'session-2', pluginId: 'second' }); + + expect(view.presented()).toEqual({ sessionId: 'session-2', pluginId: 'second' }); + }); + + it('closes only the Mini App it is showing, and only once', async () => { + const onClosed = vi.fn(); + const { view } = build(onClosed); + await view.presenter.open({ sessionId: 'session-1', pluginId: 'shown' }); + + await view.presenter.close({ pluginId: 'other' }); + expect(view.presented()).toEqual({ sessionId: 'session-1', pluginId: 'shown' }); + expect(onClosed).not.toHaveBeenCalled(); + + await view.presenter.close({ pluginId: 'shown' }); + expect(view.presented()).toBeUndefined(); + expect(onClosed).toHaveBeenCalledTimes(1); + + await view.presenter.close({ pluginId: 'shown' }); + expect(onClosed).toHaveBeenCalledTimes(1); + }); + + it('stops showing the Mini App when the person selects another session', async () => { + const closed: string[] = []; + const { view } = build((input) => closed.push(input.pluginId)); + await view.presenter.open({ sessionId: 'session-1', pluginId: 'fixture.miniapp' }); + + followSelection({ view, selectedSessionId: 'session-1' }); + expect(view.presented()).toBeDefined(); + expect(closed).toHaveLength(0); + + followSelection({ view, selectedSessionId: 'session-2' }); + expect(closed).toEqual(['fixture.miniapp']); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index df197e444..5e89f2bfd 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -4138,6 +4138,7 @@ "packages/webui/src/server/http/session-transfer.ts", "packages/webui/src/server/http/workspace-file.ts", "packages/webui/src/server/index.ts", + "packages/webui/src/server/miniapp/presenter.ts", "packages/webui/src/server/miniapp/proxy.ts", "packages/webui/src/server/operation/account.ts", "packages/webui/src/server/operation/agent-memory.ts", @@ -4344,6 +4345,7 @@ "packages/webui/test/unit/markdown-mermaid.test.tsx", "packages/webui/test/unit/message-attachments.test.tsx", "packages/webui/test/unit/message-parts.test.ts", + "packages/webui/test/unit/miniapp-presenter.test.ts", "packages/webui/test/unit/miniapp-proxy.test.ts", "packages/webui/test/unit/model-effort-scale.test.ts", "packages/webui/test/unit/model-picker-effort-options.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index b9282f12a..7e4378484 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -474,6 +474,7 @@ "packages/webui/test/unit/browser-bridge-adversarial.test.ts", "packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts", "packages/webui/test/unit/miniapp-proxy.test.ts", + "packages/webui/test/unit/miniapp-presenter.test.ts", "packages/webui/test/unit/workspace-write-fence.test.ts" ] } From 52ceb09848e4db902e657aeb542820892934d0be Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:54:55 +0800 Subject: [PATCH 17/43] feat(webui): forward Mini App list, open and stop on the client entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of #256, and the first step of the client vertical: the WebUI has no Mini App operation and no capability member today, so nothing produces a view URL for a panel tab to render. This adds the entry the operation will bind to. `CliService` gains `listMiniApps`, `openMiniApp` and `stopMiniApp`. They forward to the Mini App application and own no lifecycle logic of their own — ADR 0020 is explicit that the WebUI neither reimplements Mini App lifecycle in its server nor reaches around the facade into the application bag. `openMiniApp` returns the application's launch coordinates untouched: they are server-only and the caller must not forward them to a renderer, so the proxy is the only thing that will ever see them. Proven: the listing goes through the application; open passes the plugin id and returns the launch object by identity, so nothing is reshaped or dropped in between; stop forwards; and a host without the capability fails loudly rather than answering empty. Verified: pnpm typecheck; pnpm run test:capabilities (241 files, 5841 passed, 18 skipped); pnpm check:source after the inventory write. --- .../local-runtime-v2/src/local/cli-service.ts | 18 ++++++++ .../unit/miniapp/cli-service-miniapp.test.ts | 46 +++++++++++++++++++ release/public-source.json | 1 + test/vitest-suites.json | 1 + 4 files changed, 66 insertions(+) create mode 100644 packages/local-runtime-v2/test/unit/miniapp/cli-service-miniapp.test.ts diff --git a/packages/local-runtime-v2/src/local/cli-service.ts b/packages/local-runtime-v2/src/local/cli-service.ts index 1ac22ecc9..8057288f5 100644 --- a/packages/local-runtime-v2/src/local/cli-service.ts +++ b/packages/local-runtime-v2/src/local/cli-service.ts @@ -168,6 +168,24 @@ export class CliService { return { sessionId, workspaceRoot }; } + /** The Mini App listing the catalogue and the picker both read. */ + listMiniApps() { + return this.requireCapability("miniApps", "Mini App").list(); + } + + /** + * Starts a Mini App and, when this host has a presenter, shows it. A forwarding call only: the + * application owns the lifecycle, and `launch` is server-only coordinates the caller must not forward + * to a renderer. + */ + openMiniApp(input: { readonly pluginId: string; readonly signal?: AbortSignal }) { + return this.requireCapability("miniApps", "Mini App").open(input); + } + + stopMiniApp(input: { readonly pluginId: string; readonly signal?: AbortSignal }) { + return this.requireCapability("miniApps", "Mini App").stop(input); + } + clearSessionMcpServers(sessionId: string): Promise { return this.requireCapability("mcp", "MCP").clearSessionServers(sessionId); } diff --git a/packages/local-runtime-v2/test/unit/miniapp/cli-service-miniapp.test.ts b/packages/local-runtime-v2/test/unit/miniapp/cli-service-miniapp.test.ts new file mode 100644 index 000000000..5ce2d129f --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/cli-service-miniapp.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CliService, type CliServiceOptions } from '../../../src/local/cli-service.js'; + +/** + * The service is a pure forwarder here: it must not grow Mini App lifecycle logic of its own, and the + * only thing worth pinning is that it reaches the application and does not swallow the launch object. + */ +function serviceWith(miniApps: unknown): CliService { + return new CliService({ + applications: {}, + application: { miniApps }, + conversation: {}, + management: {}, + } as unknown as CliServiceOptions); +} + +describe('CliService Mini App forwarding', () => { + it('lists through the application', async () => { + const list = vi.fn(async () => ({ miniApps: [] })); + + await expect(serviceWith({ list }).listMiniApps()).resolves.toEqual({ miniApps: [] }); + expect(list).toHaveBeenCalledTimes(1); + }); + + it('opens by plugin and returns the launch coordinates untouched', async () => { + const launch = { launch: { origin: 'http://127.0.0.1:49999', surfacePath: '/dashboard', runId: 'run' } }; + const open = vi.fn(async () => launch); + + await expect(serviceWith({ open }).openMiniApp({ pluginId: 'fixture.miniapp' })).resolves.toBe( + launch, + ); + expect(open).toHaveBeenCalledWith({ pluginId: 'fixture.miniapp' }); + }); + + it('stops by plugin', async () => { + const stop = vi.fn(async () => undefined); + + await serviceWith({ stop }).stopMiniApp({ pluginId: 'fixture.miniapp' }); + expect(stop).toHaveBeenCalledWith({ pluginId: 'fixture.miniapp' }); + }); + + it('fails loudly when the host has no Mini App capability', () => { + expect(() => serviceWith(undefined).listMiniApps()).toThrow(); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 5e89f2bfd..6f215a391 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1664,6 +1664,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/cli-service-miniapp.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", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 7e4378484..443051f2f 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -47,6 +47,7 @@ "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/miniapp/runtime-miniapp-options.test.ts", + "packages/local-runtime-v2/test/unit/miniapp/cli-service-miniapp.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 3ec25de365b7523643c1df0579343cb344514143 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 15:59:25 +0800 Subject: [PATCH 18/43] feat(webui): hand the Mini App's launch credential to the caller that injects it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth slice of #256, and the hand-off #253 deliberately left out because it had no consumer: `launch` carried an origin but not the credential the Mini App's own guard demands, so nothing could build a proxy target from it. The supervisor now exposes the live generation's credential as `launchCredential(pluginId)`, reading the active generation's runtime handle — the same value the guard was installed with, so it cannot drift from the process it belongs to. `ProcessLocalMiniAppLaunch.launch` carries it under the same server-only comment as the origin: the proxy injects it, and no renderer and no published view ever holds it. The application fails with RUNTIME_FAILED rather than returning a launch object missing the credential, because a caller that cannot authenticate is better off told so than handed coordinates that will be refused. Untested boundary, stated rather than implied: this repository has no supervisor harness, so the credential's path through `open` has typecheck and the capability suite behind it, not a behavioural test. The operation and panel work that consumes it will exercise the path end to end; the guard and readiness suite already proves the credential is the one the port answers to. Verified: pnpm typecheck; pnpm run test:capabilities (240 files, 5841 passed, 18 skipped). --- .../session/process-local-application-contract.ts | 5 +++++ .../application/session/process-local-miniapp-application.ts | 5 ++++- packages/local-runtime-v2/src/service/miniapp/contracts.ts | 5 +++++ .../src/service/miniapp/supervisor/supervisor.ts | 4 ++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts index 83b53f7ae..da1305c24 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts @@ -46,6 +46,11 @@ export interface ProcessLocalMiniAppLaunch { readonly origin: string; readonly surfacePath: string; readonly runId: string; + /** + * The credential the Mini App's own guard demands. Server-only, like the rest of this object: the + * proxy injects it, and no renderer and no published view ever carries it. + */ + readonly credential: string; }; } diff --git a/packages/local-runtime-v2/src/application/session/process-local-miniapp-application.ts b/packages/local-runtime-v2/src/application/session/process-local-miniapp-application.ts index 01ef3a78a..cdd0ee915 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-miniapp-application.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-miniapp-application.ts @@ -19,7 +19,7 @@ type ProcessLocalMiniAppApplication = NonNullable | undefined, + supervisor: Pick | undefined, ): ProcessLocalMiniAppApplication | undefined { if (!supervisor) return undefined; @@ -44,12 +44,15 @@ export function createProcessLocalMiniAppApplication( ) { throw applicationError(status?.failureCode ?? 'RUNTIME_FAILED'); } + const credential = supervisor.launchCredential(miniApp.pluginId); + if (credential === undefined) throw applicationError('RUNTIME_FAILED'); return { miniApp: summary(miniApp, status), launch: { origin: active.origin, surfacePath: active.surfacePath, runId: active.processGeneration, + credential, }, }; }; diff --git a/packages/local-runtime-v2/src/service/miniapp/contracts.ts b/packages/local-runtime-v2/src/service/miniapp/contracts.ts index b7c5d439d..45fbccac4 100644 --- a/packages/local-runtime-v2/src/service/miniapp/contracts.ts +++ b/packages/local-runtime-v2/src/service/miniapp/contracts.ts @@ -237,6 +237,11 @@ export interface MiniAppSupervisor extends MiniAppPublicationCoordinator { readonly expectedProcessGeneration?: string; }): Promise; inspect(pluginId?: string): readonly MiniAppStatus[]; + /** + * Server-only: the live generation's loopback credential, so the proxy that injects it has something + * to inject. Never part of a published view and never forwarded to a renderer. + */ + launchCredential(pluginId: string): string | undefined; close(): Promise; } diff --git a/packages/local-runtime-v2/src/service/miniapp/supervisor/supervisor.ts b/packages/local-runtime-v2/src/service/miniapp/supervisor/supervisor.ts index 82513770b..808068a5a 100644 --- a/packages/local-runtime-v2/src/service/miniapp/supervisor/supervisor.ts +++ b/packages/local-runtime-v2/src/service/miniapp/supervisor/supervisor.ts @@ -684,6 +684,10 @@ export class DefaultMiniAppSupervisor implements MiniAppSupervisor { ); } + launchCredential(pluginId: string): string | undefined { + return this.active.get(pluginId)?.runtime?.loopbackToken; + } + inspect(pluginId?: string): readonly MiniAppStatus[] { const ids = pluginId ? [pluginId] From 75c504b20fc80390753057fb72eddd42adbb1bb8 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 17:02:55 +0800 Subject: [PATCH 19/43] feat(webui): serve a Mini App through a sealed loopback view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth slice of #256: the wire vertical that makes a Mini App reachable from the browser. `listMiniApps`, `openMiniApp` and `stopMiniApp` are declared in `OperationSpec`, described in `server/operation/miniapp.ts`, and served by one dedicated handler set under `handlers/miniapp.ts` — not bindings, because an open does more than forward one call. `openMiniApp` starts the Mini App through the facade, builds the proxy from the launch's origin and credential, and answers with that proxy's `viewUrl` and the plugin id. The Mini App's own origin, surface path and credential never enter a response: the `WebuiMiniAppLaunch` type that carries them is declared in `runtime/port.ts` rather than `shared/contracts/`, because a shared contract is importable by the browser. The credential header is not spelled in the adapter — it is `MINIAPP_LOOPBACK_TOKEN_HEADER` from `@mavis/shared/miniapp-surface`, the constant the runtime installs its guard with. One listener per plugin, because the proxy port is derived from the plugin id: a second open closes the first listener before binding its own (a second listener would either fail to bind or serve the page from an origin its own storage is not keyed by), a stop closes it, and calls for one plugin are serialised so a double open cannot race two binds onto one port and then, through the failure path, stop the app the first call had just started. An open whose proxy cannot bind stops the runtime it started, rather than leaving a process with no way in. A host without the Mini App cell is refused, not answered emptily: all three operations raise `capability_unavailable` with one message — from the handler when the port has no member, from the adapter when the facade has no application. They register unconditionally, so the refusal is a capability gap rather than `unknown_operation`. Proven (14 cases): the response carries the proxy's URL and no other key, and the credential never appears in it; that URL serves the page and injects the issued credential upstream; a second open replaces the listener instead of doubling it (the replacement binds the derived port, and the live listener injects the second credential); two concurrent opens for one plugin stay serialised; a stop forwards, closes the listener, and afterwards refuses connections; a proxy that cannot start stops the runtime; a host with no capability refuses with the typed error; and the adapter publishes a row without the fields the wire does not carry, attaches the shared header constant to the launch, and refuses a facade with no Mini App cell. Both counts moved with the roster: 93 bindings and 16 dedicated handlers (109 operations). The four new paths — and the two Mini App paths the earlier slices never recorded — are in the frozen path-metadata oracle. Verified: pnpm typecheck:webui; pnpm check:webui-dependency (0 new violations; the two `document` findings in WorkspacePanels.tsx pre-date this change); the new suite; pnpm test:webui (its 6 failures are pre-existing on this branch: the WorkspacePanels source assertions, the gate test that asserts the real tree is clean, two suites that need `pnpm build:styles`, and two real-host integration tests that fail identically at HEAD); pnpm check:source after the inventory write. Untested boundary: the runtime's own start/stop path and the credential its guard installs (a scripted facade stands in here), an Electron consumer of `viewUrl`, and proxies live as long as the process — nothing closes them on service shutdown. The client panel that frames `viewUrl` is the next slice; `client/platform/transport.ts` and the port slices still carry no Mini App member. --- CONTEXT.md | 7 + packages/webui/docs/architecture/design.md | 4 +- .../webui/docs/architecture/module-map.md | 6 +- .../webui/docs/runtime/harness-boundary.md | 9 +- packages/webui/src/runtime/harness/adapter.ts | 2 + .../src/runtime/harness/host-contract.ts | 18 + packages/webui/src/runtime/harness/miniapp.ts | 136 +++++ packages/webui/src/runtime/port.ts | 71 +++ .../src/server/operation/bind-handlers.ts | 7 +- .../src/server/operation/binding-mechanism.ts | 5 +- .../src/server/operation/handlers/miniapp.ts | 219 +++++++ .../webui/src/server/operation/miniapp.ts | 111 ++++ .../webui/src/server/operation/operations.ts | 12 +- .../webui/src/shared/contracts/miniapp.ts | 61 ++ packages/webui/src/shared/operation-names.ts | 33 ++ .../gates/fixtures/path-metadata-f00.json | 54 ++ .../test/unit/miniapp-operations.test.ts | 558 ++++++++++++++++++ .../test/unit/webui-operation-binding.test.ts | 6 +- release/public-source.json | 5 + test/vitest-suites.json | 1 + 20 files changed, 1309 insertions(+), 16 deletions(-) create mode 100644 packages/webui/src/runtime/harness/miniapp.ts create mode 100644 packages/webui/src/server/operation/handlers/miniapp.ts create mode 100644 packages/webui/src/server/operation/miniapp.ts create mode 100644 packages/webui/src/shared/contracts/miniapp.ts create mode 100644 packages/webui/test/unit/miniapp-operations.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 32d4d46ce..228e202cd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -549,6 +549,13 @@ The local process that serves one Mini App's page on a loopback port. The client and does not own its state. _Avoid_: runtime (that names the harness side), server, container +**Mini App proxy**: +The loopback listener the service puts between a Mini App's process and the client's view. The +Mini App serves its own page under a credential its own guard demands; the client frames the proxy's +URL instead, and the proxy injects that credential on the way out. The process's origin and the +credential are server-only, so an operation answers with the proxy and never with the process. +_Avoid_: gateway, tunnel, port forward + **Mini App presentation lease**: The record of which client document is currently showing one Mini App, held by the service because two presentations of the same Mini App are two documents that share nothing but that service. It is diff --git a/packages/webui/docs/architecture/design.md b/packages/webui/docs/architecture/design.md index 3c134490e..7188ec0bc 100644 --- a/packages/webui/docs/architecture/design.md +++ b/packages/webui/docs/architecture/design.md @@ -191,13 +191,13 @@ One declaration, three consumers, and a compiler that keeps them agreeing: can choose a member: group, method and operation name are fixed in the declaration, and the argument tuple and the result mapper are checked against that method's parameters and the operation's response type. No `any`, no assertion, no non-null assertion — a wrong group, method, tuple or - mapper fails to compile. The thirteen operations that shape a response, degrade when a capability is + mapper fails to compile. The sixteen operations that shape a response, degrade when a capability is absent, or carry a stream, terminal or command shape keep hand-written handlers in `src/server/operation/handlers/`. 4. **`src/runtime/port.ts`** declares `WebuiHarnessPort`: one method per operation, typed with the `shared/contracts/.ts` DTOs, and implemented under `src/runtime/harness/` by `createHarnessPortFromHost`. `bind-handlers.ts` derives its `Pick` from the port, so the roster and - the registry cannot drift: the counts are pinned by tests (86 bindings, 13 handlers, 99 operations), + the registry cannot drift: the counts are pinned by tests (93 bindings, 16 handlers, 109 operations), and a change that adds or drops one stops `pnpm typecheck:webui` compiling until the roster moves as a unit. diff --git a/packages/webui/docs/architecture/module-map.md b/packages/webui/docs/architecture/module-map.md index 1aebe3461..c30a44262 100644 --- a/packages/webui/docs/architecture/module-map.md +++ b/packages/webui/docs/architecture/module-map.md @@ -40,7 +40,7 @@ business is — and what it is not — is in `../client/ownership.md`. | `runtime/assembly.ts` | The composition root. Creates and wires the process resources, forwards the runtime-owner options, and enriches the host handle with the credential-backed slots. Creates no OAuth state machine, no close ordering. | | `runtime/lifecycle.ts` | Resource registration, close ordering and startup-failure recovery. Wraps `apiHost.close` so runtime and capability owners close exactly once. | | `runtime/port.ts` | `WebuiHarnessPort` — the boundary to the harness: the callable, `AsyncIterable` and cancellation types plus one method per operation. It declares no wire DTO; those live in `shared/contracts/.ts`. | -| `runtime/harness/` | The per-domain adapters that implement the port by delegating to the host handle: `host-contract.ts` (`WebuiRuntimeHostHandle`, `WebuiRuntimeCliService`), `adapter.ts` (`createHarnessPortFromHost`), plus `account`, `execution`, `interactions`, `models-plugins`, `requirements`, `sessions`, `settings`, `workspace`. | +| `runtime/harness/` | The per-domain adapters that implement the port by delegating to the host handle: `host-contract.ts` (`WebuiRuntimeHostHandle`, `WebuiRuntimeCliService`), `adapter.ts` (`createHarnessPortFromHost`), plus `account`, `execution`, `interactions`, `miniapp`, `models-plugins`, `requirements`, `sessions`, `settings`, `workspace`. | | `runtime/auth-session.ts`, `runtime/auth-context.ts` | The one OAuth core with its lease and refresh timer, and the per-request auth context. | | `runtime/account-login.ts`, `runtime/check-in.ts`, `runtime/usage-quota.ts`, `runtime/usage-quota-projection.ts`, `runtime/reset-card-projection.ts`, `runtime/signing.ts` | Account login, daily check-in, the account-billing client (the quota, the credit fallback and the reset cards) and its two pure projections, request signing. | | `runtime/terminal.ts` | The terminal process manager. One per WebUI service, not a host-wide singleton. | @@ -80,7 +80,7 @@ business is — and what it is not — is in `../client/ownership.md`. | `server/operation/.ts` | The operation descriptors (name + `validate`) grouped by domain. | | `server/operation/bind-handlers.ts` | `WEBUI_OPERATION_BINDINGS` — the statically declared operation→capability mapping — plus `createBindingEntries` and `DEDICATED_OPERATION_NAMES`. | | `server/operation/binding-mechanism.ts` | The binding machinery around `OperationSpec`. | -| `server/operation/handlers/` | The thirteen operations that keep a hand-written handler: `account`, `commands`, `messages`, `stream`, `terminal`, `workspace`. | +| `server/operation/handlers/` | The sixteen operations that keep a hand-written handler: `account`, `commands`, `messages`, `miniapp`, `stream`, `terminal`, `workspace`. | | `server/operation/operation-dispatch.ts` | `dispatchWebuiFrame` — one inbound frame → validate → look up → handle → response, stream or error frame. | | `server/operation/operations.ts` | `createOperationRegistry`, the single `registerOperation` call site, and the registry order. | @@ -91,7 +91,7 @@ The binding roster and the compiler enforcement that keeps it in step with the p | Module | Role | | --- | --- | -| `shared/contracts/.ts` | The request/result DTOs both sides of the boundary share — account, account-billing (the credit fallback and the reset cards), canvas, commands, goal, interactions, messages, models, personalization, queue, review, session, stream, terminal, usage-quota, version, workspace. | +| `shared/contracts/.ts` | The request/result DTOs both sides of the boundary share — account, account-billing (the credit fallback and the reset cards), canvas, commands, goal, interactions, messages, miniapp, models, personalization, queue, review, session, stream, terminal, usage-quota, version, workspace. | | `shared/envelope.ts` | Frame shape, protocol version, error codes. | | `shared/operation-names.ts` | Every `*_OPERATION_NAME` constant and the `OperationSpec` map that unary request types are derived from. | | `shared/plugin-management.ts` | `WebuiPluginManagementAction` and the plugin wire shapes. | diff --git a/packages/webui/docs/runtime/harness-boundary.md b/packages/webui/docs/runtime/harness-boundary.md index 2cfa6c161..d0f91e158 100644 --- a/packages/webui/docs/runtime/harness-boundary.md +++ b/packages/webui/docs/runtime/harness-boundary.md @@ -19,13 +19,14 @@ rather than by an assertion: Drop a port member without dropping its binding, or add one without reaching the registry, and `pnpm typecheck:webui` stops compiling. The current roster is pinned in -`test/unit/webui-operation-binding.test.ts`: **86** bindings, **13** dedicated handlers, **99** +`test/unit/webui-operation-binding.test.ts`: **93** bindings, **16** dedicated handlers, **109** operations. A new operation breaks all three counts, so bump them in the same change — after checking that the roster really grew. -The thirteen operations that keep a hand-written handler live in `server/operation/handlers/` -(`account`, `commands`, `messages`, `stream`, `terminal`, `workspace`). They are the ones that cannot -be expressed as a single capability call. +The sixteen operations that keep a hand-written handler live in `server/operation/handlers/` +(`account`, `commands`, `messages`, `miniapp`, `stream`, `terminal`, `workspace`). They are the ones +that cannot be expressed as a single capability call — a shaped response, a second runtime surface +of their own, or a capability the host may not have. ## Host types and wire projections diff --git a/packages/webui/src/runtime/harness/adapter.ts b/packages/webui/src/runtime/harness/adapter.ts index 8116aa607..479f4b16b 100644 --- a/packages/webui/src/runtime/harness/adapter.ts +++ b/packages/webui/src/runtime/harness/adapter.ts @@ -21,6 +21,7 @@ import { createInteractionsAdapter } from "./interactions.js"; import { createWorkspaceAdapter } from "./workspace.js"; import { createSettingsAdapter } from "./settings.js"; import { createModelsPluginsAdapter } from "./models-plugins.js"; +import { createMiniAppAdapter } from "./miniapp.js"; import { createAccountAdapter } from "./account.js"; import { runWebuiCommand } from "../commands/runner.js"; import { importWebuiSessionTransfer } from "../session-transfer.js"; @@ -70,6 +71,7 @@ export function createHarnessPortFromHost( ...createWorkspaceAdapter(host), ...createSettingsAdapter(host), ...createModelsPluginsAdapter(host), + ...createMiniAppAdapter(host), ...createAccountAdapter(host), async close() { if (closed) return; diff --git a/packages/webui/src/runtime/harness/host-contract.ts b/packages/webui/src/runtime/harness/host-contract.ts index 88572f58d..afc59495b 100644 --- a/packages/webui/src/runtime/harness/host-contract.ts +++ b/packages/webui/src/runtime/harness/host-contract.ts @@ -575,6 +575,24 @@ export interface WebuiRuntimeCliService { readonly reason: "ui_request"; readonly customInstructions?: string; }): Promise>; + /** + * The Mini App cell of the runtime's owner policy. + * + * Optional on the same terms as the memory pair: a host composed without the + * Mini App capability still has to satisfy this shape, and the adapter's guard + * turns the absent member into one `capability_unavailable` refusal instead of + * an empty catalogue. Results are the runtime's own (`HostResult`), because the + * publishable summary and the server-only launch are converted at the adapter — + * a `WebuiMiniAppLaunch` is not a shape the runtime has, it is what the WebUI + * makes of one. + */ + listMiniApps?(): Promise>; + openMiniApp?( + request: WebuiHostRequest<"openMiniApp">, + ): Promise>; + stopMiniApp?( + request: WebuiHostRequest<"stopMiniApp">, + ): Promise>; } export interface WebuiRuntimeHostHandle { diff --git a/packages/webui/src/runtime/harness/miniapp.ts b/packages/webui/src/runtime/harness/miniapp.ts new file mode 100644 index 000000000..a7ced8628 --- /dev/null +++ b/packages/webui/src/runtime/harness/miniapp.ts @@ -0,0 +1,136 @@ +// The Mini App cell of the harness port. +// +// The runtime owns the Mini App lifecycle — it starts the process, installs the +// loopback guard and issues the credential that guard demands — so this adapter +// forwards and converts, never decides. Two conversions happen here and they +// happen for different reasons: +// +// * the catalogue row becomes an independently owned WebUI DTO, so a runtime +// field this wire does not publish cannot leak into a response by being +// spread; +// * the launch becomes `WebuiMiniAppLaunch`, which adds the credential header +// the runtime's own type does not carry. The header name is imported from the +// shared contract the runtime installs its guard with +// (`@mavis/shared/miniapp-surface`), not spelled here: a copy is a second +// authority on which header the guard reads. +// +// The whole launch object is server-only. Nothing in it reaches the browser; the +// server hands it to `startMiniAppProxy`, which injects the credential on the way +// out and answers the client with its own URL. +import { + MINIAPP_LOOPBACK_TOKEN_HEADER, + type MiniAppSurfaceSummary, +} from "@mavis/shared/miniapp-surface"; + +import { + MINIAPP_CAPABILITY_ABSENT, + type WebuiHarnessPort, + type WebuiMiniAppLaunch, +} from "../port.js"; +import type { + WebuiRuntimeCliService, + WebuiRuntimeHostHandle, +} from "./host-contract.js"; +import { requireCliService } from "./requirements.js"; +import { WebuiCapabilityUnavailableError } from "../../shared/envelope.js"; +import type { WebuiMiniAppSummary } from "../../shared/contracts/miniapp.js"; + +/** The launch the runtime hands back, as much of it as this adapter reads. */ +interface RuntimeMiniAppLaunch { + readonly miniApp: MiniAppSurfaceSummary; + readonly launch: { + readonly origin: string; + readonly surfacePath: string; + readonly runId: string; + readonly credential: string; + }; +} + +/** + * The three facade members, or one refusal. + * + * One capability, so a host that has any two of them is a broken host rather than + * a host with a partial Mini App surface: the facade composes all three from the + * same application cell. + */ +function requireMiniApps(host: WebuiRuntimeHostHandle): Required< + Pick +> { + const cliService = requireCliService(host); + const { listMiniApps, openMiniApp, stopMiniApp } = cliService; + if (!listMiniApps || !openMiniApp || !stopMiniApp) + throw new WebuiCapabilityUnavailableError(MINIAPP_CAPABILITY_ABSENT); + // The runtime's members read `this.options`, so the receiver has to be the + // service they were destructured from — not this wrapper. + return { + listMiniApps: () => listMiniApps.call(cliService), + openMiniApp: (request) => openMiniApp.call(cliService, request), + stopMiniApp: (request) => stopMiniApp.call(cliService, request), + }; +} + +/** + * The publishable half of a catalogue row. + * + * Field by field rather than by spread: the runtime's summary is free to grow a + * field that is not meant for a renderer, and a spread would publish it the day it + * lands. + */ +export function toWebuiMiniAppSummary( + summary: MiniAppSurfaceSummary, +): WebuiMiniAppSummary { + return { + pluginId: summary.pluginId, + ...(summary.displayName !== undefined ? { displayName: summary.displayName } : {}), + ...(summary.description !== undefined ? { description: summary.description } : {}), + ...(summary.iconUrl !== undefined ? { iconUrl: summary.iconUrl } : {}), + runtime: { + kind: summary.runtime.kind, + status: summary.runtime.status, + ...(summary.runtime.errorCode !== undefined + ? { errorCode: summary.runtime.errorCode } + : {}), + }, + }; +} + +/** + * The launch, with the header its credential is read from. + * + * `credentialHeader` is the one field this conversion adds, and it is added here + * because this is the only layer that may hold both the credential and the + * contract the guard was installed with. + */ +export function toWebuiMiniAppLaunch( + result: RuntimeMiniAppLaunch, +): WebuiMiniAppLaunch { + return { + miniApp: toWebuiMiniAppSummary(result.miniApp), + launch: { + origin: result.launch.origin, + surfacePath: result.launch.surfacePath, + runId: result.launch.runId, + credential: result.launch.credential, + credentialHeader: MINIAPP_LOOPBACK_TOKEN_HEADER, + }, + }; +} + +export function createMiniAppAdapter( + host: WebuiRuntimeHostHandle, +): Pick { + return { + async listMiniApps() { + const { miniApps } = await requireMiniApps(host).listMiniApps(); + return { miniApps: miniApps.map(toWebuiMiniAppSummary) }; + }, + + async openMiniApp(request) { + return toWebuiMiniAppLaunch(await requireMiniApps(host).openMiniApp(request)); + }, + + async stopMiniApp(request) { + await requireMiniApps(host).stopMiniApp(request); + }, + }; +} diff --git a/packages/webui/src/runtime/port.ts b/packages/webui/src/runtime/port.ts index 1af314047..12c862459 100644 --- a/packages/webui/src/runtime/port.ts +++ b/packages/webui/src/runtime/port.ts @@ -108,6 +108,10 @@ import type { WebuiTestUserModelCandidateRequest, } from "../shared/contracts/models.js"; import type { WebuiHostPrerequisiteFacts } from "../shared/capability-manifest.js"; +import type { + WebuiMiniAppList, + WebuiMiniAppSummary, +} from "../shared/contracts/miniapp.js"; import type { WebuiVersionInfo } from "../shared/contracts/version.js"; import type { WebuiUsageQuotaResult } from "../shared/contracts/usage-quota.js"; import type { @@ -571,6 +575,35 @@ export interface WebuiHarnessPort { * `server` reach `runtime-port` only. */ runCommand(request: WebuiRunCommandRequest): Promise; + /** + * The Mini App catalogue. + * + * Optional because the Mini App capability is a cell of the runtime's owner + * policy: a host whose application was composed without it has no Mini Apps at + * all, which is a different answer from an empty catalogue. The operations + * refuse with `capability_unavailable` when these members are absent, rather + * than reporting "no Mini Apps installed". + */ + listMiniApps?(): Promise; + /** + * Starts one Mini App and hands back its launch coordinates. + * + * The result is **server-only**: `launch` carries the Mini App's real origin, + * the credential its own loopback guard demands and the header that credential + * is read from. `server/operation/handlers/miniapp.ts` turns it into a proxy + * and answers the browser with that proxy's URL; nothing in `launch` may be + * copied into a response. The `signal` is the requesting frame's, so a client + * that goes away stops waiting on a start. + */ + openMiniApp?(request: { + readonly pluginId: string; + readonly signal?: AbortSignal; + }): Promise; + /** Stops one Mini App. The caller closes its proxy. */ + stopMiniApp?(request: { + readonly pluginId: string; + readonly signal?: AbortSignal; + }): Promise; /** * Release anything the port owns. The service calls this after closing * every transport-side resource so the harness can tear itself down in @@ -579,6 +612,44 @@ export interface WebuiHarnessPort { close(): Promise; } +/** + * The refusal a host without the Mini App cell gives, everywhere it is given. + * + * Declared beside the capability itself because two layers answer it — the + * adapter when the runtime's facade has no Mini App application, and the + * dedicated handlers when the port has no member at all — and one message is + * what keeps "this host has no Mini Apps" recognisable to a client regardless of + * which of the two noticed it. + */ +export const MINIAPP_CAPABILITY_ABSENT = + "runtime host does not expose the Mini App capability"; + +/** + * One Mini App launch, as the runtime hands it over. + * + * Declared here rather than in `shared/contracts/` on purpose: `credential` is a + * live capability, and a shared contract is importable by the browser. Only the + * server and the runtime may name this type; the wire carries + * `WebuiMiniAppView`, which cannot express any of it. + */ +export interface WebuiMiniAppLaunch { + readonly miniApp: WebuiMiniAppSummary; + readonly launch: { + /** The Mini App's own loopback origin. Never forwarded to a renderer. */ + readonly origin: string; + readonly surfacePath: string; + readonly runId: string; + /** The credential the Mini App's guard demands. Injected by the proxy. */ + readonly credential: string; + /** + * Request header the guard reads it from. Carried with the credential so the + * server never spells the header name twice, and never spells it differently + * from the runtime that installed it. + */ + readonly credentialHeader: string; + }; +} + /** * The narrow terminal-process port. * diff --git a/packages/webui/src/server/operation/bind-handlers.ts b/packages/webui/src/server/operation/bind-handlers.ts index 299846b3f..fcf13449c 100644 --- a/packages/webui/src/server/operation/bind-handlers.ts +++ b/packages/webui/src/server/operation/bind-handlers.ts @@ -13,7 +13,7 @@ // Binding declarations contain no `any`, no type assertions and no non-null // assertions; the only registry-type erasure lives in `executeBinding`. // -// The 13 operations that shape a response, degrade when a capability is absent +// The 16 operations that shape a response, degrade when a capability is absent // or carry a stream/terminal/command shape keep dedicated handlers in // `./handlers/`; they are not bindings. import type { OperationSpec } from "../../shared/operation-names.js"; @@ -141,7 +141,7 @@ export type { } from "./binding-mechanism.js"; // --------------------------------------------------------------------------- -// Binding declarations — 86 operations (the other 13 keep dedicated handlers) +// Binding declarations — 93 operations (the other 16 keep dedicated handlers) // --------------------------------------------------------------------------- const identity = (value: T): T => value; @@ -762,6 +762,9 @@ export const DEDICATED_OPERATION_NAMES = [ "getMessages", "sendMessage", "resumeSession", + "listMiniApps", + "openMiniApp", + "stopMiniApp", ] as const; type AnyBinding = WebuiOperationBinding< diff --git a/packages/webui/src/server/operation/binding-mechanism.ts b/packages/webui/src/server/operation/binding-mechanism.ts index 27927d750..c4edc82fa 100644 --- a/packages/webui/src/server/operation/binding-mechanism.ts +++ b/packages/webui/src/server/operation/binding-mechanism.ts @@ -4,7 +4,7 @@ // Split out of `bind-handlers.ts` because the two halves change for // different reasons and are verified differently: this half is generic — it // holds no operation name and no runtime method name of its own — while the -// roster next door is a 86-row table of concrete declarations that changes with +// roster next door is a 93-row table of concrete declarations that changes with // the wire surface. A reviewer reading a table row should not have to hold the // executor's erasure in mind, and a change to the executor should not touch a // file whose diff is otherwise a list of operations. @@ -134,6 +134,9 @@ export type WebuiOperationPort = Pick< | "setUserProfile" | "getMemorySettings" | "setMemorySettings" + | "listMiniApps" + | "openMiniApp" + | "stopMiniApp" | "runCommand" >; diff --git a/packages/webui/src/server/operation/handlers/miniapp.ts b/packages/webui/src/server/operation/handlers/miniapp.ts new file mode 100644 index 000000000..d659cb103 --- /dev/null +++ b/packages/webui/src/server/operation/handlers/miniapp.ts @@ -0,0 +1,219 @@ +// Dedicated handlers: the Mini App surface. +// +// None of the three can be a plain binding. `openMiniApp` shapes its response — +// the client is handed the loopback proxy's URL, never the Mini App's own origin +// or the credential its guard demands — and `stopMiniApp` owns the other half of +// that proxy's lifetime. `listMiniApps` keeps their company rather than becoming a +// lone binding because the three answer one capability: a host either has Mini +// Apps or it has none, and the refusal has to be the same message whichever of the +// three is asked first. +// +// One listener per plugin, held in this closure. The proxy's port is derived from +// the plugin id, so a second listener for the same plugin would either fail to +// bind or serve the page from a second origin and silently discard the page's own +// storage. `open` therefore replaces the listener it finds, and `stop` closes it. +import { WebuiCapabilityUnavailableError } from "../../../shared/envelope.js"; +import { MINIAPP_CAPABILITY_ABSENT } from "../../../runtime/port.js"; +import type { WebuiOperationPort } from "../bind-handlers.js"; +import { + listMiniAppsOperation, + openMiniAppOperation, + stopMiniAppOperation, +} from "../miniapp.js"; +import { + startMiniAppProxy, + type MiniAppProxy, + type MiniAppProxyTarget, +} from "../../miniapp/proxy.js"; +import type { + WebuiOperationContext, + WebuiOperationHandler, + WebuiOperationRegistryEntry, + WebuiOperationValidation, +} from "../operation-contract.js"; + +type BodyOf = Descriptor extends { + readonly validate: (body: unknown) => WebuiOperationValidation; +} + ? Body + : never; + +type DedicatedHandler = WebuiOperationHandler, unknown>; + +/** The capability as one object, or the refusal that replaces it. */ +interface MiniAppCapability { + readonly list: NonNullable; + readonly open: NonNullable; + readonly stop: NonNullable; +} + +/** + * The capability, or `capability_unavailable`. + * + * A host composed without the Mini App cell has no member here at all. Answering + * that with an empty catalogue would tell the person the feature exists and has + * nothing in it, which is the one answer that leads nowhere; the refusal names the + * missing capability and carries the code a client can branch on. + * + * A member that is present but not callable counts as absent, because the three + * are one capability: the runtime's facade composes all of them from the same + * application cell, so a port with two of them is broken rather than partial, and + * "this host cannot answer" is the honest thing to say about it. + */ +function requireMiniApps(port: WebuiOperationPort): MiniAppCapability { + const { listMiniApps, openMiniApp, stopMiniApp } = port; + if ( + typeof listMiniApps !== "function" || + typeof openMiniApp !== "function" || + typeof stopMiniApp !== "function" + ) + throw new WebuiCapabilityUnavailableError(MINIAPP_CAPABILITY_ABSENT); + // The port members are called with the port as receiver, exactly as + // `executeBinding` does: a capability that reads `this` must see the object it + // was taken from, not this wrapper. + return { list: listMiniApps, open: openMiniApp, stop: stopMiniApp }; +} + +export interface MiniAppHandlerOptions { + /** + * Starts one plugin's loopback listener. Defaults to the real proxy. + * + * A test substitutes a wrapper over the real one to observe which target a + * launch produced and whether the previous listener was closed, without owning + * the port a plugin's URL is derived from. + */ + readonly startProxy?: typeof startMiniAppProxy; +} + +export function createMiniAppHandlerEntries( + port: WebuiOperationPort, + options: MiniAppHandlerOptions = {}, +): ReadonlyMap { + const startProxy = options.startProxy ?? startMiniAppProxy; + /** The live listener per plugin. */ + const proxies = new Map(); + /** + * The last call per plugin, so two calls cannot interleave. + * + * Run concurrently, a second `open` would find the derived port still bound by + * the first and fail to bind — and a failed open stops the runtime, so the race + * would shut down the app the first call had just started. Serialised per plugin + * (not globally: two different Mini Apps have no reason to wait for each other) + * the second call sees the first one's listener and replaces it. + */ + const inFlight = new Map>(); + + function serialised(pluginId: string, work: () => Promise): Promise { + const tail = inFlight.get(pluginId) ?? Promise.resolve(); + const run = tail.then(work, work); + // The tail must settle without rejecting: the next caller awaits it, and this + // call's own failure already has a receiver. + const settled = run.then( + () => undefined, + () => undefined, + ); + inFlight.set(pluginId, settled); + void settled.then(() => { + if (inFlight.get(pluginId) === settled) inFlight.delete(pluginId); + }); + return run; + } + + /** Replaces the plugin's listener, closing the old one before binding the new. */ + async function replaceProxy(target: MiniAppProxyTarget): Promise { + const existing = proxies.get(target.pluginId); + if (existing) { + proxies.delete(target.pluginId); + await existing.close(); + } + const proxy = await startProxy({ target }); + proxies.set(target.pluginId, proxy); + return proxy; + } + + async function closeProxy(pluginId: string): Promise { + const proxy = proxies.get(pluginId); + if (!proxy) return; + proxies.delete(pluginId); + await proxy.close(); + } + + const listMiniApps: DedicatedHandler = async () => ({ + body: await requireMiniApps(port).list.call(port), + }); + + const openMiniApp: DedicatedHandler = async ( + context: WebuiOperationContext, + body, + ) => { + const miniApps = requireMiniApps(port); + return serialised(body.pluginId, async () => { + const launch = await miniApps.open.call(port, { + pluginId: body.pluginId, + signal: context.signal, + }); + try { + const proxy = await replaceProxy({ + pluginId: launch.miniApp.pluginId, + upstreamOrigin: launch.launch.origin, + credential: launch.launch.credential, + credentialHeader: launch.launch.credentialHeader, + }); + // The response is the proxy's URL and the plugin it belongs to. The + // runtime's origin, surface path and credential stay in this frame of + // reference — no field of `launch` is copied out. + return { body: { pluginId: launch.miniApp.pluginId, viewUrl: proxy.viewUrl } }; + } catch (error) { + // The runtime started something the client cannot reach. Stopping it is + // better than leaving a process running with no way into it, and the + // original failure is what the caller needs to see. + await miniApps.stop + .call(port, { pluginId: body.pluginId, signal: context.signal }) + .catch(() => undefined); + throw error; + } + }); + }; + + const stopMiniApp: DedicatedHandler = async ( + context: WebuiOperationContext, + body, + ) => { + const miniApps = requireMiniApps(port); + return serialised(body.pluginId, async () => { + // The runtime first: a proxy closed under a live app would leave the app + // running with nothing pointing at it, and a stop that throws leaves the + // listener where it was so a retry has something to close. + await miniApps.stop.call(port, { + pluginId: body.pluginId, + signal: context.signal, + }); + await closeProxy(body.pluginId); + return { body: { success: true as const } }; + }); + }; + + return new Map([ + [ + listMiniAppsOperation.name, + { + operation: listMiniAppsOperation, + handle: listMiniApps as WebuiOperationHandler, + }, + ], + [ + openMiniAppOperation.name, + { + operation: openMiniAppOperation, + handle: openMiniApp as WebuiOperationHandler, + }, + ], + [ + stopMiniAppOperation.name, + { + operation: stopMiniAppOperation, + handle: stopMiniApp as WebuiOperationHandler, + }, + ], + ]); +} diff --git a/packages/webui/src/server/operation/miniapp.ts b/packages/webui/src/server/operation/miniapp.ts new file mode 100644 index 000000000..ef5f37d6f --- /dev/null +++ b/packages/webui/src/server/operation/miniapp.ts @@ -0,0 +1,111 @@ +// The Mini App surface: the catalogue, the launch and the stop. +// +// Three descriptors over one runtime capability, and all three are dedicated +// handlers rather than bindings (`handlers/miniapp.ts`), because two of them do +// more than forward one call: `openMiniApp` turns the runtime's launch +// coordinates into a loopback proxy and answers with that proxy's URL, and +// `stopMiniApp` closes the proxy it left behind. Keeping the catalogue beside +// them keeps the capability's three declarations in one place — a host either has +// Mini Apps or it does not. +import type { + WebuiMiniAppList, + WebuiMiniAppView, +} from "../../shared/contracts/miniapp.js"; +import { WebuiErrorCode } from "../../shared/envelope.js"; +import { + LIST_MINI_APPS_OPERATION_NAME, + OPEN_MINI_APP_OPERATION_NAME, + STOP_MINI_APP_OPERATION_NAME, +} from "../../shared/operation-names.js"; +import type { + WebuiOperation, + WebuiOperationValidation, +} from "./operation-contract.js"; + +/** + * Longest plugin id a frame may carry. + * + * Not a runtime limit — the runtime's own catalogue decides what exists — but a + * bound on what reaches the log, the error text and the plugin-id-keyed proxy map + * this capability keeps. A frame naming a 4MB plugin id is a frame that is not + * asking about an installed Mini App. + */ +const MAX_PLUGIN_ID_LENGTH = 200; + +/** + * `listMiniApps` takes no body, and says so rather than accepting a record it + * ignores: an operation that silently drops input invites a caller to believe the + * input narrowed the catalogue. + */ +export const listMiniAppsOperation: WebuiOperation = { + name: LIST_MINI_APPS_OPERATION_NAME, + validate: (body) => + body === undefined + ? { ok: true, body: undefined } + : { + ok: false, + code: WebuiErrorCode.invalidBody, + message: `${LIST_MINI_APPS_OPERATION_NAME} does not accept a body`, + }, +}; + +/** + * The one field the two Mini App mutations carry. + * + * Trimmed and non-empty: the id reaches a derived proxy port and a runtime lookup, + * and neither has anything to say about a blank one. `pluginId` is the runtime's + * own key — the catalogue's rows and this request name the same value, so a client + * never has to translate between an index and an identity. + */ +function validatePluginId( + operation: string, + body: unknown, +): WebuiOperationValidation<{ readonly pluginId: string }> { + if (body === null || typeof body !== "object" || Array.isArray(body)) + return { + ok: false, + code: WebuiErrorCode.invalidBody, + message: `${operation} body must be an object`, + }; + const pluginId = (body as Record).pluginId; + if (typeof pluginId !== "string" || !pluginId.trim()) + return { + ok: false, + code: WebuiErrorCode.invalidBody, + message: `${operation} body requires a non-empty pluginId`, + }; + if (pluginId.length > MAX_PLUGIN_ID_LENGTH) + return { + ok: false, + code: WebuiErrorCode.invalidBody, + message: `${operation} pluginId must be at most ${MAX_PLUGIN_ID_LENGTH} characters`, + }; + return { ok: true, body: { pluginId: pluginId.trim() } }; +} + +/** + * Starts one Mini App. The response is the loopback URL the client frames, not the + * Mini App's own origin: the origin, the surface path and the launch credential + * are the proxy's business (`server/miniapp/proxy.ts`) and never travel further + * than the server. + */ +export const openMiniAppOperation: WebuiOperation< + { readonly pluginId: string }, + WebuiMiniAppView +> = { + name: OPEN_MINI_APP_OPERATION_NAME, + validate: (body) => validatePluginId(OPEN_MINI_APP_OPERATION_NAME, body), +}; + +/** + * Stops one Mini App. Answers `success` rather than the runtime's `void` so the + * client has a body to read: an empty response and a dropped connection would + * otherwise look the same. + */ +export const stopMiniAppOperation: WebuiOperation< + { readonly pluginId: string }, + { readonly success: true } +> = { + name: STOP_MINI_APP_OPERATION_NAME, + validate: (body) => validatePluginId(STOP_MINI_APP_OPERATION_NAME, body), +}; diff --git a/packages/webui/src/server/operation/operations.ts b/packages/webui/src/server/operation/operations.ts index 442d565e9..5eea0c1dd 100644 --- a/packages/webui/src/server/operation/operations.ts +++ b/packages/webui/src/server/operation/operations.ts @@ -10,6 +10,7 @@ export { sendMessageOperation, enqueueMessageOperation, resumeSessionOperation } export { watchEventsOperation, listPendingPermissionsOperation, getPendingQuestionnaireOperation, replyPermissionOperation, replyQuestionnaireOperation, dismissQuestionnaireOperation } from "./questionnaire.js"; export { abortSessionOperation, listQueueMessagesOperation, deleteQueueItemOperation, listModelsOperation, listSkillsOperation, selectModelOperation, getSessionUsageOperation } from "./queue.js"; export { pluginManagementOperation } from "./plugin-management.js"; +export { listMiniAppsOperation, openMiniAppOperation, stopMiniAppOperation } from "./miniapp.js"; export { getPermissionModeOperation, setPermissionModeOperation } from "./permission-mode.js"; export { getGlobalInstructionsOperation, setGlobalInstructionsOperation } from "./personalization.js"; export { getAgentMemoryOperation, setAgentMemoryOperation } from "./agent-memory.js"; @@ -21,6 +22,7 @@ import { type WebuiOperationPort, } from "./bind-handlers.js"; import { createWorkspaceHandlerEntries } from "./handlers/workspace.js"; +import { createMiniAppHandlerEntries } from "./handlers/miniapp.js"; import { createTerminalHandlerEntries } from "./handlers/terminal.js"; import { createBrowserHandlerEntries, @@ -63,7 +65,7 @@ export type { } from "./operation-contract.js"; /** - * Builds the operation registry: 86 statically declared bindings plus the 13 + * Builds the operation registry: 93 statically declared bindings plus the 16 * operations that keep dedicated handlers. * * The registration order below is observable on the wire and must not change; @@ -91,6 +93,7 @@ export function createOperationRegistry( ...createAccountHandlerEntries(port), ...createStreamHandlerEntries(port), ...createMessageHandlerEntries(port), + ...createMiniAppHandlerEntries(port), ...(terminal ? createTerminalHandlerEntries(terminal) : []), ...(browser ? createBrowserHandlerEntries(browser, browserInstallationKeys) : []), ]); @@ -265,6 +268,13 @@ export function createOperationRegistry( registerOperation(registry, entry("sendMessage")); registerOperation(registry, entry("enqueueMessage")); registerOperation(registry, entry("resumeSession")); + // The Mini App surface is registered unconditionally, like every capability + // since batch C: a host without the Mini App cell answers + // `capability_unavailable` from the handler, which is a different statement + // from "this operation does not exist here" and one the client can branch on. + registerOperation(registry, entry("listMiniApps")); + registerOperation(registry, entry("openMiniApp")); + registerOperation(registry, entry("stopMiniApp")); return registry; } diff --git a/packages/webui/src/shared/contracts/miniapp.ts b/packages/webui/src/shared/contracts/miniapp.ts new file mode 100644 index 000000000..844b9e783 --- /dev/null +++ b/packages/webui/src/shared/contracts/miniapp.ts @@ -0,0 +1,61 @@ +// Wire contract record: every exported declaration below records its wire +// purpose, its producer and its consumers, verified against the tree. +// Documentation only. +// +// | Declaration | Wire purpose | Producer | Consumers | +// | --- | --- | --- | --- | +// | `WebuiMiniAppRuntimeStatus` | The states a Mini App row can be in, as the runtime reports them. | Runtime `MiniAppSurfaceSummary.runtime.status` (`@mavis/shared/miniapp-surface`), copied field by field in `runtime/harness/miniapp.ts`. | `WebuiMiniAppSummary`; the row a picker renders. | +// | `WebuiMiniAppSummary` | One catalogue row: identity, display copy and the runtime state, which is what tells a stopped Mini App from a running one. | `listMiniApps` and the `miniApp` half of `openMiniApp` (`runtime/harness/miniapp.ts`; CliService). | `shared/operation-names.ts`; `runtime/port.ts`; `server/operation/handlers/miniapp.ts`. | +// | `WebuiMiniAppList` | The envelope `listMiniApps` answers with. | `server/operation/handlers/miniapp.ts`. | `shared/operation-names.ts`. | +// | `WebuiMiniAppView` | Where the client frames a started Mini App: the loopback proxy's URL, with the plugin it belongs to. It never carries the Mini App's own origin, surface path or launch credential. | `openMiniApp` (`server/operation/handlers/miniapp.ts`, from `server/miniapp/proxy.ts` `viewUrl`). | `shared/operation-names.ts`; unverified — no importer yet; the panel that frames `viewUrl` is a later slice of the same work. | + +/** + * A Mini App's runtime state, as the runtime names it. + * + * Kept a closed union rather than a string: the picker decides whether to offer + * "open" or "stop" from this field, and an unrecognised value would silently + * become one of those two decisions. + */ +export type WebuiMiniAppRuntimeStatus = + | "stopped" + | "starting" + | "running" + | "stopping" + | "failed"; + +/** + * One catalogue row. + * + * `runtime.kind` is present because a Mini App's runtime is a process here and + * the panel reads the kind before the status; a second kind would be a different + * surface, not another status. + */ +export interface WebuiMiniAppSummary { + readonly pluginId: string; + readonly displayName?: string; + readonly description?: string; + readonly iconUrl?: string; + readonly runtime: { + readonly kind: "process"; + readonly status: WebuiMiniAppRuntimeStatus; + readonly errorCode?: string; + }; +} + +/** The catalogue `listMiniApps` answers with. */ +export interface WebuiMiniAppList { + readonly miniApps: readonly WebuiMiniAppSummary[]; +} + +/** + * What `openMiniApp` hands the client: the proxy's own loopback URL. + * + * `viewUrl` points at the WebUI's proxy, so the document the client frames is + * same-machine and the credential the Mini App's guard demands is injected on + * the way out. Neither that credential nor the Mini App's real origin has any + * place in this record. + */ +export interface WebuiMiniAppView { + readonly pluginId: string; + readonly viewUrl: string; +} diff --git a/packages/webui/src/shared/operation-names.ts b/packages/webui/src/shared/operation-names.ts index e9686ad3d..b07f2214c 100644 --- a/packages/webui/src/shared/operation-names.ts +++ b/packages/webui/src/shared/operation-names.ts @@ -165,6 +165,13 @@ export const GET_USER_PROFILE_OPERATION_NAME = "getUserProfile" as const; export const SET_USER_PROFILE_OPERATION_NAME = "setUserProfile" as const; export const GET_MEMORY_SETTINGS_OPERATION_NAME = "getMemorySettings" as const; export const SET_MEMORY_SETTINGS_OPERATION_NAME = "setMemorySettings" as const; +// The Mini App surface. Three operations over one runtime capability: the +// catalogue, the launch, and the stop. `openMiniApp` answers with a loopback +// view URL rather than the Mini App's own coordinates, so the credential the +// Mini App's guard demands never leaves the server. +export const LIST_MINI_APPS_OPERATION_NAME = "listMiniApps" as const; +export const OPEN_MINI_APP_OPERATION_NAME = "openMiniApp" as const; +export const STOP_MINI_APP_OPERATION_NAME = "stopMiniApp" as const; import type { WebuiActiveTurnResult, @@ -297,6 +304,10 @@ import type { WebuiRunCommandResult, } from "./contracts/terminal.js"; import type { WebuiPluginManagementRequest } from "./plugin-management.js"; +import type { + WebuiMiniAppList, + WebuiMiniAppView, +} from "./contracts/miniapp.js"; /** * The complete operation table: every wire operation name mapped to the @@ -755,6 +766,28 @@ export interface OperationSpec { request: { readonly enabled?: boolean; readonly proactive?: boolean }; response: WebuiMemorySettingsView; }; + /** + * The Mini App catalogue. A row's `runtime.status` is why this is not just a + * plugin list: the picker offers "open" or "stop" from it. + */ + listMiniApps: { request: undefined; response: WebuiMiniAppList }; + /** + * Starts one Mini App and names where to frame it. + * + * The response is the loopback proxy's `viewUrl` and the plugin it belongs to. + * The Mini App's own origin, surface path and launch credential stay on the + * server: the proxy is the only thing that sees them, and it injects the + * credential on the way out. + */ + openMiniApp: { + request: { readonly pluginId: string }; + response: WebuiMiniAppView; + }; + /** Stops one Mini App and closes the proxy that was serving it. */ + stopMiniApp: { + request: { readonly pluginId: string }; + response: { readonly success: true }; + }; } /** diff --git a/packages/webui/test/gates/fixtures/path-metadata-f00.json b/packages/webui/test/gates/fixtures/path-metadata-f00.json index dc40202c7..9a1667d54 100644 --- a/packages/webui/test/gates/fixtures/path-metadata-f00.json +++ b/packages/webui/test/gates/fixtures/path-metadata-f00.json @@ -3657,6 +3657,15 @@ "business": null, "home": "outside" }, + { + "path": "runtime/harness/miniapp.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "runtime/harness/models-plugins.ts", "layers": [ @@ -4107,6 +4116,24 @@ "business": null, "home": "outside" }, + { + "path": "server/miniapp/presenter.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, + { + "path": "server/miniapp/proxy.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "server/operation/account.ts", "layers": [ @@ -4215,6 +4242,15 @@ "business": null, "home": "outside" }, + { + "path": "server/operation/handlers/miniapp.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "server/operation/handlers/stream.ts", "layers": [ @@ -4269,6 +4305,15 @@ "business": null, "home": "outside" }, + { + "path": "server/operation/miniapp.ts", + "layers": [ + "server" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "server/operation/operation-contract.ts", "layers": [ @@ -4557,6 +4602,15 @@ "business": null, "home": "outside" }, + { + "path": "shared/contracts/miniapp.ts", + "layers": [ + "shared" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "shared/contracts/models.ts", "layers": [ diff --git a/packages/webui/test/unit/miniapp-operations.test.ts b/packages/webui/test/unit/miniapp-operations.test.ts new file mode 100644 index 000000000..11e0e52c0 --- /dev/null +++ b/packages/webui/test/unit/miniapp-operations.test.ts @@ -0,0 +1,558 @@ +/** + * The Mini App wire operation vertical. + * + * Three operations, one runtime capability, and one seam that has to stay sealed: + * `openMiniApp` starts a Mini App through the runtime facade and answers with the + * **proxy's** URL — the Mini App's own origin, surface path and launch credential + * never appear in a response. The proxy itself is real here (a listener on a + * loopback port forwarding to a real upstream `http.Server`), so "one proxy per + * plugin" is asserted against a listener that actually binds rather than against a + * call count alone: a second listener on the same derived port could not bind at + * all. + * + * The runtime side is a scripted facade behind the harness port (evidence level + * 3). What is *not* covered here: the runtime's own start/stop path, the + * credential its guard installs, and an Electron/desktop consumer of `viewUrl`. + */ +import http from "node:http"; +import type { Duplex } from "node:stream"; + +import { MINIAPP_LOOPBACK_TOKEN_HEADER } from "@mavis/shared/miniapp-surface"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createHarnessPortFromHost } from "../../src/runtime/harness/adapter.js"; +import { MINIAPP_CAPABILITY_ABSENT } from "../../src/runtime/port.js"; +import type { WebuiOperationPort } from "../../src/server/operation/bind-handlers.js"; +import { createMiniAppHandlerEntries } from "../../src/server/operation/handlers/miniapp.js"; +import { + listMiniAppsOperation, + openMiniAppOperation, + stopMiniAppOperation, +} from "../../src/server/operation/miniapp.js"; +import { createOperationRegistry } from "../../src/server/operation/operations.js"; +import type { WebuiOperationRegistryEntry } from "../../src/server/operation/operation-contract.js"; +import { + deriveMiniAppProxyPort, + startMiniAppProxy, + type MiniAppProxy, + type MiniAppProxyTarget, + type StartMiniAppProxyInput, +} from "../../src/server/miniapp/proxy.js"; +import { WebuiErrorCode } from "../../src/shared/envelope.js"; +import type { WebuiMiniAppSummary } from "../../src/shared/contracts/miniapp.js"; + +const CREDENTIAL = "miniapp-credential-0123456789"; +const SECOND_CREDENTIAL = "miniapp-credential-9876543210"; +const SURFACE_PATH = "/surface/dashboard"; + +/** One plugin id per case, so each case derives a port of its own. */ +const VIEW_PLUGIN = "operation.miniapp.view"; +const REPLACE_PLUGIN = "operation.miniapp.replace"; +const STOP_PLUGIN = "operation.miniapp.stop"; +const UNREACHABLE_PLUGIN = "operation.miniapp.unreachable"; + +interface Seen { + readonly headers: http.IncomingHttpHeaders; +} + +const seen: Seen[] = []; +const upgraded = new Set(); +let upstream: http.Server; +let upstreamOrigin = ""; + +beforeAll(async () => { + upstream = http.createServer((request, response) => { + seen.push({ headers: { ...request.headers } }); + response.writeHead(200, { "content-type": "text/plain" }).end("from-upstream"); + }); + upstream.on("upgrade", (request, socket) => { + upgraded.add(socket); + socket.once("close", () => upgraded.delete(socket)); + seen.push({ headers: { ...request.headers } }); + socket.write("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\n\r\n"); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const address = upstream.address(); + const port = typeof address === "object" && address ? address.port : 0; + upstreamOrigin = `http://127.0.0.1:${port}`; +}); + +afterAll(async () => { + // An upgraded socket leaves the server's own tracking, so the fixture ends it + // explicitly — the same teardown the proxy suite uses. + for (const socket of upgraded) socket.destroy(); + upgraded.clear(); + upstream?.closeAllConnections(); + await new Promise((resolve) => upstream?.close(() => resolve())); +}); + +interface FacadeCalls { + readonly opened: Array<{ readonly pluginId: string; readonly signal?: AbortSignal }>; + readonly stopped: string[]; + /** Makes the next open of this plugin answer with a fresh credential. */ + rotateCredential(pluginId: string, credential: string): void; +} + +/** + * The runtime side, scripted. It answers exactly what the port declares: publishable + * rows for the catalogue, and server-only launch coordinates for an open. + */ +function createFacade(): { readonly port: WebuiOperationPort; readonly calls: FacadeCalls } { + const opened: Array<{ pluginId: string; signal?: AbortSignal }> = []; + const stopped: string[] = []; + const credentials = new Map(); + const summary = (pluginId: string): WebuiMiniAppSummary => ({ + pluginId, + displayName: "Fixture Mini App", + runtime: { kind: "process", status: "running" }, + }); + const port = { + async listMiniApps() { + return { miniApps: [summary(VIEW_PLUGIN), summary(REPLACE_PLUGIN)] }; + }, + async openMiniApp(request: { pluginId: string; signal?: AbortSignal }) { + opened.push(request); + return { + miniApp: summary(request.pluginId), + launch: { + origin: upstreamOrigin, + surfacePath: SURFACE_PATH, + runId: `run-${opened.length}`, + credential: credentials.get(request.pluginId) ?? CREDENTIAL, + credentialHeader: MINIAPP_LOOPBACK_TOKEN_HEADER, + }, + }; + }, + async stopMiniApp(request: { pluginId: string }) { + stopped.push(request.pluginId); + }, + } as unknown as WebuiOperationPort; + return { + port, + calls: { + opened, + stopped, + rotateCredential: (pluginId, credential) => credentials.set(pluginId, credential), + }, + }; +} + +type Outcome = Awaited>; + +function bodyOf(outcome: Outcome): unknown { + return "body" in outcome ? outcome.body : undefined; +} + +/** One handler set, invoked through the same validate → handle path the wire uses. */ +function createHandlers( + port: WebuiOperationPort, + startProxy?: typeof startMiniAppProxy, +): (name: string, body: unknown) => Promise { + const entries = createMiniAppHandlerEntries(port, startProxy ? { startProxy } : {}); + return async (name, body) => { + const entry = entries.get(name); + if (!entry) throw new Error(`no Mini App handler for ${name}`); + const validated = entry.operation.validate(body); + if (!validated.ok) throw new Error(`rejected: ${validated.message}`); + return bodyOf(await entry.handle({ requestId: `request-${name}` }, validated.body)); + }; +} + +interface StarterRecord { + readonly target: MiniAppProxyTarget; + readonly viewUrl: string; + readonly proxy: MiniAppProxy; + closed: boolean; +} + +/** + * The real proxy, with the lifecycle it reports made observable. + * + * Wrapping rather than replacing: the listener, its derived port and its + * forwarding are the production ones, so a leak would be a real second listener + * and not a simulated one. + */ +function countingStarter() { + const records: StarterRecord[] = []; + const startProxy = async (input: StartMiniAppProxyInput): Promise => { + const proxy = await startMiniAppProxy(input); + const record: StarterRecord = { + target: input.target, + viewUrl: proxy.viewUrl, + proxy, + closed: false, + }; + records.push(record); + return { + port: proxy.port, + viewUrl: proxy.viewUrl, + async close() { + record.closed = true; + await proxy.close(); + }, + }; + }; + const closeAll = async () => { + for (const record of records) { + if (record.closed) continue; + record.closed = true; + await record.proxy.close(); + } + }; + return { records, startProxy, closeAll }; +} + +interface Fetched { + readonly status: number; + readonly body: string; + readonly headers: http.IncomingHttpHeaders; +} + +function get(url: string): Promise { + return new Promise((resolve, reject) => { + const request = http.request(url, { agent: false }, (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + body += chunk; + }); + response.on("end", () => + resolve({ status: response.statusCode ?? 0, body, headers: response.headers }), + ); + }); + request.on("error", reject); + request.end(); + }); +} + +describe("the Mini App descriptors", () => { + it("refuses a body on the catalogue operation", () => { + expect(listMiniAppsOperation.validate(undefined)).toEqual({ ok: true, body: undefined }); + expect(listMiniAppsOperation.validate({})).toEqual({ + ok: false, + code: WebuiErrorCode.invalidBody, + message: "listMiniApps does not accept a body", + }); + }); + + it("requires a non-empty, bounded pluginId on the two mutations", () => { + for (const operation of [openMiniAppOperation, stopMiniAppOperation]) { + expect(operation.validate(null)).toEqual({ + ok: false, + code: WebuiErrorCode.invalidBody, + message: `${operation.name} body must be an object`, + }); + expect(operation.validate({})).toEqual({ + ok: false, + code: WebuiErrorCode.invalidBody, + message: `${operation.name} body requires a non-empty pluginId`, + }); + expect(operation.validate({ pluginId: " " })).toEqual({ + ok: false, + code: WebuiErrorCode.invalidBody, + message: `${operation.name} body requires a non-empty pluginId`, + }); + expect(operation.validate({ pluginId: 7 })).toEqual({ + ok: false, + code: WebuiErrorCode.invalidBody, + message: `${operation.name} body requires a non-empty pluginId`, + }); + expect(operation.validate({ pluginId: "x".repeat(201) })).toEqual({ + ok: false, + code: WebuiErrorCode.invalidBody, + message: `${operation.name} pluginId must be at most 200 characters`, + }); + expect(operation.validate({ pluginId: " fixture.miniapp " })).toEqual({ + ok: true, + body: { pluginId: "fixture.miniapp" }, + }); + } + }); +}); + +describe("a host without the Mini App capability", () => { + it("fails each operation with a typed refusal rather than an empty success", async () => { + const handle = createHandlers({} as WebuiOperationPort); + const cases: Array<[string, unknown]> = [ + ["listMiniApps", undefined], + ["openMiniApp", { pluginId: VIEW_PLUGIN }], + ["stopMiniApp", { pluginId: VIEW_PLUGIN }], + ]; + for (const [name, body] of cases) { + const failure = await handle(name, body).catch((error: unknown) => error); + expect(failure, name).toBeInstanceOf(Error); + expect((failure as Error).name, name).toBe("WebuiCapabilityUnavailableError"); + expect( + (failure as { readonly code?: unknown }).code, + name, + ).toBe(WebuiErrorCode.capabilityUnavailable); + expect((failure as Error).message, name).toBe(MINIAPP_CAPABILITY_ABSENT); + } + }); + + it("still registers the three operations, so the refusal is not an unknown operation", () => { + const registry = createOperationRegistry({} as WebuiOperationPort); + for (const name of ["listMiniApps", "openMiniApp", "stopMiniApp"]) + expect(registry.has(name), name).toBe(true); + }); +}); + +describe("listMiniApps", () => { + it("answers with the runtime catalogue, rows and runtime state included", async () => { + const { port } = createFacade(); + const handle = createHandlers(port); + + expect(await handle("listMiniApps", undefined)).toEqual({ + miniApps: [ + { + pluginId: VIEW_PLUGIN, + displayName: "Fixture Mini App", + runtime: { kind: "process", status: "running" }, + }, + { + pluginId: REPLACE_PLUGIN, + displayName: "Fixture Mini App", + runtime: { kind: "process", status: "running" }, + }, + ], + }); + }); +}); + +describe("openMiniApp over a real loopback proxy", () => { + it("answers with the proxy's URL and never the Mini App's own coordinates", async () => { + const { port } = createFacade(); + const starter = countingStarter(); + const handle = createHandlers(port, starter.startProxy); + try { + const body = await handle("openMiniApp", { pluginId: VIEW_PLUGIN }); + const proxyPort = deriveMiniAppProxyPort(VIEW_PLUGIN); + + expect(body).toEqual({ + pluginId: VIEW_PLUGIN, + viewUrl: `http://127.0.0.1:${proxyPort}`, + }); + expect(Object.keys(body as object).sort()).toEqual(["pluginId", "viewUrl"]); + const serialised = JSON.stringify(body); + expect(serialised).not.toContain(CREDENTIAL); + expect(serialised).not.toContain(MINIAPP_LOOPBACK_TOKEN_HEADER); + expect(serialised).not.toContain(SURFACE_PATH); + expect(serialised).not.toContain(upstreamOrigin); + + // The URL is not decorative: it serves the page, and the credential the + // runtime issued reaches the Mini App's own guard from the proxy. + expect(starter.records).toHaveLength(1); + expect(starter.records[0]?.target).toEqual({ + pluginId: VIEW_PLUGIN, + upstreamOrigin, + credential: CREDENTIAL, + credentialHeader: MINIAPP_LOOPBACK_TOKEN_HEADER, + }); + const fetched = await get(`http://127.0.0.1:${proxyPort}/`); + expect(fetched.status).toBe(200); + expect(fetched.body).toBe("from-upstream"); + expect(seen.at(-1)?.headers[MINIAPP_LOOPBACK_TOKEN_HEADER]).toBe(CREDENTIAL); + expect(fetched.headers).not.toHaveProperty(MINIAPP_LOOPBACK_TOKEN_HEADER); + } finally { + await handle("stopMiniApp", { pluginId: VIEW_PLUGIN }).catch(() => undefined); + await starter.closeAll(); + } + }); + + it("keeps one listener per plugin: a second open replaces it instead of doubling it", async () => { + const { port, calls } = createFacade(); + const starter = countingStarter(); + const handle = createHandlers(port, starter.startProxy); + const proxyPort = deriveMiniAppProxyPort(REPLACE_PLUGIN); + try { + const first = await handle("openMiniApp", { pluginId: REPLACE_PLUGIN }); + calls.rotateCredential(REPLACE_PLUGIN, SECOND_CREDENTIAL); + const second = await handle("openMiniApp", { pluginId: REPLACE_PLUGIN }); + + // Two opens, two starts, and the second bound the same derived port — which + // it could not have done had the first listener still been listening. + expect(calls.opened).toHaveLength(2); + expect(starter.records).toHaveLength(2); + expect(starter.records[0]?.closed).toBe(true); + expect(starter.records[1]?.closed).toBe(false); + expect(first).toEqual(second); + expect((second as { readonly viewUrl: string }).viewUrl).toBe( + `http://127.0.0.1:${proxyPort}`, + ); + + // The live listener is the replacement: it injects the credential the second + // launch issued, not the one the closed proxy was built with. + await get(`http://127.0.0.1:${proxyPort}/`); + expect(seen.at(-1)?.headers[MINIAPP_LOOPBACK_TOKEN_HEADER]).toBe(SECOND_CREDENTIAL); + } finally { + await handle("stopMiniApp", { pluginId: REPLACE_PLUGIN }).catch(() => undefined); + await starter.closeAll(); + } + }); + + it("serialises two opens for one plugin instead of racing them onto one port", async () => { + const { port, calls } = createFacade(); + const starter = countingStarter(); + const handle = createHandlers(port, starter.startProxy); + try { + const bodies = await Promise.all([ + handle("openMiniApp", { pluginId: UNREACHABLE_PLUGIN }), + handle("openMiniApp", { pluginId: UNREACHABLE_PLUGIN }), + ]); + + expect(calls.opened).toHaveLength(2); + expect(starter.records).toHaveLength(2); + expect(starter.records[0]?.closed).toBe(true); + expect(starter.records.filter((record) => !record.closed)).toHaveLength(1); + expect(bodies[0]).toEqual(bodies[1]); + expect(calls.stopped).toEqual([]); + } finally { + await handle("stopMiniApp", { pluginId: UNREACHABLE_PLUGIN }).catch(() => undefined); + await starter.closeAll(); + } + }); + + it("stops the runtime it started when its proxy cannot be bound", async () => { + const { port, calls } = createFacade(); + const handle = createHandlers(port, async () => { + throw new Error("loopback port is already in use"); + }); + + await expect(handle("openMiniApp", { pluginId: STOP_PLUGIN })).rejects.toThrow( + "loopback port is already in use", + ); + expect(calls.stopped).toEqual([STOP_PLUGIN]); + }); +}); + +describe("stopMiniApp", () => { + it("stops the runtime, closes that plugin's proxy and leaves no listener behind", async () => { + const { port, calls } = createFacade(); + const starter = countingStarter(); + const handle = createHandlers(port, starter.startProxy); + const proxyPort = deriveMiniAppProxyPort(STOP_PLUGIN); + try { + const opened = await handle("openMiniApp", { pluginId: STOP_PLUGIN }); + expect((opened as { readonly viewUrl: string }).viewUrl).toBe( + `http://127.0.0.1:${proxyPort}`, + ); + expect((await get(`http://127.0.0.1:${proxyPort}/`)).status).toBe(200); + + expect(await handle("stopMiniApp", { pluginId: STOP_PLUGIN })).toEqual({ success: true }); + expect(calls.stopped).toEqual([STOP_PLUGIN]); + expect(starter.records[0]?.closed).toBe(true); + await expect(get(`http://127.0.0.1:${proxyPort}/`)).rejects.toThrow(); + + // A stop for a plugin that is not running is still a stop: the runtime is + // asked, and there is no listener to close. + expect(await handle("stopMiniApp", { pluginId: STOP_PLUGIN })).toEqual({ success: true }); + expect(calls.stopped).toEqual([STOP_PLUGIN, STOP_PLUGIN]); + } finally { + await starter.closeAll(); + } + }); +}); + +describe("the runtime adapter behind the port", () => { + const runtimeSummary = { + pluginId: "fixture.miniapp", + displayName: "Fixture Mini App", + runtime: { kind: "process", status: "running" }, + // Not part of the publishable row; must not survive the conversion. + launch: { credential: CREDENTIAL }, + }; + + function scriptedHost() { + const calls: string[] = []; + const host = { + apiHost: { close: async () => {} }, + cliService: { + listMiniApps: async () => { + calls.push("listMiniApps"); + return { miniApps: [runtimeSummary] }; + }, + openMiniApp: async (request: { pluginId: string; signal?: AbortSignal }) => { + calls.push(`openMiniApp:${request.pluginId}`); + return { + miniApp: runtimeSummary, + launch: { + origin: upstreamOrigin, + surfacePath: SURFACE_PATH, + runId: "run-1", + credential: CREDENTIAL, + }, + }; + }, + stopMiniApp: async (request: { pluginId: string }) => { + calls.push(`stopMiniApp:${request.pluginId}`); + }, + } as never, + }; + return { port: createHarnessPortFromHost(host), calls }; + } + + it("publishes the catalogue row without the fields the wire does not carry", async () => { + const { port } = scriptedHost(); + + const list = await port.listMiniApps!(); + + expect(list).toEqual({ + miniApps: [ + { + pluginId: "fixture.miniapp", + displayName: "Fixture Mini App", + runtime: { kind: "process", status: "running" }, + }, + ], + }); + expect(JSON.stringify(list)).not.toContain(CREDENTIAL); + }); + + it("hands the launch over with the credential and the header its guard reads", async () => { + const { port, calls } = scriptedHost(); + + const launch = await port.openMiniApp!({ pluginId: "fixture.miniapp" }); + + expect(calls).toEqual(["openMiniApp:fixture.miniapp"]); + expect(launch).toEqual({ + miniApp: { + pluginId: "fixture.miniapp", + displayName: "Fixture Mini App", + runtime: { kind: "process", status: "running" }, + }, + launch: { + origin: upstreamOrigin, + surfacePath: SURFACE_PATH, + runId: "run-1", + credential: CREDENTIAL, + credentialHeader: MINIAPP_LOOPBACK_TOKEN_HEADER, + }, + }); + }); + + it("forwards a stop", async () => { + const { port, calls } = scriptedHost(); + + await expect(port.stopMiniApp!({ pluginId: "fixture.miniapp" })).resolves.toBeUndefined(); + expect(calls).toEqual(["stopMiniApp:fixture.miniapp"]); + }); + + it("refuses when the runtime's facade has no Mini App cell", async () => { + const port = createHarnessPortFromHost({ + apiHost: { close: async () => {} }, + cliService: {} as never, + }); + + for (const call of [ + () => port.listMiniApps!(), + () => port.openMiniApp!({ pluginId: "fixture.miniapp" }), + () => port.stopMiniApp!({ pluginId: "fixture.miniapp" }), + ]) { + const failure = await call().catch((error: unknown) => error); + expect((failure as { readonly code?: unknown }).code).toBe( + WebuiErrorCode.capabilityUnavailable, + ); + expect((failure as Error).message).toBe(MINIAPP_CAPABILITY_ABSENT); + } + }); +}); diff --git a/packages/webui/test/unit/webui-operation-binding.test.ts b/packages/webui/test/unit/webui-operation-binding.test.ts index 34448315f..8c82d6995 100644 --- a/packages/webui/test/unit/webui-operation-binding.test.ts +++ b/packages/webui/test/unit/webui-operation-binding.test.ts @@ -1,7 +1,7 @@ // Unit tests for the typed operation bindings and their executor. // // These pin the *declaration* surface: every operation is either a binding or -// one of the thirteen dedicated handlers, a binding calls exactly the runtime +// one of the sixteen dedicated handlers, a binding calls exactly the runtime // method it names with exactly the arguments its declaration computes, the // receiver is preserved, a missing capability fails at invocation (never at // assembly) with the historical error class and message, a capability @@ -45,9 +45,9 @@ describe("WebUI operation bindings", () => { it("covers every operation exactly once as a binding or a dedicated handler", () => { const bindingNames = Object.keys(WEBUI_OPERATION_BINDINGS); expect(bindingNames).toHaveLength(93); - expect(DEDICATED_OPERATION_NAMES).toHaveLength(13); + expect(DEDICATED_OPERATION_NAMES).toHaveLength(16); const all = new Set([...bindingNames, ...DEDICATED_OPERATION_NAMES]); - expect(all.size).toBe(106); + expect(all.size).toBe(109); for (const dedicated of DEDICATED_OPERATION_NAMES) expect(bindingNames).not.toContain(dedicated); }); diff --git a/release/public-source.json b/release/public-source.json index 6f215a391..855c657d9 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -4089,6 +4089,7 @@ "packages/webui/src/runtime/harness/execution.ts", "packages/webui/src/runtime/harness/host-contract.ts", "packages/webui/src/runtime/harness/interactions.ts", + "packages/webui/src/runtime/harness/miniapp.ts", "packages/webui/src/runtime/harness/models-plugins.ts", "packages/webui/src/runtime/harness/provider-input.ts", "packages/webui/src/runtime/harness/requirements.ts", @@ -4152,12 +4153,14 @@ "packages/webui/src/server/operation/handlers/browser.ts", "packages/webui/src/server/operation/handlers/commands.ts", "packages/webui/src/server/operation/handlers/messages.ts", + "packages/webui/src/server/operation/handlers/miniapp.ts", "packages/webui/src/server/operation/handlers/stream.ts", "packages/webui/src/server/operation/handlers/terminal.ts", "packages/webui/src/server/operation/handlers/workspace.ts", "packages/webui/src/server/operation/interaction.ts", "packages/webui/src/server/operation/memory-settings.ts", "packages/webui/src/server/operation/messages.ts", + "packages/webui/src/server/operation/miniapp.ts", "packages/webui/src/server/operation/operation-contract.ts", "packages/webui/src/server/operation/operation-dispatch.ts", "packages/webui/src/server/operation/operations.ts", @@ -4189,6 +4192,7 @@ "packages/webui/src/shared/contracts/host-event-types.generated.ts", "packages/webui/src/shared/contracts/interactions.ts", "packages/webui/src/shared/contracts/messages.ts", + "packages/webui/src/shared/contracts/miniapp.ts", "packages/webui/src/shared/contracts/models.ts", "packages/webui/src/shared/contracts/observations.ts", "packages/webui/src/shared/contracts/personalization.ts", @@ -4346,6 +4350,7 @@ "packages/webui/test/unit/markdown-mermaid.test.tsx", "packages/webui/test/unit/message-attachments.test.tsx", "packages/webui/test/unit/message-parts.test.ts", + "packages/webui/test/unit/miniapp-operations.test.ts", "packages/webui/test/unit/miniapp-presenter.test.ts", "packages/webui/test/unit/miniapp-proxy.test.ts", "packages/webui/test/unit/model-effort-scale.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 443051f2f..144a08a0a 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -476,6 +476,7 @@ "packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts", "packages/webui/test/unit/miniapp-proxy.test.ts", "packages/webui/test/unit/miniapp-presenter.test.ts", + "packages/webui/test/unit/miniapp-operations.test.ts", "packages/webui/test/unit/workspace-write-fence.test.ts" ] } From 1f6184b0d2db0b076090cecd77e50acb5779a55a Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 17:59:30 +0800 Subject: [PATCH 20/43] feat(webui): host a Mini App in the workspace panel and the plugin catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mini App is now reachable end to end: the plugin catalogue's apps area lists the installed ones with their runtime state and their lifecycle, the launcher's tile opens a picker fed by the same listing, and choosing one opens a workspace tab that frames the loopback view URL the open operation answers with. One business owns all of it (ADR 0020): the catalogue, the presentation each Mini App is shown in, the refusals a start earned, and the host half of the page bridge. The plugin catalogue and the workspace panel mount its published surfaces and hold no Mini App state of their own; a `miniapp` tab carries only the session and the plugin it shows. The page bridge is injected by the proxy, validates the sender's frame and origin, and asks the person before appending to the composer — declining leaves the draft untouched. A refused fourth start names the three that are running and offers to stop one. An app another client owns is reported with that client and its pid. A stopped runtime clears the view, says why, and reopens only when a person asks. The service now closes every Mini App listener on shutdown, including both production entrypoints, and the runtime is handed the browser presenter through the assembly option. The `webui` row enables `miniAppService` because the presenter and the view land with it. --- .../src/runtime/runtime-owner-policy.ts | 8 +- .../test/unit/runtime-owner-policy.test.ts | 12 +- .../client/application/create-application.ts | 8 + .../src/client/application/port-slices.ts | 15 +- .../webui/src/client/contracts/transport.ts | 6 +- .../application/composer-controller.ts | 3 +- .../composer/contracts/composer-state.ts | 10 + .../miniapp/application/miniapp-bridge.ts | 105 +++++ .../miniapp/application/miniapp-workflows.ts | 307 +++++++++++++++ .../bindings/use-miniapp-workflows.tsx | 53 +++ .../miniapp/contracts/miniapp-port.ts | 41 ++ .../miniapp/projection/miniapp-view.ts | 119 ++++++ .../miniapp/ui/MiniAppAppsList.public.tsx | 146 +++++++ .../miniapp/ui/MiniAppPicker.public.tsx | 130 +++++++ .../miniapp/ui/MiniAppView.public.tsx | 190 +++++++++ .../features/plugins/ui/PluginCatalogue.tsx | 11 + .../workspace/contracts/panel-command.ts | 4 +- .../projection/workspace-panel-state.ts | 60 ++- .../ui/WebuiWorkspaceTabStrip.public.tsx | 38 +- .../features/workspace/ui/WorkspacePanels.tsx | 17 + .../webui/src/client/platform/transport.ts | 6 + .../client/shell/WebuiClientFoundationApp.tsx | 8 + packages/webui/src/runtime/assembly.ts | 17 + packages/webui/src/server/index.ts | 10 +- packages/webui/src/server/miniapp/bridge.ts | 86 +++++ .../webui/src/server/miniapp/presenter.ts | 14 +- .../src/server/miniapp/proxy-registry.ts | 63 +++ .../src/server/operation/handlers/miniapp.ts | 46 ++- .../webui/src/server/operation/operations.ts | 8 +- packages/webui/src/server/service.ts | 25 ++ .../webui/src/shared/contracts/miniapp.ts | 62 +++ .../gates/fixtures/path-metadata-f00.json | 90 +++++ .../unit/browser-workspace-panel.test.tsx | 7 +- .../webui/test/unit/miniapp-bridge.test.ts | 162 ++++++++ .../test/unit/miniapp-client-state.test.ts | 361 ++++++++++++++++++ .../webui/test/unit/miniapp-surfaces.test.tsx | 263 +++++++++++++ .../webui/test/unit/webui-port-slices.test.ts | 6 + .../test/unit/workspace-tab-registry.test.ts | 5 + release/public-source.json | 13 + release/webui-npm/bin/mcode-webui.mjs | 11 +- scripts/run-webui-server.mjs | 24 +- test/vitest-suites.json | 5 +- 42 files changed, 2531 insertions(+), 44 deletions(-) create mode 100644 packages/webui/src/client/features/miniapp/application/miniapp-bridge.ts create mode 100644 packages/webui/src/client/features/miniapp/application/miniapp-workflows.ts create mode 100644 packages/webui/src/client/features/miniapp/bindings/use-miniapp-workflows.tsx create mode 100644 packages/webui/src/client/features/miniapp/contracts/miniapp-port.ts create mode 100644 packages/webui/src/client/features/miniapp/projection/miniapp-view.ts create mode 100644 packages/webui/src/client/features/miniapp/ui/MiniAppAppsList.public.tsx create mode 100644 packages/webui/src/client/features/miniapp/ui/MiniAppPicker.public.tsx create mode 100644 packages/webui/src/client/features/miniapp/ui/MiniAppView.public.tsx create mode 100644 packages/webui/src/server/miniapp/bridge.ts create mode 100644 packages/webui/src/server/miniapp/proxy-registry.ts create mode 100644 packages/webui/test/unit/miniapp-bridge.test.ts create mode 100644 packages/webui/test/unit/miniapp-client-state.test.ts create mode 100644 packages/webui/test/unit/miniapp-surfaces.test.tsx diff --git a/packages/local-runtime/src/runtime/runtime-owner-policy.ts b/packages/local-runtime/src/runtime/runtime-owner-policy.ts index 5f775c991..899fc9d4f 100644 --- a/packages/local-runtime/src/runtime/runtime-owner-policy.ts +++ b/packages/local-runtime/src/runtime/runtime-owner-policy.ts @@ -376,9 +376,11 @@ export function resolveRuntimeOwnerPolicy( // layer and with it the questionnaire service. ownsV2Runtime: isV2RuntimeOwner('tui', electronHost, cliEmbedded), schedulerHost: true, - // Stays false until the presenter and the view land with it: a cell flipped on its own - // leaves the agent able to start a Mini App that nothing can display. - miniAppService: false, + // Enabled with the presenter and the view that landed beside it (ADR 0020): + // the browser presenter records the presentation, and the workspace panel's + // `miniapp` tab frames the proxy URL the open operation answers with. Flipped + // earlier, the agent could start a Mini App that nothing can display. + miniAppService: true, cronService: true, channelService: false, browserActivation: 'explicit-config', diff --git a/packages/local-runtime/test/unit/runtime-owner-policy.test.ts b/packages/local-runtime/test/unit/runtime-owner-policy.test.ts index 9c1d5ff1d..d27a3bd3b 100644 --- a/packages/local-runtime/test/unit/runtime-owner-policy.test.ts +++ b/packages/local-runtime/test/unit/runtime-owner-policy.test.ts @@ -313,13 +313,15 @@ describe('resolveRuntimeOwnerPolicy', () => { expect(policy.execution.mavisFeatureBundle).toBe(true); }); - it("'webui' resolves to its own row, which differs from tui in six cells", () => { - // Read as "where WebUI differs from tui": seven cells are inherited so an + it("'webui' resolves to its own row, which differs from tui in seven cells", () => { + // Read as "where WebUI differs from tui": the remaining cells are inherited so an // identity change does not silently move turn-execution semantics. // // Compared as the two clients actually declare today: `tui` still carries // capabilityProfile 'cli', while the webui row sets mavisFeatureBundle explicitly. - // That is why it is one of the six differences rather than an inherited cell. + // That is why it is one of the differences rather than an inherited cell. + // `miniAppService` is another: it is enabled here because the presenter, the view + // and the cell landed together (ADR 0020), and `tui` has no Mini App surface at all. const webui = policyFor({ kind: 'webui', cliEmbedded: true, @@ -345,13 +347,15 @@ describe('resolveRuntimeOwnerPolicy', () => { 'execution.memoryFeature', 'execution.restricted', 'wiring.cronService', + 'wiring.miniAppService', 'wiring.schedulerHost', ].sort(), ); - // The six differences themselves, asserted so the count is not the only check. + // The differences themselves, asserted so the count is not the only check. expect(webui.wiring.ownsV2Runtime).toBe(true); expect(webui.wiring.schedulerHost).toBe(true); expect(webui.wiring.cronService).toBe(true); + expect(webui.wiring.miniAppService).toBe(true); expect(webui.wiring.channelService).toBe(false); expect(webui.wiring.browserActivation).toBe('explicit-config'); expect(webui.execution.restricted).toBe(false); diff --git a/packages/webui/src/client/application/create-application.ts b/packages/webui/src/client/application/create-application.ts index 40e0f58ed..3c3560086 100644 --- a/packages/webui/src/client/application/create-application.ts +++ b/packages/webui/src/client/application/create-application.ts @@ -40,6 +40,7 @@ import { createWebuiAccountWorkflows } from "../features/account/application/acc import { createWebuiModelsWorkflows } from "../features/models/application/models-workflows.js"; import { createWebuiSettingsWorkflows } from "../features/settings/application/settings-workflows.js"; import { createWebuiPluginWorkflows } from "../features/plugins/application/plugin-workflows.js"; +import { createWebuiMiniAppWorkflows } from "../features/miniapp/application/miniapp-workflows.js"; import type { WebuiPluginManagementAction } from "../../shared/plugin-management.js"; import { createWebuiComposerPluginCatalogue } from "../features/plugins/application/composer-catalogue.js"; import { createWebuiPluginActions } from "../features/plugins/contracts/plugin-actions.js"; @@ -352,6 +353,8 @@ export interface WebuiApplication { readonly modelsWorkflows: ReturnType; /** The plugin manager's owner. */ readonly pluginWorkflows: ReturnType; + /** The Mini App business's owner: catalogue, presentations and refusals. */ + readonly miniAppWorkflows: ReturnType; /** The composer's plugin/skill read model, separate from the manager's. */ readonly composerPluginCatalogue: ReturnType; /** The plan-review reply workflow. */ @@ -859,6 +862,10 @@ export function createWebuiApplication( } }, }); + // The Mini App business's owner. It reads the Mini App capability and the + // package lifecycle, because the catalogue's apps area performs both: a Mini App + // is a plugin, so enabling or uninstalling one is a plugin write. + const miniAppWorkflows = createWebuiMiniAppWorkflows({ port: slices.miniapp }); // The composer's view of what is installed and what the agent can run: a // separate read model from `pluginWorkflows` on purpose, so neither overwrites // the other when one reloads. @@ -1002,6 +1009,7 @@ export function createWebuiApplication( settingsWorkflows, modelsWorkflows, pluginWorkflows, + miniAppWorkflows, composerPluginCatalogue, activeTurnProbe, planReviewWorkflow, diff --git a/packages/webui/src/client/application/port-slices.ts b/packages/webui/src/client/application/port-slices.ts index 54e5136cd..969a479ff 100644 --- a/packages/webui/src/client/application/port-slices.ts +++ b/packages/webui/src/client/application/port-slices.ts @@ -53,6 +53,8 @@ import type { TurnExecutionPort } from "../features/turn/contracts/execution-por import type { TerminalPort } from "../features/terminal/contracts/terminal-port.js"; import type { BrowserPort } from "../features/browser/contracts/browser-port.js"; import type { WorkspacePort } from "../features/workspace/contracts/workspace-port.js"; +import type { WebuiMiniAppPortSlice } from "../features/miniapp/application/miniapp-workflows.js"; +import type { MiniAppPort } from "../features/miniapp/contracts/miniapp-port.js"; import { createWebuiTurnCapabilitySlice, type WebuiTurnCapabilitySlice, @@ -77,7 +79,8 @@ export interface WebuiPortSliceSource ModelsPort, AccountPort, PluginPort, - BrowserPort {} + BrowserPort, + MiniAppPort {} // The key lists below are the old-to-new mapping as data. Each is checked // against its port by `satisfies`, so a renamed or removed member stops the @@ -234,6 +237,13 @@ const ACCOUNT_KEYS = [ const SHELL_KEYS = ["watchEvents"] as const satisfies readonly (keyof ShellEventPort)[]; +const MINIAPP_KEYS = [ + "listMiniApps", + "openMiniApp", + "stopMiniApp", + "pluginManagement", +] as const satisfies readonly (keyof MiniAppPort | keyof PluginPort)[]; + export type WebuiSessionsSlice = Pick; export type WebuiTranscriptSlice = Pick; export type WebuiWorkspaceSlice = Pick & @@ -250,6 +260,7 @@ export type WebuiSettingsSlice = Pick; export type WebuiShellSlice = Pick; export type WebuiBrowserSlice = Pick; +export type WebuiMiniAppSlice = WebuiMiniAppPortSlice; export type WebuiTurnSlice = WebuiTurnCapabilitySlice; /** One slice per business. */ @@ -266,6 +277,7 @@ export interface WebuiPortSlices { readonly account: WebuiAccountSlice; readonly shell: WebuiShellSlice; readonly browser: WebuiBrowserSlice; + readonly miniapp: WebuiMiniAppSlice; readonly turn: WebuiTurnSlice; } @@ -305,6 +317,7 @@ export function createWebuiPortSlices( account: cut(source, ACCOUNT_KEYS), shell: cut(source, SHELL_KEYS), browser: cut(source, BROWSER_KEYS), + miniapp: cut(source, MINIAPP_KEYS), // `turn`'s published slice, built by its own factory so there is exactly one // construction site for it. That factory drops an absent member // instead of storing `undefined` under it, so `turn` is the one slice whose diff --git a/packages/webui/src/client/contracts/transport.ts b/packages/webui/src/client/contracts/transport.ts index a3b01e728..7e5e75b9a 100644 --- a/packages/webui/src/client/contracts/transport.ts +++ b/packages/webui/src/client/contracts/transport.ts @@ -1,7 +1,7 @@ // Browser transport — the single bag of methods the foundation app and the // composer consume. // -// Composed from the fourteen business capability ports. It +// Composed from the fifteen business capability ports. It // declares no method of its own: every operation lives in exactly one port and // this type only joins them. Every method stays optional — `undefined` means // "the operation is not wired" (the panel renders the affected area @@ -28,6 +28,7 @@ import type { AccountPort } from "../features/account/contracts/account-port.js" import type { PluginPort } from "../features/plugins/contracts/plugin-port.js"; import type { TerminalPort } from "../features/terminal/contracts/terminal-port.js"; import type { BrowserPort } from "../features/browser/contracts/browser-port.js"; +import type { MiniAppPort } from "../features/miniapp/contracts/miniapp-port.js"; export interface WebuiTransport extends SessionPort, @@ -43,4 +44,5 @@ export interface WebuiTransport AccountPort, PluginPort, TerminalPort, - BrowserPort {} + BrowserPort, + MiniAppPort {} diff --git a/packages/webui/src/client/features/composer/application/composer-controller.ts b/packages/webui/src/client/features/composer/application/composer-controller.ts index 3b5f57b6e..467211e8e 100644 --- a/packages/webui/src/client/features/composer/application/composer-controller.ts +++ b/packages/webui/src/client/features/composer/application/composer-controller.ts @@ -3,6 +3,7 @@ import type { WebuiComposerController, WebuiComposerControllerSnapshot, WebuiCom import type { WebuiComposerStore } from "./composer-store.js"; import type { WebuiComposerSubmitIntent } from "../contracts/composer-controller.js"; import type { WebuiComposerStagingState } from "../contracts/composer-state.js"; +import { WEBUI_COMPOSER_HOME_KEY } from "../contracts/composer-state.js"; import type { WebuiRunCommandRequest, WebuiCommandWorkflows } from "./command-workflows.js"; import type { WebuiConversationTurnCommands } from "../../turn/contracts/turn-commands.js"; import type { WebuiSubmissionCompletion, WebuiSubmissionTicket } from "../../turn/contracts/submission.js"; @@ -55,7 +56,7 @@ export interface WebuiComposerControllerDeps { readonly clearHomeTurn: () => void; } -const HOME_KEY = "__webui_composer_home__"; +const HOME_KEY = WEBUI_COMPOSER_HOME_KEY; /** Application coordinator for the existing composer store and owners. */ export function createWebuiComposerController(deps: WebuiComposerControllerDeps): WebuiComposerController { diff --git a/packages/webui/src/client/features/composer/contracts/composer-state.ts b/packages/webui/src/client/features/composer/contracts/composer-state.ts index 49ffdfa82..c1b9df503 100644 --- a/packages/webui/src/client/features/composer/contracts/composer-state.ts +++ b/packages/webui/src/client/features/composer/contracts/composer-state.ts @@ -7,6 +7,16 @@ export interface WebuiComposerPersisted { readonly history: Readonly>; } +/** + * The composer slot that stands for "no session yet". + * + * Declared here rather than in the controller that used to spell it, because a + * second party now writes a draft: a Mini App's confirmed append targets the + * composer of the session it is shown in, and with no session that is this slot. + * One literal, so the two writers cannot drift onto different slots. + */ +export const WEBUI_COMPOSER_HOME_KEY = "__webui_composer_home__"; + export interface WebuiComposerQueueState { readonly items: readonly WebuiQueueItem[]; readonly paused: boolean; diff --git a/packages/webui/src/client/features/miniapp/application/miniapp-bridge.ts b/packages/webui/src/client/features/miniapp/application/miniapp-bridge.ts new file mode 100644 index 000000000..5825613b3 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/application/miniapp-bridge.ts @@ -0,0 +1,105 @@ +// The page bridge's host side: what the WebUI client does with a Mini App's +// `postMessage`. +// +// The proxy injects the page half (`server/miniapp/bridge.ts`); this is the half +// that runs in the client and answers. Three rules make it safe to point at an +// untrusted page: +// +// * **The sender is checked, not trusted.** A message is only read when its +// origin is the Mini App's own proxy origin and its source is the frame this +// view created. Any other window can post to this one, and a bridge that +// answered them would let any page in the browser append to the composer. +// * **The text is bounded and never sent.** An append writes the composer's +// draft; it never submits, so a page cannot spend the person's turn. +// * **A person confirms it.** The confirmation is injected so this module has +// no DOM, and declining is `user_cancelled` with the draft untouched. +// +// The identity fields the runtime's contract mentions (`sessionId`, `pluginId`, +// `pluginName`) are supplied by the host from the view, never by the page: a page +// that could name another Mini App's session would be a page that writes into a +// transcript it cannot see. + +import { + WEBUI_MINIAPP_BRIDGE_APPEND_REQUEST, + WEBUI_MINIAPP_COMPOSER_MAX_TEXT_LENGTH, + type WebuiMiniAppBridgeAppendRequest, + type WebuiMiniAppComposerResult, +} from "../../../../shared/contracts/miniapp.js"; + +/** Reads one append request out of a message's data, or nothing. */ +export function webuiMiniAppBridgeRequest( + data: unknown, +): WebuiMiniAppBridgeAppendRequest | undefined { + if (!data || typeof data !== "object" || Array.isArray(data)) return undefined; + const record = data as Record; + if (record.type !== WEBUI_MINIAPP_BRIDGE_APPEND_REQUEST) return undefined; + const requestId = record.requestId; + const text = record.text; + if (typeof requestId !== "string" || !requestId) return undefined; + if (typeof text !== "string") return undefined; + return { requestId, text }; +} + +/** + * Whether a message came from the frame this view created, on the origin this + * view framed. + * + * `viewUrl` is the proxy's URL the server answered with, so its origin is the one + * the browser reports for the document: comparing them is what tells the frame + * this view owns from a second frame, or from the page's own window. + */ +export function webuiMiniAppBridgeSenderAllowed(input: { + readonly origin: string; + readonly viewUrl: string; + readonly source: unknown; + readonly frame: unknown; +}): boolean { + if (!input.frame || input.source !== input.frame) return false; + try { + return new URL(input.viewUrl).origin === input.origin; + } catch { + // A view URL that cannot be parsed frames nothing, so nothing is allowed. + return false; + } +} + +/** + * The draft an append produces. + * + * Existing text is kept and the appended text follows on a line of its own: a + * page that appends "/review this" beside a half-written sentence would be + * editing something the person wrote, and an append is not an edit. + */ +export function webuiMiniAppDraftWithAppend(draft: string, text: string): string { + if (!draft) return text; + return draft.endsWith("\n") ? `${draft}${text}` : `${draft}\n${text}`; +} + +/** + * One append, decided. + * + * The order matters: size before identity, identity before confirmation, and the + * confirmation before anything is written. A declined append performs no write + * at all, which is the property the ticket's criterion names. + */ +export async function webuiMiniAppBridgeAppend(input: { + readonly request: WebuiMiniAppBridgeAppendRequest; + /** The session whose composer takes the draft; absent means "no target". */ + readonly sessionId?: string; + readonly pluginName: string; + readonly confirm: (question: string) => boolean | Promise; + readonly readDraft: () => string; + readonly writeDraft: (text: string) => void; +}): Promise { + const { text } = input.request; + if (text.length > WEBUI_MINIAPP_COMPOSER_MAX_TEXT_LENGTH) { + return { success: false, code: "text_too_large" }; + } + if (!input.sessionId) return { success: false, code: "target_unavailable" }; + const confirmed = await input.confirm( + `Mini App「${input.pluginName}」要把内容追加到输入框,是否允许?`, + ); + if (!confirmed) return { success: false, code: "user_cancelled" }; + input.writeDraft(webuiMiniAppDraftWithAppend(input.readDraft(), text)); + return { success: true }; +} diff --git a/packages/webui/src/client/features/miniapp/application/miniapp-workflows.ts b/packages/webui/src/client/features/miniapp/application/miniapp-workflows.ts new file mode 100644 index 000000000..663f1a680 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/application/miniapp-workflows.ts @@ -0,0 +1,307 @@ +// The Mini App business's owner. +// +// Everything this client knows about Mini Apps that is not layout: the +// catalogue, the view each Mini App is presented in, why a view was cleared, and +// the refusals a start earned. ADR 0020 puts it here rather than in the plugin +// catalogue (whose lifetime is installing packages) or the workspace panel +// (whose lifetime is layout), because the state is per session and the protocol +// is its own. +// +// Framework-free: no React, no DOM, no `window`. A component reads the snapshot +// and submits these commands; a test drives the same commands against a scripted +// port. +// +// Two rules the state exists to enforce: +// +// * A refused start is a fact about a row, not a toast. The refusal is kept +// under the plugin id it was about, so the catalogue's apps area and the +// launcher's picker — two surfaces, one listing — both show it. +// * A stopped runtime clears the view and says why, and nothing starts again +// on its own. Only `open` starts a Mini App, and only a person calls it. + +import type { PluginPort } from "../../plugins/contracts/plugin-port.js"; +import { createWebuiPluginActions } from "../../plugins/contracts/plugin-actions.js"; +import type { WebuiMiniAppList } from "../../../../shared/contracts/miniapp.js"; +import type { MiniAppPort } from "../contracts/miniapp-port.js"; +import { + webuiMiniAppRows, + webuiMiniAppStopReason, + type WebuiMiniAppRow, +} from "../projection/miniapp-view.js"; + +/** The port members this business drives: its own three, and the package lifecycle. */ +export type WebuiMiniAppPortSlice = Pick< + MiniAppPort, + "listMiniApps" | "openMiniApp" | "stopMiniApp" +> & + Pick; + +/** + * What this client is showing for one Mini App. + * + * `open` carries the proxy's view URL and nothing else — not the Mini App's + * origin, not its surface path, not the credential its guard demands. `stopped` + * is a cleared view that remembers why, which is what the panel renders instead + * of a dead frame. + */ +export type WebuiMiniAppPresentation = + | { readonly kind: "opening" } + | { readonly kind: "open"; readonly viewUrl: string } + | { readonly kind: "stopped"; readonly reason: string }; + +/** A structural refusal, kept under the plugin it was about. */ +export interface WebuiMiniAppRefusal { + /** The runtime's own wire code, e.g. `OWNED_ELSEWHERE`, `BUSY`. */ + readonly code: string; + /** The runtime's own message. */ + readonly message: string; +} + +export interface WebuiMiniAppListing { + readonly rows: readonly WebuiMiniAppRow[]; + readonly loading: boolean; + readonly error?: string; +} + +export interface WebuiMiniAppWorkflowsState { + readonly listing: WebuiMiniAppListing; + /** Per plugin: the presentation this client holds. */ + readonly presentations: Readonly>; + /** Per plugin: the last refusal a start of it earned. */ + readonly refusals: Readonly>; + /** The plugin whose start is in flight, so its row can say so. */ + readonly opening?: string; + /** + * The capability's absence, when the host has no Mini App cell at all. + * + * A different answer from an empty catalogue, and it is said once here rather + * than deduced by each surface from a missing port member. + */ + readonly unavailable?: string; +} + +export const initialWebuiMiniAppListing: WebuiMiniAppListing = { + rows: [], + loading: false, +}; + +export const initialWebuiMiniAppWorkflowsState: WebuiMiniAppWorkflowsState = { + listing: initialWebuiMiniAppListing, + presentations: {}, + refusals: {}, +}; + +/** The code a refused call carries, when it carries one. */ +function refusalOf(error: unknown): WebuiMiniAppRefusal { + const code = + error && typeof error === "object" && typeof (error as { code?: unknown }).code === "string" + ? ((error as { code: string }).code) + : "harness_error"; + return { + code, + message: error instanceof Error ? error.message : String(error), + }; +} + +export interface WebuiMiniAppWorkflows { + readonly getSnapshot: () => WebuiMiniAppWorkflowsState; + readonly subscribe: (listener: () => void) => () => void; + /** Whether the host exposes the capability at all. */ + readonly available: boolean; + + /** Read the catalogue. Rows carry the runtime's state; nothing is cached by key. */ + readonly loadListing: () => Promise; + /** + * Start one Mini App and keep its view URL. + * + * `false` means the start was refused: the refusal is in the snapshot under + * this plugin id, and both surfaces render it. + */ + readonly open: (pluginId: string) => Promise; + /** Stop one Mini App and clear its view, recording why. */ + readonly stop: (pluginId: string) => Promise; + /** + * Reconcile the open views with the runtime's own answer. + * + * A Mini App can stop without this client asking — the process exits, the + * agent stops it, another window stops it. The view it was shown in has to go, + * and the reason has to be said, so this is one read plus the transitions it + * implies. It never starts anything. + */ + readonly syncPresentations: () => Promise; + + /** Package lifecycle, for the catalogue's apps area. */ + readonly setPluginEnabled: (pluginId: string, enabled: boolean) => Promise; + readonly uninstall: (pluginId: string) => Promise; +} + +export function createWebuiMiniAppWorkflows(deps: { + readonly port: WebuiMiniAppPortSlice; +}): WebuiMiniAppWorkflows { + const { port } = deps; + const available = + typeof port.listMiniApps === "function" && + typeof port.openMiniApp === "function" && + typeof port.stopMiniApp === "function"; + const pluginActions = createWebuiPluginActions({ pluginManagement: port.pluginManagement }); + let state: WebuiMiniAppWorkflowsState = available + ? initialWebuiMiniAppWorkflowsState + : { + ...initialWebuiMiniAppWorkflowsState, + unavailable: "当前运行时没有提供 Mini App 能力。", + }; + const listeners = new Set<() => void>(); + /** One catalogue read at a time: a second read while one is open is that read. */ + let inFlight: Promise | undefined; + + const set = ( + update: (current: WebuiMiniAppWorkflowsState) => WebuiMiniAppWorkflowsState, + ): void => { + const next = update(state); + if (next === state) return; + state = next; + for (const listener of listeners) listener(); + }; + + const setPresentation = ( + pluginId: string, + presentation: WebuiMiniAppPresentation | undefined, + ): void => { + set((current) => { + const presentations = { ...current.presentations }; + if (presentation) presentations[pluginId] = presentation; + else delete presentations[pluginId]; + return { ...current, presentations }; + }); + }; + + const setRefusal = (pluginId: string, refusal: WebuiMiniAppRefusal | undefined): void => { + set((current) => { + const refusals = { ...current.refusals }; + if (refusal) refusals[pluginId] = refusal; + else delete refusals[pluginId]; + return { ...current, refusals }; + }); + }; + + const readCatalogue = async (): Promise => { + if (!port.listMiniApps) return undefined; + const list = await port.listMiniApps(); + const rows = webuiMiniAppRows(list.miniApps); + set((current) => ({ ...current, listing: { rows, loading: false } })); + return list; + }; + + const loadListing = async (): Promise => { + if (!available) return; + set((current) => ({ ...current, listing: { ...current.listing, loading: true } })); + try { + inFlight = inFlight ?? readCatalogue(); + await inFlight; + } catch (error) { + set((current) => ({ + ...current, + listing: { rows: current.listing.rows, loading: false, error: refusalOf(error).message }, + })); + } finally { + inFlight = undefined; + } + }; + + const open = async (pluginId: string): Promise => { + if (!available || !port.openMiniApp) return false; + set((current) => ({ ...current, opening: pluginId })); + setRefusal(pluginId, undefined); + try { + const view = await port.openMiniApp({ pluginId }); + setPresentation(pluginId, { kind: "open", viewUrl: view.viewUrl }); + return true; + } catch (error) { + const refusal = refusalOf(error); + setPresentation(pluginId, undefined); + setRefusal(pluginId, refusal); + // The runtime refused to start, so the row it refused about is stale: a + // capacity refusal names the running apps, and those names come from here. + await loadListing(); + setRefusal(pluginId, refusal); + return false; + } finally { + set((current) => { + if (current.opening !== pluginId) return current; + const next = { ...current }; + delete next.opening; + return next; + }); + } + }; + + const stop = async (pluginId: string): Promise => { + if (!available || !port.stopMiniApp) return; + try { + await port.stopMiniApp({ pluginId }); + setPresentation(pluginId, { kind: "stopped", reason: "运行已停止。" }); + setRefusal(pluginId, undefined); + } catch (error) { + setRefusal(pluginId, refusalOf(error)); + return; + } + await loadListing(); + }; + + const syncPresentations = async (): Promise => { + if (!available) return; + const openPlugins = Object.entries(state.presentations) + .filter(([, presentation]) => presentation.kind === "open") + .map(([pluginId]) => pluginId); + if (openPlugins.length === 0) return; + try { + await loadListing(); + } catch { + // A read that failed says nothing about a view that is up; the next one does. + return; + } + set((current) => { + let presentations: Record | undefined; + for (const pluginId of openPlugins) { + const row = current.listing.rows.find((candidate) => candidate.pluginId === pluginId); + const reason = webuiMiniAppStopReason(row); + if (!reason) continue; + presentations = presentations ?? { ...current.presentations }; + presentations[pluginId] = { kind: "stopped", reason }; + } + return presentations ? { ...current, presentations } : current; + }); + }; + + const mutatePlugin = async ( + action: "enablePlugin" | "disablePlugin" | "uninstallPlugin", + pluginId: string, + ): Promise => { + if (!port.pluginManagement) return; + try { + await pluginActions.invoke(action, { pluginName: pluginId }); + } catch (error) { + set((current) => ({ ...current, listing: { ...current.listing, error: refusalOf(error).message } })); + return; + } + await loadListing(); + }; + + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + available, + loadListing, + open, + stop, + syncPresentations, + setPluginEnabled: (pluginId, enabled) => + mutatePlugin(enabled ? "enablePlugin" : "disablePlugin", pluginId), + uninstall: (pluginId) => mutatePlugin("uninstallPlugin", pluginId), + }; +} diff --git a/packages/webui/src/client/features/miniapp/bindings/use-miniapp-workflows.tsx b/packages/webui/src/client/features/miniapp/bindings/use-miniapp-workflows.tsx new file mode 100644 index 000000000..95cacbe52 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/bindings/use-miniapp-workflows.tsx @@ -0,0 +1,53 @@ +// The Mini App business's binding: one provider, two hooks. +// +// The owner is built once by the composition root (`application/create-application.ts`) +// and provided here, so a surface submits commands and reads the snapshot rather +// than constructing a second owner. With no provider mounted — SSR, a unit test +// that mounts the view on its own — the hooks read the initial snapshot and the +// component renders "no Mini App capability", which is the same thing a host +// without the cell does. + +import { createContext, useContext, useSyncExternalStore, type ReactNode } from "react"; + +import { + initialWebuiMiniAppWorkflowsState, + type WebuiMiniAppWorkflows, + type WebuiMiniAppWorkflowsState, +} from "../application/miniapp-workflows.js"; + +const WebuiMiniAppWorkflowsContext = createContext(undefined); + +export function WebuiMiniAppWorkflowsProvider({ + workflows, + children, +}: { + readonly workflows: WebuiMiniAppWorkflows; + readonly children: ReactNode; +}): JSX.Element { + return ( + + {children} + + ); +} + +/** The owner, for a component that submits commands. */ +export function useWebuiMiniAppWorkflows(): WebuiMiniAppWorkflows | undefined { + return useContext(WebuiMiniAppWorkflowsContext); +} + +const noopSubscribe = (): (() => void) => () => undefined; + +function initialSnapshot(): WebuiMiniAppWorkflowsState { + return initialWebuiMiniAppWorkflowsState; +} + +/** The snapshot, for a component that renders it. */ +export function useWebuiMiniAppWorkflowsState(): WebuiMiniAppWorkflowsState { + const workflows = useContext(WebuiMiniAppWorkflowsContext); + return useSyncExternalStore( + workflows ? workflows.subscribe : noopSubscribe, + workflows ? workflows.getSnapshot : initialSnapshot, + workflows ? workflows.getSnapshot : initialSnapshot, + ); +} diff --git a/packages/webui/src/client/features/miniapp/contracts/miniapp-port.ts b/packages/webui/src/client/features/miniapp/contracts/miniapp-port.ts new file mode 100644 index 000000000..137ecc7a7 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/contracts/miniapp-port.ts @@ -0,0 +1,41 @@ +// Mini App capability port. +// +// The three operations the WebUI's Mini App business drives: the catalogue, the +// launch and the stop. Split from the monolithic transport the way every other +// business's port is, and composed by `transport.ts`. +// +// Every method stays optional — `undefined` means "the operation is not wired", +// which is the state a host without the Mini App cell answers with. The business +// renders that as a report ("this host has no Mini App capability") rather than +// calling into an absent member. +// +// Two things are deliberately absent. The Mini App's own origin, its surface path +// and the launch credential never appear here: the server holds them and the +// client is handed the proxy's `viewUrl` (`shared/contracts/miniapp.ts`). And +// there is no "detach"/"attach": the two presentations of ADR 0020 are two client +// documents, so what a client asks for is a view URL, not a page move. + +import type { + WebuiMiniAppList, + WebuiMiniAppView, +} from "../../../../shared/contracts/miniapp.js"; + +export interface MiniAppPort { + /** The installed Mini Apps, with the runtime state the row renders. */ + readonly listMiniApps?: () => Promise; + /** + * Starts one Mini App and answers with the loopback URL the panel frames. + * + * A refusal is the runtime's: `OWNED_ELSEWHERE` names the client that holds the + * app, `BUSY` carries the capacity refusal. The business reports the message + * it was given rather than inventing one, because the runtime is the only + * party that knows which of the two happened. + */ + readonly openMiniApp?: (request: { + readonly pluginId: string; + }) => Promise; + /** Stops one Mini App. The server closes that plugin's proxy with it. */ + readonly stopMiniApp?: (request: { + readonly pluginId: string; + }) => Promise<{ readonly success: true }>; +} diff --git a/packages/webui/src/client/features/miniapp/projection/miniapp-view.ts b/packages/webui/src/client/features/miniapp/projection/miniapp-view.ts new file mode 100644 index 000000000..b6c6064fc --- /dev/null +++ b/packages/webui/src/client/features/miniapp/projection/miniapp-view.ts @@ -0,0 +1,119 @@ +// The Mini App catalogue, projected for the two surfaces that render it. +// +// Pure functions: a row list in, what a row says out. The picker and the plugin +// catalogue's apps area read the same projection, which is what makes them the +// same rows rather than two readings of the runtime that can disagree +// (ADR 0020, "Its rows and the plugin catalogue's apps area are the same rows"). +// +// The runtime names five states and the row offers a different action for each, +// so the decision lives here rather than being re-derived in two components. + +import type { + WebuiMiniAppRuntimeStatus, + WebuiMiniAppSummary, +} from "../../../../shared/contracts/miniapp.js"; + +/** + * How many Mini Apps run at once. + * + * The runtime enforces it (`at most three run at once`, ADR 0020) and refuses a + * fourth with `BUSY`. The number is repeated here only to say so in words before + * the refusal arrives; it is not this client's rule to enforce. + */ +export const WEBUI_MINIAPP_CAPACITY = 3; + +export interface WebuiMiniAppRow { + readonly pluginId: string; + /** The catalogue's display name, or the plugin id when it has none. */ + readonly name: string; + readonly description: string; + readonly iconUrl?: string; + readonly status: WebuiMiniAppRuntimeStatus; + /** The runtime's own failure code, when the row failed. */ + readonly errorCode?: string; + /** The state in words, for the row. */ + readonly statusLabel: string; + /** True while the runtime is up, which is when the row offers "stop". */ + readonly running: boolean; +} + +/** The state in words. A closed union in, a label out — never "unknown". */ +export function webuiMiniAppStatusLabel(status: WebuiMiniAppRuntimeStatus): string { + switch (status) { + case "running": + return "运行中"; + case "starting": + return "启动中"; + case "stopping": + return "停止中"; + case "failed": + return "启动失败"; + case "stopped": + return "已停止"; + } +} + +/** One catalogue answer, as the surfaces render it. */ +export function webuiMiniAppRows( + miniApps: readonly WebuiMiniAppSummary[], +): readonly WebuiMiniAppRow[] { + return miniApps.map((miniApp) => ({ + pluginId: miniApp.pluginId, + name: miniApp.displayName || miniApp.pluginId, + description: miniApp.description ?? "", + ...(miniApp.iconUrl !== undefined ? { iconUrl: miniApp.iconUrl } : {}), + status: miniApp.runtime.status, + ...(miniApp.runtime.errorCode !== undefined ? { errorCode: miniApp.runtime.errorCode } : {}), + statusLabel: webuiMiniAppStatusLabel(miniApp.runtime.status), + running: miniApp.runtime.status === "running", + })); +} + +/** The rows that are up, in catalogue order. */ +export function webuiMiniAppRunningRows( + rows: readonly WebuiMiniAppRow[], +): readonly WebuiMiniAppRow[] { + return rows.filter((row) => row.running); +} + +/** + * What a capacity refusal says: the limit, and which applications are using it. + * + * The person's next action is to stop one of them, so the refusal names them + * rather than reporting a count — "3 running" is not something anyone can act on. + */ +export function webuiMiniAppCapacityText(rows: readonly WebuiMiniAppRow[]): string { + const names = webuiMiniAppRunningRows(rows).map((row) => row.name); + const running = names.length > 0 ? names.join("、") : "其他应用"; + return `同时最多运行 ${WEBUI_MINIAPP_CAPACITY} 个 Mini App,正在运行:${running}。请先停止一个再打开。`; +} + +/** + * What a refusal says, in words the person can act on. + * + * `OWNED_ELSEWHERE` is the runtime's answer when another client holds the app — + * the message it carries names that client and its pid, and this client cannot + * discover either on its own: the credential that reaches the app never leaves + * the process that minted it, so "is it running elsewhere?" is answered by + * trying to start it. The message is kept rather than replaced, because the + * runtime is the only party that knows who the other client is. + */ +export function webuiMiniAppRefusalText(input: { + readonly code: string; + readonly message: string; + readonly rows: readonly WebuiMiniAppRow[]; +}): string { + if (input.code === "BUSY") return webuiMiniAppCapacityText(input.rows); + if (input.code === "OWNED_ELSEWHERE") return `已在其他客户端运行:${input.message}`; + return input.message; +} + +/** Why a view was cleared, in words. `undefined` means "still up". */ +export function webuiMiniAppStopReason(row: WebuiMiniAppRow | undefined): string | undefined { + if (!row) return "该应用已不在应用列表中。"; + if (row.status === "failed") { + return row.errorCode ? `运行失败(${row.errorCode})。` : "运行失败。"; + } + if (row.status === "stopped") return "运行已停止。"; + return undefined; +} diff --git a/packages/webui/src/client/features/miniapp/ui/MiniAppAppsList.public.tsx b/packages/webui/src/client/features/miniapp/ui/MiniAppAppsList.public.tsx new file mode 100644 index 000000000..98dafa75f --- /dev/null +++ b/packages/webui/src/client/features/miniapp/ui/MiniAppAppsList.public.tsx @@ -0,0 +1,146 @@ +// The plugin catalogue's apps area, as the Mini App business publishes it. +// +// ADR 0020 gives this surface the list and the lifecycle and gives the Mini App +// business the state: the catalogue mounts this component and holds nothing of +// its own. That is why the component takes no rows, no loading flag and no error +// — it reads them from its own owner, and a row's runtime state is the same read +// the launcher's picker makes. +// +// "Install" is the one action a row cannot carry: a Mini App *is* a plugin, and +// installing one is installing its package from the marketplace. The area keeps +// the affordance and hands it to the catalogue, which owns switching to that +// view; what this component does not do is invent an install that installs +// something already installed. + +import { useEffect, type ReactElement } from "react"; + +import { ToggleSwitch } from "../../../ui/ToggleSwitch.js"; +import { + useWebuiMiniAppWorkflows, + useWebuiMiniAppWorkflowsState, +} from "../bindings/use-miniapp-workflows.js"; +import { webuiMiniAppRefusalText } from "../projection/miniapp-view.js"; + +export interface MiniAppAppsListProps { + /** Offered when the catalogue can switch to the plugin marketplace. */ + readonly onInstallRequest?: () => void; +} + +export function MiniAppAppsList({ onInstallRequest }: MiniAppAppsListProps): ReactElement { + const workflows = useWebuiMiniAppWorkflows(); + const state = useWebuiMiniAppWorkflowsState(); + const { rows, loading, error } = state.listing; + + useEffect(() => { + void workflows?.loadListing(); + }, [workflows]); + + if (state.unavailable) return

{state.unavailable}

; + if (loading && rows.length === 0) { + return ( +

+ 加载中… +

+ ); + } + + return ( +
+ {onInstallRequest ? ( +
+ +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {rows.length === 0 ?

暂无已安装的 Mini App

: null} +
+ {rows.map((row) => { + const refusal = state.refusals[row.pluginId]; + return ( +
+ +
+

{row.name}

+

{row.description || row.pluginId}

+ {refusal ? ( +

+ {webuiMiniAppRefusalText({ + code: refusal.code, + message: refusal.message, + rows, + })} +

+ ) : null} +
+ + {row.statusLabel} + {row.errorCode ? `(${row.errorCode})` : ""} + +
+ + {state.presentations[row.pluginId]?.kind === "open" ? ( + + ) : null} + + + {/* The list is the runtime's *available* Mini Apps, so every row + is enabled; turning the switch off disables the plugin, and the + row leaves the list on the reload that follows. */} + void workflows?.setPluginEnabled(row.pluginId, false)} + /> +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/webui/src/client/features/miniapp/ui/MiniAppPicker.public.tsx b/packages/webui/src/client/features/miniapp/ui/MiniAppPicker.public.tsx new file mode 100644 index 000000000..3c4caec74 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/ui/MiniAppPicker.public.tsx @@ -0,0 +1,130 @@ +// The Mini App picker the workspace panel's launcher opens. +// +// Same listing as the plugin catalogue's apps area — one owner, one read — so a +// row here and a row there cannot disagree about what is running. The picker's own +// job is small: show the rows with their state, start the one that was chosen, and +// name the running ones when the runtime refuses a fourth. +// +// It opens the view by asking its owner to start the Mini App and then telling the +// workspace which tab to open. That direction is ADR 0020's: the business issues +// the workspace panel's own command, it never reaches into the panel's state. + +import { useEffect, type ReactElement } from "react"; + +import { + useWebuiMiniAppWorkflows, + useWebuiMiniAppWorkflowsState, +} from "../bindings/use-miniapp-workflows.js"; +import { + webuiMiniAppCapacityText, + webuiMiniAppRefusalText, + webuiMiniAppRunningRows, +} from "../projection/miniapp-view.js"; + +export interface MiniAppPickerProps { + /** The session the chosen Mini App's view belongs to. */ + readonly sessionId?: string; + /** Called once a Mini App is started, with the plugin the tab is for. */ + readonly onOpened: (pluginId: string) => void; + readonly onClose?: () => void; +} + +export function MiniAppPicker({ sessionId, onOpened, onClose }: MiniAppPickerProps): ReactElement { + const workflows = useWebuiMiniAppWorkflows(); + const state = useWebuiMiniAppWorkflowsState(); + const { rows, loading } = state.listing; + const running = webuiMiniAppRunningRows(rows); + const refused = Object.values(state.refusals); + const capacityRefused = refused.some((refusal) => refusal.code === "BUSY"); + + useEffect(() => { + void workflows?.loadListing(); + }, [workflows]); + + return ( +
+
+ 选择一个 Mini App + {onClose ? ( + + ) : null} +
+ {state.unavailable ?

{state.unavailable}

: null} + {loading && rows.length === 0 ? ( +

+ 加载中… +

+ ) : null} + {!state.unavailable && !loading && rows.length === 0 ? ( +

暂无已安装的 Mini App

+ ) : null} + {/* A refused fourth start says which applications are using the capacity, + and each of them carries the stop button that frees one. */} + {capacityRefused ? ( +

+ {webuiMiniAppCapacityText(rows)} +

+ ) : null} +
+ {rows.map((row) => { + const refusal = state.refusals[row.pluginId]; + return ( +
+
+

{row.name}

+

{row.description || row.pluginId}

+ {refusal ? ( +

+ {webuiMiniAppRefusalText({ + code: refusal.code, + message: refusal.message, + rows, + })} +

+ ) : null} +
+ + {row.statusLabel} + {row.errorCode ? `(${row.errorCode})` : ""} + +
+ + +
+
+ ); + })} +
+ {sessionId ? null : ( +

当前没有会话,Mini App 会打开在第一个标签页里。

+ )} + {running.length > 0 ? ( +

+ 正在运行:{running.map((row) => row.name).join("、")} +

+ ) : null} +
+ ); +} diff --git a/packages/webui/src/client/features/miniapp/ui/MiniAppView.public.tsx b/packages/webui/src/client/features/miniapp/ui/MiniAppView.public.tsx new file mode 100644 index 000000000..c3bddd08b --- /dev/null +++ b/packages/webui/src/client/features/miniapp/ui/MiniAppView.public.tsx @@ -0,0 +1,190 @@ +// The Mini App view the workspace panel frames. +// +// Published by the Mini App business because the state it renders is this +// business's: which view URL a Mini App is presented at, whether it is starting, +// and why a view was cleared. The panel mounts it for its `miniapp` tab kind and +// stays the layout owner; it learns nothing about Mini Apps beyond the plugin id +// its tab carries. +// +// Two properties are worth stating, because both were decisions: +// +// * **The iframe is not sandboxed.** Isolation is origin separation (ADR 0020): +// the document comes from the proxy's own loopback origin, so it runs in its +// own process and cannot read this client's credential. A sandbox attribute +// would instead deny the page the storage and runtime a Mini App is a +// Mini App because of. +// * **A stopped runtime is a rendered state, not an empty frame.** The view +// remembers why it was cleared and offers one button to reopen it. Nothing +// reopens on its own: a tab may be unattended, and a retry loop would restart +// an application the person did not ask for. + +import { + useCallback, + useEffect, + useRef, + type ReactElement, +} from "react"; + +import { + WEBUI_MINIAPP_BRIDGE_APPEND_REPLY, + type WebuiMiniAppComposerResult, +} from "../../../../shared/contracts/miniapp.js"; +import { useWebuiComposerStore } from "../../../bindings/composer-store.js"; +import { + useWebuiMiniAppWorkflows, + useWebuiMiniAppWorkflowsState, +} from "../bindings/use-miniapp-workflows.js"; +import { WEBUI_COMPOSER_HOME_KEY } from "../../composer/contracts/composer-state.js"; +import { + webuiMiniAppBridgeAppend, + webuiMiniAppBridgeRequest, + webuiMiniAppBridgeSenderAllowed, +} from "../application/miniapp-bridge.js"; +import { webuiMiniAppRefusalText } from "../projection/miniapp-view.js"; + +/** + * How often an open view asks the runtime whether it is still up. + * + * One read of a small catalogue. A Mini App can stop without this client asking, + * and the alternative to asking is a frame that silently serves a dead page — the + * case ADR 0020 calls out ("a stopped runtime clears the view and reports why"). + */ +const PRESENTATION_SYNC_MS = 2_000; + +export interface MiniAppViewProps { + readonly pluginId: string; + /** The session whose composer a confirmed append writes to. */ + readonly sessionId?: string; + /** The tab's own title, when the panel has one. */ + readonly title?: string; +} + +export function MiniAppView({ pluginId, sessionId, title }: MiniAppViewProps): ReactElement { + const workflows = useWebuiMiniAppWorkflows(); + const state = useWebuiMiniAppWorkflowsState(); + const composer = useWebuiComposerStore(); + const presentation = state.presentations[pluginId]; + const refusal = state.refusals[pluginId]; + const row = state.listing.rows.find((candidate) => candidate.pluginId === pluginId); + const name = row?.name ?? pluginId; + const frameRef = useRef(null); + /** Whether this mount has already asked for the view. A retry is a person's. */ + const asked = useRef(false); + + /** + * The first show starts the Mini App — that is what opening a tab means. + * + * Only the first: a presentation that was cleared stays cleared until the + * person presses the button, so a refusal cannot turn into a retry loop. + */ + useEffect(() => { + if (!workflows || presentation !== undefined || asked.current) return; + asked.current = true; + void workflows.open(pluginId); + }, [pluginId, presentation, workflows]); + + /** While a view is up, the runtime is asked whether it still is. */ + useEffect(() => { + if (!workflows || presentation?.kind !== "open") return; + const timer = setInterval(() => void workflows.syncPresentations(), PRESENTATION_SYNC_MS); + return () => clearInterval(timer); + }, [presentation?.kind, workflows]); + + const answer = useCallback( + (target: Window, origin: string, reply: { requestId: string; result: WebuiMiniAppComposerResult }) => { + target.postMessage( + { type: WEBUI_MINIAPP_BRIDGE_APPEND_REPLY, requestId: reply.requestId, result: reply.result }, + origin, + ); + }, + [], + ); + + /** + * The page bridge's host half. + * + * A message is read only when it came from the frame this view created, on the + * origin this view framed, and the answer goes back to that origin only. Every + * other sender is ignored rather than answered: a page that is not this Mini App + * has nothing this client would say to it. + */ + useEffect(() => { + if (presentation?.kind !== "open") return; + const viewUrl = presentation.viewUrl; + const onMessage = (event: MessageEvent): void => { + const request = webuiMiniAppBridgeRequest(event.data); + if (!request) return; + const frame = frameRef.current?.contentWindow; + if ( + !webuiMiniAppBridgeSenderAllowed({ + origin: event.origin, + viewUrl, + source: event.source, + frame: frame ?? null, + }) || + !frame + ) { + return; + } + const origin = event.origin; + void webuiMiniAppBridgeAppend({ + request, + ...(sessionId !== undefined ? { sessionId } : {}), + pluginName: name, + // The person's confirmation, not the page's claim: an untrusted document + // may ask, and only a person may decide. + confirm: (question) => window.confirm(question), + readDraft: () => composer.readDraft(sessionId ?? WEBUI_COMPOSER_HOME_KEY), + writeDraft: (text) => composer.setDraft(sessionId ?? WEBUI_COMPOSER_HOME_KEY, text), + }) + .then((result) => answer(frame, origin, { requestId: request.requestId, result })) + .catch(() => answer(frame, origin, { requestId: request.requestId, result: { success: false, code: "result_unknown" } })); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [answer, composer, name, presentation, sessionId]); + + return ( +
+ {presentation?.kind === "opening" ? ( +

+ 正在启动 {name}… +

+ ) : null} + {presentation?.kind === "open" ? ( +