diff --git a/.changeset/typed-app-client.md b/.changeset/typed-app-client.md new file mode 100644 index 000000000..789586f39 --- /dev/null +++ b/.changeset/typed-app-client.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Export `createAppClient` from `agent-bundle/app` with generated `AppRegister` route contracts, make `createMcpAppBridge` validate bound route ids, cancel App-owned requests, and reject duplicate request ids, and reword `AB4837` to name Apps as the bundle-safe exception. (#601) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 310925cd5..8f51159a6 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -552,8 +552,10 @@ entry citing the warning text it drops and why it is noise; it may be empty. Every App is measured after it is emitted: the UTF-8 bytes of the self-contained HTML and their gzip size, what a compressing transport would -carry. `AB4772` is the size advisory, one **warning** per App. Any view that -imports `@modelcontextprotocol/ext-apps` starts at about 437 kB (104 kB gzip) +carry. `AB4772` is the size advisory, one **warning** per App. A view that +reaches its host through `agent-bundle/app` carries the framework's client +and nothing else from the protocol stack; one that imports +`@modelcontextprotocol/ext-apps` instead starts at about 437 kB (104 kB gzip) — `zod` v3 and v4, `@modelcontextprotocol/sdk`, `zod-to-json-schema`, and `ext-apps` itself — so the advisory bound of 1 MiB (1,048,576 bytes) sits at roughly 2.4× that floor and at half the 2 MiB (2,097,152 bytes) bound above @@ -1230,7 +1232,7 @@ sharing it. | `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. | | `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. | | `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. | -| `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | +| `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/app` (the browser MCP App client, a leaf with no Zod, Node, or compiler import), `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | | `AB4838` | error | A CLI route's `inputSchema` references a binding the static resolver cannot follow. The message is `CLI route inputSchema: .` — the chain is the reference path from `inputSchema`, each step ``, or ` ()` when it crosses into another module (`inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`), and the reason names the boundary: a specifier that `is not a relative module path`, one that `resolves outside the project` or `does not resolve to a module inside the project` (missing or unreadable), a target module that does not declare a top-level `export const `, a binding that is not a top-level `const` (`let`/`var`, destructuring, a function, a class, a default or namespace import — the message says what it is), an identifier that `is neither a top-level const in this module nor a named import from a relative module`, or a dynamic initializer — one that is neither a method chain, an object or array literal, nor a static literal (`whose initializer is a call expression`, `a function expression`, `a template literal with substitutions`). Reported on the route module; the recovery names the supported forms — relative imports inside the project, `export const`, alias chains — then says to inspect again. Only CLI routes raise it, because only there the static contract is load-bearing: an MCP, script, or event route whose schema the resolver cannot follow compiles without a static contract, as an out-of-grammar inline schema does, and the runtime derives its MCP JSON Schema from the real zod object. A reference that resolves but whose schema leaves the grammar is `AB4814`. | | `AB4839` | error | A CLI route's `inputSchema` reference chain is cyclic — `a` → `b` → `a`, within one module or across several: every visited `#` is recorded and revisiting one stops the walk. The message is `CLI route inputSchema: is a reference cycle.` and prints the cycle; it is reported on the route module, with the same recovery and the same CLI-only rule as `AB4838`. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index aef00c10b..2521403b2 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -77,7 +77,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/index.ts` | Library output with declarations. | `lib: false` | | `src/mcp/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | | `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | -| `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required (`AB4812`), and two App routes of one server sharing a URI are `AB4829` (the same URI on different servers is not a collision). An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | +| `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required (`AB4812`), and two App routes of one server sharing a URI are `AB4829` (the same URI on different servers is not a collision). An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. The view talks to its host through `createAppClient()` from the browser-safe `agent-bundle/app` (see [`agent-bundle/app`](#agent-bundleapp--the-app-side-bridge-client)), typed by the generated `AppRegister` augmentation. | Use a custom server or prefix the file with `_` | | `src/scripts/.ts` | Plain script compiled once to `scripts/.mjs` in the plugin root, shared by every selected host — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/.mjs` in the plugin root whenever a selected host publishes the `cli` capability (all built-in hosts do). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | @@ -1502,9 +1502,10 @@ resolve '../events'`). The route graph reports such an import first, as (`src/routes/framework-imports.ts`; the compiler-carrying entries are `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, and -`agent-bundle/test/browser`, matched exactly; `import type` and type-only -usage are not reported), while an external bare import (`AB6005 uses -unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a +`agent-bundle/test/browser`, matched exactly, so the bundle-safe entries — +`agent-bundle/app` among them — are never reported; `import type` and +type-only usage are not reported either), while an external bare import +(`AB6005 uses unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a non-literal dynamic import`) still fails artifact validation. The sanctioned shape is `spawnServeApp` from `agent-bundle/serve-app-command` (`src/serve-app-command.ts`, #558) — plain Node with no dependencies, so the @@ -1528,3 +1529,175 @@ sent; it is still running). It is a checkout command: an installed host pack has neither `node_modules/agent-bundle` nor the artifact, and the first two codes say so before anything is spawned. The worked example is in the MCP Apps guide, "Serving an App standalone". + +## `agent-bundle/app` — the App-side bridge client + +`agent-bundle/app` (`src/app/index.ts`, #594) is the half of the MCP Apps +bridge that runs inside the App document. It is a browser-safe leaf of the +package: its only imports are the shared protocol-version constant +(`src/contracts/mcp-app-protocol.ts`, which +`src/dev/mcp-app-profile-descriptors.ts` re-exports for the host side, so +`APP_PROTOCOL_VERSION` and the host's `MCP_APP_PROTOCOL_VERSION` are one value +— `tests/app-client.test.ts` asserts it), the browser-safe strict-JSON +helpers, and the route-name parser, so `dist/app.js` names no Zod, Node +built-in, Effect, route module, or compiler code, and the App compile inlines +it into the self-contained HTML from the installed package inside the document +(`tests/mcp-apps-compile.test.ts` walks the runtime import graph of +`dist/app.js` and the emitted document for exactly that). It is a public +package export and a compiler-reserved App import, so a consumer `paths` +mapping cannot replace the framework runtime. It is not one of the +compiler-carrying entries `AB4837` rejects. + +The other half stays where it is: the host page, sandbox proxy, frame relay +(`McpAppFrameRelay` in the Workbench, the inline relay in `serve-app`), +`/api/mcp/...` routes, consent authority, and `createMcpAppBridge` +(`src/dev/mcp-apps/mcp-app-bridge.ts`) are host-side and owned by the +Workbench, `serve-app`, and #564's production host; #594 adds no host bridge +and moves none of those modules. The one host-side behavior it adds is +cancellation: `createMcpAppBridge` now honors the client's +`notifications/cancelled` and threads the abort through the binding service +and session lease (below). The `browser-app` proof level +(`mountBrowserApp` in `agent-bundle/test/browser`, which hosts the compiled +document over `createMcpAppBridge`) is where the client meets that host bridge +in a real browser — `examples/mcp-app/tests/browser-app` asserts the +App-to-host traffic is exactly the client's `ui/initialize` and +`ui/notifications/initialized` until the view acts — so there is one wire +contract, not a second host bridge. The checkout hosts are driven by the same +client rather than by hand-written frames: `tests/serve-app.test.ts` connects +`createAppClient` to the served example App over injected ports whose +`postMessage` relays each frame through the `/api/mcp/...` routes the sandbox +relay uses, and the Workbench real-App E2E +(`packages/workbench/tests/mcp-app-real.e2e.test.ts`) compiles a fixture view +on `createAppClient` and reads its `call()` result through the relay. The +client's own contract — envelopes, handshake, pinning, dispatch, cancellation, +rebind, disposal — is proven in `tests/app-client.test.ts` over injected +ports. The client never decides which server a call reaches or which +capability needs consent. + +### Public surface + +`createAppClient(options?)` returns a frozen `AppClient`: + +| Member | Contract | +| --- | --- | +| `connect(options?)` | Performs the `ui/initialize` handshake once and resolves the validated `AppInitializeResult` (`protocolVersion` `2026-01-26` — `APP_PROTOCOL_VERSION` — plus `hostInfo`, `hostCapabilities`, `hostContext`), then sends `ui/notifications/initialized` and records the opening tool name from `hostContext.toolInfo.tool.name` for the opening-notification listeners. Idempotent: a connected client resolves the cached result, a connecting one returns the in-flight promise; a handshake that `rebind()` overtakes rejects with `connection-rebound`. | +| `call(routeId, input, options?)` | `tools/call` for the tool a `tool:/` route id names — the standard wire name is the final segment, derived the way the generated server derives it (`src/routes/protocol-name.ts`); any other shape rejects with a `TypeError`. The client also carries the canonical id in framework-private `_meta`, and `createMcpAppBridge` rejects it with `-32602` unless both server and tool match the bound server before dispatch. `input` must be a finite strict-JSON object (`invalid-message` otherwise). Resolves the result's `structuredContent` **directly**; rejects a result with `isError: true` (`rpc`, whole result on `error.data`), one without an object `structuredContent`, or a malformed envelope (`invalid-message`). The MCP projection emits `structuredContent` for object-valued results only (`advertisedOutputSchema` in `src/mcp-server-runtime.ts`), so a tool whose `resultSchema` is scalar- or array-rooted is typed by the generated map but never resolves through `call()`. | +| `request(method, params?, options?)` | The typed JSON-RPC escape hatch for `resources/read` and supported `ui/*` methods; resolves the raw result. An empty method rejects with a `TypeError`. | +| `onToolInput(routeId, listener)` / `onToolResult(routeId, listener)` / `onToolError(routeId, listener)` | The opening call's `ui/notifications/tool-input` arguments, the decoded `structuredContent` of a successful `ui/notifications/tool-result`, and that notification's failures as an `AppClientError` — `isError: true` is `rpc` with the whole result on `data`; a malformed envelope or one without an object `structuredContent` is `invalid-message`; a failed result never reaches `onToolResult`. The notifications carry no tool name, so dispatch keys on the tool the handshake named: `hostContext.toolInfo.tool.name` from the initialize result, matched against the final segment of each registered route id. Listeners for other tools stay silent; when the initialize result names no tool, `tool-input` and `tool-result` reach no listener. Listeners run on a microtask, exceptions dropped. Each returns its unsubscribe function. | +| `onToolCancelled(listener)` | `ui/notifications/tool-cancelled` as `{ reason? }`, unfiltered; returns its unsubscribe function. | +| `rebind({ parent?, targetOrigin?, window? })` | Bumps the connection generation and rejects the previous generation's pending requests with `connection-rebound` — a `connect()` still in flight included; its late response can never become the live connection — clears the pinned origin and the opening tool name, moves the message listener when `window` changes, adopts the new parent, keeps the configured `targetOrigin` unless the call names the key, and runs `connect()` again. | +| `dispose()` | Idempotent. Removes the message listener, rejects pending requests with `disposed`, drops every registration and the pin. A host `ui/resource-teardown` request is answered with `{}` and disposes the client; any other host request is answered `-32601`. | +| `connected` / `disposed` | Read-only state. | + +`CreateAppClientOptions` are `appInfo` (`{ name, version }`, default +`agent-bundle-app` / `1.0.0`), `appCapabilities` (a finite JSON object, +default `{}`), `timeoutMs` (integer milliseconds, 1 to 2³¹ − 1, default +15 000), and the connect options `parent`, `targetOrigin`, `window`. Every +request takes `{ signal, timeoutMs }`. `signal` uses the exported structural +`AppAbortSignal` contract, which the ambient DOM or Node `AbortSignal` +satisfies: the client reads +`aborted`, adds one `abort` listener, and removes it when the request settles. +A request that times out or is aborted rejects with `timeout` / `aborted` and, +once connected, sends `notifications/cancelled` with the request id and the +reason; the client does not wait to learn whether the host stopped anything. + +Two error kinds, deliberately. Everything asynchronous — wire, host, and +lifecycle — rejects with the one `AppClientError` class: `code` is `timeout`, +`aborted`, `disposed`, `connection-rebound`, `invalid-message`, `rpc`, +`capability-unavailable`, or `consent-required`; a host JSON-RPC error maps +`-32601` to `capability-unavailable`, `-32001` to `consent-required`, and +anything else to `rpc`, keeping `rpcCode` and `data`. Option misuse is a +plain `TypeError` or `RangeError`, never an `AppClientError`: +`createAppClient()` throws synchronously for a `targetOrigin` that is not an +exact `http:`/`https:` origin, for `appInfo`/`appCapabilities` that are not +finite JSON objects with nonempty `name`/`version`, for a missing `window` +with no injected port (`TypeError`), and for a `timeoutMs` that is not an +integer in 1 to 2³¹ − 1 (`RangeError`); the `on*` registrations throw a +`TypeError` for a non-`tool:` route id or a non-function listener; `call()`, +`request()`, and `connect()` surface the same `TypeError`/`RangeError` as their +rejection when the misuse is in their own arguments (a malformed route id, an +empty method, an out-of-range per-request `timeoutMs`). No AB diagnostic is +involved: App-side failures are browser errors. + +### Host-side cancellation + +The client's `notifications/cancelled` is honored by `createMcpAppBridge` +(`src/dev/mcp-apps/mcp-app-bridge.ts`), which the Workbench, `serve-app`, and +`mountBrowserApp` run. Every operation request the bridge accepts — +`tools/call`, `resources/read`, and the `ui/*` requests; `ping` is answered +inline — is tracked in flight under its JSON-RPC id with an `AbortController`; +a well-formed cancellation for an in-flight id aborts that controller, drops +the request's pending consent challenge (resolving it as denied, so a later +approval cannot run the action), and suppresses the late response — a +cancelled request gets neither a result nor an error. The abort signal is +threaded through `McpAppBindingService.callTool`/`readResource` and the +`McpAppSessionLease` into `McpSession`, which composes it into the SDK request +with `AbortSignal.any`, so the bound MCP session's request is cancelled too. A +cancellation for an unknown or finished id is a no-op; one arriving before the +App is initialized is ignored; one carrying an `id` is rejected as malformed; +and a duplicate in-flight request id aborts the ambiguous original operation +and is rejected `-32602`. Re-initialization and close abort every in-flight request. +`tests/mcp-app-bridge-cancellation.test.ts` covers each of those, including +that cancellation cannot bypass consent or reach a request the App did not +start. Hosts outside the framework apply their own policy; the client's +behavior is the same either way. + +### Opaque sandbox handshake + +The Workbench and `serve-app` render the App as `