From 48a10091eeaa0d55d6dcf134a7036dcbb8032a85 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 02:06:04 +0000 Subject: [PATCH 1/4] feat(serve-app): add agent-bundle/serve-app-command and AB4837 for compiler imports in routed executables (#558) --- .changeset/558-serve-app-command.md | 5 + docs/diagnostics.md | 5 +- docs/entry-conventions.md | 42 +- docs/framework-mode.md | 8 +- .../serve-app-command/agent-bundle.config.ts | 27 ++ .../fixtures/serve-app-command/package.json | 8 + .../serve-app-command/src/cli/dashboard.ts | 57 +++ .../serve-app-command/src/mcp/status.ts | 34 ++ .../serve-app-command/views/status.html | 10 + .../serve-app-command/views/status.ts | 1 + packages/agent-bundle/package.json | 4 + packages/agent-bundle/rslib.config.ts | 4 + packages/agent-bundle/src/build/rslib.ts | 27 +- packages/agent-bundle/src/cli.ts | 16 +- .../src/core/dependency-manifest.ts | 33 ++ .../src/routes/framework-imports.ts | 381 +++++++++++++++ packages/agent-bundle/src/routes/graph.ts | 71 +++ packages/agent-bundle/src/routes/index.ts | 10 + .../agent-bundle/src/serve-app-command.ts | 333 +++++++++++++ .../src/serve-app/command-contract.ts | 48 ++ .../tests/cli-routes-build.test.ts | 72 ++- .../tests/packed-serve-app-command.test.ts | 296 ++++++++++++ .../tests/route-framework-imports.test.ts | 443 ++++++++++++++++++ .../tests/serve-app-command-spawn.test.ts | 272 +++++++++++ .../tests/serve-app-command.test.ts | 400 ++++++++++++++++ rstest.integration-tests.ts | 2 + website/docs/en/guide/authoring/mcp.mdx | 160 +++---- .../docs/en/guide/distribution/validation.mdx | 10 +- website/docs/en/reference/api.mdx | 3 +- website/docs/en/reference/cli.mdx | 13 +- website/docs/zh/guide/authoring/mcp.mdx | 149 +++--- .../docs/zh/guide/distribution/validation.mdx | 7 +- website/docs/zh/reference/api.mdx | 3 +- website/docs/zh/reference/cli.mdx | 12 +- website/rspress.config.ts | 1 + 35 files changed, 2740 insertions(+), 227 deletions(-) create mode 100644 .changeset/558-serve-app-command.md create mode 100644 packages/agent-bundle/fixtures/serve-app-command/agent-bundle.config.ts create mode 100644 packages/agent-bundle/fixtures/serve-app-command/package.json create mode 100644 packages/agent-bundle/fixtures/serve-app-command/src/cli/dashboard.ts create mode 100644 packages/agent-bundle/fixtures/serve-app-command/src/mcp/status.ts create mode 100644 packages/agent-bundle/fixtures/serve-app-command/views/status.html create mode 100644 packages/agent-bundle/fixtures/serve-app-command/views/status.ts create mode 100644 packages/agent-bundle/src/core/dependency-manifest.ts create mode 100644 packages/agent-bundle/src/routes/framework-imports.ts create mode 100644 packages/agent-bundle/src/serve-app-command.ts create mode 100644 packages/agent-bundle/src/serve-app/command-contract.ts create mode 100644 packages/agent-bundle/tests/packed-serve-app-command.test.ts create mode 100644 packages/agent-bundle/tests/route-framework-imports.test.ts create mode 100644 packages/agent-bundle/tests/serve-app-command-spawn.test.ts create mode 100644 packages/agent-bundle/tests/serve-app-command.test.ts diff --git a/.changeset/558-serve-app-command.md b/.changeset/558-serve-app-command.md new file mode 100644 index 000000000..90cd336b0 --- /dev/null +++ b/.changeset/558-serve-app-command.md @@ -0,0 +1,5 @@ +--- +"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`). 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 (#558) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 4586b3769..a30806042 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -29,7 +29,7 @@ even when no error diagnostic was reported. | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | | `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | -| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), and provider conventions (see below). | +| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | | `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | @@ -605,7 +605,7 @@ framework-owned plugin twice by accident. | `AB4723` | error | `tools.rspack` is not an Rspack config object, a mutator function, or an array of both. | Use one of the three Rslib `tools.rspack` forms. | | `AB4724` | error | `tools.rsbuild.plugins` supplies a plugin whose `name` matches a framework-owned registration (`rsbuild:react` from `@rsbuild/plugin-react`). The message names the plugin and its package. | Remove the plugin from `tools.rsbuild.plugins`; agent-bundle registers it in every config it synthesizes. | -## Route graph, state, layout, and provider conventions (`AB4800`–`AB4836`, `AB4940`–`AB4942`) +## Route graph, state, layout, and provider conventions (`AB4800`–`AB4837`, `AB4940`–`AB4942`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -820,6 +820,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `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. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index d68a201c3..8b0d2b97b 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1467,17 +1467,35 @@ closed }`). It is a host-process API: it belongs to processes the framework does not compile — the first-party CLI, the Workbench, tests, a plugin's own `package.json` scripts or a hand-written `.mjs` run from the checkout — and never to the MCP server shell. A routed CLI command inside the artifact -cannot import it today: routed CLI bins are self-contained (#387), so the -bundler inlines `agent-bundle/dist/api.js` into the bin and fails on the +cannot import it: routed CLI bins are self-contained (#387), so the bundler +would inline `agent-bundle/dist/api.js` into the bin and fail on the framework's runtime-relative module references (`Module not found: Can't -resolve '../events'`), while an external bare import (`AB6005 uses +resolve '../events'`). The route graph reports such an import first, as +`AB4837` naming the module and the specifier +(`src/routes/framework-imports.ts`; the compiler-carrying entries are +`agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, +`agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, and +`agent-bundle/test/browser`, matched exactly; `import type` and type-only +usage are not reported), while an external bare import (`AB6005 uses unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a -non-literal dynamic import`) fails artifact validation. The pattern that -builds is a plain routed command that spawns `agent-bundle serve-app` as a -child process — resolving the framework CLI from `node_modules/agent-bundle` -by path, relaying the child's `MCP App at ` line to stderr so the -routed CLI keeps stdout for its result, and turning the route `signal` into -the child's `SIGTERM` — which makes it a checkout-only command (an installed -host pack has neither `node_modules` nor the artifact). A framework helper -for that plumbing is tracked in #558; the worked example is in the MCP Apps -guide, "Serving an App standalone". +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`), or `aborted`. 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". diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 31f4ba1de..2746b60d6 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -100,9 +100,11 @@ own packed server, launched as `mcp run` launches it. `serveApp` in `agent-bundle/api` is the programmatic form for host processes — the CLI, 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; -`AB6005`) and spawns `agent-bundle serve-app` instead; see -[Entry conventions](entry-conventions.md#agent-bundle-serve-app) and #558. +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). The compiler statically reads `config`, imports schemas and implementations only into generated entries, installs `runAgentRequest`, and derives the real 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 new file mode 100644 index 000000000..3651f8a05 --- /dev/null +++ b/packages/agent-bundle/fixtures/serve-app-command/agent-bundle.config.ts @@ -0,0 +1,27 @@ +// 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/package.json b/packages/agent-bundle/fixtures/serve-app-command/package.json new file mode 100644 index 000000000..5867fcaf3 --- /dev/null +++ b/packages/agent-bundle/fixtures/serve-app-command/package.json @@ -0,0 +1,8 @@ +{ + "name": "serve-app-command-fixture", + "private": true, + "type": "module", + "devDependencies": { + "@modelcontextprotocol/server": "2.0.0" + } +} 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 new file mode 100644 index 000000000..25b651678 --- /dev/null +++ b/packages/agent-bundle/fixtures/serve-app-command/src/cli/dashboard.ts @@ -0,0 +1,57 @@ +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/src/mcp/status.ts b/packages/agent-bundle/fixtures/serve-app-command/src/mcp/status.ts new file mode 100644 index 000000000..3b2bf15a4 --- /dev/null +++ b/packages/agent-bundle/fixtures/serve-app-command/src/mcp/status.ts @@ -0,0 +1,34 @@ +import { McpServer } from '@modelcontextprotocol/server'; +import apps from 'agent-bundle/mcp-apps'; +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 }); + + server.registerResource(app.name, app.resourceUri, { + _meta: { ui: { resourceUri: app.resourceUri } }, + mimeType: app.mimeType, + }, async (uri) => ({ + contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }], + })); + + server.registerTool('status', { + _meta: { ui: { resourceUri: app.resourceUri } }, + description: 'Reports the fixture status and opens the status App.', + }, async () => ({ + _meta: { ui: { resourceUri: app.resourceUri } }, + content: [{ text: 'status: healthy', type: 'text' }], + structuredContent: { status: 'healthy' }, + })); + + return server; +} diff --git a/packages/agent-bundle/fixtures/serve-app-command/views/status.html b/packages/agent-bundle/fixtures/serve-app-command/views/status.html new file mode 100644 index 000000000..ff379212c --- /dev/null +++ b/packages/agent-bundle/fixtures/serve-app-command/views/status.html @@ -0,0 +1,10 @@ + + + + + Status + + +
+ + diff --git a/packages/agent-bundle/fixtures/serve-app-command/views/status.ts b/packages/agent-bundle/fixtures/serve-app-command/views/status.ts new file mode 100644 index 000000000..6a0e721e8 --- /dev/null +++ b/packages/agent-bundle/fixtures/serve-app-command/views/status.ts @@ -0,0 +1 @@ +document.querySelector('#view')!.textContent = 'serve-app-command fixture status'; diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 49ca5535e..c2c0bf6f2 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -84,6 +84,10 @@ "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" diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 94b02cdbb..888bfb23a 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -119,6 +119,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', diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 837c75cb0..050768e51 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -4,12 +4,11 @@ import { pluginReact } from '@rsbuild/plugin-react'; import { createRslib, mergeRslibConfig, rspack, type LibConfig, type Rspack } from '@rslib/core'; import { readFile, realpath } from 'node:fs/promises'; -import { createRequire } from 'node:module'; import { dirname, join, resolve, sep } from 'node:path'; +import { dependencyManifestPath } from '../core/dependency-manifest.ts'; import { sha256Hex } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; -import { exists } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleToolsConfig } from '../core/types.ts'; import type { AgentBundleMeta } from '../meta.ts'; @@ -319,30 +318,6 @@ const readManifest = async (packageRoot: string): Promise path.split(sep).includes('node_modules'); -/** - * The manifest of dependency `name` as Node resolves it from `packageRoot`, - * which honours hoisting: npm, Yarn, and pnpm with a hoist pattern place a - * workspace dependency in an ancestor `node_modules`, where Rspack finds it - * too. A package whose `exports` map hides `package.json` makes that lookup - * throw, so the same ancestor walk is then performed by hand. - */ -const dependencyManifestPath = async (packageRoot: string, name: string): Promise => { - try { - return createRequire(join(packageRoot, 'package.json')).resolve(`${name}/package.json`); - } catch (error) { - if (isErrno(error, 'MODULE_NOT_FOUND')) return undefined; - if (!isErrno(error, 'ERR_PACKAGE_PATH_NOT_EXPORTED')) throw error; - } - let directory = packageRoot; - while (true) { - const candidate = join(directory, 'node_modules', ...name.split('/'), 'package.json'); - if (await exists(candidate)) return candidate; - const parent = dirname(directory); - if (parent === directory) return undefined; - directory = parent; - } -}; - /** * The project root as Rspack records it, so a dependency link back onto the * project compares equal. A directory that does not exist has no manifest and diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 4472da8f2..c90c9eac6 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -26,7 +26,6 @@ import type { validate, InspectionComponentCapability, InspectionSkippedComponent, - McpAppConsentCapability, McpAppProfileId, ProjectOptions, } from './api.ts'; @@ -52,6 +51,7 @@ import { errorMessage } from './core/errors.ts'; import { formatInstallResult, formatUninstallResult } from './install/format.ts'; import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; +import { formatServeAppReadyLine, isServeAppAllowCapability, type ServeAppAllowCapability } from './serve-app/command-contract.ts'; import type { EvalComparisonDelta, EvalConditionMetrics } from './eval/compare.ts'; import type { CliTerminal } from './effect/cli-runtime.ts'; import type { CliServices } from './effect/terminal.ts'; @@ -197,7 +197,7 @@ interface DevProxyCommandOptions { } interface ServeAppCommandOptions extends JsonInputOptions { - readonly allow: readonly McpAppConsentCapability[]; + readonly allow: readonly ServeAppAllowCapability[]; readonly artifact?: string; readonly config?: string; readonly env: boolean; @@ -251,16 +251,12 @@ const mcpAppProfile = (value: string): McpAppProfileId => { throw new InvalidArgumentError('MCP App profile must be portable, claude, or chatgpt.'); }; -const consentCapabilities: ReadonlySet = new Set([ - 'call-tool', 'download-file', 'open-external-link', 'request-display-mode', -]); - -const consentCapability = (value: string): McpAppConsentCapability => { - if (consentCapabilities.has(value as McpAppConsentCapability)) return value as McpAppConsentCapability; +const consentCapability = (value: string): ServeAppAllowCapability => { + if (isServeAppAllowCapability(value)) return value; throw new InvalidArgumentError('Consent capability must be call-tool, download-file, open-external-link, or request-display-mode.'); }; -const collectConsentCapability = (value: string, previous: readonly McpAppConsentCapability[]): readonly McpAppConsentCapability[] => +const collectConsentCapability = (value: string, previous: readonly ServeAppAllowCapability[]): readonly ServeAppAllowCapability[] => [...previous, consentCapability(value)]; const doctorHost = (value: string): DoctorHost => { @@ -819,7 +815,7 @@ export const runCli = async ( target: options.target, ...(options.tool === undefined ? {} : { tool: options.tool }), }); - await show(`MCP App ${app} at ${served.url} (tool ${served.tool}; Ctrl-C stops the server)\n`); + await show(`${formatServeAppReadyLine({ app, tool: served.tool, url: served.url })}\n`); // The host outlives this call like `dev` does; it ends on a termination // signal, or when the bound server exits on its own, which is reported // as a diagnostic and, in the real process, as exit code 1. diff --git a/packages/agent-bundle/src/core/dependency-manifest.ts b/packages/agent-bundle/src/core/dependency-manifest.ts new file mode 100644 index 000000000..753f7ce46 --- /dev/null +++ b/packages/agent-bundle/src/core/dependency-manifest.ts @@ -0,0 +1,33 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +import { isErrno } from './errors.ts'; +import { exists } from './paths.ts'; + +/** + * The manifest of dependency `name` as Node resolves it from `packageRoot`, + * which honours hoisting: npm, Yarn, and pnpm with a hoist pattern place a + * workspace dependency in an ancestor `node_modules`, where Rspack finds it + * too. A package whose `exports` map hides `package.json` makes that lookup + * 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. + */ +export const dependencyManifestPath = async (packageRoot: string, name: string): Promise => { + try { + return createRequire(join(packageRoot, 'package.json')).resolve(`${name}/package.json`); + } catch (error) { + if (isErrno(error, 'MODULE_NOT_FOUND')) return undefined; + if (!isErrno(error, 'ERR_PACKAGE_PATH_NOT_EXPORTED')) throw error; + } + let directory = packageRoot; + while (true) { + const candidate = join(directory, 'node_modules', ...name.split('/'), 'package.json'); + if (await exists(candidate)) return candidate; + const parent = dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +}; diff --git a/packages/agent-bundle/src/routes/framework-imports.ts b/packages/agent-bundle/src/routes/framework-imports.ts new file mode 100644 index 000000000..0dd2f6f4d --- /dev/null +++ b/packages/agent-bundle/src/routes/framework-imports.ts @@ -0,0 +1,381 @@ +import { dirname } from 'node:path'; + +import ts from 'typescript-5'; + +import type { Diagnostic } from '../core/diagnostics.ts'; +import { isInside, toPosixPath, toPosixRelative } from '../core/paths.ts'; +import { isRelativeSpecifier, moduleCandidates, readModuleFromDisk } from './module-candidates.ts'; + +/** + * Framework entries whose module graph carries the compiler. Every generated + * executable — the routed CLI bin, script executables, generated MCP servers, + * event-route hook wrappers — is a self-contained bundle (#387), so a value + * import of one of these inlines the whole compiler into it and the build + * fails deep inside the generated file rather than at the import: the + * bundler on the compiler's runtime-relative module probes + * (`new URL('../events/.ts', import.meta.url)` in `build/entries.ts`: + * `Module not found: Can't resolve '../events'`), or the artifact validator + * on the inlined compiler's non-literal dynamic imports (`AB6005`, naming the + * generated bin). Matched exactly: the subpaths below, never + * `agent-bundle/api/`. + * + * - `agent-bundle`: the root entry re-exports `api.ts`. + * - `agent-bundle/api`: the compiler itself (`build`, `serveApp`, ...). + * - `agent-bundle/config`: `defineConfig` pulls `validate.ts` and TypeScript. + * - `agent-bundle/eval`: the eval harnesses pull `artifact.ts`, hence the build. + * - `agent-bundle/rstest`, `agent-bundle/test`, `agent-bundle/test/browser`: + * the test harness and preset load the compiler to build fixtures. + * + * 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 + * deliberately absent. + */ +export const compilerCarryingSpecifiers: readonly string[] = Object.freeze([ + 'agent-bundle', + 'agent-bundle/api', + 'agent-bundle/config', + 'agent-bundle/eval', + 'agent-bundle/rstest', + 'agent-bundle/test', + 'agent-bundle/test/browser', +]); + +const compilerCarrying = new Set(compilerCarryingSpecifiers); + +/** How a module imports a framework entry at run time. */ +export type FrameworkValueImportForm = 'static' | 'dynamic' | 'reexport' | 'side-effect'; + +/** One value import of a compiler-carrying framework entry found by the scan. */ +export interface FrameworkValueImport { + /** Absolute path of the module containing the import (the route or a relatively imported helper). */ + readonly importer: string; + readonly specifier: string; + readonly form: FrameworkValueImportForm; +} + +/** Where the scanned module lives, so relative imports can be followed. */ +export interface ScanFrameworkValueImportsOptions { + /** + * Reads one relatively imported module's source text by absolute path; + * undefined when the file is unreadable. Defaults to a synchronous file read. + */ + readonly readModule?: (absolutePath: string) => string | undefined; + /** The scanned module's absolute path; relative imports resolve against its directory. */ + readonly source: string; +} + +const scriptKindOf = (path: string): ts.ScriptKind => { + if (path.endsWith('.tsx')) return ts.ScriptKind.TSX; + if (path.endsWith('.jsx')) return ts.ScriptKind.JSX; + if (path.endsWith('.js') || path.endsWith('.mjs') || path.endsWith('.cjs')) return ts.ScriptKind.JS; + return ts.ScriptKind.TS; +}; + +const compareStrings = (left: string, right: string): number => (left < right ? -1 : left > right ? 1 : 0); + +/** + * Whether one identifier occurrence reads the binding of that name at run + * time. The bundler's SWC transform elides an import whose bindings are only + * ever used as types — with or without the `type` keyword — so an import + * counts as a value import only when some binding survives that elision. + * Every position that is a *name* rather than a reference (`foo.serveApp`, + * `{ serveApp: 1 }`, a declaration's own name, an import/export clause) is + * ruled out first; then any ancestor that makes the occurrence a type-level + * one (a type node, which covers `typeof x` type queries and `import('x')` + * type nodes, an `implements` clause, a `type`/`interface`/type-parameter + * declaration) or an ambient `declare` declaration rules it out too. Shadowing + * declarations in nested scopes are not modelled: a same-named local + * reference still counts, which errs toward reporting only in the + * import-then-shadow-then-type-only case. + */ +const isValueReference = (identifier: ts.Identifier): boolean => { + const { parent } = identifier; + if (ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent)) return false; + if (ts.isExportSpecifier(parent)) return isLocalExportReference(identifier, parent); + if (ts.isPropertyAccessExpression(parent) && parent.name === identifier) return false; + if (ts.isMetaProperty(parent)) return false; + if (ts.isShorthandPropertyAssignment(parent)) return !isInTypeContext(parent); + if ( + (ts.isPropertyAssignment(parent) || + ts.isMethodDeclaration(parent) || + ts.isPropertyDeclaration(parent) || + ts.isPropertySignature(parent) || + ts.isEnumMember(parent) || + ts.isGetAccessorDeclaration(parent) || + ts.isSetAccessorDeclaration(parent) || + ts.isJsxAttribute(parent)) && + parent.name === identifier + ) { + return false; + } + if (ts.isBindingElement(parent) && (parent.propertyName === identifier || parent.name === identifier)) return false; + if (isDeclaredName(identifier, parent)) return false; + // A lowercase JSX tag is an intrinsic element, not a binding. + if ( + (ts.isJsxOpeningLikeElement(parent) || ts.isJsxClosingElement(parent)) && + parent.tagName === identifier && + /^[a-z]/u.test(identifier.text) + ) { + return false; + } + return !isInTypeContext(parent); +}; + +/** `export { x }` / `export { x as y }` without a module specifier reads the local binding `x`. */ +const isLocalExportReference = (identifier: ts.Identifier, specifier: ts.ExportSpecifier): boolean => { + const declaration = specifier.parent.parent; + if (specifier.isTypeOnly || declaration.isTypeOnly || declaration.moduleSpecifier !== undefined) return false; + return (specifier.propertyName ?? specifier.name) === identifier; +}; + +/** True when the identifier is the declared name of its parent, not a reference. */ +const isDeclaredName = (identifier: ts.Identifier, parent: ts.Node): boolean => { + if ( + ts.isVariableDeclaration(parent) || + ts.isParameter(parent) || + ts.isFunctionDeclaration(parent) || + ts.isFunctionExpression(parent) || + ts.isClassDeclaration(parent) || + ts.isClassExpression(parent) || + ts.isTypeAliasDeclaration(parent) || + ts.isInterfaceDeclaration(parent) || + ts.isEnumDeclaration(parent) || + ts.isModuleDeclaration(parent) || + ts.isTypeParameterDeclaration(parent) + ) { + return parent.name === identifier; + } + if (ts.isLabeledStatement(parent) || ts.isBreakOrContinueStatement(parent)) return parent.label === identifier; + return false; +}; + +/** `class C extends X`: the `X` expression is a value even though TypeScript types the node. */ +const isClassExtendsExpression = (node: ts.ExpressionWithTypeArguments): boolean => + ts.isHeritageClause(node.parent) && + node.parent.token === ts.SyntaxKind.ExtendsKeyword && + ts.isClassLike(node.parent.parent); + +/** True for a declaration carrying the `declare` modifier: an ambient context the emit erases entirely. */ +const isAmbientDeclaration = (node: ts.Node): boolean => + ts.canHaveModifiers(node) && + (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword) ?? false); + +/** True when some ancestor makes the position type-level or ambient, so no JavaScript reads it. */ +const isInTypeContext = (start: ts.Node): boolean => { + for (let ancestor: ts.Node | undefined = start; ancestor !== undefined; ancestor = ancestor.parent) { + if (ts.isExpressionWithTypeArguments(ancestor)) { + if (isClassExtendsExpression(ancestor)) continue; + return true; + } + if (ts.isTypeNode(ancestor)) return true; + if (ts.isHeritageClause(ancestor) && ancestor.token === ts.SyntaxKind.ImplementsKeyword) return true; + if ( + ts.isTypeAliasDeclaration(ancestor) || + ts.isInterfaceDeclaration(ancestor) || + ts.isTypeParameterDeclaration(ancestor) || + isAmbientDeclaration(ancestor) + ) { + return true; + } + } + return false; +}; + +/** The local binding names an import declaration introduces as values (`type`-qualified specifiers excluded). */ +const valueBindingsOf = (clause: ts.ImportClause): readonly string[] => { + if (clause.isTypeOnly) return []; + const names: string[] = []; + if (clause.name !== undefined) names.push(clause.name.text); + const bindings = clause.namedBindings; + if (bindings !== undefined) { + if (ts.isNamespaceImport(bindings)) { + names.push(bindings.name.text); + } else { + for (const element of bindings.elements) { + if (!element.isTypeOnly) names.push(element.name.text); + } + } + } + return names; +}; + +/** Whether a re-export declaration emits JavaScript (SWC keeps every specifier not marked `type`). */ +const isValueReExport = (declaration: ts.ExportDeclaration): boolean => { + if (declaration.isTypeOnly) return false; + const clause = declaration.exportClause; + if (clause === undefined || ts.isNamespaceExport(clause)) return true; + return clause.elements.some((element) => !element.isTypeOnly); +}; + +const moduleSpecifierText = (expression: ts.Expression | undefined): string | undefined => + expression !== undefined && ts.isStringLiteralLike(expression) ? expression.text : undefined; + +/** Every identifier text in `names` that some value position of the module reads. */ +const referencedValueBindings = (sourceFile: ts.SourceFile, names: ReadonlySet): Set => { + const referenced = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && names.has(node.text) && !referenced.has(node.text) && isValueReference(node)) { + referenced.add(node.text); + } + if (referenced.size < names.size) ts.forEachChild(node, visit); + }; + visit(sourceFile); + return referenced; +}; + +/** Every `import('')` specifier in the module, wherever it appears. */ +const dynamicImportSpecifiers = (sourceFile: ts.SourceFile): readonly string[] => { + const specifiers: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const specifier = moduleSpecifierText(node.arguments[0]); + if (specifier !== undefined) specifiers.push(specifier); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return specifiers; +}; + +interface ValueImport { + readonly form: FrameworkValueImportForm; + readonly specifier: string; +} + +/** + * The module's value imports — the specifiers the bundler resolves and + * inlines once SWC has elided the type-only ones — in source order. + */ +const valueImportsOf = (sourceFile: ts.SourceFile): readonly ValueImport[] => { + const imports: ValueImport[] = []; + const staticBindings = new Map>(); + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement)) { + const specifier = moduleSpecifierText(statement.moduleSpecifier); + if (specifier === undefined) continue; + if (statement.importClause === undefined) { + imports.push({ form: 'side-effect', specifier }); + continue; + } + const bindings = valueBindingsOf(statement.importClause); + if (bindings.length === 0) continue; + const known = staticBindings.get(specifier) ?? new Set(); + for (const binding of bindings) known.add(binding); + staticBindings.set(specifier, known); + continue; + } + if (ts.isExportDeclaration(statement)) { + const specifier = moduleSpecifierText(statement.moduleSpecifier); + if (specifier !== undefined && isValueReExport(statement)) imports.push({ form: 'reexport', specifier }); + } + } + if (staticBindings.size > 0) { + const names = new Set([...staticBindings.values()].flatMap((bindings) => [...bindings])); + const referenced = referencedValueBindings(sourceFile, names); + for (const [specifier, bindings] of staticBindings) { + if ([...bindings].some((binding) => referenced.has(binding))) imports.push({ form: 'static', specifier }); + } + } + for (const specifier of dynamicImportSpecifiers(sourceFile)) imports.push({ form: 'dynamic', specifier }); + return imports; +}; + +const scanModule = ( + moduleText: string, + source: string, + read: (absolutePath: string) => string | undefined, + visited: Set, + findings: FrameworkValueImport[], +): void => { + const sourceFile = ts.createSourceFile(source, moduleText, ts.ScriptTarget.Latest, true, scriptKindOf(source)); + for (const { form, specifier } of valueImportsOf(sourceFile)) { + if (compilerCarrying.has(specifier)) { + findings.push({ form, importer: source, specifier }); + continue; + } + if (!isRelativeSpecifier(specifier)) continue; + // The same candidate order the other static scans use, so every scan + // names one file for one specifier; the first readable candidate wins. + for (const candidate of moduleCandidates(dirname(source), specifier)) { + if (visited.has(candidate)) break; + const text = read(candidate); + if (text === undefined) continue; + visited.add(candidate); + scanModule(text, candidate, read, visited, findings); + break; + } + } +}; + +/** + * Statically finds every value import of a compiler-carrying framework entry + * in one module and the modules it reaches through relative value imports + * (`./` and `../` specifiers, followed with the shared candidate order, each + * file once). The module is parsed, never evaluated. Findings list the + * scanned module's own first, then by importer path, specifier, and form. + */ +export const scanFrameworkValueImports = ( + moduleText: string, + options: ScanFrameworkValueImportsOptions, +): readonly FrameworkValueImport[] => { + const findings: FrameworkValueImport[] = []; + scanModule(moduleText, options.source, options.readModule ?? readModuleFromDisk, new Set([options.source]), findings); + findings.sort((left, right) => + Number(left.importer !== options.source) - Number(right.importer !== options.source) || + compareStrings(left.importer, right.importer) || + compareStrings(left.specifier, right.specifier) || + compareStrings(left.form, right.form)); + return Object.freeze(findings.map((finding) => Object.freeze(finding))); +}; + +/** + * Names a helper module the way the project's own diagnostics name files: + * project-relative when the route's project root can be recovered from its + * paths and the helper lies inside it, otherwise relative to the route's + * directory. + */ +const describeImporter = (importer: string, relativePath: string, sourcePath: string): string => { + const posixSource = toPosixPath(sourcePath); + const suffix = `/${relativePath}`; + if (posixSource.endsWith(suffix)) { + const projectRoot = sourcePath.slice(0, sourcePath.length - suffix.length); + if (isInside(projectRoot, importer)) return toPosixRelative(projectRoot, importer); + } + const fromRoute = toPosixRelative(dirname(sourcePath), importer); + return fromRoute.startsWith('.') ? fromRoute : `./${fromRoute}`; +}; + +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.'; + +/** + * AB4837: the module a generated executable bundles value-imports a + * compiler-carrying framework entry (#558), directly or through a relatively + * imported helper. At most one diagnostic per module — the first finding in + * the scan's deterministic order — so a route that imports the compiler + * three ways reads one actionable sentence. `executable` is the noun for the + * self-contained bundle the module ends up in ('routed CLI executable', + * 'generated MCP server', ...); `subject` is how the module is addressed. + */ +export const validateRouteFrameworkImports = ( + moduleText: string, + relativePath: string, + sourcePath: string, + executable: string, + subject = 'Route module', +): readonly Diagnostic[] => { + const [finding] = scanFrameworkValueImports(moduleText, { source: sourcePath }); + if (finding === undefined) return Object.freeze([]); + const via = finding.importer === sourcePath + ? '' + : ` (via ${describeImporter(finding.importer, relativePath, sourcePath)})`; + return Object.freeze([{ + code: 'AB4837', + message: `${subject} ${relativePath} imports ${JSON.stringify(finding.specifier)} as a value${via}; the ${executable} is self-contained and cannot bundle the compiler, so the build would fail deep inside the generated executable (an unresolvable compiler module or AB6005) instead of at this import.`, + recovery, + severity: 'error', + sourcePath, + }]); +}; diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 0cd5fd69a..1cba9224e 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -23,6 +23,7 @@ import { validateProviderModuleContract, validateRouteModuleContract, } from './contract.ts'; +import { validateRouteFrameworkImports } from './framework-imports.ts'; import { extractInputSchema } from './input-schema.ts'; import { isLayoutRouteKind } from './layouts.ts'; import { providerKeyFromName } from './providers.ts'; @@ -460,6 +461,44 @@ const decideServerMode = ( return withEntry('conflict'); }; +/** + * AB4837 (#558) for one route module a generated executable bundles: the + * caller decides *whether* the route ships (a generated server or CLI, a + * conventional script, an event route); this names the self-contained + * executable the module is inlined into. App routes are browser builds and + * never bundle into a Node executable, so they are exempt. + */ +const routeFrameworkImportDiagnostics = ( + route: CompiledAgentRoute, + moduleText: string | undefined, +): readonly Diagnostic[] => { + if (moduleText === undefined) return []; + let executable: string; + switch (route.kind) { + case 'app': + return []; + case 'cli': + executable = 'routed CLI executable'; + break; + case 'script': + executable = 'script executable'; + break; + case 'tool': + case 'resource': + case 'prompt': + executable = 'generated MCP server'; + break; + case 'event-route': + executable = 'hook wrapper'; + break; + default: { + const unreachable: never = route.kind; + throw new TypeError(`Unhandled route kind ${String(unreachable)}.`); + } + } + return validateRouteFrameworkImports(moduleText, route.provenance.relativePath, route.source, executable); +}; + const compiledRoute = ( module: DiscoveredRouteModule, config: Readonly>, @@ -717,6 +756,15 @@ export const compileRouteGraph = async ( module.relativePath, module.source, )); + // A layout is inlined into every executable that renders the routes + // it wraps, so it bundles the compiler exactly as a route would. + diagnostics.push(...validateRouteFrameworkImports( + layoutText, + module.relativePath, + module.source, + 'generated executable', + 'Layout module', + )); } continue; } @@ -734,6 +782,15 @@ export const compileRouteGraph = async ( module.relativePath, module.source, )); + // Providers mount in every generated request scope, so each + // executable inlines them. + diagnostics.push(...validateRouteFrameworkImports( + providerText, + module.relativePath, + module.source, + 'generated executable', + 'Provider module', + )); } continue; } @@ -789,12 +846,19 @@ export const compileRouteGraph = async ( } case 'event-route': events.push(route); + // Every event route ships as a hook wrapper of its own, so it is + // judged here; MCP and CLI routes are judged once their server or + // CLI surface is known to be generated, because a route of a + // custom/command/remote server or a conventional CLI never bundles. + diagnostics.push(...routeFrameworkImportDiagnostics(route, moduleText)); break; case 'cli': cliRoutes.push(route); break; case 'script': scripts.push(route); + // Conventional scripts compile into every selected target. + diagnostics.push(...routeFrameworkImportDiagnostics(route, moduleText)); break; default: { const unreachable: never = route.kind; @@ -890,6 +954,9 @@ export const compileRouteGraph = async ( route.source, )); } + // The generated server inlines the route, so a compiler-carrying + // framework import would break its bundle (AB4837, #558). + diagnostics.push(...routeFrameworkImportDiagnostics(route, moduleText)); // The route's render budget (#454) is read by the generated server // from this compiled config, so it is validated here, once. diagnostics.push(...validateRouteRenderConfig(route, 'MCP route').diagnostics); @@ -950,6 +1017,10 @@ export const compileRouteGraph = async ( const compiled = await compileCliCommands(cliRoutes, async (route) => moduleTextBySource.get(route.source), projected); diagnostics.push(...compiled.diagnostics); + // The routed CLI executable inlines every command route (AB4837, #558). + for (const route of cliRoutes) { + diagnostics.push(...routeFrameworkImportDiagnostics(route, moduleTextBySource.get(route.source))); + } cli = { commands: compiled.commands, mode, diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index 884c7c804..b4fbb7856 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -49,6 +49,16 @@ export { validateRouteModuleContract, } from './contract.ts'; export type { RouteModuleExports } from './contract.ts'; +export { + compilerCarryingSpecifiers, + scanFrameworkValueImports, + validateRouteFrameworkImports, +} from './framework-imports.ts'; +export type { + FrameworkValueImport, + FrameworkValueImportForm, + ScanFrameworkValueImportsOptions, +} from './framework-imports.ts'; export { routeRenderLimits, validateRouteRenderConfig } from './render-budget.ts'; export type { RouteRenderBudget, ValidatedRouteRenderConfig } from './render-budget.ts'; export { routeTaskSupport, toolTaskSupportValues, validateRouteExecutionConfig } from './task-support.ts'; diff --git a/packages/agent-bundle/src/serve-app-command.ts b/packages/agent-bundle/src/serve-app-command.ts new file mode 100644 index 000000000..71f25a0f7 --- /dev/null +++ b/packages/agent-bundle/src/serve-app-command.ts @@ -0,0 +1,333 @@ +/** + * `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` 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. + */ +export type ServeAppCommandErrorCode = + | 'framework-not-installed' + | 'artifact-missing' + | 'spawn-failed' + | 'exited-before-ready' + | 'aborted'; + +/** 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. */ + 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 settledExit: ServeAppExit | undefined; + let resolveExit: (exit: ServeAppExit) => void = () => undefined; + const closed = new Promise((resolveClosed) => { + resolveExit = resolveClosed; + }); + const stop = (): void => { + if (settledExit === undefined) child.kill('SIGTERM'); + }; + const served = (line: ServeAppReadyLine): SpawnedServeApp => ({ + ...line, + close: async () => { + stop(); + 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 => { + stop(); + }; + options.signal?.addEventListener('abort', onAbort, { once: true }); + // An abort that landed while the CLI was being resolved has already + // dispatched its event; the listener above would wait forever. + if (options.signal?.aborted === true) stop(); + 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 }, + )); + } + }; + 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('error', (error) => { + 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 }); + }); + }); +}; diff --git a/packages/agent-bundle/src/serve-app/command-contract.ts b/packages/agent-bundle/src/serve-app/command-contract.ts new file mode 100644 index 000000000..8c8adf0dd --- /dev/null +++ b/packages/agent-bundle/src/serve-app/command-contract.ts @@ -0,0 +1,48 @@ +/** + * 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); + +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; +} + +/** The stdout line `agent-bundle serve-app` prints once the host is listening. */ +export const formatServeAppReadyLine = ({ app, tool, url }: ServeAppReadyLine): string => + `MCP App ${app} at ${url} (tool ${tool}; Ctrl-C stops the server)`; + +const readyLinePattern = + /^MCP App (?\S+) at (?https?:\/\/\S+) \(tool (?\S+); Ctrl-C stops the server\)$/u; + +/** The ready line's fields, or `undefined` for any other line of output. */ +export const parseServeAppReadyLine = (line: string): ServeAppReadyLine | undefined => { + const groups = readyLinePattern.exec(line.trimEnd())?.groups; + if (groups?.['app'] === undefined || groups['tool'] === undefined || groups['url'] === undefined) return undefined; + return { app: groups['app'], tool: groups['tool'], url: groups['url'] }; +}; diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index d79765619..198dd0be4 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -6,7 +6,8 @@ import { promisify } from 'node:util'; import { afterEach, expect, it } from '@rstest/core'; -import { build } from '../src/api.ts'; +import { build, validate } from '../src/api.ts'; +import { DiagnosticError } from '../src/core/diagnostics.ts'; const execFile = promisify(executeFile); const roots: string[] = []; @@ -412,3 +413,72 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 code: 'ENOENT', }); }); + +/** + * AB4837 (#558): a routed command that value-imports a compiler-carrying + * framework entry is refused when the route graph compiles — by `validate` + * without building, and by `build` before the bundler inlines the compiler + * into the self-contained bin and fails with an opaque error that names the + * generated file instead of the route. + */ +it('refuses a routed command that imports agent-bundle/api with AB4837 before bundling', { timeout: 120_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-cli-bin-framework-import-')); + 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({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + zod: '4.4.3', + }, + name: 'cli-bin-framework-import-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + " plugin: { description: 'Routed CLI fixture.', name: 'cli-bin-framework-import-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '});', + '', + ].join('\n')), + // The #558 shape: the command serves an MCP App by importing the compiler. + writeProjectFile(root, 'src/cli/dashboard.ts', [ + "import { z } from 'zod';", + "export const config = { description: 'Open the dashboard in a browser.' };", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ url: z.string() }).strict();', + 'export default async function dashboard() {', + " const { serveApp } = await import('agent-bundle/api');", + " const served = await serveApp({ app: 'curator/dashboard', root: process.cwd() });", + ' return { url: served.url };', + '}', + '', + ].join('\n')), + ]); + const expected = { + code: 'AB4837', + message: 'Route module src/cli/dashboard.ts imports "agent-bundle/api" as a value; the routed CLI executable is self-contained and cannot bundle the compiler, so the build would fail deep inside the generated executable (an unresolvable compiler module or AB6005) instead of at this import.', + severity: 'error', + sourcePath: join(root, 'src', 'cli', 'dashboard.ts'), + }; + + // Reported statically, without a build. + const validation = await validate({ root }); + expect(validation.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4837')).toEqual([expect.objectContaining(expected)]); + + // The build rejects on the same diagnostic before any executable is bundled. + const failure: unknown = await build({ output: 'artifact', packageOutputs: true, root }).then(() => undefined, (error: unknown) => error); + expect(failure).toBeInstanceOf(DiagnosticError); + 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'); + // 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); + expect(diagnostics.some((diagnostic) => diagnostic.code === 'AB6005')).toBe(false); + await expect(stat(join(root, 'dist'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(join(root, 'artifact'))).rejects.toMatchObject({ code: 'ENOENT' }); +}); diff --git a/packages/agent-bundle/tests/packed-serve-app-command.test.ts b/packages/agent-bundle/tests/packed-serve-app-command.test.ts new file mode 100644 index 000000000..e1f5dbf8e --- /dev/null +++ b/packages/agent-bundle/tests/packed-serve-app-command.test.ts @@ -0,0 +1,296 @@ +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 portable + * pack'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 portable artifact 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', 'portable', '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/route-framework-imports.test.ts b/packages/agent-bundle/tests/route-framework-imports.test.ts new file mode 100644 index 000000000..f6e2b620f --- /dev/null +++ b/packages/agent-bundle/tests/route-framework-imports.test.ts @@ -0,0 +1,443 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { describe, expect, it } from '@rstest/core'; + +import { + compilerCarryingSpecifiers, + type FrameworkValueImport, + scanFrameworkValueImports, + validateRouteFrameworkImports, +} from '../src/routes/framework-imports.ts'; + +/** + * AB4837 (#558): a route module that value-imports a compiler-carrying + * framework entry — directly or through a relative helper — is reported at + * route-graph compile time instead of failing inside the bundler. The scan + * is static and must never report an import the bundler's SWC transform + * would elide, so every rule below has its type-only mirror. + */ + +const route = '/project/src/cli/dashboard.ts'; + +const scan = ( + text: string, + modules: Readonly> = {}, + source = route, +): readonly FrameworkValueImport[] => + scanFrameworkValueImports(text, { readModule: (path) => modules[path], source }); + +const specifiersOf = (findings: readonly FrameworkValueImport[]): string[] => + findings.map((finding) => `${finding.form} ${finding.specifier}`); + +const lines = (...text: readonly string[]): string => `${text.join('\n')}\n`; + +describe('compilerCarryingSpecifiers', () => { + it('names exactly the framework entries whose module graph carries the compiler', () => { + expect([...compilerCarryingSpecifiers]).toEqual([ + 'agent-bundle', + 'agent-bundle/api', + 'agent-bundle/config', + 'agent-bundle/eval', + 'agent-bundle/rstest', + 'agent-bundle/test', + 'agent-bundle/test/browser', + ]); + expect(Object.isFrozen(compilerCarryingSpecifiers)).toBe(true); + }); + + it('matches specifiers exactly and leaves bundle-safe entries and other packages alone', () => { + const clean = scan(lines( + "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 { 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 () => [appResourceUri, launchEnv, spawnServeApp, deeper, z, local, later];', + )); + expect(clean).toEqual([]); + expect(Object.isFrozen(clean)).toBe(true); + }); + + it('reports every compiler-carrying entry when imported as a value', () => { + for (const specifier of compilerCarryingSpecifiers) { + expect(specifiersOf(scan(`import ${JSON.stringify(specifier)};\n`))).toEqual([`side-effect ${specifier}`]); + } + }); +}); + +describe('import forms', () => { + it('reports a side-effect import', () => { + expect(scan("import 'agent-bundle/api';\n")).toEqual([ + { form: 'side-effect', importer: route, specifier: 'agent-bundle/api' }, + ]); + }); + + it('reports a literal dynamic import anywhere in the module, including inside an async default export', () => { + const findings = scan(lines( + "import { z } from 'zod';", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ url: z.string() }).strict();', + 'export default async function dashboard() {', + " const { serveApp } = await import('agent-bundle/api');", + " const served = await serveApp({ app: 'curator/dashboard' });", + ' return { url: served.url };', + '}', + )); + expect(findings).toEqual([{ form: 'dynamic', importer: route, specifier: 'agent-bundle/api' }]); + // A substitution-free template literal is a literal the bundler resolves too. + expect(specifiersOf(scan('const api = () => import(`agent-bundle/api`);\n'))).toEqual(['dynamic agent-bundle/api']); + }); + + it('ignores a dynamic import with a non-literal argument', () => { + expect(scan(lines( + "const specifier = 'agent-bundle/api';", + 'export default async () => import(specifier);', + 'export const other = async (name: string) => import(`agent-bundle/${name}`);', + ))).toEqual([]); + }); + + it('reports value re-exports and skips type-only ones', () => { + expect(specifiersOf(scan("export { serveApp } from 'agent-bundle/api';\n"))).toEqual(['reexport agent-bundle/api']); + expect(specifiersOf(scan("export * from 'agent-bundle/api';\n"))).toEqual(['reexport agent-bundle/api']); + expect(specifiersOf(scan("export * as api from 'agent-bundle/api';\n"))).toEqual(['reexport agent-bundle/api']); + expect(specifiersOf(scan("export { type ServeAppOptions, serveApp } from 'agent-bundle/api';\n"))).toEqual(['reexport agent-bundle/api']); + expect(scan("export type { ServeAppOptions } from 'agent-bundle/api';\n")).toEqual([]); + expect(scan("export { type ServeAppOptions, type ServeAppHandle } from 'agent-bundle/api';\n")).toEqual([]); + expect(scan("export {} from 'agent-bundle/api';\n")).toEqual([]); + }); + + it('never reports import type or an import whose every specifier is type-qualified', () => { + expect(scan(lines( + "import type { ToolConfig, ToolRouteProps } from 'agent-bundle';", + "import type Api from 'agent-bundle/api';", + "import type * as Config from 'agent-bundle/config';", + "import { type Suite, type Trial } from 'agent-bundle/eval';", + "import {} from 'agent-bundle/test';", + 'export const config = { description: "x" } satisfies ToolConfig;', + 'export default async ({ input }: ToolRouteProps) => [input, null as unknown as Api, null as unknown as Config.Options, null as Trial | null];', + ))).toEqual([]); + }); +}); + +describe('static import value positions', () => { + const rendered = '/project/src/cli/dashboard.tsx'; + const staticImport = (body: string, clause = '{ serveApp }', source = route): readonly FrameworkValueImport[] => + scan(`import ${clause} from 'agent-bundle/api';\n${body}\n`, {}, source); + const reported = (body: string, clause?: string, source?: string): boolean => + staticImport(body, clause, source).length === 1; + + it('reports a binding read in a value position', () => { + expect(staticImport('export default async () => serveApp({ app: "x" });')).toEqual([ + { form: 'static', importer: route, specifier: 'agent-bundle/api' }, + ]); + expect(reported('const run = serveApp;')).toBe(true); + expect(reported('export default serveApp;')).toBe(true); + expect(reported('const value = typeof serveApp;')).toBe(true); + expect(reported('const handle = new serveApp();')).toBe(true); + expect(reported('const uri = serveApp.url;')).toBe(true); + expect(reported('const uri = serveApp?.url;')).toBe(true); + expect(reported('const entry = registry[serveApp];')).toBe(true); + expect(reported('const keyed = { [serveApp]: 1 };')).toBe(true); + expect(reported('enum Modes { Serve = serveApp }')).toBe(true); + expect(reported('const tagged = serveApp`template`;')).toBe(true); + expect(reported('const dashboard = ;', '{ ServeApp }', rendered)).toBe(true); + expect(reported('const dashboard = ;', '{ ServeApp }', rendered)).toBe(true); + expect(reported('const element =
;', '{ serveApp }', rendered)).toBe(true); + expect(reported('const { url = serveApp } = {};')).toBe(true); + expect(reported('@serveApp class Decorated {}')).toBe(true); + }); + + it('treats a shorthand property, a local re-export, a class extends, and the value side of as/satisfies as value references', () => { + expect(reported('export const config = { serveApp };')).toBe(true); + expect(reported('export { serveApp };')).toBe(true); + expect(reported('export { serveApp as run };')).toBe(true); + expect(reported('export { serveApp as default };')).toBe(true); + expect(reported('class Dashboard extends serveApp {}', '{ serveApp }')).toBe(true); + expect(reported('class Dashboard extends serveApp {}', '{ serveApp }')).toBe(true); + expect(reported('const checked = serveApp satisfies unknown;')).toBe(true); + expect(reported('const cast = serveApp as unknown;')).toBe(true); + }); + + it('does not report a binding used only in type positions, with or without the type keyword', () => { + expect(reported('let options: serveApp;')).toBe(false); + expect(reported('let options: serveApp;')).toBe(false); + expect(reported('let handle: typeof serveApp;')).toBe(false); + expect(reported('let handle: ReturnType;')).toBe(false); + expect(reported('type Handle = serveApp;')).toBe(false); + expect(reported('interface Handle extends serveApp {}')).toBe(false); + expect(reported('interface Handle { open(): serveApp }')).toBe(false); + expect(reported('class Dashboard implements serveApp {}')).toBe(false); + expect(reported('class Dashboard extends Base {}')).toBe(false); + expect(reported('const run = (value: T) => value;')).toBe(false); + expect(reported('function open(options: serveApp): serveApp { return options; }')).toBe(false); + expect(reported('const cast = value as serveApp;')).toBe(false); + expect(reported('const checked = value satisfies serveApp;')).toBe(false); + expect(reported('const asserted = (value: unknown): value is serveApp => true;')).toBe(false); + expect(reported("let api: typeof import('agent-bundle/api');")).toBe(false); + expect(reported("let handle: import('agent-bundle/api').ServeAppHandle;")).toBe(false); + expect(reported('declare const ambient: serveApp;')).toBe(false); + expect(reported('declare class Ambient extends serveApp {}')).toBe(false); + expect(reported('export type { serveApp };')).toBe(false); + expect(reported('export { type serveApp };')).toBe(false); + }); + + it('does not count a property, member, or declaration name that merely spells the binding', () => { + expect(reported('const uri = registry.serveApp;')).toBe(false); + expect(reported('const options = { serveApp: 1 };')).toBe(false); + expect(reported('const options = { serveApp() { return 1; } };')).toBe(false); + expect(reported('const options = { get serveApp() { return 1; } };')).toBe(false); + expect(reported('class Dashboard { serveApp = 1; }')).toBe(false); + expect(reported('class Dashboard { serveApp() { return 1; } }')).toBe(false); + expect(reported('interface Dashboard { serveApp: string }')).toBe(false); + expect(reported('enum Dashboard { serveApp }')).toBe(false); + expect(reported('const { serveApp: local } = registry;')).toBe(false); + expect(reported('function open({ serveApp }: { serveApp: string }) { return 1; }')).toBe(false); + expect(reported('const element =
;', '{ serveApp }', rendered)).toBe(false); + // A lowercase JSX tag is an intrinsic element (`jsx("serveApp")`), never the binding. + expect(reported('const element = ;', '{ serveApp }', rendered)).toBe(false); + expect(reported("import { meta } from 'agent-bundle';\nconst here = import.meta.url;", '{ serveApp }')).toBe(false); + expect(reported('serveApp: for (const step of []) { break serveApp; }')).toBe(false); + }); + + it('reports default and namespace bindings by the same rule', () => { + expect(specifiersOf(staticImport('export default async () => api.serveApp();', '* as api'))).toEqual(['static agent-bundle/api']); + expect(staticImport('let options: api.ServeAppOptions;', '* as api')).toEqual([]); + expect(staticImport('let handle: typeof api.serveApp;', '* as api')).toEqual([]); + expect(specifiersOf(staticImport('export default async () => api();', 'api'))).toEqual(['static agent-bundle/api']); + expect(staticImport('let handle: api;', 'api')).toEqual([]); + expect(specifiersOf(staticImport('export default async () => api();', 'api, { type ServeAppOptions }'))).toEqual(['static agent-bundle/api']); + expect(staticImport('let handle: Api;', 'Api, { type ServeAppOptions }')).toEqual([]); + }); + + it('reports the specifier once when several bindings of one import are used', () => { + expect(specifiersOf(scan(lines( + "import { build, serveApp } from 'agent-bundle/api';", + "import { defineConfig } from 'agent-bundle/config';", + 'export default async () => [build, serveApp];', + 'export const unused: typeof defineConfig | undefined = undefined;', + )))).toEqual(['static agent-bundle/api']); + }); +}); + +describe('relative import graph', () => { + const helpers: Readonly> = { + '/project/src/cli/serve.ts': lines( + "import { serveApp } from 'agent-bundle/api';", + 'export const open = async () => serveApp({ app: "x" });', + ), + '/project/src/cli/serve-types.ts': lines( + "import { serveApp } from 'agent-bundle/api';", + 'export type Open = typeof serveApp;', + ), + '/project/src/cli/widgets/index.tsx': lines( + "import { build } from 'agent-bundle';", + 'export const Widget = () => build;', + ), + '/project/src/shared/leaf.ts': "import 'agent-bundle/test';\n", + '/project/src/shared/a.ts': lines( + "import { b } from './b.ts';", + "import { leaf } from './leaf.ts';", + 'export const a = () => [b, leaf];', + ), + '/project/src/shared/b.ts': lines( + "import { a } from './a.ts';", + "import { leaf } from './leaf.ts';", + 'export const b = () => [a, leaf];', + ), + '/project/src/cli/cycle-a.ts': "export { b } from './cycle-b.ts';\n", + '/project/src/cli/cycle-b.ts': "export { a } from './cycle-a.ts';\n", + '/project/src/cli/quiet.ts': "export const quiet = 1;\n", + }; + + it('follows a relative value import and names the helper as the importer', () => { + expect(scan(lines( + "import { open } from './serve.ts';", + 'export default async () => open();', + ), helpers)).toEqual([ + { form: 'static', importer: '/project/src/cli/serve.ts', specifier: 'agent-bundle/api' }, + ]); + }); + + it('follows every value-import form of a relative module, using the shared candidate order', () => { + // A `.js` spelling maps onto its TypeScript source; an extensionless directory resolves its index module. + expect(scan("import { open } from './serve.js';\nexport default open;\n", helpers)).toHaveLength(1); + expect(scan("import { Widget } from './widgets';\nexport default Widget;\n", helpers)).toEqual([ + { form: 'static', importer: '/project/src/cli/widgets/index.tsx', specifier: 'agent-bundle' }, + ]); + expect(scan("import './serve.ts';\n", helpers)).toHaveLength(1); + expect(scan("export * from './serve.ts';\n", helpers)).toHaveLength(1); + expect(scan("export { open } from './serve.ts';\n", helpers)).toHaveLength(1); + expect(scan("export * as serve from './serve.ts';\n", helpers)).toHaveLength(1); + expect(scan("export default async () => import('./serve.ts');\n", helpers)).toHaveLength(1); + }); + + it('does not follow a relative import the bundler would elide', () => { + expect(scan("import type { Open } from './serve.ts';\nexport default (open: Open) => open;\n", helpers)).toEqual([]); + expect(scan("import { open } from './serve.ts';\nexport default (run: typeof open) => run;\n", helpers)).toEqual([]); + expect(scan("export type { Open } from './serve.ts';\n", helpers)).toEqual([]); + expect(scan("import { type Open } from './serve.ts';\n", helpers)).toEqual([]); + }); + + it('applies the value rules inside the helper too', () => { + expect(scan("import type { Open } from './serve-types.ts';\nexport default (open: Open) => open;\n", helpers)).toEqual([]); + expect(scan("export * from './serve-types.ts';\n", helpers)).toEqual([]); + expect(scan("export * from './quiet.ts';\n", helpers)).toEqual([]); + }); + + it('terminates on import cycles and scans a shared module once', () => { + expect(scan("export * from './cycle-a.ts';\n", helpers)).toEqual([]); + expect(scan("import { a } from '../shared/a.ts';\nexport default a;\n", helpers)).toEqual([ + { form: 'side-effect', importer: '/project/src/shared/leaf.ts', specifier: 'agent-bundle/test' }, + ]); + }); + + it('ignores a relative import no candidate file satisfies', () => { + expect(scan("import { missing } from './missing.ts';\nexport default missing;\n", helpers)).toEqual([]); + }); + + it('lists the scanned module\'s own findings first, then helpers by importer, specifier, and form', () => { + const findings = scan(lines( + "import { open } from './serve.ts';", + "import { Widget } from './widgets';", + "import { z } from 'agent-bundle/test';", + "export * from 'agent-bundle/config';", + "import 'agent-bundle/config';", + 'export default async () => [open, Widget, z];', + ), helpers); + expect(findings).toEqual([ + { form: 'reexport', importer: route, specifier: 'agent-bundle/config' }, + { form: 'side-effect', importer: route, specifier: 'agent-bundle/config' }, + { form: 'static', importer: route, specifier: 'agent-bundle/test' }, + { form: 'static', importer: '/project/src/cli/serve.ts', specifier: 'agent-bundle/api' }, + { form: 'static', importer: '/project/src/cli/widgets/index.tsx', specifier: 'agent-bundle' }, + ]); + expect(findings.every((finding) => Object.isFrozen(finding))).toBe(true); + }); +}); + +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.'; + + it('reports one AB4837 naming the route, the specifier, and the executable', () => { + const diagnostics = validateRouteFrameworkImports( + lines( + "import { z } from 'zod';", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ url: z.string() }).strict();', + 'export default async function dashboard() {', + " const { serveApp } = await import('agent-bundle/api');", + " return { url: (await serveApp({ app: 'curator/dashboard' })).url };", + '}', + ), + 'src/cli/dashboard.ts', + route, + 'routed CLI executable', + ); + expect(diagnostics).toEqual([{ + code: 'AB4837', + message: 'Route module src/cli/dashboard.ts imports "agent-bundle/api" as a value; the routed CLI executable is self-contained and cannot bundle the compiler, so the build would fail deep inside the generated executable (an unresolvable compiler module or AB6005) instead of at this import.', + recovery, + severity: 'error', + sourcePath: route, + }]); + expect(Object.isFrozen(diagnostics)).toBe(true); + }); + + it('reports at most one diagnostic per module, the first finding in scan order', () => { + const diagnostics = validateRouteFrameworkImports( + lines( + "import 'agent-bundle/test';", + "import { defineConfig } from 'agent-bundle/config';", + "export * from 'agent-bundle/eval';", + 'export default async () => defineConfig({});', + ), + 'src/scripts/rebuild.ts', + '/project/src/scripts/rebuild.ts', + 'script executable', + ); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toBe( + 'Route module src/scripts/rebuild.ts imports "agent-bundle/config" as a value; the script executable is self-contained and cannot bundle the compiler, so the build would fail deep inside the generated executable (an unresolvable compiler module or AB6005) instead of at this import.', + ); + }); + + it('returns a frozen empty list for a module with no compiler-carrying value import', () => { + const diagnostics = validateRouteFrameworkImports( + "import type { ToolConfig } from 'agent-bundle';\nexport const config = {} satisfies ToolConfig;\nexport default async () => undefined;\n", + 'src/mcp/curator/tools/inspect.tsx', + '/project/src/mcp/curator/tools/inspect.tsx', + 'generated MCP server', + ); + expect(diagnostics).toEqual([]); + expect(Object.isFrozen(diagnostics)).toBe(true); + }); + + it('addresses layouts and providers by the subject the caller names', () => { + const [diagnostic] = validateRouteFrameworkImports( + "import { build } from 'agent-bundle/api';\nexport default ({ children }) => [build, children];\n", + 'src/layout.tsx', + '/project/src/layout.tsx', + 'generated executable', + 'Layout module', + ); + expect(diagnostic?.message).toBe( + 'Layout module src/layout.tsx imports "agent-bundle/api" as a value; the generated executable is self-contained and cannot bundle the compiler, so the build would fail deep inside the generated executable (an unresolvable compiler module or AB6005) instead of at this import.', + ); + expect(diagnostic?.sourcePath).toBe('/project/src/layout.tsx'); + }); +}); + +describe('validateRouteFrameworkImports through a relative helper', () => { + // The validator reads helpers from disk, so the fixture is a real tree. + const withProject = (files: Readonly>, run: (root: string) => T): T => { + const root = mkdtempSync(join(tmpdir(), 'agent-bundle-framework-imports-')); + try { + for (const [path, contents] of Object.entries(files)) { + const target = join(root, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + } + return run(root); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }; + + it('names the helper project-relative when it lies inside the project', () => { + withProject({ + 'project/src/cli/serve.ts': "import { serveApp } from 'agent-bundle/api';\nexport const open = () => serveApp;\n", + }, (root) => { + const [diagnostic] = validateRouteFrameworkImports( + "import { open } from './serve.ts';\nexport default async () => open();\n", + 'src/cli/dashboard.ts', + join(root, 'project', 'src', 'cli', 'dashboard.ts'), + 'routed CLI executable', + ); + expect(diagnostic?.message).toBe( + 'Route module src/cli/dashboard.ts imports "agent-bundle/api" as a value (via src/cli/serve.ts); the routed CLI executable is self-contained and cannot bundle the compiler, so the build would fail deep inside the generated executable (an unresolvable compiler module or AB6005) instead of at this import.', + ); + expect(diagnostic?.sourcePath).toBe(join(root, 'project', 'src', 'cli', 'dashboard.ts')); + }); + }); + + it('falls back to a route-relative spelling for a helper outside the project root', () => { + withProject({ + 'shared/serve.ts': "import { serveApp } from 'agent-bundle/api';\nexport const open = () => serveApp;\n", + }, (root) => { + const [diagnostic] = validateRouteFrameworkImports( + "import { open } from '../../../shared/serve.ts';\nexport default async () => open();\n", + 'src/cli/dashboard.ts', + join(root, 'project', 'src', 'cli', 'dashboard.ts'), + 'routed CLI executable', + ); + expect(diagnostic?.message).toContain('as a value (via ../../../shared/serve.ts);'); + }); + }); +}); diff --git a/packages/agent-bundle/tests/serve-app-command-spawn.test.ts b/packages/agent-bundle/tests/serve-app-command-spawn.test.ts new file mode 100644 index 000000000..931b6ffa9 --- /dev/null +++ b/packages/agent-bundle/tests/serve-app-command-spawn.test.ts @@ -0,0 +1,272 @@ +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 }; +}; + +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('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 new file mode 100644 index 000000000..c15215187 --- /dev/null +++ b/packages/agent-bundle/tests/serve-app-command.test.ts @@ -0,0 +1,400 @@ +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/rstest.integration-tests.ts b/rstest.integration-tests.ts index c6b3a3db8..19351c0aa 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -64,6 +64,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts', 'packages/agent-bundle/tests/rstest-meta-consumer.test.ts', 'packages/agent-bundle/tests/script-playground-service.test.ts', + 'packages/agent-bundle/tests/serve-app-command-spawn.test.ts', 'packages/agent-bundle/tests/serve-app.test.ts', 'packages/agent-bundle/tests/target-hook-contract.test.ts', 'packages/agent-bundle/tests/target-mcp-runtime.test.ts', @@ -145,6 +146,7 @@ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/packed-consumer-typescript.test.ts', 'packages/agent-bundle/tests/packed-host-install-proof.test.ts', 'packages/agent-bundle/tests/packed-native-smoke.test.ts', + 'packages/agent-bundle/tests/packed-serve-app-command.test.ts', 'packages/agent-bundle/tests/packed-stdio-projection.test.ts', 'packages/agent-bundle/tests/public-api-packed.test.ts', 'packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts', diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 74178c3ec..2473becca 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -720,28 +720,25 @@ the first-party CLI, the Workbench, tests, a plugin's own `package.json` scripts `.mjs` run from the checkout — and it needs `agent-bundle` resolvable where that process runs. Never call it from the MCP server shell. -A routed CLI command inside the plugin artifact cannot import it today. Routed CLI bins are +A routed CLI command inside the plugin artifact cannot import it. Routed CLI bins are self-contained (`bin/.mjs` in every host pack, `dist/bin/.js` in the package -build), so a route with `await import('agent-bundle/api')` makes the bundler inline the whole -compiler into the bin, where it fails on the framework's runtime-relative module references -(`Module not found: Can't resolve '../events'`); leaving the import external is -`AB6005 … uses unsupported specifier "agent-bundle/api"`, and a non-literal `import(spec)` is -`AB6005 … has a non-literal dynamic import`. A helper a routed command can use is tracked in -[#558](https://github.com/ScriptedAlchemy/agent-bundle/issues/558). - -The pattern that builds is a plain routed command that spawns `agent-bundle serve-app` as a child -process, as cargo-hauler's `hauler dashboard` does. It is a **checkout command**: it needs -`agent-bundle` under `node_modules` and the built `artifact/` beside the CLI, neither of which an -installed host pack has, so it says so instead of failing inside the child. +build), so a value import of `agent-bundle/api` — or of any other entry that carries the compiler: +`agent-bundle`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, +`agent-bundle/test`, `agent-bundle/test/browser` — would make the bundler inline the whole +compiler into the bin, where it fails on the framework's runtime-relative module references. The +route graph reports it first: `AB4837`, naming the file and the specifier, whether the route +imports the entry itself or through a helper it reaches by relative import, from `inspect`, +`validate`, `build`, and `dev`; `import type` and imports used only as types are not reported +(see the [diagnostics reference](../../reference/diagnostics.md)). The sanctioned shape is +`spawnServeApp` from `agent-bundle/serve-app-command`: a dependency-free entry that spawns +`agent-bundle serve-app` as a child process, so the framework stays in its own process and the +host packs stay self-contained. This is what cargo-hauler's `hauler dashboard` does, and the +resolution of [#558](https://github.com/ScriptedAlchemy/agent-bundle/issues/558): ```ts -// src/cli/dashboard.ts — `hauler dashboard`: open the App against the running daemon. -import { spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - +// src/cli/dashboard.ts — `hauler dashboard`: open the App against the plugin's own server. import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { ServeAppCommandError, spawnServeApp } from 'agent-bundle/serve-app-command'; import { z } from 'zod'; export const config = { @@ -749,7 +746,10 @@ export const config = { exitCode: 'result', } satisfies CliRouteConfig; -export const inputSchema = z.object({ noOpen: z.boolean().optional() }).strict(); +export const inputSchema = z.object({ + noOpen: z.boolean().optional(), + port: z.number().int().min(0).max(65_535).optional(), +}).strict(); export const resultSchema = z.object({ exitCode: z.number().int(), @@ -757,76 +757,70 @@ export const resultSchema = z.object({ url: z.string().nullable(), }).strict(); -// The framework CLI, read from the `bin` of the nearest `node_modules/agent-bundle` at or -// above the plugin root. Located by path, never imported: an `import()` of the package -// would pull the framework into the bin. -const frameworkCli = (root: string): string | undefined => { - for (let directory = root; ; directory = dirname(directory)) { - const manifestPath = join(directory, 'node_modules', 'agent-bundle', 'package.json'); - if (existsSync(manifestPath)) { - const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8')) as { - bin?: string | Record; +export default async function dashboard({ input, signal }: CliRouteProps) { + let served; + try { + served = await spawnServeApp({ + app: 'hauler/dashboard', + root: process.cwd(), + artifact: 'artifact', + tool: 'hauler_status', + autoApprove: ['call-tool'], + open: input.noOpen !== true, + ...(input.port === undefined ? {} : { port: input.port }), + // Ctrl-C reaching the routed CLI stops the server. + signal, + }); + } catch (error) { + if (error instanceof ServeAppCommandError) { + // framework-not-installed, artifact-missing, exited-before-ready, ... + return { + exitCode: 1, + message: `${error.message} In an MCP host, call hauler_status instead.`, + url: null, }; - const relative = typeof bin === 'string' ? bin : bin?.['agent-bundle']; - if (relative === undefined) return undefined; - return resolve(dirname(manifestPath), relative); } - if (directory === dirname(directory)) return undefined; + throw error; } -}; - -export default async function dashboard({ input, signal }: CliRouteProps) { - // `dist/bin/.js` sits two levels under the checkout, which holds `artifact/`. - const root = fileURLToPath(new URL('../../', import.meta.url)); - const cli = frameworkCli(root); - const artifact = join(root, 'artifact'); - if (cli === undefined || !existsSync(join(artifact, 'agent-bundle.manifest.json'))) { - return { - exitCode: 1, - message: 'hauler dashboard runs from the plugin checkout (pnpm install, then ' - + 'agent-bundle build); in an MCP host, call hauler_status instead.', - url: null, - }; - } - return new Promise>((done, fail) => { - const child = spawn(process.execPath, [ - cli, 'serve-app', 'hauler/dashboard', '--root', root, - '--artifact', artifact, '--target', 'portable', - '--tool', 'hauler_status', '--allow', 'call-tool', - input.noOpen === true ? '--no-open' : '--open', - ], { stdio: ['ignore', 'pipe', 'inherit'] }); - let url: string | null = null; - let pending = ''; - child.stdout.on('data', (chunk: Buffer) => { - // The child prints `MCP App at (…)`; relay it to stderr so the routed - // CLI keeps stdout for its JSON result, and parse whole lines only — one write can - // arrive split across chunks. - const text = chunk.toString('utf8'); - process.stderr.write(text); - pending += text; - const lines = pending.split('\n'); - pending = lines.pop() ?? ''; - for (const line of lines) { - url ??= /\bat (https?:\/\/\S+)/u.exec(line)?.[1] ?? null; - } - }); - // Ctrl-C reaching the routed CLI becomes the child's SIGTERM. - signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true }); - child.once('error', fail); - child.once('exit', (code) => done({ - exitCode: code ?? 1, - message: code === 0 - ? 'dashboard closed' - : `agent-bundle serve-app exited with ${String(code)}`, - url, - })); - }); + 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}`, + url: served.url, + }; } ``` -Every `serve-app` option — `--port`, `--input`, `--profile`, `--env-file`, `--plugin-root` — -passes through as argv, and the host packs stay self-contained because the framework is spawned, -never bundled. +`spawnServeApp` lowers its options to `agent-bundle serve-app` argv (`serveAppArgv`), resolves +the framework CLI from the `agent-bundle` package installed at or above `root` +(`locateFrameworkCli`), spawns it under the current Node binary, relays every line of the child's +stdout to this process's stderr — or to a `relay` callback — so the routed command keeps stdout +for its own JSON result, and resolves once the child prints its ready line, +`MCP App at (tool ; Ctrl-C stops the server)`. The child's diagnostics stay on +stderr. The result carries `app`, `url`, `port`, `tool`, `server`, `pid`, a `closed` promise +that settles with the child's exit `{ code, signal }`, and `close()`, which sends `SIGTERM` and +waits for that exit; aborting the `signal` does the same, so Ctrl-C reaching the routed command +reaches the server. Failures reject with `ServeAppCommandError`, whose `code` is +`framework-not-installed` (no `node_modules/agent-bundle` resolves from `root`), +`artifact-missing` (the given `artifact` path does not exist), `spawn-failed`, +`exited-before-ready` (the child exited without printing the ready line; `error.exit` holds its +`{ code, signal }`), or `aborted` (the `signal` aborted before the App was served). On Ctrl-C the +routed CLI shell reports `Aborted.` and exits `130` before the route returns, so the result +document above is printed only when the server exits on its own or the route calls `close()`. + +It is a **checkout command**: it needs `agent-bundle` installed at or above `root` and a built +artifact. The published plugin package and an installed host pack have neither, and +`framework-not-installed` and `artifact-missing` say so before anything is spawned. Every +`serveApp` option with an argv form passes through — `app`, `root`, `artifact`, `autoApprove` +(the `--allow` vocabulary: `call-tool`, `download-file`, `open-external-link`, +`request-display-mode`), `configPath`, `envFiles`, `input`, `loadEnvFiles`, `mode`, `open`, +`pluginRoot`, `port`, `profile`, `target`, `tool` — and an unset option takes the CLI default +(`--target portable`, `--profile portable`, `--mode production`, the App's only tool, no browser). +Relative paths resolve as the CLI resolves them: `configPath` against `root`, `artifact` and +`envFiles` against the working directory. The `serveApp` options with no argv form — `logger`, +`registry`, `openBrowser`, `targets`, `timeoutMs` — stay with `serveApp` in host processes. ## Server modes diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index e7e71f095..b32a40a64 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -35,7 +35,15 @@ is the bundler's own output, so only the ESM lexer runs over it, which rejects u strings, templates, comments, and regexps and unbalanced braces. A module the framework did not compile — a copied consumer script, a generated installer — is parsed in full, and so is every bundle of a build whose [`tools` hatch](../../reference/configuration.mdx#tools) could have rewritten the -emitted assets. Prebuilt payloads (`kind: 'prebuilt'`) stay opaque and hash-locked only. +emitted assets. Prebuilt payloads (`kind: 'prebuilt'`) stay opaque and hash-locked only. The +route graph guards the same self-containment before the bundler runs: a route module, layout, or +provider — or a module one of them reaches through relative imports — that 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`) is +reported as `AB4837` from `inspect`, `validate`, `build`, and `dev`, naming the file and the +specifier, instead of failing inside the bundler; `import type` and imports used only as types are +not reported, and a routed command that needs the framework serves an App through +[`spawnServeApp`](../authoring/mcp.mdx#serving-an-app-standalone) instead. Every diagnostic is one structured record: a stable `AB` code, a severity, a message, and usually a `sourcePath` and a `recovery` hint. The diagnostic-gated commands — `build`, `prepack`, diff --git a/website/docs/en/reference/api.mdx b/website/docs/en/reference/api.mdx index 95b3f1fb4..49efb0768 100644 --- a/website/docs/en/reference/api.mdx +++ b/website/docs/en/reference/api.mdx @@ -14,7 +14,7 @@ Every public entry point is documented from its declarations: | Entry point | Contents | | --- | --- | | `agent-bundle` | The authoring and orchestration surface: `defineSkill`, `canonicalAgentEvents`, `startDevServer`, `runEvals`, `compareEvals`, the eval harness factories, and the artifact-manifest helpers. | -| `agent-bundle/api` | The programmatic compiler: `build`, `validate`, `inspect`, `prepack`, their option and result types, the `AgentComponentKind` / `componentKindCapability` component-kind helpers, and the artifact operations `listMcp`, `invokeMcp`, `runMcp`, `serveApp` (a built MCP App served standalone in a browser — a host-process API for scripts and tests; a routed CLI command inside the artifact cannot import it, see [Serving an App standalone](../guide/authoring/mcp.mdx#serving-an-app-standalone)), `listHooks`, and `simulateHook`. | +| `agent-bundle/api` | The programmatic compiler: `build`, `validate`, `inspect`, `prepack`, their option and result types, the `AgentComponentKind` / `componentKindCapability` component-kind helpers, and the artifact operations `listMcp`, `invokeMcp`, `runMcp`, `serveApp` (a built MCP App served standalone in a browser — a host-process API for scripts and tests; a routed CLI command inside the artifact uses `spawnServeApp` from `agent-bundle/serve-app-command` instead, see [Serving an App standalone](../guide/authoring/mcp.mdx#serving-an-app-standalone)), `listHooks`, and `simulateHook`. | | `agent-bundle/config` | `defineConfig` and the configuration types. | | `agent-bundle/test` | The route-testing harness, matchers, and contract matrices. | | `agent-bundle/test/browser` | The MCP App bridge harness for browser-rendered views. | @@ -25,6 +25,7 @@ Every public entry point is documented from its declarations: | `agent-bundle/cli-entry` | The routed-CLI shell every generated CLI executable is built on. | | `agent-bundle/mcp-entry` | The stdio MCP entry shell every generated MCP server is wrapped in. | | `agent-bundle/launch-env` | The operator `.env` layer every emitted shell applies at launch (`applyOperatorEnv`, `parseOperatorEnv`, `AGENT_BUNDLE_ENV_FILE`), for hand-rolled entries that want the same behavior. | +| `agent-bundle/serve-app-command` | Plain Node, no dependencies: `spawnServeApp`, `serveAppArgv`, `locateFrameworkCli`, `parseServeAppReadyLine`, `serveAppAllowCapabilities`, `ServeAppCommandError`, and their types — a routed CLI command (or any other generated executable) serves a built MCP App by spawning `agent-bundle serve-app` instead of importing `serveApp`. | | `agent-bundle/routes` | The route-module authoring types (`AgentEventRouteProps`, `ToolRouteProps`, `CliRouteProps`, the `config` shapes) and `appResourceUri`, the static reference to a sibling MCP App the compiler resolves to its `resourceUri`. | Because it is generated, it always matches the shipped types: signatures, unions, defaults, and diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 2a6015505..83ca1a927 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -79,7 +79,13 @@ Serves one built MCP App in a plain browser tab, outside any MCP host and withou Workbench: the command launches the plugin's packed MCP server exactly as `mcp run` does, binds the App to it through the same host stack the Workbench MCP page uses (sandbox proxy, consent authority, bridge), calls the App's tool once so it opens populated, and prints the loopback URL. -It runs in the foreground until a termination signal or until the server exits on its own. +Its output is a contract: once the host is listening, stdout carries exactly one line, +`MCP App at (tool ; Ctrl-C stops the server)` — `` as given on the command +line, `` the tool that opened the App — and every diagnostic goes to stderr. The command +then runs in the foreground: `SIGINT` or `SIGTERM` closes the host and its server and exits `0`; +when the bound MCP server exits on its own, one `AB5000` diagnostic is written to stderr and the +command exits `1`. `spawnServeApp` in `agent-bundle/serve-app-command` waits on the same line +(`parseServeAppReadyLine`), so a routed command can rely on it. | Option | Default | Meaning | | --- | --- | --- | @@ -100,8 +106,9 @@ routes (a per-launch token plus same-origin checks; `AB8003` / `AB8004` on refus only the selected server through the bridge. The App document itself runs on a second loopback origin inside the framework's sandbox. It is a local preview host, not a deployment target. The programmatic form for scripts and tests is `serveApp` in `agent-bundle/api`; a plugin's own routed -CLI command spawns this command instead, because the self-contained bin cannot import -`agent-bundle/api` — see +CLI command uses `spawnServeApp` from `agent-bundle/serve-app-command`, which runs this command as +a child process with the same options lowered to argv, because the self-contained bin cannot +import `agent-bundle/api` (the route graph reports the attempt as `AB4837`) — see [Serving an App standalone](../guide/authoring/mcp.mdx#serving-an-app-standalone). ## build and prepack diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 1635c2492..d955f5097 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -641,27 +641,22 @@ npx agent-bundle serve-app status/status --artifact artifact --input '{"service" `package.json` 脚本或从 checkout 里运行的手写 `.mjs`——并且要求 `agent-bundle` 在该进程运行之处可被 解析。绝不要在 MCP 服务器外壳里调用它。 -插件产物内部的路由式 CLI 命令今天无法导入它。路由式 CLI 的 bin 是自包含的(每个宿主包里的 -`bin/.mjs`,以及 package build 里的 `dist/bin/.js`),因此一条写着 -`await import('agent-bundle/api')` 的路由会让打包器把整个编译器内联进 bin,并在框架的运行时相对模块引用上 -失败(`Module not found: Can't resolve '../events'`);把该导入留作外部依赖会得到 -`AB6005 … uses unsupported specifier "agent-bundle/api"`,而非字面量的 `import(spec)` 则是 -`AB6005 … has a non-literal dynamic import`。可供路由式命令使用的辅助函数在 -[#558](https://github.com/ScriptedAlchemy/agent-bundle/issues/558) 中跟踪。 - -能够构建的模式,是一条普通的路由式命令把 `agent-bundle serve-app` 作为子进程启动——cargo-hauler 的 -`hauler dashboard` 正是这么做的。它是一条 **checkout 命令**:需要 `node_modules` 下的 `agent-bundle` -和 CLI 旁边已构建的 `artifact/`,而已安装的宿主包两者都没有,所以它直接说明这一点,而不是在子进程里 -失败。 +插件产物内部的路由式 CLI 命令无法导入它。路由式 CLI 的 bin 是自包含的(每个宿主包里的 +`bin/.mjs`,以及 package build 里的 `dist/bin/.js`),因此对 `agent-bundle/api`——或对任何 +其他携带编译器的入口:`agent-bundle`、`agent-bundle/config`、`agent-bundle/eval`、`agent-bundle/rstest`、 +`agent-bundle/test`、`agent-bundle/test/browser`——的值导入,都会让打包器把整个编译器内联进 bin,并在 +框架的运行时相对模块引用上失败。路由图会先一步报告它:`AB4837`,点名文件与说明符,无论该路由是自己 +导入了该入口,还是经由一个通过相对导入触达的辅助模块导入的,`inspect`、`validate`、`build` 与 `dev` 都会 +报告;`import type` 以及仅作为类型使用的导入不会被报告(见[诊断参考](../../reference/diagnostics.md))。 +正式支持的形态是 `agent-bundle/serve-app-command` 中的 `spawnServeApp`:一个无依赖的入口,把 +`agent-bundle serve-app` 作为子进程启动,让框架留在自己的进程里,宿主包保持自包含。cargo-hauler 的 +`hauler dashboard` 正是这么做的,这也是 [#558](https://github.com/ScriptedAlchemy/agent-bundle/issues/558) +的解决方案: ```ts -// src/cli/dashboard.ts —— `hauler dashboard`:针对正在运行的守护进程打开 App。 -import { spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - +// src/cli/dashboard.ts —— `hauler dashboard`:针对插件自己的服务器打开 App。 import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { ServeAppCommandError, spawnServeApp } from 'agent-bundle/serve-app-command'; import { z } from 'zod'; export const config = { @@ -669,7 +664,10 @@ export const config = { exitCode: 'result', } satisfies CliRouteConfig; -export const inputSchema = z.object({ noOpen: z.boolean().optional() }).strict(); +export const inputSchema = z.object({ + noOpen: z.boolean().optional(), + port: z.number().int().min(0).max(65_535).optional(), +}).strict(); export const resultSchema = z.object({ exitCode: z.number().int(), @@ -677,73 +675,64 @@ export const resultSchema = z.object({ url: z.string().nullable(), }).strict(); -// 框架 CLI:从插件根目录向上找到最近的 `node_modules/agent-bundle`,读取其 `bin`。 -// 按路径定位、绝不导入:对该包做 `import()` 会把框架拖进 bin。 -const frameworkCli = (root: string): string | undefined => { - for (let directory = root; ; directory = dirname(directory)) { - const manifestPath = join(directory, 'node_modules', 'agent-bundle', 'package.json'); - if (existsSync(manifestPath)) { - const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8')) as { - bin?: string | Record; +export default async function dashboard({ input, signal }: CliRouteProps) { + let served; + try { + served = await spawnServeApp({ + app: 'hauler/dashboard', + root: process.cwd(), + artifact: 'artifact', + tool: 'hauler_status', + autoApprove: ['call-tool'], + open: input.noOpen !== true, + ...(input.port === undefined ? {} : { port: input.port }), + // 抵达路由式 CLI 的 Ctrl-C 会停止服务器。 + signal, + }); + } catch (error) { + if (error instanceof ServeAppCommandError) { + // framework-not-installed、artifact-missing、exited-before-ready…… + return { + exitCode: 1, + message: `${error.message} In an MCP host, call hauler_status instead.`, + url: null, }; - const relative = typeof bin === 'string' ? bin : bin?.['agent-bundle']; - if (relative === undefined) return undefined; - return resolve(dirname(manifestPath), relative); } - if (directory === dirname(directory)) return undefined; + throw error; } -}; - -export default async function dashboard({ input, signal }: CliRouteProps) { - // `dist/bin/.js` 位于 checkout 下两层;checkout 的 `artifact/` 已构建。 - const root = fileURLToPath(new URL('../../', import.meta.url)); - const cli = frameworkCli(root); - const artifact = join(root, 'artifact'); - if (cli === undefined || !existsSync(join(artifact, 'agent-bundle.manifest.json'))) { - return { - exitCode: 1, - message: 'hauler dashboard runs from the plugin checkout (pnpm install, then ' - + 'agent-bundle build); in an MCP host, call hauler_status instead.', - url: null, - }; - } - return new Promise>((done, fail) => { - const child = spawn(process.execPath, [ - cli, 'serve-app', 'hauler/dashboard', '--root', root, - '--artifact', artifact, '--target', 'portable', - '--tool', 'hauler_status', '--allow', 'call-tool', - input.noOpen === true ? '--no-open' : '--open', - ], { stdio: ['ignore', 'pipe', 'inherit'] }); - let url: string | null = null; - let pending = ''; - child.stdout.on('data', (chunk: Buffer) => { - // 子进程打印 `MCP App at (…)`;转发到 stderr,让路由式 CLI 的 stdout - // 留给 JSON 结果,并且只解析完整的行——一次写入可能被拆成多个 chunk 到达。 - const text = chunk.toString('utf8'); - process.stderr.write(text); - pending += text; - const lines = pending.split('\n'); - pending = lines.pop() ?? ''; - for (const line of lines) { - url ??= /\bat (https?:\/\/\S+)/u.exec(line)?.[1] ?? null; - } - }); - // 抵达路由式 CLI 的 Ctrl-C 变成子进程的 SIGTERM。 - signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true }); - child.once('error', fail); - child.once('exit', (code) => done({ - exitCode: code ?? 1, - message: code === 0 - ? 'dashboard closed' - : `agent-bundle serve-app exited with ${String(code)}`, - url, - })); - }); + 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}`, + url: served.url, + }; } ``` -`serve-app` 的每个选项——`--port`、`--input`、`--profile`、`--env-file`、`--plugin-root`——都以 argv -形式透传;宿主包保持自包含,因为框架是被启动的,而不是被打包进去的。 +`spawnServeApp` 把它的选项转换为 `agent-bundle serve-app` 的 argv(`serveAppArgv`),从安装在 `root` 或 +其上层的 `agent-bundle` 包中解析出框架 CLI(`locateFrameworkCli`),用当前的 Node 二进制启动它,把子进程 +stdout 的每一行转发到本进程的 stderr——或转发给一个 `relay` 回调——让路由式命令把 stdout 留给自己的 JSON +结果,并在子进程打印出就绪行 `MCP App at (tool ; Ctrl-C stops the server)` 时兑现。 +子进程的诊断留在 stderr 上。结果携带 `app`、`url`、`port`、`tool`、`server`、`pid`,一个随子进程退出 +`{ code, signal }` 落定的 `closed` promise,以及 `close()`——它发送 `SIGTERM` 并等待那次退出;中止 `signal` +的效果相同,因此抵达路由式命令的 Ctrl-C 会抵达服务器。失败以 `ServeAppCommandError` 拒绝,其 `code` 为 +`framework-not-installed`(从 `root` 解析不到任何 `node_modules/agent-bundle`)、`artifact-missing`(给定的 +`artifact` 路径不存在)、`spawn-failed`、`exited-before-ready`(子进程在打印就绪行之前就退出了;`error.exit` +保存着它的 `{ code, signal }`),或 `aborted`(`signal` 在 App 被提供之前就已中止)。按下 Ctrl-C 时, +路由式 CLI 外壳会在路由返回之前报告 `Aborted.` 并以 `130` 退出,因此上面的结果文档只在服务器自行退出或 +路由调用 `close()` 时才会打印。 + +它是一条 **checkout 命令**:需要安装在 `root` 或其上层的 `agent-bundle`,以及一份已构建的产物。已发布的 +插件包与已安装的宿主包两者都没有,`framework-not-installed` 与 `artifact-missing` 会在启动任何东西之前 +就说明这一点。每个有 argv 形式的 `serveApp` 选项都会透传——`app`、`root`、`artifact`、`autoApprove` +(即 `--allow` 的词汇表:`call-tool`、`download-file`、`open-external-link`、`request-display-mode`)、 +`configPath`、`envFiles`、`input`、`loadEnvFiles`、`mode`、`open`、`pluginRoot`、`port`、`profile`、`target`、 +`tool`——未设置的选项取 CLI 默认值(`--target portable`、`--profile portable`、`--mode production`、该 App +唯一的工具、不打开浏览器)。相对路径按 CLI 的解析方式解析:`configPath` 相对 `root`,`artifact` 与 +`envFiles` 相对工作目录。没有 argv 形式的 `serveApp` 选项——`logger`、`registry`、`openBrowser`、`targets`、 +`timeoutMs`——仍留给宿主进程中的 `serveApp`。 ## 服务器模式 diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 1294b31b1..15620f4ee 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -28,7 +28,12 @@ Node 内建模块(`node:fs`、`fs`),要么以相对或 `file:` 说明符 模块(清单 kind 为 `bundle`)是打包器自己的输出,因此只由 ESM 词法分析器扫描,它会拒绝未终止的字符串、模板、 注释与正则以及不配对的花括号。框架没有编译的模块——被复制的消费者脚本、生成的安装器——则会被完整解析;若一次 构建的 [`tools` 逃生口](../../reference/configuration.mdx#tools)有可能改写了输出资源,该构建的每个 bundle 也会被完整解析。 -预构建载荷(`kind: 'prebuilt'`)保持不透明,只做哈希锁定。 +预构建载荷(`kind: 'prebuilt'`)保持不透明,只做哈希锁定。路由图会在打包器运行之前守住同一份自包含性: +路由模块、布局或 provider——或它们之一通过相对导入触达的模块——若值导入了携带编译器的框架入口 +(`agent-bundle`、`agent-bundle/api`、`agent-bundle/config`、`agent-bundle/eval`、`agent-bundle/rstest`、 +`agent-bundle/test`、`agent-bundle/test/browser`),会由 `inspect`、`validate`、`build` 与 `dev` 报告为 +`AB4837`,点名文件与说明符,而不是在打包器内部失败;`import type` 以及仅作为类型使用的导入不会被报告, +需要框架的路由式命令则改为通过 [`spawnServeApp`](../authoring/mcp.mdx#独立提供-app) 提供 App。 每条诊断都是一份结构化记录:稳定的 `AB` 代码、一个严重级别、一条消息,通常还有 `sourcePath` 与一条 `recovery` 提示。由诊断把关的命令——`build`、`prepack`、`validate`、`doctor`、`install` 与 `dev`——只有 diff --git a/website/docs/zh/reference/api.mdx b/website/docs/zh/reference/api.mdx index 337145328..d752c04f8 100644 --- a/website/docs/zh/reference/api.mdx +++ b/website/docs/zh/reference/api.mdx @@ -13,7 +13,7 @@ description: '生成的 agent-bundle 类型 API:它覆盖哪些入口点、如 | 入口点 | 内容 | | --- | --- | | `agent-bundle` | 编写与编排表面:`defineSkill`、`canonicalAgentEvents`、`startDevServer`、`runEvals`、`compareEvals`、eval harness 工厂,以及产物清单辅助函数。 | -| `agent-bundle/api` | 程序化编译器:`build`、`validate`、`inspect`、`prepack`,及其选项与结果类型,`AgentComponentKind` / `componentKindCapability` 组件类型辅助,以及产物操作 `listMcp`、`invokeMcp`、`runMcp`、`serveApp`(在浏览器里独立提供一个已构建的 MCP App——面向脚本与测试的宿主进程 API;产物内部的路由式 CLI 命令无法导入它,见[独立提供 App](../guide/authoring/mcp.mdx#独立提供-app))、`listHooks` 与 `simulateHook`。 | +| `agent-bundle/api` | 程序化编译器:`build`、`validate`、`inspect`、`prepack`,及其选项与结果类型,`AgentComponentKind` / `componentKindCapability` 组件类型辅助,以及产物操作 `listMcp`、`invokeMcp`、`runMcp`、`serveApp`(在浏览器里独立提供一个已构建的 MCP App——面向脚本与测试的宿主进程 API;产物内部的路由式 CLI 命令则改用 `agent-bundle/serve-app-command` 中的 `spawnServeApp`,见[独立提供 App](../guide/authoring/mcp.mdx#独立提供-app))、`listHooks` 与 `simulateHook`。 | | `agent-bundle/config` | `defineConfig` 与配置类型。 | | `agent-bundle/test` | 路由测试 harness、匹配器与契约矩阵。 | | `agent-bundle/test/browser` | 面向浏览器渲染视图的 MCP App bridge harness。 | @@ -24,6 +24,7 @@ description: '生成的 agent-bundle 类型 API:它覆盖哪些入口点、如 | `agent-bundle/cli-entry` | 每个生成的 CLI 可执行文件所基于的路由式 CLI 外壳。 | | `agent-bundle/mcp-entry` | 每个生成的 MCP 服务器所包裹的 stdio MCP 入口外壳。 | | `agent-bundle/launch-env` | 每个输出外壳在启动时应用的操作者 `.env` 层(`applyOperatorEnv`、`parseOperatorEnv`、`AGENT_BUNDLE_ENV_FILE`),供希望获得同样行为的手写入口使用。 | +| `agent-bundle/serve-app-command` | 纯 Node、无依赖:`spawnServeApp`、`serveAppArgv`、`locateFrameworkCli`、`parseServeAppReadyLine`、`serveAppAllowCapabilities`、`ServeAppCommandError` 及其类型——路由式 CLI 命令(或任何其他生成的可执行文件)通过启动 `agent-bundle serve-app` 来提供一个已构建的 MCP App,而不是导入 `serveApp`。 | | `agent-bundle/routes` | 路由模块的编写类型(`AgentEventRouteProps`、`ToolRouteProps`、`CliRouteProps` 与各类 `config` 形状),以及 `appResourceUri`——对同级 MCP App 的静态引用,编译器会把它解析为该 App 的 `resourceUri`。 | 因为它是生成的,所以它始终与已交付的类型一致:签名、联合类型、默认值,以及各表面可能抛出的错误类。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index e74e56c53..50cf17dc0 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -77,7 +77,12 @@ agent-bundle serve-app / [--artifact ] [--tool ] [--inp 在一个普通浏览器标签页里独立提供一个已构建的 MCP App——不在任何 MCP 宿主之内,也不需要 Workbench:该命令以与 `mcp run` 完全相同的方式启动插件打包好的 MCP 服务器,通过 Workbench MCP 页面 所用的同一套宿主栈(沙箱代理、同意授权、桥接)把 App 绑定到它上面,先调用一次 App 的工具让它带着数据 -打开,然后打印 loopback URL。它在前台运行,直到收到终止信号,或服务器自行退出。 +打开,然后打印 loopback URL。它的输出是一份契约:宿主开始监听后,stdout 恰好携带一行 +`MCP App at (tool ; Ctrl-C stops the server)`——`` 为命令行上给出的原样,`` 为 +打开该 App 的工具——而每条诊断都写到 stderr。随后命令在前台运行:`SIGINT` 或 `SIGTERM` 会关闭宿主及其 +服务器并以 `0` 退出;当被绑定的 MCP 服务器自行退出时,一条 `AB5000` 诊断会写到 stderr,命令以 `1` 退出。 +`agent-bundle/serve-app-command` 中的 `spawnServeApp` 等待的正是同一行(`parseServeAppReadyLine`),因此 +路由式命令可以依赖它。 | 选项 | 默认值 | 含义 | | --- | --- | --- | @@ -96,8 +101,9 @@ Workbench:该命令以与 `mcp run` 完全相同的方式启动插件打包好 宿主只绑定 `127.0.0.1`,只提供一份文档与经过认证的 `/api/mcp/...` 路由(每次启动一个令牌,加同源检查; 拒绝时为 `AB8003` / `AB8004`),并且只通过桥接暴露所选的那个服务器。App 文档本身运行在框架沙箱内的第二个 loopback origin 上。它是本地预览宿主,不是部署目标。面向脚本与测试的编程形式是 `agent-bundle/api` 中的 -`serveApp`;插件自己的路由式 CLI 命令则改为启动这条命令,因为自包含的 bin 无法导入 `agent-bundle/api`——见 -[独立提供 App](../guide/authoring/mcp.mdx#独立提供-app)。 +`serveApp`;插件自己的路由式 CLI 命令则使用 `agent-bundle/serve-app-command` 中的 `spawnServeApp`,它把同样 +的选项转换为 argv,并把这条命令作为子进程运行,因为自包含的 bin 无法导入 `agent-bundle/api`(路由图会把这种 +尝试报告为 `AB4837`)——见[独立提供 App](../guide/authoring/mcp.mdx#独立提供-app)。 ## build 与 prepack diff --git a/website/rspress.config.ts b/website/rspress.config.ts index cd651b2b9..48014aa69 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -31,6 +31,7 @@ const publicApiEntryPoints = [ 'mcp-entry.ts', 'routes/public.ts', 'rstest/index.ts', + 'serve-app-command.ts', 'test/index.ts', 'test/browser.ts', ].map(entry => path.join(packageSource, entry)); From 6bc937b6992b7737abc3864980d233be16294d32 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 02:43:20 +0000 Subject: [PATCH 2/4] fix(routes): resolve AB4837 references through the binder, judge layouts/providers only when bundled; keep closed pending through post-spawn errors; changeset names the PR --- .changeset/558-serve-app-command.md | 2 +- docs/diagnostics.md | 2 +- .../src/routes/framework-imports.ts | 174 +++++++++--------- packages/agent-bundle/src/routes/graph.ts | 59 ++++-- .../agent-bundle/src/serve-app-command.ts | 18 +- .../tests/route-framework-imports.test.ts | 19 ++ .../agent-bundle/tests/route-graph.test.ts | 52 ++++++ .../tests/serve-app-command-spawn.test.ts | 48 +++++ 8 files changed, 261 insertions(+), 113 deletions(-) diff --git a/.changeset/558-serve-app-command.md b/.changeset/558-serve-app-command.md index 90cd336b0..00b0382cc 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`). 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 (#558) +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`). 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/docs/diagnostics.md b/docs/diagnostics.md index a30806042..5c5632710 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -820,7 +820,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `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. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | +| `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no generated tool, resource, prompt, CLI, or script route composes through (a worker imports only the layouts its routes reach), and a provider in a project with no generated executable at all. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/packages/agent-bundle/src/routes/framework-imports.ts b/packages/agent-bundle/src/routes/framework-imports.ts index 0dd2f6f4d..8b9821e7f 100644 --- a/packages/agent-bundle/src/routes/framework-imports.ts +++ b/packages/agent-bundle/src/routes/framework-imports.ts @@ -76,79 +76,50 @@ const scriptKindOf = (path: string): ts.ScriptKind => { const compareStrings = (left: string, right: string): number => (left < right ? -1 : left > right ? 1 : 0); /** - * Whether one identifier occurrence reads the binding of that name at run - * time. The bundler's SWC transform elides an import whose bindings are only - * ever used as types — with or without the `type` keyword — so an import - * counts as a value import only when some binding survives that elision. - * Every position that is a *name* rather than a reference (`foo.serveApp`, - * `{ serveApp: 1 }`, a declaration's own name, an import/export clause) is - * ruled out first; then any ancestor that makes the occurrence a type-level - * one (a type node, which covers `typeof x` type queries and `import('x')` - * type nodes, an `implements` clause, a `type`/`interface`/type-parameter - * declaration) or an ambient `declare` declaration rules it out too. Shadowing - * declarations in nested scopes are not modelled: a same-named local - * reference still counts, which errs toward reporting only in the - * import-then-shadow-then-type-only case. + * A checker over the one parsed module — no library, nothing resolved. Only + * the binder's scope chain is wanted, so that an identifier occurrence names + * the declaration it actually refers to: a parameter or local spelled like an + * import binding resolves to itself, not to the import. Built lazily, and + * only for a module in which some identifier spells an import binding. */ -const isValueReference = (identifier: ts.Identifier): boolean => { - const { parent } = identifier; - if (ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent)) return false; - if (ts.isExportSpecifier(parent)) return isLocalExportReference(identifier, parent); - if (ts.isPropertyAccessExpression(parent) && parent.name === identifier) return false; - if (ts.isMetaProperty(parent)) return false; - if (ts.isShorthandPropertyAssignment(parent)) return !isInTypeContext(parent); - if ( - (ts.isPropertyAssignment(parent) || - ts.isMethodDeclaration(parent) || - ts.isPropertyDeclaration(parent) || - ts.isPropertySignature(parent) || - ts.isEnumMember(parent) || - ts.isGetAccessorDeclaration(parent) || - ts.isSetAccessorDeclaration(parent) || - ts.isJsxAttribute(parent)) && - parent.name === identifier - ) { - return false; - } - if (ts.isBindingElement(parent) && (parent.propertyName === identifier || parent.name === identifier)) return false; - if (isDeclaredName(identifier, parent)) return false; - // A lowercase JSX tag is an intrinsic element, not a binding. - if ( - (ts.isJsxOpeningLikeElement(parent) || ts.isJsxClosingElement(parent)) && - parent.tagName === identifier && - /^[a-z]/u.test(identifier.text) - ) { - return false; - } - return !isInTypeContext(parent); -}; - -/** `export { x }` / `export { x as y }` without a module specifier reads the local binding `x`. */ -const isLocalExportReference = (identifier: ts.Identifier, specifier: ts.ExportSpecifier): boolean => { - const declaration = specifier.parent.parent; - if (specifier.isTypeOnly || declaration.isTypeOnly || declaration.moduleSpecifier !== undefined) return false; - return (specifier.propertyName ?? specifier.name) === identifier; +const singleModuleChecker = (sourceFile: ts.SourceFile): ts.TypeChecker => { + const host: ts.CompilerHost = { + fileExists: (path) => path === sourceFile.fileName, + getCanonicalFileName: (path) => path, + getCurrentDirectory: () => '', + getDefaultLibFileName: () => 'lib.d.ts', + getNewLine: () => '\n', + getSourceFile: (path) => (path === sourceFile.fileName ? sourceFile : undefined), + readFile: (path) => (path === sourceFile.fileName ? sourceFile.text : undefined), + useCaseSensitiveFileNames: () => true, + writeFile: () => undefined, + }; + const options: ts.CompilerOptions = { allowJs: true, noLib: true, noResolve: true, types: [] }; + return ts.createProgram([sourceFile.fileName], options, host).getTypeChecker(); }; -/** True when the identifier is the declared name of its parent, not a reference. */ -const isDeclaredName = (identifier: ts.Identifier, parent: ts.Node): boolean => { - if ( - ts.isVariableDeclaration(parent) || - ts.isParameter(parent) || - ts.isFunctionDeclaration(parent) || - ts.isFunctionExpression(parent) || - ts.isClassDeclaration(parent) || - ts.isClassExpression(parent) || - ts.isTypeAliasDeclaration(parent) || - ts.isInterfaceDeclaration(parent) || - ts.isEnumDeclaration(parent) || - ts.isModuleDeclaration(parent) || - ts.isTypeParameterDeclaration(parent) - ) { - return parent.name === identifier; +/** + * The declarations one identifier occurrence refers to at run time, per the + * binder — or none when the occurrence is not a reference at all: a property + * name (`foo.serveApp`, `{ serveApp: 1 }`), a declaration's own name, a + * label, an import clause, a type-only or remote export specifier. A + * shorthand property (`{ serveApp }`) and a local export (`export { serveApp + * as x }`) read the binding they spell, so those resolve to its declaration. + */ +const referencedDeclarations = (checker: ts.TypeChecker, identifier: ts.Identifier): readonly ts.Declaration[] => { + const { parent } = identifier; + if (ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent)) return []; + let symbol: ts.Symbol | undefined; + if (ts.isShorthandPropertyAssignment(parent) && parent.name === identifier) { + symbol = checker.getShorthandAssignmentValueSymbol(parent); + } else if (ts.isExportSpecifier(parent)) { + const declaration = parent.parent.parent; + if (parent.isTypeOnly || declaration.isTypeOnly || declaration.moduleSpecifier !== undefined) return []; + symbol = checker.getExportSpecifierLocalTargetSymbol(parent); + } else { + symbol = checker.getSymbolAtLocation(identifier); } - if (ts.isLabeledStatement(parent) || ts.isBreakOrContinueStatement(parent)) return parent.label === identifier; - return false; + return symbol?.declarations ?? []; }; /** `class C extends X`: the `X` expression is a value even though TypeScript types the node. */ @@ -183,24 +154,29 @@ const isInTypeContext = (start: ts.Node): boolean => { return false; }; -/** The local binding names an import declaration introduces as values (`type`-qualified specifiers excluded). */ -const valueBindingsOf = (clause: ts.ImportClause): readonly string[] => { +/** A binding an import clause declares: the default name, the namespace, or one named specifier. */ +type ImportBinding = ts.ImportClause | ts.NamespaceImport | ts.ImportSpecifier; + +/** The bindings an import declaration introduces as values (`type`-qualified specifiers excluded). */ +const valueBindingsOf = (clause: ts.ImportClause): readonly ImportBinding[] => { if (clause.isTypeOnly) return []; - const names: string[] = []; - if (clause.name !== undefined) names.push(clause.name.text); - const bindings = clause.namedBindings; - if (bindings !== undefined) { - if (ts.isNamespaceImport(bindings)) { - names.push(bindings.name.text); + const bindings: ImportBinding[] = []; + if (clause.name !== undefined) bindings.push(clause); + const named = clause.namedBindings; + if (named !== undefined) { + if (ts.isNamespaceImport(named)) { + bindings.push(named); } else { - for (const element of bindings.elements) { - if (!element.isTypeOnly) names.push(element.name.text); + for (const element of named.elements) { + if (!element.isTypeOnly) bindings.push(element); } } } - return names; + return bindings; }; +const importBindingName = (binding: ImportBinding): string => binding.name!.text; + /** Whether a re-export declaration emits JavaScript (SWC keeps every specifier not marked `type`). */ const isValueReExport = (declaration: ts.ExportDeclaration): boolean => { if (declaration.isTypeOnly) return false; @@ -212,14 +188,32 @@ const isValueReExport = (declaration: ts.ExportDeclaration): boolean => { const moduleSpecifierText = (expression: ts.Expression | undefined): string | undefined => expression !== undefined && ts.isStringLiteralLike(expression) ? expression.text : undefined; -/** Every identifier text in `names` that some value position of the module reads. */ -const referencedValueBindings = (sourceFile: ts.SourceFile, names: ReadonlySet): Set => { - const referenced = new Set(); +/** + * The import bindings among `bindings` that some value position of the module + * reads. The bundler's SWC transform elides an import whose bindings are only + * ever used as types — with or without the `type` keyword — so an import + * counts as a value import only when some binding survives that elision. + * An occurrence counts when no ancestor makes it type-level or ambient (a + * type node, which covers `typeof x` type queries and `import('x')` type + * nodes; an `implements` clause; a `type`/`interface`/type-parameter + * declaration; a `declare` declaration) and the binder resolves it to the + * import rather than to a same-named parameter or local. + */ +const referencedImportBindings = ( + sourceFile: ts.SourceFile, + bindings: ReadonlySet, +): Set => { + const names = new Set([...bindings].map(importBindingName)); + const referenced = new Set(); + let checker: ts.TypeChecker | undefined; const visit = (node: ts.Node): void => { - if (ts.isIdentifier(node) && names.has(node.text) && !referenced.has(node.text) && isValueReference(node)) { - referenced.add(node.text); + if (ts.isIdentifier(node) && names.has(node.text) && !isInTypeContext(node.parent)) { + checker ??= singleModuleChecker(sourceFile); + for (const declaration of referencedDeclarations(checker, node)) { + if ((bindings as ReadonlySet).has(declaration)) referenced.add(declaration as ImportBinding); + } } - if (referenced.size < names.size) ts.forEachChild(node, visit); + if (referenced.size < bindings.size) ts.forEachChild(node, visit); }; visit(sourceFile); return referenced; @@ -250,7 +244,7 @@ interface ValueImport { */ const valueImportsOf = (sourceFile: ts.SourceFile): readonly ValueImport[] => { const imports: ValueImport[] = []; - const staticBindings = new Map>(); + const staticBindings = new Map>(); for (const statement of sourceFile.statements) { if (ts.isImportDeclaration(statement)) { const specifier = moduleSpecifierText(statement.moduleSpecifier); @@ -261,7 +255,7 @@ const valueImportsOf = (sourceFile: ts.SourceFile): readonly ValueImport[] => { } const bindings = valueBindingsOf(statement.importClause); if (bindings.length === 0) continue; - const known = staticBindings.get(specifier) ?? new Set(); + const known = staticBindings.get(specifier) ?? new Set(); for (const binding of bindings) known.add(binding); staticBindings.set(specifier, known); continue; @@ -272,8 +266,8 @@ const valueImportsOf = (sourceFile: ts.SourceFile): readonly ValueImport[] => { } } if (staticBindings.size > 0) { - const names = new Set([...staticBindings.values()].flatMap((bindings) => [...bindings])); - const referenced = referencedValueBindings(sourceFile, names); + const all = new Set([...staticBindings.values()].flatMap((bindings) => [...bindings])); + const referenced = referencedImportBindings(sourceFile, all); for (const [specifier, bindings] of staticBindings) { if ([...bindings].some((binding) => referenced.has(binding))) imports.push({ form: 'static', specifier }); } diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 1cba9224e..ba81da8c1 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -25,7 +25,7 @@ import { } from './contract.ts'; import { validateRouteFrameworkImports } from './framework-imports.ts'; import { extractInputSchema } from './input-schema.ts'; -import { isLayoutRouteKind } from './layouts.ts'; +import { isLayoutRouteKind, layoutChainFor } from './layouts.ts'; import { providerKeyFromName } from './providers.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { digest } from '../core/digest.ts'; @@ -751,20 +751,12 @@ export const compileRouteGraph = async ( }); const layoutText = await readRouteModuleText(module.source); if (layoutText !== undefined) { + moduleTextBySource.set(module.source, layoutText); diagnostics.push(...validateLayoutModuleContract( layoutText, module.relativePath, module.source, )); - // A layout is inlined into every executable that renders the routes - // it wraps, so it bundles the compiler exactly as a route would. - diagnostics.push(...validateRouteFrameworkImports( - layoutText, - module.relativePath, - module.source, - 'generated executable', - 'Layout module', - )); } continue; } @@ -777,20 +769,12 @@ export const compileRouteGraph = async ( }); const providerText = await readRouteModuleText(module.source); if (providerText !== undefined) { + moduleTextBySource.set(module.source, providerText); diagnostics.push(...validateProviderModuleContract( providerText, module.relativePath, module.source, )); - // Providers mount in every generated request scope, so each - // executable inlines them. - diagnostics.push(...validateRouteFrameworkImports( - providerText, - module.relativePath, - module.source, - 'generated executable', - 'Provider module', - )); } continue; } @@ -1041,6 +1025,43 @@ export const compileRouteGraph = async ( } } + // AB4837 (#558) for layouts and providers, judged once the generated + // surfaces are known. A worker imports only the layouts some route it + // renders composes through (`workerLayouts` in build/entry-shell.ts), so a + // layout no generated rendered route reaches — a root layout in a project + // of Apps and event routes, a server layout of a custom server — is never + // bundled and is not judged. Providers mount in every generated request + // scope, so they are judged as soon as one generated executable exists. + const renderedRoutes = [ + ...servers.filter((server) => server.mode === 'generated').flatMap((server) => server.routes), + ...(cli?.mode === 'generated' ? cli.routes : []), + ...scripts, + ]; + for (const layout of layouts) { + const layoutText = moduleTextBySource.get(layout.source); + if (layoutText === undefined || !renderedRoutes.some((route) => layoutChainFor(route, [layout]).length > 0)) continue; + diagnostics.push(...validateRouteFrameworkImports( + layoutText, + layout.provenance.relativePath, + layout.source, + 'generated executable', + 'Layout module', + )); + } + if (renderedRoutes.length > 0 || events.length > 0) { + for (const provider of providers) { + const providerText = moduleTextBySource.get(provider.source); + if (providerText === undefined) continue; + diagnostics.push(...validateRouteFrameworkImports( + providerText, + provider.provenance.relativePath, + provider.source, + 'generated executable', + 'Provider module', + )); + } + } + const identity = { ...(cli === undefined ? {} diff --git a/packages/agent-bundle/src/serve-app-command.ts b/packages/agent-bundle/src/serve-app-command.ts index 71f25a0f7..f28ed8045 100644 --- a/packages/agent-bundle/src/serve-app-command.ts +++ b/packages/agent-bundle/src/serve-app-command.ts @@ -261,6 +261,8 @@ export const spawnServeApp = async (options: SpawnServeAppOptions): Promise void = () => undefined; const closed = new Promise((resolveClosed) => { @@ -309,7 +311,7 @@ export const spawnServeApp = async (options: SpawnServeAppOptions): Promise { + child.once('spawn', () => { + spawned = true; + }); + child.on('error', (error) => { + // Before the process exists, `error` is the one notice Node gives and + // `close` may never follow: the spawn failed. Once the process runs, + // `error` reports a failed `kill()` and the process is still alive, so + // only its real `close` may settle `closed`; the error is kept as the + // cause should the child then exit before its ready line. + if (spawned) { + lateError = error; + return; + } finish( { code: null, signal: null }, new ServeAppCommandError('spawn-failed', `agent-bundle serve-app could not be started from ${cli}.`, { cause: error }), diff --git a/packages/agent-bundle/tests/route-framework-imports.test.ts b/packages/agent-bundle/tests/route-framework-imports.test.ts index f6e2b620f..0a9dfc329 100644 --- a/packages/agent-bundle/tests/route-framework-imports.test.ts +++ b/packages/agent-bundle/tests/route-framework-imports.test.ts @@ -205,6 +205,25 @@ describe('static import value positions', () => { expect(reported('serveApp: for (const step of []) { break serveApp; }')).toBe(false); }); + it('resolves a same-named parameter or local to its own declaration, not to the import', () => { + // The binding is used only as a type; the value read is the shadowing + // parameter, which SWC also resolves lexically, so the import is elided. + expect(reported('export default async ({ serveApp }: { serveApp: typeof serveApp }) => serveApp({ app: "x" });')).toBe(false); + expect(reported('export function open(serveApp: string) { return serveApp.length; }')).toBe(false); + expect(reported('export const open = () => { const serveApp = 1; return serveApp + 1; };')).toBe(false); + expect(reported('export const open = () => { function serveApp() { return 1; } return serveApp(); };')).toBe(false); + expect(reported('export const open = () => { try { return 1; } catch (serveApp) { return serveApp; } };')).toBe(false); + expect(reported('export const open = () => { for (const serveApp of []) { return serveApp; } };')).toBe(false); + expect(reported('let component: typeof ServeApp;\nexport function render(ServeApp: () => null) { return ; }', '{ ServeApp }', rendered)).toBe(false); + expect(reported('export function render(ServeApp: () => null) { return ; }\nexport const element = ;', '{ ServeApp }', rendered)).toBe(true); + // Outside the shadowing scope the same name is the import again. + expect(reported('export function open(serveApp: string) { return serveApp.length; }\nexport const run = serveApp;')).toBe(true); + expect(reported('export default async () => { { const serveApp = 1; } return serveApp({ app: "x" }); };')).toBe(true); + // `var` and function declarations hoist to their function scope. + expect(reported('export default async () => { if (Math.random()) { var serveApp = 1; } return serveApp; };')).toBe(false); + expect(reported('export default async () => serveApp();\nfunction serveApp() { return 1; }', '{ serveApp as api }')).toBe(false); + }); + it('reports default and namespace bindings by the same rule', () => { expect(specifiersOf(staticImport('export default async () => api.serveApp();', '* as api'))).toEqual(['static agent-bundle/api']); expect(staticImport('let options: api.ServeAppOptions;', '* as api')).toEqual([]); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 9ee91a7cd..64232de9b 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -1778,6 +1778,58 @@ it('validates layout modules with AB4830, duplicate scopes with AB4831, and orph expect(graph.servers.map((server) => server.name)).toEqual(['curator', 'panel']); }); +it('judges a layout or provider for AB4837 only when a generated executable bundles it', async () => { + // Value imports of the compiler (#558): the route graph reports them + // before the bundler would inline the compiler into a self-contained + // executable. A layout is inlined only into the workers of the rendered + // routes it wraps; a provider into every generated request scope. + const compilerImport = "import { serveApp } from 'agent-bundle/api';\n"; + const layout = `${compilerImport}export default ({ children }) => { void serveApp; return children; };\n`; + const provider = `${compilerImport}export default () => serveApp;\n`; + const codesBySource = (diagnostics: readonly { readonly code: string; readonly sourcePath?: string }[], root: string) => + diagnostics + .filter(({ code }) => code === 'AB4837') + .map(({ sourcePath }) => sourcePath?.slice(root.length + 1).replaceAll('\\', '/')) + .sort(); + + // A generated tool route composes through the root layout: both are judged. + const wrapped = await createRoot(); + await writeTree(wrapped, { + 'src/layout.tsx': layout, + 'src/mcp/curator/tools/inspect.tsx': moduleSource, + 'src/providers/git-worktree.ts': provider, + }); + const wrappedGraph = await compileRouteGraph(wrapped, fixtureConfig()); + expect(codesBySource(wrappedGraph.diagnostics, wrapped)).toEqual(['src/layout.tsx', 'src/providers/git-worktree.ts']); + expect(wrappedGraph.diagnostics.find(({ code }) => code === 'AB4837')!.message) + .toMatch(/^Layout module src\/layout\.tsx imports "agent-bundle\/api" as a value; the generated executable is self-contained/u); + + // Apps are browser builds and event routes take no layout, so nothing + // bundles the root layout — while the generated server and the hook + // wrapper still mount the provider. + const unwrapped = await createRoot(); + await writeTree(unwrapped, { + 'src/events/workspace/open.tsx': moduleSource, + 'src/layout.tsx': layout, + 'src/mcp/panel/apps/main.tsx': `export const config = { resourceUri: 'ui://panel/main.html' }; ${moduleSource}`, + 'src/providers/git-worktree.ts': provider, + }); + const unwrappedGraph = await compileRouteGraph(unwrapped, fixtureConfig()); + expect(codesBySource(unwrappedGraph.diagnostics, unwrapped)).toEqual(['src/providers/git-worktree.ts']); + + // A server layout of a server that is not generated wraps nothing that is + // bundled either, and with no generated executable at all the provider is + // never inlined. + const custom = await createRoot(); + await writeTree(custom, { + 'src/mcp/curator/layout.tsx': layout, + 'src/mcp/curator/tools/inspect.tsx': moduleSource, + 'src/providers/git-worktree.ts': provider, + }); + const customGraph = await compileRouteGraph(custom, fixtureConfig({ routes: { servers: { curator: 'custom' } } })); + expect(codesBySource(customGraph.diagnostics, custom)).toEqual([]); +}); + it('rejects provider key collisions and the reserved processLifetime key', async () => { const root = await createRoot(); const provider = 'export default () => undefined;\n'; diff --git a/packages/agent-bundle/tests/serve-app-command-spawn.test.ts b/packages/agent-bundle/tests/serve-app-command-spawn.test.ts index 931b6ffa9..cd73627cc 100644 --- a/packages/agent-bundle/tests/serve-app-command-spawn.test.ts +++ b/packages/agent-bundle/tests/serve-app-command-spawn.test.ts @@ -252,6 +252,54 @@ it('rejects spawn-failed with the cause whether spawn throws or the child report 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('carries a post-spawn error as the cause when the child then exits before its ready line', async () => { + const root = await temporaryDirectory(); + // The handler is installed before the noise line, so a SIGTERM that + // follows the line is never racing it. + const cli = await writeFakeCli(root, 'never-ready', [ + "process.on('SIGTERM', () => { process.exit(3); });", + "process.stdout.write('Building…\\n');", + 'setInterval(() => undefined, 60_000);', + ]); + 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'); From 96faea46a0dfac53b4a76e609697ab31d9174bae Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 02:57:30 +0000 Subject: [PATCH 3/4] fix(serve-app-command): reject stop-failed when the running child refuses the signal; judge layouts only for rendered routes and providers only where mounted --- .changeset/558-serve-app-command.md | 2 +- docs/diagnostics.md | 2 +- docs/entry-conventions.md | 4 +- packages/agent-bundle/src/routes/graph.ts | 32 +++++---- .../agent-bundle/src/serve-app-command.ts | 55 ++++++++++++--- .../agent-bundle/tests/route-graph.test.ts | 49 ++++++++++++++ .../tests/serve-app-command-spawn.test.ts | 67 +++++++++++++++++-- website/docs/en/guide/authoring/mcp.mdx | 4 +- website/docs/zh/guide/authoring/mcp.mdx | 3 +- 9 files changed, 183 insertions(+), 35 deletions(-) diff --git a/.changeset/558-serve-app-command.md b/.changeset/558-serve-app-command.md index 00b0382cc..d6981624a 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`). 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) +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) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5c5632710..9891d6dcb 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -820,7 +820,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `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 generated tool, resource, prompt, CLI, or script route composes through (a worker imports only the layouts its routes reach), and a provider in a project with no generated executable at all. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | +| `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/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`. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index f55e0c600..4f6fbc402 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1499,7 +1499,9 @@ 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`), or `aborted`. It is a +`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". diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index ba81da8c1..60544109e 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -5,6 +5,7 @@ import fastGlob from 'fast-glob'; import { conventionalEntryAt } from '../config/conventional-entry.ts'; import { isProjectPathIgnored, readProjectIgnoreRules, toPosixPath } from '../config/ignore.ts'; +import { isRenderedScriptRoute } from '../config/script-routes.ts'; import { resolveAppRouteTemplate } from './app-template.ts'; import { compileCliCommands, @@ -1026,20 +1027,27 @@ export const compileRouteGraph = async ( } // AB4837 (#558) for layouts and providers, judged once the generated - // surfaces are known. A worker imports only the layouts some route it - // renders composes through (`workerLayouts` in build/entry-shell.ts), so a - // layout no generated rendered route reaches — a root layout in a project - // of Apps and event routes, a server layout of a custom server — is never + // surfaces are known, against what the build inlines (build/entry-shell.ts, + // build/cli-bins.ts, build/entries.ts). A worker imports only the layouts + // some route it renders composes through (`workerLayouts`): the non-App + // routes of a generated server, the rendered commands of a generated CLI + // (plain `.ts` commands run without a render session), and rendered + // scripts. A layout none of them reaches — a root layout in a project of + // Apps and event routes, a server layout of a custom server — is never // bundled and is not judged. Providers mount in every generated request - // scope, so they are judged as soon as one generated executable exists. - const renderedRoutes = [ - ...servers.filter((server) => server.mode === 'generated').flatMap((server) => server.routes), - ...(cli?.mode === 'generated' ? cli.routes : []), - ...scripts, - ]; + // scope — a generated server, the routed CLI executable (plain commands + // too), a rendered script's worker, a hook wrapper — but a plain script is + // bundled from its own source and mounts none. + const generatedServers = servers.filter((server) => server.mode === 'generated'); + const renderedCommandRouteIds = new Set( + (cli?.mode === 'generated' ? cli.commands ?? [] : []).filter((command) => command.rendered).map((command) => command.routeId), + ); + const renderedCliRoutes = cli?.mode === 'generated' ? cli.routes.filter((route) => renderedCommandRouteIds.has(route.id)) : []; + const renderedScripts = scripts.filter(isRenderedScriptRoute); + const layoutRoutes = [...generatedServers.flatMap((server) => server.routes), ...renderedCliRoutes, ...renderedScripts]; for (const layout of layouts) { const layoutText = moduleTextBySource.get(layout.source); - if (layoutText === undefined || !renderedRoutes.some((route) => layoutChainFor(route, [layout]).length > 0)) continue; + if (layoutText === undefined || !layoutRoutes.some((route) => layoutChainFor(route, [layout]).length > 0)) continue; diagnostics.push(...validateRouteFrameworkImports( layoutText, layout.provenance.relativePath, @@ -1048,7 +1056,7 @@ export const compileRouteGraph = async ( 'Layout module', )); } - if (renderedRoutes.length > 0 || events.length > 0) { + if (generatedServers.length > 0 || cli?.mode === 'generated' || renderedScripts.length > 0 || events.length > 0) { for (const provider of providers) { const providerText = moduleTextBySource.get(provider.source); if (providerText === undefined) continue; diff --git a/packages/agent-bundle/src/serve-app-command.ts b/packages/agent-bundle/src/serve-app-command.ts index f28ed8045..008109dc3 100644 --- a/packages/agent-bundle/src/serve-app-command.ts +++ b/packages/agent-bundle/src/serve-app-command.ts @@ -111,20 +111,24 @@ export const serveAppArgv = (options: ServeAppArgvOptions): readonly string[] => }; /** - * Why `spawnServeApp` failed, as the error's `code`: + * 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. + * - `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'; + | 'aborted' + | 'stop-failed'; /** The exit of the `agent-bundle serve-app` process, as Node reports it. */ export interface ServeAppExit { @@ -203,7 +207,11 @@ export interface SpawnedServeApp extends ServeAppReadyLine { 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. */ + /** + * 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; } @@ -263,18 +271,37 @@ export const spawnServeApp = async (options: SpawnServeAppOptions): Promise void = () => undefined; const closed = new Promise((resolveClosed) => { resolveExit = resolveClosed; }); - const stop = (): void => { - if (settledExit === undefined) child.kill('SIGTERM'); + /** + * 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; + child.kill('SIGTERM'); + 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 () => { - stop(); + const failure = stop(); + if (failure !== undefined) throw failure; return closed; }, closed, @@ -289,12 +316,16 @@ export const spawnServeApp = async (options: SpawnServeAppOptions): Promise { - stop(); + 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); }; options.signal?.addEventListener('abort', onAbort, { once: true }); // An abort that landed while the CLI was being resolved has already // dispatched its event; the listener above would wait forever. - if (options.signal?.aborted === true) stop(); + if (options.signal?.aborted === true) onAbort(); const finish = (exit: ServeAppExit, failure?: ServeAppCommandError): void => { if (settledExit !== undefined) return; settledExit = exit; @@ -329,10 +360,12 @@ export const spawnServeApp = async (options: SpawnServeAppOptions): Promise undefined;', + '', + ].join('\n'), + 'src/layout.tsx': layout, + 'src/providers/git-worktree.ts': provider, + }); + expect(codesBySource((await compileRouteGraph(plainCli, fixtureConfig())).diagnostics, plainCli)) + .toEqual(['src/providers/git-worktree.ts']); + + // A rendered `.tsx` command renders through the worker, which imports both. + const renderedCli = await createRoot(); + await writeTree(renderedCli, { + 'src/cli/doctor.tsx': [ + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'), + 'src/layout.tsx': layout, + 'src/providers/git-worktree.ts': provider, + }); + expect(codesBySource((await compileRouteGraph(renderedCli, fixtureConfig())).diagnostics, renderedCli)) + .toEqual(['src/layout.tsx', 'src/providers/git-worktree.ts']); + + // A plain script is bundled from its own source: neither layouts nor + // providers are inlined. A rendered script's worker inlines both. + const plainScript = await createRoot(); + await writeTree(plainScript, { + 'src/layout.tsx': layout, + 'src/providers/git-worktree.ts': provider, + 'src/scripts/rebuild-index.ts': moduleSource, + }); + expect(codesBySource((await compileRouteGraph(plainScript, fixtureConfig())).diagnostics, plainScript)).toEqual([]); + const renderedScript = await createRoot(); + await writeTree(renderedScript, { + 'src/layout.tsx': layout, + 'src/providers/git-worktree.ts': provider, + 'src/scripts/rebuild-index.tsx': moduleSource, + }); + expect(codesBySource((await compileRouteGraph(renderedScript, fixtureConfig())).diagnostics, renderedScript)) + .toEqual(['src/layout.tsx', 'src/providers/git-worktree.ts']); + // A server layout of a server that is not generated wraps nothing that is // bundled either, and with no generated executable at all the provider is // never inlined. diff --git a/packages/agent-bundle/tests/serve-app-command-spawn.test.ts b/packages/agent-bundle/tests/serve-app-command-spawn.test.ts index cd73627cc..b0853c193 100644 --- a/packages/agent-bundle/tests/serve-app-command-spawn.test.ts +++ b/packages/agent-bundle/tests/serve-app-command-spawn.test.ts @@ -85,6 +85,17 @@ const servingCli = async (directory: string): Promise<{ readonly argvFile: strin 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; @@ -274,15 +285,57 @@ it('keeps closed pending through a post-spawn error until the child really exits 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!); +}); + it('carries a post-spawn error as the cause when the child then exits before its ready line', async () => { const root = await temporaryDirectory(); - // The handler is installed before the noise line, so a SIGTERM that - // follows the line is never racing it. - const cli = await writeFakeCli(root, 'never-ready', [ - "process.on('SIGTERM', () => { process.exit(3); });", - "process.stdout.write('Building…\\n');", - 'setInterval(() => undefined, 60_000);', - ]); + const cli = await neverReadyCli(root); let child: ChildProcess | undefined; const capturing = asSpawn((...args) => { child = trackingSpawn(...args); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 2473becca..dc66df8e7 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -806,7 +806,9 @@ reaches the server. Failures reject with `ServeAppCommandError`, whose `code` is `framework-not-installed` (no `node_modules/agent-bundle` resolves from `root`), `artifact-missing` (the given `artifact` path does not exist), `spawn-failed`, `exited-before-ready` (the child exited without printing the ready line; `error.exit` holds its -`{ code, signal }`), or `aborted` (the `signal` aborted before the App was served). On Ctrl-C the +`{ code, signal }`), `aborted` (the `signal` aborted before the App was served), or `stop-failed` +(the running child refused the `SIGTERM` that `close()` or the abort sent — also what `close()` +rejects with — so it is still running). On Ctrl-C the routed CLI shell reports `Aborted.` and exits `130` before the route returns, so the result document above is printed only when the server exits on its own or the route calls `close()`. diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index d955f5097..04124438c 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -720,7 +720,8 @@ stdout 的每一行转发到本进程的 stderr——或转发给一个 `relay` 的效果相同,因此抵达路由式命令的 Ctrl-C 会抵达服务器。失败以 `ServeAppCommandError` 拒绝,其 `code` 为 `framework-not-installed`(从 `root` 解析不到任何 `node_modules/agent-bundle`)、`artifact-missing`(给定的 `artifact` 路径不存在)、`spawn-failed`、`exited-before-ready`(子进程在打印就绪行之前就退出了;`error.exit` -保存着它的 `{ code, signal }`),或 `aborted`(`signal` 在 App 被提供之前就已中止)。按下 Ctrl-C 时, +保存着它的 `{ code, signal }`)、`aborted`(`signal` 在 App 被提供之前就已中止),或 `stop-failed`(正在运行的 +子进程拒绝了 `close()` 或中止所发送的 `SIGTERM`——`close()` 也以此拒绝——因此它仍在运行)。按下 Ctrl-C 时, 路由式 CLI 外壳会在路由返回之前报告 `Aborted.` 并以 `130` 退出,因此上面的结果文档只在服务器自行退出或 路由调用 `close()` 时才会打印。 From 66781bb848611165f332c01ba5580e76a1529123 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 03:03:44 +0000 Subject: [PATCH 4/4] fix(serve-app-command): attach the child's listeners before the already-aborted re-check so a refused kill() is stop-failed, not an unhandled error --- .../agent-bundle/src/serve-app-command.ts | 33 +++++++++++-------- .../tests/serve-app-command-spawn.test.ts | 20 +++++++++++ 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/agent-bundle/src/serve-app-command.ts b/packages/agent-bundle/src/serve-app-command.ts index 008109dc3..f306b9260 100644 --- a/packages/agent-bundle/src/serve-app-command.ts +++ b/packages/agent-bundle/src/serve-app-command.ts @@ -288,8 +288,11 @@ export const spawnServeApp = async (options: SpawnServeAppOptions): Promise { if (settledExit !== undefined) return; settledExit = exit; @@ -357,13 +356,14 @@ export const spawnServeApp = async (options: SpawnServeAppOptions): Promise { - // Before the process exists, `error` is the one notice Node gives and - // `close` may never follow: the spawn failed. Once the process runs, - // `error` reports a failed `kill()` and the process is still alive, so - // only its real `close` may settle `closed`: `stop()` surfaces the - // failure to whoever asked, and it is kept as the cause should the - // child then exit before its ready line. - if (spawned) { + // 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; @@ -376,5 +376,12 @@ export const spawnServeApp = async (options: SpawnServeAppOptions): Promise { 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/tests/serve-app-command-spawn.test.ts b/packages/agent-bundle/tests/serve-app-command-spawn.test.ts index b0853c193..dd956d6d2 100644 --- a/packages/agent-bundle/tests/serve-app-command-spawn.test.ts +++ b/packages/agent-bundle/tests/serve-app-command-spawn.test.ts @@ -331,6 +331,26 @@ it('rejects stop-failed from close() and from a pre-ready abort when the running 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 () => {