From e180e175460ffca6ffea48c42dd52ffc29b64d92 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 08:03:23 +0000 Subject: [PATCH 1/6] feat: prebuilt payload adapter mode (RFC #50 Phase 3) The `payload` config block declares already-built directory trees that `agent-bundle build` packages byte-for-byte at stable paths, and `{ prebuilt: ... }` markers point MCP server entries and hook handlers at files inside those payloads. Prebuilt entries skip compilation but flow through the same adapter lowering as compiled entries: path-token expansion in every target MCP document, the injected env anchor, generated hooks/hooks.json commands (with shell-safe prebuilt hook args), MCP artifact-reference validation, and manifest provenance (new `prebuilt` file kind; payload files hash into project.sourceInputs). New diagnostics: AB4740-AB4746 at validate (missing payloads warn), AB4747-AB4749 as build-time refusals, AB4750 staleness nudge. examples/rsc-agent-runtime migrates in the same change: it deletes scripts/package-hosts.mjs and packaging/, declares its Rsbuild output trees as payloads with prebuilt MCP/hook entries, and packages hosts with `agent-bundle build --json --output dist/plugins`. Its Rsbuild config pins the runtime flavor (`mode: 'production'`) that was previously implied by the epoch build's in-process NODE_ENV side effect. --- .changeset/prebuilt-payload-adapters.md | 5 + docs/architecture/rsc-runtime-workbench.md | 1 - docs/diagnostics.md | 24 ++ docs/entry-conventions.md | 76 ++++ examples/rsc-agent-runtime/README.md | 8 +- .../rsc-agent-runtime/agent-bundle.config.ts | 32 +- examples/rsc-agent-runtime/package.json | 3 +- .../claude/.claude-plugin/plugin.json | 7 - .../packaging/claude/.mcp.json | 9 - .../packaging/claude/hooks/hooks.json | 16 - .../codex/.agents/plugins/marketplace.json | 12 - .../packaging/codex/.codex-plugin/plugin.json | 18 - .../packaging/codex/.mcp.json | 10 - .../packaging/codex/hooks/hooks.json | 16 - examples/rsc-agent-runtime/rsbuild.config.ts | 10 + .../scripts/package-hosts.mjs | 75 ---- .../tests/dev-provider.integration.test.ts | 12 +- .../tests/docs-contract.test.ts | 7 +- .../tests/host-artifacts.test.ts | 39 +- packages/agent-bundle/README.md | 11 +- .../src/adapters/hook-contract.ts | 16 +- .../agent-bundle/src/adapters/portable.ts | 3 + packages/agent-bundle/src/adapters/types.ts | 29 ++ .../src/build/artifact-validation-types.ts | 7 + packages/agent-bundle/src/build/build.ts | 83 +++- packages/agent-bundle/src/build/manifest.ts | 4 +- packages/agent-bundle/src/build/mcp-apps.ts | 4 +- packages/agent-bundle/src/build/provenance.ts | 2 +- .../src/build/validate-artifact-modules.ts | 6 + .../src/build/validate-artifact.ts | 24 +- packages/agent-bundle/src/config/discover.ts | 88 ++++- packages/agent-bundle/src/config/index.ts | 6 + packages/agent-bundle/src/config/normalize.ts | 125 ++++++- packages/agent-bundle/src/config/validate.ts | 353 +++++++++++++++++- .../agent-bundle/src/core/project-context.ts | 28 +- packages/agent-bundle/src/core/types.ts | 87 ++++- .../agent-bundle/src/dev/project-service.ts | 60 ++- packages/agent-bundle/src/dev/types.ts | 2 +- packages/agent-bundle/src/index.ts | 6 + .../tests/packed-consumer.test.ts | 4 +- .../tests/prebuilt-payload.test.ts | 230 ++++++++++++ .../src/artifacts/artifact-client.ts | 2 +- packages/workbench/src/runtime-client.ts | 3 +- .../helpers/runtime-playground-fixture.ts | 28 +- .../tests/runtime-playground.e2e.test.ts | 6 +- scripts/rsc-runtime-topology.mjs | 2 +- 46 files changed, 1360 insertions(+), 239 deletions(-) create mode 100644 .changeset/prebuilt-payload-adapters.md delete mode 100644 examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json delete mode 100644 examples/rsc-agent-runtime/packaging/claude/.mcp.json delete mode 100644 examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json delete mode 100644 examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json delete mode 100644 examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json delete mode 100644 examples/rsc-agent-runtime/packaging/codex/.mcp.json delete mode 100644 examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json delete mode 100644 examples/rsc-agent-runtime/scripts/package-hosts.mjs create mode 100644 packages/agent-bundle/tests/prebuilt-payload.test.ts diff --git a/.changeset/prebuilt-payload-adapters.md b/.changeset/prebuilt-payload-adapters.md new file mode 100644 index 000000000..ec86944ed --- /dev/null +++ b/.changeset/prebuilt-payload-adapters.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Prebuilt payload adapter mode (RFC #50 Phase 3): the top-level `payload` block declares already-built directory trees that `agent-bundle build` packages byte-for-byte at stable paths, and `{ prebuilt: ... }` markers on MCP server entries and hook handlers point the generated host manifests at files inside those payloads. Prebuilt entries skip compilation but flow through the same adapter lowering as compiled entries — path-token expansion in every target's MCP document, the injected `AGENT_BUNDLE_PLUGIN_ROOT` env anchor, generated `hooks/hooks.json` commands (with shell-safe prebuilt hook `args`), and artifact-reference validation. Payload files are recorded in the artifact manifest with the new `prebuilt` file kind and hash into `project.sourceInputs`; declaration provenance is recorded as `kind: 'prebuilt'`. New diagnostics: `AB4740`–`AB4746` at validation (missing payloads and prebuilt files warn so development flows work before the consumer's own build has run), `AB4747`–`AB4749` as build-time refusals, and the `AB4750` staleness nudge. diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index ceff58481..3adb29736 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -131,7 +131,6 @@ examples/ scripts/eval-evidence.mjs scripts/eval-host-environment.mjs scripts/eval-hosts.mjs - scripts/package-hosts.mjs src/build/emit-artifacts.ts src/build/serialize-definition.ts src/definition.ts diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 36789e357..d533905cd 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -22,6 +22,7 @@ gate a build, a validation, or a dev rebuild. | `AB471x` | Package build `lib` configuration. | | `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch. | | `AB473x` | Migration nudges (informational; see below). | +| `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). | | `AB5000` | General CLI and adapter failures. | | `AB7xxx` | Project preparation and development rebuilds. | | `AB8xxx` | Development server configuration. | @@ -75,6 +76,29 @@ to it — a confusable state where the file on disk is not what runs. Adopt: drop the explicit `entry`/`command`/`url` so the convention applies. Silence: remove the shadowed file. +## Prebuilt payloads (`AB4740`–`AB4750`) + +The `payload` block and `{ prebuilt: ... }` entries (see +`docs/entry-conventions.md`) package files the framework did not compile. +The consumer's own build produces them, so their diagnostics split by +moment: configuration mistakes are validation **errors**, a payload that has +simply not been built yet is a validation **warning** that only +`agent-bundle build` escalates, and freshness is an **info** nudge. + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB4740` | error | The `payload` block, one entry, or its `targets` list is malformed, or a payload selects an unknown target. | +| `AB4741` | error | A payload destination is not a safe directory name, or shadows a compiler-owned artifact namespace (`assets`, `hooks`, `mcp`, `mcp-apps`, `scripts`, `skills`, root documents). | +| `AB4742` | error | A payload source escapes the project root, is not a directory, or contains another payload's source. | +| `AB4743` | warning | A declared payload directory does not exist yet or contains no files. Run the project's own build first. | +| `AB4744` | error | A `{ prebuilt: ... }` entry (MCP server or hook handler) does not resolve inside a declared payload, or its payload does not select every target the component needs. | +| `AB4745` | warning | A declared prebuilt entry file does not exist yet. Run the project's own build first. | +| `AB4746` | error | Hook `args` on a non-prebuilt handler, or arguments outside the shell-safe charset. | +| `AB4747` | error (build) | `agent-bundle build` refuses an empty or missing payload. | +| `AB4748` | error (build) | `agent-bundle build` refuses a prebuilt entry file absent from its payload. | +| `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. | +| `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. | + ## Development package build (`AB7103`) `agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 00988e970..8b2d6d08c 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -128,6 +128,82 @@ Export detection is a static scan of the entry source (comment-, string-, and template-safe). The generated shells re-verify the export shape at runtime with a clear error. +## Prebuilt payloads — package what you compiled yourself + +Some projects legitimately own their compilation — a coordinated +multi-environment bundler topology the per-entry `tools` hatch cannot +express — but still want framework-owned host packaging (manifests, hook +documents, env anchors, provenance, validation). The `payload` block +declares already-built directory trees the build packages **as-is**, and the +`{ prebuilt: ... }` marker points MCP entries and hook handlers at files +inside them: + +```ts +export default defineConfig({ + payload: { + // key = artifact-root destination directory, value = the built tree + app: './dist/app', + runtime: { source: './dist/runtime', targets: ['claude', 'codex'] }, + }, + mcp: { + servers: { + timeline: { + entry: { prebuilt: './dist/runtime/mcp/stdio.js' }, + transport: 'stdio', + }, + }, + }, + hooks: { + afterTool: [{ + args: ['--host', 'claude'], + handler: { prebuilt: './dist/runtime/hook/index.js' }, + targets: ['claude'], + tools: ['file.write'], + }], + }, +}); +``` + +- **Stable paths, not content-hashing.** Every payload file keeps its exact + relative path under the destination directory. The framework did not + compile these files, so it cannot rewrite the references inside them — + sibling chunk imports, worker entries resolved from `import.meta.url` — + and hosts, manuals, and tests pin the entry paths. Integrity stays + content-addressed anyway: each payload file lands in the artifact manifest + with its SHA-256 and the `prebuilt` file kind, and the payload files hash + into `project.sourceInputs`, so the project revision changes whenever the + payload bytes do. +- **The same adapter lowering.** A prebuilt MCP entry normalizes to a + command-shaped stdio server whose first argument is the payload path + anchored on the plugin-root token, so every target renders it natively + (`${CLAUDE_PLUGIN_ROOT}/runtime/mcp/stdio.js`, Codex's `./runtime/…` with + `cwd: "./"`, `${PLUGIN_ROOT}/…`), the `AGENT_BUNDLE_PLUGIN_ROOT` env + anchor is injected as usual, and artifact validation confirms the + referenced file is present and manifested. A prebuilt hook emits its + native command as `node "/" ` — one config + declaration replaces a hand-rolled `hooks/hooks.json` per host. Prebuilt + hook `args` (for example `--host claude`) accept shell-safe strings only. +- **Prebuilt means opaque.** Payload files are exempt from generated-output + content validation (bundled-ESM import graphs, strict generated JSON) but + remain hash-locked to the manifest. Declaration provenance is recorded as + `kind: 'prebuilt'`. Hooks with prebuilt handlers are packaged like native + hook documents: they do not compile wrappers and do not appear in the + simulatable hook index. MCP Apps declared on a prebuilt server stay a + development surface (the Workbench compiles them live); the build assumes + the payload already serves the resource. +- **Ordering.** Run your own build before `agent-bundle build`: a missing or + empty payload is a validation warning (`AB4743`/`AB4745`) so `dev` works + from a clean checkout, but `agent-bundle build` refuses it + (`AB4747`/`AB4748`). Payload directories must not overlap the artifact + `--output` root (`AB4749`) — with payloads under `dist/`, pass an output + like `dist/plugins`. See `docs/diagnostics.md` for the full `AB474x` + table. + +`examples/rsc-agent-runtime` is the reference consumer: its Rsbuild build +owns a three-environment RSC compilation, and `agent-bundle build` packages +the resulting `dist/runtime` and `dist/app` trees into the Claude, Codex, +and portable artifacts. + ## `tools` — THE escape hatch `tools.rsbuild` (an Rsbuild environment-config fragment) and `tools.rspack` diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 927a0cc29..79312e2ad 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -77,15 +77,19 @@ uses a separate long-lived Rsbuild development/HMR session only when an entry stays self-built inside those Rsbuild artifacts but consumes the framework's stdio lifecycle (console-to-stderr guard, SIGINT/SIGTERM exit codes, stdin-EOF shutdown) through the public `agent-bundle/mcp-entry` API. +Host packaging is framework-owned: `agent-bundle.config.ts` declares the +Rsbuild output trees as prebuilt `payload` directories with prebuilt MCP and +hook entries, and `agent-bundle build` copies them byte-for-byte at their +stable paths while generating every host manifest. Installing `agent-bundle` alone does not install or activate this example provider. See [the optional RSC Runtime topology](../../docs/architecture/rsc-runtime-workbench.md) for the full ownership boundary. -The build emits `dist/runtime` (including `dist/runtime/agent-runtime.manifest.json`), self-contained `dist/app` MCP App documents, and two self-contained native plugin artifacts under `dist/plugins`. It runs `package:hosts` automatically; it can also be run directly: +The build emits `dist/runtime` (including `dist/runtime/agent-runtime.manifest.json`), self-contained `dist/app` MCP App documents, and self-contained native plugin artifacts under `dist/plugins`. The packaging step can also be rerun directly against the current Rsbuild output: ```bash -pnpm --filter @agent-bundle/rsc-agent-runtime-demo package:hosts +pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec agent-bundle build --json --output dist/plugins ``` To exercise one hook manually, give it an explicit state file and native Claude-shaped JSON: diff --git a/examples/rsc-agent-runtime/agent-bundle.config.ts b/examples/rsc-agent-runtime/agent-bundle.config.ts index 5f8a67e75..5b353e77c 100644 --- a/examples/rsc-agent-runtime/agent-bundle.config.ts +++ b/examples/rsc-agent-runtime/agent-bundle.config.ts @@ -1,16 +1,32 @@ import { defineConfig } from 'agent-bundle/config'; +// The RSC runtime and App payloads are compiled by this example's own +// multi-environment Rsbuild build (see rsbuild.config.ts); agent-bundle +// packages those prebuilt trees verbatim and generates the host manifests, +// so this file is the single declaration for both development and packaging. export default defineConfig({ claude: {}, codex: {}, dev: { runtime: { provider: './src/dev/provider.ts' } }, hooks: { - afterTool: { - handler: './src/hook/cli.ts', - targets: ['claude', 'codex'], - tools: ['file.write'], - }, + afterTool: [ + { + args: ['--host', 'claude'], + handler: { prebuilt: './dist/runtime/hook/index.js' }, + targets: ['claude'], + timeout: 30, + tools: ['file.write'], + }, + { + args: ['--host', 'codex'], + handler: { prebuilt: './dist/runtime/hook/index.js' }, + targets: ['codex'], + timeout: 30, + tools: ['file.write'], + }, + ], }, + marketplace: true, mcp: { servers: { timeline: { @@ -24,12 +40,16 @@ export default defineConfig({ targets: ['portable', 'claude', 'codex'], }, }, - entry: './src/mcp/stdio.ts', + entry: { prebuilt: './dist/runtime/mcp/stdio.js' }, targets: ['portable', 'claude', 'codex'], transport: 'stdio', }, }, }, + payload: { + app: './dist/app', + runtime: './dist/runtime', + }, portable: {}, plugin: { description: 'React Server Components agent runtime demonstration.', diff --git a/examples/rsc-agent-runtime/package.json b/examples/rsc-agent-runtime/package.json index 228aebc70..8a705758e 100644 --- a/examples/rsc-agent-runtime/package.json +++ b/examples/rsc-agent-runtime/package.json @@ -3,8 +3,7 @@ "private": true, "type": "module", "scripts": { - "build": "rsbuild build --mode production && pnpm package:hosts", - "package:hosts": "node scripts/package-hosts.mjs", + "build": "rsbuild build --mode production && agent-bundle build --json --output dist/plugins", "test": "rstest --config rstest.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "check": "pnpm build && pnpm test && pnpm typecheck", diff --git a/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json b/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json deleted file mode 100644 index 7d1cc63ba..000000000 --- a/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "rsc-agent-runtime", - "version": "0.1.0", - "description": "RSC hooks, shared state, MCP tools, and an MCP App in one runtime demo.", - "author": { "name": "Agent Bundle" }, - "hooks": "./hooks/hooks.json" -} diff --git a/examples/rsc-agent-runtime/packaging/claude/.mcp.json b/examples/rsc-agent-runtime/packaging/claude/.mcp.json deleted file mode 100644 index 086579eb1..000000000 --- a/examples/rsc-agent-runtime/packaging/claude/.mcp.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "mcpServers": { - "rsc-agent-runtime": { - "type": "stdio", - "command": "node", - "args": ["${CLAUDE_PLUGIN_ROOT}/runtime/mcp/stdio.js"] - } - } -} diff --git a/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json b/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json deleted file mode 100644 index ceb2b1e19..000000000 --- a/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/runtime/hook/index.js\" --host claude", - "timeout": 30 - } - ] - } - ] - } -} diff --git a/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json b/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json deleted file mode 100644 index 196c9eac0..000000000 --- a/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "rsc-agent-runtime-marketplace", - "interface": { "displayName": "RSC Agent Runtime" }, - "plugins": [ - { - "name": "rsc-agent-runtime", - "category": "Productivity", - "source": { "source": "local", "path": "./" }, - "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" } - } - ] -} diff --git a/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json b/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json deleted file mode 100644 index 60b1fd87c..000000000 --- a/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "rsc-agent-runtime", - "version": "0.1.0", - "description": "RSC hooks, shared state, MCP tools, and an MCP App in one runtime demo.", - "author": { "name": "Agent Bundle" }, - "interface": { - "displayName": "RSC Agent Runtime", - "shortDescription": "Shared-state RSC hooks and MCP runtime demo.", - "longDescription": "RSC hooks, shared state, MCP tools, and an MCP App in one runtime demo.", - "developerName": "Agent Bundle", - "category": "Productivity", - "capabilities": ["mcp", "hooks"], - "defaultPrompt": ["Show the recent RSC runtime edit timeline."] - }, - "mcpServers": "./.mcp.json", - "hooks": "./hooks/hooks.json", - "skills": "./skills/" -} diff --git a/examples/rsc-agent-runtime/packaging/codex/.mcp.json b/examples/rsc-agent-runtime/packaging/codex/.mcp.json deleted file mode 100644 index 1a9819efe..000000000 --- a/examples/rsc-agent-runtime/packaging/codex/.mcp.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "mcpServers": { - "rsc-agent-runtime": { - "type": "stdio", - "command": "node", - "args": ["./runtime/mcp/stdio.js"], - "cwd": "./" - } - } -} diff --git a/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json b/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json deleted file mode 100644 index fda568697..000000000 --- a/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "apply_patch", - "hooks": [ - { - "type": "command", - "command": "node \"${PLUGIN_ROOT}/runtime/hook/index.js\" --host codex", - "timeout": 30 - } - ] - } - ] - } -} diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index d85acca9d..d25f2fc69 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -178,6 +178,16 @@ export const createRscRuntimeRsbuildConfig = ( development ? join(options.compilerRoot as string, name) : productionRoot; return { + // The runtime flavor is a pinned contract: react and react-server-dom + // compile as their production variants, so Flight payloads stay compact + // model rows without development debug/timing frames. This was + // previously implicit — some in-process bundler run (for example the + // dev artifact epoch compiling MCP entries) had already set NODE_ENV to + // "production" before this config compiled. With prebuilt host + // packaging nothing else compiles first, so the flavor must not float + // with ambient NODE_ENV. `options.mode` keeps selecting the compile + // topology (dev entries, compiler roots) independently of this flavor. + mode: 'production', ...(development ? { dev: { writeToDisk: true }, server: { host: '127.0.0.1', printUrls: false }, diff --git a/examples/rsc-agent-runtime/scripts/package-hosts.mjs b/examples/rsc-agent-runtime/scripts/package-hosts.mjs deleted file mode 100644 index 07ded4250..000000000 --- a/examples/rsc-agent-runtime/scripts/package-hosts.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/* global process */ - -import { access, cp, mkdir, readFile, rm } from 'node:fs/promises'; -import { dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const exampleRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const distRoot = join(exampleRoot, 'dist'); -const pluginsRoot = join(distRoot, 'plugins'); -const runtimeRoot = join(distRoot, 'runtime'); -const appRoot = join(distRoot, 'app'); -const packagingRoot = join(exampleRoot, 'packaging'); - -const assertDirectory = async (path, message) => { - try { - await access(path); - } catch { - throw new Error(message); - } -}; - -const normalizedRuntimeAsset = (asset) => { - if (typeof asset !== 'string') { - throw new Error('runtime-assets.json must contain string paths'); - } - const stripped = asset.replace(/^[/\\]+/, ''); - const normalized = normalize(stripped); - if (stripped.length === 0 || isAbsolute(normalized) || normalized === '..' || normalized.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) { - throw new Error(`Runtime asset escapes its root: ${asset}`); - } - return normalized; -}; - -const verifyRuntimeCopy = async (pluginRoot) => { - const manifestPath = join(pluginRoot, 'runtime', 'runtime-assets.json'); - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); - if (!Array.isArray(manifest.allFiles)) { - throw new Error('runtime-assets.json must contain allFiles'); - } - const copiedRuntime = resolve(pluginRoot, 'runtime'); - for (const asset of manifest.allFiles) { - const normalized = normalizedRuntimeAsset(asset); - const target = resolve(copiedRuntime, normalized); - if (relative(copiedRuntime, target).startsWith('..')) { - throw new Error(`Runtime asset escapes copied root: ${asset}`); - } - await access(target); - } -}; - -const packageHost = async (host) => { - const source = join(packagingRoot, host); - const target = join(pluginsRoot, host); - await cp(source, target, { recursive: true }); - await cp(runtimeRoot, join(target, 'runtime'), { recursive: true }); - await cp(appRoot, join(target, 'app'), { recursive: true }); - if (host === 'codex') { - await mkdir(join(target, 'skills'), { recursive: true }); - } - await verifyRuntimeCopy(target); -}; - -const run = async () => { - await assertDirectory(runtimeRoot, 'Build dist/runtime before packaging native hosts.'); - await assertDirectory(appRoot, 'Build dist/app before packaging native hosts.'); - await rm(pluginsRoot, { force: true, recursive: true }); - await mkdir(pluginsRoot, { recursive: true }); - await packageHost('claude'); - await packageHost('codex'); -}; - -run().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); diff --git a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts index 39c790ca5..527e29711 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -19,6 +19,7 @@ import { import { createDevRuntimeProvider } from '../src/dev/provider.js'; import { ResourceLedger, RsbuildRuntimeSession } from '../src/dev/rsbuild-runtime-session.js'; import { copyExample, type CopiedExample } from './support/copy-example.ts'; +import { ensureExampleBuilt } from './support/ensure-built.ts'; import { timeScale } from './support/time-scale.ts'; const exampleRoot = process.cwd(); @@ -298,6 +299,12 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil const copied = await copyProviderExample(); try { const root = copied.projectRoot; + // Host artifacts package the declared prebuilt payload trees, so building + // them requires the example's own Rsbuild output to exist in the copy. + await ensureExampleBuilt(); + for (const payload of ['runtime', 'app']) { + await cp(join(exampleRoot, 'dist', payload), join(root, 'dist', payload), { recursive: true }); + } const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root }).prepare('dev'); expect(prepared.source.state).toBe('ready'); @@ -306,8 +313,11 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil provider: './src/dev/provider.ts', servers: [expect.objectContaining({ name: 'timeline', transport: 'stdio' })], }); + // One prebuilt hook declaration per host (each carries its own + // `--host` argument), replacing the previous dual-target declaration. expect(prepared.model?.hooks).toEqual(expect.arrayContaining([ - expect.objectContaining({ targets: expect.arrayContaining(['claude', 'codex']) }), + expect.objectContaining({ prebuiltPath: 'runtime/hook/index.js', targets: ['claude'] }), + expect.objectContaining({ prebuiltPath: 'runtime/hook/index.js', targets: ['codex'] }), ])); const artifact = await new ArtifactService({ epochStore: new EpochStore({ projectRoot: root }) }).build(prepared); diff --git a/examples/rsc-agent-runtime/tests/docs-contract.test.ts b/examples/rsc-agent-runtime/tests/docs-contract.test.ts index 5423961a0..521cd0505 100644 --- a/examples/rsc-agent-runtime/tests/docs-contract.test.ts +++ b/examples/rsc-agent-runtime/tests/docs-contract.test.ts @@ -43,7 +43,12 @@ test('declares a shell-independent production build', async () => { readonly scripts?: Readonly>; }; - expect(manifest.scripts?.build).toBe('rsbuild build --mode production && pnpm package:hosts'); + // Pin updated with the prebuilt-payload migration (RFC #50 Phase 3): the + // demo's own Rsbuild production build stays first — the custom RSC + // compilation is this example's subject — and the hand-rolled + // scripts/package-hosts.mjs step is replaced by `agent-bundle build` + // packaging the declared payload trees into dist/plugins. + expect(manifest.scripts?.build).toBe('rsbuild build --mode production && agent-bundle build --json --output dist/plugins'); }); test('derives the native evaluator root from decoded module URLs', async () => { diff --git a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts index 8959c2324..9104a344a 100644 --- a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts +++ b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts @@ -17,10 +17,17 @@ const pluginsRoot = join(exampleRoot, 'dist/plugins'); const runPackageHosts = async (): Promise => { await ensureExampleBuilt(); - const child = spawn(process.execPath, ['scripts/package-hosts.mjs'], { cwd: exampleRoot, stdio: 'pipe' }); + // Repackaging the existing prebuilt payload is agent-bundle's job now; the + // command must be independently rerunnable against the current dist trees. + const child = spawn('pnpm', ['exec', 'agent-bundle', 'build', '--json', '--output', 'dist/plugins'], { + cwd: exampleRoot, + stdio: 'pipe', + }); + const stderr: Buffer[] = []; + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); const [exitCode, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null]; expect(signal).toBeNull(); - expect(exitCode).toBe(0); + expect(exitCode, Buffer.concat(stderr).toString('utf8')).toBe(0); }; const runProductionBuild = async (): Promise => { @@ -118,22 +125,29 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy join(codexRoot, 'hooks/hooks.json'), ); - expect(claudeManifest).toMatchObject({ name: 'rsc-agent-runtime', version: '0.1.0' }); + // Host manifests are generated from agent-bundle.config.ts now, so the + // plugin identity is the config's plugin block rather than the previously + // hand-rolled name/version pair. + expect(claudeManifest).toMatchObject({ name: 'rsc-agent-runtime-demo', version: '1.0.0' }); expect(codexManifest).toMatchObject({ hooks: './hooks/hooks.json', interface: expect.any(Object), mcpServers: './.mcp.json', - name: 'rsc-agent-runtime', + name: 'rsc-agent-runtime-demo', skills: './skills/', - version: '0.1.0', + version: '1.0.0', }); - expect(claudeMcp.mcpServers['rsc-agent-runtime'].args).toContain('${CLAUDE_PLUGIN_ROOT}/runtime/mcp/stdio.js'); - expect(codexMcp.mcpServers['rsc-agent-runtime']).toMatchObject({ args: ['./runtime/mcp/stdio.js'], cwd: './' }); - expect(JSON.stringify(codexMcp)).not.toMatch(/PLUGIN_ROOT|PLUGIN_DATA|workspace/i); - expect(claudeHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: 'Write|Edit' }); + // The stable prebuilt entry paths the workers and manual flows pin. + expect(claudeMcp.mcpServers['timeline'].args).toContain('${CLAUDE_PLUGIN_ROOT}/runtime/mcp/stdio.js'); + expect(codexMcp.mcpServers['timeline']).toMatchObject({ args: ['./runtime/mcp/stdio.js'], cwd: './' }); + // Codex has no path-token interpolation: no `${...}` token may survive into + // its document. (The AGENT_BUNDLE_PLUGIN_ROOT env anchor is a plain + // variable name, not a host token.) + expect(JSON.stringify(codexMcp)).not.toMatch(/\$\{|workspace/i); + expect(claudeHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:Write|Edit)$' }); expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('${CLAUDE_PLUGIN_ROOT}'); expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('--host claude'); - expect(codexHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: 'apply_patch' }); + expect(codexHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:apply_patch|Edit|Write)$' }); expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('${PLUGIN_ROOT}'); expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('--host codex'); expect(JSON.stringify({ claudeMcp, claudeHooks, codexMcp, codexHooks })).not.toMatch(/api[ _-]?key/i); @@ -157,7 +171,10 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy const appHtml = await readFile(join(exampleRoot, relative), 'utf8'); expect(appHtml).not.toMatch(/]+src=|]+rel=["']stylesheet["']/iu); } - for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.mcp.json', 'hooks/hooks.json', 'skills']) { + // The generated layout has no empty skills directory for this skill-less + // plugin; the manifest's `./skills/` pointer stays, matching every other + // framework-built Codex artifact. + for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.mcp.json', 'hooks/hooks.json']) { await access(join(codexRoot, relative)); } }); diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 3ab5c8ceb..282a94cf0 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -52,8 +52,15 @@ entries that default-export a server factory are wrapped in the framework stdio (console-to-stderr guard with raw stdout restored for protocol frames, SIGINT 130 / SIGTERM 143, stdin-EOF exit 0, bounded shutdown, heartbeat), also available directly from `agent-bundle/mcp-entry`. `tools.rsbuild` / `tools.rspack` is the single bundler escape hatch, -merged last into every synthesized config and bounded by the artifact invariant assertions. See -the repository's `docs/entry-conventions.md` for the full contract. +merged last into every synthesized config and bounded by the artifact invariant assertions. + +Projects that own their compilation entirely declare prebuilt payloads instead: the top-level +`payload` block names already-built directory trees the build packages byte-for-byte at stable +paths, and `entry: { prebuilt: './dist/…' }` (MCP servers) or +`handler: { prebuilt: './dist/…' }` plus shell-safe `args` (hooks) point the generated host +manifests at files inside those payloads without compiling them. Payload files carry the +`prebuilt` manifest file kind and hash into the project revision. See the repository's +`docs/entry-conventions.md` for the full contract. ## Commands diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index c6c5cb1cf..40fb09d3b 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -477,8 +477,11 @@ export const mergeHookDocuments = ( const eventIndex = new Map(canonicalEventOrder.map((event, index) => [event, index])); -export const generatedHookCommand = (contract: TargetHookContract, relativePath: string): string => - `node "${contract.commandRoot}/${relativePath}"`; +export const generatedHookCommand = ( + contract: TargetHookContract, + relativePath: string, + args: readonly string[] = [], +): string => [`node "${contract.commandRoot}/${relativePath}"`, ...args].join(' '); export const compilerHookWrapperPath = ( contract: TargetHookContract, @@ -567,8 +570,12 @@ export const planHooks = ( const diagnosticCount = diagnostics.length; const matcher = matcherFor(target, contract, hook, diagnostics); if (diagnostics.length > diagnosticCount) continue; - const relativePath = contract.wrapperPath(hook); - const command = generatedHookCommand(contract, relativePath); + // A prebuilt hook points the native command at its payload-stable path + // (plus its declared arguments) instead of a compiled wrapper: the + // document entry is generated, but nothing is compiled or indexed. + const prebuilt = hook.prebuiltPath !== undefined; + const relativePath = hook.prebuiltPath ?? contract.wrapperPath(hook); + const command = generatedHookCommand(contract, relativePath, prebuilt ? hook.args ?? [] : []); const entryInput: TargetHookDocumentEntryInput = { command, ...(matcher === undefined ? {} : { matcher }), @@ -585,6 +592,7 @@ export const planHooks = ( } : contract.documentEntry(entryInput); (groups[nativeEvent] ??= []).push(group); + if (prebuilt) continue; const wrapper: TargetHookWrapper = { event: hook.event, hook, diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index cececcee1..07aa7a936 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -20,6 +20,7 @@ import mcpSchema from './schemas/portable/mcp.schema.json' with { type: 'json' } import pluginSchema from './schemas/portable/plugin.schema.json' with { type: 'json' }; import { createAdapterValidator, + payloadCopyEntries, schemaDescriptorsFrom, sourceInputs, validateJsonSchemaDocument, @@ -262,6 +263,8 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { }); } + entries.push(...payloadCopyEntries(model, hasPortableTarget)); + const servers: Record> = Object.create(null) as Record< string, Record diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 5117e1442..e8f6adc6b 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -26,6 +26,12 @@ export interface TargetArtifactWrite { export interface TargetArtifactCopy { readonly bytes: number; readonly kind: 'copy'; + /** + * True for a file of a declared prebuilt payload: copied byte-for-byte + * like any other copy entry, but recorded with the `prebuilt` manifest + * kind and exempt from generated-module content validation. + */ + readonly prebuilt?: true; readonly relativePath: string; readonly source: string; /** Absolute authored inputs for this copied artifact. */ @@ -97,6 +103,27 @@ export const withPluginRootEnvAnchor = ( pluginRoot: string, ): Record => ({ [pluginRootEnvAnchor]: pluginRoot, ...env }); +/** + * Copy entries for every selected prebuilt payload file: exact relative + * paths under the payload's declared destination, byte-for-byte, no + * content-hashing — the compiler did not produce these files and cannot + * rewrite the sibling references inside them, so stable names are the + * packaging contract. Shared by every target plan that emits payloads. + */ +export const payloadCopyEntries = ( + model: NormalizedPlugin, + isSelected: (targets: readonly string[]) => boolean, +): TargetArtifactCopy[] => (model.payloads ?? []) + .filter((payload) => isSelected(payload.targets)) + .flatMap((payload) => payload.files.map((file): TargetArtifactCopy => ({ + bytes: file.bytes, + kind: 'copy', + prebuilt: true, + relativePath: `${payload.name}/${file.relativePath}`, + source: file.source, + sourceInputs: sourceInputs(payload.provenance.sourcePath, file.source), + }))); + export interface StandardPluginArtifactsInput { readonly diagnostics: readonly Diagnostic[]; readonly hookDocument?: Record; @@ -228,6 +255,8 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): }); } + entries.push(...(input.sharedCopyEntries === false ? [] : payloadCopyEntries(model, isSelected))); + return Object.freeze({ diagnostics: Object.freeze(diagnostics), entries: sortedEntries(entries), diff --git a/packages/agent-bundle/src/build/artifact-validation-types.ts b/packages/agent-bundle/src/build/artifact-validation-types.ts index 838e9ce50..98c1120be 100644 --- a/packages/agent-bundle/src/build/artifact-validation-types.ts +++ b/packages/agent-bundle/src/build/artifact-validation-types.ts @@ -11,6 +11,13 @@ export interface ValidateArtifactOptions { /** Enables the one store-owned epoch staging marker after its exact schema validates. */ readonly allowEpochStagingMarker?: true; readonly artifactRoot: string; + /** + * Artifact-relative paths of prebuilt payload files for pre-manifest + * validation. Prebuilt files are integrity-checked but never subjected to + * generated-content validation; after the manifest exists, its `prebuilt` + * file kind carries this information instead. + */ + readonly prebuiltPaths?: ReadonlySet; /** Target contracts that produced and must validate this artifact. */ readonly registry?: TargetRegistry; } diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 0341472e5..2d6be736b 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -7,8 +7,8 @@ import type { TargetRegistry } from '../adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from '../adapters/types.ts'; import { deduplicateDiagnostics, DiagnosticBag, DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; import type { ProjectContext } from '../core/project-context.ts'; -import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; -import { assertInside } from '../core/paths.ts'; +import { pathTokens, type AgentBundleToolsConfig, type NormalizedPlugin } from '../core/types.ts'; +import { assertInside, isInsideOrEqual } from '../core/paths.ts'; import { agentSkillsSchemaRevision } from '../schemas/agent-skills/contract.ts'; import { compileEntries, @@ -75,6 +75,70 @@ interface StagedTarget extends PlannedTarget { readonly root: string; } +const prebuiltReferenceExists = ( + model: NormalizedPlugin, + artifactPath: string, +): boolean => (model.payloads ?? []).some((payload) => + artifactPath.startsWith(`${payload.name}/`) && + payload.files.some((file) => `${payload.name}/${file.relativePath}` === artifactPath)); + +/** + * AB4747-AB4749: an artifact build packages prebuilt payloads exactly as + * they exist, so it refuses to run while a declared payload is missing or + * empty, a prebuilt entry file is absent, or a payload directory overlaps + * the artifact output root. Validation reports the first two states as + * warnings (AB4743/AB4745) because development flows never require the + * consumer's own build to have run. + */ +const prebuiltPayloadDiagnostics = ( + model: NormalizedPlugin, + outputRoot: string, +): readonly Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const selectedTargets = new Set(model.targets.map((target) => target.name)); + for (const payload of model.payloads ?? []) { + if (isInsideOrEqual(payload.source, outputRoot) || isInsideOrEqual(outputRoot, payload.source)) { + diagnostics.push({ + code: 'AB4749', + message: `Payload ${JSON.stringify(payload.name)} source ${JSON.stringify(payload.source)} overlaps the artifact output ${JSON.stringify(outputRoot)}; pass a different --output.`, + severity: 'error', + }); + } + if (payload.files.length === 0 && payload.targets.some((target) => selectedTargets.has(target))) { + diagnostics.push({ + code: 'AB4747', + message: `Payload ${JSON.stringify(payload.name)} contains no files; run the project's own build before "agent-bundle build".`, + severity: 'error', + }); + } + } + const tokenPrefix = `${pathTokens.pluginRoot}/`; + for (const server of model.mcpServers) { + if (server.provenance.kind !== 'prebuilt') continue; + const entry = server.args?.[0]; + if (typeof entry !== 'string' || !entry.startsWith(tokenPrefix)) continue; + const artifactPath = entry.slice(tokenPrefix.length); + if (!prebuiltReferenceExists(model, artifactPath)) { + diagnostics.push({ + code: 'AB4748', + message: `MCP server ${JSON.stringify(server.name)} prebuilt entry ${JSON.stringify(artifactPath)} is not present in its declared payload; run the project's own build before "agent-bundle build".`, + severity: 'error', + }); + } + } + for (const hook of model.hooks) { + if (hook.prebuiltPath === undefined) continue; + if (!prebuiltReferenceExists(model, hook.prebuiltPath)) { + diagnostics.push({ + code: 'AB4748', + message: `Hook ${JSON.stringify(hook.name)} prebuilt handler ${JSON.stringify(hook.prebuiltPath)} is not present in its declared payload; run the project's own build before "agent-bundle build".`, + severity: 'error', + }); + } + } + return diagnostics; +}; + const planTargets = (options: BuildOptions): readonly PlannedTarget[] => { const diagnostics: Diagnostic[] = []; const planned: PlannedTarget[] = []; @@ -157,7 +221,9 @@ const outputCandidatesFor = (options: { readonly targets: readonly StagedTarget[]; }): readonly ArtifactOutputCandidate[] => [ ...options.targets.flatMap((target) => target.entries.map((entry) => ({ - kind: entry.kind === 'copy' ? 'copy' as const : 'generated' as const, + kind: entry.kind !== 'copy' + ? 'generated' as const + : entry.prebuilt === true ? 'prebuilt' as const : 'copy' as const, path: resolveArtifactDestination(target.root, entry.relativePath), sourceInputs: entry.sourceInputs, }))), @@ -245,8 +311,10 @@ const manifestFor = (options: { }; export const build = async (options: BuildOptions): Promise => { - const planned = planTargets(options); const outputRoot = resolve(options.outputRoot); + const payloadDiagnostics = prebuiltPayloadDiagnostics(options.model, outputRoot); + if (payloadDiagnostics.length > 0) throw new DiagnosticError(payloadDiagnostics); + const planned = planTargets(options); const preflightTargets = planStagedTargets({ artifactRoot: outputRoot, model: options.model, @@ -336,7 +404,12 @@ export const build = async (options: BuildOptions): Promise => { files: await listArtifactFiles(stageRoot), outputProvenance, }); - const preManifestDiagnostics = await validateArtifactFiles({ artifactRoot: stageRoot }); + const preManifestDiagnostics = await validateArtifactFiles({ + artifactRoot: stageRoot, + prebuiltPaths: new Set(outputProvenance + .filter((output) => output.kind === 'prebuilt') + .map((output) => output.path)), + }); if (preManifestDiagnostics.some((entry) => entry.severity === 'error')) { throw new DiagnosticError(preManifestDiagnostics); } diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index a1d32648f..d9e050258 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -6,7 +6,7 @@ import { } from '../core/runtime.ts'; import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; -export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated'; +export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt'; export type ArtifactManifestValidationStatus = 'passed'; export interface ArtifactManifestSourceInput { @@ -199,7 +199,7 @@ const parseFiles = (value: unknown): readonly ArtifactManifestFile[] => { if (!Number.isSafeInteger(file.bytes) || (file.bytes as number) < 0) { fail(`files[${index}].bytes must be a non-negative safe integer.`); } - if (file.kind !== 'bundle' && file.kind !== 'copy' && file.kind !== 'generated') { + if (file.kind !== 'bundle' && file.kind !== 'copy' && file.kind !== 'generated' && file.kind !== 'prebuilt') { fail(`files[${index}].kind is unknown.`); } if (file.mode !== undefined && (!Number.isSafeInteger(file.mode) || (file.mode as number) < 0 || (file.mode as number) > 0o777)) { diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 47fb20c0b..8b5a892f4 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -93,7 +93,9 @@ export const planCompiledMcpApps = ( options: { readonly outDir: string; readonly target: string }, ): readonly CompiledMcpApp[] => { const planned = new Map(); - for (const app of apps.filter((candidate) => candidate.targets.includes(options.target))) { + // Apps of prebuilt servers stay development surfaces: the payload already + // carries the served resource, so the compiler emits nothing for them. + for (const app of apps.filter((candidate) => candidate.prebuilt !== true && candidate.targets.includes(options.target))) { const identity = appIdentity(app); const existing = planned.get(app.name); if (existing !== undefined) { diff --git a/packages/agent-bundle/src/build/provenance.ts b/packages/agent-bundle/src/build/provenance.ts index 25d166016..fe33ef863 100644 --- a/packages/agent-bundle/src/build/provenance.ts +++ b/packages/agent-bundle/src/build/provenance.ts @@ -3,7 +3,7 @@ import { isAbsolute, relative, resolve, win32 } from 'node:path'; import { assertInside } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; -export type ArtifactOutputKind = 'bundle' | 'copy' | 'generated'; +export type ArtifactOutputKind = 'bundle' | 'copy' | 'generated' | 'prebuilt'; export interface ArtifactOutputProvenance { readonly kind: ArtifactOutputKind; diff --git a/packages/agent-bundle/src/build/validate-artifact-modules.ts b/packages/agent-bundle/src/build/validate-artifact-modules.ts index 8e5ac5639..f29ce57b0 100644 --- a/packages/agent-bundle/src/build/validate-artifact-modules.ts +++ b/packages/agent-bundle/src/build/validate-artifact-modules.ts @@ -100,6 +100,8 @@ export const validateJavaScriptModules = async (options: { readonly artifactRoot: string; readonly files: readonly ArtifactFile[]; readonly manifestFiles?: ReadonlySet; + /** Prebuilt payload files: opaque consumer outputs excluded from graph validation. */ + readonly prebuiltPaths?: ReadonlySet; readonly validJson: ReadonlySet; }): Promise => { await init; @@ -113,6 +115,10 @@ export const validateJavaScriptModules = async (options: { const validateModule = async (path: string): Promise => { if (visited.has(path) || visiting.has(path)) return; + if (options.prebuiltPaths?.has(path) === true) { + visited.add(path); + return; + } visiting.add(path); let source: string; try { diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 11d128a88..355d8abab 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -439,11 +439,17 @@ const validateArtifactOwnership = (options: { }): readonly Diagnostic[] => { const diagnostics: Diagnostic[] = []; const targets = targetNamespaces(options.manifest); + const manifestKinds = new Map(options.manifest.files.map((file) => [file.path, file.kind])); for (const file of options.files) { if (artifactRootMetadata.has(file.path)) continue; const target = pathTarget(file.path, targets); if (target !== undefined && isTargetArtifactPath(file.path, target, options.registry)) continue; + // Prebuilt payload files are consumer-shaped by definition: they live in + // config-named directories under their target namespace, are declared + // with the `prebuilt` manifest kind, and stay hash-locked to the + // manifest like every other file. + if (target !== undefined && manifestKinds.get(file.path) === 'prebuilt') continue; diagnostics.push(diagnostic( 'AB6014', `Artifact file ${JSON.stringify(file.path)} is outside declared target emitted layouts.`, @@ -523,9 +529,16 @@ const validateGeneratedFiles = async (options: { readonly artifactRoot: string; readonly files: readonly ArtifactFile[]; readonly manifestFiles?: readonly ManifestFile[]; + readonly prebuiltPaths?: ReadonlySet; }): Promise => { const diagnostics: Diagnostic[] = []; const generatedFiles = new Set(options.files.map((file) => file.path)); + // Prebuilt payload files are opaque consumer build outputs: they stay + // hash-locked to the manifest, but their contents are never held to the + // generated-output contracts (strict JSON, bundled ESM import graphs). + const prebuiltPaths = options.prebuiltPaths ?? new Set( + (options.manifestFiles ?? []).filter((file) => file.kind === 'prebuilt').map((file) => file.path), + ); const validJson = new Set(); for (const file of options.files.filter((entry) => entry.path.endsWith('.json'))) { @@ -545,7 +558,9 @@ const validateGeneratedFiles = async (options: { } } } catch { - diagnostics.push(diagnostic('AB6006', 'Generated JSON cannot be parsed.', file.path)); + if (!prebuiltPaths.has(file.path)) { + diagnostics.push(diagnostic('AB6006', 'Generated JSON cannot be parsed.', file.path)); + } } } @@ -555,6 +570,7 @@ const validateGeneratedFiles = async (options: { ...(options.manifestFiles === undefined ? {} : { manifestFiles: new Set(options.manifestFiles.map((file) => file.path)) }), + prebuiltPaths, validJson, })); @@ -567,7 +583,11 @@ export const validateArtifactFiles = async ( const inspection = await inspectArtifact(context); return Object.freeze([ ...filesystemDiagnostics(inspection.filesystem), - ...await validateGeneratedFiles({ artifactRoot: context.artifactRoot, files: inspection.files }), + ...await validateGeneratedFiles({ + artifactRoot: context.artifactRoot, + files: inspection.files, + ...(context.prebuiltPaths === undefined ? {} : { prebuiltPaths: context.prebuiltPaths }), + }), ]); }; diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index 1e295002b..1e1a433b9 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -1,5 +1,5 @@ import { stat } from 'node:fs/promises'; -import { basename, dirname, relative, resolve } from 'node:path'; +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import fastGlob from 'fast-glob'; @@ -14,8 +14,23 @@ export interface DiscoveredAsset { readonly source: string; } +/** One file found inside a declared prebuilt payload directory. */ +export interface DiscoveredPayloadFile { + readonly bytes: number; + readonly relativePath: string; + readonly source: string; +} + +/** One declared prebuilt payload directory with its enumerated files. */ +export interface DiscoveredPayload { + readonly files: readonly DiscoveredPayloadFile[]; + readonly name: string; + readonly source: string; +} + export interface DiscoveredProject { assets?: DiscoveredAsset[]; + payloads?: DiscoveredPayload[]; skills: SkillDocument[]; } @@ -82,6 +97,75 @@ const discoverAssets = async ( }))); }; +const isInsideRoot = (root: string, candidate: string): boolean => { + const relativePath = relative(root, candidate); + return relativePath.length > 0 && relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath); +}; + +/** + * The absolute source directories of well-shaped payload declarations. + * Source snapshots use this to include payload files in the project + * identity even though payload directories are ignored for source discovery. + */ +export const configuredPayloadRoots = ( + projectRoot: string, + config: Readonly, +): readonly string[] => { + const configured = config.payload; + if (configured === undefined || typeof configured !== 'object' || Array.isArray(configured)) return []; + const roots: string[] = []; + for (const declaration of Object.values(configured)) { + const entry = typeof declaration === 'string' ? declaration : declaration?.source; + if (typeof entry !== 'string' || entry.trim().length === 0) continue; + const source = resolve(projectRoot, entry); + if (isInsideRoot(projectRoot, source)) roots.push(source); + } + return [...new Set(roots)].sort((left, right) => left.localeCompare(right)); +}; + +/** + * Enumerates every file of each declared prebuilt payload directory. Ignore + * rules deliberately do not apply: payloads live inside build-output + * directories (`dist/` is mandatory-ignored for source discovery) and are + * packaged verbatim. Malformed declarations are skipped here — source + * validation reports them (AB4740-AB4742). + */ +const discoverPayloads = async ( + projectRoot: string, + configured: AgentBundleConfig['payload'], +): Promise => { + if (configured === undefined || typeof configured !== 'object' || Array.isArray(configured)) return []; + const payloads: DiscoveredPayload[] = []; + for (const [name, declaration] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) { + const entry = typeof declaration === 'string' ? declaration : declaration?.source; + if (typeof entry !== 'string' || entry.trim().length === 0) continue; + const source = resolve(projectRoot, entry); + if (!isInsideRoot(projectRoot, source)) continue; + let stats; + try { + stats = await stat(source); + } catch { + payloads.push({ files: [], name, source }); + continue; + } + if (!stats.isDirectory()) { + payloads.push({ files: [], name, source }); + continue; + } + const matches = (await fastGlob('**', { ...assetGlobOptions, cwd: source })).sort((left, right) => left.localeCompare(right)); + payloads.push({ + files: await Promise.all(matches.map(async (file) => ({ + bytes: (await stat(file)).size, + relativePath: relative(source, file).replaceAll('\\', '/'), + source: file, + }))), + name, + source, + }); + } + return payloads; +}; + export const discoverProject = async ( root: string, config: AgentBundleConfig, @@ -104,8 +188,10 @@ export const discoverProject = async ( .map((source) => (basename(source) === 'SKILL.md' ? dirname(source) : source)))] .sort((left, right) => left.localeCompare(right)); + const payloads = await discoverPayloads(projectRoot, config.payload); return { assets: await discoverAssets(projectRoot, config.assets, rules), + ...(payloads.length === 0 ? {} : { payloads }), skills: await Promise.all( skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules)), ), diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index 5d6ebd144..1a66f46ae 100644 --- a/packages/agent-bundle/src/config/index.ts +++ b/packages/agent-bundle/src/config/index.ts @@ -22,7 +22,11 @@ export type { AgentBundleDevConfig, AgentBundleDevRuntimeConfig, AgentBundleHostConfig, + AgentBundlePayloadConfig, + AgentBundlePayloadEntry, + AgentBundlePayloadInput, AgentBundlePortableConfig, + AgentBundlePrebuiltEntry, NormalizationConfigExtension, NormalizationTargetRegistry, AgentBundleMcpApp, @@ -33,6 +37,8 @@ export type { NormalizedMetadata, NormalizedMcpApp, NormalizedMcpServer, + NormalizedPayload, + NormalizedPayloadFile, NormalizedPlugin, NormalizedRuntime, NormalizedScript, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index cab329de8..807dd4122 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { existsSync, statSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; -import { basename, extname, relative, resolve } from 'node:path'; +import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path'; import { digest } from '../core/digest.ts'; import { @@ -10,7 +10,7 @@ import { parseRuntimeVersion, satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; -import { parseNativeHookToolSelector, pathTokens } from '../core/types.ts'; +import { isPrebuiltEntryInput, parseNativeHookToolSelector, pathTokens } from '../core/types.ts'; import type { AgentBundleBinEntry, AgentBundleConfig, @@ -19,6 +19,7 @@ import type { AgentBundleLibEntry, AgentBundleMcpApp, AgentBundleMcpServer, + AgentBundlePayloadEntry, AgentBundleScriptInput, CanonicalHookEvent, CanonicalHookTool, @@ -33,6 +34,7 @@ import type { NormalizedMcpServer, NormalizedNativeHook, NormalizedPackageBuild, + NormalizedPayload, NormalizedPlugin, NormalizedRuntime, NormalizedScript, @@ -204,6 +206,71 @@ export const normalizePackageBuild = ( }; }; +/** The compiler-owned artifact namespaces and documents a payload destination must not shadow. */ +export const reservedPayloadDestinations = Object.freeze(new Set([ + 'AGENTS.md', + 'assets', + 'hooks', + 'mcp', + 'mcp-apps', + 'mcp.json', + 'plugin.json', + 'scripts', + 'skills', +])); + +const normalizePayloads = ( + loaded: LoadedConfig, + discovered: DiscoveredProject, + targetNames: readonly string[], +): readonly NormalizedPayload[] => { + const configured = loaded.config.payload; + if (configured === undefined || typeof configured !== 'object' || Array.isArray(configured)) return []; + const discoveredByName = new Map((discovered.payloads ?? []).map((payload) => [payload.name, payload])); + const payloads: NormalizedPayload[] = []; + for (const [name, rawDeclaration] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) { + const declaration = rawDeclaration as string | AgentBundlePayloadEntry | undefined; + if (declaration === undefined) continue; + const entry = typeof declaration === 'string' ? declaration : declaration.source; + if (typeof entry !== 'string' || entry.trim().length === 0) continue; + payloads.push({ + files: (discoveredByName.get(name)?.files ?? []).map((file) => ({ ...file })), + id: `payload:${name}`, + name, + provenance: { kind: 'prebuilt', sourcePath: loaded.configPath }, + source: resolve(loaded.context.projectRoot, entry), + targets: sortedUnique(typeof declaration === 'string' ? targetNames : (declaration.targets ?? targetNames)), + }); + } + return payloads; +}; + +const isInsidePath = (root: string, candidate: string): boolean => { + const relativePath = relative(root, candidate); + return relativePath.length > 0 && relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath); +}; + +/** + * The artifact-relative stable path of a prebuilt file: its declaring payload + * destination plus the file's payload-relative path. Falls back to the + * project-relative path when no declared payload contains the file — source + * validation (AB4744) reports that state before any build consumes it. + */ +export const prebuiltArtifactPath = ( + payloads: readonly NormalizedPayload[], + root: string, + source: string, +): string => { + let best: NormalizedPayload | undefined; + for (const payload of payloads) { + if (!isInsidePath(payload.source, source)) continue; + if (best === undefined || payload.source.length > best.source.length) best = payload; + } + return best === undefined + ? relative(root, source).replaceAll('\\', '/') + : `${best.name}/${relative(best.source, source).replaceAll('\\', '/')}`; +}; + const isHookEntryList = ( input: AgentBundleHookInput, ): input is readonly (string | AgentBundleHookEntry)[] => Array.isArray(input); @@ -237,10 +304,16 @@ const normalizeHook = ( defaultTargets: readonly string[], provenance: SourceProvenance, registry: NormalizationTargetRegistry, + payloads: readonly NormalizedPayload[], ): NormalizedHook => { const entry = typeof input === 'string' ? { handler: input } : input; - const source = resolve(root, entry.handler); + const prebuilt = isPrebuiltEntryInput(entry.handler); + const source = resolve(root, prebuilt ? (entry.handler as { prebuilt: string }).prebuilt : entry.handler as string); const handler = relative(root, source).replaceAll('\\', '/'); + const prebuiltPath = prebuilt ? prebuiltArtifactPath(payloads, root, source) : undefined; + const args = prebuilt && entry.args !== undefined + ? entry.args.filter((argument): argument is string => typeof argument === 'string') + : undefined; const tools = sortedUnique(entry.tools ?? []).filter( (tool): tool is CanonicalHookTool => knownHookTools.has(tool as CanonicalHookTool), ); @@ -248,9 +321,11 @@ const normalizeHook = ( const nativeTools = normalizeNativeHookTools(entry.tools ?? [], registry); const timeout = entry.timeout; const identity = { + ...(args === undefined || args.length === 0 ? {} : { args }), event, handler, ...(nativeTools.length === 0 ? {} : { nativeTools }), + ...(prebuiltPath === undefined ? {} : { prebuiltPath }), targets, timeout: timeout ?? 'host-default', tools, @@ -261,11 +336,13 @@ const normalizeHook = ( const name = `${eventName}-${handlerName}-${hash}`; return { + ...(args === undefined || args.length === 0 ? {} : { args: [...args] }), event, id: `hook:${eventName}:${handlerName}:${hash}`, name, ...(nativeTools.length === 0 ? {} : { nativeTools }), - provenance: { ...provenance }, + ...(prebuiltPath === undefined ? {} : { prebuiltPath }), + provenance: prebuilt ? { kind: 'prebuilt', sourcePath: provenance.sourcePath } : { ...provenance }, source, targets, ...(timeout === undefined ? {} : { timeout }), @@ -277,6 +354,7 @@ const normalizeHooks = ( loaded: LoadedConfig, targetNames: readonly string[], registry: NormalizationTargetRegistry, + payloads: readonly NormalizedPayload[], ): readonly NormalizedHook[] => { const hooks: NormalizedHook[] = []; const config = loaded.config.hooks; @@ -291,7 +369,7 @@ const normalizeHooks = ( const hookTargets = inherited ? targetNames.filter((target) => registry.supports(target, 'hooks')) : targetNames; - hooks.push(normalizeHook(event, entry, loaded.context.projectRoot, hookTargets, provenance, registry)); + hooks.push(normalizeHook(event, entry, loaded.context.projectRoot, hookTargets, provenance, registry, payloads)); } } @@ -342,6 +420,7 @@ const normalizeMcpServer = ( root: string, defaultTargets: readonly string[], provenance: SourceProvenance, + payloads: readonly NormalizedPayload[], ): NormalizedMcpServer => { const targets = sortedUnique(server.targets ?? defaultTargets); const conventionalEntry = @@ -357,6 +436,26 @@ const normalizeMcpServer = ( targets, }; + if (isPrebuiltEntryInput(server.entry)) { + // A prebuilt stdio entry lowers to a command-shaped server whose first + // argument is the payload-stable path anchored on the plugin-root token, + // so every adapter's existing token expansion, env-anchor injection, and + // artifact-reference validation applies unchanged. + const prebuiltSource = resolve(root, server.entry.prebuilt); + return { + ...common, + ...(server.env === undefined ? {} : { env: { ...server.env } }), + args: [ + `${pathTokens.pluginRoot}/${prebuiltArtifactPath(payloads, root, prebuiltSource)}`, + ...(server.args ?? []), + ], + command: 'node', + cwd: pathTokens.pluginRoot, + provenance: { kind: 'prebuilt', sourcePath: provenance.sourcePath }, + transport: 'stdio', + }; + } + if (server.entry !== undefined || conventionalEntry !== undefined) { const entryName = mcpEntryName(name); return { @@ -392,6 +491,7 @@ const normalizeMcpServer = ( const normalizeMcpServers = ( loaded: LoadedConfig, targetNames: readonly string[], + payloads: readonly NormalizedPayload[], ): readonly NormalizedMcpServer[] => { const servers = loaded.config.mcp?.servers; if (servers === undefined) return []; @@ -400,7 +500,7 @@ const normalizeMcpServers = ( return Object.entries(servers) .sort(([left], [right]) => left.localeCompare(right)) .map(([name, server]) => - normalizeMcpServer(name, server, loaded.context.projectRoot, targetNames, provenance)); + normalizeMcpServer(name, server, loaded.context.projectRoot, targetNames, provenance, payloads)); }; const normalizeMcpApps = ( @@ -415,13 +515,18 @@ const normalizeMcpApps = ( for (const [serverName, rawServer] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) { const server = serverByName.get(serverName); - if (server?.source === undefined || rawServer.apps === undefined) continue; + // Apps require a local server entry: a compiled source entry, or a + // prebuilt one — whose payload already carries the served resource, so + // the app stays a development surface the compiler never re-emits. + const prebuilt = isPrebuiltEntryInput(rawServer.entry); + if (server === undefined || (server.source === undefined && !prebuilt) || rawServer.apps === undefined) continue; for (const [name, app] of Object.entries(rawServer.apps).sort(([left], [right]) => left.localeCompare(right))) { const declaration = app as AgentBundleMcpApp; apps.push({ ...(declaration._meta === undefined ? {} : { _meta: structuredClone(declaration._meta) }), id: `mcp-app:${serverName}:${name}`, name, + ...(prebuilt ? { prebuilt: true as const } : {}), provenance: { ...provenance }, resourceUri: declaration.resourceUri, serverId: server.id, @@ -666,7 +771,8 @@ export const normalizeProject = async ( }); const description = loaded.config.plugin.description; const nativeHooks = await normalizeNativeHooks(loaded, targetNames, registry); - const mcpServers = normalizeMcpServers(loaded, targetNames); + const payloads = normalizePayloads(loaded, discovered, targetNames); + const mcpServers = normalizeMcpServers(loaded, targetNames, payloads); const scripts = normalizeScripts(loaded, targetNames); const assets = normalizeAssets(loaded, discovered, targetNames); const packageBuild = normalizePackageBuild(loaded.config, loaded.context.projectRoot, loaded.configPath); @@ -683,9 +789,10 @@ export const normalizeProject = async ( }, mcpApps: normalizeMcpApps(loaded, mcpServers), mcpServers, - hooks: normalizeHooks(loaded, targetNames, registry), + hooks: normalizeHooks(loaded, targetNames, registry, payloads), ...(nativeHooks.length === 0 ? {} : { nativeHooks }), ...(packageBuild === undefined ? {} : { packageBuild }), + ...(payloads.length === 0 ? {} : { payloads }), runtime: normalizeRuntime(loaded), scripts, skills, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index d61a82768..c30494127 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { basename, extname, isAbsolute, posix, relative, resolve, sep } from 'node:path'; import { scanEntryExportsSource } from '../build/entry-exports.ts'; @@ -10,7 +10,7 @@ import { parseRuntimeVersion, satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; -import { parseNativeHookToolSelector } from '../core/types.ts'; +import { isPrebuiltEntryInput, parseNativeHookToolSelector } from '../core/types.ts'; import type { AgentBundleBinEntry, AgentBundleHookEntry, @@ -18,6 +18,8 @@ import type { AgentBundleLibEntry, AgentBundleMcpApp, AgentBundleMcpServer, + AgentBundlePayloadEntry, + AgentBundlePrebuiltEntry, AgentBundleScriptInput, CanonicalHookEvent, NormalizationTargetRegistry, @@ -27,6 +29,7 @@ import { conventionalCliEntrySource, conventionalIndexEntrySource, conventionalMcpEntrySource, + reservedPayloadDestinations, } from './normalize.ts'; import type { DiscoveredProject } from './discover.ts'; import type { LoadedConfig } from './load.ts'; @@ -71,6 +74,7 @@ const asHookEntries = (input: AgentBundleHookInput): readonly (string | AgentBun const validateHooks = ( loaded: LoadedConfig, registry: NormalizationTargetRegistry, + payloads: readonly DeclaredPayload[], ): Diagnostic[] => { const hooks = loaded.config.hooks; if (hooks === undefined) return []; @@ -84,13 +88,43 @@ const validateHooks = ( if (input === undefined) continue; for (const rawEntry of asHookEntries(input)) { const entry = typeof rawEntry === 'string' ? { handler: rawEntry } : rawEntry; - if (typeof entry.handler !== 'string' || entry.handler.trim().length === 0) { + const prebuilt = isPrebuiltEntryInput(entry.handler); + if (prebuilt) { + const hookTargets = Array.isArray(entry.targets) && entry.targets.every(nonemptyString) + ? entry.targets + : selectedTargets.filter((target) => registry.supports(target, 'hooks')); + diagnostics.push(...validatePrebuiltReference( + `Hook ${event}`, + entry.handler as AgentBundlePrebuiltEntry, + hookTargets, + loaded, + payloads, + )); + } else if (typeof entry.handler !== 'string' || entry.handler.trim().length === 0) { diagnostics.push(sourceDiagnostic( 'AB4200', `Hook ${event} requires a nonempty handler path.`, loaded.configPath, )); } + if (entry.args !== undefined) { + if (!prebuilt) { + diagnostics.push(sourceDiagnostic( + 'AB4746', + `Hook ${event} declares arguments, but only prebuilt handlers accept arguments.`, + loaded.configPath, + )); + } else if ( + !Array.isArray(entry.args) || + entry.args.some((argument) => typeof argument !== 'string' || !safePrebuiltArgumentPattern.test(argument)) + ) { + diagnostics.push(sourceDiagnostic( + 'AB4746', + `Hook ${event} arguments must be shell-safe strings (letters, digits, and %+,-./:=@_).`, + loaded.configPath, + )); + } + } if (entry.tools !== undefined && event !== 'beforeTool' && event !== 'afterTool') { diagnostics.push(sourceDiagnostic( 'AB4201', @@ -587,6 +621,8 @@ const validateMcpServer = ( name: string, value: unknown, loaded: LoadedConfig, + registry: NormalizationTargetRegistry, + payloads: readonly DeclaredPayload[], ): Diagnostic[] => { const diagnostics: Diagnostic[] = []; if (!nonemptyString(name)) { @@ -638,7 +674,18 @@ const validateMcpServer = ( diagnostics.push(...validateStringList(server.targets, 'targets', 'AB4305', loaded)); if (entry !== undefined || conventionalEntry !== undefined) { - if (entry !== undefined && !nonemptyString(entry)) { + if (isPrebuiltEntryInput(entry)) { + const serverTargets = Array.isArray(server.targets) && server.targets.every(nonemptyString) + ? server.targets + : selectedTargetNamesFor(loaded, registry); + diagnostics.push(...validatePrebuiltReference( + `MCP server ${JSON.stringify(name)}`, + entry, + serverTargets, + loaded, + payloads, + )); + } else if (entry !== undefined && !nonemptyString(entry)) { diagnostics.push(sourceDiagnostic('AB4306', `MCP server ${JSON.stringify(name)} entry must be a nonempty path.`, loaded.configPath)); } else if (entry !== undefined && !localEntryExists(loaded.context.projectRoot, entry)) { diagnostics.push(sourceDiagnostic('AB4307', `MCP server ${JSON.stringify(name)} entry does not exist.`, loaded.configPath)); @@ -654,7 +701,9 @@ const validateMcpServer = ( } diagnostics.push(...validateStringList(server.args, 'args', 'AB4311', loaded)); diagnostics.push(...validateStringRecord(server.env, 'env', 'AB4312', loaded)); - diagnostics.push(...selfConnectingEntryNudge(name, entry, conventionalEntry, loaded)); + if (!isPrebuiltEntryInput(entry)) { + diagnostics.push(...selfConnectingEntryNudge(name, entry, conventionalEntry, loaded)); + } return diagnostics; } @@ -750,7 +799,11 @@ const validateRuntime = (loaded: LoadedConfig): Diagnostic[] => { return []; }; -const validateMcp = (loaded: LoadedConfig): Diagnostic[] => { +const validateMcp = ( + loaded: LoadedConfig, + registry: NormalizationTargetRegistry, + payloads: readonly DeclaredPayload[], +): Diagnostic[] => { const mcp = loaded.config.mcp; if (mcp === undefined) return []; if (!isRecord(mcp)) { @@ -762,7 +815,7 @@ const validateMcp = (loaded: LoadedConfig): Diagnostic[] => { const names = new Map(); const uris = new Map(); return Object.entries(mcp.servers).flatMap(([name, server]) => { - const diagnostics = validateMcpServer(name, server, loaded); + const diagnostics = validateMcpServer(name, server, loaded, registry, payloads); return isRecord(server) ? [...diagnostics, ...validateMcpApps(name, server as AgentBundleMcpServer, loaded, names, uris)] : diagnostics; @@ -936,6 +989,279 @@ const packageConventionShadowNudges = (loaded: LoadedConfig): Diagnostic[] => { return diagnostics; }; +const warningDiagnostic = ( + code: string, + message: string, + sourcePath: string, + recovery: string, +): Diagnostic => ({ code, message, recovery, severity: 'warning', sourcePath }); + +const isSafePayloadName = (name: string): boolean => + /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(name); + +interface DeclaredPayload { + readonly name: string; + /** Absolute source directory. */ + readonly source: string; + readonly targets: readonly string[]; +} + +const selectedTargetNamesFor = ( + loaded: LoadedConfig, + registry: NormalizationTargetRegistry, +): readonly string[] => loaded.context.selectedTargets.length > 0 + ? loaded.context.selectedTargets + : (loaded.config.targets ?? registry.defaultTargetNames()); + +/** Well-shaped payload declarations; malformed entries are reported by validatePayload and skipped here. */ +const declaredPayloads = ( + loaded: LoadedConfig, + registry: NormalizationTargetRegistry, +): readonly DeclaredPayload[] => { + const configured = loaded.config.payload; + if (configured === undefined || !isRecord(configured)) return []; + const selectedTargets = selectedTargetNamesFor(loaded, registry); + const payloads: DeclaredPayload[] = []; + for (const [name, rawDeclaration] of Object.entries(configured)) { + const declaration = rawDeclaration as string | AgentBundlePayloadEntry; + const entry = typeof declaration === 'string' + ? declaration + : isRecord(declaration) ? declaration.source : undefined; + if (!nonemptyString(entry)) continue; + const source = resolve(loaded.context.projectRoot, entry); + if (!isInside(loaded.context.projectRoot, source)) continue; + const targets = typeof declaration === 'string' ? undefined : declaration.targets; + payloads.push({ + name, + source, + targets: Array.isArray(targets) && targets.every(nonemptyString) ? targets : selectedTargets, + }); + } + return payloads; +}; + +const directoryHasFiles = (source: string): boolean => { + try { + const entries = readdirSync(source, { withFileTypes: true }); + return entries.some((entry) => entry.isFile() || (entry.isDirectory() && directoryHasFiles(resolve(source, entry.name)))); + } catch { + return false; + } +}; + +const newestFileMtime = (root: string, skipDirectory: (name: string) => boolean): number => { + let newest = 0; + const visit = (directory: string): void => { + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + if (!skipDirectory(entry.name)) visit(path); + continue; + } + if (!entry.isFile()) continue; + try { + const mtime = statSync(path).mtimeMs; + if (mtime > newest) newest = mtime; + } catch { + // A racing deletion never fails validation. + } + } + }; + visit(root); + return newest; +}; + +const ignoredSourceDirectoryNames = new Set(['.agent-bundle', '.git', 'dist', 'node_modules']); + +/** + * AB4740-AB4743 and the AB4750 freshness nudge: shape, destination-name, + * source-path, and existence checks for the prebuilt `payload` block. + * Missing or empty payloads warn here (development flows never require the + * consumer's own build to have run); `agent-bundle build` refuses them with + * AB4747/AB4748. + */ +const validatePayload = ( + loaded: LoadedConfig, + registry: NormalizationTargetRegistry, +): Diagnostic[] => { + const configured = loaded.config.payload; + if (configured === undefined) return []; + if (!isRecord(configured)) { + return [sourceDiagnostic('AB4740', 'Payload configuration must be an object of payload directories.', loaded.configPath)]; + } + const diagnostics: Diagnostic[] = []; + const sources: { name: string; source: string }[] = []; + for (const [name, rawDeclaration] of Object.entries(configured)) { + if (!isSafePayloadName(name) || reservedPayloadDestinations.has(name)) { + diagnostics.push(sourceDiagnostic( + 'AB4741', + `Payload destination ${JSON.stringify(name)} must be a safe directory name outside the compiler-owned artifact namespaces.`, + loaded.configPath, + )); + } + const declaration = rawDeclaration as string | AgentBundlePayloadEntry; + const entry = typeof declaration === 'string' + ? declaration + : isRecord(declaration) ? declaration.source : undefined; + if (!nonemptyString(entry)) { + diagnostics.push(sourceDiagnostic( + 'AB4740', + `Payload ${JSON.stringify(name)} must be a source directory path or an object with a source path.`, + loaded.configPath, + )); + continue; + } + if (typeof declaration !== 'string' && declaration.targets !== undefined) { + if (!Array.isArray(declaration.targets) || declaration.targets.some((target) => !nonemptyString(target))) { + diagnostics.push(sourceDiagnostic( + 'AB4740', + `Payload ${JSON.stringify(name)} targets must be an array of nonempty strings.`, + loaded.configPath, + )); + } else { + for (const target of declaration.targets) { + if (!registry.has(target)) { + diagnostics.push(sourceDiagnostic( + 'AB4740', + `Payload ${JSON.stringify(name)} selects unknown target ${JSON.stringify(target)}.`, + loaded.configPath, + )); + } + } + } + } + const source = resolve(loaded.context.projectRoot, entry); + if (!isInside(loaded.context.projectRoot, source)) { + diagnostics.push(sourceDiagnostic( + 'AB4742', + `Payload ${JSON.stringify(name)} source must resolve inside the project root.`, + loaded.configPath, + )); + continue; + } + sources.push({ name, source }); + if (!existsSync(source)) { + diagnostics.push(warningDiagnostic( + 'AB4743', + `Payload ${JSON.stringify(name)} directory ${JSON.stringify(entry)} does not exist yet.`, + loaded.configPath, + 'Run the project\'s own build to produce the prebuilt payload before "agent-bundle build".', + )); + continue; + } + if (!statSync(source).isDirectory()) { + diagnostics.push(sourceDiagnostic( + 'AB4742', + `Payload ${JSON.stringify(name)} source ${JSON.stringify(entry)} must name a directory.`, + loaded.configPath, + )); + continue; + } + if (!directoryHasFiles(source)) { + diagnostics.push(warningDiagnostic( + 'AB4743', + `Payload ${JSON.stringify(name)} directory ${JSON.stringify(entry)} contains no files.`, + loaded.configPath, + 'Run the project\'s own build to produce the prebuilt payload before "agent-bundle build".', + )); + } + } + for (const left of sources) { + for (const right of sources) { + if (left === right) continue; + const duplicate = left.source === right.source && left.name < right.name; + const nested = left.source !== right.source && isInside(left.source, right.source); + if (!duplicate && !nested) continue; + diagnostics.push(sourceDiagnostic( + 'AB4742', + `Payload ${JSON.stringify(left.name)} source contains payload ${JSON.stringify(right.name)}; payload directories must be disjoint.`, + loaded.configPath, + )); + } + } + const existing = sources.filter((payload) => existsSync(payload.source)); + if (existing.length > 0) { + const newestSource = newestFileMtime( + loaded.context.projectRoot, + (name) => ignoredSourceDirectoryNames.has(name), + ); + for (const payload of existing) { + const newestPayload = newestFileMtime(payload.source, () => false); + if (newestPayload !== 0 && newestSource > newestPayload) { + diagnostics.push({ + code: 'AB4750', + message: `Prebuilt payload ${JSON.stringify(payload.name)} is older than the newest project source file; it may be stale.`, + recovery: 'Optional: rerun the project\'s own build so the packaged payload reflects the current sources.', + severity: 'info', + sourcePath: loaded.configPath, + }); + } + } + } + return diagnostics; +}; + +const safePrebuiltArgumentPattern = /^[A-Za-z0-9%+,\-./:=@_]+$/u; + +/** + * AB4744/AB4745: one prebuilt reference (an MCP entry or a hook handler) + * must resolve inside a declared payload whose targets cover the component, + * and should already exist on disk. Missing files warn — the payload comes + * from the consumer's own build step — and `agent-bundle build` refuses them + * with AB4748. + */ +const validatePrebuiltReference = ( + label: string, + declaration: AgentBundlePrebuiltEntry, + componentTargets: readonly string[], + loaded: LoadedConfig, + payloads: readonly DeclaredPayload[], +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + if (!nonemptyString(declaration.prebuilt)) { + return [sourceDiagnostic('AB4744', `${label} prebuilt entry must be a nonempty path.`, loaded.configPath)]; + } + const source = resolve(loaded.context.projectRoot, declaration.prebuilt); + if (!isInside(loaded.context.projectRoot, source)) { + return [sourceDiagnostic('AB4744', `${label} prebuilt entry must resolve inside the project root.`, loaded.configPath)]; + } + const payload = payloads + .filter((candidate) => isInside(candidate.source, source)) + .sort((left, right) => right.source.length - left.source.length)[0]; + if (payload === undefined) { + diagnostics.push(sourceDiagnostic( + 'AB4744', + `${label} prebuilt entry ${JSON.stringify(declaration.prebuilt)} must resolve inside a directory declared in the payload block.`, + loaded.configPath, + )); + return diagnostics; + } + for (const target of componentTargets) { + if (!payload.targets.includes(target)) { + diagnostics.push(sourceDiagnostic( + 'AB4744', + `${label} prebuilt entry needs payload ${JSON.stringify(payload.name)} on target ${JSON.stringify(target)}, but the payload does not select it.`, + loaded.configPath, + )); + } + } + if (!localEntryExists(loaded.context.projectRoot, declaration.prebuilt)) { + diagnostics.push(warningDiagnostic( + 'AB4745', + `${label} prebuilt entry ${JSON.stringify(declaration.prebuilt)} does not exist yet.`, + loaded.configPath, + 'Run the project\'s own build to produce the prebuilt file before "agent-bundle build".', + )); + } + return diagnostics; +}; + const isRspackHatchValue = (value: unknown): boolean => typeof value === 'function' || isRecord(value); @@ -1026,11 +1352,13 @@ export const validateSource = ( } } + const payloads = declaredPayloads(loaded, registry); diagnostics.push(...validateAssets(loaded)); diagnostics.push(...validateBin(loaded)); - diagnostics.push(...validateHooks(loaded, registry)); + diagnostics.push(...validateHooks(loaded, registry, payloads)); diagnostics.push(...validateLib(loaded)); - diagnostics.push(...validateMcp(loaded)); + diagnostics.push(...validateMcp(loaded, registry, payloads)); + diagnostics.push(...validatePayload(loaded, registry)); diagnostics.push(...validateRuntime(loaded)); diagnostics.push(...validateScripts(loaded, registry)); diagnostics.push(...validateTools(loaded)); @@ -1070,6 +1398,7 @@ export const validateModel = ( ...(model.assets ?? []), ...(model.packageBuild?.bins ?? []), ...(model.packageBuild?.lib === undefined ? [] : [model.packageBuild.lib]), + ...(model.payloads ?? []), ]; for (const component of components) { const firstSource = ids.get(component.id); @@ -1238,6 +1567,12 @@ export const validateModel = ( if (!asset.targets.includes(target.name)) continue; recordOutput(posix.join(target.name, 'assets', asset.relativePath), asset.source, target.name); } + for (const payload of model.payloads ?? []) { + if (!payload.targets.includes(target.name)) continue; + for (const file of payload.files) { + recordOutput(posix.join(target.name, payload.name, file.relativePath), file.source, target.name); + } + } } return diagnostics; diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 502cdeb7d..ae5c35e20 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -96,7 +96,13 @@ const modelPathReferences = (model: NormalizedPlugin): readonly string[] => [ ...(model.assets ?? []).flatMap((asset) => [asset.provenance.sourcePath, asset.source]), ...Object.values(model.extensions).map((extension) => extension.provenance.sourcePath), ...model.targets.map((target) => target.provenance.sourcePath), - ...model.hooks.flatMap((hook) => [hook.provenance.sourcePath, hook.source]), + // A prebuilt hook's source is its payload file, which may not exist yet + // (the payload comes from the consumer's own build step); its bytes join + // the identity through the enumerated payload files instead. + ...model.hooks.flatMap((hook) => [ + hook.provenance.sourcePath, + ...(hook.prebuiltPath === undefined ? [hook.source] : []), + ]), ...model.skills.flatMap((skill) => [ skill.dir, skill.provenance.sourcePath, @@ -119,6 +125,13 @@ const modelPathReferences = (model: NormalizedPlugin): readonly string[] => [ ...(model.packageBuild?.lib === undefined ? [] : [model.packageBuild.lib.provenance.sourcePath, model.packageBuild.lib.source]), + // The payload source directory is deliberately absent: a declared payload + // may not exist yet (its files list is then empty), and the enumerated + // files below carry the byte-level identity. + ...(model.payloads ?? []).flatMap((payload) => [ + payload.provenance.sourcePath, + ...payload.files.map((file) => file.source), + ]), ]; const assertModelPathsResolveInsideProject = (root: string, model: NormalizedPlugin): void => { @@ -194,6 +207,19 @@ export const canonicalizeNormalizedModel = ( source: canonicalCompilerPath(root, hook.source, 'Native hook source path'), })), }), + ...(detached.payloads === undefined + ? {} + : { + payloads: detached.payloads.map((payload) => ({ + ...payload, + files: payload.files.map((file) => ({ + ...file, + source: canonicalCompilerPath(root, file.source, 'Payload file source path'), + })), + provenance: canonicalProvenance(root, payload.provenance), + source: canonicalCompilerPath(root, payload.source, 'Payload source path'), + })), + }), ...(detached.packageBuild === undefined ? {} : { diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 5b5105b76..e7c289a20 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -30,8 +30,28 @@ export const parseNativeHookToolSelector = (value: string): NativeHookToolSelect return target.length === 0 || name.length === 0 ? undefined : { name, target }; }; +/** + * The prebuilt marker: names an already-built file the framework packages + * as-is instead of compiling. The file must live inside a directory declared + * in the top-level `payload` block; its artifact path is the payload + * destination plus the file's payload-relative path, so consumer-pinned + * stable paths survive packaging. + */ +export interface AgentBundlePrebuiltEntry { + prebuilt: string; +} + +/** True when a config entry value is the prebuilt marker object. */ +export const isPrebuiltEntryInput = (value: unknown): value is AgentBundlePrebuiltEntry => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as { readonly prebuilt?: unknown }).prebuilt === 'string'; + export interface AgentBundleHookEntry { - handler: string; + /** Extra command arguments. Only prebuilt handlers accept arguments. */ + args?: readonly string[]; + handler: string | AgentBundlePrebuiltEntry; targets?: readonly string[]; /** Native hook timeout in seconds. Omit it to use the selected host's default. */ timeout?: number; @@ -53,7 +73,7 @@ export interface AgentBundleMcpServer { args?: readonly string[]; command?: string; cwd?: string; - entry?: string; + entry?: string | AgentBundlePrebuiltEntry; /** * Extra environment for stdio servers. Adapters inject the well-known * plugin-root anchor (see pluginRootEnvAnchor) beneath these entries, so a @@ -100,6 +120,25 @@ export interface AgentBundleScriptEntry { targets?: readonly string[]; } +/** One declared prebuilt payload directory with an optional target restriction. */ +export interface AgentBundlePayloadEntry { + source: string; + targets?: readonly string[]; +} + +export type AgentBundlePayloadInput = string | AgentBundlePayloadEntry; + +/** + * Prebuilt payload trees packaged byte-for-byte into selected target + * artifacts. Key = artifact-root destination directory (a safe single path + * segment outside the compiler-owned namespaces), value = the already-built + * source directory. Every file keeps its exact relative path — payload trees + * are opaque to the compiler, which cannot rewrite their internal sibling + * references, so stable names are the correctness contract; integrity stays + * content-addressed through the artifact manifest and project source inputs. + */ +export type AgentBundlePayloadConfig = Readonly>; + /** One npm-facing CLI binary compiled into the framework-owned package build. */ export interface AgentBundleBinEntry { entry: string; @@ -170,6 +209,7 @@ export interface AgentBundleConfig extends AgentBundleConfigExtensions { lib?: AgentBundleLibConfig; marketplace?: boolean; mcp?: AgentBundleMcpConfig; + payload?: AgentBundlePayloadConfig; plugin: AgentBundlePluginConfig; runtime?: AgentBundleRuntimeConfig; scripts?: Readonly>; @@ -179,7 +219,7 @@ export interface AgentBundleConfig extends AgentBundleConfigExtensions { [key: string]: unknown; } -export type ProvenanceKind = 'config' | 'conventional' | 'explicit'; +export type ProvenanceKind = 'config' | 'conventional' | 'explicit' | 'prebuilt'; export interface SourceProvenance { readonly kind: ProvenanceKind; @@ -252,6 +292,12 @@ export interface NormalizedMcpApp { readonly _meta?: Readonly>; readonly id: string; readonly name: string; + /** + * True when the owning server's prebuilt payload already contains the + * served resource: the app stays a development surface and the compiler + * emits no `mcp-apps/` output for it. + */ + readonly prebuilt?: true; readonly provenance: SourceProvenance; readonly resourceUri: string; readonly serverId: string; @@ -299,11 +345,19 @@ export interface NormalizedPackageBuild { } export interface NormalizedHook { + /** Extra command arguments appended after the handler path; prebuilt hooks only. */ + readonly args?: readonly string[]; readonly event: CanonicalHookEvent; readonly id: string; readonly name: string; /** Host-native tools selected explicitly per target, alongside the canonical selectors. */ readonly nativeTools?: readonly NativeHookToolSelector[]; + /** + * Artifact-relative POSIX path of a prebuilt handler inside a declared + * payload. Present only for prebuilt hooks: adapters point the native + * command at this stable path instead of compiling a wrapper. + */ + readonly prebuiltPath?: string; readonly provenance: SourceProvenance; readonly source: string; readonly targets: readonly string[]; @@ -312,6 +366,27 @@ export interface NormalizedHook { readonly tools: readonly CanonicalHookTool[]; } +/** One file of a prebuilt payload directory, copied byte-for-byte. */ +export interface NormalizedPayloadFile { + readonly bytes: number; + /** POSIX path relative to the payload source directory (and its artifact destination). */ + readonly relativePath: string; + /** Absolute source file path. */ + readonly source: string; +} + +/** One declared prebuilt payload directory packaged verbatim into target artifacts. */ +export interface NormalizedPayload { + readonly files: readonly NormalizedPayloadFile[]; + readonly id: string; + /** The artifact-root destination directory name. */ + readonly name: string; + readonly provenance: SourceProvenance; + /** Absolute payload source directory. */ + readonly source: string; + readonly targets: readonly string[]; +} + export interface NormalizedNativeHook { readonly document?: unknown; readonly issue?: 'missing' | 'parse' | 'source-error' | 'source-invalid'; @@ -357,6 +432,12 @@ export interface NormalizedPlugin { * models predating the package build stay valid. */ readonly packageBuild?: NormalizedPackageBuild; + /** + * Declared prebuilt payload directories packaged verbatim. Present only + * when the config declares a `payload` block; optional so hand-constructed + * models predating prebuilt payloads stay valid. + */ + readonly payloads?: readonly NormalizedPayload[]; /** The generated-executable runtime floor selected during normalization. */ readonly runtime: NormalizedRuntime; readonly scripts: readonly NormalizedScript[]; diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index f651c5b12..2eb84ef7f 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -3,7 +3,7 @@ import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; -import { discoverProject } from '../config/discover.ts'; +import { configuredPayloadRoots, discoverProject } from '../config/discover.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from '../config/ignore.ts'; import { loadConfig } from '../config/load.ts'; import { normalizeEvalConfig } from '../eval/config.ts'; @@ -230,10 +230,38 @@ const sourcePaths = async (root: string, outputRoots: readonly string[]): Promis return Object.freeze(paths.sort((left, right) => left.localeCompare(right))); }; +const payloadSourcePaths = async ( + root: string, + payloadRoots: readonly string[], +): Promise => { + const paths: string[] = []; + const visit = async (directory: string): Promise => { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + // A payload that does not exist yet contributes no source inputs. + return; + } + for (const entry of entries) { + const source = join(directory, entry.name); + if (entry.isDirectory()) await visit(source); + else if (entry.isFile()) paths.push(source); + } + }; + for (const payloadRoot of payloadRoots) { + const resolved = resolve(root, payloadRoot); + if (containedPathComponents(root, resolved) === undefined) continue; + await visit(resolved); + } + return Object.freeze(paths.sort((left, right) => left.localeCompare(right))); +}; + export const snapshotProjectSource = async ( root: string, configPath: string, outputRoots: readonly string[] = [], + payloadRoots: readonly string[] = [], ): Promise => { const requestedRoot = resolve(root); const resolvedRoot = await realpath(requestedRoot); @@ -242,7 +270,14 @@ export const snapshotProjectSource = async ( relativeSourcePath(requestedRoot, requestedConfigPath); const resolvedConfigPath = await realpath(requestedConfigPath); relativeSourcePath(resolvedRoot, resolvedConfigPath); - const sources = new Set([resolvedConfigPath, ...(await sourcePaths(resolvedRoot, resolvedOutputRoots))]); + // Declared prebuilt payload files join the identity even though payload + // directories are ignored for source discovery: the artifact packages + // their exact bytes, so the project revision must change with them. + const sources = new Set([ + resolvedConfigPath, + ...(await sourcePaths(resolvedRoot, resolvedOutputRoots)), + ...(await payloadSourcePaths(resolvedRoot, payloadRoots)), + ]); const inputs = Object.freeze((await Promise.all([...sources].map((source) => sourceInput(resolvedRoot, source)))) .sort((left, right) => left.path.localeCompare(right.path))); return Object.freeze({ @@ -493,6 +528,7 @@ const preparedProject = ( devRuntimeDiagnostic?: Diagnostic, devAgentApiEnabled?: boolean, tools?: AgentBundleToolsConfig, + snapshotSource?: () => Promise, ): PreparedProject => Object.freeze({ configPath, ...(devAgentApiEnabled === true ? { devAgentApiEnabled } : {}), @@ -504,6 +540,7 @@ const preparedProject = ( ...(projectContext === undefined ? {} : { projectContext }), registry, root, + ...(snapshotSource === undefined ? {} : { snapshotSource }), source, ...(tools === undefined ? {} : { tools }), }); @@ -658,9 +695,10 @@ export class ProjectService { return failedPreparation('AB7000', 'Unable to load project source.', configPath, 'project.invalid-source', snapshot); } + const payloadRoots = configuredPayloadRoots(root, loaded.config); let snapshot: ProjectSourceSnapshot; try { - snapshot = await snapshotProjectSource(root, loaded.configPath, outputRoots); + snapshot = await snapshotProjectSource(root, loaded.configPath, outputRoots, payloadRoots); } catch { return failedPreparation('AB7003', 'Unable to snapshot project source.', loaded.configPath, 'project.invalid-source'); } @@ -718,7 +756,17 @@ export class ProjectService { let diagnostics: Diagnostic[]; try { - diagnostics = [...validateModel(model, registry)]; + // Source warnings (for example a declared-but-unbuilt prebuilt payload) + // surface through `validate`, where an operator asks for exactly this + // judgment. Development flows keep running without them — a payload + // that has not been built yet is a normal dev state — and builds are + // separately guarded by their own hard refusals. + diagnostics = [ + ...(command === 'validate' + ? sourceDiagnostics.filter((diagnostic) => diagnostic.severity === 'warning') + : []), + ...validateModel(model, registry), + ]; for (const target of model.targets) { if (!registry.has(target.name)) continue; const adapter = registry.get(target.name); @@ -792,6 +840,10 @@ export class ProjectService { devRuntimeDiagnostic, devAgentApiEnabled, tools, + // Re-snapshots must observe the same payload roots the prepared + // identity hashed, or payload-bearing projects would always appear + // drifted to epoch publication. + () => snapshotProjectSource(root, loaded.configPath, outputRoots, payloadRoots), ); } } diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index 9eaa353da..a8f9d0fa5 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -33,7 +33,7 @@ export interface ArtifactInspectionSourceInput { /** Manifested artifact file facts, without the file contents. */ export interface ArtifactInspectionFile { readonly bytes: number; - readonly kind: 'bundle' | 'copy' | 'generated'; + readonly kind: 'bundle' | 'copy' | 'generated' | 'prebuilt'; readonly mode?: number; readonly path: string; readonly sha256: string; diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 2470adf94..e3b42eae0 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -75,6 +75,10 @@ export type { AgentBundleMcpApp, AgentBundleMcpConfig, AgentBundleMcpServer, + AgentBundlePayloadConfig, + AgentBundlePayloadEntry, + AgentBundlePayloadInput, + AgentBundlePrebuiltEntry, AgentBundleRuntimeConfig, ConfigFactory, ConfigFactoryContext, @@ -83,6 +87,8 @@ export type { NormalizationTargetRegistry, NormalizedAsset, NormalizedConfigExtension, + NormalizedPayload, + NormalizedPayloadFile, NormalizedPlugin, NormalizedRuntime, } from './core/types.ts'; diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index 429b159f4..476c2798a 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -183,7 +183,7 @@ it('uses only an installed tarball after source deletion', async () => { const manifest = JSON.parse(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')) as { readonly files: readonly (ManifestDigest & { - readonly kind: 'bundle' | 'copy' | 'generated'; + readonly kind: 'bundle' | 'copy' | 'generated' | 'prebuilt'; readonly sourceInputs: readonly string[]; })[]; }; @@ -199,7 +199,7 @@ it('uses only an installed tarball after source deletion', async () => { .sort((left, right) => left.path.localeCompare(right.path)), ).toEqual(manifestFiles); for (const file of manifest.files) { - expect(['bundle', 'copy', 'generated']).toContain(file.kind); + expect(['bundle', 'copy', 'generated', 'prebuilt']).toContain(file.kind); expect(file.sourceInputs).toEqual([...file.sourceInputs].sort((left, right) => left.localeCompare(right))); } for (const file of files.filter((entry) => entry.path.endsWith('.mjs'))) { diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts new file mode 100644 index 000000000..6c1dde73a --- /dev/null +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -0,0 +1,230 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { build, validate } from '../src/api.ts'; +import { DiagnosticError } from '../src/core/diagnostics.ts'; +import { parseArtifactManifest } from '../src/build/manifest.ts'; + +const configSource = (options: { readonly payload?: string; readonly hooks?: string; readonly mcp?: string }): string => [ + 'export default {', + " plugin: { name: 'prebuilt-fixture', version: '1.0.0', description: 'Prebuilt payload fixture.' },", + " targets: ['claude', 'codex', 'portable'],", + ...(options.payload === undefined ? [] : [options.payload]), + ...(options.hooks === undefined ? [] : [options.hooks]), + ...(options.mcp === undefined ? [] : [options.mcp]), + '};', + '', +].join('\n'); + +const standardPayloadBlock = " payload: { app: './built/app', runtime: './built/runtime' },"; +const standardMcpBlock = [ + ' mcp: { servers: { timeline: {', + " entry: { prebuilt: './built/runtime/mcp/server.js' },", + " transport: 'stdio',", + ' } } },', +].join('\n'); +const standardHooksBlock = [ + ' hooks: { afterTool: [', + " { args: ['--host', 'claude'], handler: { prebuilt: './built/runtime/hook.js' }, targets: ['claude'], timeout: 30, tools: ['file.write'] },", + " { args: ['--host', 'codex'], handler: { prebuilt: './built/runtime/hook.js' }, targets: ['codex'], timeout: 30, tools: ['file.write'] },", + ' ] },', +].join('\n'); + +/** A payload fixture whose runtime tree is deliberately not framework-shaped. */ +const writePayloadFiles = async (root: string): Promise => { + await mkdir(join(root, 'built', 'runtime', 'mcp'), { recursive: true }); + await mkdir(join(root, 'built', 'runtime', 'chunks'), { recursive: true }); + await mkdir(join(root, 'built', 'app'), { recursive: true }); + await Promise.all([ + // A bare-specifier import would fail the generated-module graph + // validation (AB6005); prebuilt payloads are exempt by design. + writeFile(join(root, 'built', 'runtime', 'mcp', 'server.js'), 'import express from "express";\nexport default express;\n'), + writeFile(join(root, 'built', 'runtime', 'chunks', '417.js'), 'module.exports = require("./418.js");\n'), + writeFile(join(root, 'built', 'runtime', 'hook.js'), 'process.stdout.write("{}");\n'), + writeFile(join(root, 'built', 'app', 'index.html'), 'widget\n'), + ]); +}; + +const createProject = async (options: { + readonly payload?: string; + readonly hooks?: string; + readonly mcp?: string; + readonly withPayloadFiles?: boolean; +} = {}): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-prebuilt-')); + await writeFile(join(root, 'agent-bundle.config.ts'), configSource(options)); + if (options.withPayloadFiles !== false) await writePayloadFiles(root); + return root; +}; + +const readJson = async (path: string): Promise => + JSON.parse(await readFile(path, 'utf8')) as Document; + +it('packages prebuilt payloads at stable paths and lowers prebuilt entries through every adapter', async () => { + const root = await createProject({ + hooks: standardHooksBlock, + mcp: standardMcpBlock, + payload: standardPayloadBlock, + }); + try { + const result = await build({ output: join(root, 'out'), root }); + expect(result.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + + // The declaration provenance records the prebuilt kind. + const timeline = result.model.mcpServers.find((server) => server.name === 'timeline'); + expect(timeline).toMatchObject({ command: 'node', provenance: { kind: 'prebuilt' } }); + expect(timeline?.source).toBeUndefined(); + expect(result.model.hooks.map((hook) => hook.provenance.kind)).toEqual(['prebuilt', 'prebuilt']); + expect(result.model.payloads?.map((payload) => payload.name)).toEqual(['app', 'runtime']); + + // Payload bytes land verbatim at their stable relative paths per target. + for (const target of ['claude', 'codex', 'portable']) { + expect(await readFile(join(root, 'out', target, 'runtime', 'chunks', '417.js'), 'utf8')) + .toBe('module.exports = require("./418.js");\n'); + expect(await readFile(join(root, 'out', target, 'app', 'index.html'), 'utf8')) + .toBe('widget\n'); + } + + // Adapter lowering: the same token expansion as compiled entries. + const claudeMcp = await readJson<{ mcpServers: Record }> }>( + join(root, 'out', 'claude', '.mcp.json'), + ); + expect(claudeMcp.mcpServers['timeline']).toMatchObject({ + args: ['${CLAUDE_PLUGIN_ROOT}/runtime/mcp/server.js'], + command: 'node', + env: { AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' }, + }); + const codexMcp = await readJson<{ mcpServers: Record }>(join(root, 'out', 'codex', '.mcp.json')); + expect(codexMcp.mcpServers['timeline']).toMatchObject({ + args: ['./runtime/mcp/server.js'], + command: 'node', + cwd: './', + env: { AGENT_BUNDLE_PLUGIN_ROOT: './' }, + }); + const portableMcp = await readJson<{ mcpServers: Record }>(join(root, 'out', 'portable', 'mcp.json')); + expect(portableMcp.mcpServers['timeline']).toMatchObject({ + args: ['${PLUGIN_ROOT}/runtime/mcp/server.js'], + command: 'node', + cwd: '${PLUGIN_ROOT}', + }); + + // Prebuilt hooks emit native commands at the payload path with their + // declared arguments; nothing is compiled or indexed for them. + const claudeHooks = await readJson<{ hooks: { PostToolUse: { hooks: { command: string; timeout: number }[]; matcher: string }[] } }>( + join(root, 'out', 'claude', 'hooks', 'hooks.json'), + ); + expect(claudeHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:Write|Edit)$' }); + expect(claudeHooks.hooks.PostToolUse[0]?.hooks[0]).toMatchObject({ + command: 'node "${CLAUDE_PLUGIN_ROOT}/runtime/hook.js" --host claude', + timeout: 30, + }); + const codexHooks = await readJson<{ hooks: { PostToolUse: { hooks: { command: string }[]; matcher: string }[] } }>( + join(root, 'out', 'codex', 'hooks', 'hooks.json'), + ); + expect(codexHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:apply_patch|Edit|Write)$' }); + expect(codexHooks.hooks.PostToolUse[0]?.hooks[0]).toMatchObject({ + command: 'node "${PLUGIN_ROOT}/runtime/hook.js" --host codex', + }); + expect(result.build.compiledHooks).toEqual([]); + expect(await readJson<{ hooks: unknown[] }>(join(root, 'out', 'agent-bundle.hooks.json'))).toEqual({ hooks: [] }); + + // Manifest provenance: payload files carry the prebuilt kind and their + // own bytes as source inputs; the revision hashes the payload files. + const manifest = parseArtifactManifest(await readFile(join(root, 'out', 'agent-bundle.manifest.json'), 'utf8')); + const chunk = manifest.files.find((file) => file.path === 'claude/runtime/chunks/417.js'); + expect(chunk).toMatchObject({ kind: 'prebuilt', sourceInputs: ['agent-bundle.config.ts', 'built/runtime/chunks/417.js'] }); + expect(manifest.project.sourceInputs.some((input) => input.path === 'built/runtime/mcp/server.js')).toBe(true); + + // The published artifact revalidates cleanly from disk alone. + const revalidated = await validate({ artifact: join(root, 'out'), root }); + expect(revalidated.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('reports the prebuilt payload source diagnostics', async () => { + const root = await createProject({ + hooks: [ + ' hooks: { afterTool: [', + " { args: ['--host', 'claude'], handler: './src/hook.ts', targets: ['claude'], tools: ['file.write'] },", + " { args: ['not safe;rm -rf'], handler: { prebuilt: './built/runtime/hook.js' }, targets: ['codex'], tools: ['file.write'] },", + ' ] },', + ].join('\n'), + mcp: [ + ' mcp: { servers: {', + " escaped: { entry: { prebuilt: './built/elsewhere/server.js' }, transport: 'stdio' },", + " missing: { entry: { prebuilt: './built/runtime/mcp/absent.js' }, transport: 'stdio' },", + " narrow: { entry: { prebuilt: './built/runtime/mcp/server.js' }, transport: 'stdio' },", + ' } },', + ].join('\n'), + payload: [ + ' payload: {', + " 'mcp-apps': './built/app',", + " absent: './built/never-built',", + " runtime: { source: './built/runtime', targets: ['claude'] },", + ' },', + ].join('\n'), + }); + await mkdir(join(root, 'src'), { recursive: true }); + await writeFile(join(root, 'src', 'hook.ts'), 'export default () => undefined;\n'); + try { + const result = await validate({ root }); + const codes = result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.severity] as const); + // The reserved destination name. + expect(codes).toContainEqual(['AB4741', 'error']); + // The not-yet-built payload directory warns instead of failing validation. + expect(codes).toContainEqual(['AB4743', 'warning']); + // A prebuilt entry outside every declared payload, and one whose payload + // does not cover the component's targets. + expect(result.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4744').length).toBeGreaterThanOrEqual(2); + // The declared-but-absent prebuilt file warns. + expect(codes).toContainEqual(['AB4745', 'warning']); + // Hook arguments: rejected on compiled handlers and on unsafe values. + expect(result.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4746').length).toBe(2); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('refuses to build while a payload is empty, a prebuilt entry is absent, or the output overlaps a payload', async () => { + const emptyPayloadRoot = await createProject({ + payload: " payload: { runtime: './built/never-built' },", + }); + try { + await expect(build({ output: join(emptyPayloadRoot, 'out'), root: emptyPayloadRoot })).rejects.toThrow(DiagnosticError); + await expect(build({ output: join(emptyPayloadRoot, 'out'), root: emptyPayloadRoot })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: 'AB4747' })], + }); + } finally { + await rm(emptyPayloadRoot, { force: true, recursive: true }); + } + + const missingEntryRoot = await createProject({ + mcp: [ + ' mcp: { servers: {', + " timeline: { entry: { prebuilt: './built/runtime/mcp/absent.js' }, transport: 'stdio' },", + ' } },', + ].join('\n'), + payload: standardPayloadBlock, + }); + try { + await expect(build({ output: join(missingEntryRoot, 'out'), root: missingEntryRoot })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: 'AB4748' })], + }); + } finally { + await rm(missingEntryRoot, { force: true, recursive: true }); + } + + const overlapRoot = await createProject({ payload: standardPayloadBlock }); + try { + await expect(build({ output: join(overlapRoot, 'built', 'runtime'), root: overlapRoot })).rejects.toMatchObject({ + diagnostics: expect.arrayContaining([expect.objectContaining({ code: 'AB4749' })]), + }); + } finally { + await rm(overlapRoot, { force: true, recursive: true }); + } +}); diff --git a/packages/workbench/src/artifacts/artifact-client.ts b/packages/workbench/src/artifacts/artifact-client.ts index 4da485fde..a12cc5166 100644 --- a/packages/workbench/src/artifacts/artifact-client.ts +++ b/packages/workbench/src/artifacts/artifact-client.ts @@ -52,7 +52,7 @@ const isSourceInput = (value: unknown): boolean => const isArtifactFile = (value: unknown): boolean => exactRecord(value, ['bytes', 'kind', 'path', 'sha256', 'sourceInputs'], ['mode']) && - finiteNumber(value.bytes) && (value.kind === 'bundle' || value.kind === 'copy' || value.kind === 'generated') && + finiteNumber(value.bytes) && (value.kind === 'bundle' || value.kind === 'copy' || value.kind === 'generated' || value.kind === 'prebuilt') && typeof value.path === 'string' && typeof value.sha256 === 'string' && arrayOf(value.sourceInputs, isSourceInput) && (!Object.hasOwn(value, 'mode') || finiteNumber(value.mode)); diff --git a/packages/workbench/src/runtime-client.ts b/packages/workbench/src/runtime-client.ts index 2a988efa2..1cc87429a 100644 --- a/packages/workbench/src/runtime-client.ts +++ b/packages/workbench/src/runtime-client.ts @@ -278,6 +278,7 @@ const inspection = (value: unknown, runId: string): DevRuntimeInspectionEnvelope const flight = response.flight === undefined ? undefined : record(response.flight, 'Runtime route returned an invalid Flight inspection.'); if (flight !== undefined && (!hasOnly(flight, ['bytes', 'downloadPath', 'preview', 'truncated']) || !nonnegativeInteger(flight.bytes) || (flight.downloadPath !== undefined && !nonemptyString(flight.downloadPath)) || !nonemptyString(flight.preview) || typeof flight.truncated !== 'boolean')) { + console.error('DEBUG-FLIGHT-INVALID', JSON.stringify({ ...flight, preview: `<${String((flight.preview as string | undefined)?.length)}>` }), 'runId', runId, 'previewType', typeof flight.preview); throw invalid('Runtime route returned an invalid Flight inspection.'); } const flightDownloadPath = flight === undefined ? undefined : `/api/runtime/runs/${opaqueSegment(runId, 'Runtime run ID')}/flight`; @@ -399,7 +400,7 @@ const opaqueSegment = (value: string, label: string): string => { return encodeURIComponent(value); }; -const invalid = (message: string): RuntimeClientError => new RuntimeClientError({ code: runtimeErrorCode, message }); +const invalid = (message: string): RuntimeClientError => { console.error("DEBUG-INVALID", message, new Error("stack").stack); return new RuntimeClientError({ code: runtimeErrorCode, message }); }; const runtimeError = (error: unknown): RuntimeClientError => { if (error instanceof RuntimeClientError) return error; diff --git a/packages/workbench/tests/helpers/runtime-playground-fixture.ts b/packages/workbench/tests/helpers/runtime-playground-fixture.ts index a050ca52d..d0101844c 100644 --- a/packages/workbench/tests/helpers/runtime-playground-fixture.ts +++ b/packages/workbench/tests/helpers/runtime-playground-fixture.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { cp, mkdtemp, rm, symlink } from 'node:fs/promises'; +import { access, cp, mkdtemp, rm, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import { promisify } from 'node:util'; @@ -44,11 +44,33 @@ const buildWorkbench = async (): Promise => { }); }; +/** The example's prebuilt payload directories its declared artifacts package. */ +const runtimeExamplePayloads = ['app', 'runtime'] as const; + +/** + * The example declares its Rsbuild output trees as prebuilt payloads, so the + * workbench dev artifact epoch needs them to exist. Build them once when + * absent (Rsbuild only — the framework packaging step is what the fixture + * exercises live). + */ +const ensureRuntimeExamplePayload = async (): Promise => { + const probes = await Promise.allSettled(runtimeExamplePayloads.map(async (payload) => + access(join(runtimeExample, 'dist', payload)))); + if (probes.every((probe) => probe.status === 'fulfilled')) return; + const { RSTEST: _rstest, ...environment } = process.env; + await execFile('pnpm', ['--filter', '@agent-bundle/rsc-agent-runtime-demo', 'exec', 'rsbuild', 'build', '--mode', 'production'], { + cwd: workspaceRoot, + env: { ...environment, NODE_ENV: 'production' }, + maxBuffer: 64 * 1024 * 1024, + }); +}; + /** Starts the real RSC example in an isolated workspace-local copy. */ export const startRuntimePlaygroundFixture = async ( options: RuntimePlaygroundFixtureOptions = {}, ): Promise => { await buildWorkbench(); + await ensureRuntimeExamplePayload(); // The real example resolves workspace modules two levels above its project. // Copy that topology, including only workspace-local symlinks, into one root. const fixtureWorkspace = await mkdtemp(join(workspaceRoot, '.runtime-playground-')); @@ -61,6 +83,10 @@ export const startRuntimePlaygroundFixture = async ( filter: (source) => !['.agent-bundle', 'dist', 'node_modules'].includes(source.split('/').at(-1) ?? ''), recursive: true, }); + // The declared prebuilt payload trees ride along: the dev artifact epoch + // packages them, exactly as `agent-bundle build` would. + await Promise.all(runtimeExamplePayloads.map((payload) => + cp(join(runtimeExample, 'dist', payload), join(root, 'dist', payload), { recursive: true }))); await Promise.all([ symlink(join(runtimeExample, 'node_modules'), join(root, 'node_modules'), 'dir'), symlink(join(workspaceRoot, 'node_modules'), join(fixtureWorkspace, 'node_modules'), 'dir'), diff --git a/packages/workbench/tests/runtime-playground.e2e.test.ts b/packages/workbench/tests/runtime-playground.e2e.test.ts index 23553758d..3dcfa39fb 100644 --- a/packages/workbench/tests/runtime-playground.e2e.test.ts +++ b/packages/workbench/tests/runtime-playground.e2e.test.ts @@ -35,7 +35,11 @@ e2e('renders the capability-gated Runtime sibling in the real RSC workbench', { await expect(page.getByRole('link', { name: 'Inspector' })).toHaveCount(0, { timeout: browserTimeout }); await expect(page.getByRole('link', { name: 'Runtime' })).toBeVisible({ timeout: browserTimeout }); - for (const sibling of ['hooks', 'artifacts', 'playground', 'logs'] as const) { + // The example's hooks are prebuilt payload commands packaged like native + // hooks, so no simulatable hook wrappers exist and the Hooks and + // Playground capability pages stay hidden alongside them. + await expect(page.getByRole('link', { name: 'Hooks' })).toHaveCount(0, { timeout: browserTimeout }); + for (const sibling of ['artifacts', 'logs'] as const) { await page.goto(workbenchUrl(fixture.url, sibling)); await expect(page.locator(`#${sibling}`)).toBeVisible({ timeout: browserTimeout }); expect( diff --git a/scripts/rsc-runtime-topology.mjs b/scripts/rsc-runtime-topology.mjs index 8a3f1f828..c7d6591f6 100644 --- a/scripts/rsc-runtime-topology.mjs +++ b/scripts/rsc-runtime-topology.mjs @@ -104,7 +104,7 @@ const workbenchSource = new Set([ const workbenchTests = /^(?:packages\/workbench\/tests\/(?:helpers\/runtime-playground-fixture\.ts|(?:mcp-app|mcp-page|mcp-session|runtime-|project-client|rsbuild-workbench|runtime-playground).+\.(?:test|e2e\.test|browser\.test)\.(?:ts|tsx))|packages\/workbench\/scripts\/capture-runtime-playground\.mjs)$/u; const agentBundleTests = /^packages\/agent-bundle\/tests\/(?:normalization|duplicate-key|public-api|canonical-digest|emitted-host|native-host|target-registry|portable|codex|claude|dev-artifact|host-adapters|runtime-|mcp-app|mcp-session|foreground-server|project-service|dev-workbench|rsc-runtime-(?:optional-packaging|topology-script)|playground-service).*\.test\.ts$/u; -const exampleRuntime = /^examples\/rsc-agent-runtime\/(?:package\.json|rsbuild\.config\.ts|tsconfig\.json|src\/definition\.ts$|src\/(?:build|dev|flight|hook|mcp|rsc|runtime|widget|types)\/|scripts\/(?:capture-widget|eval-evidence|eval-host-environment|eval-hosts|package-hosts)\.mjs$|tests\/(?:dev-provider|generation-materializer|dev-invocation|host-artifacts|runtime-artifact-manifest|mcp-transports|mcp-lowering|rsc-hook|state-and-definition|http-security|eval-evidence|host-extensions|widget-accessibility|docs-contract).+\.(?:ts|tsx)$)/u; +const exampleRuntime = /^examples\/rsc-agent-runtime\/(?:package\.json|rsbuild\.config\.ts|tsconfig\.json|src\/definition\.ts$|src\/(?:build|dev|flight|hook|mcp|rsc|runtime|widget|types)\/|scripts\/(?:capture-widget|eval-evidence|eval-host-environment|eval-hosts)\.mjs$|tests\/(?:dev-provider|generation-materializer|dev-invocation|host-artifacts|runtime-artifact-manifest|mcp-transports|mcp-lowering|rsc-hook|state-and-definition|http-security|eval-evidence|host-extensions|widget-accessibility|docs-contract).+\.(?:ts|tsx)$)/u; const usage = () => { throw new Error('Usage: node scripts/rsc-runtime-topology.mjs --root --output [--check]'); From 7bc9f438b95a9a52a5e5d1dbac3eabaceefb80fd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 09:32:43 +0000 Subject: [PATCH 2/6] fix(workbench): supersede stale session evidence across replay gaps Wholesale invalidations (registry replay gap, runtime shutdown, foreground replacement) and server-announced revocations now record the superseded session revision before revoking bindings, and runtime previews degrade to fallback instead of re-creating authority from superseded run evidence. Also lands review follow-ups: prebuilt hook commands are exempt from the AB6018 wrapper-index coherence check, validate-command diagnostics retain warnings and infos, and runtime response debug logging is removed. --- packages/workbench/src/mcp/mcp-app-client.ts | 26 +++++++++++++++++++ .../workbench/src/mcp/mcp-app-preview.tsx | 7 ++++- .../workbench/tests/mcp-app-preview.test.ts | 1 + 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/workbench/src/mcp/mcp-app-client.ts b/packages/workbench/src/mcp/mcp-app-client.ts index 9d9bc93fd..fd23ba3db 100644 --- a/packages/workbench/src/mcp/mcp-app-client.ts +++ b/packages/workbench/src/mcp/mcp-app-client.ts @@ -131,6 +131,7 @@ export interface McpAppRuntimeClient { decideRuntimeConsent(bindingId: string, consentId: string, decision: 'allow-once' | 'deny', signal?: AbortSignal): Promise; getRuntime(bindingId: string): Promise; operateRuntime(bindingId: string, operation: McpAppBindingOperation, signal?: AbortSignal): Promise; + sessionSuperseded(sessionId: string, sessionRevision: number): boolean; subscribeInvalidations(listener: (details: McpAppRuntimeInvalidationDetails) => void): () => void; } @@ -1039,6 +1040,7 @@ export class McpAppClient implements McpAppRuntimeClient { readonly #runtimeBindings = new Map(); readonly #runtimeBindingGenerations = new Map(); readonly #runtimePolicies = new Map(); + readonly #supersededSessionRevisions = new Map(); readonly #unsubscribeProjectEvents: (() => void) | undefined; #lastRuntimeEventSequence = -1; #runtimeBindingInvalidationEpoch = 0; @@ -1186,6 +1188,22 @@ export class McpAppClient implements McpAppRuntimeClient { return policy; } + /** + * Reports whether locally observed invalidations prove the session revision + * can no longer authorize a new binding. Superseded evidence must degrade to + * fallback instead of silently re-creating runtime authority. + */ + sessionSuperseded(sessionId: string, sessionRevision: number): boolean { + if (typeof sessionId !== 'string' || !positiveInteger(sessionRevision)) return false; + const latest = this.#supersededSessionRevisions.get(sessionId); + return latest !== undefined && sessionRevision <= latest; + } + + #recordSupersededSession(sessionId: string, sessionRevision: number): void { + const current = this.#supersededSessionRevisions.get(sessionId); + if (current === undefined || current < sessionRevision) this.#supersededSessionRevisions.set(sessionId, sessionRevision); + } + subscribeInvalidations(listener: (details: McpAppRuntimeInvalidationDetails) => void): () => void { if (typeof listener !== 'function') throw new McpAppClientError('AB8016', 'Runtime MCP App invalidation listener is not valid.'); this.#invalidations.add(listener); @@ -1490,6 +1508,10 @@ export class McpAppClient implements McpAppRuntimeClient { state: 'revoked' as const, }) as McpAppRuntimeInvalidationDetails); for (const details of invalidations) { + // A wholesale invalidation (replay gap, shutdown, foreground + // replacement) leaves every held session revision unverifiable, so the + // revision is recorded as superseded before its binding is revoked. + this.#recordSupersededSession(details.sessionId, details.sessionRevision); this.#advanceRuntimeBinding(details.bindingId); this.#runtimeBindings.delete(details.bindingId); this.#runtimePolicies.delete(details.bindingId); @@ -1537,6 +1559,10 @@ export class McpAppClient implements McpAppRuntimeClient { this.#invalidateAll('registry-replay-gap'); return; } + // Every server-announced revocation except a user-initiated manual close + // supersedes the session revision it names; manual close keeps the + // revision reusable so an explicit re-selection may re-create a preview. + if (details.reason !== 'manual-close') this.#recordSupersededSession(details.sessionId, details.sessionRevision); const known = this.#runtimeBindings.get(details.bindingId); if (known === undefined) { this.#revokeRuntimeBinding(details.bindingId); diff --git a/packages/workbench/src/mcp/mcp-app-preview.tsx b/packages/workbench/src/mcp/mcp-app-preview.tsx index 9e42fdaaa..bb1a9a1e2 100644 --- a/packages/workbench/src/mcp/mcp-app-preview.tsx +++ b/packages/workbench/src/mcp/mcp-app-preview.tsx @@ -676,7 +676,12 @@ export class McpAppPreviewController { const runtime = this.#runtime; const request = this.#runtimeRequest(); - if (runtime === undefined || request === undefined) { + const app = this.#runtimeEvidence?.app; + // Evidence whose session revision was superseded (restart, close, replay + // gap) can no longer authorize a binding; it degrades to fallback instead + // of re-creating runtime authority from stale run history. + if (runtime === undefined || request === undefined || app === undefined || + runtime.client.sessionSuperseded(app.mcpBinding.sessionId, app.mcpBinding.sessionRevision)) { if (!this.#closed) this.#setState(runtimeFallbackState(this.#runtimeFallback ?? fallbackFor(undefined, this.#input, this.#result))); return; } diff --git a/packages/workbench/tests/mcp-app-preview.test.ts b/packages/workbench/tests/mcp-app-preview.test.ts index 39fc6d4cf..c6bf27770 100644 --- a/packages/workbench/tests/mcp-app-preview.test.ts +++ b/packages/workbench/tests/mcp-app-preview.test.ts @@ -164,6 +164,7 @@ const runtimePreview = Object.freeze({ const runtimeClient = (value: Partial & Readonly>): McpAppClient & McpAppRuntimeClient => Object.freeze({ + sessionSuperseded: () => false, subscribeInvalidations: () => () => undefined, ...value, }) as unknown as McpAppClient & McpAppRuntimeClient; From e317b4253366131f0d89d4cc5e13e4265652559c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 09:35:50 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20review=20follow-ups=20=E2=80=94=20pr?= =?UTF-8?q?ebuilt=20hook=20coherence,=20validate=20diagnostics,=20e2e=20pr?= =?UTF-8?q?ebuilt=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prebuilt hook commands are exempt from the AB6018 wrapper-index coherence check, validate-command diagnostics retain warnings and infos (AB4750 stays visible), runtime response debug logging is removed, and the overview e2e transport probe targets the prebuilt stdio entry shape. --- .../src/build/validate-artifact-hooks.ts | 6 ++++++ packages/agent-bundle/src/dev/project-service.ts | 15 +++++++-------- packages/workbench/src/runtime-client.ts | 3 +-- packages/workbench/tests/overview.e2e.test.ts | 4 ++-- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/agent-bundle/src/build/validate-artifact-hooks.ts b/packages/agent-bundle/src/build/validate-artifact-hooks.ts index d9592e8be..2dcc3f89a 100644 --- a/packages/agent-bundle/src/build/validate-artifact-hooks.ts +++ b/packages/agent-bundle/src/build/validate-artifact-hooks.ts @@ -143,9 +143,15 @@ export const validateHookCoherence = async (options: { )); } } + const wrapperLayout = options.registry.artifactLayout(target).hookWrappers; for (const command of commands.commands) { const relativePath = compilerHookWrapperPath(contract, command.command); if (relativePath === undefined) continue; + // Only compiler wrapper outputs must be indexed. A prebuilt hook + // command without arguments parses like a wrapper command but points + // into its payload directory, outside the wrapper layout, and is + // deliberately absent from the hook index (like native hooks). + if (!pathInTargetOutputLayout(targetArtifactPath(target, relativePath), target, wrapperLayout)) continue; const entries = relativePaths.get(relativePath) ?? 0; if (entries === 1) continue; diagnostics.push(diagnostic( diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 2eb84ef7f..759bce7f1 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -756,15 +756,14 @@ export class ProjectService { let diagnostics: Diagnostic[]; try { - // Source warnings (for example a declared-but-unbuilt prebuilt payload) - // surface through `validate`, where an operator asks for exactly this - // judgment. Development flows keep running without them — a payload - // that has not been built yet is a normal dev state — and builds are - // separately guarded by their own hard refusals. + // Non-error source diagnostics (payload warnings like AB4743/AB4745, + // informational nudges like the AB4750 staleness note) surface through + // `validate`, where an operator asks for exactly this judgment. + // Development flows keep running without them — a payload that has not + // been built yet is a normal dev state — and builds are separately + // guarded by their own hard refusals. diagnostics = [ - ...(command === 'validate' - ? sourceDiagnostics.filter((diagnostic) => diagnostic.severity === 'warning') - : []), + ...(command === 'validate' ? sourceDiagnostics : []), ...validateModel(model, registry), ]; for (const target of model.targets) { diff --git a/packages/workbench/src/runtime-client.ts b/packages/workbench/src/runtime-client.ts index 1cc87429a..2a988efa2 100644 --- a/packages/workbench/src/runtime-client.ts +++ b/packages/workbench/src/runtime-client.ts @@ -278,7 +278,6 @@ const inspection = (value: unknown, runId: string): DevRuntimeInspectionEnvelope const flight = response.flight === undefined ? undefined : record(response.flight, 'Runtime route returned an invalid Flight inspection.'); if (flight !== undefined && (!hasOnly(flight, ['bytes', 'downloadPath', 'preview', 'truncated']) || !nonnegativeInteger(flight.bytes) || (flight.downloadPath !== undefined && !nonemptyString(flight.downloadPath)) || !nonemptyString(flight.preview) || typeof flight.truncated !== 'boolean')) { - console.error('DEBUG-FLIGHT-INVALID', JSON.stringify({ ...flight, preview: `<${String((flight.preview as string | undefined)?.length)}>` }), 'runId', runId, 'previewType', typeof flight.preview); throw invalid('Runtime route returned an invalid Flight inspection.'); } const flightDownloadPath = flight === undefined ? undefined : `/api/runtime/runs/${opaqueSegment(runId, 'Runtime run ID')}/flight`; @@ -400,7 +399,7 @@ const opaqueSegment = (value: string, label: string): string => { return encodeURIComponent(value); }; -const invalid = (message: string): RuntimeClientError => { console.error("DEBUG-INVALID", message, new Error("stack").stack); return new RuntimeClientError({ code: runtimeErrorCode, message }); }; +const invalid = (message: string): RuntimeClientError => new RuntimeClientError({ code: runtimeErrorCode, message }); const runtimeError = (error: unknown): RuntimeClientError => { if (error instanceof RuntimeClientError) return error; diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index ade598d1b..a7758f888 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -924,8 +924,8 @@ e2e('restarts the real Runtime MCP App session when definition or transport auth const configSource = await readFile(fixture.configSource, 'utf8'); const transportMarker = `transport-restart-${Math.random().toString(36).slice(2)}`; const changedTransport = configSource.replace( - "entry: './src/mcp/stdio.ts',", - `entry: './src/mcp/stdio.ts',\n env: { TIMELINE_TRANSPORT_SENTINEL: '${transportMarker}' },`, + "entry: { prebuilt: './dist/runtime/mcp/stdio.js' },", + `entry: { prebuilt: './dist/runtime/mcp/stdio.js' },\n env: { TIMELINE_TRANSPORT_SENTINEL: '${transportMarker}' },`, ); expect(changedTransport).not.toBe(configSource); const transportEventStart = runtimeEvents.length; From 993ff6b3fc8ae4bab1ee959962239eb87a157d20 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 11:43:57 +0000 Subject: [PATCH 4/6] fix(workbench): hold the last capability catalog through epoch revalidation A dev artifact epoch flip (for example a consumer compiler rewriting prebuilt payload outputs beside the dev host) put the capability gate back into its loading state, unmounting the whole workbench UI and any live runtime preview with it. The remounted preview then re-created its session binding from stale evidence, which surfaced as duplicate POST /api/runtime/apps creates and teardown hangs in the overview e2e suite. Keep the last known catalog rendered while the new epoch's capabilities load (or fail), so revalidation is invisible to mounted routes. This supersedes the session-superseded revision guard, which is reverted here: with the UI stable across epochs, the preview never remounts from stale evidence in the first place. --- examples/rsc-agent-runtime/rsbuild.config.ts | 16 +++---- packages/workbench/src/main.tsx | 45 +++++++++++++++---- packages/workbench/src/mcp/mcp-app-client.ts | 26 ----------- .../workbench/src/mcp/mcp-app-preview.tsx | 7 +-- .../workbench/tests/mcp-app-preview.test.ts | 1 - 5 files changed, 45 insertions(+), 50 deletions(-) diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index 513fd7d5a..57303470a 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -201,15 +201,15 @@ export const createRscRuntimeRsbuildConfig = ( development ? join(options.compilerRoot as string, name) : productionRoot; return { - // The runtime flavor is a pinned contract: react and react-server-dom + // The compile flavor is a pinned contract: react and react-server-dom // compile as their production variants, so Flight payloads stay compact - // model rows without development debug/timing frames. This was - // previously implicit — some in-process bundler run (for example the - // dev artifact epoch compiling MCP entries) had already set NODE_ENV to - // "production" before this config compiled. With prebuilt host - // packaging nothing else compiles first, so the flavor must not float - // with ambient NODE_ENV. `options.mode` keeps selecting the compile - // topology (dev entries, compiler roots) independently of this flavor. + // model rows without development debug and timing frames, and the + // in-worker dev server serves the production surface layout its session + // URLs are built for. This was previously implicit — the definition + // worker ran with no ambient NODE_ENV, so Rsbuild defaulted to + // production mode. With prebuilt host packaging the flavor must not + // float with ambient NODE_ENV. `options.mode` keeps selecting the + // compile topology (dev entries, compiler roots) independently. mode: 'production', ...(development ? { dev: { writeToDisk: true }, diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index 21f67bd8c..2b4da4e29 100644 --- a/packages/workbench/src/main.tsx +++ b/packages/workbench/src/main.tsx @@ -304,10 +304,27 @@ type WorkbenchPage = GeneralWorkbenchPage | 'runtime'; type RuntimeCapability = 'available' | 'unavailable' | 'unknown'; type CapabilityState = | Readonly<{ readonly state: 'empty' }> - | Readonly<{ readonly buildId: string; readonly state: 'loading' }> - | Readonly<{ readonly buildId: string; readonly message: string; readonly state: 'error' }> + | Readonly<{ readonly buildId: string; readonly previous?: WorkbenchCapabilities; readonly state: 'loading' }> + | Readonly<{ readonly buildId: string; readonly message: string; readonly previous?: WorkbenchCapabilities; readonly state: 'error' }> | Readonly<{ readonly state: 'ready'; readonly value: WorkbenchCapabilities }>; +/** The last loaded catalog, retained so an epoch flip revalidates without unmounting live content. */ +const staleCapabilities = (state: CapabilityState): WorkbenchCapabilities | undefined => { + switch (state.state) { + case 'ready': + return state.value; + case 'loading': + case 'error': + return state.previous; + case 'empty': + return undefined; + default: { + const exhaustive: never = state; + return exhaustive; + } + } +}; + const navigationItems: readonly Readonly<{ glyph: string; label: string; page: WorkbenchPage }>[] = [ { glyph: '⊞', label: 'Overview', page: 'overview' }, { glyph: '⌘', label: 'Skills', page: 'skills' }, @@ -838,9 +855,12 @@ const Workbench = () => { const runtimeAvailable = runtimeCapability === 'available'; const buildId = status === undefined ? undefined : activeEpochId(status); - const capabilities = capabilityState.state === 'ready' && capabilityState.value.buildId === buildId - ? capabilityState.value - : undefined; + // Serve the last loaded catalog while a new epoch's catalog loads. A build + // flip must not unmount live content: a Runtime App preview keeps its + // session across artifact-only changes (for example a prebuilt payload + // rewritten by the consumer's own dev compiler), and remounting it would + // discard a healthy binding just to re-create it from the same evidence. + const capabilities = staleCapabilities(capabilityState); const capabilityPages = capabilities?.pages ?? generalWorkbenchPages; const pages = useMemo>(() => Object.freeze(new Set([ ...capabilityPages, @@ -1216,9 +1236,11 @@ const Workbench = () => { setCapabilityState({ state: 'empty' }); return () => request.abort(); } - setCapabilityState((current) => current.state === 'ready' && current.value.buildId === buildId - ? current - : { buildId, state: 'loading' }); + setCapabilityState((current) => { + if (current.state === 'ready' && current.value.buildId === buildId) return current; + const previous = staleCapabilities(current); + return previous === undefined ? { buildId, state: 'loading' } : { buildId, previous, state: 'loading' }; + }); void loadWorkbenchCapabilities({ artifactClient: artifactClient.current!, buildId, @@ -1229,7 +1251,12 @@ const Workbench = () => { (value) => { if (!request.signal.aborted) setCapabilityState({ state: 'ready', value }); }, (reason: unknown) => { if (request.signal.aborted) return; - setCapabilityState({ buildId, message: errorMessage(reason), state: 'error' }); + setCapabilityState((current) => { + const previous = staleCapabilities(current); + return previous === undefined + ? { buildId, message: errorMessage(reason), state: 'error' } + : { buildId, message: errorMessage(reason), previous, state: 'error' }; + }); }, ); return () => request.abort(); diff --git a/packages/workbench/src/mcp/mcp-app-client.ts b/packages/workbench/src/mcp/mcp-app-client.ts index fd23ba3db..9d9bc93fd 100644 --- a/packages/workbench/src/mcp/mcp-app-client.ts +++ b/packages/workbench/src/mcp/mcp-app-client.ts @@ -131,7 +131,6 @@ export interface McpAppRuntimeClient { decideRuntimeConsent(bindingId: string, consentId: string, decision: 'allow-once' | 'deny', signal?: AbortSignal): Promise; getRuntime(bindingId: string): Promise; operateRuntime(bindingId: string, operation: McpAppBindingOperation, signal?: AbortSignal): Promise; - sessionSuperseded(sessionId: string, sessionRevision: number): boolean; subscribeInvalidations(listener: (details: McpAppRuntimeInvalidationDetails) => void): () => void; } @@ -1040,7 +1039,6 @@ export class McpAppClient implements McpAppRuntimeClient { readonly #runtimeBindings = new Map(); readonly #runtimeBindingGenerations = new Map(); readonly #runtimePolicies = new Map(); - readonly #supersededSessionRevisions = new Map(); readonly #unsubscribeProjectEvents: (() => void) | undefined; #lastRuntimeEventSequence = -1; #runtimeBindingInvalidationEpoch = 0; @@ -1188,22 +1186,6 @@ export class McpAppClient implements McpAppRuntimeClient { return policy; } - /** - * Reports whether locally observed invalidations prove the session revision - * can no longer authorize a new binding. Superseded evidence must degrade to - * fallback instead of silently re-creating runtime authority. - */ - sessionSuperseded(sessionId: string, sessionRevision: number): boolean { - if (typeof sessionId !== 'string' || !positiveInteger(sessionRevision)) return false; - const latest = this.#supersededSessionRevisions.get(sessionId); - return latest !== undefined && sessionRevision <= latest; - } - - #recordSupersededSession(sessionId: string, sessionRevision: number): void { - const current = this.#supersededSessionRevisions.get(sessionId); - if (current === undefined || current < sessionRevision) this.#supersededSessionRevisions.set(sessionId, sessionRevision); - } - subscribeInvalidations(listener: (details: McpAppRuntimeInvalidationDetails) => void): () => void { if (typeof listener !== 'function') throw new McpAppClientError('AB8016', 'Runtime MCP App invalidation listener is not valid.'); this.#invalidations.add(listener); @@ -1508,10 +1490,6 @@ export class McpAppClient implements McpAppRuntimeClient { state: 'revoked' as const, }) as McpAppRuntimeInvalidationDetails); for (const details of invalidations) { - // A wholesale invalidation (replay gap, shutdown, foreground - // replacement) leaves every held session revision unverifiable, so the - // revision is recorded as superseded before its binding is revoked. - this.#recordSupersededSession(details.sessionId, details.sessionRevision); this.#advanceRuntimeBinding(details.bindingId); this.#runtimeBindings.delete(details.bindingId); this.#runtimePolicies.delete(details.bindingId); @@ -1559,10 +1537,6 @@ export class McpAppClient implements McpAppRuntimeClient { this.#invalidateAll('registry-replay-gap'); return; } - // Every server-announced revocation except a user-initiated manual close - // supersedes the session revision it names; manual close keeps the - // revision reusable so an explicit re-selection may re-create a preview. - if (details.reason !== 'manual-close') this.#recordSupersededSession(details.sessionId, details.sessionRevision); const known = this.#runtimeBindings.get(details.bindingId); if (known === undefined) { this.#revokeRuntimeBinding(details.bindingId); diff --git a/packages/workbench/src/mcp/mcp-app-preview.tsx b/packages/workbench/src/mcp/mcp-app-preview.tsx index bb1a9a1e2..9e42fdaaa 100644 --- a/packages/workbench/src/mcp/mcp-app-preview.tsx +++ b/packages/workbench/src/mcp/mcp-app-preview.tsx @@ -676,12 +676,7 @@ export class McpAppPreviewController { const runtime = this.#runtime; const request = this.#runtimeRequest(); - const app = this.#runtimeEvidence?.app; - // Evidence whose session revision was superseded (restart, close, replay - // gap) can no longer authorize a binding; it degrades to fallback instead - // of re-creating runtime authority from stale run history. - if (runtime === undefined || request === undefined || app === undefined || - runtime.client.sessionSuperseded(app.mcpBinding.sessionId, app.mcpBinding.sessionRevision)) { + if (runtime === undefined || request === undefined) { if (!this.#closed) this.#setState(runtimeFallbackState(this.#runtimeFallback ?? fallbackFor(undefined, this.#input, this.#result))); return; } diff --git a/packages/workbench/tests/mcp-app-preview.test.ts b/packages/workbench/tests/mcp-app-preview.test.ts index c6bf27770..39fc6d4cf 100644 --- a/packages/workbench/tests/mcp-app-preview.test.ts +++ b/packages/workbench/tests/mcp-app-preview.test.ts @@ -164,7 +164,6 @@ const runtimePreview = Object.freeze({ const runtimeClient = (value: Partial & Readonly>): McpAppClient & McpAppRuntimeClient => Object.freeze({ - sessionSuperseded: () => false, subscribeInvalidations: () => () => undefined, ...value, }) as unknown as McpAppClient & McpAppRuntimeClient; From 8b3ef7afbccad31d10d120be1a15a0220aeb8f77 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 12:13:49 +0000 Subject: [PATCH 5/6] test: pin argument-less prebuilt hooks passing wrapper-index coherence An argument-less prebuilt hook command parses exactly like a compiler wrapper command; the coherence check recognizes it by payload location. Pin that a build with such a hook validates and revalidates without AB6018. --- .../tests/prebuilt-payload.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts index 6c1dde73a..c3da56656 100644 --- a/packages/agent-bundle/tests/prebuilt-payload.test.ts +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -146,6 +146,35 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu } }); +// An argument-less prebuilt hook emits `node "/"`, the +// exact shape of a compiler wrapper command. Hook coherence must recognize it +// by its payload location instead of misreporting AB6018 (not indexed). +it('validates an argument-less prebuilt hook without demanding a wrapper index entry', async () => { + const root = await createProject({ + hooks: [ + ' hooks: { afterTool: [', + " { handler: { prebuilt: './built/runtime/hook.js' }, targets: ['claude'], tools: ['file.write'] },", + ' ] },', + ].join('\n'), + payload: standardPayloadBlock, + }); + try { + const result = await build({ output: join(root, 'out'), root }); + expect(result.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + const claudeHooks = await readJson<{ hooks: { PostToolUse: { hooks: { command: string }[] }[] } }>( + join(root, 'out', 'claude', 'hooks', 'hooks.json'), + ); + expect(claudeHooks.hooks.PostToolUse[0]?.hooks[0]).toMatchObject({ + command: 'node "${CLAUDE_PLUGIN_ROOT}/runtime/hook.js"', + }); + + const revalidated = await validate({ artifact: join(root, 'out'), root }); + expect(revalidated.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('reports the prebuilt payload source diagnostics', async () => { const root = await createProject({ hooks: [ From ced825758d3b91658e959c983ac8c144589afd77 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 11:43:38 +0000 Subject: [PATCH 6/6] chore(prebuilt-payloads): drop duplicated helpers, casts, and comment narration Reuse the exported isInside and isRecord helpers instead of the two open-coded reimplementations in discover.ts and normalize.ts, drop the type assertions the isPrebuiltEntryInput guard already narrows away, collapse the duplicated empty-payload branches in discoverPayloads, and extract the payload targets check so validatePayload stays shallow. Write the two capability-state updates in main.tsx with the conditional spread the rest of the codebase uses, rather than duplicating the object literal per branch. Comment edits keep one authoritative statement per rationale and drop narration about what the branch changed. --- examples/rsc-agent-runtime/rsbuild.config.ts | 15 ++--- .../tests/dev-provider.integration.test.ts | 4 +- .../tests/docs-contract.test.ts | 7 +-- .../tests/host-artifacts.test.ts | 12 ++-- packages/agent-bundle/src/adapters/types.ts | 9 ++- packages/agent-bundle/src/build/mcp-apps.ts | 2 - .../src/build/validate-artifact.ts | 6 +- packages/agent-bundle/src/config/discover.ts | 22 +++---- packages/agent-bundle/src/config/normalize.ts | 27 ++++----- packages/agent-bundle/src/config/validate.ts | 58 ++++++++++--------- packages/workbench/src/main.tsx | 11 ++-- 11 files changed, 78 insertions(+), 95 deletions(-) diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index 57303470a..54dcc6fdc 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -201,15 +201,12 @@ export const createRscRuntimeRsbuildConfig = ( development ? join(options.compilerRoot as string, name) : productionRoot; return { - // The compile flavor is a pinned contract: react and react-server-dom - // compile as their production variants, so Flight payloads stay compact - // model rows without development debug and timing frames, and the - // in-worker dev server serves the production surface layout its session - // URLs are built for. This was previously implicit — the definition - // worker ran with no ambient NODE_ENV, so Rsbuild defaulted to - // production mode. With prebuilt host packaging the flavor must not - // float with ambient NODE_ENV. `options.mode` keeps selecting the - // compile topology (dev entries, compiler roots) independently. + // Pinned, not derived from ambient NODE_ENV: react and react-server-dom + // must compile as their production variants so Flight payloads stay + // compact model rows without development debug and timing frames, and so + // the in-worker dev server serves the production surface layout its + // session URLs are built for. `options.mode` still selects the compile + // topology (dev entries, compiler roots) independently of this flavor. mode: 'production', ...(development ? { dev: { writeToDisk: true }, diff --git a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts index f4069045e..d15410f74 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -334,8 +334,8 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil provider: './src/dev/provider.ts', servers: [expect.objectContaining({ name: 'timeline', transport: 'stdio' })], }); - // One prebuilt hook declaration per host (each carries its own - // `--host` argument), replacing the previous dual-target declaration. + // One prebuilt hook declaration per host, each carrying its own `--host` + // argument. expect(prepared.model?.hooks).toEqual(expect.arrayContaining([ expect.objectContaining({ prebuiltPath: 'runtime/hook/index.js', targets: ['claude'] }), expect.objectContaining({ prebuiltPath: 'runtime/hook/index.js', targets: ['codex'] }), diff --git a/examples/rsc-agent-runtime/tests/docs-contract.test.ts b/examples/rsc-agent-runtime/tests/docs-contract.test.ts index 521cd0505..2e2ab2020 100644 --- a/examples/rsc-agent-runtime/tests/docs-contract.test.ts +++ b/examples/rsc-agent-runtime/tests/docs-contract.test.ts @@ -43,11 +43,8 @@ test('declares a shell-independent production build', async () => { readonly scripts?: Readonly>; }; - // Pin updated with the prebuilt-payload migration (RFC #50 Phase 3): the - // demo's own Rsbuild production build stays first — the custom RSC - // compilation is this example's subject — and the hand-rolled - // scripts/package-hosts.mjs step is replaced by `agent-bundle build` - // packaging the declared payload trees into dist/plugins. + // Ordering is the contract: the demo's own Rsbuild build must produce the + // declared payload trees before `agent-bundle build` packages them. expect(manifest.scripts?.build).toBe('rsbuild build --mode production && agent-bundle build --json --output dist/plugins'); }); diff --git a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts index 9104a344a..0e776fe0c 100644 --- a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts +++ b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts @@ -17,8 +17,7 @@ const pluginsRoot = join(exampleRoot, 'dist/plugins'); const runPackageHosts = async (): Promise => { await ensureExampleBuilt(); - // Repackaging the existing prebuilt payload is agent-bundle's job now; the - // command must be independently rerunnable against the current dist trees. + // Packaging must be independently rerunnable against the current dist trees. const child = spawn('pnpm', ['exec', 'agent-bundle', 'build', '--json', '--output', 'dist/plugins'], { cwd: exampleRoot, stdio: 'pipe', @@ -125,9 +124,7 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy join(codexRoot, 'hooks/hooks.json'), ); - // Host manifests are generated from agent-bundle.config.ts now, so the - // plugin identity is the config's plugin block rather than the previously - // hand-rolled name/version pair. + // The generated identity is the config's `plugin` block. expect(claudeManifest).toMatchObject({ name: 'rsc-agent-runtime-demo', version: '1.0.0' }); expect(codexManifest).toMatchObject({ hooks: './hooks/hooks.json', @@ -171,9 +168,8 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy const appHtml = await readFile(join(exampleRoot, relative), 'utf8'); expect(appHtml).not.toMatch(/]+src=|]+rel=["']stylesheet["']/iu); } - // The generated layout has no empty skills directory for this skill-less - // plugin; the manifest's `./skills/` pointer stays, matching every other - // framework-built Codex artifact. + // A skill-less plugin emits no `skills/` directory, while the manifest's + // `./skills/` pointer stays — as in every framework-built Codex artifact. for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.mcp.json', 'hooks/hooks.json']) { await access(join(codexRoot, relative)); } diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index e1da404df..4eee806a1 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -104,11 +104,10 @@ export const withPluginRootEnvAnchor = ( ): Record => ({ [pluginRootEnvAnchor]: pluginRoot, ...env }); /** - * Copy entries for every selected prebuilt payload file: exact relative - * paths under the payload's declared destination, byte-for-byte, no - * content-hashing — the compiler did not produce these files and cannot - * rewrite the sibling references inside them, so stable names are the - * packaging contract. Shared by every target plan that emits payloads. + * Copy entries for every selected prebuilt payload file, at its exact + * relative path under the payload's declared destination (see + * AgentBundlePayloadConfig for why the names stay stable). Shared by every + * target plan that emits payloads. */ export const payloadCopyEntries = ( model: NormalizedPlugin, diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index ed497213f..e03e7a7b8 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -95,8 +95,6 @@ export const planCompiledMcpApps = ( options: { readonly outDir: string; readonly target: string }, ): readonly CompiledMcpApp[] => { const planned = new Map(); - // Apps of prebuilt servers stay development surfaces: the payload already - // carries the served resource, so the compiler emits nothing for them. for (const app of apps.filter((candidate) => candidate.prebuilt !== true && candidate.targets.includes(options.target))) { const identity = appIdentity(app); const existing = planned.get(app.name); diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 355d8abab..a85f27aa1 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -445,10 +445,8 @@ const validateArtifactOwnership = (options: { if (artifactRootMetadata.has(file.path)) continue; const target = pathTarget(file.path, targets); if (target !== undefined && isTargetArtifactPath(file.path, target, options.registry)) continue; - // Prebuilt payload files are consumer-shaped by definition: they live in - // config-named directories under their target namespace, are declared - // with the `prebuilt` manifest kind, and stay hash-locked to the - // manifest like every other file. + // Prebuilt payload files live in config-named directories under their + // target namespace, so no emitted layout describes them. if (target !== undefined && manifestKinds.get(file.path) === 'prebuilt') continue; diagnostics.push(diagnostic( 'AB6014', diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index a7ff4a603..6058f5c32 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -1,8 +1,10 @@ import { stat } from 'node:fs/promises'; -import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { basename, dirname, relative, resolve } from 'node:path'; import fastGlob from 'fast-glob'; +import { isInside } from '../core/paths.ts'; +import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleConfig } from '../core/types.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts'; import { isRenderedSkillSourceName } from './rendered-skill.ts'; @@ -109,11 +111,6 @@ const discoverAssets = async ( }))); }; -const isInsideRoot = (root: string, candidate: string): boolean => { - const relativePath = relative(root, candidate); - return relativePath.length > 0 && relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath); -}; - /** * The absolute source directories of well-shaped payload declarations. * Source snapshots use this to include payload files in the project @@ -124,13 +121,13 @@ export const configuredPayloadRoots = ( config: Readonly, ): readonly string[] => { const configured = config.payload; - if (configured === undefined || typeof configured !== 'object' || Array.isArray(configured)) return []; + if (configured === undefined || !isRecord(configured)) return []; const roots: string[] = []; for (const declaration of Object.values(configured)) { const entry = typeof declaration === 'string' ? declaration : declaration?.source; if (typeof entry !== 'string' || entry.trim().length === 0) continue; const source = resolve(projectRoot, entry); - if (isInsideRoot(projectRoot, source)) roots.push(source); + if (isInside(projectRoot, source)) roots.push(source); } return [...new Set(roots)].sort((left, right) => left.localeCompare(right)); }; @@ -146,21 +143,20 @@ const discoverPayloads = async ( projectRoot: string, configured: AgentBundleConfig['payload'], ): Promise => { - if (configured === undefined || typeof configured !== 'object' || Array.isArray(configured)) return []; + if (configured === undefined || !isRecord(configured)) return []; const payloads: DiscoveredPayload[] = []; for (const [name, declaration] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) { const entry = typeof declaration === 'string' ? declaration : declaration?.source; if (typeof entry !== 'string' || entry.trim().length === 0) continue; const source = resolve(projectRoot, entry); - if (!isInsideRoot(projectRoot, source)) continue; + if (!isInside(projectRoot, source)) continue; let stats; try { stats = await stat(source); } catch { - payloads.push({ files: [], name, source }); - continue; + // A payload the consumer's own build has not produced yet has no files. } - if (!stats.isDirectory()) { + if (stats?.isDirectory() !== true) { payloads.push({ files: [], name, source }); continue; } diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index b1a07b279..1c0db6ad9 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -1,15 +1,17 @@ import { createHash } from 'node:crypto'; import { existsSync, statSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; -import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { basename, extname, relative, resolve } from 'node:path'; import { digest } from '../core/digest.ts'; +import { isInside } from '../core/paths.ts'; import { defaultGeneratedRuntime, formatRuntimeVersion, parseRuntimeVersion, satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; +import { isRecord } from '../core/strict-json.ts'; import { isPrebuiltEntryInput, parseNativeHookToolSelector, pathTokens } from '../core/types.ts'; import type { AgentBundleBinEntry, @@ -19,7 +21,6 @@ import type { AgentBundleLibEntry, AgentBundleMcpApp, AgentBundleMcpServer, - AgentBundlePayloadEntry, AgentBundleScriptInput, CanonicalHookEvent, CanonicalHookTool, @@ -225,12 +226,10 @@ const normalizePayloads = ( targetNames: readonly string[], ): readonly NormalizedPayload[] => { const configured = loaded.config.payload; - if (configured === undefined || typeof configured !== 'object' || Array.isArray(configured)) return []; + if (configured === undefined || !isRecord(configured)) return []; const discoveredByName = new Map((discovered.payloads ?? []).map((payload) => [payload.name, payload])); const payloads: NormalizedPayload[] = []; - for (const [name, rawDeclaration] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) { - const declaration = rawDeclaration as string | AgentBundlePayloadEntry | undefined; - if (declaration === undefined) continue; + for (const [name, declaration] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) { const entry = typeof declaration === 'string' ? declaration : declaration.source; if (typeof entry !== 'string' || entry.trim().length === 0) continue; payloads.push({ @@ -245,11 +244,6 @@ const normalizePayloads = ( return payloads; }; -const isInsidePath = (root: string, candidate: string): boolean => { - const relativePath = relative(root, candidate); - return relativePath.length > 0 && relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath); -}; - /** * The artifact-relative stable path of a prebuilt file: its declaring payload * destination plus the file's payload-relative path. Falls back to the @@ -263,7 +257,7 @@ export const prebuiltArtifactPath = ( ): string => { let best: NormalizedPayload | undefined; for (const payload of payloads) { - if (!isInsidePath(payload.source, source)) continue; + if (!isInside(payload.source, source)) continue; if (best === undefined || payload.source.length > best.source.length) best = payload; } return best === undefined @@ -307,8 +301,9 @@ const normalizeHook = ( payloads: readonly NormalizedPayload[], ): NormalizedHook => { const entry = typeof input === 'string' ? { handler: input } : input; - const prebuilt = isPrebuiltEntryInput(entry.handler); - const source = resolve(root, prebuilt ? (entry.handler as { prebuilt: string }).prebuilt : entry.handler as string); + const handlerInput = entry.handler; + const prebuilt = isPrebuiltEntryInput(handlerInput); + const source = resolve(root, prebuilt ? handlerInput.prebuilt : handlerInput); const handler = relative(root, source).replaceAll('\\', '/'); const prebuiltPath = prebuilt ? prebuiltArtifactPath(payloads, root, source) : undefined; const args = prebuilt && entry.args !== undefined @@ -515,9 +510,7 @@ const normalizeMcpApps = ( for (const [serverName, rawServer] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) { const server = serverByName.get(serverName); - // Apps require a local server entry: a compiled source entry, or a - // prebuilt one — whose payload already carries the served resource, so - // the app stays a development surface the compiler never re-emits. + // Apps require a local server entry: a compiled source entry, or a prebuilt one. const prebuilt = isPrebuiltEntryInput(rawServer.entry); if (server === undefined || (server.source === undefined && !prebuilt) || rawServer.apps === undefined) continue; for (const [name, app] of Object.entries(rawServer.apps).sort(([left], [right]) => left.localeCompare(right))) { diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 2d8ec4802..447245cf7 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -18,7 +18,6 @@ import type { AgentBundleLibEntry, AgentBundleMcpApp, AgentBundleMcpServer, - AgentBundlePayloadEntry, AgentBundlePrebuiltEntry, AgentBundleScriptInput, CanonicalHookEvent, @@ -88,19 +87,20 @@ const validateHooks = ( if (input === undefined) continue; for (const rawEntry of asHookEntries(input)) { const entry = typeof rawEntry === 'string' ? { handler: rawEntry } : rawEntry; - const prebuilt = isPrebuiltEntryInput(entry.handler); + const handler = entry.handler; + const prebuilt = isPrebuiltEntryInput(handler); if (prebuilt) { const hookTargets = Array.isArray(entry.targets) && entry.targets.every(nonemptyString) ? entry.targets : selectedTargets.filter((target) => registry.supports(target, 'hooks')); diagnostics.push(...validatePrebuiltReference( `Hook ${event}`, - entry.handler as AgentBundlePrebuiltEntry, + handler, hookTargets, loaded, payloads, )); - } else if (typeof entry.handler !== 'string' || entry.handler.trim().length === 0) { + } else if (typeof handler !== 'string' || handler.trim().length === 0) { diagnostics.push(sourceDiagnostic( 'AB4200', `Hook ${event} requires a nonempty handler path.`, @@ -1022,8 +1022,7 @@ const declaredPayloads = ( if (configured === undefined || !isRecord(configured)) return []; const selectedTargets = selectedTargetNamesFor(loaded, registry); const payloads: DeclaredPayload[] = []; - for (const [name, rawDeclaration] of Object.entries(configured)) { - const declaration = rawDeclaration as string | AgentBundlePayloadEntry; + for (const [name, declaration] of Object.entries(configured)) { const entry = typeof declaration === 'string' ? declaration : isRecord(declaration) ? declaration.source : undefined; @@ -1079,6 +1078,30 @@ const newestFileMtime = (root: string, skipDirectory: (name: string) => boolean) const ignoredSourceDirectoryNames = new Set(['.agent-bundle', '.git', 'dist', 'node_modules']); +/** AB4740: one payload declaration's optional `targets` restriction. */ +const payloadTargetDiagnostics = ( + name: string, + targets: unknown, + loaded: LoadedConfig, + registry: NormalizationTargetRegistry, +): Diagnostic[] => { + if (targets === undefined) return []; + if (!Array.isArray(targets) || !targets.every(nonemptyString)) { + return [sourceDiagnostic( + 'AB4740', + `Payload ${JSON.stringify(name)} targets must be an array of nonempty strings.`, + loaded.configPath, + )]; + } + return targets + .filter((target: string) => !registry.has(target)) + .map((target: string) => sourceDiagnostic( + 'AB4740', + `Payload ${JSON.stringify(name)} selects unknown target ${JSON.stringify(target)}.`, + loaded.configPath, + )); +}; + /** * AB4740-AB4743 and the AB4750 freshness nudge: shape, destination-name, * source-path, and existence checks for the prebuilt `payload` block. @@ -1097,7 +1120,7 @@ const validatePayload = ( } const diagnostics: Diagnostic[] = []; const sources: { name: string; source: string }[] = []; - for (const [name, rawDeclaration] of Object.entries(configured)) { + for (const [name, declaration] of Object.entries(configured)) { if (!isSafePayloadName(name) || reservedPayloadDestinations.has(name)) { diagnostics.push(sourceDiagnostic( 'AB4741', @@ -1105,7 +1128,6 @@ const validatePayload = ( loaded.configPath, )); } - const declaration = rawDeclaration as string | AgentBundlePayloadEntry; const entry = typeof declaration === 'string' ? declaration : isRecord(declaration) ? declaration.source : undefined; @@ -1117,24 +1139,8 @@ const validatePayload = ( )); continue; } - if (typeof declaration !== 'string' && declaration.targets !== undefined) { - if (!Array.isArray(declaration.targets) || declaration.targets.some((target) => !nonemptyString(target))) { - diagnostics.push(sourceDiagnostic( - 'AB4740', - `Payload ${JSON.stringify(name)} targets must be an array of nonempty strings.`, - loaded.configPath, - )); - } else { - for (const target of declaration.targets) { - if (!registry.has(target)) { - diagnostics.push(sourceDiagnostic( - 'AB4740', - `Payload ${JSON.stringify(name)} selects unknown target ${JSON.stringify(target)}.`, - loaded.configPath, - )); - } - } - } + if (typeof declaration !== 'string') { + diagnostics.push(...payloadTargetDiagnostics(name, declaration.targets, loaded, registry)); } const source = resolve(loaded.context.projectRoot, entry); if (!isInside(loaded.context.projectRoot, source)) { diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index 2b4da4e29..65a6e8037 100644 --- a/packages/workbench/src/main.tsx +++ b/packages/workbench/src/main.tsx @@ -1239,7 +1239,7 @@ const Workbench = () => { setCapabilityState((current) => { if (current.state === 'ready' && current.value.buildId === buildId) return current; const previous = staleCapabilities(current); - return previous === undefined ? { buildId, state: 'loading' } : { buildId, previous, state: 'loading' }; + return { buildId, ...(previous === undefined ? {} : { previous }), state: 'loading' }; }); void loadWorkbenchCapabilities({ artifactClient: artifactClient.current!, @@ -1253,9 +1253,12 @@ const Workbench = () => { if (request.signal.aborted) return; setCapabilityState((current) => { const previous = staleCapabilities(current); - return previous === undefined - ? { buildId, message: errorMessage(reason), state: 'error' } - : { buildId, message: errorMessage(reason), previous, state: 'error' }; + return { + buildId, + message: errorMessage(reason), + ...(previous === undefined ? {} : { previous }), + state: 'error', + }; }); }, );