From 18c006f308ee9985277ede55e3365601f75d54a2 Mon Sep 17 00:00:00 2001 From: Jiaxiaoyuan Date: Mon, 26 Oct 2026 13:48:16 +0800 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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