diff --git a/.changeset/remove-runtime-app-routes.md b/.changeset/remove-runtime-app-routes.md new file mode 100644 index 000000000..217ca89ae --- /dev/null +++ b/.changeset/remove-runtime-app-routes.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Remove the Workbench runtime App preview path. The dev server no longer serves `/api/runtime/apps/**` or `/api/runtime/mcp/sessions/**`, `DevServerSession` (from `agent-bundle` and `agent-bundle/api`) drops `openRuntimeClientSurface`, and `DevRuntimeEventInput` no longer accepts `runtime.app.updated`, `runtime.hmr.client-connected`, or `runtime.hmr.client-disconnected`. The `AB8022` 410 and `AB8023` 413 runtime App responses are gone; both codes keep their other meanings. Runtime runs, status, surfaces, and MCP App previews for artifact sessions are unchanged. (#852) diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index f51facea7..ad6c2a387 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -29,8 +29,6 @@ packages/ src/core/types.ts src/dev/foreground-server.ts src/dev/mcp-app-action-validation.ts - src/dev/mcp-app-runtime-binding-service.ts - src/dev/mcp-app-runtime-preview-service.ts src/dev/mcp-apps/mcp-app-binding-service.ts src/dev/mcp-apps/mcp-app-preview-service.ts src/dev/mcp-apps/mcp-app-routes.ts @@ -39,11 +37,9 @@ packages/ src/dev/playground/playground-store.ts src/dev/project-service.ts src/dev/runtime-app-message-limits.ts - src/dev/runtime-client-surface-proxy.ts src/dev/runtime-controller.ts src/dev/runtime-generation-store.ts src/dev/runtime-mcp-registry.ts - src/dev/runtime-mcp-routes.ts src/dev/runtime-provider-loader.ts src/dev/runtime-provider.ts src/dev/runtime-routes.ts @@ -69,8 +65,6 @@ packages/ tests/mcp-app-metadata.test.ts tests/mcp-app-preview-service.test.ts tests/mcp-app-routes.test.ts - tests/mcp-app-runtime-binding-service.test.ts - tests/mcp-app-runtime-preview-service.test.ts tests/mcp-app-sandbox.test.ts tests/mcp-apps-compile.test.ts tests/mcp-session-routes.test.ts @@ -85,15 +79,12 @@ packages/ tests/public-api.test.ts tests/rsc-runtime-optional-packaging.test.ts tests/rsc-runtime-topology-script.test.ts - tests/runtime-client-surface-proxy.test.ts tests/runtime-generation-store.test.ts tests/runtime-mcp-registry.test.ts - tests/runtime-mcp-routes.test.ts tests/runtime-provider.test.ts tests/runtime-routes.test.ts workbench/ rsbuild.config.ts - scripts/capture-runtime-playground.mjs src/main.tsx src/mcp/mcp-app-client.ts src/mcp/mcp-app-preview.tsx @@ -104,7 +95,6 @@ packages/ src/runtime-client.ts src/runtime-model.ts src/styles.css - tests/helpers/runtime-playground-fixture.ts tests/mcp-app-client.test.ts tests/mcp-app-frame.test.ts tests/mcp-app-preview-browser.test.ts diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 37f43f594..5d4bc8771 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -39,7 +39,7 @@ even when no error diagnostic was reported. | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7101` is also the one-shot `build` / `build()` refusal when source changes during compilation; `AB7103`: the development package build; see below). | | `AB7300`–`AB7333` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), the retired `AB7332` (see below), and dangling receipt-owned marketplaces (`AB7333`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | -| `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | +| `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route (`/api/discovery`): `AB8215` invalid path, `AB8216` query string or non-`GET` method (400/405), `AB8217` report over the 16 MiB response limit (413), `AB8218` discovery not available (503). | | `AB8219`–`AB8223` | Workbench live MCP probe route (user-initiated, read-only initialize + tools/list): `AB8219` invalid path, `AB8220` invalid request/method, `AB8221` probe target not found, `AB8222` response over the 16 MiB budget, `AB8223` probe unavailable. | @@ -1863,7 +1863,7 @@ foreground server accepts. | `AB8007` | 404 / 405 / 500 | `Route was not found.`, no asset at the path, or `/mcp` when the Agent API is not composed; `Route does not accept this method.`, a route received a method it does not serve; `Request could not be completed.`, a handler threw something other than a request diagnostic. | Check the method and path; for a 500, read the dev-server log for the underlying error. | | `AB8008` | 400 | `Request host is not this foreground server.`, the `Host` header does not name this server's loopback URL. | Address the server by the URL `agent-bundle dev` printed. | | `AB8009` | 415 | `Request body must use application/json.`, a JSON route received a body without an `application/json` content type. | Send `content-type: application/json`. | -| `AB8010` | 413 | `Request body exceeds 64 KiB.`, the default `readBody` bound; the runtime MCP routes apply the same bound. Playground routes raise a 1 MiB bound under `AB8085`. | Send a smaller body. | +| `AB8010` | 413 | `Request body exceeds 64 KiB.`, the default `readBody` bound. Playground routes raise a 1 MiB bound under `AB8085`. | Send a smaller body. | | `AB8011` | 404 | `Skill workbench service is not available.`, a `/api/skills/**` route was requested but the server was composed without the Skill document service. | Nothing to fix in the project; the Skill pages need a server composed with the Skill service. | | `AB8012` | 400 | `Skill route path is not valid.`, a `/api/skills/**` path does not match the source or generated Skill tree, document, or resource shapes, or a segment does not decode. | Use the Skill links the Workbench renders. | @@ -1879,14 +1879,14 @@ foreground server accepts. | `AB8018` | 409 | `MCP session epoch is no longer available; the project changed underneath the session.`, the epoch the session was opened against is no longer available after the project changed. | Open a new session against the current epoch. | | `AB8019` | 400 / 502 | `MCP session could not be opened.` (400, on create) or `MCP session operation could not be completed.` (502), the service threw something the route does not map to a more specific code. | Read the dev-server log for the underlying error, then retry. | -### MCP App previews (`/api/mcp/apps/**`, `/api/mcp/sessions//apps`, `/api/runtime/apps/**`) +### MCP App previews (`/api/mcp/apps/**`, `/api/mcp/sessions//apps`) | Code | Status | Trigger | Recovery | | --- | --- | --- | --- | | `AB8020` | 400 / 404 | `MCP App route path is not valid.`, an App route whose binding id or operation segment is missing or does not decode, or an unknown operation under `/api/mcp/apps//`. `agent-bundle serve-app` answers unknown paths with `Not found.` (404) under the same code. | Use the App routes the Workbench MCP page issues. | | `AB8021` | 400 | `MCP App request has an invalid shape.`, the request body does not match the operation's expected fields. | Send the fields the operation defines. | -| `AB8022` | 404 / 410 / 503 | `MCP App routes are not available.`, 404 without the preview service, 503 after shutdown; `MCP App preview is not available.` (404), the binding id is unknown; `Runtime MCP App preview was revoked.` (410), the runtime binding has been revoked. `agent-bundle serve-app` reports `MCP App host is not ready.` (503) before its host finishes starting. | Re-open the App preview; after 410 the page must create a new binding. | -| `AB8023` | 404 / 409 / 413 / 502 | `MCP App operation could not be completed.` (502), an unmapped service failure; `Runtime MCP App operation exceeded its 30 second deadline.` (502); `Runtime MCP App operation response could not be encoded.` (502) or `… exceeds its transport bound.` (413), the result of a runtime App operation could not cross the bounded host-to-App channel. On `/web//`: `MCP App could not be opened.` (502), the launch, opening call, or page render failed; `Target "…" is not a declared projection that launches MCP server …` (404), an invalid `?target=`, never a fallback; `No declared projection of this artifact launches MCP server …` (404); `The declared projections launch MCP server … differently; pick one explicitly with ?target=<…>.` (409). | Read the dev-server log; shrink or split the App operation result if the bound was hit; on `/web`, pass a `?target=` the message names. | +| `AB8022` | 404 / 503 | `MCP App routes are not available.`, 404 without the preview service, 503 after shutdown; `MCP App preview is not available.` (404), the binding id is unknown. `agent-bundle serve-app` reports `MCP App host is not ready.` (503) before its host finishes starting. | Re-open the App preview. | +| `AB8023` | 404 / 409 / 502 | `MCP App operation could not be completed.` (502), an unmapped service failure. On `/web//`: `MCP App could not be opened.` (502), the launch, opening call, or page render failed; `Target "…" is not a declared projection that launches MCP server …` (404), an invalid `?target=`, never a fallback; `No declared projection of this artifact launches MCP server …` (404); `The declared projections launch MCP server … differently; pick one explicitly with ?target=<…>.` (409). | Read the dev-server log; on `/web`, pass a `?target=` the message names. | ### Hook playground (`/api/hooks/**`) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 4efabc8e2..2dc3185bc 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -128,7 +128,7 @@ moves into one of these positions moves back: `DevRuntimeUnavailableError` / `DevRuntimeGenerationConflictError`, `PlaygroundService*Error` / `PlaygroundSessionCloseError`, `HookPlaygroundCloseError`, `McpProbeTargetNotFoundError`, - `McpAppRuntimePreviewError`, `SkillDocumentError`, + `SkillDocumentError`, `InspectorLauncherError`, `EvalRunEvent*Error`, and `EvalServiceBackgroundFailureOverflowError`). The `@agent-bundle/runtime` `plugin` entry also has no `effect` import today. @@ -802,19 +802,18 @@ resolved the current repo practice stands, and new code follows it. helpers like `isRecord` and to use the `Predicate` module. The repo's shared guards are `core/strict-json.ts` `isRecord` / `isJsonRecord` / `isPlainRecord` and `workbench/src/client-helpers.ts` `isRecord`, but the - divergence is wider than two helpers: fifteen modules define their own + divergence is wider than two helpers: fourteen modules define their own private `isRecord` with the same `typeof === 'object' && !== null && !Array.isArray` body (`mcp-server-runtime.ts`, `build/pack-inventory.ts`, `install/{install,doctor,cursor-agent-plugins-launch}.ts`, `host-contracts/{claude,cursor,portable}-plugin-validation.ts`, `adapters/portable-mcp-rules.ts`, `dev/host-install-manager.ts`, - `dev/mcp-app-runtime-binding-service.ts`, `dev/mcp-apps/{mcp-app-bridge,mcp-app-host-profiles,mcp-app-routes}.ts`, `workbench/src/mcp/mcp-app-preview.tsx`), seven more alias a shared guard - under the local name, and five emit the same one-liner as a string into + under the local name, and four emit the same one-liner as a string into generated hook / proxy / sandbox source that intentionally imports nothing (`adapters/hook-contract.ts`, `install/surface.ts`, - `dev/runtime-client-surface-proxy.ts`, `dev/mcp-apps/mcp-app-sandbox.ts`). + `dev/mcp-apps/mcp-app-sandbox.ts`). Roughly 400 call sites in total, against about a dozen `Predicate.isObject` uses (the install lane). rc.117 `Predicate` has no `isRecord`; `Predicate.isObject` is the closest match (`{}`-typed, excludes diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 162c67f64..941ea229a 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -56,21 +56,8 @@ pnpm --filter @agent-bundle/rsc-agent-runtime-demo capture:widget -- --output /t pnpm docs:runtime-topology ``` -For contributor Workbench/HMR evidence, use the repository fixture rather than -the published package: - -```bash -node packages/workbench/scripts/capture-runtime-playground.mjs \ - --desktop "$PWD/docs/assets/rsc-runtime-workbench/desktop.png" \ - --hmr-before "$PWD/docs/assets/rsc-runtime-workbench/hmr-before.png" \ - --hmr-after "$PWD/docs/assets/rsc-runtime-workbench/hmr-after.png" \ - --compile-error "$PWD/docs/assets/rsc-runtime-workbench/compile-error.png" \ - --recovered "$PWD/docs/assets/rsc-runtime-workbench/recovered.png" \ - --evidence /tmp/rsc-runtime-delivery/evidence.json -``` - -The `--compile-error` capture shows the Workbench diagnostic the provider -publishes when a source change fails to compile: code `AB8206`, phase +When a source change fails to compile, the provider publishes a Workbench +diagnostic with code `AB8206`, phase `source/build`, and a message that carries the Rspack errors themselves, one `file:line:col: message` line per error, with the path relative to the example root, ANSI colour and the SWC code frame stripped. Breaking `src/rsc/worker.tsx` diff --git a/packages/agent-bundle/src/contracts/mcp-apps.ts b/packages/agent-bundle/src/contracts/mcp-apps.ts index 7c7d427f7..c0e9869e3 100644 --- a/packages/agent-bundle/src/contracts/mcp-apps.ts +++ b/packages/agent-bundle/src/contracts/mcp-apps.ts @@ -1,7 +1,7 @@ import type { McpAppJsonValue } from '../dev/mcp-app-metadata.ts'; import type { McpAppBridgeLifecycle } from '../dev/mcp-apps/mcp-app-bridge.ts'; -/** Browser-safe MCP App and Runtime App wire contracts used by Workbench. */ +/** Browser-safe MCP App wire contracts used by Workbench. */ export { MCP_APP_PROFILE_DESCRIPTORS } from '../dev/mcp-app-profile-descriptors.ts'; export type { McpAppProfileId } from '../dev/mcp-app-profile-descriptors.ts'; export { @@ -17,27 +17,13 @@ export { export type { McpAppBridgeMessage } from '../dev/mcp-apps/mcp-app-bridge.ts'; export type { McpAppBridgeLifecycle } from '../dev/mcp-apps/mcp-app-bridge.ts'; export type { McpAppJsonValue } from '../dev/mcp-app-metadata.ts'; -export type { - McpAppBoundOperationResult, - McpAppPublicRuntimeVector, - McpAppRuntimeBindingSnapshot, -} from '../dev/mcp-app-runtime-binding-service.ts'; -export type { - CreateMcpAppPreviewRequest, - McpAppBindingOperation, - McpAppConsentCreatedResponse, - McpAppConsentDecisionResponse, - McpAppPreviewAppsSnapshot, - McpAppPreviewSnapshot, - McpAppRuntimeInvalidationDetails, -} from '../dev/mcp-app-runtime-preview-service.ts'; export { isMcpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; export type { McpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; export type { McpAppConsentChallenge, McpAppConsentRequest, McpAppDocumentPolicySnapshot, -} from '../dev/mcp-apps/mcp-app-sandbox.ts'; +} from '../dev/mcp-apps/mcp-app-sandbox-types.ts'; export interface McpAppRelayFrame { readonly allow: string; diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 0e3baa0c8..917afb01f 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -22,7 +22,6 @@ import { McpProbeRoutes, type McpProbeRouteService } from './playground/mcp-prob import { McpAppRoutes, type McpAppRoutePreviewService } from './mcp-apps/mcp-app-routes.ts'; import { McpSessionRoutes } from './mcp-session/mcp-session-routes.ts'; import type { McpSessionService } from './mcp-session/mcp-session-service.ts'; -import { RuntimeMcpRoutes } from './runtime-mcp-routes.ts'; import { RuntimeRoutes, type AgentDocumentRuntimeModule } from './runtime-routes.ts'; import type { DevRuntimeSession } from './runtime-provider.ts'; import { PlaygroundRoutes, type PlaygroundRouteService } from './playground/playground-routes.ts'; @@ -426,7 +425,6 @@ export class ForegroundServer { readonly #mcpAppPreviews: McpAppRoutePreviewService | undefined; readonly #mcpAppRoutes: McpAppRoutes; readonly #mcpProbeRoutes: McpProbeRoutes; - readonly #runtimeMcpRoutes: RuntimeMcpRoutes; readonly #mcpSessionRoutes: McpSessionRoutes; readonly #runtimeRoutes: RuntimeRoutes; readonly #now: () => Date; @@ -522,18 +520,6 @@ export class ForegroundServer { authorize: (request) => this.#assertMutationSession(request), ...(options.mcpSessions === undefined ? {} : { service: options.mcpSessions }), }); - this.#runtimeMcpRoutes = new RuntimeMcpRoutes({ - authorize: (request) => this.#assertMutationSession(request), - ...(options.mcpAppPreviews === undefined - ? {} - : { - awaitRegistryMutation: async () => { await options.mcpAppPreviews?.runtime?.flushRegistry?.(); }, - awaitSessionClose: async ({ expectedSessionRevision, sessionId }) => { - await options.mcpAppPreviews?.runtime?.closeSession?.(sessionId, expectedSessionRevision); - }, - }), - ...(options.runtime === undefined ? {} : { runtime: options.runtime }), - }); this.#runtimeRoutes = new RuntimeRoutes({ authorize: (request) => this.#assertMutationSession(request), ...(options.testing?.loadAgentDocumentRuntime === undefined @@ -715,7 +701,6 @@ export class ForegroundServer { this.#mcpAppRoutes.close(); this.#hostMcpRoutes?.close(); this.#mcpSessionRoutes.close(); - this.#runtimeMcpRoutes.close(); this.#runtimeRoutes.close(); // Publish the hook playground drain before awaiting App tombstones. Its // abort callbacks may synchronously re-enter foreground shutdown, and @@ -828,7 +813,6 @@ export class ForegroundServer { } if (await this.#mcpAppRoutes.handle(request, response)) return; if (await this.#mcpSessionRoutes.handle(request, response)) return; - if (await this.#runtimeMcpRoutes.handle(request, response)) return; if (await this.#runtimeRoutes.handle(request, response)) return; if (await this.#hookPlaygroundRoutes.handle(request, response)) return; if (await this.#mcpProbeRoutes.handle(request, response)) return; diff --git a/packages/agent-bundle/src/dev/index.ts b/packages/agent-bundle/src/dev/index.ts index 0a1d0aeff..141341c8c 100644 --- a/packages/agent-bundle/src/dev/index.ts +++ b/packages/agent-bundle/src/dev/index.ts @@ -84,11 +84,6 @@ export { type WorkbenchAssetSource, } from './foreground-server.ts'; export { createWorkbenchAssetSource, type WorkbenchAssetSourceOptions } from './workbench-assets.ts'; -export { - RuntimeClientSurfaceProxy, - runtimeClientSurfaceReloadChannelPath, - type RuntimeClientSurfaceConnectionEvent, -} from './runtime-client-surface-proxy.ts'; export { routeManifestFor } from './routes/route-manifest.ts'; export type { RouteManifest, @@ -110,15 +105,6 @@ export { type RouteManifestRoutesOptions, } from './routes/route-manifest-routes.ts'; export { RuntimeRoutes, type RuntimeRoutesOptions } from './runtime-routes.ts'; -export { RuntimeMcpRoutes, type RuntimeMcpRoutesOptions } from './runtime-mcp-routes.ts'; -export { - McpAppRuntimePreviewService, - type CreateMcpAppPreviewRequest, - type McpAppBindingOperation, - type McpAppOperationResponse, - type McpAppPreviewSnapshot, - type McpAppRuntimeRoutePreviewService, -} from './mcp-app-runtime-preview-service.ts'; export { McpSession, McpSessionService, @@ -210,7 +196,6 @@ export { export type { CreateDevRuntimeProvider, DevRuntimeClientSurfaceEndpoint, - DevRuntimeClientSurfaceProxyBinding, DevRuntimeEventInput, DevRuntimeMcpRegistry, DevRuntimeMcpRegistryListener, diff --git a/packages/agent-bundle/src/dev/mcp-app-runtime-binding-service.ts b/packages/agent-bundle/src/dev/mcp-app-runtime-binding-service.ts deleted file mode 100644 index 8431c7824..000000000 --- a/packages/agent-bundle/src/dev/mcp-app-runtime-binding-service.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { settleBeforeAbort } from '../core/abort.ts'; -import { cloneMcpAppFiniteJson, type McpAppJsonValue } from './mcp-app-metadata.ts'; -import { MCP_APP_PROFILE_DESCRIPTORS, type McpAppProfileId } from './mcp-app-profile-descriptors.ts'; -import type { DevRuntimeMcpSessionView } from './runtime-provider.ts'; -import type { - DevRuntimeMcpAppRunBinding, - DevRuntimeMcpOperationRequest, - DevRuntimeMcpOperationResult, - DevRuntimeMcpSessionBinding, - RuntimeVector, -} from './runtime-protocol.ts'; - -export type { McpAppProfileId } from './mcp-app-profile-descriptors.ts'; - -export interface McpAppStableSessionIdentity { - readonly definitionDigest: string; - readonly registryRevision: number; - readonly serverDigest: string; - readonly serverName: string; - readonly sessionId: string; - readonly sessionRevision: number; - readonly target: string; - readonly transportDigest: string; -} - -/** The complete browser-safe RuntimeVector projection; private authority fields are intentionally absent. */ -export interface McpAppPublicRuntimeVector { - readonly artifactEpochId?: string; - readonly runtimeGenerationId: string; - readonly sourceRevision: string; - readonly stateVersion: number; -} - -export interface McpAppPreviewBindingVector extends McpAppStableSessionIdentity { - readonly evidence: 'simulated'; - readonly profileId: McpAppProfileId; - readonly profileVersion: string; - /** Serializable projection: provider/state authority remains private to the service. */ - readonly runVector: McpAppPublicRuntimeVector; -} - -export interface McpAppRuntimeBindingSnapshot extends McpAppPreviewBindingVector { - readonly id: string; -} - -export interface McpAppBoundOperationResult { - readonly operationId: string; - readonly sessionId: string; - readonly sessionRevision: number; - readonly value: McpAppJsonValue; - /** Serializable projection of the leased current implementation vector. */ - readonly vector: McpAppPublicRuntimeVector; -} - -export interface McpAppRuntimeBindingTeardown { - (event: Readonly<{ - readonly binding: McpAppRuntimeBindingSnapshot; - readonly reason: 'app-closed' | 'runtime-shutdown' | 'session-closed' | 'session-invalidated'; - }>): Promise | void; -} - -export interface CreateMcpAppRuntimeBindingOptions { - readonly onTeardown?: McpAppRuntimeBindingTeardown; - readonly profileId: McpAppProfileId; - readonly runBinding: DevRuntimeMcpAppRunBinding; - readonly runVector: RuntimeVector; - /** A non-owning stable registry view; runtime App bindings can never close it. */ - readonly session: DevRuntimeMcpSessionView; -} - -export type McpAppRuntimeOperationRequest = - | Readonly<{ readonly kind: 'list-tools' }> - | Readonly<{ readonly kind: 'list-resources' }> - | Readonly<{ readonly arguments?: McpAppJsonValue; readonly kind: 'call-tool'; readonly name: string }> - | Readonly<{ readonly kind: 'read-resource'; readonly uri: string }>; - -export interface McpAppRuntimeBindingInvalidation { - readonly sessionId: string; - readonly sessionRevision: number; -} - -export interface McpAppRuntimeBindingOperationOptions { - readonly signal?: AbortSignal; -} - -interface McpAppRuntimeBindingOperation { - readonly controller: AbortController; - readonly settled: Promise; -} - -interface McpAppRuntimeBinding { - closing: boolean; - readonly operations: Set; - readonly privateRunVector: RuntimeVector; - releaseAttempt: Promise | undefined; - readonly session: DevRuntimeMcpSessionView; - readonly snapshot: McpAppRuntimeBindingSnapshot; - readonly teardown: McpAppRuntimeBindingTeardown | undefined; - unsubscribe: () => void; -} - -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; - -const nonempty = (value: unknown, label: string): string => { - if (typeof value !== 'string' || value.trim().length === 0) throw new TypeError(`${label} must be a nonempty string.`); - return value; -}; - -const revision = (value: unknown, label: string): number => { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { - throw new TypeError(`${label} must be a positive safe integer.`); - } - return value; -}; - -const stateVersion = (value: unknown, label: string): number => { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { - throw new TypeError(`${label} must be a nonnegative safe integer.`); - } - return value; -}; - -const cloneVector = (value: RuntimeVector, label: string): RuntimeVector => Object.freeze({ - ...(value.artifactEpochId === undefined ? {} : { artifactEpochId: nonempty(value.artifactEpochId, `${label} artifact epoch`) }), - providerSessionId: nonempty(value.providerSessionId, `${label} provider session id`), - runtimeGenerationId: nonempty(value.runtimeGenerationId, `${label} generation id`), - sourceRevision: nonempty(value.sourceRevision, `${label} source revision`), - stateStoreId: nonempty(value.stateStoreId, `${label} state store id`), - stateVersion: stateVersion(value.stateVersion, `${label} state version`), -}); - -/** Redacts trusted provider/state authority before any value is serializable to the browser. */ -const publicVector = (value: RuntimeVector): McpAppPublicRuntimeVector => Object.freeze({ - ...(value.artifactEpochId === undefined ? {} : { artifactEpochId: value.artifactEpochId }), - runtimeGenerationId: value.runtimeGenerationId, - sourceRevision: value.sourceRevision, - stateVersion: value.stateVersion, -}); - -const profile = (value: McpAppProfileId): McpAppProfileId => { - if (value === 'portable' || value === 'chatgpt' || value === 'claude') return value; - throw new TypeError(`Unsupported MCP App profile ${JSON.stringify(value)}.`); -}; - -const stableIdentity = (binding: DevRuntimeMcpAppRunBinding, label: string): McpAppStableSessionIdentity => Object.freeze({ - definitionDigest: nonempty(binding.definitionDigest, `${label} definition digest`), - registryRevision: revision(binding.registryRevision, `${label} registry revision`), - serverDigest: nonempty(binding.serverDigest, `${label} server digest`), - serverName: nonempty(binding.serverName, `${label} server name`), - sessionId: nonempty(binding.sessionId, `${label} session id`), - sessionRevision: revision(binding.sessionRevision, `${label} session revision`), - target: nonempty(binding.target, `${label} target`), - transportDigest: nonempty(binding.transportDigest, `${label} transport digest`), -}); - -const sameStableIdentity = (left: McpAppStableSessionIdentity, right: McpAppStableSessionIdentity): boolean => - left.sessionId === right.sessionId - && left.sessionRevision === right.sessionRevision - && left.registryRevision === right.registryRevision - && left.target === right.target - && left.serverName === right.serverName - && left.definitionDigest === right.definitionDigest - && left.transportDigest === right.transportDigest - && left.serverDigest === right.serverDigest; - -const snapshotIdentity = (binding: DevRuntimeMcpSessionBinding): McpAppStableSessionIdentity => stableIdentity(binding, 'Live runtime MCP session'); - -const canonicalOperation = (request: McpAppRuntimeOperationRequest, expectedSessionRevision: number): DevRuntimeMcpOperationRequest => { - if (!isRecord(request) || typeof request.kind !== 'string') throw new TypeError('Runtime MCP App operation must be an object.'); - if (request.kind === 'list-tools' || request.kind === 'list-resources') { - return Object.freeze({ expectedSessionRevision, kind: request.kind }); - } - if (request.kind === 'read-resource') { - return Object.freeze({ expectedSessionRevision, kind: 'read-resource', uri: nonempty(request.uri, 'Runtime MCP App resource URI') }); - } - if (request.kind === 'call-tool') { - const argumentsValue = request.arguments === undefined ? Object.freeze({}) : cloneMcpAppFiniteJson(request.arguments, 'Runtime MCP App tool arguments'); - if (!isRecord(argumentsValue)) throw new TypeError('Runtime MCP App tool arguments must be a finite JSON object.'); - return Object.freeze({ - arguments: argumentsValue as Readonly>, - expectedSessionRevision, - kind: 'call-tool', - name: nonempty(request.name, 'Runtime MCP App tool name'), - }); - } - throw new TypeError('Unsupported Runtime MCP App operation.'); -}; - -const abortReason = (signal: AbortSignal): unknown => signal.reason ?? new Error('Runtime MCP App operation was cancelled.'); - -export class McpAppRuntimeBindingService { - readonly #entries = new Map(); - readonly #pendingReleases = new Map(); - #closeAttempt: Promise | undefined; - #closing = false; - - get(bindingId: string): McpAppRuntimeBindingSnapshot | undefined { - return this.#entries.get(bindingId)?.snapshot; - } - - async createBinding(options: CreateMcpAppRuntimeBindingOptions): Promise { - if (this.#closing) throw new Error('Runtime MCP App binding service is closed.'); - if (options === null || typeof options !== 'object') throw new TypeError('Runtime MCP App binding options must be an object.'); - if (options.onTeardown !== undefined && typeof options.onTeardown !== 'function') throw new TypeError('Runtime MCP App teardown callback must be a function.'); - const profileId = profile(options.profileId); - const runIdentity = stableIdentity(options.runBinding, 'Stored runtime MCP App binding'); - const privateRunVector = cloneVector(options.runVector, 'Stored runtime run vector'); - const liveSnapshot = options.session.snapshot(); - const liveIdentity = snapshotIdentity(liveSnapshot.binding); - if (!sameStableIdentity(runIdentity, liveIdentity) - || liveSnapshot.binding.providerSessionId !== privateRunVector.providerSessionId - || liveSnapshot.binding.stateStoreId !== privateRunVector.stateStoreId) { - throw new Error('Stored runtime MCP App binding does not match the live stable runtime MCP session.'); - } - if (liveSnapshot.state === 'closed') throw new Error('Runtime MCP session closed before its App binding completed.'); - - let entry: McpAppRuntimeBinding | undefined; - let sessionClosed = false; - const observation = options.session.watchClosed(() => { - sessionClosed = true; - return entry === undefined ? undefined : this.#releaseEntry(entry, 'session-closed'); - }); - try { - sessionClosed ||= observation.closed; - if (sessionClosed || this.#closing) throw new Error('Runtime MCP session closed before its App binding completed.'); - const snapshot = Object.freeze({ - ...runIdentity, - evidence: 'simulated' as const, - id: randomUUID(), - profileId, - profileVersion: MCP_APP_PROFILE_DESCRIPTORS[profileId].version, - runVector: publicVector(privateRunVector), - }); - entry = { - closing: false, - operations: new Set(), - privateRunVector, - releaseAttempt: undefined, - session: options.session, - snapshot, - teardown: options.onTeardown, - unsubscribe: observation.unsubscribe, - }; - this.#entries.set(snapshot.id, entry); - if (sessionClosed || this.#closing) { - await this.#releaseEntry(entry, this.#closing ? 'runtime-shutdown' : 'session-closed'); - throw new Error('Runtime MCP session closed before its App binding completed.'); - } - return snapshot; - } catch (error) { - if (entry === undefined) observation.unsubscribe(); - throw error; - } - } - - async execute( - bindingId: string, - request: McpAppRuntimeOperationRequest, - options: McpAppRuntimeBindingOperationOptions = {}, - ): Promise { - const entry = this.#entry(bindingId); - const canonical = canonicalOperation(request, entry.snapshot.sessionRevision); - let finish: (() => void) | undefined; - const settled = new Promise((resolve) => { - finish = resolve; - }); - const controller = new AbortController(); - const parent = options.signal; - const abortFromParent = (): void => { - if (!controller.signal.aborted) controller.abort(abortReason(parent!)); - }; - if (parent?.aborted) abortFromParent(); - else parent?.addEventListener('abort', abortFromParent, { once: true }); - const active = Object.freeze({ controller, settled }); - entry.operations.add(active); - try { - const operation = await settleBeforeAbort(Promise.resolve().then(async () => { - if (controller.signal.aborted) throw abortReason(controller.signal); - return entry.session.execute(canonical, Object.freeze({ signal: controller.signal })); - }), controller.signal, () => abortReason(controller.signal)); - this.#assertActive(entry); - return this.#operationResult(entry, operation); - } finally { - parent?.removeEventListener('abort', abortFromParent); - entry.operations.delete(active); - finish?.(); - } - } - - async closeBinding(bindingId: string): Promise { - const entry = this.#entries.get(bindingId) ?? this.#pendingReleases.get(bindingId); - if (entry === undefined) return false; - await this.#releaseEntry(entry, 'app-closed'); - return true; - } - - async invalidateBindings(invalidation: McpAppRuntimeBindingInvalidation): Promise { - const sessionId = nonempty(invalidation.sessionId, 'Runtime MCP App invalidation session id'); - const sessionRevision = revision(invalidation.sessionRevision, 'Runtime MCP App invalidation session revision'); - const entries = [...new Set([...this.#entries.values(), ...this.#pendingReleases.values()])].filter((entry) => - entry.snapshot.sessionId === sessionId && entry.snapshot.sessionRevision === sessionRevision, - ); - const outcomes = await Promise.allSettled(entries.map(async (entry) => this.#releaseEntry(entry, 'session-invalidated'))); - const failure = outcomes.find((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected'); - if (failure !== undefined) throw failure.reason; - } - - async close(): Promise { - if (this.#closeAttempt !== undefined) return this.#closeAttempt; - this.#closing = true; - const close = (async () => { - const entries = [...new Set([...this.#entries.values(), ...this.#pendingReleases.values()])]; - const outcomes = await Promise.allSettled(entries.map(async (entry) => this.#releaseEntry(entry, 'runtime-shutdown'))); - const failure = outcomes.find((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected'); - if (failure !== undefined) throw failure.reason; - })(); - this.#closeAttempt = close; - void close.then( - () => { - if (this.#closeAttempt === close) this.#closeAttempt = undefined; - }, - () => { - if (this.#closeAttempt === close) this.#closeAttempt = undefined; - }, - ); - return close; - } - - #entry(bindingId: string): McpAppRuntimeBinding { - const entry = this.#entries.get(bindingId); - if (entry === undefined || entry.closing) throw new Error(`Unknown runtime MCP App binding ${JSON.stringify(bindingId)}.`); - return entry; - } - - #assertActive(entry: McpAppRuntimeBinding): void { - if (entry.closing || this.#entries.get(entry.snapshot.id) !== entry) { - throw new Error(`Runtime MCP App binding ${JSON.stringify(entry.snapshot.id)} is closed.`); - } - } - - #operationResult(entry: McpAppRuntimeBinding, operation: DevRuntimeMcpOperationResult): McpAppBoundOperationResult { - if (operation.sessionId !== entry.snapshot.sessionId) { - throw new Error('Runtime MCP operation returned another session identity.'); - } - if (operation.sessionRevision !== entry.snapshot.sessionRevision) { - throw new Error('Runtime MCP operation returned another session revision.'); - } - const privateVector = cloneVector(operation.vector, 'Runtime MCP operation vector'); - if (privateVector.providerSessionId !== entry.privateRunVector.providerSessionId - || privateVector.stateStoreId !== entry.privateRunVector.stateStoreId) { - throw new Error('Runtime MCP operation returned a foreign provider/state authority.'); - } - return Object.freeze({ - operationId: nonempty(operation.operationId, 'Runtime MCP operation id'), - sessionId: entry.snapshot.sessionId, - sessionRevision: entry.snapshot.sessionRevision, - value: cloneMcpAppFiniteJson(operation.value, 'Runtime MCP operation result'), - vector: publicVector(privateVector), - }); - } - - #releaseEntry( - entry: McpAppRuntimeBinding, - reason: 'app-closed' | 'runtime-shutdown' | 'session-closed' | 'session-invalidated', - ): Promise { - if (entry.releaseAttempt !== undefined) return entry.releaseAttempt; - entry.closing = true; - entry.unsubscribe(); - this.#entries.delete(entry.snapshot.id); - const release = (async () => { - for (const operation of entry.operations) { - operation.controller.abort(new Error('Runtime MCP App binding was closed.')); - } - await Promise.allSettled([...entry.operations].map(async (operation) => operation.settled)); - if (entry.teardown !== undefined) await entry.teardown(Object.freeze({ binding: entry.snapshot, reason })); - })(); - entry.releaseAttempt = release; - this.#pendingReleases.set(entry.snapshot.id, entry); - void release.then( - () => { - if (this.#pendingReleases.get(entry.snapshot.id) === entry) this.#pendingReleases.delete(entry.snapshot.id); - }, - () => { - if (this.#pendingReleases.get(entry.snapshot.id) === entry) this.#pendingReleases.delete(entry.snapshot.id); - }, - ); - return release; - } -} diff --git a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts deleted file mode 100644 index 3b382841c..000000000 --- a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts +++ /dev/null @@ -1,783 +0,0 @@ -import { isPlainRecord } from '../core/strict-json.ts'; -import { - McpAppRuntimeBindingService, - type McpAppBoundOperationResult, - type McpAppPublicRuntimeVector, - type McpAppProfileId, - type McpAppRuntimeBindingSnapshot, -} from './mcp-app-runtime-binding-service.ts'; -import { parseMcpAppResource, type McpAppParsedResource } from './mcp-apps/mcp-app-bridge.ts'; -import { - cloneMcpAppFiniteJson, - inspectMcpAppMetadata, - isMcpAppToolVisible, - mergeMcpAppResourceMetadata, - projectMcpAppResult, - selectMcpAppResourceReference, - type McpAppJsonValue, - type McpAppMetadataInspection, - type McpAppResultInspection, -} from './mcp-app-metadata.ts'; -import { - resolveMcpAppHostProfile, - type McpAppAppsHostProfile, - type McpAppConfigExtensionInspectionOptions, - type McpAppFallbackHostProfile, -} from './mcp-apps/mcp-app-host-profiles.ts'; -import { - createMcpAppConsentActionDigest, - createMcpAppConsentAuthority, - createMcpAppDocumentPolicySnapshot, - deriveMcpAppSandboxPolicy, - type McpAppConsentAuthority, - type McpAppConsentChallenge, - type McpAppConsentGrant, - type McpAppConsentRequest, - type McpAppDocumentPolicySnapshot, - type McpAppSandboxDeclaration, -} from './mcp-apps/mcp-app-sandbox.ts'; -import type { RuntimeClientSurfaceContentPolicy } from './runtime-client-surface-proxy.ts'; -import type { DevRuntimeClientSurfaceProxyBinding, DevRuntimeMcpRegistryMessage, DevRuntimeMcpSessionView, DevRuntimeSession } from './runtime-provider.ts'; -import type { DevRuntimeMcpAppRunBinding, DevRuntimeMcpConnectionState, RuntimeVector } from './runtime-protocol.ts'; - -export type McpAppBindingOperation = - | Readonly<{ readonly kind: 'tools/list' }> - | Readonly<{ readonly kind: 'resources/list' }> - | Readonly<{ readonly arguments?: McpAppJsonValue; readonly consentId?: string; readonly kind: 'tools/call'; readonly name: string }> - | Readonly<{ readonly kind: 'resources/read'; readonly uri: string }>; - -export interface CreateMcpAppPreviewRequest { - readonly expectedGenerationId: string; - readonly profileId: McpAppProfileId; - readonly runId: string; -} - -export interface McpAppPreviewSessionSnapshot { - readonly binding: DevRuntimeMcpAppRunBinding; - readonly connection: DevRuntimeMcpConnectionState; - readonly state: 'ready'; -} - -export interface McpAppOperationTrace { - readonly kind: McpAppBindingOperation['kind']; - readonly operationId: string; - readonly sessionId: string; - readonly sessionRevision: number; - readonly vector: McpAppPublicRuntimeVector; -} - -export interface McpAppPreviewSnapshotBase { - readonly binding: McpAppRuntimeBindingSnapshot; - readonly metadata: Readonly<{ readonly resource: McpAppMetadataInspection; readonly result: McpAppMetadataInspection; readonly tool: McpAppMetadataInspection }>; - readonly operations: readonly McpAppOperationTrace[]; - readonly result: McpAppResultInspection; - readonly session: McpAppPreviewSessionSnapshot; -} - -export interface McpAppPreviewAppsSnapshot extends McpAppPreviewSnapshotBase { - readonly clientSurface: Readonly<{ readonly bootstrapUrl: string; readonly origin: string }>; - readonly documentPolicy: McpAppDocumentPolicySnapshot; - readonly kind: 'apps'; - readonly profile: McpAppAppsHostProfile; - readonly resource: McpAppParsedResource; -} - -export interface McpAppPreviewFallbackSnapshot extends McpAppPreviewSnapshotBase { - readonly kind: 'fallback'; - readonly profile: McpAppFallbackHostProfile; -} - -export type McpAppPreviewSnapshot = McpAppPreviewAppsSnapshot | McpAppPreviewFallbackSnapshot; - -export interface McpAppOperationResponse { readonly result: McpAppBoundOperationResult; } - -export interface McpAppConsentCreatedResponse { - readonly challenge: McpAppConsentChallenge; - readonly documentPolicy: McpAppDocumentPolicySnapshot; -} - -export interface McpAppConsentDecisionResponse { - readonly documentPolicy: McpAppDocumentPolicySnapshot; - readonly grant: McpAppConsentGrant | undefined; -} - -export interface McpAppRuntimeInvalidationDetails { - readonly bindingId: string; - readonly reason: 'manual-close' | 'registry-replay-gap' | 'restart-failed' | 'runtime-shutdown' | 'session-closed' | 'session-restarted'; - readonly sessionId: string; - readonly sessionRevision: number; - readonly state: 'revoked'; -} - -export interface McpAppRuntimeRoutePreviewService { - close(bindingId: string): Promise; - /** Awaits every run-bound App revoke/cleanup attempt for one manually closed session revision. */ - closeSession?(sessionId: string, sessionRevision: number): Promise; - create(request: CreateMcpAppPreviewRequest): Promise; - createConsent(bindingId: string, request: McpAppConsentRequest): Promise; - decideConsent(bindingId: string, consentId: string, decision: 'allow-once' | 'deny'): Promise; - /** Server-only mutation barrier for the sole manual runtime MCP route owner. */ - flushRegistry?(): Promise; - get(bindingId: string): McpAppPreviewSnapshot | undefined; - /** A bounded local tombstone only; it distinguishes revoked from unknown IDs. */ - isRevoked?(bindingId: string): boolean; - operate(bindingId: string, operation: McpAppBindingOperation, options?: McpAppRuntimeOperationOptions): Promise; -} - -export interface McpAppRuntimeOperationOptions { - readonly signal?: AbortSignal; -} - -/** Closed, phase-safe diagnostics intended for the authenticated runtime App route. */ -export class McpAppRuntimePreviewError extends Error { - readonly code: 'AB8023' | 'AB8201' | 'AB8203' | 'AB8204'; - readonly status: 400 | 404 | 409 | 502; - - constructor(code: McpAppRuntimePreviewError['code'], message: string, status: McpAppRuntimePreviewError['status']) { - super(message); - this.name = 'McpAppRuntimePreviewError'; - this.code = code; - this.status = status; - } -} - -export interface McpAppRuntimePreviewServiceOptions { - readonly bindingAuthority: McpAppRuntimeBindingService; - readonly configExtensions: () => McpAppConfigExtensionInspectionOptions; - /** This opens the foreground-owned proxy; the runtime service never receives a provider endpoint. */ - readonly openRuntimeClientSurface: ( - surfaceId: string, - policy: RuntimeClientSurfaceContentPolicy, - ) => Promise; - readonly runtime: DevRuntimeSession; - readonly emit?: (details: McpAppRuntimeInvalidationDetails) => void; - /** Injected only by deterministic service tests; production uses the system timer. */ - readonly operationClock?: McpAppRuntimeOperationClock; -} - -export interface McpAppRuntimeOperationClock { - clearTimeout(timer: ReturnType): void; - setTimeout(callback: () => void, milliseconds: number): ReturnType; -} - -interface PreviewOperation { - readonly controller: AbortController; - dispose(): void; - timedOut: boolean; - timer: ReturnType | undefined; -} - -interface PreviewEntry { - readonly binding: McpAppRuntimeBindingSnapshot; - readonly catalog: Readonly<{ - readonly resources: McpAppBoundOperationResult; - readonly resourceUris: readonly string[]; - readonly toolNames: readonly string[]; - readonly tools: McpAppBoundOperationResult; - }>; - readonly cleanup: PreviewCleanup; - readonly consent: McpAppConsentAuthority; - readonly runBinding: DevRuntimeMcpAppRunBinding; - readonly session: DevRuntimeMcpSessionView; - snapshot: McpAppPreviewSnapshot; - activeOperations: number; - readonly operations: Set; - documentGrants: readonly McpAppConsentGrant[]; - documentPolicy: McpAppDocumentPolicySnapshot; - revoked: boolean; -} - -interface PreviewCleanup { - readonly bindingId: string; - readonly sessionId: string; - readonly sessionRevision: number; - bindingReleased: boolean; - bindingReleaseInFlight: boolean; - closeAttempt?: Promise; - /** The foreground proxy can be acquired after a concurrent DELETE starts. */ - proxyAcquisition?: Promise; - proxy?: DevRuntimeClientSurfaceProxyBinding; - proxyClosed: boolean; -} - -const maxConcurrentOperations = 4; -const operationTimeoutMs = 30_000; -const systemOperationClock: McpAppRuntimeOperationClock = Object.freeze({ - clearTimeout: (timer: ReturnType) => clearTimeout(timer), - setTimeout: (callback: () => void, milliseconds: number) => setTimeout(callback, milliseconds), -}); - -const isRecord = isPlainRecord; - -const canonicalCallToolConsentDetails = ( - catalog: PreviewEntry['catalog'], - details: McpAppJsonValue, -): McpAppJsonValue => { - if (!isRecord(details) || typeof details.name !== 'string' || !catalog.toolNames.includes(details.name)) { - throw new Error('Runtime MCP App tool is not in the binding catalog.'); - } - return frozenJson(Object.freeze({ - arguments: details.arguments ?? Object.freeze({}), - name: details.name, - }), 'Runtime MCP App call-tool consent details'); -}; - -const nonempty = (value: unknown, label: string): string => { - if (typeof value !== 'string' || value.length === 0 || value.length > 4_096 || value.includes('\0')) { - throw new TypeError(`${label} must be a nonempty string.`); - } - return value; -}; - -const frozenJson = (value: unknown, label: string): McpAppJsonValue => cloneMcpAppFiniteJson(value, label); - -const sandboxDeclaration = (resource: McpAppParsedResource): McpAppSandboxDeclaration => Object.freeze({ - ...(resource.csp === undefined ? {} : { csp: resource.csp }), - ...(resource.permissions === undefined ? {} : { permissions: resource.permissions }), -}); - -const runBindingEquals = (stored: DevRuntimeMcpAppRunBinding, live: DevRuntimeMcpAppRunBinding): boolean => - stored.definitionDigest === live.definitionDigest && stored.registryRevision === live.registryRevision && - stored.serverDigest === live.serverDigest && stored.serverName === live.serverName && - stored.sessionId === live.sessionId && stored.sessionRevision === live.sessionRevision && - stored.target === live.target && stored.transportDigest === live.transportDigest; - -const defaultHost = (tool: McpAppJsonValue) => Object.freeze({ - availableDisplayModes: Object.freeze(['inline']), - containerDimensions: Object.freeze({ height: 720, width: 1_024 }), - deviceCapabilities: Object.freeze({}), - displayMode: 'inline', - locale: 'en-US', - platform: 'web', - safeAreaInsets: Object.freeze({ bottom: 0, left: 0, right: 0, top: 0 }), - styles: Object.freeze({}), - theme: 'light' as const, - timeZone: 'UTC', - toolInfo: Object.freeze({ tool }), - userAgent: 'agent-bundle-runtime-mcp-app/1', -}); - -const operation = (value: McpAppBoundOperationResult, kind: McpAppBindingOperation['kind']): McpAppOperationTrace => Object.freeze({ - kind, - operationId: value.operationId, - sessionId: value.sessionId, - sessionRevision: value.sessionRevision, - vector: value.vector, -}); - -const listItems = (value: McpAppJsonValue, key: 'tools' | 'resources'): readonly McpAppJsonValue[] => { - if (Array.isArray(value)) return value; - if (!isRecord(value) || !Array.isArray(value[key])) throw new Error(`Runtime MCP ${key} response is invalid.`); - return value[key] as readonly McpAppJsonValue[]; -}; - -const metadataOf = (value: McpAppJsonValue): unknown => isRecord(value) ? value._meta : undefined; - -const catalogOperation = ( - value: McpAppBoundOperationResult, - key: 'resources' | 'tools', - items: readonly McpAppJsonValue[], -): McpAppBoundOperationResult => Object.freeze({ - ...value, - value: Object.freeze({ [key]: Object.freeze([...items]) }), -}); - -const aggregateFailures = (message: string, failures: readonly unknown[]): never => { - throw new AggregateError(failures, message); -}; - -const flattenFailures = (failures: readonly unknown[]): readonly unknown[] => failures.flatMap((failure) => - failure instanceof AggregateError ? flattenFailures(failure.errors) : [failure], -); - -const sameDocumentPolicy = (left: McpAppDocumentPolicySnapshot, right: McpAppDocumentPolicySnapshot): boolean => - left.allow === right.allow && - JSON.stringify(left.approvedPermissions) === JSON.stringify(right.approvedPermissions) && - JSON.stringify(left.warnings) === JSON.stringify(right.warnings); - -/** Provider-owned preview lane for an already-succeeded runtime App run. */ -export class McpAppRuntimePreviewService implements McpAppRuntimeRoutePreviewService { - readonly #bindingAuthority: McpAppRuntimeBindingService; - readonly #configExtensions: () => McpAppConfigExtensionInspectionOptions; - /** Retained until every fallible proxy and lease release has succeeded. */ - readonly #cleanups = new Map(); - readonly #emit: ((details: McpAppRuntimeInvalidationDetails) => void) | undefined; - readonly #entries = new Map(); - readonly #invalidationReasons = new Map(); - readonly #openRuntimeClientSurface: McpAppRuntimePreviewServiceOptions['openRuntimeClientSurface']; - readonly #operationClock: McpAppRuntimeOperationClock; - readonly #runtime: DevRuntimeSession; - readonly #subscription: ReturnType; - readonly #revokedBindings = new Set(); - #closed = false; - #lastRegistrySequence = 0; - #registryTail: Promise = Promise.resolve(); - #replayGap = false; - - constructor(options: McpAppRuntimePreviewServiceOptions) { - this.#bindingAuthority = options.bindingAuthority; - this.#configExtensions = options.configExtensions; - this.#emit = options.emit; - this.#openRuntimeClientSurface = options.openRuntimeClientSurface; - this.#operationClock = options.operationClock ?? systemOperationClock; - this.#runtime = options.runtime; - this.#subscription = this.#runtime.mcpRegistry.subscribe({ afterSequence: 0 }, (message) => { - const operation = this.#registryTail.then( - async () => this.#onRegistry(message), - async () => this.#onRegistry(message), - ); - // Registry listeners are deliberately synchronous. Keep their async - // consequences ordered, and fail closed rather than leaving an - // unhandled rejection that can let a later create bypass a bad replay. - this.#registryTail = operation.catch(async () => { - this.#replayGap = true; - await Promise.allSettled([...this.#entries.keys()].map(async (id) => this.#closeEntry(id, 'registry-replay-gap'))); - }); - }); - } - - get(bindingId: string): McpAppPreviewSnapshot | undefined { - return this.#entries.get(bindingId)?.snapshot; - } - - isRevoked(bindingId: string): boolean { return this.#revokedBindings.has(bindingId); } - - async flushRegistry(): Promise { await this.#registryTail; } - - async create(request: CreateMcpAppPreviewRequest): Promise { - if (this.#closed || this.#replayGap) throw new Error('Runtime MCP App previews are not available.'); - const runId = nonempty(request.runId, 'Runtime MCP App run id'); - const expectedGenerationId = nonempty(request.expectedGenerationId, 'Runtime MCP App expected generation'); - if (request.profileId !== 'portable' && request.profileId !== 'chatgpt' && request.profileId !== 'claude') throw new TypeError('Unsupported MCP App profile.'); - const run = this.#runtime.run(runId); - if (run === undefined || run.status !== 'succeeded' || run.result.app === undefined) { - throw new McpAppRuntimePreviewError('AB8201', 'Runtime MCP App run is not available.', 404); - } - if (run.vector.runtimeGenerationId !== expectedGenerationId) { - throw new McpAppRuntimePreviewError('AB8204', 'Runtime MCP App run generation does not match the expected generation.', 409); - } - const runBinding = run.result.app.mcpBinding; - const session = this.#runtime.mcpRegistry.session(runBinding.sessionId); - if (session === undefined) throw new Error('Runtime MCP App stable session is not available.'); - const live = session.snapshot(); - if (live.state !== 'ready' || !runBindingEquals(runBinding, live.binding)) throw new Error('Runtime MCP App stable session does not match stored run evidence.'); - if (live.binding.providerSessionId !== run.vector.providerSessionId || live.binding.stateStoreId !== run.vector.stateStoreId) { - throw new Error('Runtime MCP App stable session has foreign provider/state authority.'); - } - if ( - live.connection.protocolEra === undefined || live.connection.protocolVersion === undefined || - live.connection.capabilities === undefined || live.connection.server === undefined - ) { - throw new Error('Runtime MCP App stable session has incomplete negotiation.'); - } - const result = projectMcpAppResult(run.result.protocol); - const createdBinding = { id: undefined as string | undefined }; - const binding = await this.#bindingAuthority.createBinding({ - // This callback is invoked by the binding authority while its own - // release is in flight. It must never call closeBinding recursively. - onTeardown: () => createdBinding.id === undefined ? undefined : this.#bindingReleased( - createdBinding.id, - this.#invalidationReasons.get(createdBinding.id) ?? 'session-closed', - ), - profileId: request.profileId, - runBinding, - runVector: run.vector, - session, - }); - createdBinding.id = binding.id; - // Retain cleanup ownership before any subsequent provider/proxy operation - // can fail. A failed create has no public binding, but shutdown must still - // be able to retry every lease and proxy release. - const cleanup: PreviewCleanup = { - bindingId: binding.id, - bindingReleased: false, - bindingReleaseInFlight: false, - proxyClosed: false, - sessionId: runBinding.sessionId, - sessionRevision: runBinding.sessionRevision, - }; - this.#cleanups.set(binding.id, cleanup); - let proxy: DevRuntimeClientSurfaceProxyBinding | undefined; - try { - await this.#registryTail; - this.#assertCreateStillValid(binding.id, runBinding, run.vector, session); - const tools = await this.#bindingAuthority.execute(binding.id, { kind: 'list-tools' }); - this.#assertCreateStillValid(binding.id, runBinding, run.vector, session); - const resources = await this.#bindingAuthority.execute(binding.id, { kind: 'list-resources' }); - this.#assertCreateStillValid(binding.id, runBinding, run.vector, session); - const appTools = listItems(tools.value, 'tools').filter((candidate) => - isRecord(candidate) && isMcpAppToolVisible(candidate) - && selectMcpAppResourceReference(metadataOf(candidate))?.uri === run.result.app!.resourceUri, - ); - if (appTools.length !== 1) throw new Error('Stored Runtime MCP App tool is not uniquely App-visible in the stable session.'); - const appTool = appTools[0]!; - if (!isRecord(appTool)) throw new Error('Stored Runtime MCP App tool is invalid.'); - const appToolName = nonempty(appTool.name, 'Stored Runtime MCP App tool name'); - const resource = listItems(resources.value, 'resources').find((candidate) => isRecord(candidate) && candidate.uri === run.result.app!.resourceUri); - if (!isRecord(resource) || resource.mimeType !== 'text/html;profile=mcp-app') { - throw new Error('Stored Runtime MCP App resource is not visible in the stable session.'); - } - const read = await this.#bindingAuthority.execute(binding.id, { kind: 'read-resource', uri: run.result.app.resourceUri }); - this.#assertCreateStillValid(binding.id, runBinding, run.vector, session); - const parsed = parseMcpAppResource(read.value, run.result.app.resourceUri); - if (parsed === undefined) throw new Error('Stored Runtime MCP App resource is not canonical Apps HTML.'); - const declaration = sandboxDeclaration(parsed); - const documentPolicy = createMcpAppDocumentPolicySnapshot(1, declaration, []); - const clientSurfacePolicy: RuntimeClientSurfaceContentPolicy = Object.freeze({ - contentSecurityPolicy: deriveMcpAppSandboxPolicy( - declaration, - Object.freeze({ permissions: documentPolicy.approvedPermissions }), - ).contentSecurityPolicy, - }); - const listedMetadata = metadataOf(resource); - const contents = isRecord(read.value) && Array.isArray(read.value.contents) ? read.value.contents : []; - const readContent = contents.find((candidate) => - isRecord(candidate) && candidate.uri === run.result.app!.resourceUri && candidate.mimeType === 'text/html;profile=mcp-app'); - if (readContent === undefined) throw new Error('Stored Runtime MCP App resource read is not canonical Apps HTML.'); - const resourceMetadata = mergeMcpAppResourceMetadata(listedMetadata, metadataOf(readContent as McpAppJsonValue)); - const configExtensions = this.#configExtensions(); - const profile = resolveMcpAppHostProfile({ - configExtensions, - host: defaultHost(frozenJson(appTool, 'Runtime MCP App tool')), - profile: request.profileId, - // The immutable merged inspection is exposed separately below. Profile - // metadata is tool-owned here so duplicate standard `ui` keys never - // become an authority-merging input. - declaredCapabilities: Object.keys(parsed.permissions ?? {}), - resource: { metadata: resourceMetadata.merged, mimeType: 'text/html;profile=mcp-app', uri: run.result.app.resourceUri }, - toolMetadata: metadataOf(appTool), - }); - if (profile.kind === 'apps') { - // Store the promise before yielding. A concurrent DELETE therefore - // joins the acquisition and closes a proxy that resolves afterward. - const proxyAcquisition = this.#openRuntimeClientSurface(run.result.app.surfaceId, clientSurfacePolicy); - cleanup.proxyAcquisition = proxyAcquisition; - proxy = await proxyAcquisition; - cleanup.proxy = proxy; - } - this.#assertCreateStillValid(binding.id, runBinding, run.vector, session); - const base = Object.freeze({ - binding, - metadata: Object.freeze({ resource: resourceMetadata.merged, result: inspectMcpAppMetadata(result.appVisible), tool: inspectMcpAppMetadata(metadataOf(appTool)) }), - operations: Object.freeze([operation(tools, 'tools/list'), operation(resources, 'resources/list'), operation(read, 'resources/read')]), - result, - session: Object.freeze({ binding: runBinding, connection: live.connection, state: 'ready' as const }), - }); - let snapshot: McpAppPreviewSnapshot; - if (profile.kind === 'apps' && proxy !== undefined) { - snapshot = Object.freeze({ - ...base, - clientSurface: Object.freeze({ bootstrapUrl: proxy.bootstrapUrl, origin: proxy.origin }), - documentPolicy, - kind: 'apps' as const, - profile, - resource: parsed, - }); - } else { - const fallback = profile.kind === 'fallback' - ? profile - : resolveMcpAppHostProfile({ - configExtensions, - host: defaultHost(frozenJson(appTool, 'Runtime MCP App tool')), - profile: request.profileId, - }); - if (fallback.kind !== 'fallback') throw new Error('Runtime MCP App fallback profile is invalid.'); - snapshot = Object.freeze({ ...base, kind: 'fallback' as const, profile: fallback }); - } - await this.#registryTail; - this.#assertCreateStillValid(binding.id, runBinding, run.vector, session); - this.#entries.set(binding.id, { - activeOperations: 0, - binding, - catalog: Object.freeze({ - resources: catalogOperation(resources, 'resources', [resource]), - resourceUris: Object.freeze([run.result.app.resourceUri]), - toolNames: Object.freeze([appToolName]), - tools: catalogOperation(tools, 'tools', [appTool]), - }), - cleanup, - consent: createMcpAppConsentAuthority(), - documentGrants: Object.freeze([]), - documentPolicy, - operations: new Set(), - revoked: false, - runBinding, - session, - snapshot, - }); - return snapshot; - } catch (error) { - cleanup.proxy = proxy; - const release = await Promise.allSettled([this.#cleanup(cleanup)]); - const failures = release.flatMap((result) => result.status === 'rejected' - ? [error, ...flattenFailures([result.reason])] - : [error]); - if (failures.length > 1) aggregateFailures('Runtime MCP App preview creation and cleanup failed.', failures); - throw error; - } - } - - async operate( - bindingId: string, - request: McpAppBindingOperation, - options: McpAppRuntimeOperationOptions = {}, - ): Promise { - const entry = this.#entry(bindingId); - if (entry.activeOperations >= maxConcurrentOperations) throw new Error('Runtime MCP App operation limit reached.'); - entry.activeOperations += 1; - const operation = this.#startOperation(entry, options.signal); - try { - if (operation.controller.signal.aborted) throw operation.controller.signal.reason ?? new Error('Runtime MCP App operation was cancelled.'); - let result: McpAppBoundOperationResult; - if (request.kind === 'tools/list') result = entry.catalog.tools; - else if (request.kind === 'resources/list') result = entry.catalog.resources; - else if (request.kind === 'resources/read') { - const uri = nonempty(request.uri, 'Runtime MCP App resource URI'); - if (!entry.catalog.resourceUris.includes(uri)) throw new Error('Runtime MCP App resource is not in the binding catalog.'); - result = await this.#bindingAuthority.execute(bindingId, { kind: 'read-resource', uri }, Object.freeze({ signal: operation.controller.signal })); - } - else { - const name = nonempty(request.name, 'Runtime MCP App tool name'); - if (!entry.catalog.toolNames.includes(name)) throw new Error('Runtime MCP App tool is not in the binding catalog.'); - if (request.consentId === undefined || !entry.consent.consume({ actionDigest: createMcpAppConsentActionDigest('call-tool', Object.freeze({ arguments: request.arguments ?? {}, name })), authorizationId: request.consentId, bindingId, capability: 'call-tool', profile: entry.binding.profileId })) { - throw new Error('Runtime MCP App tool call requires an approved consent grant.'); - } - result = await this.#bindingAuthority.execute(bindingId, { arguments: request.arguments, kind: 'call-tool', name }, Object.freeze({ signal: operation.controller.signal })); - } - return Object.freeze({ result }); - } catch (error) { - if (operation.timedOut) { - throw new McpAppRuntimePreviewError('AB8023', 'Runtime MCP App operation exceeded its 30 second deadline.', 502); - } - throw error; - } finally { - operation.dispose(); - entry.activeOperations -= 1; - } - } - - async createConsent(bindingId: string, request: McpAppConsentRequest): Promise { - const entry = this.#entry(bindingId); - if (!isRecord(request) || typeof request.actionFingerprint !== 'string' || typeof request.summary !== 'string') { - throw new TypeError('Runtime MCP App consent request is invalid.'); - } - const suppliedDetails = frozenJson(request.details, 'Runtime MCP App consent details'); - const capability = request.capability; - const valid = new Set(['call-tool', 'download-file', 'open-external-link', 'clipboard-write', 'camera', 'microphone', 'geolocation', 'request-display-mode']); - const documentScoped = capability === 'clipboard-write' || capability === 'camera' || capability === 'microphone' || capability === 'geolocation'; - if (!valid.has(capability) - || request.actionFingerprint.length === 0 || request.actionFingerprint.length > 256 - || request.summary.length === 0 || request.summary.length > 512 - || request.scope !== (documentScoped ? 'document' : 'action')) { - throw new TypeError('Runtime MCP App consent request is invalid.'); - } - const details = capability === 'call-tool' - ? canonicalCallToolConsentDetails(entry.catalog, suppliedDetails) - : suppliedDetails; - const challenge = entry.consent.challenge({ actionDigest: createMcpAppConsentActionDigest(capability, details), bindingId, capability, details, profile: entry.binding.profileId }); - if (challenge === undefined) throw new Error('Runtime MCP App consent challenge limit reached.'); - return Object.freeze({ challenge, documentPolicy: this.#documentPolicy(entry) }); - } - - async decideConsent(bindingId: string, consentId: string, decision: 'allow-once' | 'deny'): Promise { - const entry = this.#entry(bindingId); - if (decision !== 'allow-once' && decision !== 'deny') throw new TypeError('Runtime MCP App consent decision is invalid.'); - const resolution = entry.consent.resolve(nonempty(consentId, 'Runtime MCP App consent id'), decision === 'allow-once'); - const grant = resolution.status === 'approved' ? resolution.grant : undefined; - if (grant?.scope === 'document') { - const documentGrants = Object.freeze([...entry.documentGrants, grant]); - const candidate = createMcpAppDocumentPolicySnapshot(entry.documentPolicy.revision + 1, { - ...(entry.snapshot.kind !== 'apps' || entry.snapshot.resource.csp === undefined ? {} : { csp: entry.snapshot.resource.csp }), - ...(entry.snapshot.kind !== 'apps' || entry.snapshot.resource.permissions === undefined ? {} : { permissions: entry.snapshot.resource.permissions }), - }, documentGrants); - if (!sameDocumentPolicy(entry.documentPolicy, candidate)) { - entry.documentGrants = documentGrants; - this.#replaceDocumentPolicy(entry, candidate); - } - } - return Object.freeze({ documentPolicy: this.#documentPolicy(entry), grant }); - } - - async close(bindingId: string): Promise { await this.#closeEntry(bindingId, 'manual-close'); } - - /** Emits all terminal invalidations before foreground SSE/proxy teardown. */ - async prepareClose(): Promise { - if (!this.#closed) { - this.#closed = true; - this.#subscription.unsubscribe(); - } - for (const entry of [...this.#entries.values()]) this.#revoke(entry, 'runtime-shutdown'); - } - - async closeSession(sessionId: string, sessionRevision: number): Promise { - await this.#registryTail; - const matching = [...this.#cleanups.values()] - .filter((cleanup) => cleanup.sessionId === sessionId && cleanup.sessionRevision === sessionRevision); - const results = await Promise.allSettled(matching.map(async (cleanup) => this.#closeEntry(cleanup.bindingId, 'session-closed'))); - const failures = flattenFailures(results.flatMap((result) => result.status === 'rejected' ? [result.reason] : [])); - if (failures.length > 0) aggregateFailures('Runtime MCP App session cleanup failed.', failures); - } - - async closeAll(): Promise { - await this.prepareClose(); - const results = await Promise.allSettled([...this.#cleanups.keys()].map(async (id) => this.#closeEntry(id, 'runtime-shutdown'))); - const failures = flattenFailures(results.flatMap((result) => result.status === 'rejected' ? [result.reason] : [])); - if (failures.length > 0) aggregateFailures('Runtime MCP App previews could not close every resource.', failures); - } - - #entry(bindingId: string): PreviewEntry { - const entry = this.#entries.get(bindingId); - if (entry === undefined || entry.revoked) throw new Error('Runtime MCP App preview is not available.'); - return entry; - } - - #documentPolicy(entry: PreviewEntry): McpAppDocumentPolicySnapshot { return entry.documentPolicy; } - - #replaceDocumentPolicy(entry: PreviewEntry, documentPolicy: McpAppDocumentPolicySnapshot): void { - entry.documentPolicy = documentPolicy; - if (entry.snapshot.kind === 'apps') entry.snapshot = Object.freeze({ ...entry.snapshot, documentPolicy }); - } - - async #onRegistry(message: DevRuntimeMcpRegistryMessage): Promise { - if (this.#closed) return; - if ('type' in message) { - this.#replayGap = true; - await Promise.allSettled([...this.#entries.keys()].map(async (id) => this.#closeEntry(id, 'registry-replay-gap'))); - return; - } - if (message.sequence <= this.#lastRegistrySequence) return; - this.#lastRegistrySequence = message.sequence; - if (message.action === 'implementation-updated') return; - const reason = message.action === 'restart-failed' ? 'restart-failed' : 'session-restarted'; - await Promise.allSettled(message.invalidatedBindings.map(async (binding) => { - for (const [id, entry] of this.#entries) { - if (entry.binding.sessionId === binding.sessionId && entry.binding.sessionRevision === binding.sessionRevision) { - this.#invalidationReasons.set(id, reason); - } - } - await this.#bindingAuthority.invalidateBindings(binding); - })); - } - - #assertCreateStillValid( - bindingId: string, - runBinding: DevRuntimeMcpAppRunBinding, - runVector: RuntimeVector, - session: DevRuntimeMcpSessionView, - ): void { - if (this.#closed || this.#replayGap || this.#bindingAuthority.get(bindingId) === undefined) { - throw new Error('Runtime MCP App stable session changed while its preview was being created.'); - } - const live = session.snapshot(); - if (live.state !== 'ready' || !runBindingEquals(runBinding, live.binding) - || live.binding.providerSessionId !== runVector.providerSessionId - || live.binding.stateStoreId !== runVector.stateStoreId) { - throw new Error('Runtime MCP App stable session changed while its preview was being created.'); - } - } - - async #closeEntry(bindingId: string, reason: McpAppRuntimeInvalidationDetails['reason']): Promise { - const entry = this.#entries.get(bindingId); - if (entry !== undefined) this.#revoke(entry, reason); - const cleanup = this.#cleanups.get(bindingId); - if (cleanup === undefined) return; - await this.#cleanup(cleanup); - } - - async #bindingReleased(bindingId: string, reason: McpAppRuntimeInvalidationDetails['reason']): Promise { - const entry = this.#entries.get(bindingId); - if (entry !== undefined) this.#revoke(entry, reason); - const cleanup = this.#cleanups.get(bindingId); - if (cleanup === undefined) return; - cleanup.bindingReleased = true; - // The binding authority awaits its teardown callback. Joining its current - // release attempt here would recurse into that same promise forever. - if (cleanup.bindingReleaseInFlight) return; - await this.#cleanup(cleanup); - } - - #revoke(entry: PreviewEntry, reason: McpAppRuntimeInvalidationDetails['reason']): void { - if (entry.revoked) return; - for (const operation of entry.operations) { - operation.controller.abort(new Error('Runtime MCP App operation was cancelled.')); - } - entry.revoked = true; - this.#invalidationReasons.delete(entry.binding.id); - this.#revokedBindings.add(entry.binding.id); - while (this.#revokedBindings.size > 128) this.#revokedBindings.delete(this.#revokedBindings.values().next().value as string); - // Publication happens while the frozen snapshot remains reachable; the - // subscriber may tear down its bridge before the lookup disappears. - this.#emit?.(Object.freeze({ bindingId: entry.binding.id, reason, sessionId: entry.binding.sessionId, sessionRevision: entry.binding.sessionRevision, state: 'revoked' })); - this.#entries.delete(entry.binding.id); - } - - #startOperation(entry: PreviewEntry, external: AbortSignal | undefined): PreviewOperation { - const controller = new AbortController(); - const abort = (): void => { - if (!controller.signal.aborted) controller.abort(external?.reason ?? new Error('Runtime MCP App operation was cancelled.')); - }; - if (external?.aborted) abort(); - else external?.addEventListener('abort', abort, { once: true }); - const operation: PreviewOperation = { - controller, - dispose: () => { - external?.removeEventListener('abort', abort); - if (operation.timer !== undefined) this.#operationClock.clearTimeout(operation.timer); - entry.operations.delete(operation); - }, - timedOut: false, - timer: undefined, - }; - operation.timer = this.#operationClock.setTimeout(() => { - operation.timedOut = true; - controller.abort(new Error('Runtime MCP App operation exceeded its 30 second deadline.')); - }, operationTimeoutMs); - entry.operations.add(operation); - return operation; - } - - async #cleanup(cleanup: PreviewCleanup): Promise { - if (cleanup.closeAttempt !== undefined) return cleanup.closeAttempt; - const attempt = (async () => { - const failures: unknown[] = []; - if (!cleanup.bindingReleased) { - cleanup.bindingReleaseInFlight = true; - try { - await this.#bindingAuthority.closeBinding(cleanup.bindingId); - cleanup.bindingReleased = true; - } catch (error) { - failures.push(error); - } finally { - cleanup.bindingReleaseInFlight = false; - } - } - if (!cleanup.proxyClosed) { - if (cleanup.proxyAcquisition !== undefined) { - try { - cleanup.proxy ??= await cleanup.proxyAcquisition; - } catch { - // The acquisition itself failed, so no proxy resource exists to - // close. Creation reports that failure through its own promise. - cleanup.proxyClosed = true; - } - } - } - if (!cleanup.proxyClosed) { - try { - await cleanup.proxy?.close(); - cleanup.proxyClosed = true; - } catch (error) { - failures.push(error); - } - } - if (failures.length > 0) aggregateFailures('Runtime MCP App preview cleanup failed.', failures); - this.#cleanups.delete(cleanup.bindingId); - })(); - cleanup.closeAttempt = attempt; - void attempt.then( - () => undefined, - () => { if (cleanup.closeAttempt === attempt) cleanup.closeAttempt = undefined; }, - ); - return attempt; - } -} diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts index 2f658fed9..6eb8f11f1 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts @@ -13,14 +13,13 @@ import { type McpAppBinding, type McpAppJsonValue, } from './mcp-app-binding-service.ts'; -import { createMcpAppConsentActionDigest } from './mcp-app-consent.ts'; +import { createMcpAppConsentActionDigest, type McpAppConsentCapability } from './mcp-app-consent.ts'; import { cloneMcpAppJson, snapshotMcpAppJson, snapshotMcpAppJsonRecord } from './mcp-app-json.ts'; import type { McpAppConsentAuthority, - McpAppConsentCapability, McpAppSandboxCsp, McpAppSandboxPermissions, -} from './mcp-app-sandbox.ts'; +} from './mcp-app-sandbox-types.ts'; import { MCP_APP_PROTOCOL_VERSION } from '../mcp-app-profile-descriptors.ts'; export { MCP_APP_PROTOCOL_VERSION } from '../mcp-app-profile-descriptors.ts'; diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-preview-service.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-preview-service.ts index f36a124ab..61edd1057 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-preview-service.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-preview-service.ts @@ -31,13 +31,15 @@ import { createMcpAppConsentActionDigest, createMcpAppDocumentPolicySnapshot, createMcpAppSandboxFrame, - type McpAppConsentAuthority, - type McpAppConsentChallenge, - type McpAppDocumentPolicySnapshot, type McpAppSandboxEndpoint, type McpAppSandboxFrame, - type McpAppSandboxPermissions, } from './mcp-app-sandbox.ts'; +import type { + McpAppConsentAuthority, + McpAppConsentChallenge, + McpAppDocumentPolicySnapshot, + McpAppSandboxPermissions, +} from './mcp-app-sandbox-types.ts'; export interface McpAppPreviewBindingAuthority extends McpAppBridgeBindingOperations { createBinding(options: { diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts index f9de43e60..c3ff23b96 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts @@ -1,15 +1,8 @@ -import { Buffer } from 'node:buffer'; import type { IncomingMessage, ServerResponse } from 'node:http'; import type { McpAppJsonValue, McpAppPreviewProfile } from './mcp-app-binding-service.ts'; import type { McpAppBridgeCloseOptions, McpAppBridgeJsonRecord, McpAppBridgeLifecycle } from './mcp-app-bridge.ts'; import type { McpAppPreviewCloseResult, McpAppPreviewHostContext, McpAppPreviewTerminal } from './mcp-app-preview-service.ts'; -import { McpAppRuntimePreviewError } from '../mcp-app-runtime-preview-service.ts'; -import type { - CreateMcpAppPreviewRequest, - McpAppBindingOperation, - McpAppRuntimeRoutePreviewService, -} from '../mcp-app-runtime-preview-service.ts'; import { hasOnlyOwnKeys } from '../../core/strict-json.ts'; import { diagnostic, @@ -22,10 +15,7 @@ import { responseDiagnostic, responseJson as writeJsonResponse, } from '../http.ts'; -import { isMcpAppConsentCapability } from './mcp-app-sandbox.ts'; -import type { McpAppConsentChallenge } from './mcp-app-sandbox.ts'; -import type { McpAppConsentRequest } from './mcp-app-sandbox.ts'; -import { runtimeAppMessageLimits } from '../runtime-app-message-limits.ts'; +import type { McpAppConsentChallenge } from './mcp-app-sandbox-types.ts'; // A force-close DELETE that lands after an accepted graceful close must stay // idempotent (200, not 404), so this window has to dominate the frame relay's @@ -43,19 +33,7 @@ interface BindingRoute { readonly kind: 'messages' | 'host-context' | 'close' | 'force-close' | 'consent' | 'result'; } -interface RuntimeCreateRoute { readonly kind: 'runtime-create'; } -interface RuntimeBindingRoute { - readonly bindingId: string; - readonly kind: 'runtime-get' | 'runtime-close' | 'runtime-operation' | 'runtime-consent-create'; -} -interface RuntimeConsentRoute { - readonly bindingId: string; - readonly consentId: string; - readonly kind: 'runtime-consent-decide'; -} - -type Route = CreateRoute | BindingRoute | RuntimeCreateRoute | RuntimeBindingRoute | RuntimeConsentRoute; -type RuntimeRoute = RuntimeCreateRoute | RuntimeBindingRoute | RuntimeConsentRoute; +type Route = CreateRoute | BindingRoute; type JsonObject = Record; type JsonRequestId = string | number | null; @@ -90,8 +68,6 @@ export interface McpAppRoutePreviewService { get(bindingId: string): McpAppRoutePreview | undefined; receive(bindingId: string, action: unknown): Promise; takeOutbound(bindingId: string): Promise; - /** Optional provider-owned run preview lane; artifact methods above remain independent. */ - readonly runtime?: McpAppRuntimeRoutePreviewService; } /** The tool call a host already made for a session, which a page may bind without re-sending it. */ @@ -123,28 +99,6 @@ export interface McpAppRoutesOptions { readonly service?: McpAppRoutePreviewService; } -/** Runtime App operation results cross the bounded host-to-opaque-App channel. */ -const runtimeOperationResponseJson = (response: ServerResponse, body: unknown): void => { - let encoded: string; - try { - const serialized = JSON.stringify(body); - if (typeof serialized !== 'string') throw new TypeError('Runtime MCP App operation response is not JSON.'); - encoded = serialized; - } catch { - throw requestError(diagnostic('AB8023', 'Runtime MCP App operation response could not be encoded.', 502)); - } - const bytes = Buffer.byteLength(encoded, 'utf8'); - if (bytes > runtimeAppMessageLimits.hostToAppBytes) { - throw requestError(diagnostic('AB8023', 'Runtime MCP App operation response exceeds its transport bound.', 413)); - } - response.writeHead(200, { - 'content-length': String(bytes), - 'content-type': 'application/json; charset=utf-8', - 'x-content-type-options': 'nosniff', - }); - response.end(encoded); -}; - const opaqueSegment = (value: string): string => { let decoded: string; try { @@ -163,27 +117,6 @@ const opaqueSegment = (value: string): string => { const route = (requestTarget: string | undefined): Route | undefined => { const pathname = rawPathname(requestTarget); - if ( - (requestTarget?.includes('?') === true || requestTarget?.includes('#') === true) && - (pathname === '/api/runtime/apps' || pathname.startsWith('/api/runtime/apps/')) - ) { - throw requestError(diagnostic('AB8020', 'MCP App route path is not valid.', 400)); - } - if (pathname === '/api/runtime/apps') return Object.freeze({ kind: 'runtime-create' }); - if (pathname.startsWith('/api/runtime/apps/')) { - const parts = pathname.split('/'); - if (parts.length < 5 || parts[0] !== '' || parts[1] !== 'api' || parts[2] !== 'runtime' || parts[3] !== 'apps') { - throw requestError(diagnostic('AB8020', 'MCP App route path is not valid.', 400)); - } - const bindingId = opaqueSegment(parts[4]!); - if (parts.length === 5) return Object.freeze({ bindingId, kind: 'runtime-get' }); - if (parts.length === 6 && parts[5] === 'operations') return Object.freeze({ bindingId, kind: 'runtime-operation' }); - if (parts.length === 6 && parts[5] === 'consents') return Object.freeze({ bindingId, kind: 'runtime-consent-create' }); - if (parts.length === 7 && parts[5] === 'consents') { - return Object.freeze({ bindingId, consentId: opaqueSegment(parts[6]!), kind: 'runtime-consent-decide' }); - } - throw requestError(diagnostic('AB8020', 'MCP App route path is not valid.', 400)); - } if (pathname !== '/api/mcp' && !pathname.startsWith('/api/mcp/')) return undefined; const parts = pathname.split('/'); if (parts[0] !== '' || parts[1] !== 'api' || parts[2] !== 'mcp') return undefined; @@ -200,8 +133,6 @@ const route = (requestTarget: string | undefined): Route | undefined => { throw requestError(diagnostic('AB8020', 'MCP App route path is not valid.', 400)); }; -const isRuntimeRoute = (value: Route): value is RuntimeRoute => value.kind.startsWith('runtime-'); - const isRecord = (value: unknown): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; @@ -242,25 +173,6 @@ const jsonBody = async (request: IncomingMessage): Promise => { return parsed; }; -const requestAbort = (request: IncomingMessage, response: ServerResponse): Readonly<{ readonly dispose: () => void; readonly signal: AbortSignal }> => { - const controller = new AbortController(); - const abort = (): void => { - if (!controller.signal.aborted) controller.abort(new Error('Runtime MCP App request was cancelled.')); - }; - const abortResponse = (): void => { - if (!response.writableEnded) abort(); - }; - request.once('aborted', abort); - response.once('close', abortResponse); - return Object.freeze({ - dispose: () => { - request.removeListener('aborted', abort); - response.removeListener('close', abortResponse); - }, - signal: controller.signal, - }); -}; - const exactRecord = (value: unknown, fields: readonly string[]): JsonObject | undefined => isRecord(value) && hasOnly(value, fields) ? value : undefined; @@ -368,41 +280,6 @@ const consentDecision = (value: JsonObject): Readonly<{ approved: boolean; chall return Object.freeze({ approved: value.approved, challengeId: value.challengeId }); }; -const runtimeCreateRequest = (value: JsonObject): CreateMcpAppPreviewRequest => { - if (!hasOnly(value, ['expectedGenerationId', 'profileId', 'runId']) || !nonemptyString(value.expectedGenerationId) || !nonemptyString(value.runId) - || (value.profileId !== 'portable' && value.profileId !== 'chatgpt' && value.profileId !== 'claude')) return invalidShape(); - return Object.freeze({ expectedGenerationId: value.expectedGenerationId, profileId: value.profileId, runId: value.runId }); -}; - -const runtimeOperation = (value: JsonObject): McpAppBindingOperation => { - if (value.kind === 'tools/list' && hasOnly(value, ['kind'])) return Object.freeze({ kind: 'tools/list' }); - if (value.kind === 'resources/list' && hasOnly(value, ['kind'])) return Object.freeze({ kind: 'resources/list' }); - if (value.kind === 'resources/read' && hasOnly(value, ['kind', 'uri']) && nonemptyString(value.uri)) { - return Object.freeze({ kind: 'resources/read', uri: value.uri }); - } - if (value.kind === 'tools/call' && hasOnly(value, ['arguments', 'consentId', 'kind', 'name']) && nonemptyString(value.name) - && (value.arguments === undefined || isJsonValue(value.arguments)) && (value.consentId === undefined || nonemptyString(value.consentId))) { - return Object.freeze({ - ...(value.arguments === undefined ? {} : { arguments: cloneJson(value.arguments) }), - ...(value.consentId === undefined ? {} : { consentId: value.consentId }), - kind: 'tools/call', name: value.name, - }); - } - return invalidShape(); -}; - -const runtimeConsentRequest = (value: JsonObject): McpAppConsentRequest => { - if (!hasOnly(value, ['actionFingerprint', 'capability', 'details', 'scope', 'summary']) || !nonemptyString(value.actionFingerprint) - || !nonemptyString(value.summary) || !isJsonValue(value.details) || (value.scope !== 'action' && value.scope !== 'document') - || !isMcpAppConsentCapability(value.capability)) return invalidShape(); - return Object.freeze({ actionFingerprint: value.actionFingerprint, capability: value.capability, details: cloneJson(value.details), scope: value.scope, summary: value.summary }); -}; - -const runtimeConsentDecision = (value: JsonObject): 'allow-once' | 'deny' => { - if (!hasOnly(value, ['decision']) || (value.decision !== 'allow-once' && value.decision !== 'deny')) return invalidShape(); - return value.decision; -}; - const previewSnapshot = (preview: McpAppRoutePreview): Readonly> => Object.freeze({ bindingId: preview.binding.id, ...(preview.frame === undefined ? {} : { frame: preview.frame }), @@ -459,9 +336,6 @@ export class McpAppRoutes { await this.#dispatch(parsed, request, response, service); } catch (error) { if (isRequestDiagnostic(error)) throw error; - if (error instanceof McpAppRuntimePreviewError) { - throw requestError(diagnostic(error.code, error.message, error.status)); - } throw requestError(diagnostic('AB8023', 'MCP App operation could not be completed.', 502)); } return true; @@ -474,7 +348,6 @@ export class McpAppRoutes { service: McpAppRoutePreviewService, ): Promise { const method = request.method ?? 'GET'; - if (isRuntimeRoute(parsed)) return this.#dispatchRuntime(parsed, request, response, service.runtime); if (parsed.kind === 'create') { if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); const preview = await service.create(createRequest(await jsonBody(request), parsed.sessionId, this.#openingCall)); @@ -564,59 +437,6 @@ export class McpAppRoutes { return writeJsonResponse(response, result); } - async #dispatchRuntime( - parsed: RuntimeCreateRoute | RuntimeBindingRoute | RuntimeConsentRoute, - request: IncomingMessage, - response: ServerResponse, - runtime: McpAppRuntimeRoutePreviewService | undefined, - ): Promise { - if (runtime === undefined) return this.#unavailable(); - const method = request.method ?? 'GET'; - if (parsed.kind === 'runtime-create') { - if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); - return writeJsonResponse(response, { preview: await runtime.create(runtimeCreateRequest(await jsonBody(request))) }); - } - if (parsed.kind === 'runtime-get') { - if (method === 'DELETE') { - if (runtime.get(parsed.bindingId) === undefined && runtime.isRevoked?.(parsed.bindingId) !== true) { - this.#runtimeUnavailable(runtime, parsed.bindingId); - } - await runtime.close(parsed.bindingId); - return writeJsonResponse(response, { closed: true }); - } - if (method !== 'GET') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); - const preview = runtime.get(parsed.bindingId); - if (preview === undefined) this.#runtimeUnavailable(runtime, parsed.bindingId); - response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8', 'x-content-type-options': 'nosniff' }); - response.end(JSON.stringify({ preview })); - return; - } - if (parsed.kind === 'runtime-close') { - if (method !== 'DELETE') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); - await runtime.close(parsed.bindingId); - return writeJsonResponse(response, { closed: true }); - } - if (parsed.kind === 'runtime-operation') { - if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); - if (runtime.get(parsed.bindingId) === undefined) this.#runtimeUnavailable(runtime, parsed.bindingId); - const cancellation = requestAbort(request, response); - try { - return runtimeOperationResponseJson(response, await runtime.operate(parsed.bindingId, runtimeOperation(await jsonBody(request)), Object.freeze({ signal: cancellation.signal }))); - } finally { - cancellation.dispose(); - } - } - if (parsed.kind === 'runtime-consent-create') { - if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); - if (runtime.get(parsed.bindingId) === undefined) this.#runtimeUnavailable(runtime, parsed.bindingId); - return writeJsonResponse(response, await runtime.createConsent(parsed.bindingId, runtimeConsentRequest(await jsonBody(request)))); - } - if (parsed.kind !== 'runtime-consent-decide') throw new Error('Runtime MCP App route is not valid.'); - if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); - if (runtime.get(parsed.bindingId) === undefined) this.#runtimeUnavailable(runtime, parsed.bindingId); - return writeJsonResponse(response, await runtime.decideConsent(parsed.bindingId, parsed.consentId, runtimeConsentDecision(await jsonBody(request)))); - } - #preview(service: McpAppRoutePreviewService, bindingId: string): McpAppRoutePreview { return service.get(bindingId) ?? this.#unavailable(); } @@ -625,13 +445,6 @@ export class McpAppRoutes { throw requestError(diagnostic('AB8022', 'MCP App preview is not available.', 404)); } - #runtimeUnavailable(runtime: McpAppRuntimeRoutePreviewService, bindingId: string): never { - if (runtime.isRevoked?.(bindingId) === true) { - throw requestError(diagnostic('AB8022', 'Runtime MCP App preview was revoked.', 410)); - } - return this.#unavailable(); - } - #clearTeardown(bindingId: string): void { const receipt = this.#teardowns.get(bindingId); if (receipt !== undefined) clearTimeout(receipt); diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox-types.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox-types.ts new file mode 100644 index 000000000..f3d07af95 --- /dev/null +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox-types.ts @@ -0,0 +1,104 @@ +import type { McpAppJsonValue } from './mcp-app-binding-service.ts'; +import type { McpAppConsentCapability } from './mcp-app-consent.ts'; + +export type McpAppSandboxCapability = Readonly>; + +export interface McpAppSandboxCsp { + readonly baseUriDomains?: readonly string[]; + readonly connectDomains?: readonly string[]; + readonly frameDomains?: readonly string[]; + readonly resourceDomains?: readonly string[]; +} + +export interface McpAppSandboxPermissions { + readonly camera?: McpAppSandboxCapability; + readonly clipboardWrite?: McpAppSandboxCapability; + readonly geolocation?: McpAppSandboxCapability; + readonly microphone?: McpAppSandboxCapability; +} + +export interface McpAppSandboxDeclaration { + readonly csp?: McpAppSandboxCsp; + readonly permissions?: McpAppSandboxPermissions; +} + +export interface McpAppSandboxConsent { + readonly permissions?: McpAppSandboxPermissions; +} + +export interface McpAppSandboxPolicy { + readonly contentSecurityPolicy: string; + readonly iframeAllow: string; + readonly internalWebSocketUrl?: string; + readonly permissionsPolicy: string; + readonly warnings: readonly McpAppSandboxWarning[]; +} + +export interface McpAppConsentGrant { + readonly authorizationId: string; + readonly bindingId: string; + readonly capability: McpAppConsentCapability; + readonly challengeId: string; + readonly scope: 'action' | 'document'; +} + +export interface McpAppConsentRequest { + /** Opaque server-produced reference only; it has no authorization value. */ + readonly actionFingerprint: string; + readonly capability: McpAppConsentCapability; + readonly details: McpAppJsonValue; + readonly scope: 'action' | 'document'; + readonly summary: string; +} + +export interface McpAppConsentChallenge { + readonly expiresAt: number; + readonly id: string; + readonly request: McpAppConsentRequest; +} + +export type McpAppConsentResolution = + | Readonly<{ readonly grant: McpAppConsentGrant; readonly status: 'approved' }> + | Readonly<{ readonly status: 'denied' | 'expired' | 'unknown' }>; + +export interface McpAppConsentAuthority { + challenge(options: Readonly<{ + readonly actionDigest: string; + readonly bindingId: string; + readonly capability: McpAppConsentCapability; + readonly details: McpAppJsonValue; + readonly profile: string; + }>): McpAppConsentChallenge | undefined; + consume(options: Readonly<{ + readonly actionDigest: string; + readonly authorizationId: string; + readonly bindingId: string; + readonly capability: McpAppConsentCapability; + readonly profile: string; + }>): boolean; + grant(challengeId: string, approved: boolean): McpAppConsentGrant | undefined; + /** Server-only lookup; expired entries are deliberately retained long enough to deny their original continuation. */ + inspect(challengeId: string): McpAppConsentChallenge | undefined; + documentGrants(bindingId: string, profile: string): readonly McpAppConsentGrant[]; + pending(): readonly McpAppConsentChallenge[]; + /** Atomically consumes a decision and distinguishes an expired exact challenge from a forged one. */ + resolve(challengeId: string, approved: boolean): McpAppConsentResolution; +} + +export interface McpAppSandboxWarning { + readonly code: 'csp-source-rejected' | 'csp-wildcard-rejected' | 'permission-not-consented'; + readonly value: string; +} + +export interface McpAppSandboxInternalSources { + readonly origin: string; + readonly provenance: 'compiler-internal'; + readonly webSocketPath: '/rsbuild-hmr'; +} + +export interface McpAppDocumentPolicySnapshot { + readonly allow: string; + readonly approvedPermissions: McpAppSandboxPermissions; + readonly revision: number; + readonly warnings: readonly McpAppSandboxWarning[]; +} diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts index e8649e101..ecea857de 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts @@ -8,6 +8,20 @@ import { isRecord } from '../../core/strict-json.ts'; import type { McpAppJsonValue } from './mcp-app-binding-service.ts'; import type { McpAppConsentCapability } from './mcp-app-consent.ts'; import { deepFreeze } from '../../core/freeze.ts'; +import type { + McpAppConsentAuthority, + McpAppConsentChallenge, + McpAppConsentGrant, + McpAppConsentResolution, + McpAppDocumentPolicySnapshot, + McpAppSandboxCapability, + McpAppSandboxConsent, + McpAppSandboxDeclaration, + McpAppSandboxInternalSources, + McpAppSandboxPermissions, + McpAppSandboxPolicy, + McpAppSandboxWarning, +} from './mcp-app-sandbox-types.ts'; const JSON_RPC_VERSION = '2.0'; @@ -113,110 +127,6 @@ const SHELL = ` } `; -export type McpAppSandboxCapability = Readonly>; - -export interface McpAppSandboxCsp { - readonly baseUriDomains?: readonly string[]; - readonly connectDomains?: readonly string[]; - readonly frameDomains?: readonly string[]; - readonly resourceDomains?: readonly string[]; -} - -export interface McpAppSandboxPermissions { - readonly camera?: McpAppSandboxCapability; - readonly clipboardWrite?: McpAppSandboxCapability; - readonly geolocation?: McpAppSandboxCapability; - readonly microphone?: McpAppSandboxCapability; -} - -export interface McpAppSandboxDeclaration { - readonly csp?: McpAppSandboxCsp; - readonly permissions?: McpAppSandboxPermissions; -} - -export interface McpAppSandboxConsent { - readonly permissions?: McpAppSandboxPermissions; -} - -export interface McpAppSandboxPolicy { - readonly contentSecurityPolicy: string; - readonly iframeAllow: string; - readonly internalWebSocketUrl?: string; - readonly permissionsPolicy: string; - readonly warnings: readonly McpAppSandboxWarning[]; -} - -export { isMcpAppConsentCapability, type McpAppConsentCapability } from './mcp-app-consent.ts'; - -export interface McpAppConsentGrant { - readonly authorizationId: string; - readonly bindingId: string; - readonly capability: McpAppConsentCapability; - readonly challengeId: string; - readonly scope: 'action' | 'document'; -} - -export interface McpAppConsentRequest { - /** Opaque server-produced reference only; it has no authorization value. */ - readonly actionFingerprint: string; - readonly capability: McpAppConsentCapability; - readonly details: McpAppJsonValue; - readonly scope: 'action' | 'document'; - readonly summary: string; -} - -export interface McpAppConsentChallenge { - readonly expiresAt: number; - readonly id: string; - readonly request: McpAppConsentRequest; -} - -export type McpAppConsentResolution = - | Readonly<{ readonly grant: McpAppConsentGrant; readonly status: 'approved' }> - | Readonly<{ readonly status: 'denied' | 'expired' | 'unknown' }>; - -export interface McpAppConsentAuthority { - challenge(options: Readonly<{ - readonly actionDigest: string; - readonly bindingId: string; - readonly capability: McpAppConsentCapability; - readonly details: McpAppJsonValue; - readonly profile: string; - }>): McpAppConsentChallenge | undefined; - consume(options: Readonly<{ - readonly actionDigest: string; - readonly authorizationId: string; - readonly bindingId: string; - readonly capability: McpAppConsentCapability; - readonly profile: string; - }>): boolean; - grant(challengeId: string, approved: boolean): McpAppConsentGrant | undefined; - /** Server-only lookup; expired entries are deliberately retained long enough to deny their original continuation. */ - inspect(challengeId: string): McpAppConsentChallenge | undefined; - documentGrants(bindingId: string, profile: string): readonly McpAppConsentGrant[]; - pending(): readonly McpAppConsentChallenge[]; - /** Atomically consumes a decision and distinguishes an expired exact challenge from a forged one. */ - resolve(challengeId: string, approved: boolean): McpAppConsentResolution; -} - -export interface McpAppSandboxWarning { - readonly code: 'csp-source-rejected' | 'csp-wildcard-rejected' | 'permission-not-consented'; - readonly value: string; -} - -export interface McpAppSandboxInternalSources { - readonly origin: string; - readonly provenance: 'compiler-internal'; - readonly webSocketPath: '/rsbuild-hmr'; -} - -export interface McpAppDocumentPolicySnapshot { - readonly allow: string; - readonly approvedPermissions: McpAppSandboxPermissions; - readonly revision: number; - readonly warnings: readonly McpAppSandboxWarning[]; -} - export interface McpAppSandboxRelay { readonly maxMessageBytes: number; readonly maxQueuedMessages: number; diff --git a/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts b/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts deleted file mode 100644 index 2b60e2c72..000000000 --- a/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts +++ /dev/null @@ -1,978 +0,0 @@ -import { randomBytes } from 'node:crypto'; -import { - Agent, - type ClientRequest, - createServer, - request as requestUpstream, - type IncomingMessage, - type Server, - type ServerResponse, -} from 'node:http'; -import type { Socket } from 'node:net'; - -import WebSocket, { WebSocketServer } from 'ws'; - -import type { - DevRuntimeClientSurfaceEndpoint, - DevRuntimeClientSurfaceProxyBinding, -} from './runtime-provider.ts'; -import { - runtimeAppFiniteOrdinaryJsonByteLength, - runtimeAppMessageLimits, -} from './runtime-app-message-limits.ts'; - -const appAssetLimit = 4 * 1024 * 1024; -const headerLimit = 16 * 1024; -/** Bound on each upstream compiler request unless `RuntimeClientSurfaceProxyOptions` overrides it. */ -export const defaultRuntimeClientSurfaceUpstreamRequestTimeoutMs = 15_000; -/** Node collapses longer `setTimeout` delays to 1 ms, which would silently drop the bound. */ -const maximumTimerDelayMs = 2_147_483_647; -const loopbackHosts = new Set(['127.0.0.1', '::1']); -/** - * Proxy-owned browser push channel. The proxy authors both ends: its server - * broadcasts only the owned reload frame below, and the bootstrap shell it - * serves is the only intended client. Rsbuild's WebSocket envelope is never - * dialed, parsed, or forwarded here, so Rsbuild upgrades cannot silently - * change Runtime App reload behavior. - */ -export const runtimeClientSurfaceReloadChannelPath = '/__agent_bundle_runtime/reload'; -const reloadMessageKind = 'runtime-app-reload'; -const reloadMessageLimit = 256; -const endpointKeys = Object.freeze([ - 'entryPath', - 'httpOrigin', - 'httpPathPrefixes', - 'subscribeReload', - 'surfaceId', -] as const); -const contentPolicyKeys = Object.freeze(['contentSecurityPolicy'] as const); - -/** Trusted server-only policy for the one opaque child installed by this proxy. */ -export interface RuntimeClientSurfaceContentPolicy { - readonly contentSecurityPolicy: string; -} - -/** Direct fixture access receives the same strict no-network child policy. */ -export const strictRuntimeClientSurfaceContentPolicy: RuntimeClientSurfaceContentPolicy = Object.freeze({ - contentSecurityPolicy: "default-src 'none'; base-uri 'self'; connect-src 'none'; frame-src 'none'; img-src data:; media-src 'none'; font-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'", -}); - -export interface RuntimeClientSurfaceConnectionEvent { - readonly connectionCount: number; - readonly surfaceId: string; - readonly type: 'connected' | 'disconnected'; -} - -/** Server-only tuning for `RuntimeClientSurfaceProxy.open`; production callers take the defaults. */ -export interface RuntimeClientSurfaceProxyOptions { - /** - * Bound on each upstream compiler request — the bootstrap entry fetch and - * every proxied asset — from dispatch until its body has been read. A - * request still open at the deadline is aborted and answered 502. - */ - readonly upstreamRequestTimeoutMs?: number; -} - -interface ValidatedEndpoint { - readonly entryPath: string; - readonly host: string; - readonly httpOrigin: URL; - readonly httpPathPrefixes: readonly string[]; - readonly subscribeReload: (listener: () => void) => () => void; - readonly surfaceId: string; -} - -const invalidEndpoint = (message: string): never => { - throw new TypeError(`Runtime client surface endpoint must use ${message}.`); -}; - -const invalidContentPolicy = (message: string): never => { - throw new TypeError(`Runtime client surface content policy must use ${message}.`); -}; - -const endpointValue = ( - input: DevRuntimeClientSurfaceEndpoint, - key: Key, -): DevRuntimeClientSurfaceEndpoint[Key] => { - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if (descriptor === undefined || !Object.hasOwn(descriptor, 'value')) invalidEndpoint(`an own data ${key} field`); - return descriptor!.value as DevRuntimeClientSurfaceEndpoint[Key]; -}; - -const closedEndpoint = (input: DevRuntimeClientSurfaceEndpoint): void => { - if (typeof input !== 'object' || input === null || Array.isArray(input)) invalidEndpoint('a plain endpoint record'); - const keys = Reflect.ownKeys(input); - if ( - keys.length !== endpointKeys.length || keys.some((key) => typeof key !== 'string' || !endpointKeys.includes(key as (typeof endpointKeys)[number])) - ) { - invalidEndpoint('exactly the declared endpoint fields'); - } -}; - -const contentSecurityPolicy = (input: RuntimeClientSurfaceContentPolicy): string => { - if (typeof input !== 'object' || input === null || Array.isArray(input)) invalidContentPolicy('a plain policy record'); - const prototype = (() => { - try { - return Object.getPrototypeOf(input); - } catch { - return invalidContentPolicy('a plain policy record'); - } - })(); - if (prototype !== Object.prototype && prototype !== null) invalidContentPolicy('a plain policy record'); - const keys = (() => { - try { - return Reflect.ownKeys(input); - } catch { - return invalidContentPolicy('an inspectable policy record'); - } - })(); - if (keys.length !== contentPolicyKeys.length || keys.some((key) => typeof key !== 'string' || !contentPolicyKeys.includes(key as typeof contentPolicyKeys[number]))) { - invalidContentPolicy('exactly the declared policy field'); - } - const descriptor: PropertyDescriptor = (() => { - try { - const current = Object.getOwnPropertyDescriptor(input, 'contentSecurityPolicy'); - if (current === undefined || !Object.hasOwn(current, 'value')) { - return invalidContentPolicy('an own data contentSecurityPolicy field'); - } - return current; - } catch { - return invalidContentPolicy('an own data contentSecurityPolicy field'); - } - })(); - const value = descriptor.value; - if (typeof value !== 'string' || value.length === 0 || value.length > 16_384 || value.includes('\0')) { - invalidContentPolicy('a bounded nonempty contentSecurityPolicy string'); - } - return value; -}; - -const upstreamRequestTimeoutMs = (options: RuntimeClientSurfaceProxyOptions): number => { - const timeout = options.upstreamRequestTimeoutMs ?? defaultRuntimeClientSurfaceUpstreamRequestTimeoutMs; - if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > maximumTimerDelayMs) { - throw new TypeError(`Runtime client surface proxy options must use an integer upstreamRequestTimeoutMs from 1 to ${String(maximumTimerDelayMs)}.`); - } - return timeout; -}; - -const response = (target: ServerResponse, status: number): void => { - if (target.destroyed || target.writableEnded) return; - target.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'x-content-type-options': 'nosniff' }); - target.end(status === 403 ? 'Forbidden' : status === 413 ? 'Payload Too Large' : 'Not Found'); -}; - -const runtimeProxyContentSecurityPolicy = (hostOrigin: string): string => [ - "default-src 'none'", - "base-uri 'none'", - "connect-src 'self'", - `frame-ancestors ${hostOrigin}`, - "form-action 'none'", - "frame-src 'self'", - "img-src 'self' data:", - "script-src 'unsafe-inline'", - "style-src 'unsafe-inline'", -].join('; '); - -const escapedScriptValue = (value: string): string => JSON.stringify(value).replace(/[<>&\u2028\u2029]/gu, (character) => ({ - '<': '\\u003c', - '>': '\\u003e', - '&': '\\u0026', - '\u2028': '\\u2028', - '\u2029': '\\u2029', -})[character] as string); - -const escapedHtmlAttribute = (value: string): string => value.replace(/[&<>"']/gu, (character) => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', -})[character] as string); - -/** - * The same-origin outer document is the sole relay. The compiler App is never - * granted that origin: it runs in this document's one opaque nested iframe. - */ -const runtimeProxyShell = ( - entryPath: string, - entryDocument: string, - hostOrigin: string, - childContentSecurityPolicy: string, - initialReloadGeneration: number, -): string => ` - - -Runtime App surface - - -`; - -const rawHeaderBytes = (request: IncomingMessage): number => request.rawHeaders.reduce( - (size, value) => size + Buffer.byteLength(value), - 0, -); - -const hasBody = (request: IncomingMessage): boolean => { - const length = request.headers['content-length']; - if (typeof length === 'string' && length !== '0') return true; - return request.headers['transfer-encoding'] !== undefined; -}; - -interface CanonicalPath { - readonly normalized: string; - readonly upstream: string; -} - -const canonicalPath = (path: string, allowRoot = false): CanonicalPath => { - if (!path.startsWith('/')) invalidEndpoint('an absolute entry path'); - if (path === '/') { - if (allowRoot) return Object.freeze({ normalized: '/', upstream: '/' }); - invalidEndpoint('a non-root entry path'); - } - const segments = path.slice(1).split('/').map((segment) => { - let decoded: string; - try { - decoded = decodeURIComponent(segment); - } catch { - return invalidEndpoint('a percent-decodable path'); - } - if ( - decoded.length === 0 || decoded === '.' || decoded === '..' || - decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0') || - decoded.includes('?') || decoded.includes('#') || decoded.includes('%') - ) invalidEndpoint('a containment-safe path'); - return decoded; - }); - return Object.freeze({ - normalized: `/${segments.join('/')}`, - upstream: `/${segments.map((segment) => encodeURIComponent(segment)).join('/')}`, - }); -}; - -const prefix = (value: string): string => { - const path = canonicalPath(value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value, true).normalized; - return path === '/' ? '/' : `${path}/`; -}; - -const matchesPrefix = (path: string, prefixes: readonly string[]): boolean => prefixes.some((candidate) => - candidate === '/' || path === candidate.slice(0, -1) || path.startsWith(candidate)); - -const literalHost = (value: URL): string => value.hostname.startsWith('[') && value.hostname.endsWith(']') - ? value.hostname.slice(1, -1) - : value.hostname; - -const origin = (value: string): URL => { - let parsed: URL; - try { - parsed = new URL(value); - } catch { - return invalidEndpoint('a literal loopback HTTP origin'); - } - if ( - parsed.protocol !== 'http:' || !loopbackHosts.has(literalHost(parsed)) || - parsed.username.length > 0 || parsed.password.length > 0 || parsed.pathname !== '/' || - parsed.search.length > 0 || parsed.hash.length > 0 - ) { - return invalidEndpoint('a literal loopback HTTP origin'); - } - return parsed; -}; - -const canonicalHostOrigin = (value: string): string => { - let parsed: URL; - try { - parsed = new URL(value); - } catch { - return invalidEndpoint('a canonical foreground HTTP origin'); - } - if ( - (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.origin !== value || - parsed.username.length > 0 || parsed.password.length > 0 || parsed.pathname !== '/' || - parsed.search.length > 0 || parsed.hash.length > 0 - ) { - return invalidEndpoint('a canonical foreground HTTP origin'); - } - return parsed.origin; -}; - -const selfContainedEntry = (body: Uint8Array, contentType: string | undefined): string => { - if (contentType !== undefined && !/^text\/html(?:;|$)/iu.test(contentType)) { - throw new TypeError('Runtime client entry must be self-contained HTML.'); - } - const entry = new TextDecoder('utf-8', { fatal: true }).decode(body); - // The App compiler explicitly emits inline scripts and styles. A srcdoc - // child has an opaque origin, so allowing a linked executable resource here - // would silently change that compiler contract into a broken surface. - if (/<(?:base|script)\b[^>]*(?:\bhref|\bsrc)\s*=/iu.test(entry) || /]*\bhref\s*=/iu.test(entry)) { - throw new TypeError('Runtime client entry must not require external executable resources.'); - } - return entry; -}; - -const endpoint = (input: DevRuntimeClientSurfaceEndpoint): ValidatedEndpoint => { - closedEndpoint(input); - const surfaceId = endpointValue(input, 'surfaceId'); - if (typeof surfaceId !== 'string' || surfaceId.length === 0 || surfaceId.includes('\0')) { - invalidEndpoint('a nonempty surface id'); - } - const httpOrigin = origin(endpointValue(input, 'httpOrigin')); - const host = literalHost(httpOrigin); - const declaredPrefixes = endpointValue(input, 'httpPathPrefixes'); - if (!Array.isArray(declaredPrefixes) || declaredPrefixes.length === 0) { - invalidEndpoint('at least one declared HTTP path prefix'); - } - const httpPathPrefixes = Object.freeze([...new Set(declaredPrefixes.map(prefix))]); - const entryPath = canonicalPath(endpointValue(input, 'entryPath')).normalized; - if (!matchesPrefix(entryPath, httpPathPrefixes)) invalidEndpoint('an entry path within a declared HTTP prefix'); - const subscribeReload = endpointValue(input, 'subscribeReload'); - if (typeof subscribeReload !== 'function') invalidEndpoint('a provider-owned subscribeReload function'); - return Object.freeze({ - entryPath, - host, - httpOrigin, - httpPathPrefixes, - subscribeReload: subscribeReload as ValidatedEndpoint['subscribeReload'], - surfaceId, - }); -}; - -const cookieValue = (request: IncomingMessage, name: string): string | undefined => { - const header = request.headers.cookie; - if (header === undefined) return undefined; - for (const value of header.split(';')) { - const [key, ...rest] = value.trim().split('='); - if (key === name) return rest.join('='); - } - return undefined; -}; - -const copyResponseHeaders = (headers: IncomingMessage['headers']): Record => { - const copied: Record = {}; - const allowed = new Set(['cache-control', 'content-encoding', 'content-language', 'content-type', 'etag', 'last-modified', 'vary']); - for (const [name, value] of Object.entries(headers)) { - if (value !== undefined && allowed.has(name.toLowerCase())) copied[name] = value; - } - return copied; -}; - -const responseChunks = async (source: IncomingMessage): Promise => new Promise((resolvePromise, rejectPromise) => { - const declared = source.headers['content-length']; - if (typeof declared === 'string' && (!/^\d+$/u.test(declared) || Number(declared) > appAssetLimit)) { - source.destroy(); - rejectPromise(new RangeError('Runtime client asset exceeds the allowed size.')); - return; - } - let bytes = 0; - let settled = false; - const chunks: Buffer[] = []; - const finish = (callback: () => void): void => { - if (settled) return; - settled = true; - clearTimeout(timeout); - callback(); - }; - const fail = (error: Error): void => finish(() => { - source.destroy(); - rejectPromise(error); - }); - const timeout = setTimeout(() => fail(new Error('Runtime client asset timed out.')), 15_000); - source.on('data', (chunk: Buffer) => { - bytes += chunk.length; - if (bytes > appAssetLimit) { - fail(new RangeError('Runtime client asset exceeds the allowed size.')); - return; - } - chunks.push(chunk); - }); - source.once('end', () => finish(() => resolvePromise(Buffer.concat(chunks)))); - source.once('error', (error) => fail(error)); - source.once('aborted', () => fail(new Error('Runtime client asset was aborted.'))); - source.once('close', () => { - if (!source.complete) fail(new Error('Runtime client asset closed early.')); - }); -}); - -const sameOriginRedirect = (location: string, upstream: URL): string | undefined => { - let redirect: URL; - try { - redirect = new URL(location, upstream); - } catch { - return undefined; - } - if (redirect.origin !== upstream.origin) return undefined; - try { - return `${canonicalPath(redirect.pathname, true).upstream}${redirect.search}`; - } catch { - return undefined; - } -}; - -const closeServer = (server: Server): Promise => new Promise((resolvePromise, rejectPromise) => { - server.close((error) => error === undefined || (error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING' - ? resolvePromise() - : rejectPromise(error)); -}); - -/** - * Server-only proxy construction for a trusted runtime compiler endpoint. Its - * browser binding has no upstream selection, middleware, or arbitrary path API. - */ -export class RuntimeClientSurfaceProxy { - static async open( - input: DevRuntimeClientSurfaceEndpoint, - listener: (event: RuntimeClientSurfaceConnectionEvent) => void, - hostOrigin: string, - policy: RuntimeClientSurfaceContentPolicy = strictRuntimeClientSurfaceContentPolicy, - options: RuntimeClientSurfaceProxyOptions = {}, - ): Promise { - const trusted = endpoint(input); - const trustedHostOrigin = canonicalHostOrigin(hostOrigin); - const trustedContentSecurityPolicy = contentSecurityPolicy(policy); - const trustedUpstreamRequestTimeoutMs = upstreamRequestTimeoutMs(options); - const bootstrapCapability = randomBytes(32).toString('base64url'); - const sessionCapability = randomBytes(32).toString('base64url'); - const bootstrapPath = `/__agent_bundle_runtime/bootstrap/${bootstrapCapability}`; - const cookieName = `__Host-agent_bundle_runtime_${randomBytes(16).toString('hex')}`; - const sockets = new Set(); - const upstreamAgent = new Agent({ keepAlive: true }); - const upstreamAborts = new Set<() => void>(); - const upstreamRequests = new Set(); - const upstreamSockets = new Set(); - const reloadClients = new Set(); - const webSocketServer = new WebSocketServer({ maxPayload: reloadMessageLimit, noServer: true }); - let activeConnections = 0; - let bootstrapUsed = false; - let closed = false; - let closePromise: Promise | undefined; - let reloadGeneration = 0; - let reloadSubscription: (() => void) | undefined; - - const emit = (type: RuntimeClientSurfaceConnectionEvent['type']): void => { - try { - listener(Object.freeze({ connectionCount: activeConnections, surfaceId: trusted.surfaceId, type })); - } catch { - // Browser HMR availability must not depend on an observability listener. - } - }; - - const sendReloadFrame = (client: WebSocket): void => { - if (client.readyState !== WebSocket.OPEN) return; - try { - client.send(JSON.stringify({ generation: reloadGeneration, kind: reloadMessageKind })); - } catch { - client.terminate(); - } - }; - - const announceReload = (): void => { - if (closed) return; - reloadGeneration += 1; - for (const client of [...reloadClients]) sendReloadFrame(client); - }; - - const isAuthenticated = (request: IncomingMessage): boolean => - cookieValue(request, cookieName) === sessionCapability; - - const readCurrentEntry = (): Promise => new Promise((resolvePromise, rejectPromise) => { - let released = false; - let receivedResponse = false; - const release = (): void => { - if (released) return; - released = true; - clearTimeout(deadline); - upstreamRequests.delete(upstreamRequest); - upstreamAborts.delete(abort); - }; - const abort = (): void => { - release(); - if (!upstreamRequest.destroyed) upstreamRequest.destroy(); - }; - const deadline = setTimeout(() => { - abort(); - rejectPromise(new Error('Runtime client entry timed out.')); - }, trustedUpstreamRequestTimeoutMs); - const upstreamRequest = requestUpstream({ - agent: upstreamAgent, - headers: { accept: 'text/html' }, - host: trusted.host, - method: 'GET', - path: trusted.entryPath, - port: trusted.httpOrigin.port, - protocol: trusted.httpOrigin.protocol, - }, async (upstream) => { - receivedResponse = true; - try { - if ((upstream.statusCode ?? 502) !== 200) throw new Error('Runtime client entry was not available.'); - resolvePromise(selfContainedEntry(await responseChunks(upstream), upstream.headers['content-type'])); - } catch (error) { - if (!upstream.destroyed) upstream.destroy(); - rejectPromise(error); - } finally { - release(); - } - }); - upstreamRequests.add(upstreamRequest); - upstreamAborts.add(abort); - upstreamRequest.once('socket', (socket) => { - upstreamSockets.add(socket); - socket.once('close', () => upstreamSockets.delete(socket)); - }); - upstreamRequest.once('error', (error) => { - release(); - if (!receivedResponse) rejectPromise(error); - }); - upstreamRequest.end(); - }); - - const server = createServer((request, target) => { - void (async () => { - const requestUrl = new URL(request.url ?? '/', 'http://proxy.invalid'); - if (rawHeaderBytes(request) > headerLimit || hasBody(request)) { - request.resume(); - response(target, 413); - return; - } - if (requestUrl.pathname === bootstrapPath) { - if (request.method !== 'GET' || requestUrl.search.length > 0 || bootstrapUsed || closed) { - response(target, 403); - return; - } - bootstrapUsed = true; - // Capture the reload generation before fetching the entry: a reload - // that lands during the fetch then reads as newer than this shell's - // baseline, so the channel refreshes it instead of losing it. - const bootstrapReloadGeneration = reloadGeneration; - let entryDocument: string; - try { - entryDocument = await readCurrentEntry(); - } catch { - response(target, 502); - return; - } - const headers: Record = { - 'cache-control': 'no-store', - 'content-security-policy': runtimeProxyContentSecurityPolicy(trustedHostOrigin), - 'content-type': 'text/html; charset=utf-8', - 'x-content-type-options': 'nosniff', - }; - headers['set-cookie'] = `${cookieName}=${sessionCapability}; HttpOnly; SameSite=None; Secure; Partitioned; Path=/`; - target.writeHead(200, headers); - target.end(runtimeProxyShell( - trusted.entryPath, - entryDocument, - trustedHostOrigin, - trustedContentSecurityPolicy, - bootstrapReloadGeneration, - )); - return; - } - if (!isAuthenticated(request)) { - response(target, 403); - return; - } - if (closed || (request.method !== 'GET' && request.method !== 'HEAD')) { - response(target, closed ? 404 : 405); - return; - } - let path: CanonicalPath; - try { - path = canonicalPath(requestUrl.pathname, true); - } catch { - response(target, 404); - return; - } - if (!matchesPrefix(path.normalized, trusted.httpPathPrefixes)) { - response(target, 404); - return; - } - let released = false; - let receivedResponse = false; - let timedOut = false; - const release = (): void => { - if (released) return; - released = true; - clearTimeout(deadline); - upstreamRequests.delete(upstreamRequest); - upstreamAborts.delete(abort); - target.off('close', abort); - request.off('aborted', abort); - request.off('error', abort); - }; - const abort = (): void => { - release(); - if (!upstreamRequest.destroyed) upstreamRequest.destroy(); - }; - const deadline = setTimeout(() => { - timedOut = true; - response(target, 502); - abort(); - }, trustedUpstreamRequestTimeoutMs); - const upstreamRequest = requestUpstream({ - agent: upstreamAgent, - headers: { - ...(typeof request.headers.accept === 'string' ? { accept: request.headers.accept } : {}), - ...(typeof request.headers['accept-encoding'] === 'string' ? { 'accept-encoding': request.headers['accept-encoding'] } : {}), - }, - host: trusted.host, - method: request.method, - path: `${path.upstream}${requestUrl.search}`, - port: trusted.httpOrigin.port, - protocol: trusted.httpOrigin.protocol, - }, async (upstream) => { - receivedResponse = true; - const destroyResponse = (): void => { - if (!upstream.destroyed) upstream.destroy(); - if (!upstreamRequest.destroyed) upstreamRequest.destroy(); - }; - const status = upstream.statusCode ?? 502; - const headers = copyResponseHeaders(upstream.headers); - if (status >= 300 && status < 400 && typeof upstream.headers.location === 'string') { - const location = sameOriginRedirect(upstream.headers.location, trusted.httpOrigin); - destroyResponse(); - release(); - if (location === undefined) { - response(target, 502); - return; - } - target.writeHead(status, { location, 'x-content-type-options': 'nosniff' }); - target.end(); - return; - } - try { - const body = request.method === 'HEAD' - ? (destroyResponse(), new Uint8Array()) - : await responseChunks(upstream); - if (target.destroyed || target.writableEnded) return; - target.writeHead(status, { - ...headers, - 'content-length': String(body.byteLength), - 'x-content-type-options': 'nosniff', - }); - target.end(request.method === 'HEAD' ? undefined : body); - } catch { - if (target.destroyed || target.writableEnded) return; - destroyResponse(); - if (!target.headersSent) response(target, 413); - else target.destroy(); - } finally { - release(); - } - }); - upstreamRequests.add(upstreamRequest); - upstreamAborts.add(abort); - upstreamRequest.once('socket', (socket) => { - upstreamSockets.add(socket); - socket.once('close', () => upstreamSockets.delete(socket)); - }); - upstreamRequest.once('error', () => { - release(); - if (timedOut || receivedResponse || target.destroyed || target.writableEnded) return; - if (!target.headersSent) response(target, 502); - else target.destroy(); - }); - target.once('close', abort); - request.once('aborted', abort); - request.once('error', abort); - upstreamRequest.end(); - })().catch(() => response(target, 502)); - }); - - server.on('connection', (socket: Socket) => { - sockets.add(socket); - socket.once('close', () => sockets.delete(socket)); - }); - - server.on('upgrade', (request, socket, head) => { - const reject = (status: 403 | 404 | 413): void => { - socket.write(`HTTP/1.1 ${status} ${status === 403 ? 'Forbidden' : status === 413 ? 'Payload Too Large' : 'Not Found'}\r\nConnection: close\r\n\r\n`); - socket.destroy(); - }; - if (closed || rawHeaderBytes(request) > headerLimit) return reject(closed ? 404 : 413); - let requestUrl: URL; - try { - requestUrl = new URL(request.url ?? '/', 'http://proxy.invalid'); - } catch { - return reject(404); - } - if (!isAuthenticated(request)) return reject(403); - if (requestUrl.pathname !== runtimeClientSurfaceReloadChannelPath) return reject(404); - if (requestUrl.search.length > 0) return reject(404); - if (request.headers.origin !== proxyOrigin) return reject(403); - // The owned channel has no subprotocols; a client negotiating one is - // not the relay shell this proxy installed. - if (request.headers['sec-websocket-protocol'] !== undefined) return reject(403); - webSocketServer.handleUpgrade(request, socket, head, (client) => { - if (closed) { - client.terminate(); - return; - } - reloadClients.add(client); - activeConnections += 1; - emit('connected'); - let released = false; - const release = (): void => { - if (released) return; - released = true; - reloadClients.delete(client); - activeConnections -= 1; - emit('disconnected'); - if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) client.terminate(); - }; - // The channel is strictly proxy-to-relay; an inbound frame means the - // peer is not the installed shell, so release it instead of buffering. - client.on('message', release); - client.once('close', release); - client.once('error', release); - // Replay the current generation so a shell that reconnects after a - // missed reload refreshes instead of silently staying stale. - sendReloadFrame(client); - }); - }); - - await new Promise((resolvePromise, rejectPromise) => { - const fail = (error: Error) => { - server.off('listening', succeed); - rejectPromise(error); - }; - const succeed = () => { - server.off('error', fail); - resolvePromise(); - }; - server.once('error', fail); - server.once('listening', succeed); - server.listen({ host: '127.0.0.1', port: 0 }); - }); - const address = server.address(); - if (address === null || typeof address === 'string') { - await closeServer(server); - throw new Error('Runtime client proxy did not report a TCP address.'); - } - const proxyOrigin = `http://127.0.0.1:${address.port}`; - const close = async (): Promise => { - if (closePromise !== undefined) return closePromise; - closed = true; - closePromise = (async () => { - try { - reloadSubscription?.(); - } catch { - // Provider-side unsubscribe failures must not block browser release. - } - reloadSubscription = undefined; - for (const abort of [...upstreamAborts]) abort(); - for (const request of upstreamRequests) request.destroy(); - upstreamAgent.destroy(); - for (const socket of upstreamSockets) socket.destroy(); - for (const socket of reloadClients) socket.terminate(); - for (const socket of sockets) socket.destroy(); - webSocketServer.close(); - await closeServer(server); - })(); - return closePromise; - }; - try { - const subscription = trusted.subscribeReload(announceReload); - if (typeof subscription !== 'function') { - throw new TypeError('Runtime client surface endpoint must return a reload unsubscriber.'); - } - reloadSubscription = subscription; - } catch (error) { - await close(); - throw error; - } - return Object.freeze({ - bootstrapUrl: `${proxyOrigin}${bootstrapPath}`, - close, - origin: proxyOrigin, - surfaceId: trusted.surfaceId, - }); - } -} diff --git a/packages/agent-bundle/src/dev/runtime-controller.ts b/packages/agent-bundle/src/dev/runtime-controller.ts index 25f0a9728..25d4241e1 100644 --- a/packages/agent-bundle/src/dev/runtime-controller.ts +++ b/packages/agent-bundle/src/dev/runtime-controller.ts @@ -427,11 +427,6 @@ export class DevRuntimeController implements DevRuntimeSession { return this.#enqueueReconcile(prepared); } - /** Core-owned observability bridge for fixed client-surface proxy events. */ - emit(event: DevRuntimeEventInput): void { - this.#publish(event); - } - replay(request: DevRuntimeReplayRequest): Promise { return this.#activeSession().replay(request); } diff --git a/packages/agent-bundle/src/dev/runtime-mcp-routes.ts b/packages/agent-bundle/src/dev/runtime-mcp-routes.ts deleted file mode 100644 index dff8203dd..000000000 --- a/packages/agent-bundle/src/dev/runtime-mcp-routes.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { Buffer } from 'node:buffer'; -import type { IncomingMessage, ServerResponse } from 'node:http'; - -import { hasOnlyOwnKeys, isPlainRecord } from '../core/strict-json.ts'; - -import type { DevRuntimeSession } from './runtime-provider.ts'; -import type { - DevRuntimeMcpAppRunBinding, - DevRuntimeMcpConnectionState, - DevRuntimeMcpOperationRequest, - DevRuntimeMcpRegistryReconcileResult, - DevRuntimeMcpSessionControlRequest, - DevRuntimeMcpSessionRequest, - DevRuntimeMcpSessionSnapshot, -} from './runtime-protocol.ts'; - -const bodyLimit = 64 * 1024; - -interface RequestDiagnostic { readonly code: string; readonly message: string; readonly status: number; } -type Route = - | Readonly<{ readonly kind: 'open' }> - | Readonly<{ readonly kind: 'restart' | 'close' | 'rpc'; readonly sessionId: string }>; - -export interface RuntimeMcpRoutesOptions { - readonly authorize: (request: IncomingMessage) => void; - /** Drains provider-owned App invalidations after a manual registry mutation. */ - readonly awaitRegistryMutation?: () => Promise; - /** Waits for a matching run-bound App logical revoke and cleanup attempt after session close. */ - readonly awaitSessionClose?: (request: DevRuntimeMcpSessionControlRequest) => Promise; - readonly runtime?: DevRuntimeSession; -} - -const diagnostic = (code: string, message: string, status: number): RequestDiagnostic => ({ code, message, status }); -const requestError = (value: RequestDiagnostic): RequestDiagnostic & Error => Object.assign(new Error(value.message), value); -const isRequestDiagnostic = (value: unknown): value is RequestDiagnostic => typeof value === 'object' && value !== null && typeof (value as Partial).status === 'number'; -const responseJson = (response: ServerResponse, body: unknown): void => { response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); response.end(JSON.stringify(body)); }; -const responseDiagnostic = (response: ServerResponse, value: RequestDiagnostic): void => { response.writeHead(value.status, { 'content-type': 'application/json; charset=utf-8' }); response.end(JSON.stringify({ diagnostic: { code: value.code, message: value.message } })); }; -const isRecord = isPlainRecord; -const nonempty = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 4_096 && !value.includes('\0'); -const positive = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0; -const hasOnly: (value: Record, fields: readonly string[]) => boolean = hasOnlyOwnKeys; - -const readBody = async (request: IncomingMessage): Promise> => { - const contentType = request.headers['content-type']; - if (contentType !== 'application/json' && contentType !== 'application/json; charset=utf-8') throw requestError(diagnostic('AB8009', 'Request body must use application/json.', 415)); - const chunks: Buffer[] = []; - let bytes = 0; - await new Promise((resolvePromise, rejectPromise) => { - request.on('data', (chunk: Buffer) => { - bytes += chunk.length; - if (bytes > bodyLimit) rejectPromise(requestError(diagnostic('AB8010', 'Request body exceeds 64 KiB.', 413))); - else chunks.push(chunk); - }); - request.once('end', resolvePromise); - request.once('error', rejectPromise); - }); - try { - const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')); - if (!isRecord(parsed)) throw new Error('shape'); - return parsed; - } catch (error) { - if (isRequestDiagnostic(error)) throw error; - throw requestError(diagnostic('AB8203', 'Runtime request has an invalid shape.', 400)); - } -}; - -const opaque = (value: string): string => { - try { - const decoded = decodeURIComponent(value); - if (!nonempty(decoded) || decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) throw new Error('path'); - return decoded; - } catch { - throw requestError(diagnostic('AB8202', 'Runtime route path is not valid.', 400)); - } -}; - -const route = (target: string | undefined): Route | undefined => { - const pathname = target?.split(/[?#]/u, 1)[0] ?? ''; - if ( - (target?.includes('?') === true || target?.includes('#') === true) && - (pathname === '/api/runtime/mcp/sessions' || pathname.startsWith('/api/runtime/mcp/sessions/')) - ) { - throw requestError(diagnostic('AB8202', 'Runtime route path is not valid.', 400)); - } - if (pathname === '/api/runtime/mcp/sessions') return Object.freeze({ kind: 'open' }); - if (!pathname.startsWith('/api/runtime/mcp/sessions/')) return undefined; - const parts = pathname.split('/'); - if (parts.length < 6) throw requestError(diagnostic('AB8202', 'Runtime route path is not valid.', 400)); - const sessionId = opaque(parts[5]!); - if (parts.length === 6) return Object.freeze({ kind: 'close', sessionId }); - if (parts.length === 7 && parts[6] === 'restart') return Object.freeze({ kind: 'restart', sessionId }); - if (parts.length === 7 && parts[6] === 'rpc') return Object.freeze({ kind: 'rpc', sessionId }); - throw requestError(diagnostic('AB8202', 'Runtime route path is not valid.', 400)); -}; - -const openRequest = (body: Record): DevRuntimeMcpSessionRequest => { - if (!hasOnly(body, ['expectedRegistryRevision', 'serverName', 'target']) || !nonempty(body.serverName) || !nonempty(body.target) || (body.expectedRegistryRevision !== undefined && !positive(body.expectedRegistryRevision))) { - throw requestError(diagnostic('AB8203', 'Runtime request has an invalid shape.', 400)); - } - return Object.freeze({ ...(body.expectedRegistryRevision === undefined ? {} : { expectedRegistryRevision: body.expectedRegistryRevision }), serverName: body.serverName, target: body.target }); -}; - -const controlRequest = (body: Record, sessionId: string): DevRuntimeMcpSessionControlRequest => { - if (!hasOnly(body, ['expectedSessionRevision', 'sessionId']) || body.sessionId !== sessionId || !positive(body.expectedSessionRevision)) { - throw requestError(diagnostic('AB8203', 'Runtime request has an invalid shape.', 400)); - } - return Object.freeze({ expectedSessionRevision: body.expectedSessionRevision, sessionId }); -}; - -const json = (value: unknown): value is null | boolean | number | string | readonly unknown[] | Readonly> => - value === null || typeof value === 'boolean' || typeof value === 'string' || typeof value === 'number' && Number.isFinite(value) - || Array.isArray(value) && value.every(json) || isRecord(value) && Object.values(value).every(json); - -interface PublicRuntimeMcpSessionSnapshot { - readonly binding: DevRuntimeMcpAppRunBinding; - readonly connection: DevRuntimeMcpConnectionState; - readonly state: DevRuntimeMcpSessionSnapshot['state']; -} - -const publicSessionSnapshot = (snapshot: DevRuntimeMcpSessionSnapshot): PublicRuntimeMcpSessionSnapshot => { - const server = snapshot.connection.server; - return Object.freeze({ - binding: Object.freeze({ - definitionDigest: snapshot.binding.definitionDigest, - registryRevision: snapshot.binding.registryRevision, - serverDigest: snapshot.binding.serverDigest, - serverName: snapshot.binding.serverName, - sessionId: snapshot.binding.sessionId, - sessionRevision: snapshot.binding.sessionRevision, - target: snapshot.binding.target, - transportDigest: snapshot.binding.transportDigest, - }), - connection: Object.freeze({ - capabilities: snapshot.connection.capabilities, - protocolEra: snapshot.connection.protocolEra, - protocolVersion: snapshot.connection.protocolVersion, - server: server === undefined ? undefined : Object.freeze({ name: server.name, version: server.version }), - }), - state: snapshot.state, - }); -}; - -const restartSnapshot = ( - snapshot: DevRuntimeMcpSessionSnapshot, - request: DevRuntimeMcpSessionControlRequest, - reconcile: DevRuntimeMcpRegistryReconcileResult, -): PublicRuntimeMcpSessionSnapshot => { - if ( - reconcile.action !== 'sessions-restarted' || !reconcile.restartedSessionIds.includes(request.sessionId) || - !reconcile.invalidatedBindings.some((binding) => binding.sessionId === request.sessionId && binding.sessionRevision === request.expectedSessionRevision) || - snapshot.state !== 'ready' || snapshot.binding.sessionId !== request.sessionId || - snapshot.binding.sessionRevision !== request.expectedSessionRevision + 1 || snapshot.binding.registryRevision !== reconcile.registryRevision - ) throw requestError(diagnostic('AB8205', 'Runtime MCP session restart did not produce a valid replacement session.', 500)); - return publicSessionSnapshot(snapshot); -}; - -const rpcRequest = (body: Record, sessionId: string): DevRuntimeMcpOperationRequest => { - if (!hasOnly(body, ['arguments', 'expectedSessionRevision', 'kind', 'name', 'uri']) || !positive(body.expectedSessionRevision)) { - throw requestError(diagnostic('AB8203', 'Runtime request has an invalid shape.', 400)); - } - if (body.kind === 'list-tools' || body.kind === 'list-resources') { - if (!hasOnly(body, ['expectedSessionRevision', 'kind'])) throw requestError(diagnostic('AB8203', 'Runtime request has an invalid shape.', 400)); - return Object.freeze({ expectedSessionRevision: body.expectedSessionRevision, kind: body.kind }); - } - if (body.kind === 'read-resource' && hasOnly(body, ['expectedSessionRevision', 'kind', 'uri']) && nonempty(body.uri)) { - return Object.freeze({ expectedSessionRevision: body.expectedSessionRevision, kind: 'read-resource', uri: body.uri }); - } - if (body.kind === 'call-tool' && hasOnly(body, ['arguments', 'expectedSessionRevision', 'kind', 'name']) && nonempty(body.name) && isRecord(body.arguments) && json(body.arguments)) { - return Object.freeze({ arguments: body.arguments as Readonly>, expectedSessionRevision: body.expectedSessionRevision, kind: 'call-tool', name: body.name }); - } - void sessionId; - throw requestError(diagnostic('AB8203', 'Runtime request has an invalid shape.', 400)); -}; - -/** Stable, manual-only runtime MCP control surface. App previews use non-owning registry views instead. */ -export class RuntimeMcpRoutes { - readonly #authorize: RuntimeMcpRoutesOptions['authorize']; - readonly #awaitRegistryMutation: RuntimeMcpRoutesOptions['awaitRegistryMutation']; - readonly #awaitSessionClose: RuntimeMcpRoutesOptions['awaitSessionClose']; - readonly #runtime: DevRuntimeSession | undefined; - #closed = false; - - constructor(options: RuntimeMcpRoutesOptions) { - this.#authorize = options.authorize; - this.#awaitRegistryMutation = options.awaitRegistryMutation; - this.#awaitSessionClose = options.awaitSessionClose; - this.#runtime = options.runtime; - } - close(): void { this.#closed = true; } - - async handle(request: IncomingMessage, response: ServerResponse): Promise { - const parsed = route(request.url); - if (parsed === undefined) return false; - this.#authorize(request); - if (this.#closed || this.#runtime === undefined) throw requestError(diagnostic('AB8201', 'Development runtime is not available.', 404)); - try { - const registry = this.#runtime.mcpRegistry; - const method = request.method ?? 'GET'; - if (parsed.kind === 'open') { - if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)), true; - const session = await registry.open(openRequest(await readBody(request))); - responseJson(response, { session: publicSessionSnapshot(session.snapshot()) }); - return true; - } - if (parsed.kind === 'restart') { - if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)), true; - const control = controlRequest(await readBody(request), parsed.sessionId); - const reconcile = await registry.restart(control); - await this.#awaitRegistryMutation?.(); - if (reconcile.action === 'restart-failed') { - return responseDiagnostic(response, diagnostic('AB8205', 'Runtime MCP session restart failed.', 409)), true; - } - const session = registry.session(parsed.sessionId); - if (session === undefined) throw requestError(diagnostic('AB8201', 'Runtime MCP session is not available.', 404)); - responseJson(response, { reconcile, session: restartSnapshot(session.snapshot(), control, reconcile) }); - return true; - } - if (parsed.kind === 'close') { - if (method !== 'DELETE') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)), true; - const control = controlRequest(await readBody(request), parsed.sessionId); - await registry.closeSession(control); - await this.#awaitSessionClose?.(control); - await this.#awaitRegistryMutation?.(); - responseJson(response, { closed: true }); - return true; - } - if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)), true; - const view = registry.session(parsed.sessionId); - if (view === undefined) throw requestError(diagnostic('AB8201', 'Runtime MCP session is not available.', 404)); - responseJson(response, { result: await view.execute(rpcRequest(await readBody(request), parsed.sessionId)) }); - return true; - } catch (error) { - if (isRequestDiagnostic(error)) throw error; - const anyError = error as Partial<{ readonly code: string }>; - if (anyError.code === 'AB8204' || anyError.code === 'RUNTIME_MCP_REGISTRY_CONFLICT') { - throw requestError(diagnostic('AB8204', 'Runtime session revision is stale or unavailable for this operation.', 409)); - } - if (anyError.code === 'RUNTIME_MCP_REGISTRY_NOT_FOUND' || anyError.code === 'RUNTIME_MCP_REGISTRY_CLOSED') { - throw requestError(diagnostic('AB8201', 'Runtime MCP session is not available.', 404)); - } - if (anyError.code === 'RUNTIME_MCP_REGISTRY_INVALID') { - throw requestError(diagnostic('AB8203', 'Runtime request has an invalid shape.', 400)); - } - throw requestError(diagnostic('AB8205', 'Runtime MCP request could not be completed.', 500)); - } - } -} diff --git a/packages/agent-bundle/src/dev/runtime-provider.ts b/packages/agent-bundle/src/dev/runtime-provider.ts index b10484fcb..03dfc190e 100644 --- a/packages/agent-bundle/src/dev/runtime-provider.ts +++ b/packages/agent-bundle/src/dev/runtime-provider.ts @@ -29,22 +29,12 @@ export interface DevRuntimeClientSurfaceEndpoint { /** * Provider-owned Runtime App reload authority. The provider invokes every * subscribed listener after a successful, changed App environment compile; - * the returned function detaches that listener. This is the only reload - * signal the core relay consumes — Rsbuild's private WebSocket frames are - * not part of the contract. + * the returned function detaches that listener. */ readonly subscribeReload: (listener: () => void) => () => void; readonly surfaceId: string; } -/** Core-owned, server-only proxy handle; the host plan may embed only bootstrapUrl. */ -export interface DevRuntimeClientSurfaceProxyBinding { - readonly bootstrapUrl: string; - readonly origin: string; - readonly surfaceId: string; - close(): Promise; -} - /** Trusted normalized input from ProjectService; never serialize to the browser. */ export interface DevRuntimePreparedMcpServer { readonly args?: readonly string[]; @@ -97,10 +87,7 @@ export interface DevRuntimeEventInput { | 'runtime.run.failed' | 'runtime.mcp.restarting' | 'runtime.mcp.ready' - | 'runtime.mcp.failed' - | 'runtime.app.updated' - | 'runtime.hmr.client-connected' - | 'runtime.hmr.client-disconnected'; + | 'runtime.mcp.failed'; } export interface DevRuntimeStartContext { diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 5f2f269b0..3fe255236 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -42,8 +42,6 @@ import { McpAppBindingService, type McpAppToolDefinition } from './mcp-apps/mcp- import type { McpAppRoutePreviewService } from './mcp-apps/mcp-app-routes.ts'; import { McpAppPreviewService } from './mcp-apps/mcp-app-preview-service.ts'; import { mcpAppPreviewHost, mcpAppPreviewHostInfo, openInBrowser, type OpenBrowser } from './mcp-apps/mcp-app-preview-host.ts'; -import { McpAppRuntimeBindingService } from './mcp-app-runtime-binding-service.ts'; -import { McpAppRuntimePreviewService } from './mcp-app-runtime-preview-service.ts'; import { createMcpAppSandboxProxy, type CreateMcpAppSandboxProxyOptions, @@ -69,18 +67,7 @@ import { import { routeManifestFor } from './routes/route-manifest.ts'; import type { RouteManifestRouteService } from './routes/route-manifest-routes.ts'; import { DevRuntimeController } from './runtime-controller.ts'; -import { - RuntimeClientSurfaceProxy, - strictRuntimeClientSurfaceContentPolicy, - type RuntimeClientSurfaceContentPolicy, -} from './runtime-client-surface-proxy.ts'; import { resolveDevRuntimeProvider } from './runtime-provider-loader.ts'; -import { - isDevRuntimeUnavailableError, - type DevRuntimeClientSurfaceEndpoint, - type DevRuntimeClientSurfaceProxyBinding, - type DevRuntimeEventInput, -} from './runtime-provider.ts'; import { ScriptPlaygroundService } from './playground/script-playground-service.ts'; import { SkillDocumentService } from './skill-document-service.ts'; import { TraceHub } from './trace/trace-hub.ts'; @@ -92,7 +79,6 @@ import { deepFreeze } from '../core/freeze.ts'; export interface DevServerSession { close(): Promise; - openRuntimeClientSurface(surfaceId: string): Promise; status(): ProjectStatus; readonly url: string; } @@ -105,7 +91,7 @@ interface Closeable { export interface DevServerLifecycleCloseFailure { readonly error: unknown; - readonly resource: 'coordinator' | 'epoch-adoption' | 'host-installs' | 'inspector' | 'logs' | 'mcp-apps' | 'mcp-sessions' | 'playground' | 'runtime' | 'runtime-client-surfaces'; + readonly resource: 'coordinator' | 'epoch-adoption' | 'host-installs' | 'inspector' | 'logs' | 'mcp-apps' | 'mcp-sessions' | 'playground' | 'runtime'; } /** Reports session and coordinator cleanup failures without hiding either resource. */ @@ -162,11 +148,6 @@ interface DevServerTesting { | 'registry' | 'timeoutMs' >; - readonly openRuntimeClientSurface?: ( - endpoint: DevRuntimeClientSurfaceEndpoint, - listener: Parameters[1], - hostOrigin: string, - ) => Promise; readonly startForegroundServer?: (options: ForegroundServerOptions) => Promise; } @@ -188,7 +169,7 @@ export class DevServerStartError extends Error { interface McpAppLifecycleCloseFailure { readonly error: unknown; - readonly resource: 'previews' | 'runtime-previews' | 'sandbox'; + readonly resource: 'previews' | 'sandbox'; } class McpAppLifecycleCloseError extends Error { @@ -205,59 +186,33 @@ class McpAppLifecycle implements Closeable { readonly #sandbox: McpAppSandboxProxy; #closePromise: Promise | undefined; #closing = false; - #prepareClosePromise: Promise | undefined; #previews: McpAppPreviewService | undefined; - #runtimePreviews: McpAppRuntimePreviewService | undefined; constructor(sandbox: McpAppSandboxProxy) { this.#sandbox = sandbox; } - attach(previews: McpAppPreviewService, runtimePreviews?: McpAppRuntimePreviewService): void { + attach(previews: McpAppPreviewService): void { if (this.#previews !== undefined) throw new Error('MCP App previews are already attached.'); if (this.#closing) throw new Error('MCP App previews are closing.'); this.#previews = previews; - this.#runtimePreviews = runtimePreviews; - } - - /** The runtime App lane can arrive after its initial model becomes valid. */ - attachRuntime(runtimePreviews: McpAppRuntimePreviewService): boolean { - if (this.#previews === undefined) throw new Error('MCP App previews are not attached.'); - if (this.#closing || this.#runtimePreviews !== undefined) return false; - this.#runtimePreviews = runtimePreviews; - return true; } - get acceptsRuntimePreviews(): boolean { return !this.#closing && this.#previews !== undefined && this.#runtimePreviews === undefined; } - close(): Promise { this.#closing = true; this.#closePromise ??= this.#close(); return this.#closePromise; } - prepareClose(): Promise { - this.#closing = true; - this.#prepareClosePromise ??= this.#runtimePreviews?.prepareClose() ?? Promise.resolve(); - return this.#prepareClosePromise; - } - async #close(): Promise { - await this.prepareClose(); const preview = this.#previews === undefined ? undefined : await Promise.allSettled([this.#previews.closeAll()]); - const runtimePreview = this.#runtimePreviews === undefined - ? undefined - : await Promise.allSettled([this.#runtimePreviews.closeAll()]); const sandbox = await Promise.allSettled([this.#sandbox.close()]); const failures: McpAppLifecycleCloseFailure[] = [ ...(preview?.flatMap((result) => result.status === 'rejected' ? [Object.freeze({ error: result.reason, resource: 'previews' as const })] : []) ?? []), - ...(runtimePreview?.flatMap((result) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, resource: 'runtime-previews' as const })] - : []) ?? []), ...sandbox.flatMap((result) => result.status === 'rejected' ? [Object.freeze({ error: result.reason, resource: 'sandbox' as const })] : []), @@ -270,33 +225,16 @@ class McpAppLifecycle implements Closeable { class DeferredMcpAppPreviewService implements McpAppRoutePreviewService { #closing = false; #service: McpAppRoutePreviewService | undefined; - #runtime: McpAppRuntimePreviewService | undefined; - #prepareClose: (() => Promise) | undefined; - attach(service: McpAppRoutePreviewService, runtime?: McpAppRuntimePreviewService, lifecycle?: McpAppLifecycle): void { + attach(service: McpAppRoutePreviewService): void { if (this.#service !== undefined) throw new Error('MCP App preview route service is already attached.'); if (this.#closing) throw new Error('MCP App preview route service is closing.'); this.#service = service; - this.#runtime = runtime; - this.#prepareClose = lifecycle === undefined ? undefined : () => lifecycle.prepareClose(); - } - - /** Publishes one lifecycle-owned runtime lane only after it is fully registered. */ - attachRuntime(runtime: McpAppRuntimePreviewService): boolean { - if (this.#service === undefined) throw new Error('MCP App preview route service is not ready.'); - if (this.#closing || this.#runtime !== undefined) return false; - this.#runtime = runtime; - return true; } - get runtime(): McpAppRuntimePreviewService | undefined { return this.#runtime; } - prepareClose(): Promise { - // The route facade must fail closed synchronously, before the foreground - // begins draining the runtime preview lane it no longer exposes. this.#closing = true; - this.#runtime = undefined; - return this.#prepareClose?.() ?? Promise.resolve(); + return Promise.resolve(); } get(bindingId: string) { @@ -341,113 +279,7 @@ class DeferredMcpAppPreviewService implements McpAppRoutePreviewService { } } -/** Owns every fixed loopback proxy binding for the life of a Workbench session. */ -export class RuntimeClientSurfaceBindings implements Closeable { - readonly #openProxy: typeof RuntimeClientSurfaceProxy.open; - readonly #runtime: DevRuntimeController | undefined; - readonly #bindings = new Set(); - readonly #lateCloseFailures: unknown[] = []; - readonly #pending = new Set>(); - #closing = false; - #closePromise: Promise | undefined; - #hostOrigin: string | undefined; - - constructor( - runtime: DevRuntimeController | undefined, - openProxy: typeof RuntimeClientSurfaceProxy.open = RuntimeClientSurfaceProxy.open, - ) { - this.#runtime = runtime; - this.#openProxy = openProxy; - } - - /** The foreground listener is the only authority allowed to embed a surface. */ - bindHostOrigin(hostOrigin: string): void { - let parsed: URL; - try { - parsed = new URL(hostOrigin); - } catch { - throw new TypeError('Runtime client surfaces require a canonical foreground origin.'); - } - if ( - (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.origin !== hostOrigin || - parsed.username.length > 0 || parsed.password.length > 0 || parsed.pathname !== '/' || - parsed.search.length > 0 || parsed.hash.length > 0 || this.#hostOrigin !== undefined - ) throw new TypeError('Runtime client surfaces require one canonical foreground origin binding.'); - this.#hostOrigin = parsed.origin; - } - - async open( - surfaceId: string, - policy: RuntimeClientSurfaceContentPolicy = strictRuntimeClientSurfaceContentPolicy, - ): Promise { - if (this.#closing) throw new Error('Development runtime client surfaces are closed.'); - if (this.#hostOrigin === undefined) throw new Error('Development runtime client surfaces are not bound to a foreground origin.'); - let endpoint; - try { - endpoint = this.#runtime?.clientSurface(surfaceId); - } catch (error) { - if (isDevRuntimeUnavailableError(error)) return undefined; - throw error; - } - if (endpoint === undefined) return undefined; - const opening = this.#openProxy(endpoint, (event) => { - this.#runtime?.emit(Object.freeze({ - details: Object.freeze({ connectionCount: event.connectionCount, surfaceId: event.surfaceId }), - type: event.type === 'connected' ? 'runtime.hmr.client-connected' : 'runtime.hmr.client-disconnected', - } satisfies DevRuntimeEventInput)); - }, this.#hostOrigin, policy).then(async (binding) => { - if (this.#closing) { - try { - await binding.close(); - } catch (error) { - this.#lateCloseFailures.push(error); - } - throw new Error('Development runtime client surfaces are closed.'); - } - const wrapped: DevRuntimeClientSurfaceProxyBinding = Object.freeze({ - ...binding, - close: async (): Promise => { - try { - await binding.close(); - } finally { - this.#bindings.delete(wrapped); - } - }, - }); - this.#bindings.add(wrapped); - return wrapped; - }); - this.#pending.add(opening); - void opening.then( - () => this.#pending.delete(opening), - () => this.#pending.delete(opening), - ); - return opening; - } - - /** Fences new proxy acquisition before the App lanes begin their ordered drain. */ - beginClose(): void { this.#closing = true; } - - close(): Promise { - this.#closePromise ??= this.#close(); - return this.#closePromise; - } - - async #close(): Promise { - this.#closing = true; - await Promise.allSettled([...this.#pending]); - const results = await Promise.allSettled([...this.#bindings].map((binding) => binding.close())); - const failures = [ - ...results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []), - ...this.#lateCloseFailures, - ]; - if (failures.length === 1) throw failures[0]; - if (failures.length > 1) throw new AggregateError(failures, 'Runtime client surfaces could not close.'); - } -} - export interface DevServerRuntimeLifecycleResources { - readonly clientSurfaces?: Closeable; readonly runtime?: Closeable; } @@ -495,7 +327,6 @@ export const closeDevServerLifecycle = async ({ ['playground', playground], ['inspector', inspector], ['mcp-apps', mcpApps], - ['runtime-client-surfaces', runtimeResources?.clientSurfaces], ['runtime', runtimeResources?.runtime], ['epoch-adoption', epochAdoption], ['mcp-sessions', mcpSessions], @@ -531,7 +362,6 @@ const withMcpSessionLifecycle = ( mcpSessions: McpSessionService, mcpApps: () => Closeable | undefined, runtime: DevRuntimeController | undefined, - clientSurfaces: RuntimeClientSurfaceBindings, status: () => ProjectStatus, playground: Closeable, logs: DevLogService, @@ -544,9 +374,7 @@ const withMcpSessionLifecycle = ( publishHookReceiptUrl: (url: string) => void, hostInstalls?: DevHostInstallManager, ): ForegroundCoordinator => Object.freeze({ - close: () => { - clientSurfaces.beginClose(); - return closeDevServerLifecycle({ + close: () => closeDevServerLifecycle({ coordinator, detachProjectLogs, detachProjectTrace, @@ -557,10 +385,9 @@ const withMcpSessionLifecycle = ( mcpApps: mcpApps(), mcpSessions, playground, - runtimeResources: { clientSurfaces, runtime }, + runtimeResources: { runtime }, trace, - }); - }, + }), publishServerUrl: async (url: string) => { await coordinator.publishServerUrl(url); publishHookReceiptUrl(url); @@ -614,7 +441,6 @@ export const startDevServer = async (options: StartDevServerOptions): Promise session.openRuntimeClientSurface(surfaceId), status: () => session.status(), url: session.url, }); @@ -658,9 +484,6 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun build: { state: 'idle' }, source: { diagnostics: Object.freeze([]), state: 'unknown' }, }); - // A provider can start in `compiling`; event delivery retries the no-op - // placeholder only after the Workbench has installed its App lifecycle. - let ensureRuntimeAppPreviews: () => void = () => undefined; let runtime: DevRuntimeController | undefined; if (initialPreparedProject.devRuntime !== undefined || initialPreparedProject.devRuntimeDiagnostic !== undefined) { const preparedRuntime = initialPreparedProject.devRuntime ?? Object.freeze({ @@ -689,9 +512,6 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun payload: event, type: 'runtime.event', }); - if (event.type === 'runtime.generation.activated' || event.type === 'runtime.generation.failed' || event.type === 'runtime.status') { - ensureRuntimeAppPreviews(); - } }, environment: process.env, preparedRuntime, @@ -702,71 +522,13 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun }); } const appPreviews = new DeferredMcpAppPreviewService(); - const clientSurfaces = new RuntimeClientSurfaceBindings(runtime, options.testing?.openRuntimeClientSurface); const runtimeTopology = runtime === undefined ? undefined : Object.freeze({ state: 'configured' as const }); let foregroundClosing = false; - let installingRuntimePreviews = false; let mcpApps: McpAppLifecycle | undefined; let mcpAppSandboxOrigin: string | undefined; let previews: McpAppPreviewService | undefined; - /** - * Runtime topology is fixed at startup, but a valid model can arrive later - * than the controller. Register the service with its lifecycle before the - * foreground facade publishes it, so close/reconcile races remain closed. - */ - ensureRuntimeAppPreviews = (): void => { - const lifecycle = mcpApps; - const prepared = latestValidPreparedProject; - if ( - foregroundClosing || runtime === undefined || prepared === undefined || previews === undefined || - lifecycle === undefined || !lifecycle.acceptsRuntimePreviews || installingRuntimePreviews - ) return; - const runtimeStatus = runtime.status(); - if (runtimeStatus.state !== 'active' && runtimeStatus.state !== 'degraded') return; - installingRuntimePreviews = true; - try { - // Reading this getter proves that the provider has exposed the stable - // broker surface; a merely constructed controller is not enough. - const registry = runtime.mcpRegistry; - if (typeof registry.session !== 'function' || typeof registry.subscribe !== 'function') return; - const runtimePreviews = new McpAppRuntimePreviewService({ - bindingAuthority: new McpAppRuntimeBindingService(), - configExtensions: () => { - const current = latestValidPreparedProject; - if (current === undefined || current.source.state !== 'ready' || current.source.revision === undefined || current.model === undefined) { - throw new Error('No valid prepared project is available for Runtime MCP App inspection.'); - } - return Object.freeze({ - descriptors: current.registry.configExtensions(), - extensions: current.model.extensions, - projectRoot: current.root, - sourceRevision: current.source.revision, - }); - }, - emit: (details) => runtime.emit(Object.freeze({ - details: Object.freeze({ ...details }), - mcpSessionId: details.sessionId, - mcpSessionRevision: details.sessionRevision, - type: 'runtime.app.updated', - })), - openRuntimeClientSurface: (surfaceId) => clientSurfaces.open(surfaceId), - runtime, - }); - if (!lifecycle.attachRuntime(runtimePreviews)) { - void runtimePreviews.closeAll().catch(() => undefined); - return; - } - // `attachRuntime` is synchronous and immediately follows lifecycle - // registration, so a foreground close cannot expose a half-owned lane. - if (!appPreviews.attachRuntime(runtimePreviews)) void runtimePreviews.prepareClose().catch(() => undefined); - } catch (error) { - if (!isDevRuntimeUnavailableError(error)) throw error; - } finally { - installingRuntimePreviews = false; - } - }; const coordinator = new DevCoordinator({ epochStore, eventHub, @@ -777,7 +539,6 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun latestValidPreparedProject = prepared; if (runtime !== undefined) { await runtime.reconcileDeclaration(prepared.devRuntime, prepared.devRuntimeDiagnostic); - ensureRuntimeAppPreviews(); return; } if (!runtimeTopologyChanged && (prepared.devRuntime !== undefined || prepared.devRuntimeDiagnostic !== undefined)) { @@ -1068,7 +829,6 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun mcpSessions, () => mcpApps, runtime, - clientSurfaces, status, playground, logs, @@ -1118,18 +878,9 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun latestPublishedPreparedProject = initialPreparedProject; } } - clientSurfaces.bindHostOrigin(foreground.url); - // Linearize Workbench-owned runtime proxy acquisition before Foreground - // begins its asynchronous App/SSE drain. The coordinator repeats this fence - // defensively during lifecycle close, but that happens too late for a proxy - // open already pending when callers request server.close(). const closeForeground = async (): Promise => { foregroundClosing = true; - // Fence authenticated runtime routes before the foreground begins closing; - // the lifecycle retains the service for its ordered preview cleanup. void appPreviews.prepareClose().catch(() => undefined); - void mcpApps?.prepareClose().catch(() => undefined); - clientSurfaces.beginClose(); try { try { await hookReceipts.close(); @@ -1175,8 +926,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun }, }); mcpApps.attach(previews); - appPreviews.attach(previews, undefined, mcpApps); - ensureRuntimeAppPreviews(); + appPreviews.attach(previews); if (options.open === true) await openBrowser(foreground.url); } catch (error) { const [cleanup] = await Promise.allSettled([closeForeground()]); @@ -1190,7 +940,6 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun } return Object.freeze({ close: closeForeground, - openRuntimeClientSurface: (surfaceId: string) => clientSurfaces.open(surfaceId), status, url: foreground.url, }); diff --git a/packages/agent-bundle/src/test/browser.ts b/packages/agent-bundle/src/test/browser.ts index feff239ba..6b08074b5 100644 --- a/packages/agent-bundle/src/test/browser.ts +++ b/packages/agent-bundle/src/test/browser.ts @@ -19,13 +19,13 @@ import type { } from '../dev/mcp-apps/mcp-app-binding-service.ts'; import { snapshotMcpAppJsonRecord } from '../dev/mcp-apps/mcp-app-json.ts'; import type { McpAppProfileId } from '../dev/mcp-app-profile-descriptors.ts'; +import type { McpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; import type { McpAppConsentAuthority, - McpAppConsentCapability, McpAppConsentChallenge, McpAppConsentGrant, McpAppConsentResolution, -} from '../dev/mcp-apps/mcp-app-sandbox.ts'; +} from '../dev/mcp-apps/mcp-app-sandbox-types.ts'; import { AGENT_BROWSER_TEST_REGISTRY_SYMBOL_KEY, AGENT_BROWSER_TEST_REGISTRY_VERSION, diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 39b08aef3..ad4146a09 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -234,7 +234,6 @@ it('passes repeatable --workbench-dev-origin values to the public dev API and om received.push(options); return { close: async () => { closeCalls += 1; }, - openRuntimeClientSurface: async () => undefined, status: () => ({}) as never, url: 'http://127.0.0.1:4100', }; diff --git a/packages/agent-bundle/tests/dev-events.contract.test.ts b/packages/agent-bundle/tests/dev-events.contract.test.ts index a2dea71e6..809c752c3 100644 --- a/packages/agent-bundle/tests/dev-events.contract.test.ts +++ b/packages/agent-bundle/tests/dev-events.contract.test.ts @@ -44,7 +44,7 @@ const runtimeEvent: ProjectEventInput = { providerSessionId: 'provider-a', runId: 'run-1', runtimeGenerationId: 'generation-a', - type: 'runtime.hmr.client-connected', + type: 'runtime.run.started', }, type: 'runtime.event', }; diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 797a1d88b..7b7e538bc 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -16,7 +16,6 @@ import { closeDevServerLifecycle, DevServerLifecycleCloseError, DevServerStartError, - RuntimeClientSurfaceBindings, startDevServer, } from '../src/dev/workbench-server.ts'; import type { ForegroundCoordinator, ForegroundServerOptions } from '../src/dev/foreground-server.ts'; @@ -43,18 +42,6 @@ const within = async (promise: Promise, milliseconds: number): Pro }), ]); -it('requires one canonical foreground origin before opening runtime client surfaces', async () => { - const bindings = new RuntimeClientSurfaceBindings(undefined, async () => { - throw new Error('Runtime lookup must not open a proxy without a bound foreground.'); - }); - await expect(bindings.open('mcp.edit-timeline')).rejects.toThrow('not bound'); - expect(() => bindings.bindHostOrigin('http://127.0.0.1:42000/not-an-origin')).toThrow('canonical foreground'); - bindings.bindHostOrigin('http://127.0.0.1:42000'); - expect(() => bindings.bindHostOrigin('http://127.0.0.1:42000')).toThrow('one canonical foreground origin binding'); - await expect(bindings.open('mcp.edit-timeline')).resolves.toBeUndefined(); - await expect(bindings.close()).resolves.toBeUndefined(); -}); - const openProjectEventStream = (url: string, cookie: string): Readonly<{ readonly close: () => void; readonly opened: Promise; @@ -188,67 +175,6 @@ const appPreviewBody = () => ({ toolName: 'show-app', }); -interface CompilingRuntimeAppState { - emit: ((event: { readonly type: 'runtime.generation.activated' | 'runtime.status' }) => void) | undefined; - phase: 'active' | 'compiling'; - subscribes: number; - unsubscribes: number; -} - -const writeCompilingRuntimeAppProject = async (root: string, stateKey: string): Promise => { - await mkdir(join(root, 'src', 'dev'), { recursive: true }); - await Promise.all([ - writeFile(join(root, 'src', 'dev', 'provider.ts'), [ - `const state = globalThis[${JSON.stringify(stateKey)}];`, - "if (state === undefined) throw new Error('Missing compiling Runtime Apps test state.');", - 'const registry = {', - ' close: async () => undefined,', - ' closeSession: async () => undefined,', - ' open: async () => { throw new Error(\'unused\'); },', - ' reconcile: async () => ({ invalidatedBindings: [], registryRevision: 0 }),', - ' restart: async () => ({ invalidatedBindings: [], registryRevision: 0 }),', - ' session: () => undefined,', - ' snapshot: () => undefined,', - " subscribe: () => { state.subscribes += 1; state.emit?.({ type: 'runtime.status' }); return { unsubscribe: () => { state.unsubscribes += 1; } }; },", - '};', - 'export const createDevRuntimeProvider = () => ({', - " descriptor: { environmentVariables: [], id: 'compiling-runtime-apps', label: 'Compiling Runtime Apps', schemaVersion: 1 },", - ' start: async (context) => {', - ' state.emit = context.emit;', - ' return {', - ' clientSurface: () => undefined,', - ' close: async () => undefined,', - ' invoke: async () => { throw new Error(\'unused\'); },', - ' mcpRegistry: registry,', - " providerSessionId: 'provider-compiling-runtime-apps',", - ' readAsset: async () => undefined,', - ' readRunFlight: async () => undefined,', - ' reconcilePreparedRuntime: async () => undefined,', - ' replay: async () => { throw new Error(\'unused\'); },', - " resetState: async () => ({ stateStoreId: 'state-compiling-runtime-apps', stateVersion: 0 }),", - ' run: () => undefined,', - ' runs: () => [],', - " status: () => ({ descriptor: { environmentVariables: [], id: 'compiling-runtime-apps', label: 'Compiling Runtime Apps', schemaVersion: 1 }, diagnostics: [], hmrReady: true, state: state.phase }),", - ' surfaces: () => [],', - ' };', - ' },', - '});', - '', - ].join('\n')), - writeFile(join(root, 'agent-bundle.config.ts'), [ - "import { defineConfig } from 'agent-bundle';", - '', - 'export default defineConfig({', - " dev: { runtime: { provider: './src/dev/provider.ts' } },", - " plugin: { name: 'compiling-runtime-apps' },", - " skills: ['src/skills/review'],", - " targets: ['portable'],", - '});', - '', - ].join('\n')), - ]); -}; - const workbenchSyntheticAdapter: TargetAdapter = Object.freeze({ artifactLayout: Object.freeze({ scripts: Object.freeze({ @@ -715,304 +641,7 @@ it('keeps the ordinary foreground and artifact lane available when provider star await expect(fetch(`${server.url}/api/runtime/status`).then((response) => response.json())).resolves.toMatchObject({ status: { diagnostics: [{ phase: 'provider-lifecycle' }], state: 'failed' }, }); - const bootstrap = await fetch(`${server.url}/api/project/session`, { headers: { 'sec-fetch-site': 'same-origin' } }); - const { token } = await bootstrap.json() as { readonly token: string }; - await expect(fetch(`${server.url}/api/runtime/apps`, { - body: JSON.stringify({ expectedGenerationId: 'missing-generation', profileId: 'portable', runId: 'missing-run' }), - headers: { 'content-type': 'application/json', origin: server.url, 'x-agent-bundle-session': token }, - method: 'POST', - }).then(async (response) => ({ body: await response.json(), status: response.status }))).resolves.toEqual({ - body: { diagnostic: { code: 'AB8022', message: 'MCP App preview is not available.' } }, - status: 404, - }); - } finally { - await server?.close().catch(() => undefined); - await Promise.all([removeProjectFixture(project.root), removeTree(assetsRoot)]); - } -}, 30_000); - -it('retains Runtime App routes through invalid config updates and reconciles only repaired or removed declarations', async () => { - const project = await createProjectFixture(); - const assetsRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-late-runtime-apps-')); - const stateKey = `__agentBundleLateRuntimeApps${Date.now()}${Math.random().toString(16).slice(2)}`; - const runtimeState = { calls: [] as string[], closes: 0, reconciles: 0, subscribes: 0, unsubscribes: 0 }; - const runtimeGlobal = globalThis as typeof globalThis & Record; - let server: Awaited> | undefined; - const config = (targets: string[], marker: string, extension?: string, includeRuntime = true): string => [ - "import { defineConfig } from 'agent-bundle';", - '', - 'export default defineConfig({', - ...(includeRuntime ? [" dev: { runtime: { provider: './src/dev/provider.ts' } },"] : []), - ` fixtureMarker: ${JSON.stringify(marker)},`, - " plugin: { name: 'late-runtime-apps' },", - " skills: ['src/skills/review'],", - ` targets: ${JSON.stringify(targets)},`, - ...(extension === undefined ? [] : [` portable: ${extension},`]), - '});', - '', - ].join('\n'); - try { - runtimeGlobal[stateKey] = runtimeState; - await mkdir(join(project.root, 'src', 'dev'), { recursive: true }); - await Promise.all([ - writeFile(join(assetsRoot, 'index.html'), 'Agent Bundle workbench'), - writeFile(join(project.root, 'src', 'dev', 'provider.ts'), [ - `const state = globalThis[${JSON.stringify(stateKey)}];`, - "if (state === undefined) throw new Error('Missing late Runtime Apps test state.');", - 'const registry = {', - ' close: async () => undefined,', - ' closeSession: async () => undefined,', - ' open: async () => { throw new Error(\'unused\'); },', - ' reconcile: async () => ({ invalidatedBindings: [], registryRevision: 0 }),', - ' restart: async () => ({ invalidatedBindings: [], registryRevision: 0 }),', - ' session: () => undefined,', - ' snapshot: () => undefined,', - " subscribe: () => { state.calls.push('subscribe'); state.subscribes += 1; return { unsubscribe: () => { state.calls.push('unsubscribe'); state.unsubscribes += 1; } }; },", - '};', - 'export const createDevRuntimeProvider = () => ({', - " descriptor: { environmentVariables: [], id: 'late-runtime-apps', label: 'Late Runtime Apps', schemaVersion: 1 },", - ' start: async () => ({', - ' clientSurface: () => undefined,', - " close: async () => { state.calls.push('close'); state.closes += 1; },", - ' invoke: async () => { throw new Error(\'unused\'); },', - ' mcpRegistry: registry,', - " providerSessionId: 'provider-late-runtime-apps',", - ' readAsset: async () => undefined,', - ' readRunFlight: async () => undefined,', - " reconcilePreparedRuntime: async () => { state.calls.push('reconcile'); state.reconciles += 1; },", - ' replay: async () => { throw new Error(\'unused\'); },', - " resetState: async () => ({ stateStoreId: 'state-late-runtime-apps', stateVersion: 0 }),", - ' run: () => undefined,', - ' runs: () => [],', - " status: () => ({ descriptor: { environmentVariables: [], id: 'late-runtime-apps', label: 'Late Runtime Apps', schemaVersion: 1 }, diagnostics: [], hmrReady: true, state: 'active' }),", - ' surfaces: () => [],', - ' }),', - '});', - '', - ].join('\n')), - // An unknown target is a model failure, but the valid development - // declaration still constructs the fixed runtime controller. - writeFile(project.configPath, config(['portable', 'unknown-target'], 'invalid-initial')), - ]); - server = await startDevServer({ - assets: createWorkbenchAssetSource({ root: assetsRoot }), - open: false, - port: 0, - root: project.root, - }); - await expect(fetch(`${server.url}/api/runtime/status`).then((response) => response.json())).resolves.toMatchObject({ - status: { descriptor: { id: 'late-runtime-apps' }, state: 'active' }, - }); - const bootstrap = await fetch(`${server.url}/api/project/session`, { headers: { 'sec-fetch-site': 'same-origin' } }); - const { token } = await bootstrap.json() as { readonly token: string }; - const headers = { 'content-type': 'application/json', origin: server.url, 'x-agent-bundle-session': token }; - const create = () => fetch(`${server!.url}/api/runtime/apps`, { - body: JSON.stringify({ expectedGenerationId: 'missing-generation', profileId: 'portable', runId: 'missing-run' }), - headers, - method: 'POST', - }); - const history = () => fetch(`${server!.url}/api/runtime/runs`, { headers }).then(async (response) => ({ body: await response.json(), status: response.status })); - - await expect(create().then(async (response) => ({ body: await response.json(), status: response.status }))).resolves.toEqual({ - body: { diagnostic: { code: 'AB8022', message: 'MCP App preview is not available.' } }, - status: 404, - }); - expect(runtimeState.subscribes).toBe(0); - - await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-first', '{}')); - await within((async () => { - for (let attempt = 0; attempt < 100; attempt += 1) { - const response = await create(); - const result = { body: await response.json(), status: response.status }; - if (result.status === 404 && result.body.diagnostic?.code === 'AB8201') return; - await new Promise((resolvePromise) => { setTimeout(resolvePromise, 25); }); - } - throw new Error('Runtime MCP App preview did not attach after the valid config update.'); - })(), 5_000); - expect(runtimeState.subscribes).toBe(1); - const stableHistory = await history(); - expect(stableHistory).toEqual({ - body: { providerSessionId: expect.any(String), runs: [] }, - status: 200, - }); - const stableRuntime = { - calls: [...runtimeState.calls], - closes: runtimeState.closes, - reconciles: runtimeState.reconciles, - subscribes: runtimeState.subscribes, - unsubscribes: runtimeState.unsubscribes, - }; - - await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'invalid-nonfinite', 'Number.NaN')); - const invalid = await fetch(`${server.url}/api/project/rebuild`, { - body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), - headers, - method: 'POST', - }).then(async (response) => ({ body: await response.json(), status: response.status })); - expect(invalid).toMatchObject({ - body: { - status: { - source: { - diagnostics: [{ - code: 'AB4500', - message: 'A registered config extension must contain strict finite JSON data.', - sourcePath: project.configPath, - }], - state: 'invalid', - }, - }, - }, - status: 200, - }); - expect(runtimeState).toEqual(stableRuntime); - await expect(history()).resolves.toEqual(stableHistory); - await expect(create().then(async (response) => ({ body: await response.json(), status: response.status }))).resolves.toEqual({ - body: { diagnostic: { code: 'AB8201', message: 'Runtime MCP App run is not available.' } }, - status: 404, - }); - - await expect(fetch(`${server.url}/api/project/rebuild`, { - body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), - headers, - method: 'POST', - }).then((response) => response.status)).resolves.toBe(200); - expect(runtimeState).toEqual(stableRuntime); - - await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-repair', '{}')); - await expect(fetch(`${server.url}/api/project/rebuild`, { - body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), - headers, - method: 'POST', - }).then((response) => response.status)).resolves.toBe(200); - expect(runtimeState).toEqual({ - calls: [...stableRuntime.calls, 'reconcile'], - closes: stableRuntime.closes, - reconciles: stableRuntime.reconciles + 1, - subscribes: stableRuntime.subscribes, - unsubscribes: stableRuntime.unsubscribes, - }); - - await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-removal', undefined, false)); - await expect(fetch(`${server.url}/api/project/rebuild`, { - body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), - headers, - method: 'POST', - }).then((response) => response.status)).resolves.toBe(200); - expect(runtimeState).toEqual({ - calls: [...stableRuntime.calls, 'reconcile'], - closes: stableRuntime.closes, - reconciles: stableRuntime.reconciles + 1, - subscribes: stableRuntime.subscribes, - unsubscribes: stableRuntime.unsubscribes, - }); - await expect(fetch(`${server.url}/api/runtime/status`).then((response) => response.json())).resolves.toMatchObject({ - status: { - diagnostics: [{ code: 'AB8200', message: 'Development runtime declaration changed; restart required.', phase: 'provider-lifecycle' }], - state: 'failed', - }, - }); - await expect(create().then(async (response) => ({ body: await response.json(), status: response.status }))).resolves.toEqual({ - body: { diagnostic: { code: 'AB8023', message: 'MCP App operation could not be completed.' } }, - status: 502, - }); - - await expect(server.close()).resolves.toBeUndefined(); - expect(runtimeState.unsubscribes).toBe(1); - } finally { - delete runtimeGlobal[stateKey]; - await server?.close().catch(() => undefined); - await Promise.all([removeProjectFixture(project.root), removeTree(assetsRoot)]); - } -}, 60_000); - -it('fences a closing foreground before a held valid runtime reconcile can attach an App preview service', async () => { - const project = await createProjectFixture(); - const assetsRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-late-runtime-close-')); - const stateKey = `__agentBundleLateRuntimeClose${Date.now()}${Math.random().toString(16).slice(2)}`; - let enteredReconcile: () => void = () => undefined; - let releaseReconcile: () => void = () => undefined; - const reconcileEntered = new Promise((resolvePromise) => { enteredReconcile = resolvePromise; }); - const reconcileReleased = new Promise((resolvePromise) => { releaseReconcile = resolvePromise; }); - const runtimeState = { subscribes: 0, unsubscribes: 0, enteredReconcile, reconcileReleased }; - const runtimeGlobal = globalThis as typeof globalThis & Record; - let server: Awaited> | undefined; - const config = (targets: string[]): string => [ - "import { defineConfig } from 'agent-bundle';", - '', - 'export default defineConfig({', - " dev: { runtime: { provider: './src/dev/provider.ts' } },", - " plugin: { name: 'late-runtime-close' },", - " skills: ['src/skills/review'],", - ` targets: ${JSON.stringify(targets)},`, - '});', - '', - ].join('\n'); - try { - runtimeGlobal[stateKey] = runtimeState; - await mkdir(join(project.root, 'src', 'dev'), { recursive: true }); - await Promise.all([ - writeFile(join(assetsRoot, 'index.html'), 'Agent Bundle workbench'), - writeFile(join(project.root, 'src', 'dev', 'provider.ts'), [ - `const state = globalThis[${JSON.stringify(stateKey)}];`, - "if (state === undefined) throw new Error('Missing late Runtime Apps close state.');", - 'const registry = {', - ' close: async () => undefined,', - ' closeSession: async () => undefined,', - ' open: async () => { throw new Error(\'unused\'); },', - ' reconcile: async () => ({ invalidatedBindings: [], registryRevision: 0 }),', - ' restart: async () => ({ invalidatedBindings: [], registryRevision: 0 }),', - ' session: () => undefined,', - ' snapshot: () => undefined,', - ' subscribe: () => { state.subscribes += 1; return { unsubscribe: () => { state.unsubscribes += 1; } }; },', - '};', - 'export const createDevRuntimeProvider = () => ({', - " descriptor: { environmentVariables: [], id: 'late-runtime-close', label: 'Late Runtime Close', schemaVersion: 1 },", - ' start: async () => ({', - ' clientSurface: () => undefined,', - ' close: async () => undefined,', - ' invoke: async () => { throw new Error(\'unused\'); },', - ' mcpRegistry: registry,', - " providerSessionId: 'provider-late-runtime-close',", - ' readAsset: async () => undefined,', - ' readRunFlight: async () => undefined,', - ' reconcilePreparedRuntime: async () => { state.enteredReconcile(); await state.reconcileReleased; },', - ' replay: async () => { throw new Error(\'unused\'); },', - " resetState: async () => ({ stateStoreId: 'state-late-runtime-close', stateVersion: 0 }),", - ' run: () => undefined,', - ' runs: () => [],', - " status: () => ({ descriptor: { environmentVariables: [], id: 'late-runtime-close', label: 'Late Runtime Close', schemaVersion: 1 }, diagnostics: [], hmrReady: true, state: 'active' }),", - ' surfaces: () => [],', - ' }),', - '});', - '', - ].join('\n')), - writeFile(project.configPath, config(['portable', 'unknown-target'])), - ]); - server = await startDevServer({ - assets: createWorkbenchAssetSource({ root: assetsRoot }), - open: false, - port: 0, - root: project.root, - }); - const bootstrap = await fetch(`${server.url}/api/project/session`, { headers: { 'sec-fetch-site': 'same-origin' } }); - const { token } = await bootstrap.json() as { readonly token: string }; - const headers = { 'content-type': 'application/json', origin: server.url, 'x-agent-bundle-session': token }; - await replaceWatchedSource(project.root, project.configPath, config(['portable'])); - const rebuilding = fetch(`${server.url}/api/project/rebuild`, { - body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), - headers, - method: 'POST', - }); - await within(reconcileEntered, 5_000); - const closing = server.close(); - releaseReconcile(); - await Promise.allSettled([rebuilding]); - await expect(closing).resolves.toBeUndefined(); - expect(runtimeState.subscribes).toBe(0); - expect(runtimeState.unsubscribes).toBe(0); } finally { - releaseReconcile(); - delete runtimeGlobal[stateKey]; await server?.close().catch(() => undefined); await Promise.all([removeProjectFixture(project.root), removeTree(assetsRoot)]); } @@ -1119,7 +748,6 @@ it('does not reconcile a valid preparation released after foreground close begin const stableRuntime = { closes: runtimeState.closes, reconciles: runtimeState.reconciles, - subscribes: runtimeState.subscribes, }; await replaceWatchedSource(project.root, project.configPath, config('held-after-close', true)); @@ -1136,8 +764,6 @@ it('does not reconcile a valid preparation released after foreground close begin expect(runtimeState).toMatchObject({ closes: stableRuntime.closes + 1, reconciles: stableRuntime.reconciles, - subscribes: stableRuntime.subscribes, - unsubscribes: 1, }); expect(runtimeState.calls).not.toContain('reconcile'); } finally { @@ -1222,103 +848,10 @@ it('does not publish a prepared runtime topology after foreground close begins', } }, 30_000); -it('attaches Runtime App routes once when a compiling provider later activates, even if registry subscription re-enters status delivery', async () => { - const project = await createProjectFixture(); - const assetsRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-compiling-runtime-apps-')); - const stateKey = `__agentBundleCompilingRuntimeApps${Date.now()}${Math.random().toString(16).slice(2)}`; - const runtimeState: CompilingRuntimeAppState = { emit: undefined, phase: 'compiling', subscribes: 0, unsubscribes: 0 }; - const runtimeGlobal = globalThis as typeof globalThis & Record; - let server: Awaited> | undefined; - try { - runtimeGlobal[stateKey] = runtimeState; - await Promise.all([ - writeCompilingRuntimeAppProject(project.root, stateKey), - writeFile(join(assetsRoot, 'index.html'), 'Agent Bundle workbench'), - ]); - server = await startDevServer({ - assets: createWorkbenchAssetSource({ root: assetsRoot }), - open: false, - port: 0, - root: project.root, - }); - const bootstrap = await fetch(`${server.url}/api/project/session`, { headers: { 'sec-fetch-site': 'same-origin' } }); - const { token } = await bootstrap.json() as { readonly token: string }; - const create = () => fetch(`${server!.url}/api/runtime/apps`, { - body: JSON.stringify({ expectedGenerationId: 'missing-generation', profileId: 'portable', runId: 'missing-run' }), - headers: { 'content-type': 'application/json', origin: server!.url, 'x-agent-bundle-session': token }, - method: 'POST', - }); - - await expect(create().then(async (response) => ({ body: await response.json(), status: response.status }))).resolves.toEqual({ - body: { diagnostic: { code: 'AB8022', message: 'MCP App preview is not available.' } }, - status: 404, - }); - expect(runtimeState.subscribes).toBe(0); - - runtimeState.phase = 'active'; - runtimeState.emit?.({ type: 'runtime.generation.activated' }); - await expect(create().then(async (response) => ({ body: await response.json(), status: response.status }))).resolves.toEqual({ - body: { diagnostic: { code: 'AB8201', message: 'Runtime MCP App run is not available.' } }, - status: 404, - }); - expect(runtimeState.subscribes).toBe(1); - runtimeState.emit?.({ type: 'runtime.generation.activated' }); - runtimeState.emit?.({ type: 'runtime.status' }); - expect(runtimeState.subscribes).toBe(1); - - await expect(server.close()).resolves.toBeUndefined(); - expect(runtimeState.unsubscribes).toBe(1); - } finally { - delete runtimeGlobal[stateKey]; - await server?.close().catch(() => undefined); - await Promise.all([removeProjectFixture(project.root), removeTree(assetsRoot)]); - } -}, 30_000); - -it('does not attach a compiling Runtime App preview service after foreground close fences a late activation', async () => { - const project = await createProjectFixture(); - const assetsRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-compiling-runtime-close-')); - const stateKey = `__agentBundleCompilingRuntimeClose${Date.now()}${Math.random().toString(16).slice(2)}`; - const runtimeState: CompilingRuntimeAppState = { emit: undefined, phase: 'compiling', subscribes: 0, unsubscribes: 0 }; - const runtimeGlobal = globalThis as typeof globalThis & Record; - let server: Awaited> | undefined; - try { - runtimeGlobal[stateKey] = runtimeState; - await Promise.all([ - writeCompilingRuntimeAppProject(project.root, stateKey), - writeFile(join(assetsRoot, 'index.html'), 'Agent Bundle workbench'), - ]); - server = await startDevServer({ - assets: createWorkbenchAssetSource({ root: assetsRoot }), - open: false, - port: 0, - root: project.root, - }); - const closing = server.close(); - runtimeState.phase = 'active'; - runtimeState.emit?.({ type: 'runtime.generation.activated' }); - await expect(closing).resolves.toBeUndefined(); - expect(runtimeState.subscribes).toBe(0); - expect(runtimeState.unsubscribes).toBe(0); - } finally { - delete runtimeGlobal[stateKey]; - await server?.close().catch(() => undefined); - await Promise.all([removeProjectFixture(project.root), removeTree(assetsRoot)]); - } -}, 30_000); - it('prepares the optional runtime once with the development config context before provider startup', async () => { const project = await createProjectFixture(); const assetsRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-runtime-')); let server: Awaited> | undefined; - let failedServer: Awaited> | undefined; - const boundOrigins: string[] = []; - let resolveSurface: ((binding: { readonly bootstrapUrl: string; close(): Promise; readonly origin: string; readonly surfaceId: string }) => void) | undefined; - const pendingSurface = new Promise<{ readonly bootstrapUrl: string; close(): Promise; readonly origin: string; readonly surfaceId: string }>((resolvePromise) => { - resolveSurface = resolvePromise; - }); - let proxyCalls = 0; - let surfaceCloseCalls = 0; // Both fixture records live outside the watched project source. The dev // watcher treats every non-ignored path under the project root as source // (the project snapshot is broad by design), so a config that appended its @@ -1381,13 +914,6 @@ it('prepares the optional runtime once with the development config context befor open: false, port: 0, root: project.root, - testing: { - openRuntimeClientSurface: async (_endpoint, _listener, hostOrigin) => { - proxyCalls += 1; - boundOrigins.push(hostOrigin); - return pendingSurface.then((binding) => binding); - }, - }, }); const runtimeStorageRoot = join(project.root, '.agent-bundle', 'runtime'); @@ -1414,46 +940,8 @@ it('prepares the optional runtime once with the development config context befor expect(runtimeStatus).toMatchObject({ status: { descriptor: { id: 'fixture-runtime' }, state: 'active' } }); expect(projectStatus).toMatchObject({ status: { runtime: { state: 'configured' } } }); expect((projectStatus as { readonly status: { readonly runtime?: unknown } }).status.runtime).toEqual({ state: 'configured' }); - await expect(server.openRuntimeClientSurface('unknown-surface')).resolves.toBeUndefined(); - const opening = server.openRuntimeClientSurface('timeline'); - // The test seam records synchronously before returning its unresolved - // promise, proving foreground shutdown races an actually pending open. - expect(proxyCalls).toBe(1); - expect(boundOrigins).toEqual([server.url]); - const closing = server.close(); - expect(surfaceCloseCalls).toBe(0); - resolveSurface?.({ - bootstrapUrl: 'http://127.0.0.1:41112/bootstrap', - close: async () => { surfaceCloseCalls += 1; }, - origin: 'http://127.0.0.1:41112', - surfaceId: 'timeline', - }); - await expect(opening).rejects.toThrow('closed'); - await expect(closing).resolves.toBeUndefined(); - expect(proxyCalls).toBe(1); - expect(surfaceCloseCalls).toBe(1); - await expect(server.openRuntimeClientSurface('unknown-surface')).rejects.toThrow('closed'); - let failedCloseCalls = 0; - failedServer = await startDevServer({ - assets: createWorkbenchAssetSource({ root: assetsRoot }), - open: false, - port: 0, - root: project.root, - testing: { - openRuntimeClientSurface: async () => ({ - bootstrapUrl: 'http://127.0.0.1:41113/bootstrap', - close: async () => { failedCloseCalls += 1; throw new Error('Completed client surface close failed.'); }, - origin: 'http://127.0.0.1:41113', - surfaceId: 'timeline', - }), - }, - }); - await expect(failedServer.openRuntimeClientSurface('timeline')).resolves.toMatchObject({ surfaceId: 'timeline' }); - await expect(failedServer.close()).rejects.toMatchObject({ name: 'ForegroundServerCloseError' }); - expect(failedCloseCalls).toBe(1); } finally { await server?.close().catch(() => undefined); - await failedServer?.close().catch(() => undefined); await Promise.all([removeProjectFixture(project.root), removeTree(assetsRoot)]); } }, 30_000); @@ -1996,7 +1484,6 @@ it('closes MCP Apps before sessions and the coordinator while retaining every cl }); it('leaves Agent API ownership to the foreground release while closing every lifecycle resource in order', async () => { - const clientFailure = new Error('Runtime client surface cleanup failed.'); const appFailure = new Error('MCP App cleanup failed.'); const runtimeFailure = new Error('Runtime cleanup failed.'); const mcpFailure = new Error('MCP cleanup failed.'); @@ -2009,7 +1496,6 @@ it('leaves Agent API ownership to the foreground release while closing every lif mcpApps: { close: async () => { closeOrder.push('mcp-apps'); throw appFailure; } }, mcpSessions: { close: async () => { closeOrder.push('mcp-sessions'); throw mcpFailure; } }, runtimeResources: { - clientSurfaces: { close: async () => { closeOrder.push('runtime-client-surfaces'); throw clientFailure; } }, runtime: { close: async () => { closeOrder.push('runtime'); throw runtimeFailure; } }, }, playground: { close: async () => { closeOrder.push('playground'); throw playgroundFailure; } }, @@ -2017,7 +1503,6 @@ it('leaves Agent API ownership to the foreground release while closing every lif failures: [ { error: playgroundFailure, resource: 'playground' }, { error: appFailure, resource: 'mcp-apps' }, - { error: clientFailure, resource: 'runtime-client-surfaces' }, { error: runtimeFailure, resource: 'runtime' }, { error: mcpFailure, resource: 'mcp-sessions' }, { error: coordinatorFailure, resource: 'coordinator' }, @@ -2027,7 +1512,6 @@ it('leaves Agent API ownership to the foreground release while closing every lif expect(closeOrder).toEqual([ 'playground', 'mcp-apps', - 'runtime-client-surfaces', 'runtime', 'mcp-sessions', 'coordinator', @@ -2088,7 +1572,6 @@ it('passes --no-open, the requested port, and repeatable dev host installs to th received.push(options); return { close: async () => undefined, - openRuntimeClientSurface: async () => undefined, status: () => ({}) as never, url: 'http://127.0.0.1:4100', }; @@ -2111,7 +1594,6 @@ it('passes explicit Agent API enablement and disablement through the dev CLI', a received.push(options); return { close: async () => undefined, - openRuntimeClientSurface: async () => undefined, status: () => ({}) as never, url: 'http://127.0.0.1:4100', }; @@ -2138,7 +1620,6 @@ it('closes the foreground session once when the dev CLI receives a termination s }, startDevServer: async () => ({ close: async () => { closeCalls += 1; }, - openRuntimeClientSurface: async () => undefined, status: () => ({}) as never, url: 'http://127.0.0.1:4100', }), diff --git a/packages/agent-bundle/tests/mcp-app-routes.test.ts b/packages/agent-bundle/tests/mcp-app-routes.test.ts index ee896389e..fd35b9536 100644 --- a/packages/agent-bundle/tests/mcp-app-routes.test.ts +++ b/packages/agent-bundle/tests/mcp-app-routes.test.ts @@ -1,4 +1,4 @@ -import { createServer, request as httpRequest, type IncomingMessage } from 'node:http'; +import { createServer, type IncomingMessage } from 'node:http'; import { Buffer } from 'node:buffer'; import type { AddressInfo } from 'node:net'; @@ -9,11 +9,7 @@ import { type McpAppRoutePreviewService, type McpAppRoutesOptions, } from '../src/dev/mcp-apps/mcp-app-routes.ts'; -import { runtimeAppMessageLimits } from '../src/dev/runtime-app-message-limits.ts'; -import { McpAppRuntimePreviewError } from '../src/dev/mcp-app-runtime-preview-service.ts'; -import type { McpAppRuntimeRoutePreviewService } from '../src/dev/mcp-app-runtime-preview-service.ts'; import type { McpAppBridgeLifecycle, McpAppBridgeMessage } from '../src/dev/mcp-apps/mcp-app-bridge.ts'; -import { deepFreeze } from '../src/core/freeze.ts'; interface StartedRoutes { @@ -249,301 +245,6 @@ it('leaves unrelated MCP paths for the session route handler', async () => { } }); -it('dispatches only the fixed authenticated runtime App create route to the optional runtime lane', async () => { - const calls: unknown[] = []; - const runtime: McpAppRuntimeRoutePreviewService = { - close: async () => undefined, - create: async (request) => { - calls.push(request); - return Object.freeze({ binding: Object.freeze({ id: 'runtime-binding' }), kind: 'fallback' }) as never; - }, - createConsent: async () => { throw new Error('unused'); }, - decideConsent: async () => { throw new Error('unused'); }, - get: () => undefined, - operate: async () => { throw new Error('unused'); }, - }; - const service = Object.assign(new RecordingPreviewService(), { runtime }); - const started = await startRoutes(service); - try { - const response = await fetch(`${started.url}/api/runtime/apps`, { - body: JSON.stringify({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }), - headers: { ...headers(), 'content-type': 'application/json' }, - method: 'POST', - }); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ preview: { binding: { id: 'runtime-binding' }, kind: 'fallback' } }); - expect(calls).toEqual([{ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }]); - expect(started.service.calls).toEqual([]); - } finally { - await started.close(); - } -}); - -it('rejects query-bearing runtime App routes before they reach the preview lane', async () => { - const runtime: McpAppRuntimeRoutePreviewService = { - close: async () => undefined, - create: async () => { throw new Error('runtime create must not receive a query-bearing route'); }, - createConsent: async () => { throw new Error('unused'); }, - decideConsent: async () => { throw new Error('unused'); }, - get: () => undefined, - operate: async () => { throw new Error('unused'); }, - }; - const started = await startRoutes(Object.assign(new RecordingPreviewService(), { runtime })); - try { - const response = await fetch(`${started.url}/api/runtime/apps?attempt=1`, { - body: JSON.stringify({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }), - headers: { ...headers(), 'content-type': 'application/json' }, - method: 'POST', - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ - diagnostic: { code: 'AB8020', message: 'MCP App route path is not valid.' }, - }); - } finally { - await started.close(); - } -}); - -it('authenticates runtime App snapshots before lookup and distinguishes a revoked binding from an unknown id', async () => { - const lookups: string[] = []; - const runtime: McpAppRuntimeRoutePreviewService = { - close: async () => undefined, - create: async () => { throw new Error('unused'); }, - createConsent: async () => { throw new Error('unused'); }, - decideConsent: async () => { throw new Error('unused'); }, - get: (bindingId) => { - lookups.push(bindingId); - return bindingId === 'binding-a' - ? Object.freeze({ binding: Object.freeze({ id: bindingId }), kind: 'fallback' }) as never - : undefined; - }, - isRevoked: (bindingId) => bindingId === 'revoked-a', - operate: async () => { throw new Error('unused'); }, - }; - const started = await startRoutes(Object.assign(new RecordingPreviewService(), { runtime })); - try { - const missingToken = await fetch(`${started.url}/api/runtime/apps/binding-a`, { - headers: { origin: 'http://127.0.0.1:4567' }, - }); - expect(missingToken.status).toBe(403); - const wrongOrigin = await fetch(`${started.url}/api/runtime/apps/binding-a`, { - headers: { 'x-agent-bundle-session': 'test-session-token' }, - }); - expect(wrongOrigin.status).toBe(403); - expect(lookups).toEqual([]); - - const available = await fetch(`${started.url}/api/runtime/apps/binding-a`, { headers: headers() }); - expect(available.status).toBe(200); - expect(available.headers.get('cache-control')).toBe('no-store'); - expect(available.headers.get('x-content-type-options')).toBe('nosniff'); - await expect(available.json()).resolves.toEqual({ preview: { binding: { id: 'binding-a' }, kind: 'fallback' } }); - expect(lookups).toEqual(['binding-a']); - - const revoked = await fetch(`${started.url}/api/runtime/apps/revoked-a`, { headers: headers() }); - expect(revoked.status).toBe(410); - const unknown = await fetch(`${started.url}/api/runtime/apps/unknown-a`, { headers: headers() }); - expect(unknown.status).toBe(404); - } finally { - await started.close(); - } -}); - -it('classifies unavailable runtime App operations as unknown or revoked before delegating', async () => { - const operations: string[] = []; - const runtime: McpAppRuntimeRoutePreviewService = { - close: async () => undefined, - create: async () => { throw new Error('unused'); }, - createConsent: async () => { throw new Error('unused'); }, - decideConsent: async () => { throw new Error('unused'); }, - get: () => undefined, - isRevoked: (bindingId) => bindingId === 'revoked-a', - operate: async (bindingId) => { - operations.push(bindingId); - throw new Error('unavailable runtime operation must not delegate'); - }, - }; - const started = await startRoutes(Object.assign(new RecordingPreviewService(), { runtime })); - try { - for (const [bindingId, status] of [['unknown-a', 404], ['revoked-a', 410]] as const) { - const response = await fetch(`${started.url}/api/runtime/apps/${bindingId}/operations`, { - body: JSON.stringify({ kind: 'tools/list' }), - headers: { ...headers(), 'content-type': 'application/json' }, - method: 'POST', - }); - expect(response.status).toBe(status); - } - expect(operations).toEqual([]); - } finally { - await started.close(); - } -}); - -it('forwards one request-owned abort signal to each admitted runtime App operation', async () => { - const signals: Array = []; - const runtime: McpAppRuntimeRoutePreviewService = { - close: async () => undefined, - create: async () => { throw new Error('unused'); }, - createConsent: async () => { throw new Error('unused'); }, - decideConsent: async () => { throw new Error('unused'); }, - get: (bindingId) => bindingId === 'runtime-binding' - ? Object.freeze({ binding: Object.freeze({ id: bindingId }), kind: 'fallback' }) as never - : undefined, - operate: async (_bindingId, _operation, options?: Readonly<{ readonly signal?: AbortSignal }>) => { - signals.push(options?.signal); - return deepFreeze({ result: { content: Object.freeze([]) } }) as never; - }, - }; - const started = await startRoutes(Object.assign(new RecordingPreviewService(), { runtime })); - try { - const response = await fetch(`${started.url}/api/runtime/apps/runtime-binding/operations`, { - body: JSON.stringify({ kind: 'tools/list' }), - headers: { ...headers(), 'content-type': 'application/json' }, - method: 'POST', - }); - expect(response.status).toBe(200); - expect(signals).toEqual([expect.any(AbortSignal)]); - expect(signals[0]?.aborted).toBe(false); - } finally { - await started.close(); - } -}); - -it('aborts an admitted runtime App operation when its HTTP client disconnects', async () => { - let operationSignal: AbortSignal | undefined; - const runtime: McpAppRuntimeRoutePreviewService = { - close: async () => undefined, - create: async () => { throw new Error('unused'); }, - createConsent: async () => { throw new Error('unused'); }, - decideConsent: async () => { throw new Error('unused'); }, - get: (bindingId) => bindingId === 'runtime-binding' - ? Object.freeze({ binding: Object.freeze({ id: bindingId }), kind: 'fallback' }) as never - : undefined, - operate: async (_bindingId, _operation, options?: Readonly<{ readonly signal?: AbortSignal }>) => new Promise((_resolve, reject) => { - operationSignal = options?.signal; - operationSignal?.addEventListener('abort', () => reject(operationSignal?.reason), { once: true }); - }), - }; - const started = await startRoutes(Object.assign(new RecordingPreviewService(), { runtime })); - try { - const target = new URL(`/api/runtime/apps/runtime-binding/operations`, started.url); - const pending = httpRequest(target, { - headers: { ...headers(), 'content-type': 'application/json' }, - method: 'POST', - }); - pending.on('error', () => undefined); - pending.end(JSON.stringify({ kind: 'tools/list' })); - await eventually(() => operationSignal !== undefined); - pending.destroy(); - await eventually(() => operationSignal?.aborted === true); - expect(operationSignal?.reason).toBeInstanceOf(Error); - } finally { - await started.close(); - } -}); - -it('serializes complete runtime App operation results within the directional UTF-8 transport bound', async () => { - const resultFor = (text: string) => ({ result: { content: [{ text, type: 'text' }] } }); - const maximumBytes = runtimeAppMessageLimits.hostToAppBytes; - const fixedBytes = Buffer.byteLength(JSON.stringify(resultFor('')), 'utf8'); - const exactText = 'x'.repeat(maximumBytes - fixedBytes); - const multibyteText = 'é'.repeat(Math.floor((maximumBytes - fixedBytes - 1) / 2)); - let result = resultFor(exactText); - const runtime: McpAppRuntimeRoutePreviewService = { - close: async () => undefined, - create: async () => { throw new Error('unused'); }, - createConsent: async () => { throw new Error('unused'); }, - decideConsent: async () => { throw new Error('unused'); }, - get: (bindingId) => bindingId === 'runtime-binding' - ? Object.freeze({ binding: Object.freeze({ id: bindingId }), kind: 'fallback' }) as never - : undefined, - operate: async () => result as never, - }; - const started = await startRoutes(Object.assign(new RecordingPreviewService(), { runtime })); - const operation = () => fetch(`${started.url}/api/runtime/apps/runtime-binding/operations`, { - body: JSON.stringify({ kind: 'tools/list' }), - headers: { ...headers(), 'content-type': 'application/json' }, - method: 'POST', - }); - try { - expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBe(maximumBytes); - const exact = await operation(); - expect(exact.status).toBe(200); - expect(exact.headers.get('content-length')).toBe(String(maximumBytes)); - await expect(exact.json()).resolves.toEqual(result); - - result = resultFor(multibyteText); - const multibyteBytes = Buffer.byteLength(JSON.stringify(result), 'utf8'); - expect(multibyteBytes).toBeLessThanOrEqual(maximumBytes); - expect(multibyteBytes).toBeGreaterThan(maximumBytes - 3); - const multibyte = await operation(); - expect(multibyte.status).toBe(200); - expect(multibyte.headers.get('content-length')).toBe(String(multibyteBytes)); - await expect(multibyte.json()).resolves.toEqual(result); - - result = resultFor(`${exactText}x`); - const rejected = await operation(); - expect(rejected.status).toBe(413); - expect(rejected.headers.get('content-length')).toBeNull(); - await expect(rejected.json()).resolves.toEqual({ - diagnostic: { code: 'AB8023', message: 'Runtime MCP App operation response exceeds its transport bound.' }, - }); - } finally { - await started.close(); - } -}); - -it('permits an authenticated DELETE retry for a revoked runtime App cleanup but never for an unknown id', async () => { - const closes: string[] = []; - const runtime: McpAppRuntimeRoutePreviewService = { - close: async (bindingId) => { closes.push(bindingId); }, - create: async () => { throw new Error('unused'); }, - createConsent: async () => { throw new Error('unused'); }, - decideConsent: async () => { throw new Error('unused'); }, - get: () => undefined, - isRevoked: (bindingId) => bindingId === 'revoked-a', - operate: async () => { throw new Error('unused'); }, - }; - const started = await startRoutes(Object.assign(new RecordingPreviewService(), { runtime })); - try { - const retry = await fetch(`${started.url}/api/runtime/apps/revoked-a`, { headers: headers(), method: 'DELETE' }); - expect(retry.status).toBe(200); - await expect(retry.json()).resolves.toEqual({ closed: true }); - - const unknown = await fetch(`${started.url}/api/runtime/apps/unknown-a`, { headers: headers(), method: 'DELETE' }); - expect(unknown.status).toBe(404); - expect(closes).toEqual(['revoked-a']); - } finally { - await started.close(); - } -}); - -it('returns a phase-safe 409 when a runtime App create request names a stale run generation', async () => { - const runtime: McpAppRuntimeRoutePreviewService = { - close: async () => undefined, - create: async () => { throw new McpAppRuntimePreviewError('AB8204', 'Runtime MCP App run generation does not match the expected generation.', 409); }, - createConsent: async () => { throw new Error('unused'); }, - decideConsent: async () => { throw new Error('unused'); }, - get: () => undefined, - operate: async () => { throw new Error('unused'); }, - }; - const started = await startRoutes(Object.assign(new RecordingPreviewService(), { runtime })); - try { - const response = await fetch(`${started.url}/api/runtime/apps`, { - body: JSON.stringify({ expectedGenerationId: 'stale-generation', profileId: 'portable', runId: 'run-a' }), - headers: { ...headers(), 'content-type': 'application/json' }, - method: 'POST', - }); - expect(response.status).toBe(409); - await expect(response.json()).resolves.toEqual({ - diagnostic: { code: 'AB8204', message: 'Runtime MCP App run generation does not match the expected generation.' }, - }); - } finally { - await started.close(); - } -}); - it('exposes only server-created consent challenges and accepts a decision by opaque challenge id', async () => { const started = await startRoutes(); try { diff --git a/packages/agent-bundle/tests/mcp-app-runtime-binding-service.test.ts b/packages/agent-bundle/tests/mcp-app-runtime-binding-service.test.ts deleted file mode 100644 index 22fd46bc5..000000000 --- a/packages/agent-bundle/tests/mcp-app-runtime-binding-service.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { expect, it } from '@rstest/core'; - -import { - McpAppRuntimeBindingService, - type CreateMcpAppRuntimeBindingOptions, -} from '../src/dev/mcp-app-runtime-binding-service.ts'; -import type { DevRuntimeMcpSessionView } from '../src/dev/runtime-provider.ts'; -import type { DevRuntimeMcpAppRunBinding, DevRuntimeMcpOperationRequest, DevRuntimeMcpSessionSnapshot, RuntimeVector } from '../src/dev/runtime-protocol.ts'; -import type { McpAppJsonValue } from '../src/dev/mcp-app-metadata.ts'; - -interface SessionFixture { - readonly executeRequests: DevRuntimeMcpOperationRequest[]; - readonly watcherOrder: string[]; - readonly view: DevRuntimeMcpSessionView; - close(reason?: unknown): Promise; - setReturnedAuthority(authority: Readonly<{ readonly providerSessionId: string; readonly stateStoreId: string }>): void; - setReturnedRevision(revision: number): void; - setReturnedStateVersion(version: number): void; -} - -const runBinding: DevRuntimeMcpAppRunBinding = Object.freeze({ - definitionDigest: 'definitions-a', - registryRevision: 8, - serverDigest: 'server-a', - serverName: 'rsc-agent-runtime', - sessionId: 'mcp-1', - sessionRevision: 3, - target: 'portable', - transportDigest: 'transport-a', -}); - -const runVector: RuntimeVector = Object.freeze({ - providerSessionId: 'provider-private', - runtimeGenerationId: 'g7', - sourceRevision: 'source-a', - stateStoreId: 'state-private', - stateVersion: 4, -}); - -const sessionSnapshot = (): DevRuntimeMcpSessionSnapshot => Object.freeze({ - binding: Object.freeze({ ...runBinding, providerSessionId: 'provider-private', stateStoreId: 'state-private' }), - connection: Object.freeze({ capabilities: undefined, protocolEra: 'modern', protocolVersion: '2026-01-26', server: undefined }), - state: 'ready', -}); - -const createSessionFixture = (options: { readonly closeAtObservation?: boolean } = {}): SessionFixture => { - const listeners = new Set<(reason?: unknown) => Promise | void>(); - const executeRequests: DevRuntimeMcpOperationRequest[] = []; - const watcherOrder: string[] = []; - let closed = false; - let returnedProviderSessionId = 'provider-private'; - let returnedRevision = 3; - let returnedStateStoreId = 'state-private'; - let returnedStateVersion = 4; - return { - executeRequests, - watcherOrder, - view: { - execute: async (request) => { - executeRequests.push(request); - const value: McpAppJsonValue = request.kind === 'read-resource' - ? { contents: [{ mimeType: 'text/html', text: request.uri, type: 'text' }] } - : { content: [{ text: 'current implementation', type: 'text' }] }; - return Object.freeze({ - operationId: `op-${executeRequests.length}`, - sessionId: 'mcp-1', - sessionRevision: returnedRevision, - value, - vector: Object.freeze({ - ...runVector, - providerSessionId: returnedProviderSessionId, - runtimeGenerationId: request.kind === 'read-resource' ? 'g7' : 'g8', - stateStoreId: returnedStateStoreId, - stateVersion: returnedStateVersion, - }), - }); - }, - snapshot: sessionSnapshot, - watchClosed: (listener) => { - watcherOrder.push('watch'); - if (options.closeAtObservation) { - closed = true; - return { closed: true, unsubscribe: () => watcherOrder.push('unsubscribe') }; - } - listeners.add(listener); - return { - closed, - unsubscribe: () => { - watcherOrder.push('unsubscribe'); - listeners.delete(listener); - }, - }; - }, - }, - close: async (reason = new Error('closed')) => { - closed = true; - await Promise.all([...listeners].map((listener) => listener(reason))); - }, - setReturnedRevision: (revision) => { - returnedRevision = revision; - }, - setReturnedAuthority: (authority) => { - returnedProviderSessionId = authority.providerSessionId; - returnedStateStoreId = authority.stateStoreId; - }, - setReturnedStateVersion: (version) => { - returnedStateVersion = version; - }, - }; -}; - -const optionsFor = (fixture: SessionFixture, extra: Partial = {}): CreateMcpAppRuntimeBindingOptions => ({ - profileId: 'portable', - runBinding, - runVector, - session: fixture.view, - ...extra, -}); - -it('derives a binding only from verified run evidence and keeps private provider/state identity out of the serializable snapshot', async () => { - const fixture = createSessionFixture(); - const service = new McpAppRuntimeBindingService(); - const binding = await service.createBinding(optionsFor(fixture)); - - expect(binding).toMatchObject({ - definitionDigest: 'definitions-a', - evidence: 'simulated', - profileId: 'portable', - registryRevision: 8, - runVector: { runtimeGenerationId: 'g7', stateVersion: 4 }, - serverDigest: 'server-a', - serverName: 'rsc-agent-runtime', - sessionId: 'mcp-1', - sessionRevision: 3, - target: 'portable', - transportDigest: 'transport-a', - }); - expect(binding.profileVersion).toBe('agent-bundle:mcp-apps:2026-01-26'); - expect(JSON.stringify(binding)).not.toContain('provider-private'); - expect(JSON.stringify(binding)).not.toContain('state-private'); - expect(Object.isFrozen(binding)).toBe(true); - await expect(service.createBinding(optionsFor(fixture, { - runBinding: { ...runBinding, definitionDigest: 'browser-forged' }, - }))).rejects.toThrow('does not match'); -}); - -it('executes against the stable session revision while retaining the originating run vector and returning the current implementation vector', async () => { - const fixture = createSessionFixture(); - const service = new McpAppRuntimeBindingService(); - const binding = await service.createBinding(optionsFor(fixture)); - - const read = await service.execute(binding.id, { kind: 'read-resource', uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }); - const call = await service.execute(binding.id, { arguments: {}, kind: 'call-tool', name: 'render_edit_timeline' }); - - expect(fixture.executeRequests).toEqual([ - { expectedSessionRevision: 3, kind: 'read-resource', uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }, - { arguments: {}, expectedSessionRevision: 3, kind: 'call-tool', name: 'render_edit_timeline' }, - ]); - expect(read.vector.runtimeGenerationId).toBe('g7'); - expect(call.vector.runtimeGenerationId).toBe('g8'); - expect(service.get(binding.id)?.runVector.runtimeGenerationId).toBe('g7'); - expect(JSON.stringify(call.vector)).not.toContain('provider-private'); - expect(JSON.stringify(call.vector)).not.toContain('state-private'); -}); - -it('rejects an operation that returns another session revision', async () => { - const fixture = createSessionFixture(); - const service = new McpAppRuntimeBindingService(); - const binding = await service.createBinding(optionsFor(fixture)); - fixture.setReturnedRevision(4); - - await expect(service.execute(binding.id, { kind: 'list-tools' })).rejects.toThrow('session revision'); -}); - -it('allows a list operation to report initial state version zero but rejects a foreign provider/state authority', async () => { - const fixture = createSessionFixture(); - const service = new McpAppRuntimeBindingService(); - const binding = await service.createBinding(optionsFor(fixture)); - fixture.setReturnedStateVersion(0); - - await expect(service.execute(binding.id, { kind: 'list-tools' })).resolves.toMatchObject({ - vector: { stateVersion: 0 }, - }); - fixture.setReturnedAuthority({ providerSessionId: 'provider-other', stateStoreId: 'state-other' }); - await expect(service.execute(binding.id, { kind: 'list-resources' })).rejects.toThrow('provider/state authority'); -}); - -it('does not publish a binding when atomic watchClosed observes a closed session', async () => { - const fixture = createSessionFixture({ closeAtObservation: true }); - const service = new McpAppRuntimeBindingService(); - - await expect(service.createBinding(optionsFor(fixture))).rejects.toThrow('closed before'); - expect(fixture.watcherOrder).toEqual(['watch', 'unsubscribe']); -}); - -it('invalidates only the exact session revision, drops views when teardown fails, and never owns the broker session close', async () => { - const first = createSessionFixture(); - const second = createSessionFixture(); - const service = new McpAppRuntimeBindingService(); - const teardownCalls: string[] = []; - const rev3 = await service.createBinding(optionsFor(first, { - onTeardown: () => { - teardownCalls.push('rev3'); - throw new Error('teardown failed'); - }, - })); - const rev4 = await service.createBinding(optionsFor(second, { - runBinding: { ...runBinding, sessionRevision: 4 }, - runVector: { ...runVector, providerSessionId: 'provider-private', stateStoreId: 'state-private' }, - session: { - ...second.view, - snapshot: () => Object.freeze({ - ...sessionSnapshot(), - binding: Object.freeze({ ...sessionSnapshot().binding, sessionRevision: 4 }), - }), - }, - })); - - await expect(service.invalidateBindings({ sessionId: 'mcp-1', sessionRevision: 3 })).rejects.toThrow('teardown failed'); - expect(service.get(rev3.id)).toBeUndefined(); - expect(service.get(rev4.id)).toBe(rev4); - expect(teardownCalls).toEqual(['rev3']); - expect(first.watcherOrder).toEqual(['watch', 'unsubscribe']); - await expect(service.closeBinding(rev4.id)).resolves.toBe(true); - await expect(service.closeBinding(rev4.id)).resolves.toBe(false); - expect(second.watcherOrder).toEqual(['watch', 'unsubscribe']); -}); - -it('releases a runtime binding when the non-owning session view closes and closes all remaining views on broker shutdown', async () => { - const first = createSessionFixture(); - const second = createSessionFixture(); - const service = new McpAppRuntimeBindingService(); - const firstBinding = await service.createBinding(optionsFor(first)); - const secondBinding = await service.createBinding(optionsFor(second)); - - await first.close(); - expect(service.get(firstBinding.id)).toBeUndefined(); - expect(service.get(secondBinding.id)).toBe(secondBinding); - await service.close(); - expect(service.get(secondBinding.id)).toBeUndefined(); -}); - -it('waits for an in-flight operation before invalidation delivers teardown and rejects its stale result', async () => { - let resolveOperation: ((value: { readonly operationId: string; readonly sessionId: string; readonly sessionRevision: number; readonly value: McpAppJsonValue; readonly vector: RuntimeVector }) => void) | undefined; - let teardownDelivered = false; - const service = new McpAppRuntimeBindingService(); - const binding = await service.createBinding({ - onTeardown: () => { - teardownDelivered = true; - }, - profileId: 'portable', - runBinding, - runVector, - session: { - execute: async () => new Promise((resolve) => { - resolveOperation = resolve; - }), - snapshot: sessionSnapshot, - watchClosed: () => ({ closed: false, unsubscribe: () => undefined }), - }, - }); - - const operation = service.execute(binding.id, { kind: 'list-tools' }); - const invalidation = service.invalidateBindings({ sessionId: 'mcp-1', sessionRevision: 3 }); - await Promise.resolve(); - expect(teardownDelivered).toBe(false); - resolveOperation?.({ - operationId: 'op-in-flight', - sessionId: 'mcp-1', - sessionRevision: 3, - value: { content: [] }, - vector: { ...runVector, runtimeGenerationId: 'g8' }, - }); - await expect(operation).rejects.toThrow('closed'); - await invalidation; - expect(teardownDelivered).toBe(true); -}); - -it('joins concurrent session-close, invalidation, and service-close releases through one teardown failure', async () => { - const fixture = createSessionFixture(); - let rejectTeardown: ((error: Error) => void) | undefined; - let teardownStarted = false; - const service = new McpAppRuntimeBindingService(); - await service.createBinding(optionsFor(fixture, { - onTeardown: () => new Promise((_resolve, reject) => { - teardownStarted = true; - rejectTeardown = reject; - }), - })); - - const sessionClose = fixture.close(); - await Promise.resolve(); - expect(teardownStarted).toBe(true); - let invalidationSettled = false; - let shutdownSettled = false; - const invalidation = service.invalidateBindings({ sessionId: 'mcp-1', sessionRevision: 3 }).finally(() => { - invalidationSettled = true; - }); - const shutdown = service.close().finally(() => { - shutdownSettled = true; - }); - await Promise.resolve(); - expect(invalidationSettled).toBe(false); - expect(shutdownSettled).toBe(false); - rejectTeardown?.(new Error('teardown failed')); - await expect(sessionClose).rejects.toThrow('teardown failed'); - await expect(invalidation).rejects.toThrow('teardown failed'); - await expect(shutdown).rejects.toThrow('teardown failed'); - await expect(service.closeBinding('missing-binding')).resolves.toBe(false); -}); - -it('forgets failed release entries after concurrent joiners settle', async () => { - const fixture = createSessionFixture(); - const service = new McpAppRuntimeBindingService(); - const binding = await service.createBinding(optionsFor(fixture, { - onTeardown: () => { - throw new Error('teardown failed'); - }, - })); - - const first = service.closeBinding(binding.id); - const second = service.invalidateBindings({ sessionId: 'mcp-1', sessionRevision: 3 }); - await expect(first).rejects.toThrow('teardown failed'); - await expect(second).rejects.toThrow('teardown failed'); - await expect(service.closeBinding(binding.id)).resolves.toBe(false); - await expect(service.invalidateBindings({ sessionId: 'mcp-1', sessionRevision: 3 })).resolves.toBeUndefined(); -}); diff --git a/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts b/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts deleted file mode 100644 index deabb2450..000000000 --- a/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts +++ /dev/null @@ -1,608 +0,0 @@ -import { expect, it } from '@rstest/core'; - -import { - McpAppRuntimePreviewService, - type McpAppRuntimeOperationClock, - type McpAppRuntimePreviewServiceOptions, -} from '../src/dev/mcp-app-runtime-preview-service.ts'; -import { McpAppRuntimeBindingService } from '../src/dev/mcp-app-runtime-binding-service.ts'; -import type { DevRuntimeClientSurfaceProxyBinding, DevRuntimeMcpRegistryMessage, DevRuntimeMcpSessionView, DevRuntimeSession } from '../src/dev/runtime-provider.ts'; -import type { DevRuntimeMcpOperationRequest, DevRuntimeMcpOperationResult, DevRuntimeMcpSessionSnapshot, DevRuntimeRun, RuntimeVector } from '../src/dev/runtime-protocol.ts'; -import type { JsonValue } from '../src/dev/types.ts'; - -const vector: RuntimeVector = Object.freeze({ - providerSessionId: 'provider-private', - runtimeGenerationId: 'generation-a', - sourceRevision: 'source-a', - stateStoreId: 'state-private', - stateVersion: 0, -}); - -const run = Object.freeze({ - completedAt: '2026-08-15T00:00:01.000Z', - id: 'run-a', - input: Object.freeze({ city: 'Paris' }), - result: Object.freeze({ - app: Object.freeze({ - mcpBinding: Object.freeze({ - definitionDigest: 'definition-a', registryRevision: 3, serverDigest: 'server-a', serverName: 'weather', - sessionId: 'session-a', sessionRevision: 2, target: 'portable', transportDigest: 'transport-a', - }), - resourceUri: 'ui://weather/forecast.html', - surfaceId: 'mcp.edit-weather', - }), - protocol: Object.freeze({ content: Object.freeze([{ text: 'Sunny', type: 'text' }]) }), - state: Object.freeze({ identity: Object.freeze({ stateStoreId: 'state-private', stateVersion: 0 }) }), - trace: Object.freeze([]), - tree: Object.freeze([]), - }), - startedAt: '2026-08-15T00:00:00.000Z', - status: 'succeeded' as const, - surfaceId: 'mcp.render_weather', - target: 'portable', - vector, -} satisfies DevRuntimeRun); - -const snapshot = (): DevRuntimeMcpSessionSnapshot => Object.freeze({ - binding: Object.freeze({ - ...run.result.app.mcpBinding, - providerSessionId: 'provider-private', - stateStoreId: 'state-private', - }), - connection: Object.freeze({ - capabilities: Object.freeze({ resources: Object.freeze({}), tools: Object.freeze({}) }), - protocolEra: 'modern' as const, - protocolVersion: '2026-01-26', - server: Object.freeze({ name: 'weather', version: '1.0.0' }), - }), - state: 'ready' as const, -}); - -type SessionViewOptions = Readonly<{ - readonly csp?: JsonValue; - readonly execute?: ( - request: DevRuntimeMcpOperationRequest, - options: Readonly<{ readonly signal?: AbortSignal }> | undefined, - fallback: () => DevRuntimeMcpOperationResult, - ) => Promise; - readonly permissions?: JsonValue; -}>; - -const createSessionView = ( - requests: DevRuntimeMcpOperationRequest[], - options: SessionViewOptions = {}, -): DevRuntimeMcpSessionView => ({ - execute: async (request, operationOptions) => { - requests.push(request); - const value: JsonValue = request.kind === 'list-tools' - ? [ - { _meta: { ui: { resourceUri: 'ui://weather/other.html', visibility: ['app'] } }, name: 'foreign-app' }, - { _meta: { ui: { resourceUri: 'ui://weather/forecast.html', visibility: ['model'] } }, name: 'model-only-app' }, - { _meta: { ui: { resourceUri: 'ui://weather/forecast.html', visibility: ['app'] } }, name: 'show-weather' }, - ] - : request.kind === 'list-resources' - ? [{ _meta: { ui: { resourceUri: 'ui://weather/forecast.html' } }, mimeType: 'text/html;profile=mcp-app', uri: 'ui://weather/forecast.html' }] - : request.kind === 'read-resource' - ? { contents: [{ _meta: { ui: { - ...(options.csp === undefined ? {} : { csp: options.csp }), - ...(options.permissions === undefined ? {} : { permissions: options.permissions }), - } }, mimeType: 'text/html;profile=mcp-app', text: '
Weather
', uri: request.uri }] } - : { content: [{ text: 'called', type: 'text' }] }; - const fallback = (): DevRuntimeMcpOperationResult => Object.freeze({ operationId: `op-${requests.length}`, sessionId: 'session-a', sessionRevision: 2, value, vector }); - return options.execute === undefined ? fallback() : options.execute(request, operationOptions, fallback); - }, - snapshot, - watchClosed: () => Object.freeze({ closed: false, unsubscribe: () => undefined }), -}); - -const createRuntimeFixture = (options: Readonly<{ - readonly closeBinding?: () => Promise; - readonly closeProxy?: () => Promise; - readonly csp?: JsonValue; - readonly execute?: SessionViewOptions['execute']; - readonly failProxyOpen?: boolean; - readonly openRuntimeClientSurface?: (surfaceId: string, ...policy: readonly unknown[]) => Promise; - readonly operationClock?: McpAppRuntimeOperationClock; - readonly permissions?: JsonValue; -}> = {}) => { - const requests: DevRuntimeMcpOperationRequest[] = []; - const openedClientSurfaces: string[] = []; - const openedClientSurfacePolicies: unknown[] = []; - const controls: string[] = []; - const emitted: unknown[] = []; - let listener: ((message: DevRuntimeMcpRegistryMessage) => void) | undefined; - const view = createSessionView(requests, options); - const runtime = { - clientSurface: () => undefined, - close: async () => { controls.push('close'); }, - invoke: async () => run, - mcpRegistry: Object.freeze({ - close: async () => undefined, - closeSession: async () => { controls.push('closeSession'); }, - open: async () => { controls.push('open'); return view as never; }, - reconcile: async () => { throw new Error('unused'); }, - restart: async () => { controls.push('restart'); throw new Error('unused'); }, - session: (id: string) => id === 'session-a' ? view : undefined, - snapshot: () => undefined, - subscribe: (_options: unknown, next: (message: DevRuntimeMcpRegistryMessage) => void) => { - listener = next; - return Object.freeze({ unsubscribe: () => undefined }); - }, - }), - providerSessionId: 'provider-private', - readAsset: async () => undefined, - readRunFlight: async () => undefined, - reconcilePreparedRuntime: async () => undefined, - replay: async () => run, - resetState: async () => ({ stateStoreId: 'state-private', stateVersion: 0 }), - run: (id: string) => id === run.id ? run : undefined, - runs: () => [run], - status: () => ({ descriptor: { environmentVariables: [], id: 'fixture', label: 'Fixture', schemaVersion: 1 }, diagnostics: [], hmrReady: true, state: 'active' as const }), - surfaces: () => [], - } satisfies DevRuntimeSession; - const bindingAuthority = new McpAppRuntimeBindingService(); - if (options.closeBinding !== undefined) { - const closeBinding = bindingAuthority.closeBinding.bind(bindingAuthority); - bindingAuthority.closeBinding = async (bindingId) => { - await options.closeBinding?.(); - return closeBinding(bindingId); - }; - } - const serviceOptions: McpAppRuntimePreviewServiceOptions = { - bindingAuthority, - configExtensions: () => Object.freeze({ descriptors: [], extensions: Object.freeze({}), projectRoot: '/project', sourceRevision: 'source-a' }), - emit: (details) => { emitted.push(details); }, - openRuntimeClientSurface: async (surfaceId, ...policy) => { - openedClientSurfaces.push(surfaceId); - openedClientSurfacePolicies.push(policy[0]); - if (options.failProxyOpen === true) throw new Error('proxy open failed'); - if (options.openRuntimeClientSurface !== undefined) return options.openRuntimeClientSurface(surfaceId, ...policy); - return Object.freeze({ bootstrapUrl: `http://proxy.test/${surfaceId}`, close: options.closeProxy ?? (async () => undefined), origin: 'http://proxy.test', surfaceId }); - }, - runtime, - ...(options.operationClock === undefined ? {} : { operationClock: options.operationClock }), - }; - const service = new McpAppRuntimePreviewService(serviceOptions); - return Object.freeze({ - controls, - emitted, - openedClientSurfacePolicies, - openedClientSurfaces, - requests, - service, - deliver: (message: DevRuntimeMcpRegistryMessage) => listener?.(message), - }); -}; - -const eventually = async (predicate: () => boolean): Promise => { - for (let attempt = 0; attempt < 50; attempt += 1) { - if (predicate()) return; - await new Promise((resolvePromise) => setImmediate(resolvePromise)); - } - throw new Error('Timed out waiting for runtime MCP App preview state.'); -}; - -const deferred = (): Readonly<{ - readonly promise: Promise; - readonly reject: (reason?: unknown) => void; - readonly resolve: (value: Value) => void; -}> => { - let reject!: (reason?: unknown) => void; - let resolve!: (value: Value) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return Object.freeze({ promise, reject, resolve }); -}; - -class ControlledClock { - #now = 0; - #next = 0; - readonly #timers = new Map void; readonly due: number }>>(); - readonly delays: number[] = []; - - clearTimeout(id: ReturnType): void { this.#timers.delete(id as unknown as number); } - - setTimeout(callback: () => void, milliseconds: number): ReturnType { - const id = ++this.#next; - this.delays.push(milliseconds); - this.#timers.set(id, Object.freeze({ callback, due: this.#now + milliseconds })); - return id as unknown as ReturnType; - } - - advance(milliseconds: number): void { - const deadline = this.#now + milliseconds; - while (true) { - const next = [...this.#timers.entries()] - .filter(([, timer]) => timer.due <= deadline) - .sort(([left], [right]) => left - right)[0]; - if (next === undefined) break; - const [id, timer] = next; - this.#timers.delete(id); - this.#now = timer.due; - timer.callback(); - } - this.#now = deadline; - } -} - -it('derives an Apps preview only from one stored succeeded run and opens its distinct client surface', async () => { - const { controls, openedClientSurfaces, requests, service } = createRuntimeFixture(); - - const preview = await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - - expect(preview).toMatchObject({ - binding: { sessionId: 'session-a', sessionRevision: 2 }, - clientSurface: { bootstrapUrl: 'http://proxy.test/mcp.edit-weather', origin: 'http://proxy.test' }, - kind: 'apps', - result: { isError: false, modelVisible: { content: [{ text: 'Sunny', type: 'text' }] } }, - session: { state: 'ready' }, - }); - if (preview.profile.kind !== 'apps') throw new Error('Expected an admitted Apps host profile.'); - expect(preview.metadata.tool.standard.ui).toEqual({ resourceUri: 'ui://weather/forecast.html', visibility: ['app'] }); - expect(preview.profile.hostContext.platform).toBe('web'); - expect(requests).toEqual([ - { expectedSessionRevision: 2, kind: 'list-tools' }, - { expectedSessionRevision: 2, kind: 'list-resources' }, - { expectedSessionRevision: 2, kind: 'read-resource', uri: 'ui://weather/forecast.html' }, - ]); - expect(controls).toEqual([]); - expect(openedClientSurfaces).toEqual(['mcp.edit-weather']); - expect(JSON.stringify(preview)).not.toContain('provider-private'); - expect(JSON.stringify(preview)).not.toContain('state-private'); - await expect(service.create({ expectedGenerationId: 'generation-b', profileId: 'portable', runId: 'run-a' })).rejects.toThrow('generation'); -}); - -it('derives one closed child CSP before proxy acquisition while retaining declared permissions as unapproved evidence', async () => { - const { openedClientSurfacePolicies, service } = createRuntimeFixture({ - csp: { connectDomains: ['https://api.weather.test', 'not-a-canonical-origin'] }, - permissions: { camera: {} }, - }); - - const preview = await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - expect(openedClientSurfacePolicies).toEqual([{ - contentSecurityPolicy: "default-src 'none'; base-uri 'self'; connect-src https://api.weather.test; frame-src 'none'; img-src data:; media-src 'none'; font-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'", - }]); - if (preview.kind !== 'apps') throw new Error('Expected an admitted Apps preview.'); - expect(preview.documentPolicy).toMatchObject({ allow: '', approvedPermissions: {}, revision: 1 }); - expect(preview.documentPolicy.warnings).toEqual([{ code: 'csp-source-rejected', value: 'not-a-canonical-origin' }]); -}); - -it('subscribes to registry invalidations, revokes exactly once, and fails closed after a replay gap', async () => { - const { controls, deliver, emitted, service } = createRuntimeFixture(); - const preview = await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - - deliver(Object.freeze({ - action: 'sessions-restarted' as const, - invalidatedBindings: Object.freeze([{ sessionId: 'session-a', sessionRevision: 2 }]), - registryRevision: 4, - restartedSessionIds: Object.freeze(['session-a']), - runtimeGenerationId: 'generation-b', - sequence: 1, - })); - await eventually(() => service.get(preview.binding.id) === undefined); - expect(emitted).toEqual([{ - bindingId: preview.binding.id, - reason: 'session-restarted', - sessionId: 'session-a', - sessionRevision: 2, - state: 'revoked', - }]); - expect(controls).toEqual([]); - - deliver(Object.freeze({ earliestAvailableSequence: 4, latestDroppedSequence: 3, requestedAfterSequence: 0, type: 'replay.gap' as const })); - await eventually(() => emitted.length === 1); - await expect(service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' })).rejects.toThrow('not available'); -}); - -it('binds a call-tool consent grant to one exact operation and rejects a browser-selected document scope', async () => { - const { requests, service } = createRuntimeFixture(); - const preview = await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - await expect(service.operate(preview.binding.id, { kind: 'tools/call', name: 'show-weather' })).rejects.toThrow('requires an approved consent'); - await expect(service.createConsent(preview.binding.id, { - actionFingerprint: 'browser-non-authority', capability: 'call-tool', details: { arguments: {}, name: 'show-weather' }, scope: 'document', summary: 'forged scope', - })).rejects.toThrow('consent request is invalid'); - - const created = await service.createConsent(preview.binding.id, { - actionFingerprint: 'browser-non-authority', capability: 'call-tool', details: { arguments: {}, name: 'show-weather' }, scope: 'action', summary: 'forged summary is not authority', - }); - expect(created.documentPolicy.revision).toBe(1); - const decision = await service.decideConsent(preview.binding.id, created.challenge.id, 'allow-once'); - expect(decision.grant).toMatchObject({ bindingId: preview.binding.id, capability: 'call-tool', scope: 'action' }); - await expect(service.operate(preview.binding.id, { - consentId: decision.grant?.authorizationId, - kind: 'tools/call', - name: 'show-weather', - })).resolves.toMatchObject({ result: { operationId: 'op-4' } }); - await expect(service.operate(preview.binding.id, { - consentId: decision.grant?.authorizationId, - kind: 'tools/call', - name: 'show-weather', - })).rejects.toThrow('requires an approved consent'); - expect(requests.at(-1)).toEqual({ arguments: {}, expectedSessionRevision: 2, kind: 'call-tool', name: 'show-weather' }); -}); - -it('times out and releases hung Runtime App operations without waiting for late provider settlement', async () => { - const clock = new ControlledClock(); - const signals: Array = []; - const late = Array.from({ length: 5 }, () => deferred()); - let initial = 0; - let pending = 0; - const { service } = createRuntimeFixture({ - execute: async (_request, options, fallback) => { - if (initial < 3) { - initial += 1; - return fallback(); - } - const held = late[pending]!; - pending += 1; - signals.push(options?.signal); - return held.promise; - }, - operationClock: clock, - }); - const preview = await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - const operations = Array.from({ length: 4 }, () => service.operate(preview.binding.id, { - kind: 'resources/read' as const, - uri: 'ui://weather/forecast.html', - })); - - await eventually(() => signals.length === 4); - expect(clock.delays).toEqual([30_000, 30_000, 30_000, 30_000]); - await expect(service.operate(preview.binding.id, { - kind: 'resources/read', uri: 'ui://weather/forecast.html', - })).rejects.toThrow('operation limit reached'); - - clock.advance(30_000); - for (const operation of operations) { - await expect(operation).rejects.toMatchObject({ - code: 'AB8023', - message: 'Runtime MCP App operation exceeded its 30 second deadline.', - status: 502, - }); - } - expect(signals).toEqual([expect.any(AbortSignal), expect.any(AbortSignal), expect.any(AbortSignal), expect.any(AbortSignal)]); - expect(signals.every((signal) => signal?.aborted === true)).toBe(true); - - const reclaimed = service.operate(preview.binding.id, { kind: 'resources/read', uri: 'ui://weather/forecast.html' }); - await eventually(() => signals.length === 5); - await expect(service.close(preview.binding.id)).resolves.toBeUndefined(); - await expect(reclaimed).rejects.toThrow('cancelled'); - expect(signals.every((signal) => signal?.aborted === true)).toBe(true); - - late[0]!.reject(new Error('late provider rejection')); - for (const held of late.slice(1)) held.resolve(Object.freeze({ - operationId: 'late-operation', sessionId: 'session-a', sessionRevision: 2, value: Object.freeze({ content: [] }), vector, - })); - await Promise.allSettled(late.map((held) => held.promise)); - expect(service.get(preview.binding.id)).toBeUndefined(); -}); - -it('reclaims a hung Runtime App operation when its caller aborts', async () => { - const clock = new ControlledClock(); - const held = Array.from({ length: 2 }, () => deferred()); - const signals: AbortSignal[] = []; - let aborts = 0; - let initial = 0; - let pending = 0; - const { service } = createRuntimeFixture({ - execute: async (_request, options, fallback) => { - if (initial < 3) { - initial += 1; - return fallback(); - } - const signal = options?.signal; - if (signal === undefined) throw new Error('Runtime App operation did not receive a cancellation signal.'); - signals.push(signal); - signal.addEventListener('abort', () => { aborts += 1; }, { once: true }); - return held[pending++]!.promise; - }, - operationClock: clock, - }); - const preview = await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - const caller = new AbortController(); - const operation = service.operate(preview.binding.id, { - kind: 'resources/read', uri: 'ui://weather/forecast.html', - }, Object.freeze({ signal: caller.signal })); - await eventually(() => signals.length === 1); - const reason = new DOMException('Caller cancelled the Runtime App operation.', 'AbortError'); - caller.abort(reason); - await expect(operation).rejects.toBe(reason); - expect(aborts).toBe(1); - - const reclaimed = service.operate(preview.binding.id, { kind: 'resources/read', uri: 'ui://weather/forecast.html' }); - await eventually(() => signals.length === 2); - await expect(service.close(preview.binding.id)).resolves.toBeUndefined(); - await expect(reclaimed).rejects.toThrow('cancelled'); - expect(aborts).toBe(2); - - held[0]!.resolve(Object.freeze({ operationId: 'late-cancelled', sessionId: 'session-a', sessionRevision: 2, value: Object.freeze({ content: [] }), vector })); - held[1]!.reject(new Error('late cancelled provider rejection')); - await Promise.allSettled(held.map((pendingOperation) => pendingOperation.promise)); -}); - -it('retains only the frozen App-visible catalog and rejects hidden actions before provider execution', async () => { - const { requests, service } = createRuntimeFixture(); - const preview = await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - - await expect(service.operate(preview.binding.id, { kind: 'tools/list' })).resolves.toMatchObject({ - result: { value: { tools: [{ name: 'show-weather' }] } }, - }); - await expect(service.operate(preview.binding.id, { kind: 'resources/list' })).resolves.toMatchObject({ - result: { value: { resources: [{ uri: 'ui://weather/forecast.html' }] } }, - }); - await expect(service.operate(preview.binding.id, { - kind: 'resources/read', uri: 'ui://weather/other.html', - })).rejects.toThrow('not in the binding catalog'); - - for (let attempt = 0; attempt < 9; attempt += 1) { - await expect(service.createConsent(preview.binding.id, { - actionFingerprint: `server-fingerprint-${attempt}`, capability: 'call-tool', details: { arguments: {}, name: 'foreign-app' }, scope: 'action', summary: 'foreign App tool', - })).rejects.toThrow('not in the binding catalog'); - } - await expect(service.createConsent(preview.binding.id, { - actionFingerprint: 'visible-after-hidden', capability: 'call-tool', details: { arguments: {}, name: 'show-weather' }, scope: 'action', summary: 'visible App tool', - })).resolves.toMatchObject({ challenge: { request: { capability: 'call-tool' } } }); - expect(requests).toHaveLength(3); -}); - -it('persists only effective declared document policy approvals in the binding snapshot', async () => { - const undeclared = createRuntimeFixture(); - const undeclaredPreview = await undeclared.service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - const ignored = await undeclared.service.createConsent(undeclaredPreview.binding.id, { - actionFingerprint: 'ignored-camera', capability: 'camera', details: {}, scope: 'document', summary: 'undeclared camera', - }); - const ignoredDecision = await undeclared.service.decideConsent(undeclaredPreview.binding.id, ignored.challenge.id, 'allow-once'); - expect(ignoredDecision.documentPolicy.revision).toBe(1); - expect(undeclared.service.get(undeclaredPreview.binding.id)).toMatchObject({ documentPolicy: { revision: 1 } }); - - const declared = createRuntimeFixture({ permissions: { camera: {} } }); - const declaredPreview = await declared.service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - const accepted = await declared.service.createConsent(declaredPreview.binding.id, { - actionFingerprint: 'accepted-camera', capability: 'camera', details: {}, scope: 'document', summary: 'declared camera', - }); - const acceptedDecision = await declared.service.decideConsent(declaredPreview.binding.id, accepted.challenge.id, 'allow-once'); - expect(acceptedDecision.documentPolicy).toMatchObject({ revision: 2 }); - expect(declared.service.get(declaredPreview.binding.id)).toMatchObject({ documentPolicy: { revision: 2 } }); -}); - -it('awaits matching session-close App cleanup after publishing its terminal invalidation', async () => { - let releaseProxy: (() => void) | undefined; - const { emitted, service } = createRuntimeFixture({ - closeProxy: async () => { - await new Promise((resolvePromise) => { releaseProxy = resolvePromise; }); - }, - }); - const preview = await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - - let settled = false; - const closing = service.closeSession('session-a', 2).then(() => { settled = true; }); - await eventually(() => releaseProxy !== undefined); - expect(settled).toBe(false); - expect(service.get(preview.binding.id)).toBeUndefined(); - expect(emitted).toEqual([expect.objectContaining({ bindingId: preview.binding.id, reason: 'session-closed', state: 'revoked' })]); - releaseProxy?.(); - await closing; - expect(settled).toBe(true); -}); - -it('joins a late runtime client-surface acquisition before manual session cleanup returns', async () => { - let resolveProxy: ((proxy: DevRuntimeClientSurfaceProxyBinding) => void) | undefined; - let proxyCloseCalls = 0; - let surfaceOpenCalls = 0; - const { service } = createRuntimeFixture({ - openRuntimeClientSurface: async () => new Promise((resolvePromise) => { - surfaceOpenCalls += 1; - resolveProxy = resolvePromise; - }), - }); - const creating = service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - await eventually(() => surfaceOpenCalls === 1 && resolveProxy !== undefined); - - let closed = false; - const closing = service.closeSession('session-a', 2).then(() => { closed = true; }); - await new Promise((resolvePromise) => setImmediate(resolvePromise)); - expect(closed).toBe(false); - - resolveProxy?.(Object.freeze({ - bootstrapUrl: 'http://proxy.test/app.weather', - close: async () => { proxyCloseCalls += 1; }, - origin: 'http://proxy.test', - surfaceId: 'app.weather', - })); - await closing; - await expect(creating).rejects.toThrow('stable session changed'); - expect(proxyCloseCalls).toBe(1); - await expect(service.closeAll()).resolves.toBeUndefined(); - expect(proxyCloseCalls).toBe(1); -}); - -it('logically revokes before retaining failed runtime preview cleanup for an explicit retry', async () => { - let closeAttempts = 0; - const { emitted, service } = createRuntimeFixture({ - closeProxy: async () => { - closeAttempts += 1; - if (closeAttempts === 1) throw new Error('proxy close failed'); - }, - }); - const preview = await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - await expect(service.close(preview.binding.id)).rejects.toBeInstanceOf(AggregateError); - expect(service.get(preview.binding.id)).toBeUndefined(); - expect(service.isRevoked(preview.binding.id)).toBe(true); - expect(emitted).toEqual([expect.objectContaining({ bindingId: preview.binding.id, reason: 'manual-close', state: 'revoked' })]); - await expect(service.close(preview.binding.id)).resolves.toBeUndefined(); - expect(closeAttempts).toBe(2); - expect(service.get(preview.binding.id)).toBeUndefined(); - expect(emitted).toHaveLength(1); -}); - -it('aggregates binding and proxy cleanup failures while retaining both for retry', async () => { - let bindingAttempts = 0; - let proxyAttempts = 0; - const { service } = createRuntimeFixture({ - closeBinding: async () => { - bindingAttempts += 1; - if (bindingAttempts === 1) throw new Error('binding close failed'); - }, - closeProxy: async () => { - proxyAttempts += 1; - if (proxyAttempts === 1) throw new Error('proxy close failed'); - }, - }); - const preview = await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - - let failure: unknown; - try { - await service.close(preview.binding.id); - } catch (error) { - failure = error; - } - expect(failure).toBeInstanceOf(AggregateError); - expect((failure as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'binding close failed' }), - expect.objectContaining({ message: 'proxy close failed' }), - ]); - await expect(service.close(preview.binding.id)).resolves.toBeUndefined(); - expect({ bindingAttempts, proxyAttempts }).toEqual({ bindingAttempts: 2, proxyAttempts: 2 }); -}); - -it('retains a provisional create cleanup after both creation and release fail', async () => { - let closeAttempts = 0; - const { service } = createRuntimeFixture({ - closeBinding: async () => { - closeAttempts += 1; - if (closeAttempts === 1) throw new Error('binding cleanup failed'); - }, - failProxyOpen: true, - }); - - await expect(service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' })) - .rejects.toBeInstanceOf(AggregateError); - await expect(service.closeAll()).resolves.toBeUndefined(); - expect(closeAttempts).toBe(2); -}); - -it('closeAll retains every runtime preview cleanup cause instead of the first rejection', async () => { - let closeAttempts = 0; - const { service } = createRuntimeFixture({ - closeProxy: async () => { - closeAttempts += 1; - throw new Error(`proxy cleanup ${closeAttempts}`); - }, - }); - await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - await service.create({ expectedGenerationId: 'generation-a', profileId: 'portable', runId: 'run-a' }); - - let failure: unknown; - try { - await service.closeAll(); - } catch (error) { - failure = error; - } - expect(failure).toBeInstanceOf(AggregateError); - expect((failure as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'proxy cleanup 1' }), - expect.objectContaining({ message: 'proxy cleanup 2' }), - ]); -}); diff --git a/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts b/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts index a6a5a0e4d..14905a7d3 100644 --- a/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts +++ b/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts @@ -25,8 +25,6 @@ const expectedTree = `packages/ tests/playground-service.test.ts tests/runtime-provider.test.ts workbench/ - src/mcp/runtime-consent-dialog.tsx - src/mcp/runtime-consent-queue.ts src/runtime-model.ts tests/runtime-consent-dialog.test.ts tests/runtime-consent-queue.test.ts @@ -68,8 +66,6 @@ describe('rsc runtime topology script', () => { 'packages/agent-bundle/tests/normalization.test.ts', 'packages/agent-bundle/tests/playground-service.test.ts', 'packages/agent-bundle/tests/runtime-provider.test.ts', - 'packages/workbench/src/mcp/runtime-consent-dialog.tsx', - 'packages/workbench/src/mcp/runtime-consent-queue.ts', 'packages/workbench/src/runtime-model.ts', 'packages/workbench/tests/runtime-consent-dialog.test.ts', 'packages/workbench/tests/runtime-consent-queue.test.ts', diff --git a/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts b/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts deleted file mode 100644 index 2656e2a0e..000000000 --- a/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts +++ /dev/null @@ -1,1280 +0,0 @@ -import { createServer, get as httpGet, globalAgent, type IncomingMessage, type ServerResponse } from 'node:http'; -import type { AddressInfo } from 'node:net'; - -import { expect, it } from '@rstest/core'; -import WebSocket from 'ws'; - -import { - RuntimeClientSurfaceProxy as RuntimeClientSurfaceProxyImplementation, - runtimeClientSurfaceReloadChannelPath, - type DevRuntimeClientSurfaceEndpoint, -} from '../src/dev/index.ts'; -import { runtimeAppMessageLimits } from '../src/dev/runtime-app-message-limits.ts'; -import { - defaultRuntimeClientSurfaceUpstreamRequestTimeoutMs, - type RuntimeClientSurfaceProxyOptions, -} from '../src/dev/runtime-client-surface-proxy.ts'; - -const foregroundOrigin = 'http://127.0.0.1:41999'; -const noopSubscribeReload = (): (() => void) => () => undefined; -const reloadFrame = (generation: number): string => JSON.stringify({ generation, kind: 'runtime-app-reload' }); -/** - * Deadline tests bound the upstream far below the production default: long - * enough for the loopback bootstrap fetch that shares the bound, short enough - * that the file no longer waits on the real 15 s. - */ -const shortUpstreamRequestTimeout: RuntimeClientSurfaceProxyOptions = Object.freeze({ upstreamRequestTimeoutMs: 500 }); - -/** Provider-side reload authority stub: the trusted channel the proxy relays. */ -const createReloadSource = () => { - const listeners = new Set<() => void>(); - return Object.freeze({ - emit: (): void => { for (const listener of [...listeners]) listener(); }, - listenerCount: (): number => listeners.size, - subscribeReload: (listener: () => void): (() => void) => { - listeners.add(listener); - return () => { listeners.delete(listener); }; - }, - }); -}; -const RuntimeClientSurfaceProxy = Object.freeze({ - open: ( - input: DevRuntimeClientSurfaceEndpoint, - listener: Parameters[1], - options?: RuntimeClientSurfaceProxyOptions, - ) => RuntimeClientSurfaceProxyImplementation.open(input, listener, foregroundOrigin, undefined, options), -}); - -const listen = async (server: ReturnType): Promise => { - await new Promise((resolvePromise) => server.listen({ host: '127.0.0.1', port: 0 }, resolvePromise)); - const address = server.address() as AddressInfo; - return `http://127.0.0.1:${address.port}`; -}; - -const listenIpv6 = async (server: ReturnType): Promise => new Promise((resolvePromise, rejectPromise) => { - const failed = (error: Error): void => { - server.off('listening', listening); - if ((error as NodeJS.ErrnoException).code === 'EADDRNOTAVAIL') { - resolvePromise(undefined); - return; - } - rejectPromise(error); - }; - const listening = (): void => { - server.off('error', failed); - const address = server.address() as AddressInfo; - resolvePromise(`http://[::1]:${address.port}`); - }; - server.once('error', failed); - server.once('listening', listening); - server.listen({ host: '::1', port: 0 }); -}); - -const close = async (server: ReturnType): Promise => new Promise((resolvePromise, rejectPromise) => { - server.close((error) => error === undefined ? resolvePromise() : rejectPromise(error)); -}); - -const within = async (promise: Promise, milliseconds: number): Promise => Promise.race([ - promise, - new Promise((_resolvePromise, rejectPromise) => { - setTimeout(() => rejectPromise(new Error(`Timed out after ${milliseconds}ms.`)), milliseconds); - }), -]); - -const bootstrapCookie = async (binding: Awaited>): Promise => { - const response = await fetch(binding.bootstrapUrl, { redirect: 'manual' }); - expect(response.status).toBe(200); - return response.headers.get('set-cookie')!.split(';', 1)[0]!; -}; - -const canonicalEntry = '
runtime App
'; - -const serveBootstrapEntry = ( - request: IncomingMessage, - response: ServerResponse, - path = '/app/index.html', -): boolean => { - if (request.url !== path) return false; - response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(canonicalEntry); - return true; -}; - -type RuntimeProxyShellHarnessOptions = Readonly<{ - readonly fetch?: typeof globalThis.fetch; -}>; - -const runtimeProxyShellHarness = async ( - binding: Awaited>, - options: RuntimeProxyShellHarnessOptions = {}, -) => { - const bootstrap = await fetch(binding.bootstrapUrl, { redirect: 'manual' }); - expect(bootstrap.status).toBe(200); - const source = /'; - const refreshedEntry = ''; - const upstream = createServer((request, response) => { - if (request.url === '/app/index.html') { - response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(initialEntry); - return; - } - response.writeHead(404).end(); - }); - const origin = await listen(upstream); - const childPolicy = "default-src 'none'; script-src 'unsafe-inline' \"<&"; - const binding = await RuntimeClientSurfaceProxyImplementation.open({ - entryPath: '/app/index.html', - httpOrigin: origin, - httpPathPrefixes: ['/app/'], - surfaceId: 'app.weather', - subscribeReload: noopSubscribeReload, - }, () => undefined, foregroundOrigin, Object.freeze({ contentSecurityPolicy: childPolicy }) as never); - try { - const shell = await runtimeProxyShellHarness(binding, { - fetch: async () => new Response(refreshedEntry, { - headers: { 'content-type': 'text/html; charset=utf-8' }, - }), - }); - const prefix = ''; - expect(shell.entries).toEqual([`${prefix}${initialEntry}`]); - expect(shell.source).not.toContain('full-reload'); - expect(shell.source).toContain(runtimeClientSurfaceReloadChannelPath); - - shell.sockets[0]!.emit('open'); - shell.sockets[0]!.emit('message', { data: reloadFrame(1) }); - await new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); - expect(shell.entries).toEqual([`${prefix}${initialEntry}`, `${prefix}${refreshedEntry}`]); - } finally { - await binding.close(); - upstream.closeAllConnections(); - await close(upstream); - } -}); - -it('uses an exact empty-domain child CSP for direct non-MCP surface opens', async () => { - const upstream = createServer((request, response) => { - if (serveBootstrapEntry(request, response)) return; - response.writeHead(404).end(); - }); - const origin = await listen(upstream); - const binding = await RuntimeClientSurfaceProxy.open({ - entryPath: '/app/index.html', - httpOrigin: origin, - httpPathPrefixes: ['/app/'], - surfaceId: 'app.weather', - subscribeReload: noopSubscribeReload, - }, () => undefined); - try { - const shell = await runtimeProxyShellHarness(binding); - expect(shell.entries).toEqual([ - '
runtime App
', - ]); - } finally { - await binding.close(); - upstream.closeAllConnections(); - await close(upstream); - } -}); - -it('rejects a custom-prototype child policy before opening a proxy binding', async () => { - const policy = Object.create({ inherited: true }) as Record; - Object.defineProperty(policy, 'contentSecurityPolicy', { - enumerable: true, - value: "default-src 'none'", - }); - await expect(RuntimeClientSurfaceProxyImplementation.open({ - entryPath: '/app/index.html', - httpOrigin: 'http://127.0.0.1:41998', - httpPathPrefixes: ['/app/'], - surfaceId: 'app.weather', - subscribeReload: noopSubscribeReload, - }, () => undefined, foregroundOrigin, policy as never)).rejects.toThrow('plain policy record'); -}); - -it('bounds upstream requests at 15 s by default and rejects out-of-range overrides before opening', async () => { - expect(defaultRuntimeClientSurfaceUpstreamRequestTimeoutMs).toBe(15_000); - for (const upstreamRequestTimeoutMs of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648]) { - await expect(RuntimeClientSurfaceProxy.open({ - entryPath: '/app/index.html', - httpOrigin: 'http://127.0.0.1:41998', - httpPathPrefixes: ['/app/'], - surfaceId: 'app.weather', - subscribeReload: noopSubscribeReload, - }, () => undefined, { upstreamRequestTimeoutMs })).rejects.toThrow('upstreamRequestTimeoutMs from 1 to'); - } -}); - -it('does not reinstall the opaque child when a held refresh fetch resolves after pagehide', async () => { - const upstream = createServer((request, response) => { - if (serveBootstrapEntry(request, response)) return; - response.writeHead(404).end(); - }); - const origin = await listen(upstream); - const binding = await RuntimeClientSurfaceProxy.open({ - entryPath: '/app/index.html', - httpOrigin: origin, - httpPathPrefixes: ['/app/'], - surfaceId: 'app.weather', - subscribeReload: noopSubscribeReload, - }, () => undefined); - let resolveFetch: ((response: Response) => void) | undefined; - let refreshSignal: AbortSignal | null | undefined; - const heldFetch = new Promise((resolvePromise) => { resolveFetch = resolvePromise; }); - try { - const shell = await runtimeProxyShellHarness(binding, { - fetch: (_input, init) => { - refreshSignal = init?.signal; - return heldFetch; - }, - }); - shell.sockets[0]!.emit('open'); - shell.sockets[0]!.emit('message', { data: reloadFrame(1) }); - shell.pagehide(); - resolveFetch?.(new Response('
late fetch
', { - headers: { 'content-type': 'text/html; charset=utf-8' }, - })); - await new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); - expect(shell.entries).toHaveLength(1); - expect(refreshSignal?.aborted).toBe(true); - expect(shell.appPosts).toEqual([]); - expect(shell.parentPosts).toEqual([]); - expect(shell.pendingTimers()).toBe(0); - expect(shell.sockets).toHaveLength(1); - } finally { - await binding.close(); - upstream.closeAllConnections(); - await close(upstream); - } -}); - -it('does not reinstall the opaque child when held refresh text resolves after pagehide', async () => { - const upstream = createServer((request, response) => { - if (serveBootstrapEntry(request, response)) return; - response.writeHead(404).end(); - }); - const origin = await listen(upstream); - const binding = await RuntimeClientSurfaceProxy.open({ - entryPath: '/app/index.html', - httpOrigin: origin, - httpPathPrefixes: ['/app/'], - surfaceId: 'app.weather', - subscribeReload: noopSubscribeReload, - }, () => undefined); - let resolveText: ((entry: string) => void) | undefined; - let refreshSignal: AbortSignal | null | undefined; - let textStarted = false; - const heldText = new Promise((resolvePromise) => { resolveText = resolvePromise; }); - const response = Object.freeze({ - headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), - ok: true, - text: () => { - textStarted = true; - return heldText; - }, - }) as unknown as Response; - try { - const shell = await runtimeProxyShellHarness(binding, { - fetch: async (_input, init) => { - refreshSignal = init?.signal; - return response; - }, - }); - shell.sockets[0]!.emit('open'); - shell.sockets[0]!.emit('message', { data: reloadFrame(1) }); - await new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); - expect(textStarted).toBe(true); - shell.pagehide(); - resolveText?.('
late text
'); - await new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); - expect(shell.entries).toHaveLength(1); - expect(refreshSignal?.aborted).toBe(true); - expect(shell.appPosts).toEqual([]); - expect(shell.parentPosts).toEqual([]); - expect(shell.pendingTimers()).toBe(0); - expect(shell.sockets).toHaveLength(1); - } finally { - await binding.close(); - upstream.closeAllConnections(); - await close(upstream); - } -}); - -it('uses a one-use bootstrap capability before serving declared app assets and the owned reload channel', async () => { - const upstream = createServer((request, response) => { - if (request.url === '/app/index.html') { - response.writeHead(200, { 'content-type': 'text/html' }).end('
runtime app
'); - return; - } - response.writeHead(404).end(); - }); - let upstreamUpgrades = 0; - upstream.on('upgrade', (_request, socket) => { - upstreamUpgrades += 1; - socket.destroy(); - }); - const origin = await listen(upstream); - const reloadSource = createReloadSource(); - const events: unknown[] = []; - const binding = await RuntimeClientSurfaceProxy.open({ - entryPath: '/app/index.html', - httpOrigin: origin, - httpPathPrefixes: ['/app/'], - subscribeReload: reloadSource.subscribeReload, - surfaceId: 'app.weather', - } satisfies DevRuntimeClientSurfaceEndpoint, (event) => events.push(event)); - - try { - expect(reloadSource.listenerCount()).toBe(1); - const first = await fetch(binding.bootstrapUrl, { redirect: 'manual' }); - expect(first.status).toBe(200); - expect(first.headers.get('content-security-policy')).toContain(`frame-ancestors ${foregroundOrigin}`); - expect(first.headers.get('set-cookie')).toMatch(/^__Host-agent_bundle_runtime_[a-f0-9]+=/u); - expect(first.headers.get('set-cookie')).toContain('HttpOnly'); - expect(first.headers.get('set-cookie')).toContain('Secure'); - expect(first.headers.get('set-cookie')).toContain('SameSite=None'); - expect(first.headers.get('set-cookie')).toContain('Partitioned'); - expect(first.headers.get('set-cookie')).not.toContain('Domain='); - const cookie = first.headers.get('set-cookie')!.split(';', 1)[0]!; - const shell = await first.text(); - expect(shell).toContain('