Repository navigation
feat(webui): host Mini Apps across runtime and client - #268
Merged
Merged
Conversation
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.
…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.
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.
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.
…ntial Work in progress on #253. The guard and the credential threading are in place; the test suite and the wiring that hands the credential to the proxy are not. A Mini App's port is derived from its plugin id, so a local process can compute it rather than scan for it. Every `node:http` server the plugin creates is now intercepted at the `http.Server.prototype.emit` seam before the entry module loads, and only a request carrying the Host-issued credential on the exact loopback Host reaches plugin code: anything else is answered 401 with no body, and an `upgrade` or `connect` socket without it is destroyed. The credential is stripped from both `headers` and `rawHeaders` on success, so plugin code never observes it. One reserved health path is answered 204 by the guard itself. The credential is minted once per process generation in the node runtime, rides the runner's init message, and stays on the runtime handle as server-only state. It is absent from `MiniAppPublishedView` and from every renderer contract, so no published view, surface path or client payload can carry it. Readiness stops meaning "the socket accepts": the runtime now requires an anonymous health request to be rejected with 401 and the credentialed one to be answered 204, which is what proves the guard is in the path rather than merely listening. Still to do before #253 can be reviewed: the guard and validator test suites, and the supervisor hand-off of the credential to the proxy that injects it.
…ntial Closes #253. A Mini App's port is derived from its plugin id, so a local process can compute it rather than scan for it. Every `node:http` server the plugin creates is now intercepted at the `http.Server.prototype.emit` seam before the entry module loads, and only a request carrying the Host-issued credential on the exact loopback Host reaches plugin code: anything else is answered 401 with no body, and an `upgrade` or `connect` socket without it is destroyed. One reserved health path is answered 204 by the guard itself, so readiness can prove the guard is in the path rather than merely that a socket accepts. The credential is stripped from both `headers` and `rawHeaders` before plugin code runs, so plugin code never observes it. The credential is minted once per process generation in the node runtime, before the port-attempt loop, rides the runner's init message, and stays on the runtime handle as server-only state. It is absent from `MiniAppPublishedView` and from every renderer contract, so no published view, surface path or client payload can hold it. Handing it to the proxy that injects it belongs to the proxy change, where its consumer exists. Readiness no longer means "the socket accepts": the runtime requires an anonymous health request to be rejected with 401 and the credentialed one to be answered 204, with `READINESS_PROBE_REJECTED` and `READINESS_PROBE_TIMEOUT` as the failure codes. The suite drives a real `http.Server` through the real runner seam rather than a mock: anonymous 401 with an empty body, wrong credential 401, right credential on the wrong Host 401, the plugin handler reached with no credential header visible, the health path answered without reaching the plugin, and an unauthenticated upgrade destroyed while a credentialed one is forwarded. Six of the seven cases fail when the guard install is removed. Verified: pnpm typecheck; pnpm run test:capabilities (236 files, 5818 passed, 18 skipped); pnpm check:source after node scripts/source-inventory.mjs --write.
Readiness now lives in `loopback-readiness.ts` instead of inside `node-runtime.ts`, so it can be exercised against a real server without faking a child process — and so the proxy and ownership work reuse one definition of "the guard answered". The module keeps the rule the guard exists for: an anonymous health request must be rejected with 401 and the credentialed one answered 204, a port that answers wrongly is not retried, and a stalled probe reports READINESS_PROBE_TIMEOUT. Four cases added to the guard suite, all against real servers: - the guarded fixture passes readiness - a wrong credential fails with "expected 204, received 401" - a plain server in a child process that answers 200 passes nothing: it fails with "expected 401, received 200", which is the case readiness exists to catch - a closed port times out instead of reporting ready Verified: pnpm typecheck; pnpm run test:capabilities (236 files, 5822 passed, 18 skipped); pnpm check:source after the inventory write.
The scripted-child harness `node-runtime` was missing: a `ChildProcess` stand-in that runs the real runner in-process, so the readiness probe meets the real guard while the child itself is scripted. With it, the credential stops being construction-only. Two cases: - one preparation mints exactly one credential, the init message carries it, and the runtime handle exposes the same value after readiness passed for it - a second process generation receives a different credential, observed on the init message it was spawned with Together with the guard suite this closes the last unproven acceptance criterion of #253; the credential also stays out of every published view by type. Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5824 passed, 18 skipped); pnpm check:source after the inventory write.
First slice of #254. The desktop client writes no ownership record, so for anything already running — including a Mini App the desktop client started — the only signal is the Mini App's own derived port. This adds the probe that reads it. `probeMiniAppPortOwnership` answers with three outcomes rather than a boolean, because the runtime reacts differently to each: `free` (the port is this client's to take), `guarded` (a Mini App's guard rejected the anonymous request, so another owner is running this Mini App and this client must refuse), and `foreign` (something else holds the port and does not answer like the guard, so another port is fine). The answer is the guard's, not the socket's: a port that accepts but answers 200 is `foreign`, not `guarded`. That is the distinction that keeps a stray listener from being mistaken for a Mini App, and it is what the ownership record's liveness check cannot cover on its own. Proven against real servers: a closed port reports free, the guarded fixture reports guarded, and a plain `node:http` server in a child process reports foreign. Still to do for #254: the ownership record with an OS-released liveness proof, the refusal surfaced in the listing and the picker, per-Mini-App granularity across two clients, and refusing before spawn. Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5827 passed, 18 skipped); pnpm check:source after the inventory write.
…point Part of #254. ADR 0020 required a liveness proof the operating system releases when the owner dies rather than a timestamp, and did not name one. This picks it and records the choice in the ADR's ownership section: a socket file on POSIX, a named pipe on Windows, named from the plugin id the way the OAuth lease endpoint is named from its data directory. Binding is the ownership. A second bind fails while the owner lives and the operating system releases it when the owner dies, so a record beside it states who holds the Mini App and since when, while the endpoint remains the truth. Nothing is unlinked before the first bind attempt — doing that would steal a live owner's endpoint, which is why this does not copy the OAuth lease server's path cleanup. The case that matters is the crashed owner: it leaves a socket file with nothing listening on it. A failed bind is therefore told apart from a leftover by connecting — a refused connection proves the file is stale, and only then is it removed and the bind retried once. An endpoint that still answers is somebody's, and the acquisition reports the recorded holder instead. Proven: acquire and re-acquire after release; a second client is refused with the holder's name; a socket file left by a dead owner is reclaimed; two different Mini Apps are owned at once; the endpoint is named from the plugin data directory rather than a global path. Still to do for #254: wire the acquisition into `prepareRuntime` before the port loop so the refusal precedes spawn, and surface the held state in the listing and the picker. Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5832 passed, 18 skipped); pnpm check:source after the inventory write.
Part of #254. Ownership is now taken in `prepareRuntime`, before the port loop and before any spawn, so a second client cannot reach the point of starting a process over the same plugin data. The refusal is a `MiniAppNodeRuntimeError` with code `OWNED_ELSEWHERE` whose text names the holder (`client` and `pid` from the record), so the listing has something readable to report rather than a silent failure. The acquisition is released where the rest of the preparation is cleaned up: on stop, on a fatal abort, and on any failure between taking it and returning a runtime — so a failed start does not leave the Mini App locked to this client. Proven through the scripted-child harness: with one client holding the Mini App, a second preparation for the same plugin data directory rejects with `OWNED_ELSEWHERE` and the second child receives no init message at all, which is what "before spawn" means in observable terms. Still to do for #254: surface the held state in the WebUI listing and picker (that is the client half), and decide whether the desktop client's presence needs a record the port probe cannot see — it writes none, so today it is detected only by its port answering like the guard. Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5833 passed, 18 skipped); pnpm check:source.
First slice of #255. A Mini App page refers to its own origin by absolute path — the scaffold serves `/dashboard`, and a real page also fetches `/api/...` and its assets — so the proxy forwards paths unchanged rather than hiding them behind a prefix that would have to be patched back by intercepting every fetch and absolute URL in the document. `startMiniAppProxy` puts one listener on loopback per running Mini App, on a port derived per plugin. That stability is required, not cosmetic: the page's `localStorage` and `IndexedDB` are keyed by its origin, so a port that moved between runs would silently discard the page's own state. Requests are forwarded with the Mini App's credential injected, and the credential never appears in a response. Upgrades are forwarded under the same rule, and a closed peer tears down its pair — a half-closed pipe would hold the upstream server open forever, which is how this showed up in the test teardown. Server-only by placement: the proxy takes the upstream origin and the credential as input and returns `viewUrl`, which is what a client frames. The client never assembles a Mini App origin. It lives in `packages/webui/src/server/` because ADR 0020 puts the proxy in the WebUI service and keeps everything that touches a real origin on the server side of the facade; the server layer may import `shared` and `runtime-port` and nothing else. Proven against a real upstream server: the path is forwarded unchanged, the response body reaches the caller, the credential is injected outbound and absent from the response, an upgrade is forwarded with the credential, and the derived port is stable per plugin and inside the loopback range. Still to do for #255: the document rewrite that injects the page bridge (the bridge itself is #260), the per-session and per-presentation view URL, and wiring the proxy into the open operation so it starts and stops with the Mini App. Verified: pnpm typecheck:webui (server, client and test); the focused suite (4 cases); pnpm check:source after the inventory write. pnpm check:webui-dependency still fails on a pre-existing violation in WorkspacePanels.tsx — `document.fonts.ready` in a bindings file, present at the base commit 9713c4c and untouched by this branch; the new module adds none.
…e bridge Second slice of #255. The page is framed by this client on a different origin, so a page must not be able to refuse being framed. The proxy drops `X-Frame-Options` from every response it forwards and removes the `frame-ancestors` directive from a page's own CSP, leaving its other directives alone — the Mini App's origin is separate from the client's, so the rest of its policy governs a document that cannot reach the client anyway. Nothing is added: no header the page did not ask for, no CSP of the proxy's own. The document is the one response the proxy rewrites. The page bridge has to be in the document before the page's own scripts run, so the proxy injects a script tag for it before `</head>`, after the opening `<body>` tag when a page has no head, or ahead of the document when it has neither; the body it sends is not the body it received, so it restates `Content-Length` and drops the upstream's `Transfer-Encoding` with it. The bridge script is served by the proxy itself at its own path and never forwarded upstream, which keeps the seam in one place: this change owns which document, where the tag goes and what headers survive, while the bridge body arrives with #260. ADR 0020's open question about framing is settled by these rules and removed from the open list; the bridge's method-level error mapping is the only one left. Proven: the bridge is injected before `</head>`, before `<body>` on a headless document, `X-Frame-Options` is gone, `frame-ancestors` is gone while `default-src` and `script-src` survive, the bridge path is served locally without reaching the upstream, and paths and bodies of ordinary responses are untouched. Verified: pnpm typecheck:webui (server, client and test); the focused suite (8 cases); pnpm check:source after the inventory write. pnpm check:webui-dependency still fails on the pre-existing WorkspacePanels.tsx violation described in the previous commit.
Closes #255's stability criterion with evidence. The page's `localStorage` and `IndexedDB` are keyed by its origin, so a port that moved between runs would silently discard the page's own state. The derived port is a pure function of the plugin id, and this asserts the property end to end: start on the derived port, close, start again from a fresh derivation, and the view URL is identical. Verified: pnpm typecheck:webui; the focused suite (9 cases).
…licy First slice of #256, and behaviour-preserving: electron keeps the service, every other row still has it off. The point is to move the gate out of a kind comparison and into the policy table, so a change to this client's row can reach it. `wiring.miniAppService` is stated as a literal in every row — electron `true`, cli, tui, runtime, webui and absent `false` — and in the fail-closed default. A literal rather than the electron-capability predicate, because that predicate answers true for an owner that declared no kind, and a service enabled for an absent owner is a behaviour change no client asked for. The webui row says `false` with the reason written next to it: it flips only when the presenter and the view land with it, since a cell flipped alone leaves the agent able to start a Mini App that nothing can display. `resolveRuntimeMiniAppOptions` now takes that cell as its owner-derived input instead of comparing the kind, so the two things that read it — the composition and the `miniappAvailable` capability flag in `services.ts` — cannot disagree with each other or with the service. Explicit Mini App options still win unchanged, including with no declared owner kind, which is the override a test already depends on. Proven: the policy suite still pins the wiring key set and the cells where webui differs from tui (84 cases across it and `services.test.ts` pass), and four cases pin the resolver's new rule — the cell starts the service, absence or false leaves it off, explicit options are returned as given, and a kind of `electron` with the cell off no longer gets the service. Verified: pnpm typecheck; pnpm run test:capabilities (238 files, 5837 passed, 18 skipped); pnpm check:source after the inventory write.
Second slice of #256, and the piece that has to exist before the service cell can be flipped: `MiniAppPresenter` is a host-owned port, so outside Electron there is no implementation, and the agent's own `open` action throws OPEN_UNAVAILABLE without one. ADR 0020 maps that `open` onto the session rail: it selects the session through the client's ordinary selection path and records that the panel is showing that Mini App. Selection is what makes a Mini-App-created session's permission asks answerable, so the record follows the selection rather than the page's wishes — which is why `followSelection` exists: when the person selects another session or closes the panel, the Mini App stops being shown and the service reports it closed instead of claiming a presentation that no longer exists. One presentation per service, because the panel shows one session at a time: a second `open` replaces the first, and `close` only clears the record when it names the Mini App currently presented, so a stale close from a stopped runtime cannot clear a live one. Proven: open selects and records; an aborted open neither selects nor records; a second open replaces the first; close ignores another plugin and reports exactly once; and selecting another session reports the closure. Verified: pnpm typecheck:webui (server, client and test); the focused suite (5 cases); pnpm check:source after the inventory write.
Third slice of #256, and the first step of the client vertical: the WebUI has no Mini App operation and no capability member today, so nothing produces a view URL for a panel tab to render. This adds the entry the operation will bind to. `CliService` gains `listMiniApps`, `openMiniApp` and `stopMiniApp`. They forward to the Mini App application and own no lifecycle logic of their own — ADR 0020 is explicit that the WebUI neither reimplements Mini App lifecycle in its server nor reaches around the facade into the application bag. `openMiniApp` returns the application's launch coordinates untouched: they are server-only and the caller must not forward them to a renderer, so the proxy is the only thing that will ever see them. Proven: the listing goes through the application; open passes the plugin id and returns the launch object by identity, so nothing is reshaped or dropped in between; stop forwards; and a host without the capability fails loudly rather than answering empty. Verified: pnpm typecheck; pnpm run test:capabilities (241 files, 5841 passed, 18 skipped); pnpm check:source after the inventory write.
… injects it Fourth slice of #256, and the hand-off #253 deliberately left out because it had no consumer: `launch` carried an origin but not the credential the Mini App's own guard demands, so nothing could build a proxy target from it. The supervisor now exposes the live generation's credential as `launchCredential(pluginId)`, reading the active generation's runtime handle — the same value the guard was installed with, so it cannot drift from the process it belongs to. `ProcessLocalMiniAppLaunch.launch` carries it under the same server-only comment as the origin: the proxy injects it, and no renderer and no published view ever holds it. The application fails with RUNTIME_FAILED rather than returning a launch object missing the credential, because a caller that cannot authenticate is better off told so than handed coordinates that will be refused. Untested boundary, stated rather than implied: this repository has no supervisor harness, so the credential's path through `open` has typecheck and the capability suite behind it, not a behavioural test. The operation and panel work that consumes it will exercise the path end to end; the guard and readiness suite already proves the credential is the one the port answers to. Verified: pnpm typecheck; pnpm run test:capabilities (240 files, 5841 passed, 18 skipped).
Fifth slice of #256: the wire vertical that makes a Mini App reachable from the browser. `listMiniApps`, `openMiniApp` and `stopMiniApp` are declared in `OperationSpec`, described in `server/operation/miniapp.ts`, and served by one dedicated handler set under `handlers/miniapp.ts` — not bindings, because an open does more than forward one call. `openMiniApp` starts the Mini App through the facade, builds the proxy from the launch's origin and credential, and answers with that proxy's `viewUrl` and the plugin id. The Mini App's own origin, surface path and credential never enter a response: the `WebuiMiniAppLaunch` type that carries them is declared in `runtime/port.ts` rather than `shared/contracts/`, because a shared contract is importable by the browser. The credential header is not spelled in the adapter — it is `MINIAPP_LOOPBACK_TOKEN_HEADER` from `@mavis/shared/miniapp-surface`, the constant the runtime installs its guard with. One listener per plugin, because the proxy port is derived from the plugin id: a second open closes the first listener before binding its own (a second listener would either fail to bind or serve the page from an origin its own storage is not keyed by), a stop closes it, and calls for one plugin are serialised so a double open cannot race two binds onto one port and then, through the failure path, stop the app the first call had just started. An open whose proxy cannot bind stops the runtime it started, rather than leaving a process with no way in. A host without the Mini App cell is refused, not answered emptily: all three operations raise `capability_unavailable` with one message — from the handler when the port has no member, from the adapter when the facade has no application. They register unconditionally, so the refusal is a capability gap rather than `unknown_operation`. Proven (14 cases): the response carries the proxy's URL and no other key, and the credential never appears in it; that URL serves the page and injects the issued credential upstream; a second open replaces the listener instead of doubling it (the replacement binds the derived port, and the live listener injects the second credential); two concurrent opens for one plugin stay serialised; a stop forwards, closes the listener, and afterwards refuses connections; a proxy that cannot start stops the runtime; a host with no capability refuses with the typed error; and the adapter publishes a row without the fields the wire does not carry, attaches the shared header constant to the launch, and refuses a facade with no Mini App cell. Both counts moved with the roster: 93 bindings and 16 dedicated handlers (109 operations). The four new paths — and the two Mini App paths the earlier slices never recorded — are in the frozen path-metadata oracle. Verified: pnpm typecheck:webui; pnpm check:webui-dependency (0 new violations; the two `document` findings in WorkspacePanels.tsx pre-date this change); the new suite; pnpm test:webui (its 6 failures are pre-existing on this branch: the WorkspacePanels source assertions, the gate test that asserts the real tree is clean, two suites that need `pnpm build:styles`, and two real-host integration tests that fail identically at HEAD); pnpm check:source after the inventory write. Untested boundary: the runtime's own start/stop path and the credential its guard installs (a scripted facade stands in here), an Electron consumer of `viewUrl`, and proxies live as long as the process — nothing closes them on service shutdown. The client panel that frames `viewUrl` is the next slice; `client/platform/transport.ts` and the port slices still carry no Mini App member.
…talogue A Mini App is now reachable end to end: the plugin catalogue's apps area lists the installed ones with their runtime state and their lifecycle, the launcher's tile opens a picker fed by the same listing, and choosing one opens a workspace tab that frames the loopback view URL the open operation answers with. One business owns all of it (ADR 0020): the catalogue, the presentation each Mini App is shown in, the refusals a start earned, and the host half of the page bridge. The plugin catalogue and the workspace panel mount its published surfaces and hold no Mini App state of their own; a `miniapp` tab carries only the session and the plugin it shows. The page bridge is injected by the proxy, validates the sender's frame and origin, and asks the person before appending to the composer — declining leaves the draft untouched. A refused fourth start names the three that are running and offers to stop one. An app another client owns is reported with that client and its pid. A stopped runtime clears the view, says why, and reopens only when a person asks. The service now closes every Mini App listener on shutdown, including both production entrypoints, and the runtime is handed the browser presenter through the assembly option. The `webui` row enables `miniAppService` because the presenter and the view land with it.
Expose the installed Mini App inventory through the Mini App business so the catalogue can show disabled local apps and re-enable them without treating ordinary plugins as Mini Apps. Keep the startable projection enabled-only, wire the state to the apps area and picker, and cover the projection and built client panel flow.
The plugin catalogue can start a Mini App without mounting its workspace view. Refresh the shared listing after a successful open so the runtime status and stop action reflect the started process.
ADR 0020's standalone presentation. An inline Mini App tab can hand its presentation to a browser tab of its own, and the client and the service keep one record of which presentation is live. The record is a lease the service owns, held by a `watchMiniAppPresentation` stream for as long as the standalone document's connection lives: a unary request would settle in milliseconds and release the presentation with it. `detached` and `presentationRevision` in the catalogue are read from that record at the moment of the read, so an inline view that polls sees a standalone tab open and close instead of a constant. The inline view reports a live standalone presentation and offers to switch to that tab; it starts nothing and frames nothing while one is live. The standalone page is the same client on `/miniapp?plugin=&session=`, mounting one published surface, claiming the browsing-context name the inline control focuses, and offering no way back inline. A deep link naming a session the runtime no longer has still shows the Mini App and reports the session as ended; a lookup that failed for any other reason surfaces as a failure rather than as an ended session.
An inline Mini App tab mounted while a standalone tab held the presentation declined to start anything, and then nothing read the catalogue again: the open-presentation poll needs a local presentation, and syncPresentations returns without a read when this client holds none. When the standalone tab closed, the notice outlived the lease and the Mini App never came back. The owner now answers that question with one command. `presentInline` reads the catalogue, reports `waiting` while a lease is live, presents the Mini App when it is not, and reports `blocked` for the two states only a person may restart — a refused start and a stopped app. The inline view asks it on mount and again on its existing poll interval while the answer is `waiting`, so a released lease is noticed and the notice clears. The standalone surface keeps presenting itself rather than waiting for its own lease.
The inline wait loop asked the catalogue while another document held the presentation. If the person stopped the Mini App there, the next answer had the lease released and `runtime.status: "stopped"`, and `presentInline` read only the released lease and started the app again — with no user action, which #256's accepted criterion forbids. The fresh row's terminal stop state now ends the loop: `presentInline` records the stopped presentation (so the surface reports "运行已停止" and offers its own retry) and answers `blocked`, and no `openMiniApp` is issued. Lease release while the runtime is still running keeps presenting the app, and the person's retry from the stopped state still starts it.
The panel's browser suite proved the tile, the picker and the framed view. Four #256 criteria had no direct evidence at the assembled client, and they are the ones a person exercises: what a Mini App may append to the composer, what a refused fourth start says, whether a Mini App the runtime already started can be shown, and where installing one goes. The composer case posts the bridge's own wire message from inside the framed document, served at a second loopback origin, so the view's real message listener, sender check and composer write all run. Accepting keeps the existing draft and appends; declining leaves the draft untouched; neither sends. A mutation of the sender check turns this case red, so it binds to the listener rather than to the fixture. The capacity case drives the runtime's BUSY refusal, reads the three running names, stops one of them from the picker and retries. The fixture gains allowOperation because a refusal is a condition, not a latch. A row already reported as running is opened into the panel without the surface stopping or restarting anything, and the apps area's install action is pinned to the marketplace it hands to. No product code changes: all four pass on the current source.
A Mini App manifest's `agentAccess` now reaches the runtime as an admission policy, and the runtime can create host sessions for a plugin and drive turns in them. - `candidate.ts` derives `agentPolicy` from the parsed manifest once: an absent or empty `agentAccess` is deny-all, and `agentConcurrency` only applies when access is granted (default 4, maximum 32). The policy types and both bounds live with `MiniAppAgentPolicy` in `service/miniapp/contracts.ts`, which the manifest reader already imports. - `service/miniapp/agent/` carries the forward ported admission layer: per-plugin run slots with a bounded FIFO (`MINIAPP_AGENT_QUEUE_FACTOR` x concurrency), caller-readable `session_busy` / `queue_full` busy reasons, cancellation and settlement, session ownership and turn lineage, the frame projection onto the shared run-event vocabulary, and permission/questionnaire handling that keeps request identifiers intact. - `process-local-miniapp-agent-application.ts` and the new `LocalRuntimeApplication['miniAppAgent']` member publish the nine client operations. The gateway and its session ports stay private to composition. - `services.ts` resolves the Mini App options once, creates the gateway behind the same availability answer the supervisor uses, and binds the ports to this runtime's own session records, turns, questionnaires and permissions after the session applications exist. Three local differences from the shipped generation, each recorded at its site: this tree has no `channel-system/settle` (the gateway keeps its own `detach`); ownership is read from the persisted `miniapp:<pluginId>` `purpose` label because `SessionRecord` has no structured Mini App origin here; and the questionnaire compat entry is a factory whose pending record carries the wire payload in `.request`, so composition adapts both shapes. Arena registration stays with the child IPC connector, so an end-to-end host turn through the page bridge is not reachable in this change.
…talogue The Mini App agent module declares and emits its own run-event vocabulary, and several names collide with host event names (turn.started, turn.finished, message.committed, permission.requested). Those events are projected to the Mini App page bridge rather than written to the host event bus, so the catalogue scanner counting them would advertise five host events that never occur and demand an unhandled-fact rationale for each. Exclude that one directory from the scan by path, so the catalogue keeps reporting real host producers only and needs no regeneration.
…logue The catalogue scanner reads every host source, so the Mini App agent run-frame vocabulary (turn.started, turn.finished, message.committed, permission.requested) looks like host events. The exclusion landed with the generator fix; this pins it. A fixture tree carries one real host producer, the agent subtree declaring and emitting run frames, and a sibling Mini App file publishing a message.* name. The catalogue must keep both host facts and drop every frame, which fails exactly on "not to contain \"turn.started\"" when the exclusion is emptied.
…nership Review follow-up on the Mini App agent turn. Mini App approval routing reached no consumer: `MiniAppApprovalRouter.shouldDenyApproval` had no caller, so an ask raised by a plugin-owned Session waited on an approval nobody could answer and the plugin's queue never drained. The v2 permission gate now consults the router before it prompts, denies with a reason the model can act on, and fails closed only for a Session already known to be a Mini App root. The gateway supplies that router, and the turn preparation derives the lineage from the trusted SessionRecord, so a request identifier is never the only thing standing between an ask and a stuck turn. Ownership was also claimable: a Mini App Session is identified by its `purpose` label, and the client-facing conversation surface passed a caller-supplied `purpose` straight into `createInternalSession` on its non-root branch. Creation now refuses the reserved prefix on that boundary and on the external `SessionLifecycleService.createSession`; the runtime's own ports keep using the internal path. `hostSessionId` was dead on the ports' `createSession`: this build does not persist a host session association, so the parameter, its stale `miniAppOrigin` comment and the test claims that pinned it are gone rather than left pretending otherwise. The WebUI host-event scanner excluded the whole agent directory; it now excludes only the four files that carry the run-frame vocabulary, and a fixture proves a host producer added to that same directory is still catalogued. Regressions: `test/unit/turn-system/miniapp-approval-gate.test.ts` (6 cases, written failing first) and `test/integration/miniapp-purpose-reservation.integration.test.ts` (2 cases against a real host, watched resolving before the boundary guard existed).
…failure
Review follow-up on the purpose reservation.
The integration suite only exercised `sessionType: 'root'`, which takes the branch that drops
`purpose` anyway. The vulnerable branch is the other one: the compat surface routes every
non-root create through `createInternalSession`, so a caller's `purpose` reached the internal
record path unchanged. The suite now covers that branch explicitly — with the guard disabled it
resolves a full 33-field session, which is the leak the guard closes.
Both client-facing paths also report the same typed failure now: the compat guard throws
`SessionServiceError('purpose-reserved', ...)` instead of a bare `Error`, matching what
`SessionLifecycleService.createSession` raises. That keeps one reason for the two surfaces, and
the existing `LIFECYCLE_FAILURES` entry turns it into `SESSION_PURPOSE_RESERVED` at status 400
where the application surface maps it; the mapping table is keyed by the reason union, so a
missing entry fails the typecheck rather than silently degrading to a generic error.
Move the Host Connector and the Mini App Agent session onto the parent/child IPC channel the runner already uses for lifecycle messages, dispatched by message type. - add the IPC transport (encode, bind, bounded send) and the shared wire contract with a reserved admission budget for permission replies - port the shipped host-side agent session and child agent client; register the arena on activate and connect the runtime's MiniAppAgentGateway through services -> Mini App composition -> initialize -> session factory - spawn with stdin ignored, log stdout/stderr as bounded user logs, and drop the NDJSON connector module and its stdio contract - port the connector, IPC transport, runner, port registry and startup-error suites; add the runtime channel suite for stream logs and the agent channel Ticket #259.
…ild harness The child harness is TypeScript in this distribution, so the two real-process exit cases spawn it through the repo's tsx with the standalone path map, resolve every path from the test file instead of the working directory, and allow the child the time it needs to compile the runtime graph. Child stderr is forwarded so a failed spawn explains itself. Ticket #259.
…t catalogue The child IPC connector answers the same Mini App run frames the agent session declares, so scanning it advertised `permission.requested` as a host event that never occurs. Exclude that one file, as the agent sources already are, and pin it: a host producer beside it is still catalogued and the run frames are not. Ticket #259.
A Mini App page's window.mcode.miniapp.agent now reaches the runtime from the browser client. The proxy's injected script speaks one postMessage protocol, the client that framed the page answers it, and the service resolves the identity the page may not name. - local-runtime-v2: nine forwarding facade methods over the existing miniAppAgent application. No new domain behaviour. - webui: two operations (a followed turn as a stream, and one short call whose refusals are contract-shaped), their port and harness adapter, a shared wire contract that reuses @mavis/shared/miniapp-surface validators, connection and cancellation registries, and the page bridge's agent block. - observe travels as its own field: a run with no listener reaches the runtime with none, so its permission and questionnaire asks are denied rather than left waiting. - open selects the session through the client's ordinary #session= path and reports it shown only once the rail has it; leaving reports it closed.
The new stream and the new dedicated operation move three pinned rosters, and an open answer now carries the connection generation it issued. - the stream set is 8 and names watchMiniAppAgentTurn, which keeps a dedicated handler because its frames are run events plus one settlement. - the dedicated operation roster is 18. - the frozen path metadata records the seven new source paths so every current client path stays classified. - the two opens-one-plugin cases assert the stable part of the answer and that the generation advanced, instead of requiring byte-identical answers.
…e navigation The review found two acceptance criteria unmet in the wiring and four smaller defects. This closes them. - the Mini App business follows the application store's selection instead of a hashchange listener: a rail click selects through the store and replaceState, which fires no navigation event, so the shown lease was never closed. A page's open now waits for the selection to land before reporting it shown. - models(), agents(), create() and the void calls resolve the payload the shipped page API resolves, not the wire envelope around it. - a navigated iframe cancels the turns of the document it replaced and releases the shown lease before the replacement can call. - a watch that settles synchronously no longer leaves its id in flight. - a stop closes the panels the runtime was told about and cancels this plugin's runs before the generation is revoked, so nothing stays answerable. - the dead cancel flag, the duplicated failure guards and the misplaced doc block are gone. Evidence: the duplicate-id, cancellation, navigation, selection, payload-shape and stop cases now fail if their seam is disconnected; the approval rule is driven against the runtime's real gateway; the two operations have loopback-wire cases.
Two boundary defects from the re-check. - the shown lease is remembered while the person is away, so selecting that session again reports panelOpened without the page asking twice. Without it the runtime keeps denying the session's asks while the rail shows it, which is the state AC6 says must be answerable. A released lease — unmount, navigation, stop — is not revived. - a stop keeps the panel records until the runtime stop succeeds, so a stop that throws leaves a retry that still knows what to close, instead of a stale record the runtime carries into the plugin's next start.
The re-check found the remembered presentation had no termination: a refused panelOpened left the record in place, so every store notification asked again. A refusal now drops the claim, because nothing this client can do would make the next attempt succeed.
…dy left Two low-severity defects in the remembered-presentation path. - reopen had no catch, so a transport failure on panelOpened became an unhandled rejection and left the record retrying on every snapshot change. It is now handled like a refusal: the claim is dropped, and a page that still wants it can open it again. - a successful reopen recorded agentShown without re-reading the selection, so a person who left during the call could be left with a shown session nobody was looking at. The selection is checked after the await, and a session that is no longer selected is closed with the runtime instead of being recorded. Both cases were mutation-checked: removing the catch reproduces the unhandled rejection, removing the selection guard drops the expected close.
# Conflicts: # packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Change
Hosts Mini Apps in the WebUI across runtime composition, the client bridge, the loopback proxy, inline and standalone presentations, catalogue and launcher workflows. This consolidates the Mini App stack against the current
webuibaseline and addresses review findings in the plan and handoff.Closes #251
Closes #256
Closes #257
Closes #258
Closes #259
Closes #260
Validation
pnpm test:capabilities— 255 files passed; 6,170 tests passed, 18 skipped.pnpm test:webui— 229 files passed; 3,493 tests passed.pnpm typecheck:webui— passed.pnpm build:webui— passed.pnpm check:source— passed (5,528 files).git diff --check— passed.pnpm test:webui-browserwrapper could not install Linux system dependencies becausesudorequires a terminal. Chromium was already available, so the Mini App browser specs were run directly against the newly built client and passed. Other browser specs remain for CI.Publication and contribution checks
Maintainer handoff
Publication scope or license changes: none.
Shared-source port: pending maintainer handoff after merge.