Skip to content

feat(webui): host Mini Apps across runtime and client - #268

Merged
Fectivnfy112357 merged 45 commits into
webuifrom
feat/miniapp-hosting-completion
Oct 27, 2026
Merged

Fectivnfy112357 merged 45 commits into
webuifrom
feat/miniapp-hosting-completion

Conversation

@Fectivnfy112357

Copy link
Copy Markdown

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 webui baseline 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.
  • Mini App panel and standalone browser specs — 10 passed, including the added cross-client ownership refusal case.
  • pnpm check:source — passed (5,528 files).
  • git diff --check — passed.
  • The pnpm test:webui-browser wrapper could not install Linux system dependencies because sudo requires 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.
  • Browser fixture results cover the built client and its transport wiring; the deterministic fixture is not a live desktop-client ownership test or a live external-provider run.

Publication and contribution checks

  • Added/removed source files were reviewed; this PR adds no source paths or packages.
  • Shared Mini App ADR and known-gaps documentation were synchronized with current source.
  • No credentials, account data, real user content, internal source history or private review material is included.

Maintainer handoff

Publication scope or license changes: none.

Shared-source port: pending maintainer handoff after merge.

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
@Fectivnfy112357
Fectivnfy112357 merged commit edf17c2 into webui Oct 27, 2026
5 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant