diff --git a/.changeset/558-serve-app-command.md b/.changeset/558-serve-app-command.md index d6981624a..983a80b4a 100644 --- a/.changeset/558-serve-app-command.md +++ b/.changeset/558-serve-app-command.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Add `agent-bundle/serve-app-command`, a dependency-free entry a routed CLI command (or any other generated executable) imports to serve a built MCP App without importing the compiler: `spawnServeApp(options)` lowers the `serveApp` options to `agent-bundle serve-app` argv (`serveAppArgv`), resolves the framework CLI installed at or above the project root (`locateFrameworkCli`), spawns it with its stdout relayed to stderr so the route keeps stdout for its JSON result, resolves with `{ url, port, tool, server, pid, closed, close() }` once the CLI prints its ready line (`parseServeAppReadyLine`), tears the server down when the route's `signal` aborts, and rejects with `ServeAppCommandError` (`framework-not-installed`, `artifact-missing`, `spawn-failed`, `exited-before-ready`, `aborted`, `stop-failed`). Report the new `AB4837` diagnostic from `inspect`, `validate`, `build`, and `dev` when a route module, layout, or provider — or a module it reaches through relative imports — value-imports a compiler-carrying framework entry (`agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, `agent-bundle/test/browser`), naming the file, the specifier, and the helper, instead of failing inside the bundler with `Can't resolve '../events'`; `import type` and type-only usage are not reported (#582) +Report the new `AB4837` diagnostic from `inspect`, `validate`, `build`, and `dev` when a route module, layout, or provider — or a module it reaches through relative imports — value-imports a compiler-carrying framework entry (`agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, `agent-bundle/test/browser`), naming the file, the specifier, and the helper, instead of failing inside the bundler with `Can't resolve '../events'`; `import type` and type-only usage are not reported (#582) diff --git a/.changeset/564-web-surface.md b/.changeset/564-web-surface.md new file mode 100644 index 000000000..404c5a4e3 --- /dev/null +++ b/.changeset/564-web-surface.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add the `web` config key to expose declared MCP Apps in a browser: `web.apps` selects `/` entries already under `mcp.servers..apps`, `web.open` is `browser` or `never` (default `never`), and invalid exposure reports `AB4341`. When `web` is configured, the composite artifact's `agent-bundle.manifest.json` gains a `web` section (each App carries its server's `entry`, `args`, and `env`; Apps scoped to unselected targets are omitted) and `bin/.mjs` carries the framework-owned ` web` command even without `src/cli/**`; `agent-bundle dev` serves the same host at `/web//`. Host adapters publish a `web` capability row that gates the web-only bin like `cli` gates a routed CLI; a target without it is an `AB4341` warning, and an authored command or alias spelled `web` is `AB4341`. Remove `agent-bundle/serve-app-command` (`spawnServeApp`, `serveAppArgv`, `locateFrameworkCli`, `ServeAppCommandError`); the supported path from an installed artifact is ` web`. `web` never displaces an authored executable: a hand-written `src/cli.ts`, a `bin` entry claiming the plugin name, or `bin: false` keeps its bin and `AB4341` reports the web surface with nowhere to live. Fix `agent-bundle dev` rebuilding the epoch it just produced whenever the project has a `dist/` package build: the watcher now ignores the build's `.dist.stage-*` staging directory. (#620) diff --git a/.gitignore b/.gitignore index e8bc03e1c..2601b55c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +packages/agent-bundle/web-host-dist/ .agent-bundle/ .worktrees/ coverage/ diff --git a/docs/diagnostics.md b/docs/diagnostics.md index efb5d5883..1161381c5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -17,7 +17,7 @@ even when no error diagnostic was reported. | `AB40xx` | Plugin metadata and Skill source validation (`AB4000`/`AB4001`: name/version; `AB4002`–`AB4007`: Skill fields; `AB4008`–`AB4011` and `AB4013`: release identity; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project); see below. | | `AB41xx` | Normalized model invariants (`AB4100`–`AB4102`: unknown targets — the retired `plugin` name included — duplicate IDs and outputs; `AB4103`, `AB4105`, `AB4106`: the composite-root checks — same path with different bytes across selected projections, a host-scoped component leaking through conventional discovery, an advanced-registry adapter selected beside another target; see below). | | `AB42xx` | Hook configuration and native hook sources (`AB4200`–`AB4212`; see below). | -| `AB43xx` | MCP server and MCP App configuration (`AB4300`–`AB4339`, see below; `AB4340`: a declaration for a route-generated server redeclares `entry`/`command`/`url`; see below). | +| `AB43xx` | MCP server and MCP App configuration (`AB4300`–`AB4339`, see below; `AB4340`: a declaration for a route-generated server redeclares `entry`/`command`/`url`; `AB4341`: the `web` exposure/policy key; see below). | | `AB44xx` | Script configuration (`AB4400`–`AB4408`; see below). | | `AB4500` | Registered config extensions (strict finite JSON; see below). | | `AB46xx` | Assets and the generated-runtime floor (`AB4600`–`AB4602`; see below). | @@ -876,6 +876,17 @@ its entry. | --- | --- | --- | | `AB4340` | error | A declaration for a route-generated server sets `entry`, `command`, or `url` while `routes.servers.` is `generated`. The routes already compile this server, so a second entry claim has no reading the compiler could honor. Remove the field to keep the generated server (the other fields still apply), or set the mode to `custom`, `command`, or `remote` to serve the declared entry and omit the routes. Without an explicit mode the same collision is `AB4800`. | +## Web surface exposure (`AB4341`) + +The optional `web` config key selects among MCP Apps already declared under +`mcp.servers..apps` and is the only input the artifact-resident browser +host consults (via the manifest `web` section). Validation is one code with +nine message shapes. + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB4341` | error (shape 9: warning) | The `web` key is invalid. Message shapes: (1) `web.apps[] names /, which no mcp.servers..apps entry declares.` Recovery: `Declare the App under mcp.servers..apps or remove it from web.apps.` — an App `web.apps` names that no `mcp.servers..apps` declares. (2) `web.apps[] allows , which is not an App-initiated consent capability.` Recovery: `Use one of: call-tool, download-file, open-external-link, request-display-mode; browser hardware and clipboard permissions always ask in the host page.` — unknown `allow` capability. (3) `web.open must be "browser" or "never".` — `open` outside that enum. (4) `web.apps[].tool is not a tool this project's route graph declares for .` Recovery: `Name a tool whose _meta.ui.resourceUri is , or omit tool when exactly one such tool exists.` — only for a generated route server whose tools are known statically; a hand-written server factory skips this check and the runtime verifies. (5) `web.apps[] names / twice.` Recovery: `List each App once.` (6) `CLI command "web" is reserved by the web surface (web.apps is configured).` (or `CLI alias "web" of is reserved ...`). Recovery: `Rename the command or remove web.apps.` (`Rename the alias ...`) — a generated-mode `src/cli/**` command whose first path segment is `web`, or a top-level command with a `web` alias, while `web` is configured; the generated shell dispatches `web` before the authored tree, so either would be unreachable. (7) `web.apps is configured, but src/cli.ts owns the "" executable, so the framework-generated web command has nowhere to live.` (or `... the bin config owns ...`). Recovery: `Move that executable's commands under src/cli/** so the framework generates the bin, or remove web.apps.` — `web` never replaces an authored executable; the hand-written bin keeps the plugin name and the web surface is not emitted. (8) `web.apps is configured, but no framework-generated executable carries the web command (bin is false, or the plugin name is not a safe executable name).` Recovery: `Remove bin: false (or choose a safe plugin name), or remove web.apps.` (9) **warning** `The web surface is not hosted by target "": . Its artifact carries no working web command.` where the judgment is `the target publishes no web capability row` or `its web capability is : `. Recovery: `Publish a supported web capability on the adapter, or drop the target.` — the `web` capability row gates the web-only bin's emission the way `cli` gates a routed CLI (`AB4765`); every built-in host publishes it, so this fires for custom adapters only. | + ## Conventional host components: rules and commands (`AB4900`–`AB4908`, `AB4920`–`AB4928`) Conventional `src/rules/*.mdc` documents compile to the Rule IR (closed @@ -1282,7 +1293,7 @@ resolving a provider set the author did not write. | `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. | | `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. | | `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. | -| `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/app` (the browser MCP App client, a leaf with no Zod, Node, or compiler import), `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | +| `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Keep framework calls in a host process: expose an MCP App with `web.apps` and open it from the installed artifact with ` web`; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/app` (the browser MCP App client, a leaf with no Zod, Node, or compiler import), `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/web-host`. | | `AB4838` | error | A CLI route's `inputSchema` references a binding the static resolver cannot follow. The message is `CLI route inputSchema: .` — the chain is the reference path from `inputSchema`, each step ``, or ` ()` when it crosses into another module (`inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`), and the reason names the boundary: a specifier that `is not a relative module path`, one that `resolves outside the project` or `does not resolve to a module inside the project` (missing or unreadable), a target module that does not declare a top-level `export const `, a binding that is not a top-level `const` (`let`/`var`, destructuring, a function, a class, a default or namespace import — the message says what it is), an identifier that `is neither a top-level const in this module nor a named import from a relative module`, or a dynamic initializer — one that is neither a method chain, an object or array literal, nor a static literal (`whose initializer is a call expression`, `a function expression`, `a template literal with substitutions`). Reported on the route module; the recovery names the supported forms — relative imports inside the project, `export const`, alias chains — then says to inspect again. Only CLI routes raise it, because only there the static contract is load-bearing: an MCP, script, or event route whose schema the resolver cannot follow compiles without a static contract, as an out-of-grammar inline schema does, and the runtime derives its MCP JSON Schema from the real zod object. A reference that resolves but whose schema leaves the grammar is `AB4814`. | | `AB4839` | error | A CLI route's `inputSchema` reference chain is cyclic — `a` → `b` → `a`, within one module or across several: every visited `#` is recorded and revisiting one stops the walk. The message is `CLI route inputSchema: is a reference cycle.` and prints the cycle; it is reported on the route module, with the same recovery and the same CLI-only rule as `AB4838`. | | `AB4840` | error | An event route's `preflight` gate (#595) is not the one physically cheap form the compiler can bundle on its own. Rejected: `preflight` declared inline in the route module (`export const preflight = …`, `export function preflight`) or exported more than once; re-exported under a binding other than `default` (`export { gate as preflight } from './gate.js'`, `export { preflight } from './gate.js'`); re-exported from a non-relative specifier (a bare package such as `'@scope/gate'`); a relative target that is missing, unreadable, or part of a re-export cycle; a target default export that cannot be followed through an acyclic chain of relative default re-exports; or a target default export that is not a function the scan can see. The message names the route module and, once a re-export was found, its specifier. Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per route with the route module as `sourcePath`; the route compiles without a gate beside the error, and the build fails rather than silently taking the expensive rendered path. Write exactly `export { default as preflight } from './.js'` in the route module, and make that module default-export one sync or async function receiving `{ canonical, host, signal, terminal }` and returning `'execute'`, `{ outcome: 'continue' }`, or `{ outcome: 'deny', reason }`. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 2521403b2..d94a19fba 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -892,7 +892,10 @@ skills, and script routes ship with the **plugin root**, so the build also emits the same compiled command graph into that root whenever a selected host's adapter publishes the `cli` capability — all built-in hosts do (`claude`, `codex`, `cursor`, `portable`) — because the artifact root is -already a plain directory Node executes `mcp/` and `scripts/` files from: +already a plain directory Node executes `mcp/` and `scripts/` files from. +The same `bin/.mjs` is also emitted when `web` is configured, +even if `src/cli/**` compiled no commands (the bin then carries only the +framework-owned `web` command, or both surfaces when routed commands exist): ```text artifact/ @@ -1502,33 +1505,48 @@ resolve '../events'`). The route graph reports such an import first, as (`src/routes/framework-imports.ts`; the compiler-carrying entries are `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, and -`agent-bundle/test/browser`, matched exactly, so the bundle-safe entries — -`agent-bundle/app` among them — are never reported; `import type` and -type-only usage are not reported either), while an external bare import -(`AB6005 uses unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a -non-literal dynamic import`) still fails artifact validation. The sanctioned -shape is `spawnServeApp` from `agent-bundle/serve-app-command` -(`src/serve-app-command.ts`, #558) — plain Node with no dependencies, so the -bundler inlines it into the self-contained executable the way it inlines -`agent-bundle/launch-env`. It lowers the `serveApp` options to `serve-app` -argv (`serveAppArgv`; every `ServeAppOptions` key except the host-process-only -`logger`, `registry`, `openBrowser`, `targets`, and `timeoutMs`), resolves the -framework CLI from the `agent-bundle` package installed at or above `root` -(`locateFrameworkCli`, through `src/core/dependency-manifest.ts`), spawns it -with the child's stdout piped and its stderr inherited, relays every stdout -line to stderr so the routed CLI keeps stdout for its result, resolves once -the child prints the ready line `MCP App at (tool ; Ctrl-C -stops the server)` — the CLI writes it and the helper parses it through one -module, `src/serve-app/command-contract.ts` — and turns the route `signal` -into the child's `SIGTERM`. The result is `{ app, url, tool, server, port, -pid, closed, close() }`; failures are `ServeAppCommandError` with `code` -`framework-not-installed`, `artifact-missing`, `spawn-failed`, -`exited-before-ready` (carrying the child's `exit`), `aborted`, or -`stop-failed` (the running child refused the signal `close()` or the abort -sent; it is still running). It is a -checkout command: an installed host pack has neither `node_modules/agent-bundle` -nor the artifact, and the first two codes say so before anything is spawned. -The worked example is in the MCP Apps guide, "Serving an App standalone". +`agent-bundle/test/browser`, matched exactly; `import type` and type-only +usage are not reported), while an external bare import (`AB6005 uses +unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a +non-literal dynamic import`) still fails artifact validation. From an +installed artifact the supported command is ` web` on +`bin/.mjs` (emitted when `web` is configured, even with no +`src/cli/**` commands). It reads the manifest `web` section beside `bin/`, +launches the plugin's own packed MCP server, and prints the same ready line +`MCP App / at (tool ; Ctrl-C stops the server)` — +`src/serve-app/command-contract.ts` is the shared contract for that line +and for `--json` `{ app, server, tool, url, port, resourceUri, sandboxOrigin }`. +`agent-bundle/serve-app-command` (`spawnServeApp`) is removed. The worked +example is in the MCP Apps guide, "Exposing an App in the browser". + +## ` web` + +```sh +node /bin/.mjs web [/] [--port N] + [--open|--no-open] [--tool T] [--input JSON] [--allow ]... + [--profile portable|claude|chatgpt] [--json] +``` + +The framework-owned `web` command. Config is exposure/policy only: + +```ts +web?: { + apps: ReadonlyArray; + allow?: McpAppConsentCapability[]; + }>; + open?: 'browser' | 'never'; // default 'never' +} +``` + +`apps[]` selects among Apps already declared under `mcp.servers..apps`. +Invalid `web` is `AB4341`. When configured, `agent-bundle.manifest.json` +gains a `web` section and the bin exists even without authored CLI commands. +`agent-bundle dev` serves the same host at `GET /web//` (404 +for Apps not listed in `web.apps`). There is no `web/` directory in the +artifact. ## `agent-bundle/app` — the App-side bridge client @@ -1549,10 +1567,10 @@ mapping cannot replace the framework runtime. It is not one of the compiler-carrying entries `AB4837` rejects. The other half stays where it is: the host page, sandbox proxy, frame relay -(`McpAppFrameRelay` in the Workbench, the inline relay in `serve-app`), +(`McpAppFrameRelay` in `src/web-host/browser/frame-relay.ts`, shared by the Workbench, `serve-app`, dev `/web`, and ` web`), `/api/mcp/...` routes, consent authority, and `createMcpAppBridge` (`src/dev/mcp-apps/mcp-app-bridge.ts`) are host-side and owned by the -Workbench, `serve-app`, and #564's production host; #594 adds no host bridge +Workbench, `serve-app`, and the ` web` host (#564); #594 adds no host bridge and moves none of those modules. The one host-side behavior it adds is cancellation: `createMcpAppBridge` now honors the client's `notifications/cancelled` and threads the abort through the binding service diff --git a/docs/framework-mode.md b/docs/framework-mode.md index b473813c2..f452e1741 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -101,10 +101,11 @@ own packed server, launched as `mcp run` launches it. `serveApp` in the Workbench, tests, a plugin's own scripts — never the MCP shell, and a local preview host, not a deployment target. A plugin's own "open the dashboard" CLI route cannot import it — the routed CLI bin is self-contained, -and the route graph reports the value import as `AB4837` — so it calls -`spawnServeApp` from `agent-bundle/serve-app-command`, which spawns -`agent-bundle serve-app` as a child process (#558); see -[Entry conventions](entry-conventions.md#agent-bundle-serve-app). +and the route graph reports the value import as `AB4837`. From an installed +artifact the supported command is ` web` on `bin/.mjs`, +emitted when the `web` config key lists declared Apps; `agent-bundle dev` +serves the same host at `/web//`. See +[Entry conventions](entry-conventions.md#plugin-web). The compiler statically reads `config`, imports schemas and implementations only into generated entries, installs `runAgentRequest`, and derives the real diff --git a/examples/mcp-app/agent-bundle.config.ts b/examples/mcp-app/agent-bundle.config.ts index 50d2328ff..39fe987b6 100644 --- a/examples/mcp-app/agent-bundle.config.ts +++ b/examples/mcp-app/agent-bundle.config.ts @@ -28,4 +28,5 @@ export default defineConfig({ 'check-service-fixture': './src/scripts/check-service-fixture.ts', }, targets: ['portable', 'codex', 'claude'], + web: { apps: [{ app: 'status/status', tool: 'show-status', allow: ['call-tool'] }] }, }); diff --git a/package.json b/package.json index a49169ea8..3dc82ed34 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "lint": "rslint .", "bench:hook-cold-start": "node scripts/measure-hook-cold-start.mjs", "bench:preflight-cold-start": "node scripts/measure-preflight-cold-start.mjs", - "typecheck": "node scripts/check-dist-fresh.mjs && tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json && tsc --project packages/rsc-markdown-stream/tsconfig.json && pnpm --filter @agent-bundle/docs typecheck", + "typecheck": "node scripts/check-dist-fresh.mjs && tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json && tsc --project packages/rsc-markdown-stream/tsconfig.json && tsc --project packages/agent-bundle/tsconfig.web-host.json && pnpm --filter @agent-bundle/docs typecheck", "check": "pnpm build && pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration:run && pnpm lint && pnpm typecheck", "check:local-ci": "node scripts/local-ci.mjs", "check:host-cli": "node scripts/host-cli-pins.mjs verify", diff --git a/packages/agent-bundle/fixtures/serve-app-command/agent-bundle.config.ts b/packages/agent-bundle/fixtures/serve-app-command/agent-bundle.config.ts deleted file mode 100644 index 3651f8a05..000000000 --- a/packages/agent-bundle/fixtures/serve-app-command/agent-bundle.config.ts +++ /dev/null @@ -1,27 +0,0 @@ -// The `agent-bundle/serve-app-command` packed proof (#558): one MCP server -// with one App, and a routed CLI command (`src/cli/dashboard.ts`) that serves -// the App by spawning `agent-bundle serve-app` from inside the generated bin. -// Plain object export, like every other repository fixture: the fixture must -// compile without the package's own built configuration entry. -export default { - mcp: { - servers: { - status: { - apps: { - status: { - entry: './views/status.ts', - resourceUri: 'ui://serve-app-command-fixture/status.html', - targets: ['portable'], - template: './views/status.html', - }, - }, - }, - }, - }, - plugin: { - description: 'A routed CLI command that serves this plugin\'s MCP App through agent-bundle/serve-app-command.', - name: 'serve-app-command-fixture', - version: '1.0.0', - }, - targets: ['portable'], -}; diff --git a/packages/agent-bundle/fixtures/serve-app-command/src/cli/dashboard.ts b/packages/agent-bundle/fixtures/serve-app-command/src/cli/dashboard.ts deleted file mode 100644 index 25b651678..000000000 --- a/packages/agent-bundle/fixtures/serve-app-command/src/cli/dashboard.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; -import { ServeAppCommandError, spawnServeApp } from 'agent-bundle/serve-app-command'; -import { z } from 'zod'; - -export const config = { - description: 'Open the status App in a browser, served from this checkout\'s built artifact.', - exitCode: 'result', -} satisfies CliRouteConfig; - -export const inputSchema = z.object({ - noOpen: z.boolean().optional(), - port: z.number().int().min(0).max(65_535).optional(), - /** Test seam: fetch the served page once, then stop the server and report. */ - probe: z.boolean().optional(), -}).strict(); - -export const resultSchema = z.object({ - exitCode: z.number().int(), - message: z.string(), - pid: z.number().int().nullable(), - probeStatus: z.number().int().nullable(), - url: z.string().nullable(), -}).strict(); - -export default async function dashboard({ input, signal }: CliRouteProps) { - let served; - try { - served = await spawnServeApp({ - app: 'status/status', - root: process.cwd(), - artifact: 'artifact', - tool: 'status', - autoApprove: ['call-tool'], - open: input.noOpen !== true, - ...(input.port === undefined ? {} : { port: input.port }), - signal, - }); - } catch (error) { - if (error instanceof ServeAppCommandError) { - return { exitCode: 1, message: `${error.code}: ${error.message}`, pid: null, probeStatus: null, url: null }; - } - throw error; - } - let probeStatus: number | null = null; - if (input.probe === true) { - probeStatus = (await fetch(served.url)).status; - await served.close(); - } - const exit = await served.closed; - return { - exitCode: exit.code ?? 1, - message: exit.code === 0 ? 'dashboard closed' : `agent-bundle serve-app exited with ${exit.signal ?? exit.code}`, - pid: served.pid, - probeStatus, - url: served.url, - }; -} diff --git a/packages/agent-bundle/fixtures/serve-app-command/views/status.ts b/packages/agent-bundle/fixtures/serve-app-command/views/status.ts deleted file mode 100644 index 6a0e721e8..000000000 --- a/packages/agent-bundle/fixtures/serve-app-command/views/status.ts +++ /dev/null @@ -1 +0,0 @@ -document.querySelector('#view')!.textContent = 'serve-app-command fixture status'; diff --git a/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts b/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts new file mode 100644 index 000000000..56da308f4 --- /dev/null +++ b/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts @@ -0,0 +1,25 @@ +// Plain object export keeps this packed fixture independent of the package build (#564). +export default { + mcp: { + servers: { + status: { + apps: { + status: { + entry: './views/status.ts', + resourceUri: 'ui://web-surface-fixture/status.html', + targets: ['portable'], + template: './views/status.html', + }, + }, + }, + }, + }, + plugin: { + description: 'A plugin whose MCP App is exposed through web.apps and opened with web.', + name: 'web-surface-fixture', + version: '1.0.0', + }, + targets: ['portable'], + // Omitting `tool` proves unique live-server resolution. + web: { apps: [{ allow: ['call-tool'], app: 'status/status' }] }, +}; diff --git a/packages/agent-bundle/fixtures/serve-app-command/package.json b/packages/agent-bundle/fixtures/web-surface/package.json similarity index 74% rename from packages/agent-bundle/fixtures/serve-app-command/package.json rename to packages/agent-bundle/fixtures/web-surface/package.json index 5867fcaf3..ffdeedac2 100644 --- a/packages/agent-bundle/fixtures/serve-app-command/package.json +++ b/packages/agent-bundle/fixtures/web-surface/package.json @@ -1,5 +1,5 @@ { - "name": "serve-app-command-fixture", + "name": "web-surface-fixture", "private": true, "type": "module", "devDependencies": { diff --git a/packages/agent-bundle/fixtures/web-surface/src/cli/dashboard.ts b/packages/agent-bundle/fixtures/web-surface/src/cli/dashboard.ts new file mode 100644 index 000000000..9cb72e9db --- /dev/null +++ b/packages/agent-bundle/fixtures/web-surface/src/cli/dashboard.ts @@ -0,0 +1,14 @@ +import type { CliRouteConfig } from 'agent-bundle'; +import { z } from 'zod'; + +export const config = { + description: 'Report that the dashboard command is wired into the plugin bin.', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({}).strict(); + +export const resultSchema = z.object({ ok: z.literal(true) }).strict(); + +export default async function dashboard() { + return { ok: true as const }; +} diff --git a/packages/agent-bundle/fixtures/serve-app-command/src/mcp/status.ts b/packages/agent-bundle/fixtures/web-surface/src/mcp/status.ts similarity index 75% rename from packages/agent-bundle/fixtures/serve-app-command/src/mcp/status.ts rename to packages/agent-bundle/fixtures/web-surface/src/mcp/status.ts index 3b2bf15a4..71ca5f37e 100644 --- a/packages/agent-bundle/fixtures/serve-app-command/src/mcp/status.ts +++ b/packages/agent-bundle/fixtures/web-surface/src/mcp/status.ts @@ -5,12 +5,6 @@ import { name, version } from 'agent-bundle/meta'; const app = apps[0]; if (app === undefined) throw new Error('Expected the status MCP App.'); -/** - * Default-exported server factory: `agent-bundle build` wraps it in the - * framework stdio lifecycle shell. One App resource and one tool that opens - * it — the pair `agent-bundle serve-app status/status --tool status` binds. - * The tool takes no input, so the opening call `serve-app` makes needs none. - */ export default function createStatusServer(): McpServer { const server = new McpServer({ name, version }); diff --git a/packages/agent-bundle/fixtures/serve-app-command/views/status.html b/packages/agent-bundle/fixtures/web-surface/views/status.html similarity index 100% rename from packages/agent-bundle/fixtures/serve-app-command/views/status.html rename to packages/agent-bundle/fixtures/web-surface/views/status.html diff --git a/packages/agent-bundle/fixtures/web-surface/views/status.ts b/packages/agent-bundle/fixtures/web-surface/views/status.ts new file mode 100644 index 000000000..c7a0e1393 --- /dev/null +++ b/packages/agent-bundle/fixtures/web-surface/views/status.ts @@ -0,0 +1 @@ +document.querySelector('#view')!.textContent = 'web-surface fixture status'; diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 4a1351a0e..e42c04bdb 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -25,7 +25,8 @@ "node": ">=22.19.0" }, "scripts": { - "build": "node ../../scripts/sync-license-files.mjs && pnpm build:workbench && rslib build", + "build": "node ../../scripts/sync-license-files.mjs && pnpm build:workbench && pnpm build:web-host && rslib build", + "build:web-host": "rsbuild build -c rsbuild.web-host.config.ts", "build:workbench": "pnpm --filter agent-bundle-workbench build" }, "files": [ @@ -87,10 +88,6 @@ "types": "./dist/rstest/index.d.ts", "import": "./dist/rstest.js" }, - "./serve-app-command": { - "types": "./dist/serve-app-command.d.ts", - "import": "./dist/serve-app-command.js" - }, "./test": { "types": "./dist/test/index.d.ts", "import": "./dist/test.js" @@ -99,6 +96,10 @@ "types": "./dist/test/browser.d.ts", "import": "./dist/test/browser.js" }, + "./web-host": { + "types": "./dist/web-host.d.ts", + "import": "./dist/web-host.js" + }, "./package.json": "./package.json" }, "dependencies": { diff --git a/packages/agent-bundle/rsbuild.web-host.config.ts b/packages/agent-bundle/rsbuild.web-host.config.ts new file mode 100644 index 000000000..fe971c086 --- /dev/null +++ b/packages/agent-bundle/rsbuild.web-host.config.ts @@ -0,0 +1,34 @@ +import { resolve } from 'node:path'; + +import { defineConfig } from '@rsbuild/core'; + +export default defineConfig({ + mode: 'production', + output: { + distPath: { + js: './', + jsAsync: './', + root: 'web-host-dist', + }, + filename: { + js: 'page.js', + }, + filenameHash: false, + target: 'web', + }, + performance: { + chunkSplit: { + strategy: 'all-in-one', + }, + }, + root: import.meta.dirname, + source: { + entry: { + page: resolve(import.meta.dirname, 'src/web-host/browser/main.ts'), + }, + tsconfigPath: './tsconfig.web-host.json', + }, + tools: { + htmlPlugin: false, + }, +}); diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 2972dab11..a9dd13f58 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -92,6 +92,7 @@ export default defineConfig({ cleanDistPath: true, copy: [ { from: resolve(import.meta.dirname, '../workbench/dist'), to: 'workbench', info: { minimized: true } }, + { from: resolve(import.meta.dirname, 'web-host-dist'), to: 'web-host', info: { minimized: true } }, ], filenameHash: false, legalComments: 'linked', @@ -175,13 +176,10 @@ export default defineConfig({ // into its generated bundle. routes: './src/routes/public.ts', rstest: './src/rstest/index.ts', - // Plain Node (#558): a routed command serves an MCP App by spawning - // `agent-bundle serve-app` through this entry instead of importing the - // compiler, so it must bundle into a self-contained executable. - 'serve-app-command': './src/serve-app-command.ts', 'terminal-capability': './src/terminal-capability.ts', test: './src/test/index.ts', 'test/browser': './src/test/browser.ts', + 'web-host': './src/web-host.ts', }, }, }); diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json index 05cbffe0a..8050908d1 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json @@ -1200,6 +1200,10 @@ "fileContents": "opaque", "manifestField": "workflows", "replacesDefault": true + }, + "web": { + "reason": "browser host inside the composite artifact; web runs from the installed root on any host", + "state": "supported" } }, "tokens": { diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 75d262d1d..f64de6692 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -915,7 +915,11 @@ "state": "unavailable" } }, - "skills": true + "skills": true, + "web": { + "reason": "browser host inside the composite artifact; web runs from the installed root on any host", + "state": "supported" + } }, "validation": { "observedAt": "2026-09-02", diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index 6e251c99d..240e21e3a 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -701,6 +701,10 @@ ], "secretValuesEmitted": false, "state": "supported" + }, + "web": { + "reason": "browser host inside the composite artifact; web runs from the installed root on any host", + "state": "supported" } }, "tokens": { diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index f6bd0e148..726cbbc6d 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -279,7 +279,11 @@ "state": "unavailable" } }, - "skills": true + "skills": true, + "web": { + "reason": "browser host inside the composite artifact; web runs from the installed root on any host", + "state": "supported" + } }, "tokens": { "pluginData": "${PLUGIN_DATA}", diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 930c4a363..91fcd55f4 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -38,6 +38,9 @@ export const supportedCapability = (evidence: CapabilityEvidence): CapabilitySta */ export const cliBinCapability = 'cli'; +/** Capability for the framework-owned ` web` command (#564). */ +export const webSurfaceCapability = 'web'; + export const unavailableCapability = (reason: string): CapabilityState => Object.freeze({ reason, state: 'unavailable', diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index fd5b83863..0eea6e53c 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -28,6 +28,7 @@ import { noticeDeliveryAdvertisementFrom, supportedEventRouteNamesFrom, cliBinCapability, + webSurfaceCapability, supportedCapability, unavailableCapability, } from './capability-state.ts'; @@ -3295,6 +3296,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ // The routed CLI bin rides the same plugin-root directory the pinned // contract already executes `mcp/` and `scripts/` files from (#387). [cliBinCapability]: supportedCapability(evidence), + [webSurfaceCapability]: capabilityFromTableRow(capabilityTable.plugin.web, evidence), commands: capabilityStateFromSupport( capabilityTable.plugin.commands, evidence, diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 55fa662d7..f2aa1a185 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -22,7 +22,9 @@ import { featureCapabilitiesFrom, noticeDeliveryAdvertisementFrom, supportedEventRouteNamesFrom, + capabilityFromTableRow, cliBinCapability, + webSurfaceCapability, supportedCapability, unavailableCapability, } from './capability-state.ts'; @@ -1267,6 +1269,7 @@ export const codexAdapter: TargetAdapter = Object.freeze({ // The routed CLI bin rides the same plugin-root directory the pinned // contract already executes `mcp/` and `scripts/` files from (#387). [cliBinCapability]: supportedCapability(evidence), + [webSurfaceCapability]: capabilityFromTableRow(capabilityTable.plugin.web, evidence), // Component feature sets (#100): one row per host feature a kind may use. ...featureCapabilitiesFrom('hooks', capabilityTable.hooks.features, evidence), ...featureCapabilitiesFrom('skills', capabilityTable.plugin.skillFeatures, evidence), diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index d1406b591..634bf26a1 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -19,6 +19,7 @@ import { capabilityStateFromSupport, eventRouteCapabilitiesFrom, cliBinCapability, + webSurfaceCapability, featureCapabilitiesFrom, frontmatterFeatureCapabilitiesFrom, noticeDeliveryAdvertisementFrom, @@ -703,6 +704,7 @@ export const cursorAdapter: TargetAdapter = Object.freeze({ // The routed CLI bin rides the same plugin-root directory the pinned // contract already executes `mcp/` and `scripts/` files from (#387). [cliBinCapability]: supportedCapability(evidence), + [webSurfaceCapability]: capabilityFromTableRow(capabilityTable.plugin.web, evidence), // Component feature sets (#100): commands are frontmatter-free on Cursor // (every field row unavailable), rules carry the documented .mdc fields. ...frontmatterFeatureCapabilitiesFrom('commands', capabilityTable.plugin.commandFrontmatter, evidence), diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index d8957d316..9e6a45ba0 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -19,6 +19,7 @@ import { capabilityFromTableRow, capabilityStateFromSupport, cliBinCapability, + webSurfaceCapability, eventRouteCapabilitiesFrom, noticeDeliveryAdvertisementFrom, supportedCapability, @@ -626,6 +627,7 @@ export const portableAdapter: TargetAdapter = Object.freeze({ // it rides the plugin-root directory the standard's stdio MCP servers // already execute from (#387). [cliBinCapability]: supportedCapability(evidence), + [webSurfaceCapability]: capabilityFromTableRow(capabilityTable.plugin.web, evidence), // Component feature sets (#100): the portable Skill document carries only // the Agent Skills fields and no interpolation placeholders. ...featureCapabilitiesFrom('skills', capabilityTable.plugin.skillFeatures, evidence), diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 1732d8f68..e7c69e631 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -36,8 +36,9 @@ import { import { emptyCompiledRouteGraph } from './routes/graph.ts'; import { inspectRouteGraph, type RouteGraphInspection } from './routes/inspect.ts'; import { mcpServerStateDirectory, runMcpForeground } from './services/mcp-run.ts'; -import { parseServeAppSelector, serveMcpApp } from './serve-app/serve-mcp-app.ts'; +import { serveMcpApp } from './serve-app/serve-mcp-app.ts'; import type { ServedMcpApp, ServeMcpAppPublicOptions } from './serve-app/types.ts'; +import { parseAppSelector } from './web-host/select-app.ts'; export type { McpAppConsentCapability, ServedMcpApp as ServedApp } from './serve-app/types.ts'; export type { OpenBrowser } from './dev/mcp-apps/mcp-app-preview-host.ts'; export type { McpAppProfileId } from './dev/mcp-app-profile-descriptors.ts'; @@ -1542,7 +1543,7 @@ export const serveApp = async (options: ServeAppOptions): Promise const registry = registryFor(options); const workspaceRoot = resolve(options.root); const target = options.target ?? 'portable'; - const { server } = parseServeAppSelector(options.app); + const { server } = parseAppSelector(options.app); return serveMcpApp({ app: options.app, artifact: options.artifact === undefined ? scopedThrowawayArtifact({ ...options, registry }) : resolve(options.artifact), diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 88b929a18..2f82837cd 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -52,6 +52,7 @@ import { } from './provenance.ts'; import { validateArtifact, validateArtifactFiles } from './validate-artifact.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { parseWebManifest, type WebManifest } from '../web-host/manifest.ts'; export interface BuildResult { @@ -316,7 +317,60 @@ const manifestTargets = ( }) .sort((left, right) => left.name.localeCompare(right.name))); +/** + * The manifest `web` section for this composite root: the exposed Apps whose + * declaration targets intersect the selection, exactly the Apps + * `planCompiledMcpApps` compiles into it. An App scoped to a host outside the + * selection is not advertised, since the server the root ships cannot serve + * it; a selection that exposes none leaves the section out. + */ +const webManifestFor = (options: { + readonly artifactRoot: string; + readonly compiledMcpEntries: readonly CompiledMcpEntry[]; + readonly model: NormalizedPlugin; + readonly selected: readonly string[]; +}): WebManifest | undefined => { + if (options.model.web === undefined) return undefined; + const entries = new Map(options.compiledMcpEntries.map((entry) => [ + entry.id, + relative(options.artifactRoot, entry.output).replaceAll('\\', '/'), + ])); + const servers = new Map(options.model.mcpServers.map((server) => [server.id, server])); + const selectedApps = (options.model.mcpApps ?? []).filter((app) => + app.targets.some((target) => options.selected.includes(target))); + const exposed = options.model.web.apps.filter((app) => + selectedApps.some((candidate) => candidate.serverId === app.serverId && candidate.name === app.appName)); + if (exposed.length === 0) return undefined; + const apps = exposed.map((app) => { + const server = servers.get(app.serverId); + const declaredEntry = server?.args?.[0]; + const pluginRootPrefix = `${pathTokens.pluginRoot}/`; + const entry = entries.get(app.serverId) ?? + (declaredEntry?.startsWith(pluginRootPrefix) === true + ? declaredEntry.slice(pluginRootPrefix.length) + : undefined); + if (server === undefined || entry === undefined) { + throw new Error(`Web App ${JSON.stringify(app.app)} has no compiled MCP server entry.`); + } + return { + allow: [...app.allow], + app: app.app, + args: server.args?.slice(1) ?? [], + entry, + env: { ...(server.env ?? {}) }, + ...(app.input === undefined ? {} : { input: structuredClone(app.input) }), + name: app.appName, + resourceUri: app.resourceUri, + server: app.serverName, + ...(app.tool === undefined ? {} : { tool: app.tool }), + }; + }).sort((left, right) => left.app.localeCompare(right.app)); + return parseWebManifest({ apps, open: options.model.web.open }); +}; + const manifestFor = (options: { + readonly artifactRoot: string; + readonly compiledMcpEntries: readonly CompiledMcpEntry[]; readonly files: ArtifactManifest['files']; readonly model: NormalizedPlugin; readonly projectContext: ProjectContext; @@ -324,6 +378,7 @@ const manifestFor = (options: { readonly selected: readonly string[]; }): ArtifactManifest => { const targets = manifestTargets(options.registry, options.selected); + const web = webManifestFor(options); return { agentSkills: agentSkillsSchemaRevision, files: options.files, @@ -336,6 +391,7 @@ const manifestFor = (options: { source: { status: 'passed' }, targets: targets.map(({ name }) => ({ name, status: 'passed' })), }, + ...(web === undefined ? {} : { web }), }; }; @@ -523,6 +579,8 @@ export const build = async (options: BuildOptions): Promise => { const manifest = await writeManifest({ artifactRoot: stageRoot, manifest: manifestFor({ + artifactRoot: stageRoot, + compiledMcpEntries, files, model: options.model, projectContext: options.projectContext, diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index 29022a38c..9713e7f6c 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -1,10 +1,11 @@ import { resolve } from 'node:path'; -import { cliBinCapability } from '../adapters/capability-state.ts'; +import { cliBinCapability, webSurfaceCapability } from '../adapters/capability-state.ts'; import type { TargetRegistry } from '../adapters/registry.ts'; import { routedCliBinLayout, type TargetArtifactEntry } from '../adapters/types.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import type { NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts'; +import { readWebHostPageScript } from '../web-host/page-script.ts'; import { resolveArtifactDestination } from './emit.ts'; import type { CompiledEntry } from './entries.ts'; import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; @@ -14,10 +15,15 @@ import { generatedCliBinEntrySource, generatedRenderedRouteWorkerSource, launchEnvRuntimePath, + webHostRuntimePath, + webHostRuntimeSpecifier, } from './entry-shell.ts'; import type { RslibEntry, RslibSurfacePlan } from './rslib.ts'; import { runtimeIgnoredRoot } from './runtime-path.ts'; +const webHostPageVirtualModuleSpecifier = 'agent-bundle/web-host-page'; +const webHostPageScript = await readWebHostPageScript(); + /** * The artifact-hosted routed CLI (#387). A generated-mode `src/cli/**` * surface already compiles into the npm package bin (`dist/bin/.js`); @@ -41,15 +47,33 @@ export const cliBinWorkerArtifactPath = (name: string): string => `${cliBinDirec /** The framework-generated routed-CLI bins of a project (never hand-written `bin` entries). */ export const routedCliBins = (model: NormalizedPlugin): readonly NormalizedBinEntry[] => - Object.freeze((model.packageBuild?.bins ?? []).filter((bin) => bin.generatedCli !== undefined)); + Object.freeze((model.packageBuild?.bins ?? []).filter((bin) => + bin.generatedCli !== undefined || bin.web === true)); + +/** + * True when the target's adapter admits the generated executable into its + * artifact — by the component judgment (`componentCapabilities ?? + * capabilities`), so emission and `inspect` accounting can never disagree. + * The `cli` capability judges a bin that compiles authored commands; the + * `web` capability judges one that exists only to carry ` web` + * (#564). A project without a generated bin is judged on `cli`, as the + * routed-CLI collision checks always were. + */ +export const targetHostsGeneratedBin = (registry: TargetRegistry, model: NormalizedPlugin, target: string): boolean => { + const bin = routedCliBins(model)[0]; + return registry.hostsComponent(target, generatedBinCapability(bin)); +}; /** - * True when the target's adapter admits the routed CLI bin into its artifact — - * by the component judgment (`componentCapabilities ?? capabilities`), so - * emission and `inspect` accounting can never disagree. + * The capability row that judges a generated bin: `web` for one that + * compiles no command and exists only to carry ` web` — its + * `generatedCli` is present but empty, so the command count decides, not the + * field's presence — and `cli` otherwise. */ -export const targetHostsCliBin = (registry: TargetRegistry, target: string): boolean => - registry.hostsComponent(target, cliBinCapability); +export const generatedBinCapability = (bin: NormalizedBinEntry | undefined): string => { + if (bin === undefined || bin.web !== true) return cliBinCapability; + return (bin.generatedCli?.commands.length ?? 0) > 0 ? cliBinCapability : webSurfaceCapability; +}; export interface CompiledCliBin extends CompiledEntry { readonly id: string; @@ -78,6 +102,7 @@ export const planCompiledCliBins = ( const rendered = cli.commands.some((command) => command.rendered); const sourceInputs = Object.freeze([...new Set([ bin.provenance.sourcePath, + model.metadata.provenance.sourcePath, ...cli.routes.map((route) => route.source), ...(model.layouts ?? []).map((layout) => layout.source), ...(model.providers ?? []).map((provider) => provider.source), @@ -115,9 +140,21 @@ export const cliBinRslibEntries = ( const workerFile = `${entry.name}-flight.mjs`; const entries: RslibEntry[] = [{ // The artifact-hosted bin applies the pack's operator `.env` layer (#469). - aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath(), [launchEnvRuntimeSpecifier]: launchEnvRuntimePath() }, + aliases: { + [cliEntryRuntimeSpecifier]: cliEntryRuntimePath(), + [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), + ...(entry.bin.web === true ? { [webHostRuntimeSpecifier]: webHostRuntimePath() } : {}), + }, name: `bin-${entry.name}`, - virtualModules: [operatorEnvLayerVirtualModule()], + virtualModules: [ + operatorEnvLayerVirtualModule(), + ...(entry.bin.web === true + ? [{ + name: webHostPageVirtualModuleSpecifier, + source: `const script = ${JSON.stringify(webHostPageScript)}; export default script;`, + }] + : []), + ], outputRelativePath: cliBinArtifactPath(entry.name), ...(entry.rendered ? { rscManifest: true as const } : {}), source: entry.source, @@ -137,6 +174,14 @@ export const cliBinRslibEntries = ( // the same fallback the generated MCP worker beside it uses, so a // co-installed CLI and server observe one store. stateFallback: 'artifact', + ...(entry.bin.web === true + ? { + web: { + manifestRelativeUrl: '../agent-bundle.manifest.json', + pluginRootRelativeUrl: '../', + }, + } + : {}), ...(entry.rendered ? { workerFile } : {}), }), }]; @@ -198,7 +243,13 @@ export const planCliBinsSurface = ( }), }))); }, - ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath()), runtimeIgnoredRoot(launchEnvRuntimePath())], + ignoredSourcePaths: [ + runtimeIgnoredRoot(cliEntryRuntimePath()), + runtimeIgnoredRoot(launchEnvRuntimePath()), + ...(planned.some((entry) => entry.bin.web === true) + ? [runtimeIgnoredRoot(webHostRuntimePath())] + : []), + ], logLevel: 'error', }; }; diff --git a/packages/agent-bundle/src/build/compose.ts b/packages/agent-bundle/src/build/compose.ts index 0734dc7b1..ba451077e 100644 --- a/packages/agent-bundle/src/build/compose.ts +++ b/packages/agent-bundle/src/build/compose.ts @@ -12,7 +12,7 @@ import { type TargetHookEntry, } from '../adapters/types.ts'; import { installSurfaceEntries } from '../install/surface.ts'; -import { cliBinCollisionDiagnostics, targetHostsCliBin } from './cli-bins.ts'; +import { cliBinCollisionDiagnostics, targetHostsGeneratedBin } from './cli-bins.ts'; /** * One selected host's projection into the composite root (#555): the plan its @@ -246,7 +246,7 @@ export const planComposite = (model: NormalizedPlugin, registry: TargetRegistry) }); } } - const cliBin = targetHostsCliBin(registry, name); + const cliBin = targetHostsGeneratedBin(registry, model, name); if (cliBin) diagnostics.push(...cliBinCollisionDiagnostics(model, name, plan.entries)); return Object.freeze({ cliBin, name, plan }); }); diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index c88c70e13..c9ec1bfee 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -165,6 +165,10 @@ export const cliEntryRuntimeSpecifier = 'agent-bundle/cli-entry'; */ export const cliEntryRuntimePath = (): string => runtimeModulePath('cli-entry'); +export const webHostRuntimeSpecifier = 'agent-bundle/web-host'; + +export const webHostRuntimePath = (): string => runtimeModulePath('web-host'); + export const installEntryRuntimeSpecifier = 'agent-bundle/install-entry'; export const installEntryRuntimePath = (): string => runtimeModulePath('install-entry'); @@ -204,6 +208,10 @@ export interface GeneratedCliBinEntryOptions { readonly state?: NormalizedStateDefinition; /** Durable-state anchor fallback; defaults to `cwd` (the npm package bin). */ readonly stateFallback?: GeneratedStateFallback; + readonly web?: { + readonly manifestRelativeUrl: string; + readonly pluginRootRelativeUrl: string; + }; /** The sibling react-server worker bundle; required when any command is rendered. */ readonly workerFile?: string; } @@ -214,15 +222,23 @@ export interface GeneratedCliBinEntryOptions { * the npm bin anchors on the caller's `.agent-bundle`. Emitted once per * generated module, ahead of every other `node:path` / `node:url` import. */ -const pluginRootImports = (fallback: GeneratedStateFallback): readonly string[] => - fallback === 'artifact' +const pluginRootImports = ( + fallback: GeneratedStateFallback, + relativeUrl?: string, +): readonly string[] => + fallback === 'artifact' || relativeUrl !== undefined ? ["import { fileURLToPath } from 'node:url';"] : ["import { join } from 'node:path';"]; -const pluginRootFallbackExpression = (fallback: GeneratedStateFallback): string => - fallback === 'artifact' - ? "fileURLToPath(new URL('..', import.meta.url))" - : "join(process.cwd(), '.agent-bundle')"; +const pluginRootFallbackExpression = ( + fallback: GeneratedStateFallback, + relativeUrl?: string, +): string => + relativeUrl !== undefined + ? `fileURLToPath(new URL(${JSON.stringify(relativeUrl)}, import.meta.url))` + : fallback === 'artifact' + ? "fileURLToPath(new URL('..', import.meta.url))" + : "join(process.cwd(), '.agent-bundle')"; /** * The one plugin-root resolution of a generated module (#468): the SQLite @@ -230,8 +246,11 @@ const pluginRootFallbackExpression = (fallback: GeneratedStateFallback): string * module opens read `pluginRoot`, so `(await agent()).plugin.stateRoot` is the * directory they mount by construction. */ -const pluginRootDeclaration = (fallback: GeneratedStateFallback): string => - `const pluginRoot = resolvePluginRoot({ fallback: ${pluginRootFallbackExpression(fallback)} });`; +const pluginRootDeclaration = ( + fallback: GeneratedStateFallback, + relativeUrl?: string, +): string => + `const pluginRoot = resolvePluginRoot({ fallback: ${pluginRootFallbackExpression(fallback, relativeUrl)} });`; const generatedStateImports = ( state: NormalizedStateDefinition | undefined, @@ -364,9 +383,16 @@ const renderedSessionSource = (workerFile: string): readonly string[] => [ * exit 2); the route module's zod schemas stay the runtime validation * boundary. */ -export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions): string => { - const commandRoutes = options.routes.filter((route) => - options.commands.some((command) => command.routeId === route.id)); +export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): string => { + const commandRoutes = input.routes.filter((route) => + input.commands.some((command) => command.routeId === route.id)); + // A bin that compiles no command opens no request scope (a `web`-only + // plugin, #564): it mounts neither the project's state nor its providers + // and never imports the optional `@agent-bundle/runtime` they need, so a + // state or provider module's evaluation cannot keep ` web` from + // starting. + const runtimeBacked = commandRoutes.length > 0; + const options: GeneratedCliBinEntryOptions = runtimeBacked ? input : { ...input, providers: [], state: undefined }; const rendered = options.commands.some((command) => command.rendered); if (rendered && options.workerFile === undefined) { throw new Error('A generated CLI with rendered commands requires a worker file.'); @@ -382,16 +408,28 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) // npm package bin runs from the operator's own shell and reads none. ...(stateFallback === 'artifact' ? [operatorEnvLayerImport] : []), `import { cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, - rendered - ? "import { available, createAgentRenderDispatcher, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';" - : "import { available, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", - ...pluginRootImports(stateFallback), + ...(options.web === undefined + ? [] + : [ + `import { runWebCommand } from ${JSON.stringify(webHostRuntimeSpecifier)};`, + "import webHostPage from 'agent-bundle/web-host-page';", + ]), + ...(runtimeBacked + ? [rendered + ? "import { available, createAgentRenderDispatcher, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';" + : "import { available, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';"] + : []), + ...pluginRootImports(stateFallback, options.web?.pluginRootRelativeUrl), ...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []), ...generatedStateImports(options.state), ...routeImports(commandRoutes), ...providerImports(providers), '', - pluginRootDeclaration(stateFallback), + ...(runtimeBacked ? [pluginRootDeclaration(stateFallback, options.web?.pluginRootRelativeUrl)] : []), + // Launch from the artifact carrying the manifest, not an environment override. + ...(options.web === undefined + ? [] + : [`const artifactRoot = ${pluginRootFallbackExpression(stateFallback, options.web.pluginRootRelativeUrl)};`]), ...generatedStateOwner(options.state, options), 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', ...providerRegistrySource(providers), @@ -415,6 +453,10 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) // Plain commands mount the same conventional providers as every other // generated request scope (#313, #459): once per request, in deterministic // key order, fail-closed, as the typed Agent request's own resolver. + ...(runtimeBacked ? [] : [ + "const execute = async (command) => { throw new TypeError(`This plugin compiles no CLI command (${command.path.join(' ')}).`); };", + ]), + ...(runtimeBacked ? [ 'const execute = async (command, input, context) => {', ' const route = routes[command.routeId];', " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated CLI route must default-export an async function.');", @@ -450,6 +492,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ? [] : [' } finally {', ' await bindings.close();', ' }']), '};', + ] : []), '', ...(rendered ? [ @@ -492,6 +535,19 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ` name: ${JSON.stringify(options.plugin.name)},`, ...(rendered ? [' render,'] : []), ` version: ${JSON.stringify(options.plugin.version)},`, + ...(options.web === undefined + ? [] + : [ + ' web: Object.freeze({', + ' run: (argv, context) => runWebCommand({', + ' argv,', + ` manifestPath: fileURLToPath(new URL(${JSON.stringify(options.web.manifestRelativeUrl)}, import.meta.url)),`, + ' pageScript: webHostPage,', + ' pluginRoot: artifactRoot,', + ' ...context,', + ' }),', + ' }),', + ]), '});', ...(options.state === undefined ? [] diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index 5b91c530a..70cf0e82f 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -6,6 +6,7 @@ import { } from '../core/runtime.ts'; import { isValidPackageName, isValidPackageVersion } from '../core/project-context.ts'; import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import { parseWebManifest, type WebManifest } from '../web-host/manifest.ts'; export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt'; export type ArtifactManifestValidationStatus = 'passed'; @@ -88,6 +89,7 @@ export interface ArtifactManifest { readonly runtime: ArtifactManifestRuntime; readonly targets: readonly ArtifactManifestTarget[]; readonly validation: ArtifactManifestValidation; + readonly web?: WebManifest; } export interface AssembledArtifactManifest { @@ -300,7 +302,12 @@ const parseRuntime = (value: unknown): ArtifactManifestRuntime => { const validateManifest = (value: unknown): ArtifactManifest => { const manifest = requireRecord(value, 'root'); - requireExactKeys(manifest, 'root', ['agentSkills', 'files', 'producer', 'project', 'runtime', 'targets', 'validation']); + requireExactKeys( + manifest, + 'root', + ['agentSkills', 'files', 'producer', 'project', 'runtime', 'targets', 'validation'], + ['web'], + ); const agentSkills = requireRecord(manifest.agentSkills, 'agentSkills'); requireExactKeys(agentSkills, 'agentSkills', ['schemaSha256', 'sourceRevision', 'specification']); @@ -382,6 +389,7 @@ const validateManifest = (value: unknown): ArtifactManifest => { runtime: parseRuntime(manifest.runtime), targets, validation, + ...(manifest.web === undefined ? {} : { web: parseWebManifest(manifest.web) }), }; }; diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index 060e663af..a68f06291 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -354,10 +354,25 @@ export interface RunGeneratedCliOptions { */ readonly terminal?: AgentTerminal; readonly version: string; + /** Framework-owned command dispatched before authored routes (#564). */ + readonly web?: GeneratedCliWebCommand; readonly writeErr?: (text: string) => void; readonly writeOut?: (text: string) => void; } +export interface GeneratedCliWebContext { + readonly name: string; + readonly signal: AbortSignal; + readonly writeErr: (text: string) => void; + readonly writeOut: (text: string) => void; +} + +export interface GeneratedCliWebCommand { + readonly run: (argv: readonly string[], context: GeneratedCliWebContext) => Promise; +} + +const webCommandRow: readonly [string, string] = ['web', "Open one of the plugin's MCP Apps in a browser."]; + interface CommandTreeNode { readonly children: Map; command?: CompiledCliCommand; @@ -467,6 +482,7 @@ const treeHelp = ( version: string, description: string | undefined, node: CommandTreeNode, + web: boolean, ): string => { const lines: string[] = []; if (node.path.length === 0) { @@ -484,6 +500,10 @@ const treeHelp = ( const label = child.command === undefined ? `${segment} ` : segment; rows.push([label, child.command?.description ?? '']); } + if (web && node.path.length === 0) { + const at = rows.findIndex(([label]) => label.localeCompare(webCommandRow[0]) > 0); + rows.splice(at === -1 ? rows.length : at, 0, webCommandRow); + } lines.push('', 'Commands:', helpColumns(rows)); lines.push('', 'Options:', helpColumns(globalOptionRows)); return `${lines.join('\n')}\n`; @@ -881,16 +901,20 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro let node = tree; let index = 0; let parsed: ParsedArgv | undefined; + const web = options.web !== undefined; try { if (options.argv[0] === '--version') { writeOut(`${options.name} ${options.version}\n`); return 0; } + if (options.web !== undefined && options.argv[0] === 'web') { + return await options.web.run(options.argv.slice(1), { name: options.name, signal, writeErr, writeOut }); + } while (index < options.argv.length) { const token = options.argv[index]!; if (token === '--help' || token === '-h') { writeOut(node.command === undefined - ? treeHelp(options.name, options.version, options.description, node) + ? treeHelp(options.name, options.version, options.description, node, web) : commandHelp(options.name, node.command)); return 0; } @@ -906,7 +930,7 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro } if (node.command === undefined) { if (node.path.length === 0 && index >= options.argv.length) { - writeOut(treeHelp(options.name, options.version, options.description, node)); + writeOut(treeHelp(options.name, options.version, options.description, node, web)); return 0; } const token = options.argv[index]; diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 21b098b96..f30574baa 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -489,6 +489,9 @@ const humanDoctor = (result: DoctorReport): string => { `${file.path} (${file.state === 'present' ? `${String(file.variables ?? 0)} variable${file.variables === 1 ? '' : 's'}` : file.state})`).join(', ')}\n`); } } + if (result.web !== undefined) { + out.push(`${result.web.line}\n`); + } out.push( `runtime endpoints: ${result.endpoints.status}; ${result.endpoints.summary.live} live, ` + `${result.endpoints.summary.staleSockets} stale socket(s), ` + diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index cba213b70..23368c08b 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -14,6 +14,7 @@ import { satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; import { isRecord } from '../core/strict-json.ts'; +import { isServeAppAllowCapability } from '../core/mcp-app-allow.ts'; import { conventionalEntryAt } from './conventional-entry.ts'; import { pluginIdentity } from './plugin-identity.ts'; import { @@ -59,6 +60,8 @@ import type { NormalizedScript, NormalizedSkill, NormalizedStateDefinition, + NormalizedWeb, + NormalizedWebApp, SourceProvenance, } from '../core/types.ts'; import { appRouteTemplatePath, resolveAppRouteTemplate } from '../routes/app-template.ts'; @@ -218,29 +221,31 @@ export const configuredArtifactDistPath = ( }; /** - * The framework-generated routed-CLI bin (#102 stage 2): a generated-mode - * `src/cli/**` surface with at least one compiled command becomes one - * executable named after the plugin, exactly where the `src/cli.ts` - * convention would have placed it. Rendered routes that compiled no command - * are hard source-validation errors (AB4816), so omitting them here is - * deterministic hygiene, never a silent choice. + * A configured web surface rides the generated routed-CLI executable and + * creates it when the plugin has no command route (#564). */ const generatedCliBinEntry = ( config: Readonly, + configPath: string, routeCli: CompiledCliSurface | undefined, ): NormalizedBinEntry | undefined => { - if (routeCli?.mode !== 'generated' || !safePackageOutputName(config.plugin.name)) return undefined; - const commands = routeCli.commands ?? []; - if (commands.length === 0) return undefined; + if (!safePackageOutputName(config.plugin.name)) return undefined; + const web = Array.isArray(config.web?.apps) && config.web.apps.length > 0; + const generatedMode = routeCli?.mode === 'generated'; + const commands = generatedMode ? (routeCli.commands ?? []) : []; + if (commands.length === 0 && !web) return undefined; const commandRouteIds = new Set(commands.map((command) => command.routeId)); - const routes = routeCli.routes.filter((route) => commandRouteIds.has(route.id)); - const source = routes[0]!.source; + const routes = generatedMode + ? routeCli.routes.filter((route) => commandRouteIds.has(route.id)) + : []; + const source = routes[0]?.source ?? configPath; return { generatedCli: { commands, routes }, id: `bin:${config.plugin.name}`, name: config.plugin.name, provenance: { kind: 'conventional', sourcePath: source }, source, + ...(web ? { web: true } : {}), }; }; @@ -250,8 +255,8 @@ const normalizeBinEntries = ( configPath: string, routeCli: CompiledCliSurface | undefined, ): readonly NormalizedBinEntry[] => { + const generated = generatedCliBinEntry(config, configPath, routeCli); if (config.bin === false) return []; - const generated = generatedCliBinEntry(config, routeCli); if (config.bin !== undefined) { const explicit = Object.entries(config.bin) .sort(([left], [right]) => left.localeCompare(right)) @@ -266,13 +271,16 @@ const normalizeBinEntries = ( }; }); // Config always wins one name: an explicit bin claiming the plugin name - // shadows the generated CLI, and source validation reports the collision. + // shadows the generated CLI, and validation reports the collision + // (source validation for command routes, AB4341 for the web surface). return generated === undefined || explicit.some((entry) => entry.name === generated.name) ? explicit : [...explicit, generated].sort((left, right) => left.name.localeCompare(right.name)); } - if (generated !== undefined) return [generated]; const conventional = conventionalCliEntrySource(root); + // A hand-written `src/cli.ts` keeps its executable: `web` alone never + // replaces authored code, and AB4341 names the collision instead. + if (generated !== undefined && ((generated.generatedCli?.commands.length ?? 0) > 0 || conventional === undefined)) return [generated]; if (conventional === undefined || !safePackageOutputName(config.plugin.name)) return []; return [{ id: `bin:${config.plugin.name}`, @@ -891,6 +899,41 @@ const normalizeMcpApps = ( return apps.sort((left, right) => left.id.localeCompare(right.id)); }; +const normalizeWeb = ( + loaded: LoadedConfig, + apps: readonly NormalizedMcpApp[], +): NormalizedWeb | undefined => { + const configured = loaded.config.web; + if (configured === undefined || !Array.isArray(configured.apps)) return undefined; + const normalizedApps: NormalizedWebApp[] = configured.apps.map((value) => { + const declaration = typeof value === 'string' ? { app: value } : value; + const selector = typeof declaration.app === 'string' ? declaration.app : ''; + const separator = selector.indexOf('/'); + const serverName = separator < 0 ? selector : selector.slice(0, separator); + const appName = separator < 0 ? '' : selector.slice(separator + 1); + const resolved = apps.find((app) => app.serverName === serverName && app.name === appName); + const allow = Array.isArray(declaration.allow) + ? declaration.allow.filter((capability: unknown): capability is NormalizedWebApp['allow'][number] => + typeof capability === 'string' && isServeAppAllowCapability(capability)) + : []; + return { + allow, + app: selector, + appName, + ...(isRecord(declaration.input) ? { input: structuredClone(declaration.input) } : {}), + resourceUri: resolved?.resourceUri ?? '', + serverId: resolved?.serverId ?? `mcp:${serverName}`, + serverName, + ...(typeof declaration.tool === 'string' ? { tool: declaration.tool } : {}), + }; + }); + return { + apps: normalizedApps, + open: configured.open === 'browser' ? 'browser' : 'never', + provenance: { sourcePath: loaded.configPath }, + }; +}; + const bundleExtensions = new Set([ '.js', '.jsx', @@ -1258,6 +1301,8 @@ export const normalizeProject = async ( const nativeHooks = await normalizeNativeHooks(loaded, targetNames, registry); const payloads = normalizePayloads(loaded, discovered, targetNames); const mcpServers = normalizeMcpServers(loaded, discovered, targetNames, payloads); + const mcpApps = normalizeMcpApps(loaded, discovered, mcpServers); + const web = normalizeWeb(loaded, mcpApps); const scripts = normalizeScripts(loaded, discovered, targetNames); const assets = normalizeAssets(loaded, discovered, targetNames); const commands = normalizeCommands(discovered, targetNames); @@ -1300,7 +1345,7 @@ export const normalizeProject = async ( provenance: configProvenance, version: identity.version, }, - mcpApps: normalizeMcpApps(loaded, discovered, mcpServers), + mcpApps, mcpServers, hooks: normalizeHooks(loaded, discovered, targetNames, registry, payloads), ...(nativeHooks.length === 0 ? {} : { nativeHooks }), @@ -1318,6 +1363,7 @@ export const normalizeProject = async ( name, provenance: { ...configProvenance }, })), + ...(web === undefined ? {} : { web }), }; return deepFreeze(model); diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 0d46464c5..c010dfb25 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; -import { capabilityIsSupported, cliBinCapability } from '../adapters/capability-state.ts'; +import { capabilityIsSupported, cliBinCapability, webSurfaceCapability } from '../adapters/capability-state.ts'; import { builtInHostNames, isBuiltInHost } from '../adapters/composite-layout.ts'; import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts'; import { frameworkOwnedPluginCollisions, frameworkOwnedRsbuildPlugins } from '../build/framework-plugins.ts'; @@ -25,6 +25,7 @@ import { canonicalHookEvents, isPrebuiltEntryInput, parseNativeHookToolSelector import { type RouteModuleExports, scanRouteModuleExports } from '../routes/contract.ts'; import { mcpRouteProtocolName } from '../routes/protocol-name.ts'; import { featureCapabilityName } from '../core/components.ts'; +import { isServeAppAllowCapability } from '../core/mcp-app-allow.ts'; import type { AgentBundleBinEntry, AgentBundleHookEntry, @@ -944,6 +945,33 @@ const validateMcp = ( }); }; +const validateWebSource = (loaded: LoadedConfig): Diagnostic[] => { + const value = loaded.config.web as unknown; + if (value === undefined || !isRecord(value)) return []; + const diagnostics: Diagnostic[] = []; + if (value.open !== undefined && value.open !== 'browser' && value.open !== 'never') { + diagnostics.push(sourceDiagnostic( + 'AB4341', + 'web.open must be "browser" or "never".', + loaded.configPath, + )); + } + if (!Array.isArray(value.apps)) return diagnostics; + value.apps.forEach((candidate, index) => { + if (!isRecord(candidate) || !Array.isArray(candidate.allow)) return; + for (const capability of candidate.allow) { + if (typeof capability === 'string' && isServeAppAllowCapability(capability)) continue; + diagnostics.push(sourceDiagnostic( + 'AB4341', + `web.apps[${index}] allows ${String(capability)}, which is not an App-initiated consent capability.`, + loaded.configPath, + 'Use one of: call-tool, download-file, open-external-link, request-display-mode; browser hardware and clipboard permissions always ask in the host page.', + )); + } + }); + return diagnostics; +}; + const portableFrontmatterKeys = [ 'allowed-tools', 'compatibility', @@ -2205,6 +2233,7 @@ export const validateSource = ( diagnostics.push(...validateHooks(loaded, registry, payloads)); diagnostics.push(...validateLib(loaded)); diagnostics.push(...validateMcp(loaded, discovered, registry, payloads)); + diagnostics.push(...validateWebSource(loaded)); diagnostics.push(...validateOutput(loaded)); diagnostics.push(...validatePayload(loaded, registry, options?.payloadFreshness !== false)); diagnostics.push(...validateRuntime(loaded)); @@ -2263,46 +2292,11 @@ const routedCliBinTargetDiagnostics = ( registry: NormalizationTargetRegistry, ): Diagnostic[] => { const diagnostics: Diagnostic[] = []; - // The judgment must be the one emission and `inspect` use: the adapter's - // component override when published, otherwise its plain capabilities. A - // registry exposing neither accessor falls back to its boolean view. - const judgmentFor = (target: string): { readonly known: true; readonly state: CapabilityState | undefined } | { readonly known: false } => { - if (registry.componentCapabilityState !== undefined) { - return { known: true, state: registry.componentCapabilityState(target, cliBinCapability) }; - } - if (registry.capabilityState !== undefined) { - return { known: true, state: registry.capabilityState(target, cliBinCapability) }; - } - return { known: false }; - }; for (const bin of model.packageBuild?.bins ?? []) { if (bin.generatedCli === undefined) continue; for (const target of model.targets) { - if (!registry.has(target.name)) continue; - const judged = judgmentFor(target.name); - const supported = judged.known - ? capabilityIsSupported(judged.state) - : registry.supports(target.name, cliBinCapability); - if (supported) continue; - const capability = judged.known ? judged.state : undefined; - let judgment: string; - if (capability === undefined) { - judgment = `the target publishes no ${cliBinCapability} capability row`; - } else { - switch (capability.state) { - case 'supported': - continue; - case 'degraded': - case 'unavailable': - case 'prohibited': - judgment = `its ${cliBinCapability} capability is ${capability.state}: ${capability.reason}`; - break; - default: { - const exhaustive: never = capability; - return exhaustive; - } - } - } + const judgment = unsupportedCapabilityJudgment(registry, target.name, cliBinCapability); + if (judgment === undefined) continue; diagnostics.push({ code: 'AB4765', message: `Routed CLI ${JSON.stringify(bin.name)} is not emitted into target ${JSON.stringify(target.name)}: ${judgment}. Skills, hooks, and scripts in that artifact cannot invoke bin/${bin.name}.mjs.`, @@ -2316,6 +2310,38 @@ const routedCliBinTargetDiagnostics = ( return diagnostics; }; +/** + * Why `target` does not host the component `capability` gates, or + * `undefined` when it does. The judgment must be the one emission and + * `inspect` use: the adapter's component override when published, otherwise + * its plain capabilities. A registry exposing neither accessor falls back to + * its boolean view. Unknown targets are AB4100's, not judged here. + */ +const unsupportedCapabilityJudgment = ( + registry: NormalizationTargetRegistry, + target: string, + capability: string, +): string | undefined => { + if (!registry.has(target)) return undefined; + const judge = registry.componentCapabilityState ?? registry.capabilityState; + const noRow = `the target publishes no ${capability} capability row`; + if (judge === undefined) return registry.supports(target, capability) ? undefined : noRow; + const state = judge.call(registry, target, capability); + if (state === undefined) return noRow; + switch (state.state) { + case 'supported': + return undefined; + case 'degraded': + case 'unavailable': + case 'prohibited': + return `its ${capability} capability is ${state.state}: ${state.reason}`; + default: { + const exhaustive: never = state; + return exhaustive; + } + } +}; + /** * One composite root is shared by the built-in hosts only (#555): their * projections agree on where the files they cannot share live, which @@ -2349,6 +2375,105 @@ const compositeRootTargetDiagnostics = ( })); }; +const webToolResourceUri = (route: { readonly config: Readonly> }): string | undefined => { + const metadata = route.config['_meta']; + if (!isRecord(metadata)) return undefined; + const ui = metadata['ui']; + return isRecord(ui) && typeof ui['resourceUri'] === 'string' ? ui['resourceUri'] : undefined; +}; + +const webDiagnostics = (model: NormalizedPlugin, registry: NormalizationTargetRegistry): Diagnostic[] => { + if (model.web === undefined) return []; + const diagnostics: Diagnostic[] = []; + const seen = new Set(); + for (const [index, app] of model.web.apps.entries()) { + const declared = (model.mcpApps ?? []).some((candidate) => + candidate.serverName === app.serverName && candidate.name === app.appName); + if (!declared) { + diagnostics.push(sourceDiagnostic( + 'AB4341', + `web.apps[${index}] names ${app.app}, which no mcp.servers..apps entry declares.`, + model.web.provenance.sourcePath, + `Declare the App under mcp.servers.${app.serverName}.apps or remove it from web.apps.`, + )); + } + if (seen.has(app.app)) { + diagnostics.push(sourceDiagnostic( + 'AB4341', + `web.apps[${index}] names ${app.app} twice.`, + model.web.provenance.sourcePath, + 'List each App once.', + )); + } + seen.add(app.app); + + if (app.tool !== undefined) { + const server = model.mcpServers.find((candidate) => candidate.id === app.serverId); + if (server?.generatedRoutes !== undefined) { + const tool = server.generatedRoutes.find((route) => + route.kind === 'tool' && + mcpRouteProtocolName(route.id) === app.tool && + webToolResourceUri(route) === app.resourceUri); + if (tool === undefined) { + diagnostics.push(sourceDiagnostic( + 'AB4341', + `web.apps[${index}].tool ${app.tool} is not a tool this project's route graph declares for ${app.serverName}.`, + model.web.provenance.sourcePath, + `Name a tool whose _meta.ui.resourceUri is ${app.resourceUri}, or omit tool when exactly one such tool exists.`, + )); + } + } + } + } + const bins = model.packageBuild?.bins ?? []; + for (const bin of bins) { + for (const command of bin.generatedCli?.commands ?? []) { + // The generated shell dispatches a first argument of `web` before the + // authored tree, so a top-level command or alias spelled `web` is + // unreachable, not merely shadowed in help. + const spelling = command.path[0] === 'web' + ? 'command' + : command.path.length === 1 && command.aliases.includes('web') ? 'alias' : undefined; + if (spelling === undefined) continue; + diagnostics.push(sourceDiagnostic( + 'AB4341', + `CLI ${spelling} "web"${spelling === 'alias' ? ` of ${command.path.join(' ')}` : ''} is reserved by the web surface (web.apps is configured).`, + bin.generatedCli?.routes.find((route) => route.id === command.routeId)?.source ?? bin.provenance.sourcePath, + `Rename the ${spelling} or remove web.apps.`, + )); + } + } + if (model.web.apps.length > 0 && !bins.some((bin) => bin.web === true)) { + const owner = bins.find((bin) => bin.name === model.metadata.name); + diagnostics.push(sourceDiagnostic( + 'AB4341', + owner === undefined + ? 'web.apps is configured, but no framework-generated executable carries the web command (bin is false, or the plugin name is not a safe executable name).' + : `web.apps is configured, but ${owner.provenance.kind === 'config' ? 'the bin config' : 'src/cli.ts'} owns the ${JSON.stringify(owner.name)} executable, so the framework-generated web command has nowhere to live.`, + model.web.provenance.sourcePath, + owner === undefined + ? 'Remove bin: false (or choose a safe plugin name), or remove web.apps.' + : 'Move that executable\'s commands under src/cli/** so the framework generates the bin, or remove web.apps.', + )); + } + // The `web` capability row gates the web-only bin's emission + // (`targetHostsGeneratedBin`) the way `cli` gates a routed CLI (AB4765); + // a target that does not publish it is told so here, never silently. + for (const target of model.targets) { + const judgment = unsupportedCapabilityJudgment(registry, target.name, webSurfaceCapability); + if (judgment === undefined) continue; + diagnostics.push({ + code: 'AB4341', + message: `The web surface is not hosted by target ${JSON.stringify(target.name)}: ${judgment}. Its artifact carries no working ${model.metadata.name} web command.`, + recovery: `Publish a supported ${webSurfaceCapability} capability on the ${target.name} adapter, or drop the target.`, + severity: 'warning', + sourcePath: model.web.provenance.sourcePath, + target: target.name, + }); + } + return diagnostics; +}; + export const validateModel = ( model: NormalizedPlugin, registry: NormalizationTargetRegistry, @@ -2370,6 +2495,7 @@ export const validateModel = ( diagnostics.push(...compositeRootTargetDiagnostics(model, registry)); diagnostics.push(...routedCliBinTargetDiagnostics(model, registry)); + diagnostics.push(...webDiagnostics(model, registry)); const ids = new Map(); const components = [ diff --git a/packages/agent-bundle/src/contracts/mcp-apps.ts b/packages/agent-bundle/src/contracts/mcp-apps.ts index 04009e552..7c7d427f7 100644 --- a/packages/agent-bundle/src/contracts/mcp-apps.ts +++ b/packages/agent-bundle/src/contracts/mcp-apps.ts @@ -1,3 +1,6 @@ +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. */ export { MCP_APP_PROFILE_DESCRIPTORS } from '../dev/mcp-app-profile-descriptors.ts'; export type { McpAppProfileId } from '../dev/mcp-app-profile-descriptors.ts'; @@ -12,6 +15,7 @@ export { runtimeAppMessageLimits, } from '../dev/runtime-app-message-limits.ts'; 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, @@ -34,3 +38,34 @@ export type { McpAppConsentRequest, McpAppDocumentPolicySnapshot, } from '../dev/mcp-apps/mcp-app-sandbox.ts'; + +export interface McpAppRelayFrame { + readonly allow: string; + readonly documentPolicy?: Readonly<{ + readonly allow: string; + readonly approvedPermissions: McpAppJsonValue; + readonly revision: number; + readonly warnings: readonly McpAppJsonValue[]; + }>; + readonly policy: Readonly<{ + readonly contentSecurityPolicy: string; + readonly iframeAllow: string; + readonly permissionsPolicy: string; + }>; + readonly referrerPolicy: 'no-referrer'; + readonly relay: Readonly<{ readonly maxMessageBytes: number; readonly maxQueuedMessages: number }>; + readonly sandbox: 'allow-scripts allow-same-origin'; + readonly src: string; + readonly targetOrigin: string; +} + +export interface McpAppRouteMessages { + readonly accepted: boolean; + readonly lifecycle: McpAppBridgeLifecycle; + readonly messages: readonly McpAppJsonValue[]; +} + +export interface McpAppRouteClose { + readonly lifecycle: McpAppBridgeLifecycle; + readonly message?: McpAppJsonValue; +} diff --git a/packages/agent-bundle/src/core/dependency-manifest.ts b/packages/agent-bundle/src/core/dependency-manifest.ts index 753f7ce46..eede681b7 100644 --- a/packages/agent-bundle/src/core/dependency-manifest.ts +++ b/packages/agent-bundle/src/core/dependency-manifest.ts @@ -12,8 +12,8 @@ import { exists } from './paths.ts'; * throw, so the same ancestor walk is then performed by hand. * * Plain Node, no framework imports: the build's dependency-root discovery and - * `agent-bundle/serve-app-command`, which is bundled into generated - * executables, locate packages the same way. + * `agent-bundle/web-host`, which is bundled into generated executables, locate + * packages the same way. */ export const dependencyManifestPath = async (packageRoot: string, name: string): Promise => { try { diff --git a/packages/agent-bundle/src/core/mcp-app-allow.ts b/packages/agent-bundle/src/core/mcp-app-allow.ts new file mode 100644 index 000000000..ac3027220 --- /dev/null +++ b/packages/agent-bundle/src/core/mcp-app-allow.ts @@ -0,0 +1,17 @@ +/** + * App-initiated consent capabilities an operator may approve in advance. + * Browser hardware and clipboard permissions always require a host-page + * decision. + */ +export const serveAppAllowCapabilities = Object.freeze([ + 'call-tool', + 'download-file', + 'open-external-link', + 'request-display-mode', +] as const); + +export type ServeAppAllowCapability = (typeof serveAppAllowCapabilities)[number]; + +/** Whether a value belongs to the pre-approvable App consent vocabulary. */ +export const isServeAppAllowCapability = (value: string): value is ServeAppAllowCapability => + (serveAppAllowCapabilities as readonly string[]).includes(value); diff --git a/packages/agent-bundle/src/core/mcp-state-directory.ts b/packages/agent-bundle/src/core/mcp-state-directory.ts new file mode 100644 index 000000000..3f574089a --- /dev/null +++ b/packages/agent-bundle/src/core/mcp-state-directory.ts @@ -0,0 +1,10 @@ +import { sha256Hex } from './digest.ts'; + +const safeStateSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; + +/** + * Converts an arbitrary MCP server name into one safe state-directory segment. + * Plain segments remain readable; unsafe names become content-addressed. + */ +export const mcpServerStateDirectory = (server: string): string => + safeStateSegment.test(server) ? server : `server-${sha256Hex(server).slice(0, 16)}`; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 782b125c8..359e3dfc1 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -14,6 +14,7 @@ import type { } from '../routes/types.ts'; import type { SkillHostDocument, SkillIr, SkillTreeLayoutDecision } from '../skills/ir.ts'; import type { CapabilityState } from './capabilities.ts'; +import type { ServeAppAllowCapability } from './mcp-app-allow.ts'; export interface AgentBundlePluginConfig { description?: string; @@ -146,6 +147,20 @@ export interface AgentBundleMcpConfig { servers: Readonly>; } +export interface AgentBundleWebAppConfig { + /** `/` */ + app: string; + allow?: readonly ServeAppAllowCapability[]; + input?: Readonly>; + tool?: string; +} + +export interface AgentBundleWebConfig { + apps: ReadonlyArray; + /** Whether the generated command opens the system browser. Defaults to `never`. */ + open?: 'browser' | 'never'; +} + /** Optional artifact output location config, inspired by Rsbuild's `output.distPath`. */ export interface AgentBundleOutputConfig { /** @@ -332,6 +347,7 @@ export interface AgentBundleConfig extends AgentBundleConfigExtensions { state?: AgentBundleStateConfig; targets?: string[]; tools?: AgentBundleToolsConfig; + web?: AgentBundleWebConfig; [key: string]: unknown; } @@ -480,6 +496,23 @@ export interface NormalizedMcpApp { readonly template?: string; } +export interface NormalizedWebApp { + readonly allow: readonly ServeAppAllowCapability[]; + readonly app: string; + readonly appName: string; + readonly input?: Readonly>; + readonly resourceUri: string; + readonly serverId: string; + readonly serverName: string; + readonly tool?: string; +} + +export interface NormalizedWeb { + readonly apps: readonly NormalizedWebApp[]; + readonly open: 'browser' | 'never'; + readonly provenance: { readonly sourcePath: string }; +} + export interface NormalizedScript { readonly id: string; readonly mode: 'bundle' | 'copy'; @@ -502,6 +535,7 @@ export interface NormalizedBinEntry { readonly name: string; readonly provenance: SourceProvenance; readonly source: string; + readonly web?: true; } /** The normalized single-entry ESM+dts library output of the package build. */ @@ -743,6 +777,7 @@ export interface NormalizedPlugin { readonly skills: readonly NormalizedSkill[]; readonly state?: NormalizedStateDefinition; readonly targets: readonly NormalizedTarget[]; + readonly web?: NormalizedWeb; } export interface NormalizationConfigExtension { diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 9aef368e9..ef2a2c233 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -27,6 +27,7 @@ import { PlaygroundRoutes, type PlaygroundRouteService } from './playground/play import { RouteManifestRoutes, type RouteManifestRouteService } from './routes/route-manifest-routes.ts'; import { SkillDocumentError, type SkillDocumentService } from './skill-document-service.ts'; import type { Invalidation, ProjectEventMessage, ProjectStatus } from './types.ts'; +import { WebHostRoutes, type WebHostEpochSource } from './web-host-routes.ts'; import { diagnostic, isJsonRequest, @@ -157,11 +158,15 @@ export interface ForegroundServerOptions { /** The project-owned Eval service closes after foreground Eval routes and Agent API admissions drain. */ readonly evalLifecycle?: Readonly<{ close(): Promise }>; readonly eventHub: ProjectEventHub; + /** Active composite artifact epochs used by the development Web host. */ + readonly epochs?: WebHostEpochSource; readonly host?: string; /** Injectable only to make restart-recovery contracts deterministic. */ readonly instanceId?: string; /** Already-bound MCP App previews, never executable data supplied by a browser request. */ readonly mcpAppPreviews?: McpAppRoutePreviewService; + /** Deferred until the foreground origin has its distinct loopback App sandbox. */ + readonly mcpAppSandboxOrigin?: () => string | undefined; /** Epoch-bound hook playground service; the browser never selects a wrapper or artifact path. */ readonly hookPlayground?: HookPlaygroundRouteService; /** Read-only host probes, install inventory, bundle drift, and runtime endpoint health. */ @@ -416,6 +421,7 @@ export class ForegroundServer { readonly #sockets = new Set(); readonly #streamSubscriptions = new Set(); readonly #testing: ForegroundServerTesting | undefined; + readonly #webHostRoutes: WebHostRoutes; readonly #workbenchDevOrigins: ReadonlySet; #closePromise: Promise | undefined; #closing = false; @@ -460,8 +466,17 @@ export class ForegroundServer { this.#testing = options.testing; this.sessionToken = options.sessionToken ?? randomUUID(); this.#workbenchDevOrigins = Object.freeze(new Set(workbenchDevOrigins)); + this.#webHostRoutes = new WebHostRoutes({ + authorize: (request) => this.#assertWebHostNavigation(request), + ...(options.epochs === undefined ? {} : { epochs: options.epochs }), + ...(options.mcpSessions === undefined ? {} : { mcpSessions: options.mcpSessions }), + ...(options.mcpAppPreviews === undefined ? {} : { previews: options.mcpAppPreviews }), + sandboxOrigin: options.mcpAppSandboxOrigin ?? (() => undefined), + sessionToken: this.sessionToken, + }); this.#mcpAppRoutes = new McpAppRoutes({ authorize: (request) => this.#assertMutationSession(request), + openingCall: (sessionId, toolName, opening) => this.#webHostRoutes.openingCall(sessionId, toolName, opening), ...(options.mcpAppPreviews === undefined ? {} : { service: options.mcpAppPreviews }), }); this.#mcpSessionRoutes = new McpSessionRoutes({ @@ -647,6 +662,7 @@ export class ForegroundServer { } async #release(): Promise { + this.#webHostRoutes.close(); this.#mcpAppRoutes.close(); this.#hostMcpRoutes?.close(); this.#mcpSessionRoutes.close(); @@ -799,6 +815,7 @@ export class ForegroundServer { if (method !== 'GET') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); return this.#streamEvents(request, response); } + if (await this.#webHostRoutes.handle(request, response)) return; return this.#serveAsset(request, response, method); } @@ -855,6 +872,18 @@ export class ForegroundServer { throw requestError(diagnostic('AB8003', 'Request origin is not this foreground server.', 403)); } + /** Top-level same-origin navigations have no Origin and may report `none`. */ + #assertWebHostNavigation(request: IncomingMessage): void { + const origin = singleHeader(request.headers.origin); + if (origin !== undefined) { + if (this.#isBrowserOrigin(origin)) return; + } else { + const site = singleHeader(request.headers['sec-fetch-site']); + if (site === undefined || site === 'none' || site === 'same-origin') return; + } + throw requestError(diagnostic('AB8003', 'Request origin is not this foreground server.', 403)); + } + /** Codex MCP clients may omit Origin; browsers with one must be this exact foreground origin. */ #assertAgentApiOrigin(request: IncomingMessage): void { const origin = singleHeader(request.headers.origin); 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 82643c86c..85d545b6e 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 @@ -106,9 +106,12 @@ export interface McpAppRoutesOptions { * page with the result). A create request that omits `input` and `result` * binds to this call, so a large result is never round-tripped through the * browser and past the request-body bound (#562); without it, both fields - * are required, as the Workbench sends them. + * are required, as the Workbench sends them. `opening` is the opaque + * per-page id a host that serves many pages over one session stamps into + * each page's seed, so concurrent pages never bind each other's call; a + * single-page host ignores it. */ - readonly openingCall?: (sessionId: string, toolName: string) => McpAppOpeningCall | undefined; + readonly openingCall?: (sessionId: string, toolName: string, opening: string | undefined) => McpAppOpeningCall | undefined; /** * Test-only override for the graceful-close receipt window. Production * callers must leave this unset so the window keeps dominating the frame @@ -312,19 +315,20 @@ const createRequest = ( sessionId: string, openingCall: McpAppRoutesOptions['openingCall'], ): Parameters[0] => { - if (!hasOnly(value, ['host', 'input', 'previewProfile', 'result', 'toolName']) || !nonemptyString(value.toolName) - || (value.previewProfile !== 'portable' && value.previewProfile !== 'chatgpt' && value.previewProfile !== 'claude')) { + if (!hasOnly(value, ['host', 'input', 'opening', 'previewProfile', 'result', 'toolName']) || !nonemptyString(value.toolName) + || (value.previewProfile !== 'portable' && value.previewProfile !== 'chatgpt' && value.previewProfile !== 'claude') + || (Object.hasOwn(value, 'opening') && !nonemptyString(value.opening))) { return invalidShape(); } - // A request carrying neither field binds the call the host already made; - // one carrying both is the Workbench's own tool run. Anything in between - // is malformed. + // A request carrying neither field binds the call the host already made + // (optionally naming which one with `opening`); one carrying both is the + // Workbench's own tool run. Anything in between is malformed. const carriesCall = Object.hasOwn(value, 'input') || Object.hasOwn(value, 'result'); const call = carriesCall - ? isJsonValue(value.input) && isJsonValue(value.result) + ? isJsonValue(value.input) && isJsonValue(value.result) && !Object.hasOwn(value, 'opening') ? { input: cloneJson(value.input), result: cloneJson(value.result) } : undefined - : openingCall?.(sessionId, value.toolName); + : openingCall?.(sessionId, value.toolName, typeof value.opening === 'string' ? value.opening : undefined); if (call === undefined) return invalidShape(); return Object.freeze({ host: hostContext(value.host), diff --git a/packages/agent-bundle/src/dev/watcher.ts b/packages/agent-bundle/src/dev/watcher.ts index 96d304297..cb1b0fa18 100644 --- a/packages/agent-bundle/src/dev/watcher.ts +++ b/packages/agent-bundle/src/dev/watcher.ts @@ -1,6 +1,6 @@ import chokidar from 'chokidar'; import { stat } from 'node:fs/promises'; -import { relative, resolve } from 'node:path'; +import { basename, dirname, relative, resolve } from 'node:path'; import { freezeInvalidation, type Invalidation } from './types.ts'; @@ -33,6 +33,20 @@ const relativePath = (root: string, path: string): string | undefined => { return value === '..' || value.startsWith('../') ? undefined : value; }; +/** + * Whether `source` is inside the staging directory an output root is + * assembled in: the package build (`package-build.ts`) and the artifact + * build (`build.ts`) `mkdtemp` a `..stage-XXXXXX` sibling of the + * output root and rename it into place, so those paths are build output + * too, not source. Without this, every `dist/` package build inside + * `agent-bundle dev` would invalidate the epoch it just produced. + */ +const isOutputStagingPath = (output: string, source: string): boolean => { + const parent = dirname(output); + const prefix = `${parent === '.' ? '' : `${parent}/`}.${basename(output)}.stage-`; + return source.startsWith(prefix); +}; + const defaultPathSignature = async (path: string): Promise => { try { const source = await stat(path, { bigint: true }); @@ -102,7 +116,7 @@ export class ProjectWatcher { if (source === undefined) return true; if (source.split('/').some((part) => excludedDirectoryNames.has(part))) return true; for (const ignored of this.#outputPaths) { - if (source === ignored || source.startsWith(`${ignored}/`)) return true; + if (source === ignored || source.startsWith(`${ignored}/`) || isOutputStagingPath(ignored, source)) return true; } return source.length > 0 && options.isIgnored?.(resolve(this.#root, path)) === true; }; diff --git a/packages/agent-bundle/src/dev/web-host-routes.ts b/packages/agent-bundle/src/dev/web-host-routes.ts new file mode 100644 index 000000000..79e418d6a --- /dev/null +++ b/packages/agent-bundle/src/dev/web-host-routes.ts @@ -0,0 +1,347 @@ +import type { Resource, Tool } from '@modelcontextprotocol/client'; +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { join } from 'node:path'; + +import { isRecord } from '../core/strict-json.ts'; +import type { + McpAppJsonValue, + McpAppSessionLease, + McpAppToolDefinition, +} from './mcp-apps/mcp-app-binding-service.ts'; +import { MCP_APP_MIME_TYPE } from './mcp-apps/mcp-app-bridge.ts'; +import type { + McpAppOpeningCall, + McpAppRoutePreviewService, +} from './mcp-apps/mcp-app-routes.ts'; +import { + canonicalMcpAppJson, + canonicalMcpAppTool, +} from './mcp-session/mcp-session-apps.ts'; +import type { McpSession } from './mcp-session/mcp-session.ts'; +import type { McpSessionService } from './mcp-session/mcp-session-service.ts'; +import { + decodedOpaqueSegment, + diagnostic, + isRequestDiagnostic, + rawPathname, + requestError, + responseDiagnostic, +} from './http.ts'; +import { + readWebManifest, + type WebManifestApp, +} from '../web-host/manifest.ts'; +import { renderWebHostPage, webHostContentSecurityPolicy } from '../web-host/page.ts'; +import { readWebHostPageScript } from '../web-host/page-script.ts'; +import { + openApp, + type AppSelectionSource, +} from '../web-host/select-app.ts'; + +const devWebHostTarget = 'portable'; +const manifestFileName = 'agent-bundle.manifest.json'; +const maxRetainedOpeningCalls = 64; + +interface WebHostEpochReference { + close(): Promise; + readonly epoch: Readonly<{ readonly id: string }>; + readonly root: string; +} + +export interface WebHostEpochSource { + acquireActiveEpochReference(): Promise; +} + +interface RegisteredSession { + readonly dispose: () => Promise; + readonly session: McpSession; +} + +export interface WebHostRoutesOptions { + readonly authorize: (request: IncomingMessage) => void; + readonly epochs?: WebHostEpochSource; + readonly mcpSessions?: McpSessionService; + readonly previews?: McpAppRoutePreviewService; + readonly sandboxOrigin: () => string | undefined; + readonly sessionToken: string; +} + +interface WebHostRoute { + readonly app: string; + readonly server: string; +} + +const routeSegment = (value: string): string => + decodedOpaqueSegment(value, { + code: 'AB8020', + message: 'Web host route path is not valid.', + rejectBlank: true, + }); + +const route = (requestTarget: string | undefined): WebHostRoute | false | undefined => { + const pathname = rawPathname(requestTarget); + if (pathname !== '/web' && !pathname.startsWith('/web/')) return undefined; + const parts = pathname.split('/'); + if (parts.length !== 4 || parts[0] !== '' || parts[1] !== 'web') return false; + return Object.freeze({ app: routeSegment(parts[3]!), server: routeSegment(parts[2]!) }); +}; + +const jsonInput = ( + value: Readonly> | undefined, +): Readonly> => { + const input = canonicalMcpAppJson(value ?? {}, 'MCP App opening input'); + if (!isRecord(input)) throw new TypeError('MCP App opening input must be a JSON object.'); + return input; +}; + +const selectionSource = (session: McpSession): AppSelectionSource => Object.freeze({ + callTool: async ( + name: string, + input: Readonly>, + ): Promise => canonicalMcpAppJson( + await session.callTool({ arguments: { ...input }, name }), + 'MCP App tool result', + ), + listAppResourceUris: async () => Object.freeze( + (await session.listResources()) + .filter((resource: Resource) => resource.mimeType === MCP_APP_MIME_TYPE) + .map((resource: Resource) => resource.uri), + ), + listToolDefinitions: async (): Promise => Object.freeze( + (await session.listTools()).map((tool: Tool) => canonicalMcpAppTool(tool).definition), + ), +}); + +const writePage = ( + response: ServerResponse, + method: string, + sandboxOrigin: string, + body: string, +): void => { + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-security-policy': webHostContentSecurityPolicy(sandboxOrigin), + 'content-type': 'text/html; charset=utf-8', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', + }); + response.end(method === 'HEAD' ? undefined : body); +}; + +/** Same-origin development entry point for Apps explicitly exposed by the active epoch. */ +export class WebHostRoutes { + readonly #authorize: (request: IncomingMessage) => void; + readonly #epochs: WebHostEpochSource | undefined; + readonly #mcpSessions: McpSessionService | undefined; + readonly #previews: McpAppRoutePreviewService | undefined; + readonly #sandboxOrigin: () => string | undefined; + readonly #sessionToken: string; + readonly #openingCalls = new Map(); + readonly #sessions = new Map>(); + #closed = false; + + constructor(options: WebHostRoutesOptions) { + this.#authorize = options.authorize; + this.#epochs = options.epochs; + this.#mcpSessions = options.mcpSessions; + this.#previews = options.previews; + this.#sandboxOrigin = options.sandboxOrigin; + this.#sessionToken = options.sessionToken; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#openingCalls.clear(); + const sessions = [...this.#sessions.values()]; + this.#sessions.clear(); + for (const session of sessions) { + void session.then((registered) => registered.dispose()).catch(() => undefined); + } + } + + /** + * Every `/web` page shares its server's session with every other page of + * that server, so a page binds only the call stamped into its own seed: + * without `opening`, two tabs on one tool would read each other's result. + */ + openingCall(sessionId: string, toolName: string, opening: string | undefined): McpAppOpeningCall | undefined { + return opening === undefined ? undefined : this.#openingCalls.get(this.#openingCallKey(sessionId, toolName, opening)); + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + const parsed = route(request.url); + if (parsed === undefined) return false; + if (parsed === false) { + responseDiagnostic(response, diagnostic('AB8020', 'Web host route was not found.', 404)); + return true; + } + this.#authorize(request); + const method = request.method ?? 'GET'; + if (method !== 'GET' && method !== 'HEAD') { + responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return true; + } + if (this.#closed) throw requestError(diagnostic('AB8022', 'Web host routes are not available.', 503)); + const epochs = this.#epochs; + const mcpSessions = this.#mcpSessions; + const sandboxOrigin = this.#sandboxOrigin(); + if (epochs === undefined || mcpSessions === undefined || this.#previews === undefined || sandboxOrigin === undefined) { + throw requestError(diagnostic('AB8022', 'Web host routes are not available.', 404)); + } + + try { + const exposed = await this.#exposedApp(epochs, parsed); + if (exposed.app === undefined) { + const suffix = exposed.names.length === 0 + ? ' No Apps are exposed.' + : ` Exposed Apps: ${exposed.names.join(', ')}.`; + responseDiagnostic( + response, + diagnostic( + 'AB8020', + `MCP App ${JSON.stringify(`${parsed.server}/${parsed.app}`)} is not exposed.${suffix}`, + 404, + ), + ); + return true; + } + const { app, epochId } = exposed; + if (method === 'HEAD') { + writePage(response, method, sandboxOrigin, ''); + return true; + } + const registered = await this.#session(mcpSessions, epochId, app.server); + const selection = await openApp(selectionSource(registered.session), { + input: jsonInput(app.input), + resourceUri: app.resourceUri, + server: app.server, + ...(app.tool === undefined ? {} : { tool: app.tool }), + }); + const opening = randomUUID(); + this.#retainOpeningCall( + this.#openingCallKey(registered.session.id, selection.tool.name, opening), + Object.freeze({ input: selection.input, result: selection.result }), + ); + const body = renderWebHostPage({ + script: await readWebHostPageScript(), + seed: { + autoApprove: app.allow, + input: selection.input, + opening, + previewProfile: 'portable', + result: selection.result, + sessionId: registered.session.id, + title: app.app, + token: this.#sessionToken, + tokenHeader: 'x-agent-bundle-session', + toolName: selection.tool.name, + }, + }); + writePage(response, method, sandboxOrigin, body); + } catch (error) { + if (isRequestDiagnostic(error)) throw error; + throw requestError(diagnostic('AB8023', 'MCP App could not be opened.', 502)); + } + return true; + } + + async #exposedApp( + epochs: WebHostEpochSource, + requested: WebHostRoute, + ): Promise> { + let reference: WebHostEpochReference; + try { + reference = await epochs.acquireActiveEpochReference(); + } catch { + throw requestError(diagnostic('AB8022', 'Web host routes are not available without an active artifact epoch.', 404)); + } + try { + const manifest = await readWebManifest(join(reference.root, manifestFileName)); + const apps: readonly WebManifestApp[] = manifest?.apps ?? []; + const requestedName = `${requested.server}/${requested.app}`; + const app = apps.find((candidate) => candidate.app === requestedName); + const names = Object.freeze(apps.map((candidate) => candidate.app).sort((left, right) => left.localeCompare(right))); + return Object.freeze({ + ...(app === undefined ? {} : { app }), + epochId: reference.epoch.id, + names, + }); + } finally { + await reference.close(); + } + } + + async #session(service: McpSessionService, epochId: string, serverName: string): Promise { + const key = `${epochId}\0${serverName}`; + const existing = this.#sessions.get(key); + if (existing !== undefined) return existing; + const opening = this.#openSession(service, key, epochId, serverName); + this.#sessions.set(key, opening); + try { + return await opening; + } catch (error) { + if (this.#sessions.get(key) === opening) this.#sessions.delete(key); + throw error; + } + } + + async #openSession( + service: McpSessionService, + key: string, + epochId: string, + serverName: string, + ): Promise { + const session = await service.open({ epochId, serverName, target: devWebHostTarget }); + const lease: McpAppSessionLease = await service.acquireAppLease(session.id); + let disposed = false; + let unsubscribe = (): void => undefined; + const dispose = async (): Promise => { + if (disposed) return; + disposed = true; + unsubscribe(); + await lease.release(); + }; + const watched = lease.watchSessionClosed(() => { + this.#forgetSession(key, session.id); + }); + unsubscribe = watched.unsubscribe; + if (watched.closed) { + await dispose(); + throw new Error('MCP App session closed while it was being registered.'); + } + const registered = Object.freeze({ dispose, session }); + if (this.#closed) { + await dispose(); + throw requestError(diagnostic('AB8022', 'Web host routes are not available.', 503)); + } + return registered; + } + + #forgetSession(key: string, sessionId: string): void { + const current = this.#sessions.get(key); + if (current === undefined) return; + this.#sessions.delete(key); + for (const openingKey of this.#openingCalls.keys()) { + if (openingKey.startsWith(`${sessionId}\0`)) this.#openingCalls.delete(openingKey); + } + void current.then((registered) => registered.dispose()).catch(() => undefined); + } + + #retainOpeningCall(key: string, call: McpAppOpeningCall): void { + this.#openingCalls.set(key, call); + for (const oldest of this.#openingCalls.keys()) { + if (this.#openingCalls.size <= maxRetainedOpeningCalls) break; + this.#openingCalls.delete(oldest); + } + } + + #openingCallKey(sessionId: string, toolName: string, opening: string): string { + return `${sessionId}\0${toolName}\0${opening}`; + } +} diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index fa6544af4..46938d414 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -663,6 +663,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun 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 @@ -909,6 +910,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun ), evals, evalLifecycle: evals, + epochs: epochStore, eventHub, hookPlayground, hostDiscovery, @@ -917,6 +919,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun lifecycleReplay, logs, mcpAppPreviews: appPreviews, + mcpAppSandboxOrigin: () => mcpAppSandboxOrigin, mcpProbe, mcpSessions, playground, @@ -951,6 +954,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun }; try { const sandbox = await (options.testing?.createSandboxProxy ?? createMcpAppSandboxProxy)({ hostOrigin: foreground.url }); + mcpAppSandboxOrigin = sandbox.origin; mcpApps = new McpAppLifecycle(sandbox); const bindings = new McpAppBindingService({ sessionAuthority: mcpSessions }); previews = new McpAppPreviewService({ diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 69cf51452..9db99d4ea 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -329,6 +329,13 @@ export interface DoctorEndpointReport { }; } +export interface DoctorWebSurface { + readonly apps: number; + /** `web: App(s) exposed — run web` */ + readonly line: string; + readonly plugin: string; +} + export interface DoctorReport { readonly diagnostics: readonly Diagnostic[]; readonly endpoints: DoctorEndpointReport; @@ -338,6 +345,11 @@ export interface DoctorReport { readonly infos: number; readonly warnings: number; }; + /** + * Present when `--from` points at a bundle whose `agent-bundle.manifest.json` + * has a `web` section. + */ + readonly web?: DoctorWebSurface; } export const doctorEndpointDirectory = (): string => { @@ -1164,6 +1176,37 @@ const readPublicHostListing = async ( const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const artifactManifestName = 'agent-bundle.manifest.json'; + +/** + * The `web.apps` count from the bundle root manifest, read leniently rather + * than through `readWebManifest`'s strict key contract: doctor reports what a + * bundle contains and never fails on a malformed manifest. + */ +const readWebSurface = async ( + from: string | undefined, + pluginName: string | undefined, +): Promise => { + if (from === undefined) return undefined; + const manifestFile = join(resolve(from), artifactManifestName); + if (!(await exists(manifestFile))) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(manifestFile, 'utf8')) as unknown; + } catch { + return undefined; + } + if (!isRecord(parsed) || parsed.web === undefined) return undefined; + const web = parsed.web; + const apps = isRecord(web) && Array.isArray(web.apps) ? web.apps.length : 0; + const plugin = pluginName === undefined || pluginName.length === 0 ? '' : pluginName; + return Object.freeze({ + apps, + line: `web: ${String(apps)} App(s) exposed — run ${plugin} web`, + plugin, + }); +}; + /** * The host's installed-plugin inventory from its pinned `plugin list --json` * verb (Claude rows carry `id`/`version`/`scope`/`installPath`; Codex rows @@ -2699,6 +2742,8 @@ export const runDoctor = async (options: DoctorOptions = {}): Promise report.diagnostics), ...endpoints.diagnostics, ]); + const pluginName = hostReports.find((report) => report.bundle?.name !== undefined)?.bundle?.name; + const web = await readWebSurface(options.from, pluginName); return Object.freeze({ diagnostics, endpoints, @@ -2708,5 +2753,6 @@ export const runDoctor = async (options: DoctorOptions = {}): Promise entry.severity === 'info').length, warnings: diagnostics.filter((entry) => entry.severity === 'warning').length, }), + ...(web === undefined ? {} : { web }), }); }; diff --git a/packages/agent-bundle/src/routes/framework-imports.ts b/packages/agent-bundle/src/routes/framework-imports.ts index 18304388e..14faa81f7 100644 --- a/packages/agent-bundle/src/routes/framework-imports.ts +++ b/packages/agent-bundle/src/routes/framework-imports.ts @@ -30,7 +30,7 @@ import { parseModule } from './module-scope.ts'; * The leaf entries a route may value-import (`agent-bundle/routes`, * `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, * `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, - * `agent-bundle/terminal-capability`, `agent-bundle/serve-app-command`) are + * `agent-bundle/terminal-capability`, `agent-bundle/web-host`) are * deliberately absent. */ export const compilerCarryingSpecifiers: readonly string[] = Object.freeze([ @@ -338,7 +338,7 @@ const describeImporter = (importer: string, relativePath: string, sourcePath: st }; const recovery = - 'Keep framework calls in a host process: serve an MCP App from a routed command with spawnServeApp from agent-bundle/serve-app-command, which spawns agent-bundle serve-app; use import type for framework types; otherwise move the call into a package.json script or a hand-written .mjs run from the checkout.'; + 'Keep framework calls in a host process: expose the App with web.apps and open it with web; use import type for framework types; otherwise move the call into a package.json script or a hand-written .mjs run from the checkout.'; /** * AB4837: the module a generated executable bundles value-imports a diff --git a/packages/agent-bundle/src/serve-app-command.ts b/packages/agent-bundle/src/serve-app-command.ts deleted file mode 100644 index f306b9260..000000000 --- a/packages/agent-bundle/src/serve-app-command.ts +++ /dev/null @@ -1,387 +0,0 @@ -/** - * `agent-bundle/serve-app-command` (#558): serve a built MCP App from a routed - * CLI command — or any other generated executable — without importing the - * compiler. - * - * A plugin's generated executables are self-contained ESM (#387): the bundler - * inlines everything a route imports, so `import('agent-bundle/api')` for - * `serveApp` would inline the whole framework and fail on its runtime-relative - * module references (the route graph reports that as `AB4837` before the - * bundler does). The sanctioned shape keeps the framework in its own process: - * this module lowers the `serveApp` options to `agent-bundle serve-app` argv, - * resolves the framework CLI the project installed, spawns it, relays its - * stdout to stderr so the route keeps stdout for its own JSON result, and - * settles once the CLI prints its ready line. Plain Node with no dependencies, - * so it bundles into every host pack's executable exactly like - * `agent-bundle/launch-env`. - */ -import { spawn as spawnChildProcess, type ChildProcess } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; - -import { dependencyManifestPath } from './core/dependency-manifest.ts'; -import { CodedError } from './core/errors.ts'; -import { exists } from './core/paths.ts'; -import { isRecord } from './core/strict-json.ts'; -import type { McpAppProfileId } from './dev/mcp-app-profile-descriptors.ts'; -import { - parseServeAppReadyLine, - serveAppAllowCapabilities, - type ServeAppAllowCapability, - type ServeAppReadyLine, -} from './serve-app/command-contract.ts'; - -export type { McpAppProfileId, ServeAppAllowCapability, ServeAppReadyLine }; -export { parseServeAppReadyLine, serveAppAllowCapabilities }; - -/** - * The `serveApp` options with an `agent-bundle serve-app` argv form: every - * key of `ServeAppOptions` (`agent-bundle/api`) except the in-process - * injections — `logger`, `registry`, `openBrowser` — and the two keys the - * CLI does not expose, `targets` and `timeoutMs`, which stay with host - * processes that call `serveApp` directly. Unset keys take the CLI's - * defaults (`--target portable`, `--profile portable`, `--mode production`, - * no browser). Relative paths resolve exactly as they would in-process: - * `configPath` against `root`, `artifact` and `envFiles` against the - * working directory. - */ -export interface ServeAppArgvOptions { - /** The MCP App to serve: `/` (for example `status/status`), or `/ui://...` for an exact resource URI. */ - readonly app: string; - /** Use exactly this built artifact instead of building a throwaway one (`--artifact`). */ - readonly artifact?: string; - /** - * Consent capabilities approved on the operator's behalf as the App requests - * them (`--allow`, repeatable): the App-initiated actions the CLI lets a - * flag approve. Browser hardware and clipboard permissions always wait for - * a decision in the host page. - */ - readonly autoApprove?: readonly ServeAppAllowCapability[]; - /** Configuration file relative to `root` (`--config`). */ - readonly configPath?: string; - /** Explicit `.env` files replacing the conventional project-root set (`--env-file`, repeatable). */ - readonly envFiles?: readonly string[]; - /** Arguments for the opening tool call, serialized as JSON (`--input`). */ - readonly input?: Readonly>; - /** Set false to launch the server without any `.env` layer (`--no-env`). */ - readonly loadEnvFiles?: boolean; - /** Configuration mode (`--mode`). */ - readonly mode?: string; - /** Open the default browser on the served URL once the host is listening (`--open` / `--no-open`). */ - readonly open?: boolean; - /** Root the env-declared plugin-root anchors expand to (`--plugin-root`). */ - readonly pluginRoot?: string; - /** Loopback TCP port for the host document; `0` picks an ephemeral one (`--port`). */ - readonly port?: number; - /** The simulated MCP Apps host profile (`--profile`). */ - readonly profile?: McpAppProfileId; - /** The plugin project root: where `agent-bundle` is installed and the configuration lives (`--root`). */ - readonly root: string; - /** The artifact target whose generated server to bind (`--target`). */ - readonly target?: string; - /** The tool whose result the App opens with (`--tool`). */ - readonly tool?: string; -} - -/** - * The `agent-bundle` argv equivalent to `serveApp(options)`: `serve-app - * ` followed by one flag per set option, in the order the CLI documents - * them. The record is keyed by every option, so a new `ServeAppArgvOptions` - * key fails to compile until it is lowered. - */ -export const serveAppArgv = (options: ServeAppArgvOptions): readonly string[] => { - const lowered: { readonly [K in keyof ServeAppArgvOptions]-?: readonly string[] } = { - app: [options.app], - root: ['--root', options.root], - configPath: options.configPath === undefined ? [] : ['--config', options.configPath], - mode: options.mode === undefined ? [] : ['--mode', options.mode], - artifact: options.artifact === undefined ? [] : ['--artifact', options.artifact], - target: options.target === undefined ? [] : ['--target', options.target], - tool: options.tool === undefined ? [] : ['--tool', options.tool], - input: options.input === undefined ? [] : ['--input', JSON.stringify(options.input)], - port: options.port === undefined ? [] : ['--port', String(options.port)], - profile: options.profile === undefined ? [] : ['--profile', options.profile], - autoApprove: (options.autoApprove ?? []).flatMap((capability) => ['--allow', capability]), - open: options.open === undefined ? [] : [options.open ? '--open' : '--no-open'], - envFiles: (options.envFiles ?? []).flatMap((file) => ['--env-file', file]), - loadEnvFiles: options.loadEnvFiles === false ? ['--no-env'] : [], - pluginRoot: options.pluginRoot === undefined ? [] : ['--plugin-root', options.pluginRoot], - }; - return ['serve-app', ...Object.values(lowered).flat()]; -}; - -/** - * Why `spawnServeApp` (or a served App's `close()`) failed, as the error's `code`: - * - `framework-not-installed`: no `agent-bundle` package resolves from `root`; - * - `artifact-missing`: the given `artifact` path does not exist; - * - `spawn-failed`: the framework CLI process could not be started; - * - `exited-before-ready`: `agent-bundle serve-app` exited without printing - * its ready line (its diagnostics went to stderr); - * - `aborted`: the `signal` aborted before the App was served; - * - `stop-failed`: the running `agent-bundle serve-app` process could not be - * signalled when the `signal` aborted or `close()` was called (Node's - * `kill` error is the `cause`), so it is still running. - */ -export type ServeAppCommandErrorCode = - | 'framework-not-installed' - | 'artifact-missing' - | 'spawn-failed' - | 'exited-before-ready' - | 'aborted' - | 'stop-failed'; - -/** The exit of the `agent-bundle serve-app` process, as Node reports it. */ -export interface ServeAppExit { - /** The exit code, or `null` when a signal ended the process. */ - readonly code: number | null; - /** The terminating signal, or `null` when the process exited on its own. */ - readonly signal: NodeJS.Signals | null; -} - -export class ServeAppCommandError extends CodedError { - /** Present for `exited-before-ready`: how the CLI process ended. */ - readonly exit: ServeAppExit | undefined; - - constructor(code: ServeAppCommandErrorCode, message: string, options?: ErrorOptions & { readonly exit?: ServeAppExit }) { - super('ServeAppCommandError', code, message, options); - this.exit = options?.exit; - } -} - -/** - * The `agent-bundle` CLI entry (`bin/agent-bundle.js`) of the framework - * installed for the project at `root`, resolved the way the framework itself - * finds a dependency: through Node's resolution from the project's - * `package.json` (which honours hoisting and pnpm's layout), then by the - * ancestor `node_modules` walk when the package's `exports` hide its - * manifest. `undefined` when the framework is not installed: the published - * plugin package and an installed host pack ship no runtime dependencies, so - * only a checkout (or a consumer that installed `agent-bundle`) can serve. - */ -export const locateFrameworkCli = async (root: string): Promise => { - const manifestPath = await dependencyManifestPath(resolve(root), 'agent-bundle'); - if (manifestPath === undefined) return undefined; - let manifest: unknown; - try { - manifest = JSON.parse(await readFile(manifestPath, 'utf8')); - } catch { - // An unreadable or malformed manifest names no CLI: "not installed" is - // the actionable reading, not a raw parse error escaping the typed errors. - return undefined; - } - if (!isRecord(manifest)) return undefined; - const bin = manifest['bin']; - const relative = typeof bin === 'string' ? bin : isRecord(bin) ? bin['agent-bundle'] : undefined; - return typeof relative === 'string' ? resolve(dirname(manifestPath), relative) : undefined; -}; - -export interface SpawnServeAppOptions extends ServeAppArgvOptions { - /** - * The framework CLI to run instead of the one resolved from `root`. - * Injectable for tests and for hosts that carry their own copy. - */ - readonly cli?: string; - /** - * Receives every line the CLI prints on stdout — the ready line and - * anything after it. Defaults to writing them to this process's stderr, - * the operator's channel, so the routed command keeps stdout for its - * result document. The CLI's stderr (diagnostics) is inherited as is. - */ - readonly relay?: (line: string) => void; - /** - * Tears the server down when aborted — the request `signal` a routed - * command receives, so Ctrl-C reaching the command reaches the server. - */ - readonly signal?: AbortSignal; - /** Injectable only to make the child process deterministic in tests. */ - readonly spawn?: typeof spawnChildProcess; -} - -/** A served MCP App, as `agent-bundle serve-app` reported it. */ -export interface SpawnedServeApp extends ServeAppReadyLine { - /** The generated MCP server the App is bound to: the part of `app` before the first `/`. */ - readonly server: string; - /** The loopback port the host document listens on. */ - readonly port: number; - /** The `agent-bundle serve-app` process id. */ - readonly pid: number; - /** Settles once the CLI process has exited — by `close()`, the `signal`, Ctrl-C, or on its own when the bound server ended. */ - readonly closed: Promise; - /** - * Stops the server (SIGTERM to the CLI, which closes the host and its MCP - * server) and waits for the exit. Rejects with `stop-failed` when the - * running process cannot be signalled; it is then still running. - */ - close(): Promise; -} - -const portOf = (url: string): number => { - const parsed = new URL(url); - if (parsed.port.length > 0) return Number(parsed.port); - return parsed.protocol === 'https:' ? 443 : 80; -}; - -const serverOf = (app: string): string => app.slice(0, Math.max(0, app.indexOf('/'))); - -const describeExit = ({ code, signal }: ServeAppExit): string => - signal === null ? `exit code ${String(code ?? 'unknown')}` : `signal ${signal}`; - -const writeToStderr = (line: string): void => { - process.stderr.write(`${line}\n`); -}; - -/** - * Serves one built MCP App by running `agent-bundle serve-app` in a child - * process. Resolves once the CLI prints its ready line; the App then stays - * up until `close()`, the `signal`, or the bound MCP server ending. Rejects - * with a `ServeAppCommandError` whose `code` says what went wrong. - */ -export const spawnServeApp = async (options: SpawnServeAppOptions): Promise => { - const root = resolve(options.root); - const relay = options.relay ?? writeToStderr; - if (options.signal?.aborted === true) { - throw new ServeAppCommandError('aborted', `Serving ${options.app} was aborted before agent-bundle serve-app started.`); - } - const cli = options.cli ?? await locateFrameworkCli(root); - if (cli === undefined) { - throw new ServeAppCommandError( - 'framework-not-installed', - `agent-bundle is not installed for the project at ${root}: no node_modules/agent-bundle/package.json resolves ` - + 'from it. Serving an App needs the framework CLI, which the plugin checkout has as a dev dependency and the ' - + 'published package and installed host packs do not; run the command from the checkout after installing.', - ); - } - if (options.artifact !== undefined && !(await exists(resolve(options.artifact)))) { - throw new ServeAppCommandError( - 'artifact-missing', - `No built artifact at ${resolve(options.artifact)}. Run \`agent-bundle build\` first, or leave artifact unset so ` - + 'serve-app builds a throwaway one.', - ); - } - const argv = [cli, ...serveAppArgv(options)]; - return new Promise((settle, reject) => { - let child: ChildProcess; - try { - child = (options.spawn ?? spawnChildProcess)(process.execPath, argv, { stdio: ['ignore', 'pipe', 'inherit'] }); - } catch (error) { - reject(new ServeAppCommandError('spawn-failed', `agent-bundle serve-app could not be started from ${cli}.`, { cause: error })); - return; - } - let ready: ServeAppReadyLine | undefined; - let buffered = ''; - let spawned = false; - let lateError: Error | undefined; - let stopping = false; - let stopFailure: Error | undefined; - let settledExit: ServeAppExit | undefined; - let resolveExit: (exit: ServeAppExit) => void = () => undefined; - const closed = new Promise((resolveClosed) => { - resolveExit = resolveClosed; - }); - /** - * Sends SIGTERM. Node reports a signal the running process refuses (EPERM) - * as a synchronous `error` event rather than a throw; that is the returned - * failure. A process that already exited but has not closed yet is not a - * failure: its `close` is on the way. - */ - const stop = (): ServeAppCommandError | undefined => { - if (settledExit !== undefined) return undefined; - stopFailure = undefined; - stopping = true; - try { - child.kill('SIGTERM'); - } finally { - stopping = false; - } - if (stopFailure === undefined) return undefined; - return new ServeAppCommandError( - 'stop-failed', - `agent-bundle serve-app (pid ${String(child.pid ?? 'unknown')}) could not be signalled to stop and is still running.`, - { cause: stopFailure }, - ); - }; - const served = (line: ServeAppReadyLine): SpawnedServeApp => ({ - ...line, - close: async () => { - const failure = stop(); - if (failure !== undefined) throw failure; - return closed; - }, - closed, - pid: child.pid ?? -1, - port: portOf(line.url), - server: serverOf(line.app), - }); - const onLine = (line: string): void => { - relay(line); - if (ready !== undefined) return; - ready = parseServeAppReadyLine(line); - if (ready !== undefined) settle(served(ready)); - }; - const onAbort = (): void => { - const failure = stop(); - // Before the ready line the caller is still awaiting this promise, so - // an abort that could not stop the child is its answer; after it, the - // App is the caller's and `close()` reports the same failure. - if (failure !== undefined && ready === undefined) reject(failure); - }; - const finish = (exit: ServeAppExit, failure?: ServeAppCommandError): void => { - if (settledExit !== undefined) return; - settledExit = exit; - options.signal?.removeEventListener('abort', onAbort); - if (buffered.length > 0) onLine(buffered); - buffered = ''; - resolveExit(exit); - if (ready !== undefined) return; - if (failure !== undefined) { - reject(failure); - } else if (options.signal?.aborted === true) { - reject(new ServeAppCommandError('aborted', `Serving ${options.app} was aborted before agent-bundle serve-app was ready.`)); - } else { - reject(new ServeAppCommandError( - 'exited-before-ready', - `agent-bundle serve-app exited with ${describeExit(exit)} before serving ${options.app}; its diagnostics are on stderr.`, - { exit, ...(lateError === undefined ? {} : { cause: lateError }) }, - )); - } - }; - child.stdout?.setEncoding('utf8'); - child.stdout?.on('data', (chunk: string) => { - buffered += chunk; - const lines = buffered.split('\n'); - buffered = lines.pop() ?? ''; - for (const line of lines) onLine(line); - }); - child.once('spawn', () => { - spawned = true; - }); - child.on('error', (error) => { - // While `stop()` runs, `error` is Node's report of a refused `kill()` - // (EPERM) and the process is alive — whether or not its `spawn` event - // has been observed yet — so it is the stop failure and only the real - // `close` may settle `closed`. Otherwise, before the process exists, - // `error` is the one notice Node gives and `close` may never follow: - // the spawn failed. Once the process runs, a later `error` is kept as - // the cause should the child then exit before its ready line. - if (stopping || spawned) { - lateError = error; - if (stopping) stopFailure = error; - return; - } - finish( - { code: null, signal: null }, - new ServeAppCommandError('spawn-failed', `agent-bundle serve-app could not be started from ${cli}.`, { cause: error }), - ); - }); - child.once('close', (code, signal) => { - finish({ code, signal }); - }); - // Registered after the child's own listeners: an abort that landed while - // the CLI or artifact was being resolved has already dispatched its - // event, so the listener alone would wait forever and the re-check below - // stops the child at once — through `kill()`, whose refusal Node emits - // synchronously as `error`, which must already have a handler. - options.signal?.addEventListener('abort', onAbort, { once: true }); - if (options.signal?.aborted === true) onAbort(); - }); -}; diff --git a/packages/agent-bundle/src/serve-app/command-contract.ts b/packages/agent-bundle/src/serve-app/command-contract.ts index 8c8adf0dd..bd26ef958 100644 --- a/packages/agent-bundle/src/serve-app/command-contract.ts +++ b/packages/agent-bundle/src/serve-app/command-contract.ts @@ -1,35 +1,13 @@ -/** - * The `agent-bundle serve-app` command's wire contract, shared by the CLI - * that implements it and `agent-bundle/serve-app-command` (#558), which - * spawns it from a routed command: the consent vocabulary `--allow` accepts - * and the ready line printed once the App's host listens. One module writes - * and reads them so the two never drift — a parser that lagged the CLI's own - * output would leave a routed command waiting on a server that is already - * up. Plain Node, no imports: it is bundled into generated executables. - */ -import type { McpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; - -/** - * The consent capabilities `--allow` may approve on the operator's behalf: - * the App-initiated actions. Browser hardware and clipboard permissions - * (`camera`, `microphone`, `geolocation`, `clipboard-write`) always wait for - * an Allow/Deny decision in the host page, as in the Workbench. - */ -export const serveAppAllowCapabilities = [ - 'call-tool', 'download-file', 'open-external-link', 'request-display-mode', -] as const satisfies readonly McpAppConsentCapability[]; - -export type ServeAppAllowCapability = (typeof serveAppAllowCapabilities)[number]; - -export const isServeAppAllowCapability = (value: string): value is ServeAppAllowCapability => - (serveAppAllowCapabilities as readonly string[]).includes(value); +/** Plain Node wire contract shared by `serve-app` and generated `web` commands. */ +export { + isServeAppAllowCapability, + serveAppAllowCapabilities, + type ServeAppAllowCapability, +} from '../core/mcp-app-allow.ts'; export interface ServeAppReadyLine { - /** The App selector as the operator gave it: `/` or `/ui://...`. */ readonly app: string; - /** The tool whose result opened the App. */ readonly tool: string; - /** The host document URL. */ readonly url: string; } diff --git a/packages/agent-bundle/src/serve-app/serve-app-page.ts b/packages/agent-bundle/src/serve-app/serve-app-page.ts deleted file mode 100644 index 49ad360cc..000000000 --- a/packages/agent-bundle/src/serve-app/serve-app-page.ts +++ /dev/null @@ -1,328 +0,0 @@ -import type { McpAppJsonValue } from '../dev/mcp-apps/mcp-app-binding-service.ts'; -import type { McpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; -import type { McpAppProfileId } from '../dev/mcp-app-profile-descriptors.ts'; - -/** - * Everything the standalone host document needs to bind its App: the bound - * session, the tool whose result the App opens with, and the per-launch - * credential the authenticated MCP App routes require. It is embedded in the - * document served at `/`, which only this process's loopback origin can read. - */ -export interface ServeAppPageSeed { - /** Consent capabilities the operator pre-approved when launching the host. */ - readonly autoApprove: readonly McpAppConsentCapability[]; - readonly input: McpAppJsonValue; - readonly previewProfile: McpAppProfileId; - readonly result: McpAppJsonValue; - readonly sessionId: string; - readonly title: string; - readonly token: string; - readonly toolName: string; -} - -/** The request header the host document presents on every authenticated route. */ -export const SERVE_APP_TOKEN_HEADER = 'x-agent-bundle-serve-app'; - -const escapeHtml = (value: string): string => - value.replace(/[&<>"']/gu, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ?? character); - -/** JSON that is safe inside a ``, - ``, - '', - '', - '', -].join('\n'); diff --git a/packages/agent-bundle/src/serve-app/serve-mcp-app.ts b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts index 8f6a5f65c..4199c118a 100644 --- a/packages/agent-bundle/src/serve-app/serve-mcp-app.ts +++ b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts @@ -1,65 +1,18 @@ -import { Client, type Resource, type Tool } from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { Context, Effect, Layer, type Scope } from 'effect'; -import { randomBytes, randomUUID } from 'node:crypto'; -import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import type { Socket } from 'node:net'; -import type { Stream } from 'node:stream'; import type { TargetRegistry } from '../adapters/registry.ts'; -import { isRecord } from '../core/strict-json.ts'; -import type { McpAppProfileId } from '../dev/mcp-app-profile-descriptors.ts'; import type { ServedMcpApp, ServeMcpAppPublicOptions } from './types.ts'; -import { - McpAppBindingService, - selectMcpAppResourceUri, - type McpAppBridgeResource, - type McpAppBridgeSession, - type McpAppBridgeTool, - type McpAppJsonValue, - type McpAppSessionAuthority, - type McpAppSessionLease, - type McpAppToolDefinition, -} from '../dev/mcp-apps/mcp-app-binding-service.ts'; -import { MCP_APP_MIME_TYPE } from '../dev/mcp-apps/mcp-app-bridge.ts'; -import { - mcpAppPreviewHost, - mcpAppPreviewHostInfo, - openInBrowser, -} from '../dev/mcp-apps/mcp-app-preview-host.ts'; -import { McpAppPreviewService } from '../dev/mcp-apps/mcp-app-preview-service.ts'; -import { McpAppRoutes } from '../dev/mcp-apps/mcp-app-routes.ts'; -import { createMcpAppSandboxProxy, type McpAppSandboxProxy } from '../dev/mcp-apps/mcp-app-sandbox.ts'; -import { - canonicalMcpAppJson, - canonicalMcpAppResource, - canonicalMcpAppTool, - mcpAppClientCapabilities, -} from '../dev/mcp-session/mcp-session-apps.ts'; -import { diagnostic, isRequestDiagnostic, requestError, responseDiagnostic, singleHeader } from '../dev/http.ts'; import { makeScopedEffectRuntime } from '../effect/boundary.ts'; import { liftPromise, liftTry } from '../effect/lift.ts'; -import { resolveMcpLaunchEnvironment, type McpLaunchEnvironmentOptions, type ResolvedMcpStdioLaunch } from '../services/mcp-run.ts'; -import { renderServeAppPage, SERVE_APP_TOKEN_HEADER } from './serve-app-page.ts'; +import { resolveMcpLaunchEnvironment, type McpLaunchEnvironmentOptions } from '../services/mcp-run.ts'; +import { startWebHost, validPort, validProfile, type WebHost } from '../web-host/host-server.ts'; +import { readWebHostPageScript } from '../web-host/page-script.ts'; +import { appNameOf, openApp, parseAppSelector } from '../web-host/select-app.ts'; +import { openStdioAppSession } from '../web-host/session.ts'; /** - * `agent-bundle serve-app`: one built MCP App, served standalone in a browser - * over a bound session to the plugin's own packed MCP server. - * - * This is the Workbench's MCP App preview stack without the Workbench: - * the same `McpAppBindingService` → `McpAppPreviewService` → `McpAppRoutes` - * chain hosts the App over `/api/mcp/...`, the same loopback sandbox proxy - * (`createMcpAppSandboxProxy`) isolates the App document on its own origin, - * and the same `McpAppBridge` enforces the MCP Apps protocol, consent, and - * resource policy. Only two things are specific to this module: the session - * authority is one stdio connection to the packed server (launched exactly - * as `mcp run` launches it), and the host document is a small page whose - * inline relay mirrors the Workbench's `McpAppFrameRelay` over those routes. - * - * Every resource is `acquireRelease`d into one Effect scope owned by a - * `makeScopedEffectRuntime`; `close()` finalizes that scope once, newest - * resource first: routes, preview bindings, sandbox proxy, HTTP server, MCP - * session. + * The Effect scope owns the web host, MCP session, and optional throwaway + * artifact, releasing them newest first. */ export type { McpAppConsentCapability, ServedMcpApp, ServeMcpAppPublicOptions } from './types.ts'; @@ -76,337 +29,18 @@ export interface ServeMcpAppOptions extends ServeMcpAppPublicOptions, Omit; - readonly sessionId: string; - readonly stderr: () => string; - close(): Promise; - listResources(): Promise; - listTools(): Promise; - watchClosed(listener: () => void): () => void; -} - -interface AppSelection { - readonly input: Readonly>; - readonly result: McpAppJsonValue; - readonly resourceUri: string; - readonly server: string; - readonly tool: McpAppToolDefinition; -} - -interface ServedMcpAppShape { - readonly closed: Promise; - readonly resourceUri: string; - readonly sandboxOrigin: string; - readonly server: string; - readonly tool: string; - readonly url: string; -} - -class ServedMcpAppService extends Context.Service()( +class ServedMcpAppService extends Context.Service()( 'agent-bundle/serve-app/ServedMcpAppService', ) {} -const loopbackHosts: ReadonlySet = new Set(['127.0.0.1', 'localhost', '[::1]']); - -const requireJsonObject = (value: unknown, label: string): Readonly> => { - const snapshot = canonicalMcpAppJson(value, label); - if (!isRecord(snapshot)) throw new TypeError(`${label} must be a JSON object.`); - return snapshot as Readonly>; -}; - -export interface ServeAppSelector { - readonly name?: string; - readonly resourceUri?: string; - readonly server: string; -} - -/** Splits `/` or `/ui://...` into its server and App parts, rejecting anything else. */ -export const parseServeAppSelector = (value: string): ServeAppSelector => { - const trimmed = value.trim(); - if (trimmed.length === 0) throw new Error('MCP App must be named as / or a ui:// resource URI.'); - const separator = trimmed.indexOf('/'); - if (separator < 1 || separator === trimmed.length - 1) { - throw new Error(`MCP App ${JSON.stringify(value)} must be named as / or /ui://... .`); - } - const server = trimmed.slice(0, separator); - const rest = trimmed.slice(separator + 1); - if (rest.startsWith('ui://')) return Object.freeze({ resourceUri: rest, server }); - if (rest.includes('/')) throw new Error(`MCP App name ${JSON.stringify(rest)} must not contain a slash.`); - return Object.freeze({ name: rest, server }); -}; - -const appNameOf = (resourceUri: string): string | undefined => { - try { - const parsed = new URL(resourceUri); - if (parsed.protocol !== 'ui:') return undefined; - const segment = parsed.pathname.split('/').filter((part) => part.length > 0).at(-1); - return segment === undefined ? undefined : segment.replace(/\.html?$/iu, ''); - } catch { - return undefined; - } -}; - -const captureStderr = (stream: Stream | null): (() => string) => { - if (stream === null) return () => ''; - let captured = ''; - stream.on('data', (chunk: unknown) => { - if (captured.length >= maxStderrBytes) return; - captured = `${captured}${String(chunk)}`.slice(0, maxStderrBytes); - }); - return () => captured; -}; - -const openSession = async ( - launch: ResolvedMcpStdioLaunch, - identity: Readonly<{ readonly serverName: string; readonly target: string }>, - timeoutMs: number, -): Promise => { - const client = new Client({ name: mcpAppPreviewHostInfo.name, version: mcpAppPreviewHostInfo.version }, { - capabilities: mcpAppClientCapabilities, - }); - const transport = new StdioClientTransport({ - args: [...launch.args], - command: launch.command, - cwd: launch.cwd, - env: { ...launch.env }, - stderr: 'pipe', - }); - const stderr = captureStderr(transport.stderr); - const closedGate = Promise.withResolvers(); - const listeners = new Set<() => void>(); - let closed = false; - const markClosed = (): void => { - if (closed) return; - closed = true; - closedGate.resolve(); - for (const listener of listeners) { - try { - listener(); - } catch { - // A close watcher must never disrupt teardown. - } - } - listeners.clear(); - }; - transport.onclose = markClosed; - try { - await client.connect(transport, { timeout: timeoutMs }); - } catch (error) { - markClosed(); - const output = stderr(); - throw new Error( - `The packed MCP server did not start: ${error instanceof Error ? error.message : String(error)}` + - `${output.length === 0 ? '' : `\nserver stderr:\n${output}`}`, - { cause: error }, - ); - } - // The transport's own onclose is installed by the SDK client on connect; - // chain ours behind it so an unexpected server exit still settles `closed`. - const sdkOnClose = transport.onclose; - transport.onclose = () => { - try { - sdkOnClose?.(); - } finally { - markClosed(); - } - }; - const assertActive = (): void => { - if (closed) throw new Error('The bound MCP server connection is closed.'); - }; - const requestOptions = Object.freeze({ timeout: timeoutMs }); - let bridgeTools: Promise | undefined; - let bridgeResources: Promise | undefined; - const listTools = async (): Promise => Object.freeze([...(await client.listTools(undefined, requestOptions)).tools]); - const listResources = async (): Promise => Object.freeze([...(await client.listResources(undefined, requestOptions)).resources]); - const sessionId = randomUUID(); - const bridge: McpAppBridgeSession = Object.freeze({ - callTool: async ({ arguments: toolArguments, name }: { readonly arguments: McpAppJsonValue | undefined; readonly name: string }) => { - assertActive(); - const argumentsSnapshot = requireJsonObject(toolArguments ?? {}, 'MCP App tool arguments'); - const result = await client.callTool({ arguments: { ...argumentsSnapshot }, name }, requestOptions); - assertActive(); - return canonicalMcpAppJson(result, 'MCP App tool result'); - }, - identity: Object.freeze({ epochId: `serve-app:${sessionId}`, serverName: identity.serverName, sessionId, target: identity.target }), - listBridgeResources: async () => { - assertActive(); - bridgeResources ??= listResources().then((resources) => Object.freeze(resources.map(canonicalMcpAppResource))); - const resources = await bridgeResources; - assertActive(); - return resources; - }, - listBridgeTools: async () => { - assertActive(); - bridgeTools ??= listTools().then((tools) => Object.freeze(tools.map(canonicalMcpAppTool))); - const tools = await bridgeTools; - assertActive(); - return tools; - }, - readResource: async ({ uri }: { readonly uri: string }) => { - assertActive(); - const result = await client.readResource({ uri }, requestOptions); - assertActive(); - return canonicalMcpAppJson(result, 'MCP App resource result'); - }, - }); - let closing: Promise | undefined; - return Object.freeze({ - bridge, - client, - close: () => { - closing ??= client.close().catch(() => undefined).then(markClosed); - return closing; - }, - closed: closedGate.promise, - listResources, - listTools, - sessionId, - stderr, - watchClosed: (listener: () => void) => { - if (closed) { - listener(); - return () => undefined; - } - listeners.add(listener); - return () => { listeners.delete(listener); }; - }, - }); -}; - -/** - * Resolves the App and its opening tool against the live server, then calls - * the tool once so the App opens populated — the same input/result pair the - * Workbench binds when it previews a tool run. - */ -const selectApp = async (session: StandaloneSession, options: ServeMcpAppOptions): Promise => { - const requested = parseServeAppSelector(options.app); - const [tools, resources] = await Promise.all([session.listTools(), session.listResources()]); - const appResources = resources.filter((resource) => resource.mimeType === MCP_APP_MIME_TYPE); - const matching = appResources.filter((resource) => requested.resourceUri === undefined - ? appNameOf(resource.uri) === requested.name - : resource.uri === requested.resourceUri); - const available = appResources.map((resource) => `${requested.server}/${appNameOf(resource.uri) ?? resource.uri}`); - if (matching.length === 0) { - throw new Error( - `MCP server ${JSON.stringify(requested.server)} serves no MCP App ${JSON.stringify(requested.name ?? requested.resourceUri)}` + - `${available.length === 0 ? ' (it serves no MCP App resources).' : `; available: ${available.join(', ')}.`}`, - ); - } - if (matching.length > 1) { - throw new Error( - `MCP App ${JSON.stringify(requested.name)} names ${String(matching.length)} resources on server ${JSON.stringify(requested.server)}; ` + - `use ${requested.server}/ to select one of: ${matching.map((resource) => resource.uri).join(', ')}.`, - ); - } - const resourceUri = matching[0]!.uri; - const appTools = tools.filter((tool) => { - const definition = canonicalMcpAppTool(tool).definition; - return selectMcpAppResourceUri(definition) === resourceUri; - }); - const selectedTool = options.tool === undefined - ? appTools.length === 1 ? appTools[0] : undefined - : appTools.find((tool) => tool.name === options.tool); - if (selectedTool === undefined) { - if (options.tool !== undefined) { - throw new Error( - `Tool ${JSON.stringify(options.tool)} does not open MCP App ${resourceUri}` + - `${appTools.length === 0 ? '.' : `; tools that do: ${appTools.map((tool) => tool.name).join(', ')}.`}`, - ); - } - throw new Error(appTools.length === 0 - ? `No tool on server ${JSON.stringify(requested.server)} declares _meta.ui.resourceUri ${resourceUri}.` - : `Several tools open MCP App ${resourceUri} (${appTools.map((tool) => tool.name).join(', ')}); choose one with --tool.`); - } - const definition = canonicalMcpAppTool(selectedTool).definition; - const input = requireJsonObject(options.input ?? {}, 'MCP App tool input'); - const result = await session.bridge.callTool({ arguments: input, name: definition.name }); - return Object.freeze({ input, resourceUri, result, server: requested.server, tool: definition }); -}; - -/** The one bound session, leased to every App binding the host page creates. */ -const sessionAuthorityFor = (session: StandaloneSession): McpAppSessionAuthority => Object.freeze({ - acquireAppLease: async (sessionId: string): Promise => { - if (sessionId !== session.sessionId) throw new Error(`Unknown MCP App session ${JSON.stringify(sessionId)}.`); - return Object.freeze({ - release: async () => undefined, - session: session.bridge, - watchSessionClosed: (listener: (reason?: unknown) => Promise | void) => { - let closedNow = false; - const unsubscribe = session.watchClosed(() => { - closedNow = true; - void listener(); - }); - return Object.freeze({ closed: closedNow, unsubscribe }); - }, - }); - }, -}); - -const listen = async (server: Server, port: number): Promise => new Promise((resolvePort, reject) => { - server.once('error', reject); - server.listen({ host: '127.0.0.1', port }, () => { - server.off('error', reject); - const address = server.address(); - if (address === null || typeof address === 'string') { - reject(new Error('The MCP App host did not receive a TCP address.')); - return; - } - resolvePort(address.port); - }); -}); - -const closeServer = async (server: Server, sockets: ReadonlySet): Promise => new Promise((resolveClose, reject) => { - const deadline = setTimeout(() => { - for (const socket of sockets) socket.destroy(); - }, closeTimeoutMs); - server.close((error) => { - clearTimeout(deadline); - if (error !== undefined && (error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING') reject(error); - else resolveClose(); - }); - for (const socket of sockets) socket.destroy(); -}); - -const validPort = (value: number | undefined): number => { - const port = value ?? 0; - if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) throw new RangeError('MCP App host port must be a TCP port number.'); - return port; -}; - -const validProfile = (value: McpAppProfileId | undefined): McpAppProfileId => { - const profile = value ?? 'portable'; - if (profile !== 'portable' && profile !== 'claude' && profile !== 'chatgpt') { - throw new RangeError(`Unsupported MCP App profile ${JSON.stringify(String(profile))}.`); - } - return profile; -}; - -const requestOriginIsHost = (request: IncomingMessage, url: string): boolean => { - const origin = singleHeader(request.headers.origin); - if (origin !== undefined) return origin === url; - return singleHeader(request.headers['sec-fetch-site']) === 'same-origin'; -}; - -const hostHeaderIsLoopback = (request: IncomingMessage, port: number): boolean => { - const host = singleHeader(request.headers.host); - if (host === undefined) return false; - const separator = host.lastIndexOf(':'); - if (separator === -1) return false; - return loopbackHosts.has(host.slice(0, separator)) && host.slice(separator + 1) === String(port); -}; - -const serveProgram = (options: ServeMcpAppOptions): Effect.Effect => Effect.gen(function* () { +const serveProgram = (options: ServeMcpAppOptions): Effect.Effect => Effect.gen(function* () { const port = yield* liftTry(() => validPort(options.port)); const profile = yield* liftTry(() => validProfile(options.profile)); + const requestedApp = yield* liftTry(() => parseAppSelector(options.app)); const autoApprove = Object.freeze([...(options.autoApprove ?? [])]); const timeoutMs = options.timeoutMs ?? defaultTimeoutMs; - const requestedApp = yield* liftTry(() => parseServeAppSelector(options.app)); + const pageScript = yield* liftPromise(() => readWebHostPageScript()); const artifact = typeof options.artifact === 'string' ? options.artifact : yield* options.artifact; const launch = yield* liftPromise(() => resolveMcpLaunchEnvironment({ artifact, @@ -421,151 +55,38 @@ const serveProgram = (options: ServeMcpAppOptions): Effect.Effect openSession(launch, { serverName: requestedApp.server, target: options.target }, timeoutMs)), + liftPromise(() => openStdioAppSession(launch, { serverName: requestedApp.server, target: options.target }, timeoutMs)), (opened) => Effect.promise(() => opened.close()), ); - const selection = yield* liftPromise(() => selectApp(session, options)); - - const token = randomBytes(32).toString('base64url'); - const sockets = new Set(); - // The listener is installed after the routes exist; a request racing the - // wiring is refused rather than served without authorization. - const dispatch: { current?: (request: IncomingMessage, response: ServerResponse) => Promise } = {}; - const server = createServer((request, response) => { - const handler = dispatch.current; - if (handler === undefined) { - responseDiagnostic(response, diagnostic('AB8022', 'MCP App host is not ready.', 503)); - return; - } - void handler(request, response).catch((error: unknown) => { - if (isRequestDiagnostic(error)) { - responseDiagnostic(response, error); - return; - } - responseDiagnostic(response, diagnostic('AB8023', 'MCP App operation could not be completed.', 502)); - }); - }); - server.on('connection', (socket) => { - sockets.add(socket); - socket.once('close', () => sockets.delete(socket)); - }); - const boundPort = yield* Effect.acquireRelease( - liftPromise(() => listen(server, port)), - () => Effect.promise(() => closeServer(server, sockets).catch(() => undefined)), - ); - const url = `http://127.0.0.1:${String(boundPort)}`; - const sandbox: McpAppSandboxProxy = yield* Effect.acquireRelease( - liftPromise(() => createMcpAppSandboxProxy({ hostOrigin: url })), - (proxy) => Effect.promise(() => proxy.close().catch(() => undefined)), - ); - const openBrowser = options.openBrowser ?? openInBrowser; - const bindings = new McpAppBindingService({ sessionAuthority: sessionAuthorityFor(session) }); - const previews = yield* Effect.acquireRelease( - Effect.sync(() => new McpAppPreviewService({ - bindingAuthority: bindings, - host: mcpAppPreviewHost(openBrowser), - hostInfo: mcpAppPreviewHostInfo, - hostOrigin: url, - sandboxProxy: sandbox, - toolAuthority: { - resolveTool: async (sessionId, toolName): Promise => { - if (sessionId !== session.sessionId || toolName !== selection.tool.name) { - throw new Error(`Unknown MCP App tool ${JSON.stringify(toolName)}.`); - } - return selection.tool; - }, - }, + const selection = yield* liftPromise(() => openApp(session.selection, { + ...(options.input === undefined ? {} : { input: options.input }), + ...(requestedApp.name === undefined ? {} : { name: requestedApp.name }), + ...(requestedApp.resourceUri === undefined ? {} : { resourceUri: requestedApp.resourceUri }), + server: requestedApp.server, + ...(options.tool === undefined ? {} : { tool: options.tool }), + })); + return yield* Effect.acquireRelease( + liftPromise(() => startWebHost({ + autoApprove, + open: options.open === true, + ...(options.openBrowser === undefined ? {} : { openBrowser: options.openBrowser }), + pageScript, + port, + profile, + selection, + session, + title: `${selection.server}/${appNameOf(selection.resourceUri) ?? selection.resourceUri}`, })), - (service) => Effect.promise(() => service.closeAll().catch(() => undefined)), + (host) => Effect.promise(() => host.close()), ); - const authorize = (request: IncomingMessage): void => { - if (!hostHeaderIsLoopback(request, boundPort) || !requestOriginIsHost(request, url)) { - throw requestError(diagnostic('AB8003', 'Request origin is not this MCP App host.', 403)); - } - if (singleHeader(request.headers[SERVE_APP_TOKEN_HEADER]) !== token) { - throw requestError(diagnostic('AB8004', 'A valid MCP App host token is required.', 403)); - } - }; - // The page binds the opening call this host already made instead of - // posting the result back: a large result would otherwise exceed the - // request-body bound and drop the App to the fallback panel (#562). - const openingCall = (sessionId: string, toolName: string) => - sessionId === session.sessionId && toolName === selection.tool.name - ? Object.freeze({ input: selection.input, result: selection.result }) - : undefined; - const routes = yield* Effect.acquireRelease( - Effect.sync(() => new McpAppRoutes({ authorize, openingCall, service: previews })), - (created) => Effect.sync(() => { created.close(); }), - ); - const page = renderServeAppPage({ - autoApprove, - input: selection.input, - previewProfile: profile, - result: selection.result, - sessionId: session.sessionId, - title: `${selection.server}/${appNameOf(selection.resourceUri) ?? selection.resourceUri}`, - token, - toolName: selection.tool.name, - }); - // `frame-ancestors` does not inherit from `default-src`: without it, a page - // on another origin could frame this consent-bearing document on a fixed - // `--port` and clickjack its Allow/Deny controls. - const contentSecurityPolicy = [ - "default-src 'none'", - "base-uri 'none'", - "connect-src 'self'", - "form-action 'none'", - "frame-ancestors 'none'", - `frame-src ${sandbox.origin}`, - "script-src 'unsafe-inline'", - "style-src 'unsafe-inline'", - ].join('; '); - dispatch.current = async (request, response) => { - if (!hostHeaderIsLoopback(request, boundPort)) { - throw requestError(diagnostic('AB8003', 'Request origin is not this MCP App host.', 403)); - } - if (await routes.handle(request, response)) return; - const pathname = new URL(request.url ?? '/', url).pathname; - if (pathname !== '/' && pathname !== '/index.html') { - responseDiagnostic(response, diagnostic('AB8020', 'Not found.', 404)); - return; - } - if (request.method !== 'GET' && request.method !== 'HEAD') { - responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); - return; - } - response.writeHead(200, { - 'cache-control': 'no-store', - 'content-security-policy': contentSecurityPolicy, - 'content-type': 'text/html; charset=utf-8', - 'referrer-policy': 'no-referrer', - 'x-content-type-options': 'nosniff', - }); - response.end(request.method === 'HEAD' ? undefined : page); - }; - if (options.open === true) yield* liftPromise(() => Promise.resolve(openBrowser(`${url}/`))); - return Object.freeze({ - closed: session.closed, - resourceUri: selection.resourceUri, - sandboxOrigin: sandbox.origin, - server: selection.server, - tool: selection.tool.name, - url: `${url}/`, - }); }); -/** - * Serves one built MCP App standalone: launches the plugin's packed MCP - * server exactly as `agent-bundle mcp run` would, binds the App to it - * through the Workbench's MCP App host stack, and returns the loopback URL of - * a page that renders the App. `close()` tears everything down, the server - * process included. - */ +/** Serves one built MCP App until `close()` tears down its host and server. */ export const serveMcpApp = async (options: ServeMcpAppOptions): Promise => { const runtime = makeScopedEffectRuntime(Layer.effect(ServedMcpAppService, serveProgram(options))); - let service: ServedMcpAppShape; + let host: WebHost; try { - service = await runtime.run(ServedMcpAppService); + host = await runtime.run(ServedMcpAppService); } catch (error) { await runtime.close().catch(() => undefined); throw error; @@ -576,11 +97,11 @@ export const serveMcpApp = async (options: ServeMcpAppOptions): Promise - safeStateSegment.test(server) ? server : `server-${sha256Hex(server).slice(0, 16)}`; - export const resolveMcpStdioLaunch = async ( options: ResolveMcpStdioLaunchOptions, ): Promise => { diff --git a/packages/agent-bundle/src/web-host.ts b/packages/agent-bundle/src/web-host.ts new file mode 100644 index 000000000..f4883dbb1 --- /dev/null +++ b/packages/agent-bundle/src/web-host.ts @@ -0,0 +1,9 @@ +/** Plain Node entry bundled into generated executables; do not add Effect or compiler imports (#564). */ +export { + formatServeAppReadyLine, + parseServeAppReadyLine, + type ServeAppReadyLine, +} from './serve-app/command-contract.ts'; +export { runWebCommand, type WebCommandOptions } from './web-host/command.ts'; +export { parseWebManifest, readWebManifest, type WebManifest, type WebManifestApp } from './web-host/manifest.ts'; +export { WEB_HOST_TOKEN_HEADER } from './web-host/page.ts'; diff --git a/packages/workbench/src/mcp/finite-json.ts b/packages/agent-bundle/src/web-host/browser/finite-json.ts similarity index 95% rename from packages/workbench/src/mcp/finite-json.ts rename to packages/agent-bundle/src/web-host/browser/finite-json.ts index 62435ccd8..bbc763c63 100644 --- a/packages/workbench/src/mcp/finite-json.ts +++ b/packages/agent-bundle/src/web-host/browser/finite-json.ts @@ -8,8 +8,7 @@ const defaultMaximumDepth = 32; const defaultMaximumNodes = 4_096; /** - * Admits only finite, data-only JSON without recursively traversing untrusted - * browser messages. The original value is never cloned or mutated. + * Admits finite, data-only JSON while bounding traversal of untrusted browser messages. */ export const finiteOrdinaryJsonByteLength = ( value: unknown, diff --git a/packages/agent-bundle/src/web-host/browser/frame-relay.ts b/packages/agent-bundle/src/web-host/browser/frame-relay.ts new file mode 100644 index 000000000..df87edbef --- /dev/null +++ b/packages/agent-bundle/src/web-host/browser/frame-relay.ts @@ -0,0 +1,401 @@ +import type { + McpAppJsonValue, + McpAppRelayFrame, + McpAppRouteClose, + McpAppRouteMessages, +} from '../../contracts/mcp-apps.ts'; +import { isPlainRecord } from '../../contracts/strict-json.ts'; +import { finiteOrdinaryJsonByteLength } from './finite-json.ts'; + +const proxyReadyMethod = 'ui/notifications/sandbox-proxy-ready'; +const resourceReadyMethod = 'ui/notifications/sandbox-resource-ready'; +const closedRelay = Promise.resolve(); + +export interface McpAppFrameMessageEvent { + readonly data: unknown; + readonly origin: string; + readonly source: unknown; +} + +export type McpAppFrameMessageListener = (event: McpAppFrameMessageEvent) => void; + +export interface McpAppFrameTarget { + postMessage(message: unknown, targetOrigin: string): void; +} + +export interface McpAppFrameIframe { + readonly contentWindow: McpAppFrameTarget | null; + /** A DOM iframe publishes the relay lifecycle through this (`relayStateAttribute`); fakes may omit it. */ + setAttribute?(name: string, value: string): void; +} + +export interface McpAppFrameWindow { + addEventListener(type: 'message', listener: McpAppFrameMessageListener): void; + removeEventListener(type: 'message', listener: McpAppFrameMessageListener): void; +} + +export interface McpAppFrameRelayRoutes { + close(bindingId: string, options: Readonly<{ readonly id: string; readonly reason: string }>): Promise; + forceClose(bindingId: string): Promise; + message(bindingId: string, message: McpAppJsonValue): Promise; +} + +export interface McpAppFrameRelayOptions { + readonly bindingId: string; + readonly closeTimeoutMs?: number; + readonly frame: McpAppRelayFrame; + readonly iframe: McpAppFrameIframe; + readonly onError?: (error: McpAppFrameRelayError) => void; + readonly resource: McpAppJsonValue; + readonly routes: McpAppFrameRelayRoutes; + readonly window: McpAppFrameWindow; +} + +type RelayState = 'closed' | 'closing' | 'open'; + +/** + * Relay lifecycle as published on the outer iframe's `data-mcp-app-relay-state` + * attribute, so the host UI and browser tests observe the state the relay is + * in instead of inferring it from route traffic. `loading`: listening, but the + * proxy has not signalled readiness, so `close()` releases the binding with a + * forced DELETE. `ready`: the proxy holds the resource, so `close()` runs the + * graceful `POST …/close` teardown handshake. `closing` and `closed` mirror + * `RelayState`. + */ +type RelayFrameState = 'closed' | 'closing' | 'loading' | 'ready'; +const relayStateAttribute = 'data-mcp-app-relay-state'; + +interface CanonicalResource { + readonly csp?: McpAppJsonValue; + readonly html: string; + readonly permissions?: McpAppJsonValue; +} + +interface RpcMessage extends Readonly> { + readonly id?: McpAppJsonValue; + readonly jsonrpc: '2.0'; + readonly method?: string; +} + +const validRequestId = (value: unknown): boolean => + value === null || typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)); + +const asMessage = (value: unknown, maximumBytes: number): RpcMessage | undefined => { + if ( + finiteOrdinaryJsonByteLength(value, { maximumBytes }) === undefined || + !isPlainRecord(value) || + value.jsonrpc !== '2.0' || + Object.hasOwn(value, 'bindingId') + ) return undefined; + const hasMethod = typeof value.method === 'string' && value.method.length > 0; + const hasId = Object.hasOwn(value, 'id') && validRequestId(value.id); + const hasResponse = hasId && !hasMethod && (Object.hasOwn(value, 'result') || Object.hasOwn(value, 'error')); + if (!hasMethod && !hasResponse) return undefined; + return value as RpcMessage; +}; + +const resource = (value: McpAppJsonValue): CanonicalResource => { + if (!isPlainRecord(value) || value.kind !== 'resource' || typeof value.html !== 'string') { + throw new McpAppFrameRelayError('MCP App preview does not contain a canonical HTML resource.'); + } + if (value.csp !== undefined && !isPlainRecord(value.csp)) { + throw new McpAppFrameRelayError('MCP App preview resource has invalid CSP declarations.'); + } + if (value.permissions !== undefined && !isPlainRecord(value.permissions)) { + throw new McpAppFrameRelayError('MCP App preview resource has invalid permission declarations.'); + } + return Object.freeze({ + ...(value.csp === undefined ? {} : { csp: value.csp }), + html: value.html, + ...(value.permissions === undefined ? {} : { permissions: value.permissions }), + }); +}; + +const messageForResource = (frame: McpAppRelayFrame, value: CanonicalResource): RpcMessage => Object.freeze({ + jsonrpc: '2.0', + method: resourceReadyMethod, + params: Object.freeze({ + // The proxy accepts its policy only from this server-issued frame. The + // resource declaration is intentionally never relayed as an authority. + allow: frame.allow, + contentSecurityPolicy: frame.policy.contentSecurityPolicy, + html: value.html, + }), +}); + +const positiveTimeout = (value: number | undefined): number => { + const timeout = value ?? 5_000; + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30_000) { + throw new RangeError('MCP App frame close timeout must be an integer from 1 to 30000 ms.'); + } + return timeout; +}; + +const opaqueBindingId = (value: string): string => { + if ( + value.length === 0 || value.length > 4_096 || value.trim().length === 0 || value === '.' || value === '..' || + value.includes('/') || value.includes('\\') || value.includes('\0') + ) throw new McpAppFrameRelayError('MCP App frame binding is not available.'); + return value; +}; + +const isProxyReady = (message: RpcMessage): boolean => + message.method === proxyReadyMethod && !Object.hasOwn(message, 'id'); + +const isTeardownAcknowledgement = (message: RpcMessage, id: string): boolean => + !Object.hasOwn(message, 'method') && + message.id === id && + (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error')); + +/** Security boundary between one sandbox proxy iframe and one opaque App binding. */ +export class McpAppFrameRelay { + readonly #bindingId: string; + readonly #closeTimeoutMs: number; + readonly #frame: McpAppRelayFrame; + readonly #iframe: McpAppFrameIframe; + readonly #onError: ((error: McpAppFrameRelayError) => void) | undefined; + readonly #resource: CanonicalResource; + readonly #routes: McpAppFrameRelayRoutes; + readonly #window: McpAppFrameWindow; + readonly #listener: McpAppFrameMessageListener; + readonly #queue: (() => Promise)[] = []; + #closePromise: Promise | undefined; + #closeTimer: ReturnType | undefined; + #finishClose: (() => void) | undefined; + #forceClosePromise: Promise | undefined; + #listening = false; + #processing = false; + #resourceProvided = false; + #state: RelayState = 'open'; + + constructor(options: McpAppFrameRelayOptions) { + this.#bindingId = opaqueBindingId(options.bindingId); + this.#closeTimeoutMs = positiveTimeout(options.closeTimeoutMs); + this.#frame = options.frame; + this.#iframe = options.iframe; + this.#onError = options.onError; + this.#resource = resource(options.resource); + this.#routes = options.routes; + this.#window = options.window; + this.#listener = (event) => { this.receive(event); }; + } + + get state(): RelayState { + return this.#state; + } + + start(): boolean { + if (this.#state !== 'open' || this.#listening) return false; + this.#window.addEventListener('message', this.#listener); + this.#listening = true; + this.#publishFrameState(); + return true; + } + + receive(event: McpAppFrameMessageEvent): boolean { + const proxy = this.#iframe.contentWindow; + if (proxy === null || event.source !== proxy || event.origin !== this.#frame.targetOrigin || this.#state === 'closed') return false; + const message = asMessage(event.data, this.#frame.relay.maxMessageBytes); + if (message === undefined) return false; + if (this.#state === 'closing') { + const teardownId = this.#teardownId(); + if (!isTeardownAcknowledgement(message, teardownId)) return false; + return this.#enqueue(() => this.#deliver(message)); + } + if (isProxyReady(message)) { + if (this.#resourceProvided) return false; + this.#resourceProvided = true; + this.#publishFrameState(); + return this.#post(messageForResource(this.#frame, this.#resource), false); + } + if (!this.#resourceProvided) return false; + return this.#enqueue(() => this.#deliver(message)); + } + + close(): Promise { + if (this.#state === 'closed') return closedRelay; + if (this.#closePromise !== undefined) return this.#closePromise; + this.#state = 'closing'; + this.#publishFrameState(); + this.#closePromise = new Promise((resolve) => { this.#finishClose = resolve; }); + // Before the proxy signals readiness there is no app to tear down and no + // window that can acknowledge a teardown frame: the proxy document is + // still loading (or is the initial about:blank, whose origin never matches + // targetOrigin, so postMessage drops the frame silently). The graceful + // handshake could only wait out the force timer, so release the binding + // now instead of holding the closing state for the whole budget. + if (!this.#resourceProvided) { + void this.#forceClose(); + return this.#closePromise; + } + this.#closeTimer = setTimeout(() => { void this.#forceClose(); }, this.#closeTimeoutMs); + this.#enqueue(() => this.#beginClose(), true); + return this.#closePromise; + } + + /** Detaches one remounted document without closing the server binding. */ + detach(): void { + if (!this.#listening || this.#state === 'closed') return; + this.#window.removeEventListener('message', this.#listener); + this.#listening = false; + this.#queue.length = 0; + } + + /** Delivers authenticated route continuations to the exact current proxy. */ + deliverHostMessages(messages: readonly McpAppJsonValue[]): boolean { + if (this.#state !== 'open' || !this.#listening) return false; + try { + this.#postAll(messages); + return true; + } catch (cause) { + this.#report(new McpAppFrameRelayError('MCP App consent continuation delivery failed.', cause)); + return false; + } + } + + #enqueue(operation: () => Promise, essential = false): boolean { + if (this.#state === 'closed') return false; + const occupied = this.#queue.length + Number(this.#processing); + if (!essential && occupied >= this.#frame.relay.maxQueuedMessages) { + this.#report(new McpAppFrameRelayError('MCP App frame relay queue is full.')); + return false; + } + this.#queue.push(operation); + void this.#drain(); + return true; + } + + async #drain(): Promise { + if (this.#processing) return; + this.#processing = true; + try { + while (this.#queue.length > 0) { + const operation = this.#queue.shift(); + if (operation === undefined) continue; + try { + await operation(); + } catch (cause) { + this.#report(new McpAppFrameRelayError('MCP App frame relay operation failed.', cause)); + if (this.#state === 'closing') await this.#forceClose(); + } + } + } finally { + this.#processing = false; + } + } + + async #deliver(message: RpcMessage): Promise { + const response = await this.#routes.message(this.#bindingId, message as McpAppJsonValue); + if (this.#state === 'closed') return; + this.#postAll(response.messages); + if (response.lifecycle === 'closed') this.#completeClose(); + } + + async #beginClose(): Promise { + try { + const response = await this.#routes.close(this.#bindingId, { + id: this.#teardownId(), + reason: 'MCP App frame unmounted.', + }); + if (this.#state === 'closed') return; + if (response.message !== undefined) this.#postOne(response.message); + if (response.lifecycle === 'closed') { + this.#completeClose(); + return; + } + } catch (cause) { + if (this.#state === 'closed') return; + this.#report(new McpAppFrameRelayError('MCP App graceful close failed.', cause)); + await this.#forceClose(); + } + } + + #forceClose(): Promise { + if (this.#state === 'closed') return closedRelay; + if (this.#forceClosePromise !== undefined) return this.#forceClosePromise; + if (this.#closeTimer !== undefined) clearTimeout(this.#closeTimer); + this.#closeTimer = undefined; + this.#forceClosePromise = Promise.resolve().then(async () => { + try { + await this.#routes.forceClose(this.#bindingId); + } catch (cause) { + this.#report(new McpAppFrameRelayError('MCP App force close failed.', cause)); + } finally { + this.#completeClose(); + } + }); + return this.#forceClosePromise; + } + + #postAll(messages: readonly McpAppJsonValue[]): void { + for (const message of messages) this.#postOne(message); + } + + #postOne(message: McpAppJsonValue): void { + const validated = asMessage(message, this.#frame.relay.maxMessageBytes); + if (validated === undefined) throw new McpAppFrameRelayError('MCP App route returned an invalid frame.'); + if (!this.#post(validated)) throw new McpAppFrameRelayError('MCP App proxy window is not available.'); + } + + #post(message: RpcMessage, enforceMessageLimit = true): boolean { + const proxy = this.#iframe.contentWindow; + if ( + proxy === null || + finiteOrdinaryJsonByteLength(message, { + maximumBytes: enforceMessageLimit ? this.#frame.relay.maxMessageBytes : Number.MAX_SAFE_INTEGER, + }) === undefined + ) return false; + try { + proxy.postMessage(message, this.#frame.targetOrigin); + return true; + } catch (cause) { + this.#report(new McpAppFrameRelayError('MCP App proxy postMessage failed.', cause)); + return false; + } + } + + #completeClose(): void { + if (this.#state === 'closed') return; + this.#state = 'closed'; + this.#publishFrameState(); + this.#queue.length = 0; + if (this.#closeTimer !== undefined) clearTimeout(this.#closeTimer); + this.#closeTimer = undefined; + if (this.#listening) this.#window.removeEventListener('message', this.#listener); + this.#listening = false; + this.#finishClose?.(); + this.#finishClose = undefined; + } + + /** Mirrors `#state` and `#resourceProvided` onto the iframe on the same event that changes them. */ + #publishFrameState(): void { + const state: RelayFrameState = this.#state === 'open' ? (this.#resourceProvided ? 'ready' : 'loading') : this.#state; + this.#iframe.setAttribute?.(relayStateAttribute, state); + } + + #teardownId(): string { + return `mcp-app-frame-close:${this.#bindingId}`; + } + + #report(error: McpAppFrameRelayError): void { + try { + this.#onError?.(error); + } catch { + // Error observers never disrupt isolation or cleanup. + } + } +} + +export class McpAppFrameRelayError extends Error { + readonly cause: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'McpAppFrameRelayError'; + this.cause = cause; + } +} + +export const createMcpAppFrameRelay = ( + options: McpAppFrameRelayOptions, +): McpAppFrameRelay => new McpAppFrameRelay(options); diff --git a/packages/agent-bundle/src/web-host/browser/main.ts b/packages/agent-bundle/src/web-host/browser/main.ts new file mode 100644 index 000000000..26a9bc495 --- /dev/null +++ b/packages/agent-bundle/src/web-host/browser/main.ts @@ -0,0 +1,291 @@ +import type { + McpAppJsonValue, + McpAppRelayFrame, + McpAppRouteClose, + McpAppRouteMessages, +} from '../../contracts/mcp-apps.ts'; +import { isPlainRecord } from '../../contracts/strict-json.ts'; +import { isMcpAppConsentCapability } from '../../dev/mcp-apps/mcp-app-consent.ts'; +import { + createMcpAppFrameRelay, + type McpAppFrameRelay, + type McpAppFrameRelayRoutes, +} from './frame-relay.ts'; +import { WEB_HOST_SEED_ELEMENT_ID, type WebHostPageSeed } from './seed.ts'; + +interface ConsentChallenge { + readonly id: string; + readonly request?: Readonly<{ + readonly capability?: string; + readonly details?: McpAppJsonValue; + readonly summary?: string; + }>; +} + +interface Preview { + readonly bindingId: string; + readonly frame?: McpAppRelayFrame; + readonly resource: McpAppJsonValue; +} + +interface ConsentDecision { + readonly messages?: readonly McpAppJsonValue[]; + readonly preview?: Readonly<{ readonly frame?: McpAppRelayFrame }>; +} + +const seedElement = document.getElementById(WEB_HOST_SEED_ELEMENT_ID)!; +const seed: WebHostPageSeed = JSON.parse(seedElement.textContent!); +const status = document.getElementById('status')!; +const frameHost = document.getElementById('frame-host')!; +const consentPanel = document.getElementById('consent')!; +const consentList = document.querySelector('#consent-list')!; +const fallbackPanel = document.getElementById('fallback')!; +const fallbackReason = document.getElementById('fallback-reason')!; +const fallbackInput = document.getElementById('fallback-input')!; +const fallbackResult = document.getElementById('fallback-result')!; + +const setStatus = (text: string, tone = 'info'): void => { + status.textContent = text; + status.dataset.tone = tone; +}; + +const api = async (method: string, path: string, body?: unknown): Promise => { + const response = await fetch(path, { + body: body === undefined ? undefined : JSON.stringify(body), + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + [seed.tokenHeader]: seed.token, + }, + method, + }); + const text = await response.text(); + let json: unknown; + try { + json = text.length === 0 ? {} : JSON.parse(text); + } catch { + json = undefined; + } + if (!response.ok) { + const detail = isPlainRecord(json) && + isPlainRecord(json.diagnostic) && + typeof json.diagnostic.code === 'string' && + typeof json.diagnostic.message === 'string' + ? `${json.diagnostic.code}: ${json.diagnostic.message}` + : `${response.status} ${response.statusText}`; + throw new Error(detail); + } + return json as Result; +}; + +const showFallback = (reason: string, input: McpAppJsonValue, result: McpAppJsonValue): void => { + frameHost.hidden = true; + fallbackPanel.hidden = false; + fallbackReason.textContent = reason; + fallbackInput.textContent = JSON.stringify(input, null, 2); + fallbackResult.textContent = JSON.stringify(result, null, 2); +}; + +const browserHostContext = (): McpAppJsonValue => ({ + availableDisplayModes: ['inline'], + containerDimensions: { + height: Math.max(0, window.innerHeight), + width: Math.max(0, window.innerWidth), + }, + deviceCapabilities: {}, + displayMode: 'inline', + locale: navigator.language || 'en', + platform: 'web', + safeAreaInsets: { bottom: 0, left: 0, right: 0, top: 0 }, + styles: {}, + theme: window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + userAgent: navigator.userAgent || 'unknown', +}); + +const revisionOf = (frame: McpAppRelayFrame | undefined): number => + frame?.documentPolicy?.revision ?? 0; + +const start = async (): Promise => { + setStatus(`Binding ${seed.toolName} to the App…`); + const created = await api>( + 'POST', + `/api/mcp/sessions/${encodeURIComponent(seed.sessionId)}/apps`, + { + host: browserHostContext(), + ...(seed.opening === undefined ? {} : { opening: seed.opening }), + previewProfile: seed.previewProfile, + toolName: seed.toolName, + }, + ); + const preview = created.preview; + const bindingPath = `/api/mcp/apps/${encodeURIComponent(preview.bindingId)}`; + let closed = false; + + window.addEventListener('pagehide', () => { + if (closed) return; + closed = true; + fetch(bindingPath, { + headers: { [seed.tokenHeader]: seed.token }, + keepalive: true, + method: 'DELETE', + }).catch(() => undefined); + }); + + if ( + preview.frame === undefined || + !isPlainRecord(preview.resource) || + preview.resource.kind !== 'resource' || + typeof preview.resource.html !== 'string' + ) { + const reason = isPlainRecord(preview.resource) && typeof preview.resource.reason === 'string' + ? preview.resource.reason + : 'no-sandbox-frame'; + showFallback(reason, seed.input, seed.result); + setStatus( + `Interactive App rendering is unavailable (${reason}); showing the tool result instead.`, + 'warn', + ); + return; + } + + const resource = preview.resource; + const iframe = document.createElement('iframe'); + iframe.title = seed.title; + let frame = preview.frame; + let relay: McpAppFrameRelay | undefined; + let refreshConsent: () => Promise = async () => undefined; + + const routes: McpAppFrameRelayRoutes = { + close: async (_bindingId, options) => + api('POST', `${bindingPath}/close`, options), + forceClose: async () => { + const response = await api>('DELETE', bindingPath); + closed = response.closed; + return response.closed; + }, + message: async (_bindingId, message) => { + const response = await api( + 'POST', + `${bindingPath}/messages`, + { message }, + ); + if ( + isPlainRecord(message) && + Object.hasOwn(message, 'id') && + typeof message.method === 'string' && + response.messages.length === 0 + ) void refreshConsent(); + if (response.lifecycle === 'closed') { + closed = true; + setStatus('The MCP App binding closed.', 'warn'); + } + return response; + }, + }; + + const reportRelayError = (error: Error): void => { + setStatus(`MCP App relay failed: ${error.message}`, 'error'); + }; + + const mount = (nextFrame: McpAppRelayFrame): void => { + relay?.detach(); + frame = nextFrame; + iframe.setAttribute('allow', frame.allow); + iframe.setAttribute('referrerpolicy', frame.referrerPolicy); + iframe.setAttribute('sandbox', frame.sandbox); + iframe.src = 'about:blank'; + relay = createMcpAppFrameRelay({ + bindingId: preview.bindingId, + frame, + iframe, + onError: reportRelayError, + resource, + routes, + window, + }); + relay.start(); + iframe.src = frame.src; + }; + + const decide = async (challengeId: string, approved: boolean): Promise => { + try { + const response = await api('POST', `${bindingPath}/consent`, { + approved, + challengeId, + }); + if (closed) return; + const nextFrame = response.preview?.frame; + if (nextFrame !== undefined && revisionOf(nextFrame) !== revisionOf(frame)) mount(nextFrame); + else if (response.messages !== undefined) relay?.deliverHostMessages(response.messages); + await refreshConsent(); + } catch (error) { + setStatus( + `Consent decision failed: ${error instanceof Error ? error.message : String(error)}`, + 'error', + ); + } + }; + + const renderChallenges = (challenges: readonly ConsentChallenge[]): void => { + consentList.replaceChildren(); + const visible: ConsentChallenge[] = []; + for (const challenge of challenges) { + const capability = challenge.request?.capability; + if (isMcpAppConsentCapability(capability) && seed.autoApprove.includes(capability)) { + void decide(challenge.id, true); + continue; + } + visible.push(challenge); + } + consentPanel.hidden = visible.length === 0; + for (const challenge of visible) { + const item = document.createElement('li'); + const summary = document.createElement('span'); + summary.textContent = challenge.request?.summary ?? 'Allow MCP App action?'; + const details = document.createElement('code'); + details.textContent = JSON.stringify(challenge.request?.details); + const allow = document.createElement('button'); + allow.type = 'button'; + allow.textContent = 'Allow'; + allow.addEventListener('click', () => { void decide(challenge.id, true); }); + const deny = document.createElement('button'); + deny.type = 'button'; + deny.textContent = 'Deny'; + deny.addEventListener('click', () => { void decide(challenge.id, false); }); + item.append(summary, details, allow, deny); + consentList.append(item); + } + }; + + refreshConsent = async () => { + if (closed) return; + const response = await api>( + 'GET', + `${bindingPath}/consent`, + ); + renderChallenges(response.challenges ?? []); + }; + + window.addEventListener('message', (event) => { + if ( + closed || + event.source !== iframe.contentWindow || + event.origin !== frame.targetOrigin || + !isPlainRecord(event.data) || + event.data.method !== 'ui/notifications/sandbox-proxy-ready' || + Object.hasOwn(event.data, 'id') + ) return; + setStatus(`Serving ${seed.title} over the bound session.`, 'ok'); + }); + + frameHost.replaceChildren(iframe); + frameHost.hidden = false; + mount(frame); + await refreshConsent(); +}; + +void start().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + setStatus(`MCP App preview failed: ${message}`, 'error'); + showFallback('preview-error', seed.input, seed.result); +}); diff --git a/packages/agent-bundle/src/web-host/browser/seed.ts b/packages/agent-bundle/src/web-host/browser/seed.ts new file mode 100644 index 000000000..e90906c08 --- /dev/null +++ b/packages/agent-bundle/src/web-host/browser/seed.ts @@ -0,0 +1,21 @@ +import type { + McpAppConsentCapability, + McpAppJsonValue, + McpAppProfileId, +} from '../../contracts/mcp-apps.ts'; + +export const WEB_HOST_SEED_ELEMENT_ID = 'agent-bundle-web-host-seed'; + +export interface WebHostPageSeed { + readonly autoApprove: readonly McpAppConsentCapability[]; + readonly input: McpAppJsonValue; + /** Opaque per-page id of the opening call, set by hosts that serve many pages over one session (dev `/web`). */ + readonly opening?: string; + readonly previewProfile: McpAppProfileId; + readonly result: McpAppJsonValue; + readonly sessionId: string; + readonly title: string; + readonly token: string; + readonly tokenHeader: string; + readonly toolName: string; +} diff --git a/packages/agent-bundle/src/web-host/command.ts b/packages/agent-bundle/src/web-host/command.ts new file mode 100644 index 000000000..025fda9eb --- /dev/null +++ b/packages/agent-bundle/src/web-host/command.ts @@ -0,0 +1,458 @@ +import type { McpAppConsentCapability, McpAppJsonValue, McpAppProfileId } from '../contracts/mcp-apps.ts'; +import { stableJson } from '../core/digest.ts'; +import { CodedError, errorMessage } from '../core/errors.ts'; +import { exists } from '../core/paths.ts'; +import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import { MCP_APP_PROFILE_DESCRIPTORS } from '../dev/mcp-app-profile-descriptors.ts'; +import { + formatServeAppReadyLine, + isServeAppAllowCapability, + serveAppAllowCapabilities, + type ServeAppAllowCapability, +} from '../serve-app/command-contract.ts'; +import { startWebHost, type WebHost } from './host-server.ts'; +import { resolveWebLaunch } from './launch.ts'; +import { readWebManifest, type WebManifest, type WebManifestApp } from './manifest.ts'; +import { openApp, parseAppSelector, requireJsonObject, type AppSelector } from './select-app.ts'; +import { openStdioAppSession, type StdioAppSession } from './session.ts'; + +/** Plain Node entry bundled into generated executables; do not import Effect or compiler modules (#564). */ +export interface WebCommandOptions { + readonly argv: readonly string[]; + readonly manifestPath: string; + readonly name?: string; + readonly pageScript: string; + readonly pluginRoot: string; + readonly signal: AbortSignal; + readonly writeErr: (text: string) => void; + readonly writeOut: (text: string) => void; +} + +export interface WebCommandRuntime { + readonly openApp: typeof openApp; + readonly openStdioAppSession: typeof openStdioAppSession; + readonly readWebManifest: typeof readWebManifest; + readonly resolveWebLaunch: typeof resolveWebLaunch; + readonly startWebHost: typeof startWebHost; +} + +const webCommandRuntime: WebCommandRuntime = Object.freeze({ + openApp, + openStdioAppSession, + readWebManifest, + resolveWebLaunch, + startWebHost, +}); + +export type WebCommandErrorCode = + | 'app-ambiguous' + | 'app-not-exposed' + | 'manifest-invalid' + | 'manifest-missing' + | 'server-exited' + | 'usage' + | 'web-missing'; + +export class WebCommandError extends CodedError { + constructor(code: WebCommandErrorCode, message: string, options?: ErrorOptions) { + super('WebCommandError', code, message, options); + } +} + +const exitCodeOf = (code: WebCommandErrorCode): number => { + switch (code) { + case 'app-ambiguous': + case 'app-not-exposed': + case 'usage': + return 2; + case 'manifest-invalid': + case 'manifest-missing': + case 'server-exited': + case 'web-missing': + return 1; + default: { + const unreachable: never = code; + throw new TypeError(`Unhandled web command error code ${String(unreachable)}.`); + } + } +}; + +const sessionTimeoutMs = 30_000; +const stderrTailChars = 4096; + +const webProfiles = Object.freeze(Object.keys(MCP_APP_PROFILE_DESCRIPTORS)) as readonly McpAppProfileId[]; + +const isWebProfile = (value: string): value is McpAppProfileId => (webProfiles as readonly string[]).includes(value); + +const commandName = (name: string | undefined): string => `${name ?? ''} web`; + +export const webUsageLine = (name: string | undefined): string => + `Usage: ${commandName(name)} [/] [options]`; + +const columns = (rows: readonly (readonly [string, string])[]): string => { + const width = rows.reduce((max, [left]) => Math.max(max, left.length), 0); + return rows.map(([left, right]) => ` ${left.padEnd(width)} ${right}`).join('\n'); +}; + +export const webHelp = (name: string | undefined): string => [ + webUsageLine(name), + '', + "Open one of the plugin's MCP Apps in a browser.", + '', + 'Arguments:', + columns([[ + '[/]', + 'The exposed App to open; /ui://... selects it by resource URI. Defaults to the only exposed App.', + ]]), + '', + 'Options:', + columns([ + [' --port ', 'Loopback TCP port of the host page; 0 picks a free one. [default: 0]'], + [' --open', 'Open the default browser once the host is listening.'], + [' --no-open', "Do not open a browser, whatever the manifest's web.open says."], + [' --tool ', 'The tool whose result opens the App. [default: the configured tool, else the only tool that opens the App]'], + [' --input ', 'Arguments of the opening tool call, as one JSON object. [default: the configured input]'], + [ + ' --allow ...', + `Approve a consent capability as the App requests it; repeatable. One of: ${serveAppAllowCapabilities.join(', ')}.`, + ], + [` --profile <${webProfiles.join('|')}>`, 'The simulated MCP Apps host profile. [default: "portable"]'], + [' --json', 'Print one JSON line describing the host instead of the ready line.'], + ['-h, --help', 'Show help.'], + ]), + '', +].join('\n'); + +interface WebArgv { + readonly allow: readonly ServeAppAllowCapability[]; + readonly help: boolean; + readonly input?: Readonly>; + readonly json: boolean; + readonly open?: boolean; + readonly port?: number; + readonly profile?: McpAppProfileId; + readonly selector?: string; + readonly tool?: string; +} + +const usage = (message: string): WebCommandError => new WebCommandError('usage', message); + +const parsePort = (value: string): number => { + const port = Number(value); + if (value.trim() === '' || !Number.isSafeInteger(port) || port < 0 || port > 65_535) { + throw usage(`--port requires a TCP port number (0-65535); got ${JSON.stringify(value)}.`); + } + return port; +}; + +const parseInput = (value: string): Readonly> => { + let parsed; + try { + parsed = parseJsonWithoutDuplicateKeys(value); + } catch { + throw usage('--input must be one valid JSON object.'); + } + try { + return requireJsonObject(parsed, 'MCP App tool input'); + } catch { + throw usage('--input must be a JSON object; arrays, null, and scalar values are not accepted.'); + } +}; + +const parseAllow = (value: string): ServeAppAllowCapability => { + if (!isServeAppAllowCapability(value)) { + throw usage(`--allow must be one of: ${serveAppAllowCapabilities.join(', ')}; got ${JSON.stringify(value)}.`); + } + return value; +}; + +const parseProfile = (value: string): McpAppProfileId => { + if (!isWebProfile(value)) throw usage(`--profile must be one of: ${webProfiles.join(', ')}; got ${JSON.stringify(value)}.`); + return value; +}; + +export const parseWebArgv = (argv: readonly string[]): WebArgv => { + if (argv.includes('--help') || argv.includes('-h')) return { allow: [], help: true, json: false }; + const allow: ServeAppAllowCapability[] = []; + const seen = new Set(); + let json = false; + let input: WebArgv['input']; + let open: boolean | undefined; + let port: number | undefined; + let profile: McpAppProfileId | undefined; + let selector: string | undefined; + let tool: string | undefined; + const once = (name: string): void => { + if (seen.has(name)) throw usage(`Duplicate option: --${name}.`); + seen.add(name); + }; + for (let index = 0; index < argv.length; index += 1) { + const raw = argv[index]!; + if (!raw.startsWith('-') || raw === '-') { + if (selector !== undefined) throw usage(`Unexpected argument: ${JSON.stringify(raw)}.`); + selector = raw; + continue; + } + if (!raw.startsWith('--')) throw usage(`Unknown option: ${raw}.`); + const separator = raw.indexOf('='); + const name = separator === -1 ? raw.slice(2) : raw.slice(2, separator); + const inline = separator === -1 ? undefined : raw.slice(separator + 1); + const flag = (): void => { + if (inline !== undefined) throw usage(`--${name} is a flag and takes no value.`); + once(name); + }; + const value = (): string => { + if (inline !== undefined) return inline; + const next = argv[index + 1]; + if (next === undefined || next.startsWith('--')) throw usage(`--${name} requires a value.`); + index += 1; + return next; + }; + switch (name) { + case 'json': + flag(); + json = true; + break; + case 'open': + flag(); + if (open === false) throw usage('Use either --open or --no-open, not both.'); + open = true; + break; + case 'no-open': + flag(); + if (open === true) throw usage('Use either --open or --no-open, not both.'); + open = false; + break; + case 'port': + once(name); + port = parsePort(value()); + break; + case 'tool': + once(name); + tool = value(); + break; + case 'input': + once(name); + input = parseInput(value()); + break; + case 'profile': + once(name); + profile = parseProfile(value()); + break; + case 'allow': { + const capability = parseAllow(value()); + if (!allow.includes(capability)) allow.push(capability); + break; + } + default: + throw usage(`Unknown option: --${name}.`); + } + } + return { + allow, + help: false, + ...(input === undefined ? {} : { input }), + json, + ...(open === undefined ? {} : { open }), + ...(port === undefined ? {} : { port }), + ...(profile === undefined ? {} : { profile }), + ...(selector === undefined ? {} : { selector }), + ...(tool === undefined ? {} : { tool }), + }; +}; + +const manifestRequirement = (name: string | undefined): string => + `agent-bundle.manifest.json with a web section is required beside bin/; run ${commandName(name)} from the built artifact or the installed plugin root.`; + +const readExposedApps = async ( + runtime: WebCommandRuntime, + manifestPath: string, + name: string | undefined, +): Promise => { + if (!(await exists(manifestPath))) { + throw new WebCommandError('manifest-missing', `${manifestRequirement(name)} (No manifest at ${manifestPath}.)`); + } + let manifest: WebManifest | undefined; + try { + manifest = await runtime.readWebManifest(manifestPath); + } catch (error) { + throw new WebCommandError('manifest-invalid', `Cannot read the web section of ${manifestPath}: ${errorMessage(error)}`, { cause: error }); + } + if (manifest === undefined) { + throw new WebCommandError( + 'web-missing', + `${manifestRequirement(name)} (${manifestPath} has no web section: configure web.apps and rebuild.)`, + ); + } + if (manifest.apps.length === 0) { + throw new WebCommandError( + 'web-missing', + `${manifestRequirement(name)} (The web section of ${manifestPath} exposes no App: configure web.apps and rebuild.)`, + ); + } + return manifest; +}; + +const exposedList = (apps: readonly WebManifestApp[]): string => apps.map((app: WebManifestApp) => app.app).join(', '); + +/** + * Whether a selector names an exposed App. The server part may be the + * configured server id (`app.app` is `/` as configured, which + * is what the ready line and the ambiguity listing print) or the server's + * name (what `mcp run` and the ready line's tool call use); the App part is + * the App name, or its exact `ui://` resource URI. + */ +const appMatches = (app: WebManifestApp, selector: AppSelector): boolean => { + const configuredServer = app.app.slice(0, Math.max(0, app.app.indexOf('/'))); + if (selector.server !== app.server && selector.server !== configuredServer) return false; + return selector.resourceUri === undefined ? selector.name === app.name : selector.resourceUri === app.resourceUri; +}; + +const pickApp = (manifest: WebManifest, selector: string | undefined): WebManifestApp => { + const { apps } = manifest; + if (selector === undefined) { + if (apps.length === 1) return apps[0]!; + throw new WebCommandError('app-ambiguous', `Several MCP Apps are exposed; name one: ${exposedList(apps)}.`); + } + let parsed: AppSelector; + try { + parsed = parseAppSelector(selector); + } catch (error) { + throw usage(errorMessage(error)); + } + const matching = apps.filter((app: WebManifestApp) => appMatches(app, parsed)); + if (matching.length === 1) return matching[0]!; + if (matching.length === 0) { + throw new WebCommandError( + 'app-not-exposed', + `MCP App ${JSON.stringify(selector)} is not exposed by this plugin; exposed: ${exposedList(apps)}.`, + ); + } + throw new WebCommandError( + 'app-ambiguous', + `MCP App ${JSON.stringify(selector)} names ${String(matching.length)} exposed Apps; use one of: ${exposedList(matching)}.`, + ); +}; + +const portOf = (url: string): number => { + const parsed = new URL(url); + if (parsed.port.length > 0) return Number(parsed.port); + return parsed.protocol === 'https:' ? 443 : 80; +}; + +const hostingEnd = (signal: AbortSignal, closed: Promise): Promise<'aborted' | 'closed'> => + new Promise((settle) => { + if (signal.aborted) { + settle('aborted'); + return; + } + const onAbort = (): void => settle('aborted'); + signal.addEventListener('abort', onAbort, { once: true }); + const onClosed = (): void => { + signal.removeEventListener('abort', onAbort); + settle('closed'); + }; + closed.then(onClosed, onClosed); + }); + +const stderrTail = (session: StdioAppSession): string => { + const output = session.stderr().trimEnd(); + return output.length <= stderrTailChars ? output : output.slice(-stderrTailChars); +}; + +interface HostedApp { + readonly app: WebManifestApp; + readonly host: WebHost; +} + +const reportReady = (options: WebCommandOptions, json: boolean, hosted: HostedApp): void => { + const { app, host } = hosted; + if (json) { + options.writeOut(`${stableJson({ + app: app.app, + port: portOf(host.url), + resourceUri: host.resourceUri, + sandboxOrigin: host.sandboxOrigin, + server: host.server, + tool: host.tool, + url: host.url, + })}\n`); + return; + } + options.writeOut(`${formatServeAppReadyLine({ app: app.app, tool: host.tool, url: host.url })}\n`); +}; + +/** Owns and closes the session it launches. */ +const hostApp = async ( + options: WebCommandOptions, + runtime: WebCommandRuntime, + manifest: WebManifest, + app: WebManifestApp, + argv: WebArgv, +): Promise => { + const tool = argv.tool ?? app.tool; + const input = argv.input ?? app.input; + const allow: readonly McpAppConsentCapability[] = argv.allow.length > 0 ? argv.allow : app.allow; + const open = argv.open ?? manifest.open === 'browser'; + const launch = await runtime.resolveWebLaunch({ app, env: process.env, pluginRoot: options.pluginRoot }); + if (options.signal.aborted) return 0; + const session = await runtime.openStdioAppSession(launch, { serverName: app.server, target: 'web' }, sessionTimeoutMs); + let host: WebHost | undefined; + try { + if (options.signal.aborted) return 0; + const selection = await runtime.openApp(session.selection, { + ...(input === undefined ? {} : { input }), + resourceUri: app.resourceUri, + server: app.server, + ...(tool === undefined ? {} : { tool }), + }); + if (options.signal.aborted) return 0; + host = await runtime.startWebHost({ + autoApprove: allow, + open, + pageScript: options.pageScript, + port: argv.port ?? 0, + profile: argv.profile ?? 'portable', + selection, + session, + title: app.app, + }); + reportReady(options, argv.json, { app, host }); + const end = await hostingEnd(options.signal, host.closed); + if (end === 'aborted') return 0; + const tail = stderrTail(session); + throw new WebCommandError( + 'server-exited', + `MCP server ${JSON.stringify(app.server)} exited while ${app.app} was open.${tail.length === 0 ? '' : `\nserver stderr:\n${tail}`}`, + ); + } finally { + try { + await host?.close(); + } finally { + await session.close(); + } + } +}; + +export const runWebCommand = async (options: WebCommandOptions, runtime: WebCommandRuntime = webCommandRuntime): Promise => { + try { + const argv = parseWebArgv(options.argv); + if (argv.help) { + options.writeOut(webHelp(options.name)); + return 0; + } + if (options.signal.aborted) return 0; + const manifest = await readExposedApps(runtime, options.manifestPath, options.name); + const app = pickApp(manifest, argv.selector); + return await hostApp(options, runtime, manifest, app, argv); + } catch (error) { + if (error instanceof WebCommandError) { + const exitCode = exitCodeOf(error.code); + options.writeErr(`${error.message}\n`); + if (exitCode === 2) options.writeErr(`${webUsageLine(options.name)}\n`); + return exitCode; + } + options.writeErr(`${errorMessage(error)}\n`); + return 1; + } +}; diff --git a/packages/agent-bundle/src/web-host/host-server.ts b/packages/agent-bundle/src/web-host/host-server.ts new file mode 100644 index 000000000..433112142 --- /dev/null +++ b/packages/agent-bundle/src/web-host/host-server.ts @@ -0,0 +1,250 @@ +import { randomBytes } from 'node:crypto'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { Socket } from 'node:net'; + +import { isErrno } from '../core/errors.ts'; +import { diagnostic, isRequestDiagnostic, requestError, responseDiagnostic, singleHeader } from '../dev/http.ts'; +import type { McpAppProfileId } from '../dev/mcp-app-profile-descriptors.ts'; +import { McpAppBindingService, type McpAppToolDefinition } from '../dev/mcp-apps/mcp-app-binding-service.ts'; +import type { McpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; +import { + mcpAppPreviewHost, + mcpAppPreviewHostInfo, + openInBrowser, + type OpenBrowser, +} from '../dev/mcp-apps/mcp-app-preview-host.ts'; +import { McpAppPreviewService } from '../dev/mcp-apps/mcp-app-preview-service.ts'; +import { McpAppRoutes } from '../dev/mcp-apps/mcp-app-routes.ts'; +import { createMcpAppSandboxProxy } from '../dev/mcp-apps/mcp-app-sandbox.ts'; +import { renderWebHostPage, WEB_HOST_TOKEN_HEADER, webHostContentSecurityPolicy } from './page.ts'; +import type { AppSelection } from './select-app.ts'; +import { sessionAuthorityFor, type StdioAppSession } from './session.ts'; + +/** Plain Node host bundled into generated executables; the caller retains ownership of the session (#564). */ +export interface StartWebHostOptions { + readonly autoApprove: readonly McpAppConsentCapability[]; + readonly open: boolean; + readonly openBrowser?: OpenBrowser; + readonly pageScript: string; + readonly port: number; + readonly profile: McpAppProfileId; + readonly selection: AppSelection; + readonly session: StdioAppSession; + readonly title: string; +} + +export interface WebHost { + readonly closed: Promise; + readonly resourceUri: string; + readonly sandboxOrigin: string; + readonly server: string; + readonly tool: string; + readonly url: string; + close(): Promise; +} + +const closeTimeoutMs = 1_000; + +const loopbackHosts: ReadonlySet = new Set(['127.0.0.1', 'localhost', '[::1]']); + +export const validPort = (value: number | undefined): number => { + const port = value ?? 0; + if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) throw new RangeError('MCP App host port must be a TCP port number.'); + return port; +}; + +export const validProfile = (value: McpAppProfileId | undefined): McpAppProfileId => { + const profile = value ?? 'portable'; + if (profile !== 'portable' && profile !== 'claude' && profile !== 'chatgpt') { + throw new RangeError(`Unsupported MCP App profile ${JSON.stringify(String(profile))}.`); + } + return profile; +}; + +const listen = async (server: Server, port: number): Promise => new Promise((resolvePort, reject) => { + server.once('error', reject); + server.listen({ host: '127.0.0.1', port }, () => { + server.off('error', reject); + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('The MCP App host did not receive a TCP address.')); + return; + } + resolvePort(address.port); + }); +}); + +const closeServer = async (server: Server, sockets: ReadonlySet): Promise => new Promise((resolveClose, reject) => { + const deadline = setTimeout(() => { + for (const socket of sockets) socket.destroy(); + }, closeTimeoutMs); + server.close((error) => { + clearTimeout(deadline); + if (error !== undefined && !isErrno(error, 'ERR_SERVER_NOT_RUNNING')) reject(error); + else resolveClose(); + }); + for (const socket of sockets) socket.destroy(); +}); + +const requestOriginIsHost = (request: IncomingMessage, url: string): boolean => { + const origin = singleHeader(request.headers.origin); + if (origin !== undefined) return origin === url; + return singleHeader(request.headers['sec-fetch-site']) === 'same-origin'; +}; + +const hostHeaderIsLoopback = (request: IncomingMessage, port: number): boolean => { + const host = singleHeader(request.headers.host); + if (host === undefined) return false; + const separator = host.lastIndexOf(':'); + if (separator === -1) return false; + return loopbackHosts.has(host.slice(0, separator)) && host.slice(separator + 1) === String(port); +}; + +/** + * Acquired resources, released newest first. Each release swallows its own + * failure so a later one still runs, as the finalizers of the former Effect + * scope did; `run()` is idempotent. + */ +const releaseStack = (): Readonly<{ push(release: () => Promise | void): void; run(): Promise }> => { + const releases: (() => Promise | void)[] = []; + let running: Promise | undefined; + return Object.freeze({ + push: (release: () => Promise | void) => { releases.push(release); }, + run: () => { + running ??= (async () => { + for (const release of releases.splice(0).reverse()) { + try { + await release(); + } catch { + // Best-effort teardown: the next resource still gets released. + } + } + })(); + return running; + }, + }); +}; + +export const startWebHost = async (options: StartWebHostOptions): Promise => { + const port = validPort(options.port); + const profile = validProfile(options.profile); + const { selection, session } = options; + const autoApprove = Object.freeze([...options.autoApprove]); + const openBrowser = options.openBrowser ?? openInBrowser; + const token = randomBytes(32).toString('base64url'); + const releases = releaseStack(); + try { + const sockets = new Set(); + // The listener is installed after the routes exist; a request racing the + // wiring is refused rather than served without authorization. + const dispatch: { current?: (request: IncomingMessage, response: ServerResponse) => Promise } = {}; + const server = createServer((request, response) => { + const handler = dispatch.current; + if (handler === undefined) { + responseDiagnostic(response, diagnostic('AB8022', 'MCP App host is not ready.', 503)); + return; + } + void handler(request, response).catch((error: unknown) => { + if (isRequestDiagnostic(error)) { + responseDiagnostic(response, error); + return; + } + responseDiagnostic(response, diagnostic('AB8023', 'MCP App operation could not be completed.', 502)); + }); + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + const boundPort = await listen(server, port); + releases.push(() => closeServer(server, sockets)); + const url = `http://127.0.0.1:${String(boundPort)}`; + const sandbox = await createMcpAppSandboxProxy({ hostOrigin: url }); + releases.push(() => sandbox.close()); + const bindings = new McpAppBindingService({ sessionAuthority: sessionAuthorityFor(session) }); + const previews = new McpAppPreviewService({ + bindingAuthority: bindings, + host: mcpAppPreviewHost(openBrowser), + hostInfo: mcpAppPreviewHostInfo, + hostOrigin: url, + sandboxProxy: sandbox, + toolAuthority: { + resolveTool: async (sessionId, toolName): Promise => { + if (sessionId !== session.sessionId || toolName !== selection.tool.name) { + throw new Error(`Unknown MCP App tool ${JSON.stringify(toolName)}.`); + } + return selection.tool; + }, + }, + }); + releases.push(() => previews.closeAll()); + const authorize = (request: IncomingMessage): void => { + if (!hostHeaderIsLoopback(request, boundPort) || !requestOriginIsHost(request, url)) { + throw requestError(diagnostic('AB8003', 'Request origin is not this MCP App host.', 403)); + } + if (singleHeader(request.headers[WEB_HOST_TOKEN_HEADER]) !== token) { + throw requestError(diagnostic('AB8004', 'A valid MCP App host token is required.', 403)); + } + }; + // The page binds the opening call this host already made instead of + // posting the result back: a large result would otherwise exceed the + // request-body bound and drop the App to the fallback panel (#562). + const openingCall = (sessionId: string, toolName: string) => + sessionId === session.sessionId && toolName === selection.tool.name + ? Object.freeze({ input: selection.input, result: selection.result }) + : undefined; + const routes = new McpAppRoutes({ authorize, openingCall, service: previews }); + releases.push(() => { routes.close(); }); + const page = renderWebHostPage({ + script: options.pageScript, + seed: { + autoApprove, + input: selection.input, + previewProfile: profile, + result: selection.result, + sessionId: session.sessionId, + title: options.title, + token, + tokenHeader: WEB_HOST_TOKEN_HEADER, + toolName: selection.tool.name, + }, + }); + const contentSecurityPolicy = webHostContentSecurityPolicy(sandbox.origin); + dispatch.current = async (request, response) => { + if (!hostHeaderIsLoopback(request, boundPort)) { + throw requestError(diagnostic('AB8003', 'Request origin is not this MCP App host.', 403)); + } + if (await routes.handle(request, response)) return; + const pathname = new URL(request.url ?? '/', url).pathname; + if (pathname !== '/' && pathname !== '/index.html') { + responseDiagnostic(response, diagnostic('AB8020', 'Not found.', 404)); + return; + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return; + } + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-security-policy': contentSecurityPolicy, + 'content-type': 'text/html; charset=utf-8', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', + }); + response.end(request.method === 'HEAD' ? undefined : page); + }; + if (options.open) await openBrowser(`${url}/`); + return Object.freeze({ + close: () => releases.run(), + closed: session.closed, + resourceUri: selection.resourceUri, + sandboxOrigin: sandbox.origin, + server: selection.server, + tool: selection.tool.name, + url: `${url}/`, + }); + } catch (error) { + await releases.run(); + throw error; + } +}; diff --git a/packages/agent-bundle/src/web-host/launch.ts b/packages/agent-bundle/src/web-host/launch.ts new file mode 100644 index 000000000..da634073b --- /dev/null +++ b/packages/agent-bundle/src/web-host/launch.ts @@ -0,0 +1,73 @@ +import { mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { CodedError } from '../core/errors.ts'; +import { mcpServerStateDirectory } from '../core/mcp-state-directory.ts'; +import { exists, joinArtifact, safeArtifactPath } from '../core/paths.ts'; +import { pathTokens, pluginRootEnvAnchor } from '../core/types.ts'; +import type { WebManifestApp } from './manifest.ts'; +import type { StdioLaunch } from './session.ts'; + +/** Plain Node launch support bundled into generated executables (#564). */ +export interface ResolveWebLaunchOptions { + readonly app: WebManifestApp; + readonly env: NodeJS.ProcessEnv; + readonly pluginRoot: string; +} + +export type WebLaunchErrorCode = 'entry-missing' | 'entry-outside-root'; + +export class WebLaunchError extends CodedError { + constructor(code: WebLaunchErrorCode, message: string) { + super('WebLaunchError', code, message); + } +} + +export const webPluginDataDirectory = (pluginRoot: string, server: string): string => + join(pluginRoot, '.agent-bundle', 'web', mcpServerStateDirectory(server)); + +const inheritedEnvironment = (env: NodeJS.ProcessEnv): Record => + Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); + +/** + * Declared env overrides inherited env, matching installed hosts. Plugin data + * is artifact-local because the artifact is the durable installation. + */ +export const resolveWebLaunch = async (options: ResolveWebLaunchOptions): Promise => { + const pluginRoot = resolve(options.pluginRoot); + const { app } = options; + if (!safeArtifactPath(app.entry)) { + throw new WebLaunchError( + 'entry-outside-root', + `MCP server entry ${JSON.stringify(app.entry)} of ${app.app} escapes the plugin root ${pluginRoot}; ` + + 'a web manifest may only name files of its own artifact.', + ); + } + const entry = joinArtifact(pluginRoot, app.entry); + if (!(await exists(entry))) { + throw new WebLaunchError( + 'entry-missing', + `MCP server entry ${entry} of ${app.app} does not exist; rebuild the plugin so the artifact matches its manifest.`, + ); + } + const pluginData = webPluginDataDirectory(pluginRoot, app.server); + const workspaceRoot = process.cwd(); + const expand = (value: string): string => value + .replaceAll(pathTokens.pluginRoot, pluginRoot) + .replaceAll(pathTokens.pluginData, pluginData) + .replaceAll(pathTokens.workspaceRoot, workspaceRoot); + const declared = Object.fromEntries(Object.entries(app.env).map(([key, value]) => [key, expand(value)])); + if (Object.values(app.env).some((value) => value.includes(pathTokens.pluginData))) { + await mkdir(pluginData, { recursive: true }); + } + return Object.freeze({ + args: Object.freeze([entry, ...app.args.map(expand)]), + command: process.execPath, + cwd: pluginRoot, + env: Object.freeze({ + ...inheritedEnvironment(options.env), + ...declared, + ...(Object.hasOwn(declared, pluginRootEnvAnchor) ? {} : { [pluginRootEnvAnchor]: pluginRoot }), + }), + }); +}; diff --git a/packages/agent-bundle/src/web-host/manifest.ts b/packages/agent-bundle/src/web-host/manifest.ts new file mode 100644 index 000000000..39e2fe3bb --- /dev/null +++ b/packages/agent-bundle/src/web-host/manifest.ts @@ -0,0 +1,130 @@ +import { readFile } from 'node:fs/promises'; + +import { + isServeAppAllowCapability, + type ServeAppAllowCapability, +} from '../core/mcp-app-allow.ts'; +import { errorMessage } from '../core/errors.ts'; +import { hasDataKeys, isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; + +export interface WebManifestApp { + readonly allow: readonly ServeAppAllowCapability[]; + readonly app: string; + /** The server's declared arguments after its entry, path tokens unexpanded. */ + readonly args: readonly string[]; + readonly entry: string; + readonly env: Readonly>; + readonly input?: Readonly>; + readonly name: string; + readonly resourceUri: string; + readonly server: string; + readonly tool?: string; +} + +export interface WebManifest { + readonly apps: readonly WebManifestApp[]; + readonly open: 'browser' | 'never'; +} + +type JsonRecord = Readonly>; + +const prefix = 'agent-bundle.manifest.json web section is invalid:'; + +const invalid = (message: string): Error => new Error(`${prefix} ${message}`); + +const fail = (message: string): never => { + throw invalid(message); +}; + +const record = (value: unknown, location: string): JsonRecord => + isPlainRecord(value) ? value : fail(`${location} must be a plain object.`); + +const keyedRecord = ( + value: unknown, + location: string, + required: readonly string[], + optional: readonly string[] = [], +): JsonRecord => { + const candidate = record(value, location); + if (hasDataKeys(candidate, required, optional)) return candidate; + const expected = [...required, ...optional.map((key) => `${key}?`)].join(', '); + return fail(`${location} must have exactly the keys ${expected}; found ${Object.keys(candidate).join(', ') || 'none'}.`); +}; + +const string = (value: unknown, location: string): string => + typeof value === 'string' && value.length > 0 + ? value + : fail(`${location} must be a non-empty string.`); + +const stringArray = (value: unknown, location: string): readonly string[] => { + if (!Array.isArray(value)) throw invalid(`${location} must be an array.`); + return value.map((entry: unknown, index: number) => + typeof entry === 'string' ? entry : fail(`${location}[${index}] must be a string.`)); +}; + +const stringRecord = (value: unknown, location: string): Readonly> => { + const candidate = record(value, location); + const result: Record = {}; + for (const [key, entry] of Object.entries(candidate)) { + if (typeof entry !== 'string') throw invalid(`${location}.${key} must be a string.`); + result[key] = entry; + } + return result; +}; + +const inputRecord = (value: unknown, location: string): Readonly> => + record(value, location); + +const parseApp = (value: unknown, index: number): WebManifestApp => { + const location = `apps[${index}]`; + const app = keyedRecord( + value, + location, + ['allow', 'app', 'args', 'entry', 'env', 'name', 'resourceUri', 'server'], + ['input', 'tool'], + ); + if (!Array.isArray(app.allow)) throw invalid(`${location}.allow must be an array.`); + const allow = app.allow.map((capability: unknown, capabilityIndex: number) => { + if (typeof capability !== 'string' || !isServeAppAllowCapability(capability)) { + return fail(`${location}.allow[${capabilityIndex}] is not an App-initiated consent capability.`); + } + return capability; + }); + return { + allow, + app: string(app.app, `${location}.app`), + args: stringArray(app.args, `${location}.args`), + entry: string(app.entry, `${location}.entry`), + env: stringRecord(app.env, `${location}.env`), + ...(app.input === undefined ? {} : { input: inputRecord(app.input, `${location}.input`) }), + name: string(app.name, `${location}.name`), + resourceUri: string(app.resourceUri, `${location}.resourceUri`), + server: string(app.server, `${location}.server`), + ...(app.tool === undefined ? {} : { tool: string(app.tool, `${location}.tool`) }), + }; +}; + +export const parseWebManifest = (value: unknown): WebManifest => { + const manifest = keyedRecord(value, 'root', ['apps', 'open']); + if (!Array.isArray(manifest.apps)) throw invalid('apps must be an array.'); + if (manifest.open !== 'browser' && manifest.open !== 'never') { + throw invalid('open must be "browser" or "never".'); + } + const apps = manifest.apps.map(parseApp); + for (let index = 1; index < apps.length; index += 1) { + if (apps[index - 1]!.app.localeCompare(apps[index]!.app) >= 0) { + fail('apps must be sorted by app with no duplicates.'); + } + } + return { apps, open: manifest.open }; +}; + +export const readWebManifest = async (manifestPath: string): Promise => { + try { + const document = parseJsonWithoutDuplicateKeys(await readFile(manifestPath, 'utf8')); + const manifest = record(document, 'manifest'); + return manifest['web'] === undefined ? undefined : parseWebManifest(manifest['web']); + } catch (error) { + throw new Error(`Unable to read web section from ${manifestPath}: ${errorMessage(error)}`, { cause: error }); + } +}; diff --git a/packages/agent-bundle/src/web-host/page-script.ts b/packages/agent-bundle/src/web-host/page-script.ts new file mode 100644 index 000000000..a22b8b755 --- /dev/null +++ b/packages/agent-bundle/src/web-host/page-script.ts @@ -0,0 +1,27 @@ +import { readFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; + +// Rslib bundles this module into a chunk at the dist root, while source-level +// consumers (tests) run it from src/web-host. Spelled as paths, not +// `new URL(…, import.meta.url)`: the package's own Rslib build would read a +// static URL as an asset to emit. The same rule dev/workbench-assets.ts uses +// to find dist/workbench. +const packageRoot = basename(import.meta.dirname) === 'dist' + ? resolve(import.meta.dirname, '..') + : resolve(import.meta.dirname, '../..'); + +const pageScriptPath = resolve(packageRoot, 'dist', 'web-host', 'page.js'); + +let pageScript: Promise | undefined; + +/** Cache successful reads only, so a later build repairs an initial miss. */ +export const readWebHostPageScript = (): Promise => { + pageScript ??= readFile(pageScriptPath, 'utf8').catch((error: unknown) => { + pageScript = undefined; + throw new Error( + `agent-bundle was built without its web host page (${pageScriptPath}); run pnpm build.`, + { cause: error }, + ); + }); + return pageScript; +}; diff --git a/packages/agent-bundle/src/web-host/page.ts b/packages/agent-bundle/src/web-host/page.ts new file mode 100644 index 000000000..446b51ed0 --- /dev/null +++ b/packages/agent-bundle/src/web-host/page.ts @@ -0,0 +1,89 @@ +import { WEB_HOST_SEED_ELEMENT_ID, type WebHostPageSeed } from './browser/seed.ts'; + +/** Shared header for a standalone host's per-launch credential. */ +export const WEB_HOST_TOKEN_HEADER = 'x-agent-bundle-web-host'; + +export interface RenderWebHostPageOptions { + readonly script: string; + readonly seed: WebHostPageSeed; +} + +const escapeHtml = (value: string): string => + value.replace(/[&<>"']/gu, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ?? character); + +/** JSON that is safe inside a ``, + ``, + '', + '', + '', + ].join('\n'); +}; diff --git a/packages/agent-bundle/src/web-host/select-app.ts b/packages/agent-bundle/src/web-host/select-app.ts new file mode 100644 index 000000000..5583f98ba --- /dev/null +++ b/packages/agent-bundle/src/web-host/select-app.ts @@ -0,0 +1,117 @@ +import { isRecord } from '../core/strict-json.ts'; +import { + selectMcpAppResourceUri, + type McpAppJsonValue, + type McpAppToolDefinition, +} from '../dev/mcp-apps/mcp-app-binding-service.ts'; +import { canonicalMcpAppJson } from '../dev/mcp-session/mcp-session-apps.ts'; + +export interface AppSelectionSource { + callTool(name: string, input: Readonly>): Promise; + listAppResourceUris(): Promise; + listToolDefinitions(): Promise; +} + +export interface AppSelector { + readonly name?: string; + readonly resourceUri?: string; + readonly server: string; +} + +export interface OpenAppRequest { + readonly input?: Readonly>; + readonly name?: string; + readonly resourceUri?: string; + readonly server: string; + readonly tool?: string; +} + +export interface AppSelection { + readonly input: Readonly>; + readonly resourceUri: string; + readonly result: McpAppJsonValue; + readonly server: string; + readonly tool: McpAppToolDefinition; +} + +export const requireJsonObject = (value: unknown, label: string): Readonly> => { + const snapshot = canonicalMcpAppJson(value, label); + if (!isRecord(snapshot)) throw new TypeError(`${label} must be a JSON object.`); + return snapshot; +}; + +export const parseAppSelector = (value: string): AppSelector => { + const trimmed = value.trim(); + if (trimmed.length === 0) throw new Error('MCP App must be named as / or a ui:// resource URI.'); + const separator = trimmed.indexOf('/'); + if (separator < 1 || separator === trimmed.length - 1) { + throw new Error(`MCP App ${JSON.stringify(value)} must be named as / or /ui://... .`); + } + const server = trimmed.slice(0, separator); + const rest = trimmed.slice(separator + 1); + if (rest.startsWith('ui://')) return Object.freeze({ resourceUri: rest, server }); + if (rest.includes('/')) throw new Error(`MCP App name ${JSON.stringify(rest)} must not contain a slash.`); + return Object.freeze({ name: rest, server }); +}; + +export const appNameOf = (resourceUri: string): string | undefined => { + try { + const parsed = new URL(resourceUri); + if (parsed.protocol !== 'ui:') return undefined; + const segment = parsed.pathname.split('/').filter((part) => part.length > 0).at(-1); + return segment === undefined ? undefined : segment.replace(/\.html?$/iu, ''); + } catch { + return undefined; + } +}; + +const matchingResourceUris = (resourceUris: readonly string[], request: OpenAppRequest): readonly string[] => { + if (request.resourceUri !== undefined) return resourceUris.includes(request.resourceUri) ? [request.resourceUri] : []; + return resourceUris.filter((uri) => appNameOf(uri) === request.name); +}; + +/** + * Resolves the App and its opening tool against the live server, then calls + * the tool once. A known `resourceUri` skips name matching but is still + * verified against what the server serves; the tool must advertise the App + * as `_meta.ui.resourceUri`, and without `tool` exactly one may. + */ +export const openApp = async (source: AppSelectionSource, request: OpenAppRequest): Promise => { + if (request.resourceUri === undefined && request.name === undefined) { + throw new Error('MCP App must be named as / or a ui:// resource URI.'); + } + const [tools, resourceUris] = await Promise.all([source.listToolDefinitions(), source.listAppResourceUris()]); + const matching = matchingResourceUris(resourceUris, request); + const available = resourceUris.map((uri) => `${request.server}/${appNameOf(uri) ?? uri}`); + if (matching.length === 0) { + throw new Error( + `MCP server ${JSON.stringify(request.server)} serves no MCP App ${JSON.stringify(request.resourceUri ?? request.name)}` + + `${available.length === 0 ? ' (it serves no MCP App resources).' : `; available: ${available.join(', ')}.`}`, + ); + } + if (matching.length > 1) { + throw new Error( + `MCP App ${JSON.stringify(request.name)} names ${String(matching.length)} resources on server ${JSON.stringify(request.server)}; ` + + `use ${request.server}/ to select one of: ${matching.join(', ')}.`, + ); + } + const resourceUri = matching[0]!; + const appTools = tools.filter((tool) => selectMcpAppResourceUri(tool) === resourceUri); + const selectedTool = request.tool === undefined + ? appTools.length === 1 ? appTools[0] : undefined + : appTools.find((tool) => tool.name === request.tool); + if (selectedTool === undefined) { + if (request.tool !== undefined) { + throw new Error( + `Tool ${JSON.stringify(request.tool)} does not open MCP App ${resourceUri}` + + `${appTools.length === 0 ? '.' : `; tools that do: ${appTools.map((tool) => tool.name).join(', ')}.`}`, + ); + } + throw new Error(appTools.length === 0 + ? `No tool on server ${JSON.stringify(request.server)} declares _meta.ui.resourceUri ${resourceUri}.` + : `Several tools open MCP App ${resourceUri} (${appTools.map((tool) => tool.name).join(', ')}); choose one with --tool.`); + } + const input = requireJsonObject(request.input ?? {}, 'MCP App tool input'); + const result = await source.callTool(selectedTool.name, input); + return Object.freeze({ input, resourceUri, result, server: request.server, tool: selectedTool }); +}; diff --git a/packages/agent-bundle/src/web-host/session.ts b/packages/agent-bundle/src/web-host/session.ts new file mode 100644 index 000000000..a56ad484a --- /dev/null +++ b/packages/agent-bundle/src/web-host/session.ts @@ -0,0 +1,209 @@ +import { Client, type Resource, type Tool } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { randomUUID } from 'node:crypto'; +import type { Stream } from 'node:stream'; + +import type { + McpAppBridgeResource, + McpAppBridgeSession, + McpAppBridgeTool, + McpAppJsonValue, + McpAppSessionAuthority, + McpAppSessionLease, + McpAppToolDefinition, +} from '../dev/mcp-apps/mcp-app-binding-service.ts'; +import { MCP_APP_MIME_TYPE } from '../dev/mcp-apps/mcp-app-bridge.ts'; +import { mcpAppPreviewHostInfo } from '../dev/mcp-apps/mcp-app-preview-host.ts'; +import { + canonicalMcpAppJson, + canonicalMcpAppResource, + canonicalMcpAppTool, + mcpAppClientCapabilities, +} from '../dev/mcp-session/mcp-session-apps.ts'; +import { requireJsonObject, type AppSelectionSource } from './select-app.ts'; + +/** Plain Node session support bundled into generated executables (AB6005). */ +const maxStderrBytes = 64 * 1024; + +export interface StdioLaunch { + readonly args: readonly string[]; + readonly command: string; + readonly cwd: string; + readonly env: Readonly>; +} + +export interface StdioAppSession { + readonly bridge: McpAppBridgeSession; + readonly closed: Promise; + readonly selection: AppSelectionSource; + readonly sessionId: string; + readonly stderr: () => string; + close(): Promise; + watchClosed(listener: WebHostCloseListener): () => void; +} + +export type WebHostCloseListener = () => Promise | void; + +// Teardown listeners belong to the App bindings, not to the transport: one +// that throws or rejects must not abort the others or escape the transport's +// close callback, matching `McpSessionService`'s watcher dispatch. +const notifyClosed = (listener: WebHostCloseListener): void => { + try { + void Promise.resolve(listener()).catch(() => undefined); + } catch { + // Isolated above; a synchronous throw is the same failure surfaced early. + } +}; + +const captureStderr = (stream: Stream | null): (() => string) => { + if (stream === null) return () => ''; + let captured = ''; + stream.on('data', (chunk: unknown) => { + if (captured.length >= maxStderrBytes) return; + captured = `${captured}${String(chunk)}`.slice(0, maxStderrBytes); + }); + return () => captured; +}; + +export const openStdioAppSession = async ( + launch: StdioLaunch, + identity: Readonly<{ readonly serverName: string; readonly target: string }>, + timeoutMs: number, +): Promise => { + const client = new Client({ name: mcpAppPreviewHostInfo.name, version: mcpAppPreviewHostInfo.version }, { + capabilities: mcpAppClientCapabilities, + }); + const transport = new StdioClientTransport({ + args: [...launch.args], + command: launch.command, + cwd: launch.cwd, + env: { ...launch.env }, + stderr: 'pipe', + }); + const stderr = captureStderr(transport.stderr); + const closedGate = Promise.withResolvers(); + const listeners = new Set(); + let closed = false; + const markClosed = (): void => { + if (closed) return; + closed = true; + closedGate.resolve(); + const pending = [...listeners]; + listeners.clear(); + for (const listener of pending) notifyClosed(listener); + }; + transport.onclose = markClosed; + try { + await client.connect(transport, { timeout: timeoutMs }); + } catch (error) { + markClosed(); + const output = stderr(); + throw new Error( + `The packed MCP server did not start: ${error instanceof Error ? error.message : String(error)}` + + `${output.length === 0 ? '' : `\nserver stderr:\n${output}`}`, + { cause: error }, + ); + } + // The transport's own onclose is installed by the SDK client on connect; + // chain ours behind it so an unexpected server exit still settles `closed`. + const sdkOnClose = transport.onclose; + transport.onclose = () => { + try { + sdkOnClose?.(); + } finally { + markClosed(); + } + }; + const assertActive = (): void => { + if (closed) throw new Error('The bound MCP server connection is closed.'); + }; + const requestOptions = Object.freeze({ timeout: timeoutMs }); + let bridgeTools: Promise | undefined; + let bridgeResources: Promise | undefined; + const listTools = async (): Promise => Object.freeze([...(await client.listTools(undefined, requestOptions)).tools]); + const listResources = async (): Promise => Object.freeze([...(await client.listResources(undefined, requestOptions)).resources]); + const sessionId = randomUUID(); + const bridge: McpAppBridgeSession = Object.freeze({ + callTool: async ({ arguments: toolArguments, name }: { readonly arguments: McpAppJsonValue | undefined; readonly name: string }) => { + assertActive(); + const argumentsSnapshot = requireJsonObject(toolArguments ?? {}, 'MCP App tool arguments'); + const result = await client.callTool({ arguments: { ...argumentsSnapshot }, name }, requestOptions); + assertActive(); + return canonicalMcpAppJson(result, 'MCP App tool result'); + }, + identity: Object.freeze({ epochId: `web-host:${sessionId}`, serverName: identity.serverName, sessionId, target: identity.target }), + listBridgeResources: async () => { + assertActive(); + bridgeResources ??= listResources().then((resources) => Object.freeze(resources.map(canonicalMcpAppResource))); + const resources = await bridgeResources; + assertActive(); + return resources; + }, + listBridgeTools: async () => { + assertActive(); + bridgeTools ??= listTools().then((tools) => Object.freeze(tools.map(canonicalMcpAppTool))); + const tools = await bridgeTools; + assertActive(); + return tools; + }, + readResource: async ({ uri }: { readonly uri: string }) => { + assertActive(); + const result = await client.readResource({ uri }, requestOptions); + assertActive(); + return canonicalMcpAppJson(result, 'MCP App resource result'); + }, + }); + const selection: AppSelectionSource = Object.freeze({ + callTool: (name: string, input: Readonly>) => bridge.callTool({ arguments: input, name }), + listAppResourceUris: async (): Promise => { + assertActive(); + const resources = await listResources(); + assertActive(); + return Object.freeze(resources.filter((resource) => resource.mimeType === MCP_APP_MIME_TYPE).map((resource) => resource.uri)); + }, + listToolDefinitions: async (): Promise => { + assertActive(); + const tools = await listTools(); + assertActive(); + return Object.freeze(tools.map((tool) => canonicalMcpAppTool(tool).definition)); + }, + }); + let closing: Promise | undefined; + return Object.freeze({ + bridge, + close: () => { + closing ??= client.close().catch(() => undefined).then(markClosed); + return closing; + }, + closed: closedGate.promise, + selection, + sessionId, + stderr, + watchClosed: (listener: WebHostCloseListener) => { + if (closed) { + notifyClosed(listener); + return () => undefined; + } + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + }); +}; + +export const sessionAuthorityFor = (session: StdioAppSession): McpAppSessionAuthority => Object.freeze({ + acquireAppLease: async (sessionId: string): Promise => { + if (sessionId !== session.sessionId) throw new Error(`Unknown MCP App session ${JSON.stringify(sessionId)}.`); + return Object.freeze({ + release: async () => undefined, + session: session.bridge, + watchSessionClosed: (listener: (reason?: unknown) => Promise | void) => { + let closedNow = false; + const unsubscribe = session.watchClosed(() => { + closedNow = true; + return listener(); + }); + return Object.freeze({ closed: closedNow, unsubscribe }); + }, + }); + }, +}); diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index bbaa35fd8..5654cb0d0 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -6,21 +6,27 @@ import type { AgentNoticeDeliveryAdvertisement, AgentNoticeDeliveryRoute } from import { capabilityEvidence, capabilityIsSupported, + cliBinCapability, intersectNoticeDeliveryAdvertisements, noticeDeliveryAdvertisementFrom, supportedCapability, unavailableCapability, + webSurfaceCapability, } from '../src/adapters/capability-state.ts'; import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.260.json' with { type: 'json' }; import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; import cursorCapabilityTable from '../src/adapters/capabilities/cursor-2026-08-28.json' with { type: 'json' }; +import portableCapabilityTable from '../src/adapters/capabilities/portable-1.0.0.json' with { type: 'json' }; import cursorHooksSchema from '../src/adapters/schemas/cursor/hooks.schema.json' with { type: 'json' }; import { cursorContractCapabilityRows } from '../src/adapters/cursor.ts'; import { NOTICE_DELIVERY_ROUTES } from '../src/adapters/notice-delivery.ts'; import type { NoticeDeliveryAdvertisement, NoticeDeliveryRoute } from '../src/adapters/notice-delivery.ts'; import { TargetRegistry, createDefaultRegistry } from '../src/adapters/registry.ts'; +import { generatedBinCapability } from '../src/build/cli-bins.ts'; import { CapabilityStateError, isCapabilityState } from '../src/core/capabilities.ts'; import type { CapabilityEvidence, CapabilityState } from '../src/core/capabilities.ts'; +import type { NormalizedBinEntry } from '../src/core/types.ts'; +import type { CompiledCliCommand } from '../src/routes/types.ts'; const evidence = (target: string): CapabilityEvidence => Object.freeze({ observedVersion: `${target}-version`, @@ -911,6 +917,58 @@ it('publishes the routed CLI bin capability with its bin layout on every built-i expect(registry.hostsComponent('unknown-target', 'cli')).toBe(false); }); +it('pins a supported web surface row on every host capability table (#564)', () => { + const row = { + reason: 'browser host inside the composite artifact; web runs from the installed root on any host', + state: 'supported', + }; + expect(webSurfaceCapability).toBe('web'); + for (const table of [claudeCapabilityTable, codexCapabilityTable, cursorCapabilityTable, portableCapabilityTable]) { + expect(table.plugin.web).toEqual(row); + } + // The row is what emission consults: every built-in host hosts the web-only bin. + const registry = createDefaultRegistry(); + for (const host of ['claude', 'codex', 'cursor', 'portable']) { + expect(registry.hostsComponent(host, webSurfaceCapability), host).toBe(true); + expect(registry.capabilityState(host, webSurfaceCapability)?.state, host).toBe('supported'); + } +}); + +it('judges a web-only bin on the web row and a routed bin on the cli row', () => { + const cursor = createDefaultRegistry().get('cursor'); + const webOnlyHost = new TargetRegistry(); + webOnlyHost.register({ + ...cursor, + capabilities: { ...cursor.capabilities, [cliBinCapability]: unavailableCapability('no routed CLI here') }, + }); + const cliOnlyHost = new TargetRegistry(); + cliOnlyHost.register({ + ...cursor, + capabilities: { ...cursor.capabilities, [webSurfaceCapability]: unavailableCapability('no browser here') }, + }); + const bin: NormalizedBinEntry = { + id: 'bin:fixture', + name: 'fixture', + provenance: { kind: 'conventional', sourcePath: '/project/agent-bundle.config.ts' }, + source: '/project/agent-bundle.config.ts', + }; + const command: CompiledCliCommand = { aliases: [], exitCode: 'zero', options: [], path: ['report'], rendered: false, routeId: 'cli:report' }; + const webOnly: NormalizedBinEntry = { ...bin, generatedCli: { commands: [], routes: [] }, web: true }; + const routed: NormalizedBinEntry = { ...bin, generatedCli: { commands: [command], routes: [] } }; + const routedWithWeb: NormalizedBinEntry = { ...routed, web: true }; + + // A generated web bin carries an empty `generatedCli`; the command count, not + // the field's presence, decides which capability row admits it. + expect(generatedBinCapability(webOnly)).toBe(webSurfaceCapability); + expect(generatedBinCapability(routed)).toBe(cliBinCapability); + expect(generatedBinCapability(routedWithWeb)).toBe(cliBinCapability); + expect(generatedBinCapability(undefined)).toBe(cliBinCapability); + expect(webOnlyHost.hostsComponent('cursor', generatedBinCapability(webOnly))).toBe(true); + expect(cliOnlyHost.hostsComponent('cursor', generatedBinCapability(webOnly))).toBe(false); + expect(webOnlyHost.hostsComponent('cursor', generatedBinCapability(routed))).toBe(false); + expect(cliOnlyHost.hostsComponent('cursor', generatedBinCapability(routed))).toBe(true); +}); + it('rejects a malformed inspection component capability when the adapter registers', () => { const source = createDefaultRegistry().get('cursor'); diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts index d67721102..0daea636d 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -60,6 +60,7 @@ const createFixture = async (options: { /** Ship a Claude skill referencing the bin through the plugin-root token (Claude-only Skill Markdown syntax). */ readonly skill?: boolean; readonly targets: readonly string[]; + readonly web?: boolean; }): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-cli-bin-artifact-')); roots.push(root); @@ -80,6 +81,7 @@ const createFixture = async (options: { 'export default defineConfig({', ` plugin: { description: 'Artifact routed CLI fixture.', name: ${JSON.stringify(pluginName)}, version: '3.8.7' },`, ` targets: ${JSON.stringify(options.targets)},`, + ...(options.web === true ? [" web: { apps: ['curator/dashboard'] },"] : []), '});', '', ].join('\n')), @@ -144,6 +146,20 @@ const createFixture = async (options: { '}', '', ].join('\n')), + ...(options.web === true + ? [ + writeProjectFile(root, 'src/mcp/curator/apps/dashboard.ts', [ + "export const config = { resourceUri: 'ui://curator/dashboard.html', template: './dashboard.html' };", + 'export default async () => ({});', + '', + ].join('\n')), + writeProjectFile( + root, + 'src/mcp/curator/apps/dashboard.html', + 'dashboard\n', + ), + ] + : []), // A plain script route forwarding to the routed CLI through the // documented sibling convention: `../bin/.mjs` relative to // the script's own `import.meta.url` inside the artifact. @@ -178,6 +194,40 @@ const createFixture = async (options: { return root; }; +const webOnlyPluginName = 'web-only-artifact'; +const agentBundleImport = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]agent-bundle(?:\/[^'"]*)?['"]/u; + +const createWebOnlyFixture = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-web-only-artifact-')); + roots.push(root); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ name: webOnlyPluginName, type: 'module', version: '1.0.0' })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + ' mcp: { servers: { status: {', + ' apps: {', + " status: { entry: './views/status.ts', resourceUri: 'ui://web-only-artifact/status.html', template: './views/status.html' },", + " 'claude-only': { entry: './views/status.ts', resourceUri: 'ui://web-only-artifact/claude-only.html', targets: ['claude'], template: './views/status.html' },", + ' },', + " args: ['--verbose'],", + " entry: './src/mcp/status.ts',", + " targets: ['claude', 'portable'],", + ' } } },', + ` plugin: { description: 'Web-only artifact fixture.', name: ${JSON.stringify(webOnlyPluginName)}, version: '1.0.0' },`, + " targets: ['claude', 'portable'],", + " web: { apps: ['status/claude-only', 'status/status'] },", + '});', + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/status.ts', "process.stderr.write('status server\\n');\n"), + writeProjectFile(root, 'views/status.html', '
\n'), + writeProjectFile(root, 'views/status.ts', "document.querySelector('#view')!.textContent = 'web-only status';\n"), + ]); + return root; +}; + const parseJsonLine = (stdout: string): unknown => JSON.parse(stdout) as unknown; /** @@ -339,6 +389,72 @@ it('lets a skill reach the artifact bin through the plugin-root token, and the b expect(await probe({ AGENT_BUNDLE_ENV_FILE: 'none' })).toEqual({ atImport: 'unset', atRun: 'unset', providerAtImport: 'unset' }); }); +it('emits a self-contained routed bin for a web-only plugin', { retry: 1, timeout: 240_000 }, async () => { + const root = await createFixture({ targets: ['portable'], web: true }); + await rm(join(root, 'src', 'cli'), { force: true, recursive: true }); + + const result = await build({ output: 'artifact', root }); + const binPath = join(root, 'artifact', 'bin', `${pluginName}.mjs`); + const source = await readFile(binPath, 'utf8'); + + await expect(stat(binPath)).resolves.toMatchObject({}); + expect(result.build.manifest.files.find((file) => file.path === `bin/${pluginName}.mjs`)) + .toMatchObject({ kind: 'bundle' }); + expect(source).toContain('agent-bundle-web-host-seed'); + expect(source).not.toMatch(/from\s*['"]agent-bundle\//u); + expect((await validateArtifact({ artifactRoot: join(root, 'artifact') })) + .filter((entry) => entry.code === 'AB6005')).toEqual([]); +}); + +it('lists authored commands and web in one generated artifact bin', { retry: 1, timeout: 240_000 }, async () => { + const root = await createFixture({ targets: ['portable'], web: true }); + await build({ output: 'artifact', root }); + + const binPath = join(root, 'artifact', 'bin', `${pluginName}.mjs`); + const help = await execFile(process.execPath, [binPath, '--help']); + expect(help.stdout).toContain('status'); + expect(help.stdout).toContain('web'); + expect(await readFile(binPath, 'utf8')).toContain('agent-bundle-web-host-seed'); +}); + +it('emits bin/.mjs for a project with web.apps and no src/cli, and its --help lists web (#564)', { retry: 1, timeout: 240_000 }, async () => { + const root = await createWebOnlyFixture(); + // The `claude`-only App is exposed but out of this root's selection: it + // is neither compiled nor advertised, so ` web` cannot pick an App + // the shipped server does not serve. + const result = await build({ output: 'artifact', root, targets: ['portable'] }); + const artifactRoot = join(root, 'artifact'); + + const binPath = join(artifactRoot, 'bin', `${webOnlyPluginName}.mjs`); + await expect(stat(binPath)).resolves.toMatchObject({}); + expect(result.build.manifest.files.find((file) => file.path === `bin/${webOnlyPluginName}.mjs`)).toMatchObject({ kind: 'bundle' }); + expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + expect(await readFile(binPath, 'utf8')).not.toMatch(agentBundleImport); + + const help = await execFile(process.execPath, [binPath, '--help']); + expect(help.stdout).toContain(`${webOnlyPluginName} 1.0.0`); + expect(help.stdout).toMatch(/^Commands:$/mu); + expect(help.stdout).toMatch(/^\s+web\b/mu); + + const manifest = JSON.parse(await readFile(join(artifactRoot, 'agent-bundle.manifest.json'), 'utf8')) as { readonly web?: unknown }; + const mcpEntries = result.build.manifest.files.filter((file) => file.path.startsWith('mcp/')).map((file) => file.path); + expect(mcpEntries).toHaveLength(1); + expect(result.build.manifest.files.filter((file) => file.path.startsWith('mcp-apps/')).map((file) => file.path)).toEqual(['mcp-apps/status.html']); + expect(manifest.web).toEqual({ + apps: [{ + allow: [], + app: 'status/status', + args: ['--verbose'], + entry: mcpEntries[0]!, + env: {}, + name: 'status', + resourceUri: 'ui://web-only-artifact/status.html', + server: 'status', + }], + open: 'never', + }); +}); + it('refuses a host-emitted file that collides with the routed CLI bin (AB4766)', { timeout: 120_000 }, async () => { const root = await createFixture({ targets: ['claude'] }); // A configured Claude bin directory shipping the same file name the routed diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 55fcdc5d2..ebf7494ab 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -474,7 +474,7 @@ it('refuses a routed command that imports agent-bundle/api with AB4837 before bu const { diagnostics } = failure as DiagnosticError; const reported = diagnostics.filter((diagnostic) => diagnostic.code === 'AB4837'); expect(reported).toEqual([expect.objectContaining(expected)]); - expect(reported[0]?.recovery).toContain('spawnServeApp from agent-bundle/serve-app-command'); + expect(reported[0]?.recovery).toContain('expose the App with web.apps and open it with web'); // Neither the bundler's resolution failure nor the artifact validator's // rejection of the inlined compiler reaches the author any more. expect(diagnostics.some((diagnostic) => diagnostic.message.includes("Can't resolve"))).toBe(false); diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts index 710cb5ee9..4bf9b79b1 100644 --- a/packages/agent-bundle/tests/cli-routes.test.ts +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -16,6 +16,8 @@ import { type CliRenderedDocument, type CliRenderedEvent, type GeneratedCliRenderSession, + type GeneratedCliWebCommand, + type GeneratedCliWebContext, } from '../src/cli-entry.ts'; import { normalizePackageBuild } from '../src/config/normalize.ts'; import type { AgentBundleConfig } from '../src/core/types.ts'; @@ -814,9 +816,11 @@ describe('generated CLI shell', () => { const run = async ( argv: readonly string[], options: { + readonly commands?: readonly CompiledCliCommand[]; readonly result?: unknown; readonly signal?: AbortSignal; readonly throws?: Error; + readonly web?: GeneratedCliWebCommand; } = {}, ): Promise => { const calls: RunResult['calls'] = []; @@ -824,7 +828,7 @@ describe('generated CLI shell', () => { const stderr: string[] = []; const code = await runGeneratedCliEntry({ argv, - commands, + commands: options.commands ?? commands, description: 'Curate audiobooks.', execute: async (command, input, context) => { calls.push({ command, input, json: context.json }); @@ -834,6 +838,7 @@ describe('generated CLI shell', () => { name: 'curator', ...(options.signal === undefined ? {} : { signal: options.signal }), version: '1.2.3', + ...(options.web === undefined ? {} : { web: options.web }), writeErr: (text) => void stderr.push(text), writeOut: (text) => void stdout.push(text), }); @@ -921,6 +926,64 @@ describe('generated CLI shell', () => { expect(version).toMatchObject({ code: 0, stdout: 'curator 1.2.3\n' }); }); + describe('the framework-owned web command (#564)', () => { + const webRow = " web Open one of the plugin's MCP Apps in a browser."; + const recording = () => { + const invocations: { argv: readonly string[]; context: GeneratedCliWebContext }[] = []; + const web: GeneratedCliWebCommand = { + run: async (argv, context) => { + invocations.push({ argv, context }); + context.writeOut('MCP App status/status at http://127.0.0.1:4321/ (tool show_status; Ctrl-C stops the server)\n'); + return 7; + }, + }; + return { invocations, web }; + }; + + it('dispatches web with the arguments after it before consulting the authored tree', async () => { + const { invocations, web } = recording(); + const controller = new AbortController(); + const result = await run(['web', 'status/status', '--json'], { signal: controller.signal, web }); + expect(result.code).toBe(7); + expect(result.calls).toEqual([]); + expect(result.stdout).toBe('MCP App status/status at http://127.0.0.1:4321/ (tool show_status; Ctrl-C stops the server)\n'); + expect(result.stderr).toBe(''); + expect(invocations).toHaveLength(1); + expect(invocations[0]!.argv).toEqual(['status/status', '--json']); + expect(invocations[0]!.context.name).toBe('curator'); + expect(invocations[0]!.context.signal).toBe(controller.signal); + }); + + it('lists web among the root commands only when the executable carries it, and only at the root', async () => { + const { web } = recording(); + const withWeb = await run(['--help'], { web }); + expect(withWeb.code).toBe(0); + expect(withWeb.stdout).toContain('\nCommands:\n doctor Inspect the runtime.\n library \n offset Apply a signed offset.\n' + `${webRow}\n`); + expect((await run([], { web })).stdout).toBe(withWeb.stdout); + expect((await run(['--help'])).stdout).not.toContain('MCP Apps'); + expect((await run(['library', '--help'], { web })).stdout).not.toContain('MCP Apps'); + }); + + it('prints the help listing web for an executable with no authored command', async () => { + const { web } = recording(); + const help = await run(['--help'], { commands: [], web }); + expect(help.code).toBe(0); + expect(help.stdout).toContain('Usage: curator [options]'); + expect(help.stdout).toContain(`\nCommands:\n web Open one of the plugin's MCP Apps in a browser.\n`); + expect(help.stderr).toBe(''); + expect((await run([], { commands: [], web })).stdout).toBe(help.stdout); + }); + + it('keeps --version ahead of web and web unknown without the hook', async () => { + const { invocations, web } = recording(); + expect(await run(['--version'], { web })).toMatchObject({ code: 0, stdout: 'curator 1.2.3\n' }); + expect(invocations).toEqual([]); + const unknown = await run(['web', 'status/status']); + expect(unknown.code).toBe(2); + expect(unknown.stderr).toContain('Unknown command: web.'); + }); + }); + it('prints command help with usage, aliases, arguments, defaults, and choices', async () => { const help = await run(['doctor', '--help']); expect(help.code).toBe(0); diff --git a/packages/agent-bundle/tests/dev-watcher.test.ts b/packages/agent-bundle/tests/dev-watcher.test.ts index ba5f45a5c..4afd18541 100644 --- a/packages/agent-bundle/tests/dev-watcher.test.ts +++ b/packages/agent-bundle/tests/dev-watcher.test.ts @@ -67,11 +67,16 @@ it('debounces only relevant source paths into one ordered invalidation and close fake.emit('change', '/project with spaces/node_modules/dependency/index.js'); fake.emit('change', '/project with spaces/.agent-bundle/active-epoch.json'); fake.emit('change', '/project with spaces/dist/plugin.json'); + // The package build stages `dist/` in a `.dist.stage-XXXXXX` sibling before + // renaming it into place; that staging tree is output, not source. + fake.emit('addDir', '/project with spaces/.dist.stage-W3rHAr'); + fake.emit('add', '/project with spaces/.dist.stage-W3rHAr/bin/plugin.js'); + fake.emit('add', '/project with spaces/.distinct-source.ts'); await watcher.flush(); expect(invalidations).toEqual([{ occurredAt: '2026-08-14T12:00:00.000Z', - paths: ['src/first.ts', 'src/second.ts'], + paths: ['.distinct-source.ts', 'src/first.ts', 'src/second.ts'], reason: 'source-change', }]); diff --git a/packages/agent-bundle/tests/dev-web-host-routes.test.ts b/packages/agent-bundle/tests/dev-web-host-routes.test.ts new file mode 100644 index 000000000..9a917ef1f --- /dev/null +++ b/packages/agent-bundle/tests/dev-web-host-routes.test.ts @@ -0,0 +1,136 @@ +import { mkdir, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { startDevServer } from '../src/dev/workbench-server.ts'; +import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; +import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; + +const hostContext = { + availableDisplayModes: ['inline'], + containerDimensions: { height: 360, width: 640 }, + deviceCapabilities: {}, + displayMode: 'inline', + locale: 'en-US', + platform: 'web', + safeAreaInsets: { bottom: 0, left: 0, right: 0, top: 0 }, + styles: {}, + theme: 'light', + timeZone: 'UTC', + userAgent: 'agent-bundle-dev-web-host-test/1.0', +}; + +const seedString = (html: string, field: string): string => { + const match = html.match(new RegExp(`"${field}":"([^"]+)"`, 'u')); + if (match?.[1] === undefined) throw new Error(`Web host seed does not contain ${field}.`); + return match[1]; +}; + +it('serves an exposed App and registers its opening call for page binding', async () => { + const project = await createProjectFixture({ prefix: 'agent-bundle-dev-web-host-' }); + let server: Awaited> | undefined; + try { + await Promise.all([ + mkdir(join(project.root, 'src'), { recursive: true }), + mkdir(join(project.root, 'views'), { recursive: true }), + ]); + await symlink( + join(agentBundleNodeModules, '@modelcontextprotocol'), + join(project.root, 'node_modules', '@modelcontextprotocol'), + 'dir', + ); + await Promise.all([ + writeFile(project.configPath, [ + "import { defineConfig } from 'agent-bundle';", + '', + 'export default defineConfig({', + ' mcp: { servers: { status: {', + " apps: { status: { entry: './views/status.ts', resourceUri: 'ui://fixture/status.html', template: './views/status.html' } },", + " entry: './src/server.ts',", + ' } } },', + " plugin: { name: 'dev-web-host-fixture', version: '1.0.0' },", + " targets: ['portable'],", + " web: { apps: [{ app: 'status/status', input: { service: 'compiler' }, tool: 'show-status' }] },", + '});', + '', + ].join('\n')), + writeFile(join(project.root, 'src', 'server.ts'), [ + "import { McpServer } from '@modelcontextprotocol/server';", + '', + "const server = new McpServer({ name: 'dev-web-host-fixture', version: '1.0.0' });", + "server.registerResource('status', 'ui://fixture/status.html', { mimeType: 'text/html;profile=mcp-app' }, async (uri) => ({", + " contents: [{ mimeType: 'text/html;profile=mcp-app', text: '
Fixture status
', uri: uri.href }],", + '}));', + "server.registerTool('show-status', { _meta: { ui: { resourceUri: 'ui://fixture/status.html' } } }, async () => ({", + " content: [{ text: 'Fixture status ready.', type: 'text' }],", + " structuredContent: { service: 'compiler', status: 'healthy' },", + '}));', + 'export default () => server;', + '', + ].join('\n')), + writeFile(join(project.root, 'views', 'status.ts'), "document.body.dataset.ready = 'true';\n"), + writeFile(join(project.root, 'views', 'status.html'), '
Fixture status
\n'), + ]); + + server = await startDevServer({ open: false, port: 0, root: project.root }); + const pageResponse = await fetch(`${server.url}/web/status/status`); + const html = await pageResponse.text(); + expect(pageResponse.status).toBe(200); + expect(pageResponse.headers.get('cache-control')).toBe('no-store'); + expect(pageResponse.headers.get('content-security-policy')).toContain('http://127.0.0.1:'); + expect(pageResponse.headers.get('referrer-policy')).toBe('no-referrer'); + expect(pageResponse.headers.get('x-content-type-options')).toBe('nosniff'); + expect(html).toContain('"tokenHeader":"x-agent-bundle-session"'); + expect(html).toContain('"previewProfile":"portable"'); + + const sessionResponse = await fetch(`${server.url}/api/project/session`, { + headers: { 'sec-fetch-site': 'same-origin' }, + }); + const session = await sessionResponse.json() as { readonly token: string }; + expect(html).toContain(JSON.stringify(session.token)); + + const sessionId = seedString(html, 'sessionId'); + const toolName = seedString(html, 'toolName'); + const opening = seedString(html, 'opening'); + const headers = { + 'content-type': 'application/json', + origin: server.url, + 'x-agent-bundle-session': session.token, + }; + const url = server.url; + const bind = (body: Readonly>) => fetch(`${url}/api/mcp/sessions/${encodeURIComponent(sessionId)}/apps`, { + body: JSON.stringify({ host: hostContext, previewProfile: 'portable', toolName, ...body }), + headers, + method: 'POST', + }); + const previewResponse = await bind({ opening }); + const preview = await previewResponse.json() as { + readonly preview?: Readonly<{ readonly resource?: Readonly<{ readonly kind?: string }> }>; + }; + expect(previewResponse.status).toBe(200); + expect(preview.preview?.resource?.kind).toBe('resource'); + + // A second page load of the same App shares the session but gets its own + // opening id; a bind that names no opening, or another page's, is refused + // rather than handed a call the page never saw. + const secondHtml = await (await fetch(`${server.url}/web/status/status`)).text(); + const secondOpening = seedString(secondHtml, 'opening'); + expect(seedString(secondHtml, 'sessionId')).toBe(sessionId); + expect(secondOpening).not.toBe(opening); + expect((await bind({})).status).toBe(400); + expect((await bind({ opening: 'not-a-page' })).status).toBe(400); + expect((await bind({ opening: secondOpening })).status).toBe(200); + + const missingResponse = await fetch(`${server.url}/web/status/nope`); + const missing = await missingResponse.json() as { + readonly diagnostic: Readonly<{ readonly code: string; readonly message: string }>; + }; + expect(missingResponse.status).toBe(404); + expect(missing.diagnostic.code).toBe('AB8020'); + expect(missing.diagnostic.message).toContain('status/status'); + } finally { + await server?.close(); + await removeProjectFixture(project.root); + } +}); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index b452a6f7f..9acfcb39f 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -625,6 +625,33 @@ it('records both operator env files as absent for a pack that ships none, withou } }); +it('prints a web surface line when the bundle manifest exposes Apps', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'cursor'); + await writeJson(join(bundle, 'agent-bundle.manifest.json'), { + web: { apps: [{ app: 'status/status' }, { app: 'status/other' }] }, + }); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['cursor'], + }); + expect(report.web).toEqual({ + apps: 2, + line: 'web: 2 App(s) exposed — run doctor-fixture web', + plugin: 'doctor-fixture', + }); + const human = captureCliTerminal(); + const humanCode = await runCli(['doctor'], human.output, { runDoctor: async () => report }); + expect(humanCode).toBe(0); + expect(human.stdout()).toContain('web: 2 App(s) exposed — run doctor-fixture web'); + } finally { + await fixture.cleanup(); + } +}); + it('inventories durable state under a checked --from bundle', async () => { const fixture = await temporaryDoctor(); try { diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 26bde21fc..b62082c8f 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -240,6 +240,105 @@ describe('generated entry templates', () => { expect(npmBin).not.toContain('applyOperatorEnv'); }); + it('conditionally wires the generated web command without changing non-web entry bytes', () => { + const route = { + config: {}, + id: 'cli:status', + kind: 'cli' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/cli/status.ts' }, + source: '/project/src/cli/status.ts', + }; + const command = { + aliases: [], + exitCode: 'zero' as const, + options: [], + path: ['status'], + rendered: false, + routeId: route.id, + }; + const web = { + manifestRelativeUrl: '../agent-bundle.manifest.json', + pluginRootRelativeUrl: '../', + }; + const routed = entryShellModule.generatedCliBinEntrySource({ + commands: [command], + plugin: { name: 'fixture', version: '1.0.0' }, + routes: [route], + stateFallback: 'artifact', + web, + }); + expect(routed).toContain('import { runWebCommand } from "agent-bundle/web-host";'); + expect(routed).toContain("import webHostPage from 'agent-bundle/web-host-page';"); + expect(routed).toContain( + "const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL(\"../\", import.meta.url)) });", + ); + expect(routed).toContain('const artifactRoot = fileURLToPath(new URL("../", import.meta.url));'); + expect(routed).toContain([ + ' web: Object.freeze({', + ' run: (argv, context) => runWebCommand({', + ' argv,', + ' manifestPath: fileURLToPath(new URL("../agent-bundle.manifest.json", import.meta.url)),', + ' pageScript: webHostPage,', + ' pluginRoot: artifactRoot,', + ' ...context,', + ' }),', + ' }),', + ].join('\n')); + + const webOnly = entryShellModule.generatedCliBinEntrySource({ + commands: [], + plugin: { name: 'fixture', version: '1.0.0' }, + routes: [], + stateFallback: 'artifact', + web, + }); + expect(webOnly).toContain('const commands = Object.freeze([]);'); + expect(webOnly).toContain('web: Object.freeze({'); + expect(webOnly).not.toContain('import * as route0'); + // A web-only plugin owes no `@agent-bundle/runtime`: nothing opens a request scope. + expect(webOnly).not.toContain('@agent-bundle/runtime'); + expect(webOnly).not.toContain('resolvePluginRoot'); + expect(webOnly).toContain('const artifactRoot = fileURLToPath(new URL("../", import.meta.url));'); + expect(routed).toContain('@agent-bundle/runtime'); + + // A project's state and providers belong to its request scope; a bin with + // no command opens none, so the web-only bin mounts neither and their + // modules cannot keep ` web` from starting. + const webOnlyWithState = entryShellModule.generatedCliBinEntrySource({ + commands: [], + plugin: { name: 'fixture', version: '1.0.0' }, + providers: [{ + id: 'provider:project-auth', + name: 'project-auth', + provenance: { kind: 'conventional', relativePath: 'src/providers/project-auth.ts' }, + source: '/project/src/providers/project-auth.ts', + }], + routes: [], + state: { + id: 'project/tasks', + lifetime: 'workspace-durable', + provenance: { kind: 'conventional', sourcePath: '/project/src/state.ts' }, + source: '/project/src/state.ts', + }, + stateFallback: 'artifact', + web, + }); + expect(webOnlyWithState).toBe(webOnly); + + // A routed bin without `web` is byte-identical to the pre-#564 generator + // (hash of the same input on the parent commit's `entry-shell.ts`). + const withoutWeb = entryShellModule.generatedCliBinEntrySource({ + commands: [command], + plugin: { name: 'fixture', version: '1.0.0' }, + routes: [route], + stateFallback: 'artifact', + }); + expect(createHash('sha256').update(withoutWeb).digest('hex')) + .toBe('b4fea3c82a3f5b3ec5df4dcdb7496f5bbf030fb230ae1550dbd01b65936b2e9f'); + expect(withoutWeb).not.toContain('agent-bundle/web-host'); + expect(withoutWeb).not.toContain('web: Object.freeze({'); + }); + it('generates a process envelope that adopts numeric exit codes and hands main the terminal capability (#511)', () => { const source = generatedExecutableEntrySource({ entrySource: '/proj/src/cli.ts', exportName: 'main', hostSurface: 'cli' }); expect(source).toContain('import * as entry from "/proj/src/cli.ts"'); diff --git a/packages/agent-bundle/tests/manifest.test.ts b/packages/agent-bundle/tests/manifest.test.ts index 76648064a..700326827 100644 --- a/packages/agent-bundle/tests/manifest.test.ts +++ b/packages/agent-bundle/tests/manifest.test.ts @@ -292,6 +292,30 @@ it('round-trips the optional package identity axes distinctly', () => { }); }); +it('round-trips and deeply freezes the optional web section', () => { + const source: ArtifactManifest = { + ...validManifest(), + web: { + apps: [{ + allow: ['call-tool'], + app: 'catalog/details', + args: [], + entry: 'mcp/mcp-catalog-01234567.mjs', + env: { TOKEN: 'agent-bundle:path:plugin-data/token' }, + name: 'details', + resourceUri: 'ui://catalog/details', + server: 'catalog', + }], + open: 'never', + }, + }; + const manifest = parseArtifactManifest(serializeArtifactManifest(source)); + + expect(manifest.web).toEqual(source.web); + expect(Object.isFrozen(manifest.web)).toBe(true); + expect(Object.isFrozen(manifest.web?.apps[0])).toBe(true); +}); + it('accepts a project without package identity and rejects invalid identity values', () => { const withoutIdentity = assembleArtifactManifest(validManifest()); expect(withoutIdentity.manifest.project.packageName).toBeUndefined(); diff --git a/packages/agent-bundle/tests/mcp-app-routes.test.ts b/packages/agent-bundle/tests/mcp-app-routes.test.ts index fe0c2512a..ba8f2daf6 100644 --- a/packages/agent-bundle/tests/mcp-app-routes.test.ts +++ b/packages/agent-bundle/tests/mcp-app-routes.test.ts @@ -613,8 +613,13 @@ it('binds the host\'s own opening call when a create request omits input and res const large = { structuredContent: { rows: Array.from({ length: 4000 }, (_, index) => ({ index, text: 'x'.repeat(24) })) } }; expect(Buffer.byteLength(JSON.stringify(large))).toBeGreaterThan(64 * 1024); const service = new RecordingPreviewService(); - const started = await startRoutes(service, undefined, (sessionId, toolName) => - sessionId === 'session-a' && toolName === 'show-weather' ? { input: { city: 'Oslo' }, result: large } : undefined); + const openings: (string | undefined)[] = []; + const started = await startRoutes(service, undefined, (sessionId, toolName, opening) => { + openings.push(opening); + return sessionId === 'session-a' && toolName === 'show-weather' && opening !== 'stale-page' + ? { input: { city: 'Oslo' }, result: large } + : undefined; + }); try { const bound = await fetch(`${started.url}/api/mcp/sessions/session-a/apps`, { body: JSON.stringify({ host, previewProfile: 'portable', toolName: 'show-weather' }), @@ -626,11 +631,25 @@ it('binds the host\'s own opening call when a create request omits input and res kind: 'create', options: { host, input: { city: 'Oslo' }, previewProfile: 'portable', result: large, sessionId: 'session-a', toolName: 'show-weather' }, }]); + // A page's opaque opening id reaches the host verbatim, so a many-page + // host can hand each page its own call. + const named = await fetch(`${started.url}/api/mcp/sessions/session-a/apps`, { + body: JSON.stringify({ host, opening: 'page-7', previewProfile: 'portable', toolName: 'show-weather' }), + headers: { ...headers(), 'content-type': 'application/json' }, + method: 'POST', + }); + expect(named.status).toBe(200); + expect(openings).toEqual([undefined, 'page-7']); - // Another tool, or a session the host did not open, has no call to bind. + // Another tool, a session the host did not open, an opening the host no + // longer holds, a blank opening, or an opening sent alongside the + // Workbench's own call: none has a call to bind. for (const body of [ { host, previewProfile: 'portable', toolName: 'other-tool' }, { host, input: { city: 'Oslo' }, previewProfile: 'portable', toolName: 'show-weather' }, + { host, opening: 'stale-page', previewProfile: 'portable', toolName: 'show-weather' }, + { host, opening: '', previewProfile: 'portable', toolName: 'show-weather' }, + { host, input: { city: 'Oslo' }, opening: 'page-7', previewProfile: 'portable', result: { content: [] }, toolName: 'show-weather' }, ]) { const response = await fetch(`${started.url}/api/mcp/sessions/session-a/apps`, { body: JSON.stringify(body), @@ -646,7 +665,7 @@ it('binds the host\'s own opening call when a create request omits input and res method: 'POST', }); expect(otherSession.status).toBe(400); - expect(service.calls).toHaveLength(1); + expect(service.calls).toHaveLength(2); } finally { await started.close(); } diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index f6c12a24a..deeaa815c 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -12,7 +12,7 @@ import { build, runMcp } from '../src/api.ts'; import { runCli } from '../src/cli.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; -import { mcpServerStateDirectory } from '../src/services/mcp-run.ts'; +import { mcpServerStateDirectory } from '../src/core/mcp-state-directory.ts'; const execFile = promisify(executeFile); const workspaceNodeModules = join(process.cwd(), 'node_modules'); diff --git a/packages/agent-bundle/tests/packed-serve-app-command.test.ts b/packages/agent-bundle/tests/packed-serve-app-command.test.ts deleted file mode 100644 index 757ffec3f..000000000 --- a/packages/agent-bundle/tests/packed-serve-app-command.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { execFile as executeFile, spawn, type ChildProcess } from 'node:child_process'; -import { cp, mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { promisify } from 'node:util'; - -import { afterAll, beforeAll, expect, it } from '@rstest/core'; - -import { descendantProcessIds } from '../../workbench/tests/support/packed-release-harness.ts'; -import { eventuallyPasses, within } from './support/eventually.ts'; -import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; - -/** - * The `agent-bundle/serve-app-command` packed proof (#558): a plugin's routed - * CLI command serves the plugin's own MCP App by spawning the *installed* - * framework's `agent-bundle serve-app` through `spawnServeApp`, from inside - * a generated executable that the installed framework built. - * - * One tarball set (the run-level shared pack), one scratch consumer copied - * from `fixtures/serve-app-command`, one `agent-bundle build` with the - * installed CLI, then the generated bins run as separate operating-system - * processes: the package build's `dist/bin/.js` and the composite - * root's `bin/.mjs`. The proof covers the ready-line relay to stderr - * (stdout stays the JSON result), the served page, teardown of the - * `serve-app` child and its packed MCP server, the request `signal` reaching - * the child on Ctrl-C, and every `ServeAppCommandError` code a checkout can - * hit without a seam: `artifact-missing`, `framework-not-installed`, and - * `exited-before-ready`. - */ - -const execFile = promisify(executeFile); -const fixtureRoot = resolve(import.meta.dirname, '../fixtures/serve-app-command'); -const pluginName = 'serve-app-command-fixture'; -/** A live framework import surviving in a generated executable: `from "agent-bundle/..."` or `import("agent-bundle/...")`. */ -const agentBundleImport = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]agent-bundle(?:\/[^'"]*)?['"]/u; -/** The `agent-bundle serve-app` ready line as the route relays it to stderr (`serve-app/command-contract.ts`). */ -const readyLine = (url: string): string => `MCP App status/status at ${url} (tool status; Ctrl-C stops the server)`; -const readyLinePattern = /^MCP App status\/status at (?http:\/\/127\.0\.0\.1:\d+\/) \(tool status; Ctrl-C stops the server\)$/mu; -const teardownBudget = { attempts: 50, delayMs: 100 } as const; - -/** The fixture route's `resultSchema`. */ -interface DashboardResult { - readonly exitCode: number; - readonly message: string; - readonly pid: number | null; - readonly probeStatus: number | null; - readonly url: string | null; -} - -interface ProcessExit { - readonly code: number | null; - readonly signal: NodeJS.Signals | null; -} - -interface BinRun { - readonly child: ChildProcess; - readonly exit: Promise; - stderr(): string; - stdout(): string; -} - -let consumer = ''; -let project = ''; -let packageBin = ''; -let artifactBin = ''; -/** Every bin this file spawned and every descendant it observed, killed on teardown if still alive. */ -const spawned = new Set(); -const observedProcessIds = new Set(); - -/** - * Runs one generated bin as `node ` with both output streams - * piped — neither is a terminal, so the routed CLI emits its JSON result — - * in the NODE_PATH-free installed environment. - */ -const runBin = (bin: string, args: readonly string[], cwd: string): BinRun => { - const child = spawn(process.execPath, [bin, ...args], { cwd, env: installedEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); - spawned.add(child); - let stdout = ''; - let stderr = ''; - child.stdout?.setEncoding('utf8'); - child.stderr?.setEncoding('utf8'); - child.stdout?.on('data', (chunk: string) => { stdout += chunk; }); - child.stderr?.on('data', (chunk: string) => { stderr += chunk; }); - const exit = new Promise((settle, reject) => { - child.once('error', reject); - child.once('close', (code, signal) => { - spawned.delete(child); - settle({ code, signal }); - }); - }); - return { child, exit, stderr: () => stderr, stdout: () => stdout }; -}; - -/** The one JSON result document a plain routed command prints: exactly one line, nothing else on stdout. */ -const resultDocument = (stdout: string): DashboardResult => { - const lines = stdout.split('\n'); - expect(lines).toHaveLength(2); - expect(lines[1]).toBe(''); - return JSON.parse(lines[0]!) as DashboardResult; -}; - -/** Resolves with the served URL once the relayed ready line reaches the bin's stderr; rejects if the bin exits first. */ -const awaitReadyLine = (run: BinRun): Promise => within(new Promise((settle, reject) => { - const check = (): void => { - const url = readyLinePattern.exec(run.stderr())?.groups?.['url']; - if (url !== undefined) settle(url); - }; - run.child.stderr?.on('data', check); - void run.exit.then( - (exit) => reject(new Error(`The routed bin exited (${JSON.stringify(exit)}) before serving the App.\nstderr:\n${run.stderr()}`)), - reject, - ); - check(); -}), 60_000); - -/** Signal 0 probes for existence: `ESRCH` is the one outcome that means the process is gone. */ -const processGone = (processId: number): void => { - let outcome: unknown = 'alive'; - try { - process.kill(processId, 0); - } catch (error) { - outcome = (error as NodeJS.ErrnoException).code; - } - expect(outcome).toBe('ESRCH'); -}; - -const refused = async (url: string): Promise => { - try { - await fetch(url); - return false; - } catch { - return true; - } -}; - -/** Polls (≤5s) until the served host refuses connections and every listed process is gone. */ -const expectTornDown = async (url: string, processIds: readonly number[]): Promise => { - await eventuallyPasses(async () => { - for (const processId of processIds) processGone(processId); - expect(await refused(url)).toBe(true); - }, teardownBudget); -}; - -beforeAll(async () => { - const [agentBundle, runtime, markdownStream] = await Promise.all([ - sharedPackedTarball('agent-bundle'), - sharedPackedTarball('runtime'), - sharedPackedTarball('markdown-stream'), - ]); - consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-serve-app-command-')); - project = join(consumer, 'project'); - await cp(fixtureRoot, project, { recursive: true }); - // The generated routed-CLI bin resolves `@agent-bundle/runtime` (and its - // React peer) from the consumer, exactly like the packed stdio proof. - await execFile('npm', ['install', ...cachedNpmInstallArguments, - agentBundle.tarball, - runtime.tarball, - markdownStream.tarball, - 'react@19.2.8', - 'react-dom@19.2.8', - 'zod@4.4.3', - ], { cwd: project, env: installedEnvironment() }); - // The installed CLI builds both surfaces at once: the composite root the - // route serves from (`artifact/`) and the package build whose generated bin - // carries the route (`dist/bin/`). - const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); - await execFile(cli, ['build', '--root', project, '--output', join(project, 'artifact')], { - cwd: project, - env: installedEnvironment(), - }); - packageBin = join(project, 'dist', 'bin', `${pluginName}.js`); - artifactBin = join(project, 'artifact', 'bin', `${pluginName}.mjs`); -}, 300_000); - -afterAll(async () => { - for (const child of spawned) child.kill('SIGKILL'); - for (const processId of observedProcessIds) { - try { - process.kill(processId, 'SIGKILL'); - } catch { - // Already gone, which is what the tests asserted. - } - } - if (consumer.length > 0) await rm(consumer, { force: true, recursive: true }); -}); - -it('builds the routed command with the installed framework into self-contained package and artifact bins', { timeout: 60_000 }, async () => { - await expect(stat(join(project, 'artifact', 'agent-bundle.manifest.json'))).resolves.toMatchObject({}); - expect((await stat(packageBin)).mode & 0o111).not.toBe(0); - for (const bin of [packageBin, artifactBin]) { - const source = await readFile(bin, 'utf8'); - // The helper was inlined (a residual framework import would have failed - // the build as AB6005 anyway): no live `agent-bundle` import remains, and - // the ready-line contract it parses is part of the executable's bytes. - expect(source).not.toMatch(agentBundleImport); - expect(source).toContain('Ctrl-C stops the server'); - } -}); - -it('serves the App from the routed command, relays the ready line to stderr, and tears the server down (probe run)', { timeout: 120_000 }, async () => { - for (const bin of [packageBin, artifactBin]) { - // `root: process.cwd()` / `artifact: 'artifact'` in the route: the - // checkout root is the working directory, as for a real `pnpm exec`. - const run = runBin(bin, ['dashboard', '--probe', '--no-open'], project); - const exit = await within(run.exit, 90_000); - expect(exit, run.stderr()).toEqual({ code: 0, signal: null }); - const result = resultDocument(run.stdout()); - expect(result).toEqual({ - exitCode: 0, - message: 'dashboard closed', - pid: expect.any(Number), - probeStatus: 200, - url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/$/u), - }); - expect(Number.isInteger(result.pid) && result.pid! > 0).toBe(true); - observedProcessIds.add(result.pid!); - // The child's stdout (its ready line) was relayed to the bin's stderr — - // the operator's channel — so stdout stayed the one JSON document. - expect(run.stderr()).toContain(readyLine(result.url!)); - expect(run.stderr()).not.toContain('"exitCode"'); - // `close()` ended `agent-bundle serve-app` (the reported pid) and, with - // it, the host and the packed MCP server behind it. - await expectTornDown(result.url!, [result.pid!]); - } -}); - -it('stops the served App when the routed bin receives SIGINT: the request signal reaches the serve-app child (signal run)', { timeout: 120_000 }, async () => { - const run = runBin(packageBin, ['dashboard', '--no-open'], project); - const url = await awaitReadyLine(run); - expect((await fetch(url)).status).toBe(200); - // The `serve-app` CLI and, under it, the packed MCP server it launched. - const descendants = await descendantProcessIds(run.child.pid!); - expect(descendants.length).toBeGreaterThanOrEqual(1); - for (const processId of descendants) observedProcessIds.add(processId); - - run.child.kill('SIGINT'); - // The generated CLI shell (`cli-entry.ts`) maps SIGINT to exit 130: the - // signal aborts the route's request `AbortSignal`, `spawnServeApp` turns the - // abort into the child's SIGTERM, `agent-bundle serve-app` closes and exits - // 0, the route returns — and the shell, finding the request aborted, prints - // `Aborted.` on stderr instead of the result and exits with the signal's - // code. Nothing reaches stdout. - const exit = await within(run.exit, 30_000); - expect(exit, run.stderr()).toEqual({ code: 130, signal: null }); - expect(run.stdout()).toBe(''); - expect(run.stderr()).toContain(readyLine(url)); - expect(run.stderr()).toContain('Aborted.\n'); - await expectTornDown(url, descendants); -}); - -it('reports every ServeAppCommandError as the result document, with the route\'s exit code (failure paths)', { timeout: 120_000 }, async () => { - // `artifact-missing`: a working directory under the consumer still resolves - // `node_modules/agent-bundle` above it, but has no `artifact/`. - const unbuilt = join(project, 'unbuilt'); - await mkdir(unbuilt, { recursive: true }); - const missing = runBin(packageBin, ['dashboard', '--no-open'], unbuilt); - expect(await within(missing.exit, 60_000), missing.stderr()).toEqual({ code: 1, signal: null }); - expect(resultDocument(missing.stdout())).toEqual({ - exitCode: 1, - message: expect.stringMatching(/^artifact-missing: No built artifact at .*[\\/]unbuilt[\\/]artifact\. Run `agent-bundle build` first/u), - pid: null, - probeStatus: null, - url: null, - }); - - // `framework-not-installed`: the self-contained bin runs anywhere, but only - // a checkout (or a consumer that installed `agent-bundle`) can serve. - const noFramework = join(consumer, 'no-framework'); - await mkdir(noFramework, { recursive: true }); - const uninstalled = runBin(packageBin, ['dashboard', '--no-open'], noFramework); - expect(await within(uninstalled.exit, 60_000), uninstalled.stderr()).toEqual({ code: 1, signal: null }); - expect(resultDocument(uninstalled.stdout())).toEqual({ - exitCode: 1, - message: expect.stringMatching(/^framework-not-installed: agent-bundle is not installed for the project at .*[\\/]no-framework: no node_modules\/agent-bundle\/package\.json resolves from it\./u), - pid: null, - probeStatus: null, - url: null, - }); - - // `exited-before-ready`: the artifact exists but is empty, so the spawned - // `agent-bundle serve-app` fails (AB6000) before its ready line; its - // diagnostics arrive on the bin's inherited stderr, the result names the - // exit, and stdout is still exactly one document. - const broken = join(project, 'broken'); - await mkdir(join(broken, 'artifact'), { recursive: true }); - const unready = runBin(packageBin, ['dashboard', '--no-open'], broken); - expect(await within(unready.exit, 60_000), unready.stderr()).toEqual({ code: 1, signal: null }); - expect(resultDocument(unready.stdout())).toEqual({ - exitCode: 1, - message: 'exited-before-ready: agent-bundle serve-app exited with exit code 1 before serving status/status; its diagnostics are on stderr.', - pid: null, - probeStatus: null, - url: null, - }); - expect(unready.stderr()).toContain('"code":"AB6000"'); -}); diff --git a/packages/agent-bundle/tests/packed-web-command.test.ts b/packages/agent-bundle/tests/packed-web-command.test.ts new file mode 100644 index 000000000..ddd0c9cbc --- /dev/null +++ b/packages/agent-bundle/tests/packed-web-command.test.ts @@ -0,0 +1,260 @@ +import { execFile as executeFile, type ChildProcess } from 'node:child_process'; +import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterAll, beforeAll, expect, it } from '@rstest/core'; + +import { descendantProcessIds } from '../../workbench/tests/support/packed-release-harness.ts'; +import { formatServeAppReadyLine, parseServeAppReadyLine } from '../src/serve-app/command-contract.ts'; +import { removeProjectSource } from '../src/test/packed.ts'; +import { WEB_HOST_SEED_ELEMENT_ID, type WebHostPageSeed } from '../src/web-host/browser/seed.ts'; +import { WEB_HOST_TOKEN_HEADER } from '../src/web-host/page.ts'; +import { awaitStdoutLine, connectionRefused, isProcessGone, killAll, runBin, type BinRun } from './support/bin-process.ts'; +import { eventuallyPasses, within } from './support/eventually.ts'; +import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; +import { timeScale } from './support/time-scale.ts'; + +const execFile = promisify(executeFile); +const fixtureRoot = resolve(import.meta.dirname, '../fixtures/web-surface'); +const pluginName = 'web-surface-fixture'; +const app = 'status/status'; +const resourceUri = 'ui://web-surface-fixture/status.html'; +// Fixture configures no tool; the live server resolves the one declaring this App. +const tool = 'status'; +const agentBundleImport = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]agent-bundle(?:\/[^'"]*)?['"]/u; +// The web host is plain Node and must not carry the compiler's Effect runtime. +const effectImport = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]effect(?:\/[^'"]*)?['"]/u; +const seedElementPattern = new RegExp(`]*\\btype="application/json")(?=[^>]*\\bid="${WEB_HOST_SEED_ELEMENT_ID}")[^>]*>([\\s\\S]*?)`, 'u'); +const startupBudget = 60_000 * timeScale; +const exitBudget = 5_000 * timeScale; +const teardownBudget = { attempts: 50 * timeScale, delayMs: 100 } as const; + +interface WebReadyDocument { + readonly app: string; + readonly port: number; + readonly resourceUri: string; + readonly sandboxOrigin: string; + readonly server: string; + readonly tool: string; + readonly url: string; +} +const webReadyKeys: readonly (keyof WebReadyDocument)[] = ['app', 'port', 'resourceUri', 'sandboxOrigin', 'server', 'tool', 'url']; + +let consumer = ''; +let project = ''; +let artifact = ''; +let bin = ''; +const spawned = new Set(); +const observedProcessIds = new Set(); + +const spawnBin = (executable: string, args: readonly string[], cwd = project): BinRun => + runBin(executable, args, { cwd, env: installedEnvironment(), track: spawned }); + +const seedOf = (html: string): WebHostPageSeed => { + const match = seedElementPattern.exec(html); + if (match?.[1] === undefined) throw new Error(`The served page carries no #${WEB_HOST_SEED_ELEMENT_ID} element:\n${html}`); + return JSON.parse(match[1]) as WebHostPageSeed; +}; + +const expectTornDown = async (urls: readonly string[], processIds: readonly number[]): Promise => { + await eventuallyPasses(async () => { + for (const processId of processIds) expect(isProcessGone(processId), `process ${String(processId)} is still alive`).toBe(true); + for (const url of urls) expect(await connectionRefused(url), `${url} still accepts connections`).toBe(true); + }, teardownBudget); +}; + +const observeDescendants = async (run: BinRun): Promise => { + const descendants = await descendantProcessIds(run.child.pid!); + for (const processId of descendants) observedProcessIds.add(processId); + return descendants; +}; + +beforeAll(async () => { + const [agentBundle, runtime, markdownStream] = await Promise.all([ + sharedPackedTarball('agent-bundle'), + sharedPackedTarball('runtime'), + sharedPackedTarball('markdown-stream'), + ]); + consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-web-command-')); + project = join(consumer, 'project'); + artifact = join(project, 'artifact'); + await cp(fixtureRoot, project, { recursive: true }); + // The generated routed-CLI bin resolves `@agent-bundle/runtime` (and its + // React peer) from the consumer, exactly like the packed stdio proof. + await execFile('npm', ['install', ...cachedNpmInstallArguments, + agentBundle.tarball, + runtime.tarball, + markdownStream.tarball, + 'react@19.2.8', + 'react-dom@19.2.8', + 'zod@4.4.3', + ], { cwd: project, env: installedEnvironment() }); + const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); + await execFile(cli, ['build', '--root', project, '--output', artifact], { + cwd: project, + env: installedEnvironment(), + }); + bin = join(artifact, 'bin', `${pluginName}.mjs`); + // `packed-deleted-source`: the bin serves out of the artifact alone, so the + // config, the routes, the server, and the App view are removed and verified + // absent before any process runs. + const receipt = await removeProjectSource({ extraPaths: ['views'], projectRoot: project }); + expect(receipt.removed).toEqual(['agent-bundle.config.ts', 'src', 'views']); +}, 300_000); + +afterAll(async () => { + for (const child of spawned) child.kill('SIGKILL'); + killAll(observedProcessIds); + if (consumer.length > 0) await rm(consumer, { force: true, recursive: true }); +}); + +it('builds the exposed App into the composite root: a manifest web section and one self-contained bin carrying the host', { timeout: 60_000 }, async () => { + const manifest = JSON.parse(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')) as { readonly web?: unknown }; + const mcpEntries = (await readdir(join(artifact, 'mcp'))).filter((name) => name.endsWith('.mjs')).sort(); + expect(mcpEntries).toHaveLength(1); + // `WebManifest` (web-host/manifest.ts): `open` defaults to `never`; the + // fixture configures no `tool` and no `input`, so neither key is written; + // `env` is the server's static env (none) and `entry` the artifact-relative + // compiled MCP executable the host launches. + expect(manifest.web).toEqual({ + apps: [{ + allow: ['call-tool'], + app, + args: [], + entry: `mcp/${mcpEntries[0]!}`, + env: {}, + name: 'status', + resourceUri, + server: 'status', + }], + open: 'never', + }); + + await expect(stat(bin)).resolves.toMatchObject({}); + const source = await readFile(bin, 'utf8'); + // Self-contained (AB6005): no live framework import and no Effect import + // survive; the ready-line contract and the host page (whose seed element + // the inlined page script reads) are part of the executable's bytes. + expect(source).not.toMatch(agentBundleImport); + expect(source).not.toMatch(effectImport); + expect(source).toContain('Ctrl-C stops the server'); + expect(source).toContain(WEB_HOST_SEED_ELEMENT_ID); +}); + +it('serves the App from `web --json --no-open` as a real process out of the deleted-source consumer, gates its routes by token, and tears down on SIGINT', { timeout: 120_000 }, async () => { + const run = spawnBin(bin, ['web', '--no-open', '--json']); + const line = await awaitStdoutLine(run, (candidate) => candidate.startsWith('{'), startupBudget); + const ready = JSON.parse(line) as WebReadyDocument; + expect(Object.keys(ready)).toEqual(webReadyKeys); + expect(JSON.stringify(ready)).toBe(line); + expect(ready).toEqual({ + app, + port: expect.any(Number), + resourceUri, + sandboxOrigin: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/u), + server: 'status', + tool, + url: `http://127.0.0.1:${String(ready.port)}/`, + }); + expect(ready.sandboxOrigin).not.toBe(new URL(ready.url).origin); + expect(run.stdout()).toBe(`${line}\n`); + + const page = await fetch(ready.url); + expect(page.status).toBe(200); + expect(page.headers.get('content-type')).toMatch(/^text\/html/u); + const policy = page.headers.get('content-security-policy') ?? ''; + expect(policy).toContain("frame-ancestors 'none'"); + expect(policy).toContain(`frame-src ${ready.sandboxOrigin}`); + const html = await page.text(); + expect(html).toMatch(seedElementPattern); + const seed = seedOf(html); + expect(seed).toMatchObject({ + autoApprove: ['call-tool'], + input: {}, + previewProfile: 'portable', + result: { structuredContent: { status: 'healthy' } }, + tokenHeader: WEB_HOST_TOKEN_HEADER, + toolName: tool, + }); + expect(seed.token.length).toBeGreaterThan(0); + expect(seed.sessionId.length).toBeGreaterThan(0); + + const anonymous = await fetch(new URL(`/api/mcp/sessions/${encodeURIComponent(seed.sessionId)}/apps`, ready.url), { + body: '{}', + headers: { 'content-type': 'application/json', origin: new URL(ready.url).origin }, + method: 'POST', + }); + expect(anonymous.status).toBe(403); + + const descendants = await observeDescendants(run); + expect(descendants.length).toBeGreaterThanOrEqual(1); + run.child.kill('SIGINT'); + const exit = await within(run.exit, exitBudget); + expect(exit, run.stderr()).toEqual({ code: 130, signal: null }); + expect(run.stdout()).toBe(`${line}\n`); + await expectTornDown([ready.url, ready.sandboxOrigin], descendants); +}); + +it('prints the shared ready line without --json and exits 143 on SIGTERM', { timeout: 120_000 }, async () => { + const run = spawnBin(bin, ['web', '--no-open']); + const line = await awaitStdoutLine(run, (candidate) => parseServeAppReadyLine(candidate) !== undefined, startupBudget); + // The same line `agent-bundle serve-app` prints (serve-app/command-contract.ts). + const ready = parseServeAppReadyLine(line); + expect(ready).toEqual({ app, tool, url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/$/u) }); + expect(line).toBe(formatServeAppReadyLine(ready!)); + expect((await fetch(ready!.url)).status).toBe(200); + + const descendants = await observeDescendants(run); + expect(descendants.length).toBeGreaterThanOrEqual(1); + run.child.kill('SIGTERM'); + const exit = await within(run.exit, exitBudget); + expect(exit, run.stderr()).toEqual({ code: 143, signal: null }); + expect(run.stdout()).toBe(`${line}\n`); + await expectTornDown([ready!.url], descendants); +}); + +it('shares one bin with the authored command: --help lists both, `web --help` answers, and the routed command still runs', { timeout: 60_000 }, async () => { + const help = spawnBin(bin, ['--help']); + expect(await within(help.exit, 30_000 * timeScale), help.stderr()).toEqual({ code: 0, signal: null }); + expect(help.stdout()).toContain(`${pluginName} 1.0.0`); + expect(help.stdout()).toMatch(/^Commands:$/mu); + expect(help.stdout()).toMatch(/^\s+dashboard\b/mu); + expect(help.stdout()).toMatch(/^\s+web\b/mu); + + const webHelp = spawnBin(bin, ['web', '--help']); + expect(await within(webHelp.exit, 30_000 * timeScale), webHelp.stderr()).toEqual({ code: 0, signal: null }); + expect(webHelp.stdout()).toContain('web'); + expect(webHelp.stdout()).toContain('--port'); + expect(webHelp.stdout()).toContain('--json'); + + const dashboard = spawnBin(bin, ['dashboard', '--json']); + expect(await within(dashboard.exit, 30_000 * timeScale), dashboard.stderr()).toEqual({ code: 0, signal: null }); + expect(dashboard.stdout()).toBe('{"ok":true}\n'); +}); + +it('fails closed: exit 1 without a manifest beside the bin, exit 2 for an App web.apps does not expose or an --allow outside the vocabulary', { timeout: 120_000 }, async () => { + // The bin alone, copied out of its artifact: no `agent-bundle.manifest.json` + // resolves from it (nor from the working directory), and the message + // names the file it looked for. + const strayBinDirectory = join(consumer, 'elsewhere', 'bin'); + await mkdir(strayBinDirectory, { recursive: true }); + const strayBin = join(strayBinDirectory, `${pluginName}.mjs`); + await cp(bin, strayBin); + const missing = spawnBin(strayBin, ['web', '--no-open', '--json'], consumer); + expect(await within(missing.exit, 30_000 * timeScale), missing.stderr()).toEqual({ code: 1, signal: null }); + expect(missing.stdout()).toBe(''); + expect(missing.stderr()).toContain('agent-bundle.manifest.json'); + + const unknownApp = spawnBin(bin, ['web', 'nope/nope', '--no-open']); + expect(await within(unknownApp.exit, 30_000 * timeScale), unknownApp.stderr()).toEqual({ code: 2, signal: null }); + expect(unknownApp.stdout()).toBe(''); + expect(unknownApp.stderr()).toContain('nope/nope'); + expect(unknownApp.stderr()).toContain(app); + + const unknownAllow = spawnBin(bin, ['web', '--allow', 'camera', '--no-open']); + expect(await within(unknownAllow.exit, 30_000 * timeScale), unknownAllow.stderr()).toEqual({ code: 2, signal: null }); + expect(unknownAllow.stdout()).toBe(''); + expect(unknownAllow.stderr()).toContain('camera'); +}); diff --git a/packages/agent-bundle/tests/route-framework-imports.test.ts b/packages/agent-bundle/tests/route-framework-imports.test.ts index d5779b441..6cdd6a248 100644 --- a/packages/agent-bundle/tests/route-framework-imports.test.ts +++ b/packages/agent-bundle/tests/route-framework-imports.test.ts @@ -53,13 +53,13 @@ describe('compilerCarryingSpecifiers', () => { "import 'agent-bundle/routes';", "import { appResourceUri } from 'agent-bundle/routes';", "import { launchEnv } from 'agent-bundle/launch-env';", - "import { spawnServeApp } from 'agent-bundle/serve-app-command';", + "import { runWebCommand } from 'agent-bundle/web-host';", "import { deeper } from 'agent-bundle/api/deeper';", "import { z } from 'zod';", "import { local } from './local';", "export * from 'agent-bundle/meta';", "const later = await import('agent-bundle/mcp-entry');", - 'export default async () => [createAppClient, appResourceUri, launchEnv, spawnServeApp, deeper, z, local, later];', + 'export default async () => [createAppClient, appResourceUri, launchEnv, runWebCommand, deeper, z, local, later];', )); expect(clean).toEqual([]); expect(Object.isFrozen(clean)).toBe(true); @@ -343,7 +343,7 @@ describe('relative import graph', () => { describe('validateRouteFrameworkImports', () => { const recovery = - 'Keep framework calls in a host process: serve an MCP App from a routed command with spawnServeApp from agent-bundle/serve-app-command, which spawns agent-bundle serve-app; use import type for framework types; otherwise move the call into a package.json script or a hand-written .mjs run from the checkout.'; + 'Keep framework calls in a host process: expose the App with web.apps and open it with web; use import type for framework types; otherwise move the call into a package.json script or a hand-written .mjs run from the checkout.'; it('reports one AB4837 naming the route, the specifier, and the executable', () => { const diagnostics = validateRouteFrameworkImports( diff --git a/packages/agent-bundle/tests/serve-app-command-spawn.test.ts b/packages/agent-bundle/tests/serve-app-command-spawn.test.ts deleted file mode 100644 index dd956d6d2..000000000 --- a/packages/agent-bundle/tests/serve-app-command-spawn.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { spawn as spawnChildProcess, type ChildProcess } from 'node:child_process'; -import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; - -import { afterEach, expect, it } from '@rstest/core'; - -import { isErrno } from '../src/core/errors.ts'; -import { formatServeAppReadyLine } from '../src/serve-app/command-contract.ts'; -import { - ServeAppCommandError, - serveAppArgv, - spawnServeApp, - type SpawnServeAppOptions, -} from '../src/serve-app-command.ts'; -import { eventuallyPasses } from './support/eventually.ts'; -import { timeScale } from './support/time-scale.ts'; - -/** - * `spawnServeApp` against fake `agent-bundle` CLIs: one `.mjs` per scenario, - * written under a temporary directory and run through the injected `cli`, so - * the ready-line, relay, abort, and exit contracts are exercised with real - * child processes and no build. The last test runs the checkout's real CLI - * to confirm its fast failure is classified the same way. - */ - -const app = 'hauler/dashboard'; -const ready = { app, tool: 'hauler_status', url: 'http://127.0.0.1:4941/' }; -const readyLine = formatServeAppReadyLine(ready); -const realCli = join(import.meta.dirname, '..', 'bin', 'agent-bundle.js'); - -/** The single-signature shape the module calls `spawn` with; `typeof spawn` itself is overloaded. */ -type SpawnLike = (...args: Parameters) => ChildProcess; -const asSpawn = (fake: SpawnLike): typeof spawnChildProcess => fake as typeof spawnChildProcess; - -const temporaryDirectories: string[] = []; -const spawnedPids: number[] = []; - -const temporaryDirectory = async (): Promise => { - const directory = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-serve-app-spawn-'))); - temporaryDirectories.push(directory); - return directory; -}; - -/** `kill -0`: true while the process exists (not yet reaped). */ -const isAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if (isErrno(error, 'ESRCH')) return false; - throw error; - } -}; - -/** The real `spawn`, recording every child so a failing test cannot leak one. */ -const trackingSpawn = asSpawn((...args) => { - const child = spawnChildProcess(...args); - if (child.pid !== undefined) spawnedPids.push(child.pid); - return child; -}); - -const writeFakeCli = async (directory: string, name: string, lines: readonly string[]): Promise => { - const path = join(directory, `${name}.mjs`); - await writeFile(path, `${lines.join('\n')}\n`); - return path; -}; - -/** - * A fake `agent-bundle` that records its argv to `argvFile`, prints a noise - * line and then the ready line, stays up, and exits 0 on SIGTERM — the - * handler is installed before the ready line so a `close()` that follows it - * is never racing the handler. - */ -const servingCli = async (directory: string): Promise<{ readonly argvFile: string; readonly cli: string }> => { - const argvFile = join(directory, 'argv.json'); - const cli = await writeFakeCli(directory, 'serving', [ - "import { writeFileSync } from 'node:fs';", - `writeFileSync(${JSON.stringify(argvFile)}, JSON.stringify(process.argv.slice(2)));`, - "process.on('SIGTERM', () => { process.exit(0); });", - "process.stdout.write('Building…\\n');", - `process.stdout.write(${JSON.stringify(`${readyLine}\n`)});`, - 'setInterval(() => undefined, 60_000);', - ]); - return { argvFile, cli }; -}; - -/** - * A fake `agent-bundle` that prints one noise line and never the ready line, - * exiting 3 on SIGTERM — the handler is installed before the line, so a - * SIGTERM that follows the line is never racing it. - */ -const neverReadyCli = (directory: string): Promise => writeFakeCli(directory, 'never-ready', [ - "process.on('SIGTERM', () => { process.exit(3); });", - "process.stdout.write('Building…\\n');", - 'setInterval(() => undefined, 60_000);', -]); - -const rejection = async (pending: Promise): Promise => { - try { - await pending; - } catch (error) { - expect(error).toBeInstanceOf(ServeAppCommandError); - return error as ServeAppCommandError; - } - throw new Error('Expected spawnServeApp to reject.'); -}; - -const relayInto = (lines: string[]): SpawnServeAppOptions['relay'] => (line) => { lines.push(line); }; - -/** Bounded polling (about two seconds, scaled for shared machines) for a process-level fact. */ -const polling = { attempts: 400 * timeScale, delayMs: 5 } as const; - -const untilGone = (pid: number): Promise => - eventuallyPasses(() => { expect(isAlive(pid)).toBe(false); }, polling); - -afterEach(async () => { - for (const pid of spawnedPids.splice(0)) { - if (!isAlive(pid)) continue; - try { - process.kill(pid, 'SIGKILL'); - } catch (error) { - if (!isErrno(error, 'ESRCH')) throw error; - } - } - await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))); -}); - -it('serves through the CLI: relays every stdout line, resolves on the ready line, and closes on SIGTERM', async () => { - const root = await temporaryDirectory(); - const { argvFile, cli } = await servingCli(root); - const lines: string[] = []; - const options: SpawnServeAppOptions = { - app, - cli, - port: 4941, - relay: relayInto(lines), - root, - spawn: trackingSpawn, - tool: 'hauler_status', - }; - const served = await spawnServeApp(options); - expect(served).toMatchObject({ ...ready, port: 4941, server: 'hauler' }); - expect(served.pid).toBeGreaterThan(0); - expect(spawnedPids).toEqual([served.pid]); - expect(isAlive(served.pid)).toBe(true); - expect(lines).toEqual(['Building…', readyLine]); - expect(JSON.parse(await readFile(argvFile, 'utf8'))).toEqual(serveAppArgv(options)); - - await expect(served.close()).resolves.toEqual({ code: 0, signal: null }); - await expect(served.closed).resolves.toEqual({ code: 0, signal: null }); - expect(isAlive(served.pid)).toBe(false); - expect(() => process.kill(served.pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' })); - await expect(served.close()).resolves.toEqual({ code: 0, signal: null }); -}); - -it('rejects exited-before-ready with the exit when the CLI ends without printing its ready line', async () => { - const root = await temporaryDirectory(); - const cli = await writeFakeCli(root, 'failing', [ - "process.stderr.write('AB6000 fake diagnostic: no artifact manifest (expected in this test)\\n');", - 'process.exitCode = 1;', - ]); - const lines: string[] = []; - const failure = await rejection(spawnServeApp({ app, cli, relay: relayInto(lines), root, spawn: trackingSpawn })); - expect(failure.name).toBe('ServeAppCommandError'); - expect(failure.code).toBe('exited-before-ready'); - expect(failure.exit).toEqual({ code: 1, signal: null }); - expect(failure.message).toContain(app); - expect(failure.message).toContain('exit code 1'); - expect(lines).toEqual([]); -}); - -it('rejects aborted and ends the child when the signal aborts before the ready line', async () => { - const root = await temporaryDirectory(); - const cli = await writeFakeCli(root, 'silent', ['setInterval(() => undefined, 60_000);']); - const controller = new AbortController(); - const lines: string[] = []; - const pending = spawnServeApp({ app, cli, relay: relayInto(lines), root, signal: controller.signal, spawn: trackingSpawn }); - await eventuallyPasses(() => { expect(spawnedPids).toHaveLength(1); }, polling); - controller.abort(); - const failure = await rejection(pending); - expect(failure.code).toBe('aborted'); - expect(failure.exit).toBeUndefined(); - expect(failure.message).toContain(app); - await untilGone(spawnedPids[0]!); - expect(lines).toEqual([]); -}); - -it('tears the served App down when the signal aborts after the ready line', async () => { - const root = await temporaryDirectory(); - const { cli } = await servingCli(root); - const controller = new AbortController(); - const served = await spawnServeApp({ app, cli, relay: relayInto([]), root, signal: controller.signal, spawn: trackingSpawn }); - expect(isAlive(served.pid)).toBe(true); - controller.abort(); - await expect(served.closed).resolves.toEqual({ code: 0, signal: null }); - expect(isAlive(served.pid)).toBe(false); -}); - -it('rejects aborted without spawning when the signal is already aborted', async () => { - const root = await temporaryDirectory(); - const { cli } = await servingCli(root); - const failure = await rejection(spawnServeApp({ app, cli, relay: relayInto([]), root, signal: AbortSignal.abort(), spawn: trackingSpawn })); - expect(failure.code).toBe('aborted'); - expect(failure.message).toContain(app); - expect(spawnedPids).toEqual([]); -}); - -// The abort event has already been dispatched by the time the child exists -// and the listener is registered, so this relies on the post-spawn -// `signal.aborted` re-check: without it the App would be served and stay up -// under an aborted signal. -it('rejects aborted and ends the child when the signal aborts during the artifact check, before the spawn', async () => { - const root = await temporaryDirectory(); - const { cli } = await servingCli(root); - const artifact = await temporaryDirectory(); - const controller = new AbortController(); - const lines: string[] = []; - const pending = spawnServeApp({ app, artifact, cli, relay: relayInto(lines), root, signal: controller.signal, spawn: trackingSpawn }); - expect(spawnedPids).toEqual([]); - controller.abort(); - const failure = await rejection(pending); - expect(failure.code).toBe('aborted'); - expect(spawnedPids).toHaveLength(1); - await untilGone(spawnedPids[0]!); - expect(lines).toEqual([]); -}); - -it('rejects framework-not-installed when no agent-bundle resolves from root', async () => { - const root = await temporaryDirectory(); - const failure = await rejection(spawnServeApp({ app, relay: relayInto([]), root, spawn: trackingSpawn })); - expect(failure.code).toBe('framework-not-installed'); - expect(failure.exit).toBeUndefined(); - expect(failure.message).toContain(root); - expect(failure.message).toContain('node_modules/agent-bundle'); - expect(spawnedPids).toEqual([]); -}); - -it('rejects artifact-missing before spawning when the artifact path does not exist', async () => { - const root = await temporaryDirectory(); - const { cli } = await servingCli(root); - const artifact = join(root, 'missing'); - const failure = await rejection(spawnServeApp({ app, artifact, cli, relay: relayInto([]), root, spawn: trackingSpawn })); - expect(failure.code).toBe('artifact-missing'); - expect(failure.message).toContain(resolve(artifact)); - expect(failure.message).toContain('agent-bundle build'); - expect(spawnedPids).toEqual([]); -}); - -it('rejects spawn-failed with the cause whether spawn throws or the child reports the failure', async () => { - const root = await temporaryDirectory(); - const { cli } = await servingCli(root); - const boom = new Error('boom'); - const thrown = await rejection(spawnServeApp({ app, cli, relay: relayInto([]), root, spawn: asSpawn(() => { throw boom; }) })); - expect(thrown.code).toBe('spawn-failed'); - expect(thrown.cause).toBe(boom); - expect(thrown.message).toContain(cli); - - const missingBinary = asSpawn((_command, args, options) => spawnChildProcess('/nonexistent/binary', args, options)); - const reported = await rejection(spawnServeApp({ app, cli, relay: relayInto([]), root, spawn: missingBinary })); - expect(reported.code).toBe('spawn-failed'); - expect(reported.cause).toMatchObject({ code: 'ENOENT' }); - expect(reported.message).toContain(cli); -}); - -it('keeps closed pending through a post-spawn error until the child really exits', async () => { - // Node reports a failed `kill()` on a running child as `error`; unlike a - // spawn failure the process is still alive, so the exit must not be - // fabricated: `closed` settles with the real exit, `close()` still works. - const root = await temporaryDirectory(); - const { cli } = await servingCli(root); - let child: ChildProcess | undefined; - const capturing = asSpawn((...args) => { - child = trackingSpawn(...args); - return child; - }); - const served = await spawnServeApp({ app, cli, relay: relayInto([]), root, spawn: capturing }); - child!.emit('error', new Error('kill EPERM')); - let settled = false; - void served.closed.then(() => { settled = true; }); - await new Promise((resolve) => { setTimeout(resolve, 50); }); - expect(settled).toBe(false); - expect(isAlive(served.pid)).toBe(true); - await expect(served.close()).resolves.toEqual({ code: 0, signal: null }); - await untilGone(served.pid); -}); - -it('rejects stop-failed from close() and from a pre-ready abort when the running child refuses the signal', async () => { - // Node's `kill()` reports EPERM on a running child as a synchronous `error` - // event and returns false; the fake reproduces exactly that, then lets the - // real `kill` through so the test can tear the child down. - const root = await temporaryDirectory(); - const { cli } = await servingCli(root); - const refuse = (child: ChildProcess): (() => void) => { - const realKill = child.kill.bind(child); - child.kill = () => { - child.emit('error', Object.assign(new Error('kill EPERM'), { code: 'EPERM', syscall: 'kill' })); - return false; - }; - return () => { child.kill = realKill; }; - }; - let restore: (() => void) | undefined; - const refusing = asSpawn((...args) => { - const child = trackingSpawn(...args); - restore = refuse(child); - return child; - }); - - const served = await spawnServeApp({ app, cli, relay: relayInto([]), root, spawn: refusing }); - const failure = await rejection(served.close()); - expect(failure.code).toBe('stop-failed'); - expect(failure.message).toContain(`pid ${String(served.pid)}`); - expect(failure.cause).toMatchObject({ code: 'EPERM', syscall: 'kill' }); - expect(isAlive(served.pid)).toBe(true); - restore!(); - await expect(served.close()).resolves.toEqual({ code: 0, signal: null }); - await untilGone(served.pid); - - const controller = new AbortController(); - const lines: string[] = []; - const pending = spawnServeApp({ - app, cli: await neverReadyCli(root), relay: relayInto(lines), root, signal: controller.signal, spawn: refusing, - }); - await eventuallyPasses(() => { expect(lines).toEqual(['Building…']); }, polling); - controller.abort(); - const aborted = await rejection(pending); - expect(aborted.code).toBe('stop-failed'); - expect(aborted.cause).toMatchObject({ code: 'EPERM' }); - const [pid] = spawnedPids.slice(-1); - expect(isAlive(pid!)).toBe(true); - restore!(); - process.kill(pid!, 'SIGTERM'); - await untilGone(pid!); - - // An abort during the artifact check is re-checked right after the spawn, - // before the child's `spawn` event: the refused `kill()` emits `error` - // synchronously, so the handler must already be attached and must read it - // as the stop failure rather than as a spawn failure or an unhandled event. - const early = new AbortController(); - const artifact = await temporaryDirectory(); - const earlyPending = spawnServeApp({ - app, artifact, cli: await neverReadyCli(root), relay: relayInto([]), root, signal: early.signal, spawn: refusing, - }); - early.abort(); - const earlyFailure = await rejection(earlyPending); - expect(earlyFailure.code).toBe('stop-failed'); - expect(earlyFailure.cause).toMatchObject({ code: 'EPERM' }); - const [earlyPid] = spawnedPids.slice(-1); - expect(earlyPid).not.toBe(pid); - expect(isAlive(earlyPid!)).toBe(true); - restore!(); - process.kill(earlyPid!, 'SIGTERM'); - await untilGone(earlyPid!); -}); - -it('carries a post-spawn error as the cause when the child then exits before its ready line', async () => { - const root = await temporaryDirectory(); - const cli = await neverReadyCli(root); - let child: ChildProcess | undefined; - const capturing = asSpawn((...args) => { - child = trackingSpawn(...args); - return child; - }); - const lines: string[] = []; - const pending = spawnServeApp({ app, cli, relay: relayInto(lines), root, spawn: capturing }); - await eventuallyPasses(() => { expect(lines).toEqual(['Building…']); }, polling); - const late = new Error('kill EPERM'); - child!.emit('error', late); - child!.kill('SIGTERM'); - const failure = await rejection(pending); - expect(failure.code).toBe('exited-before-ready'); - expect(failure.exit).toEqual({ code: 3, signal: null }); - expect(failure.cause).toBe(late); -}); - -it('classifies the real CLI failing fast on a missing artifact manifest as exited-before-ready', async () => { - const root = await temporaryDirectory(); - await writeFile(join(root, 'package.json'), '{"type":"module"}\n'); - const artifact = await temporaryDirectory(); - const lines: string[] = []; - const failure = await rejection(spawnServeApp({ - app: 'nope/nope', - artifact, - cli: realCli, - relay: relayInto(lines), - root, - spawn: trackingSpawn, - })); - expect(failure.code).toBe('exited-before-ready'); - expect(failure.exit).toEqual({ code: 1, signal: null }); - expect(failure.message).toContain('nope/nope'); - expect(lines).toEqual([]); -}, 30_000); diff --git a/packages/agent-bundle/tests/serve-app-command.test.ts b/packages/agent-bundle/tests/serve-app-command.test.ts deleted file mode 100644 index c15215187..000000000 --- a/packages/agent-bundle/tests/serve-app-command.test.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; - -import { afterEach, describe, expect, it } from '@rstest/core'; - -import type { McpAppConsentCapability, ServeAppOptions, ServedApp } from '../src/api.ts'; -import { runCli } from '../src/cli.ts'; -import { formatServeAppReadyLine } from '../src/serve-app/command-contract.ts'; -import { - locateFrameworkCli, - parseServeAppReadyLine, - serveAppAllowCapabilities, - serveAppArgv, - type ServeAppAllowCapability, - type ServeAppArgvOptions, -} from '../src/serve-app-command.ts'; -import { captureCliTerminal } from './support/cli-terminal.ts'; -import { deferred } from './support/eventually.ts'; - -/** `true` only when `A` and `B` are the same type; the usual conditional-type identity check, since `@rstest/core` ships no `expectTypeOf`. */ -type Equals = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; - -/** - * The `serveApp` options that stay with host processes calling `serveApp` - * directly: in-process injections (`logger`, `registry`, `openBrowser`) and - * the two keys the CLI does not expose (`targets`, `timeoutMs`). Everything - * else must have an argv form — the #558 acceptance criterion. - */ -type HostOnlyKey = 'logger' | 'openBrowser' | 'registry' | 'targets' | 'timeoutMs'; -type ArgvKey = Exclude; - -/** - * Every key of `ServeAppOptions` classified. A new `ServeAppOptions` key - * fails to compile here until it is either lowered by `serveAppArgv` (and - * listed in `argvEvidence` below) or added to `HostOnlyKey` with a reason. - */ -const classification: { readonly [K in keyof ServeAppOptions]-?: K extends HostOnlyKey ? 'host-only' : 'argv' } = { - app: 'argv', - artifact: 'argv', - autoApprove: 'argv', - configPath: 'argv', - envFiles: 'argv', - input: 'argv', - loadEnvFiles: 'argv', - logger: 'host-only', - mode: 'argv', - open: 'argv', - openBrowser: 'host-only', - pluginRoot: 'argv', - port: 'argv', - profile: 'argv', - registry: 'host-only', - root: 'argv', - target: 'argv', - targets: 'host-only', - timeoutMs: 'host-only', - tool: 'argv', -}; - -// (a) The argv keys are exactly the non-host-only serveApp keys, in both directions. -const argvKeysAreTheRest: Equals = true; -// (b) Per argv key, the helper accepts nothing serveApp would reject. -const argvValuesAssignable: Equals<{ [K in ArgvKey]: ServeAppArgvOptions[K] extends ServeAppOptions[K] ? true : false }[ArgvKey], true> = true; -const argvOptionsAreServeAppOptions: Equals = true; -// autoApprove is the only key whose type differs: it is narrowed to the CLI's `--allow` vocabulary. -const onlyAutoApproveDiffers: Equals< - { [K in ArgvKey]: Equals extends true ? never : K }[ArgvKey], - 'autoApprove' -> = true; -const autoApproveIsTheAllowVocabulary: Equals = true; -const allowVocabulary: Equals = true; -const allowIsASubsetOfConsent: Equals, never> = true; -const browserPermissionsStayInteractive: Equals< - Exclude, - 'camera' | 'clipboard-write' | 'geolocation' | 'microphone' -> = true; - -/** Every argv key set to a non-default value; `Required` makes a missing key a compile error. */ -const sample: Required = { - app: 'hauler/dashboard', - artifact: 'artifact', - autoApprove: ['call-tool', 'open-external-link'], - configPath: 'agent-bundle.config.ts', - envFiles: ['.env.dashboard', '.env.local'], - input: { scope: 'all', nested: { n: 1, list: [true, null, 'x'] } }, - loadEnvFiles: true, - mode: 'development', - open: true, - pluginRoot: '/state', - port: 4941, - profile: 'claude', - root: '/project', - target: 'claude', - tool: 'hauler_status', -}; - -/** - * The contiguous argv tokens each key lowers to, from `sample` with - * `loadEnvFiles: false` (the default `true` lowers to nothing; `--no-env` is - * the only observable form). Keyed by every argv key, so a key classified - * `argv` above fails to compile until its evidence is listed. - */ -const argvEvidence: { readonly [K in ArgvKey]: readonly string[] } = { - app: ['hauler/dashboard'], - artifact: ['--artifact', 'artifact'], - autoApprove: ['--allow', 'call-tool', '--allow', 'open-external-link'], - configPath: ['--config', 'agent-bundle.config.ts'], - envFiles: ['--env-file', '.env.dashboard', '--env-file', '.env.local'], - input: ['--input', '{"scope":"all","nested":{"n":1,"list":[true,null,"x"]}}'], - loadEnvFiles: ['--no-env'], - mode: ['--mode', 'development'], - open: ['--open'], - pluginRoot: ['--plugin-root', '/state'], - port: ['--port', '4941'], - profile: ['--profile', 'claude'], - root: ['--root', '/project'], - target: ['--target', 'claude'], - tool: ['--tool', 'hauler_status'], -}; - -const sampleArgv: readonly string[] = [ - 'serve-app', 'hauler/dashboard', - '--root', '/project', - '--config', 'agent-bundle.config.ts', - '--mode', 'development', - '--artifact', 'artifact', - '--target', 'claude', - '--tool', 'hauler_status', - '--input', '{"scope":"all","nested":{"n":1,"list":[true,null,"x"]}}', - '--port', '4941', - '--profile', 'claude', - '--allow', 'call-tool', '--allow', 'open-external-link', - '--open', - '--env-file', '.env.dashboard', '--env-file', '.env.local', - '--plugin-root', '/state', -]; - -const readyLine = 'MCP App hauler/dashboard at http://127.0.0.1:4941/ (tool hauler_status; Ctrl-C stops the server)'; - -const containsSequence = (haystack: readonly string[], needle: readonly string[]): boolean => - haystack.some((_token, start) => needle.every((token, offset) => haystack[start + offset] === token)); - -interface ServeAppRoundTrip { - readonly calls: readonly ServeAppOptions[]; - readonly code: number; - readonly stderr: string; - readonly stdout: string; - /** Delivers the CLI's SIGTERM so it closes the fake host and releases its terminal runtime. */ - readonly shutdown: () => Promise; -} - -/** Runs `argv` through the real `serve-app` command with `serveApp` replaced by a recorder, as cli.test.ts does. */ -const roundTrip = async (argv: readonly string[]): Promise => { - const calls: ServeAppOptions[] = []; - const handlers = new Map void>(); - const closedGate = deferred(); - const served: ServedApp = { - close: async () => { closedGate.resolve(); }, - closed: closedGate.promise, - resourceUri: 'ui://cargo-hauler/dashboard.html', - sandboxOrigin: 'http://127.0.0.1:4942', - server: 'hauler', - tool: 'hauler_status', - url: 'http://127.0.0.1:4941/', - }; - const terminal = captureCliTerminal(); - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); - const code = await runCli([...argv], terminal.output, { - serveApp: async (options) => { - calls.push(options); - return served; - }, - signals: { - once: (signal, listener) => { handlers.set(signal, listener); }, - removeListener: () => undefined, - }, - }); - return { - calls, - code, - shutdown: async () => { - handlers.get('SIGTERM')?.(); - if (handlers.size > 0) await closedGate.promise; - }, - stderr: terminal.stderr(), - stdout: terminal.stdout(), - }; -}; - -const temporaryDirectories: string[] = []; - -const temporaryDirectory = async (): Promise => { - const directory = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-serve-app-command-'))); - temporaryDirectories.push(directory); - return directory; -}; - -const writeManifest = async (root: string, manifest: Readonly>): Promise => { - const path = join(root, 'node_modules', 'agent-bundle', 'package.json'); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(manifest)}\n`); - return path; -}; - -const testsRoot = import.meta.dirname; -const packageRoot = resolve(testsRoot, '..'); -const workspaceRoot = resolve(testsRoot, '../../..'); - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))); -}); - -describe('ServeAppOptions classification', () => { - it('classifies every serveApp option as lowered to argv or host-process-only', () => { - expect(argvKeysAreTheRest).toBe(true); - expect(argvValuesAssignable).toBe(true); - expect(argvOptionsAreServeAppOptions).toBe(true); - expect(onlyAutoApproveDiffers).toBe(true); - const entries = Object.entries(classification) as readonly (readonly [keyof ServeAppOptions, 'argv' | 'host-only'])[]; - const argvKeys = entries.filter(([, kind]) => kind === 'argv').map(([key]) => key).sort(); - const hostOnlyKeys = entries.filter(([, kind]) => kind === 'host-only').map(([key]) => key).sort(); - expect(hostOnlyKeys).toEqual(['logger', 'openBrowser', 'registry', 'targets', 'timeoutMs']); - expect(Object.keys(sample).sort()).toEqual(argvKeys); - expect(Object.keys(argvEvidence).sort()).toEqual(argvKeys); - }); - - it('lowers every argv key of the full sample to its documented flag', () => { - const argv = serveAppArgv({ ...sample, loadEnvFiles: false }); - const unlowered = Object.entries(argvEvidence).filter(([, tokens]) => !containsSequence(argv, tokens)).map(([key]) => key); - expect(unlowered).toEqual([]); - expect(serveAppArgv(sample)).not.toContain('--no-env'); - }); - - it('narrows autoApprove to the --allow vocabulary the CLI accepts', () => { - expect(autoApproveIsTheAllowVocabulary).toBe(true); - expect(allowVocabulary).toBe(true); - expect(allowIsASubsetOfConsent).toBe(true); - expect(browserPermissionsStayInteractive).toBe(true); - expect(serveAppAllowCapabilities).toEqual(['call-tool', 'download-file', 'open-external-link', 'request-display-mode']); - }); -}); - -describe('serveAppArgv', () => { - it('lowers the full sample to serve-app argv in the documented flag order, deterministically', () => { - expect(serveAppArgv(sample).slice(0, 4)).toEqual(['serve-app', 'hauler/dashboard', '--root', '/project']); - expect(serveAppArgv(sample)).toEqual(sampleArgv); - expect(serveAppArgv(sample)).toEqual(serveAppArgv(sample)); - }); - - it('lowers the minimal options to the positional and --root only', () => { - expect(serveAppArgv({ app: 'status/status', root: '/project' })).toEqual(['serve-app', 'status/status', '--root', '/project']); - }); - - it('lowers falsy and empty values faithfully', () => { - const argv = serveAppArgv({ app: 'status/status', autoApprove: [], envFiles: [], input: {}, port: 0, root: '/project' }); - expect(argv).toEqual(['serve-app', 'status/status', '--root', '/project', '--input', '{}', '--port', '0']); - expect(argv).not.toContain('--allow'); - expect(argv).not.toContain('--env-file'); - expect(serveAppArgv({ app: 'status/status', loadEnvFiles: false, open: false, root: '/project' })) - .toEqual(['serve-app', 'status/status', '--root', '/project', '--no-open', '--no-env']); - }); -}); - -describe('serve-app round trip through the CLI parser', () => { - it('parses the full sample back into the serveApp options it came from, omitting the default loadEnvFiles', async () => { - const result = await roundTrip(serveAppArgv(sample)); - expect(result.code).toBe(0); - expect(result.stderr).toBe(''); - expect(result.stdout).toBe(`${readyLine}\n`); - const { loadEnvFiles, ...expected } = sample; - expect(loadEnvFiles).toBe(true); - expect(result.calls).toHaveLength(1); - expect(result.calls[0]).toStrictEqual(expected); - expect(result.calls[0]).not.toHaveProperty('loadEnvFiles'); - await result.shutdown(); - }); - - it('parses --no-env and --no-open back into loadEnvFiles: false and open: false', async () => { - const options: Required = { ...sample, envFiles: [], loadEnvFiles: false, open: false }; - const argv = serveAppArgv(options); - expect(argv).toContain('--no-env'); - expect(argv).toContain('--no-open'); - expect(argv).not.toContain('--env-file'); - expect(argv).not.toContain('--open'); - const result = await roundTrip(argv); - expect(result.code).toBe(0); - expect(result.stderr).toBe(''); - const { envFiles, ...expected } = options; - expect(envFiles).toEqual([]); - expect(result.calls).toHaveLength(1); - expect(result.calls[0]).toStrictEqual(expected); - await result.shutdown(); - }); - - it('fills the CLI defaults for the minimal options', async () => { - const result = await roundTrip(serveAppArgv({ app: 'hauler/dashboard', root: '/project' })); - expect(result.code).toBe(0); - expect(result.stderr).toBe(''); - expect(result.stdout).toBe(`${readyLine}\n`); - expect(result.calls).toStrictEqual([{ - app: 'hauler/dashboard', - input: {}, - mode: 'production', - open: false, - profile: 'portable', - root: '/project', - target: 'portable', - }]); - await result.shutdown(); - }); - - it('leaves the --env-file/--no-env conflict to the CLI, which rejects it before serving', async () => { - const argv = serveAppArgv({ app: 'hauler/dashboard', envFiles: ['.env'], loadEnvFiles: false, root: '/project' }); - expect(argv).toEqual(['serve-app', 'hauler/dashboard', '--root', '/project', '--env-file', '.env', '--no-env']); - const result = await roundTrip(argv); - expect(result.code).toBe(1); - expect(result.stdout).toBe(''); - expect(JSON.parse(result.stderr)).toEqual([{ - code: 'AB5000', - message: 'Use either --env-file or --no-env, not both.', - severity: 'error', - }]); - expect(result.calls).toEqual([]); - await result.shutdown(); - }); -}); - -describe('ready line', () => { - it('parses what the CLI formats', () => { - for (const app of ['hauler/dashboard', 'status/ui://status/dashboard.html']) { - const fields = { app, tool: 'hauler_status', url: 'http://127.0.0.1:4941/' }; - expect(parseServeAppReadyLine(formatServeAppReadyLine(fields))).toEqual(fields); - } - }); - - it('parses the exact CLI line, tolerating a trailing line ending', () => { - const fields = { app: 'hauler/dashboard', tool: 'hauler_status', url: 'http://127.0.0.1:4941/' }; - expect(parseServeAppReadyLine(readyLine)).toEqual(fields); - expect(parseServeAppReadyLine(`${readyLine}\n`)).toEqual(fields); - expect(parseServeAppReadyLine(`${readyLine}\r\n`)).toEqual(fields); - }); - - it('ignores every other line', () => { - expect(parseServeAppReadyLine('Building…')).toBeUndefined(); - expect(parseServeAppReadyLine('MCP App x at nowhere')).toBeUndefined(); - expect(parseServeAppReadyLine('')).toBeUndefined(); - expect(parseServeAppReadyLine(` ${readyLine}`)).toBeUndefined(); - }); -}); - -describe('locateFrameworkCli', () => { - it('resolves the bin of the agent-bundle installed under root, in object and string form', async () => { - const objectRoot = await temporaryDirectory(); - await writeManifest(objectRoot, { bin: { 'agent-bundle': './bin/agent-bundle.js' }, name: 'agent-bundle' }); - await expect(locateFrameworkCli(objectRoot)).resolves.toBe(join(objectRoot, 'node_modules/agent-bundle/bin/agent-bundle.js')); - - const stringRoot = await temporaryDirectory(); - await writeManifest(stringRoot, { bin: './bin/agent-bundle.js', name: 'agent-bundle' }); - await expect(locateFrameworkCli(stringRoot)).resolves.toBe(join(stringRoot, 'node_modules/agent-bundle/bin/agent-bundle.js')); - }); - - it('finds a manifest installed two levels above the root', async () => { - const root = await temporaryDirectory(); - await writeManifest(root, { bin: { 'agent-bundle': './bin/agent-bundle.js' }, name: 'agent-bundle' }); - await mkdir(join(root, 'packages', 'plugin'), { recursive: true }); - await expect(locateFrameworkCli(join(root, 'packages', 'plugin'))) - .resolves.toBe(join(root, 'node_modules/agent-bundle/bin/agent-bundle.js')); - }); - - it('walks the ancestor node_modules by hand when the package exports hide its manifest', async () => { - const root = await temporaryDirectory(); - await writeManifest(root, { - bin: { 'agent-bundle': './bin/agent-bundle.js' }, - exports: { '.': './dist/index.js' }, - name: 'agent-bundle', - }); - await mkdir(join(root, 'packages', 'plugin'), { recursive: true }); - await expect(locateFrameworkCli(join(root, 'packages', 'plugin'))) - .resolves.toBe(join(root, 'node_modules/agent-bundle/bin/agent-bundle.js')); - }); - - it('returns undefined for a manifest without a bin and for a root without the framework', async () => { - const binless = await temporaryDirectory(); - await writeManifest(binless, { name: 'agent-bundle' }); - await expect(locateFrameworkCli(binless)).resolves.toBeUndefined(); - - const empty = await temporaryDirectory(); - await expect(locateFrameworkCli(empty)).resolves.toBeUndefined(); - }); - - it('resolves this checkout to its own bin from the package and from the workspace root', async () => { - const expected = await realpath(join(packageRoot, 'bin', 'agent-bundle.js')); - for (const root of [packageRoot, workspaceRoot]) { - const located = await locateFrameworkCli(root); - expect(located).toBeDefined(); - expect(await realpath(located!)).toBe(expected); - } - }); -}); diff --git a/packages/agent-bundle/tests/serve-app.test.ts b/packages/agent-bundle/tests/serve-app.test.ts index 1ba6fa3a7..43e87bc15 100644 --- a/packages/agent-bundle/tests/serve-app.test.ts +++ b/packages/agent-bundle/tests/serve-app.test.ts @@ -14,7 +14,7 @@ import { createAppClient, } from '../src/app/index.ts'; import { MCP_APP_PROTOCOL_VERSION } from '../src/dev/mcp-apps/mcp-app-bridge.ts'; -import { SERVE_APP_TOKEN_HEADER } from '../src/serve-app/serve-app-page.ts'; +import { WEB_HOST_TOKEN_HEADER } from '../src/web-host/page.ts'; import { timeScale } from './support/time-scale.ts'; /** @@ -44,11 +44,12 @@ interface Seed { readonly sessionId: string; readonly title: string; readonly token: string; + readonly tokenHeader: string; readonly toolName: string; } const seedOf = (html: string): Seed => { - const match = /`, 'u').exec(html); + if (match?.[1] === undefined) throw new Error('The host document carries no seed element.'); + return match[1]; +}; + +describe('renderWebHostPage', () => { + it('embeds the seed as JSON the page reads back unchanged, and the script verbatim', () => { + const html = renderWebHostPage({ script, seed }); + expect(html.startsWith('\n')).toBe(true); + expect(JSON.parse(seedElementOf(html))).toEqual(seed); + expect(html).toContain(``); + expect(html).toContain('status/status'); + expect(html).toContain('

status/status

'); + expect(html).toContain(''); + }); + + it('keeps a hostile tool result from terminating the seed element', () => { + const hostile = { + ...seed, + result: { content: [{ text: ' line\u2028break\u2029