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 932ff0bdd..319f9ffcf 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. | @@ -94,6 +95,29 @@ document beats a generated one — so the component module never compiles. Adopt: remove `SKILL.md` so the rendered skill compiles at build. Silence: remove the component module. +## 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 03cfe5067..cf81be67d 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -131,6 +131,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 2b213f875..6aeec4197 100644 --- a/examples/rsc-agent-runtime/agent-bundle.config.ts +++ b/examples/rsc-agent-runtime/agent-bundle.config.ts @@ -1,5 +1,9 @@ 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({ // Kept deliberately: the empty per-target sections are not redundant with // `targets:` — normalization materializes each one as a `model.extensions` @@ -8,12 +12,24 @@ export default defineConfig({ 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: { @@ -27,12 +43,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 42e3526b6..0621e443d 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", "validate": "agent-bundle validate", 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 2ce2087c2..54dcc6fdc 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -201,6 +201,13 @@ export const createRscRuntimeRsbuildConfig = ( development ? join(options.compilerRoot as string, name) : productionRoot; return { + // 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 }, // Port 0 lets the OS assign the listener. Rsbuild's default (3000 with an 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 969388b3a..d15410f74 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(); @@ -319,6 +320,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'); @@ -327,8 +334,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 carrying its own `--host` + // argument. 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..2e2ab2020 100644 --- a/examples/rsc-agent-runtime/tests/docs-contract.test.ts +++ b/examples/rsc-agent-runtime/tests/docs-contract.test.ts @@ -43,7 +43,9 @@ test('declares a shell-independent production build', async () => { readonly scripts?: Readonly>; }; - expect(manifest.scripts?.build).toBe('rsbuild build --mode production && pnpm package:hosts'); + // 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'); }); 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..0e776fe0c 100644 --- a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts +++ b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts @@ -17,10 +17,16 @@ 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' }); + // 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', + }); + 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 +124,27 @@ 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' }); + // 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', 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 +168,9 @@ 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']) { + // 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/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 98f24fe5b..da908cdd1 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, @@ -271,6 +272,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 c3e4e0781..4eee806a1 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,26 @@ export const withPluginRootEnvAnchor = ( pluginRoot: string, ): Record => ({ [pluginRootEnvAnchor]: pluginRoot, ...env }); +/** + * 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, + 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; @@ -237,6 +263,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 225e17940..e03e7a7b8 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -95,7 +95,7 @@ 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))) { + 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-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/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..a85f27aa1 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -439,11 +439,15 @@ 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 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', `Artifact file ${JSON.stringify(file.path)} is outside declared target emitted layouts.`, @@ -523,9 +527,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 +556,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 +568,7 @@ const validateGeneratedFiles = async (options: { ...(options.manifestFiles === undefined ? {} : { manifestFiles: new Set(options.manifestFiles.map((file) => file.path)) }), + prebuiltPaths, validJson, })); @@ -567,7 +581,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 e306294a9..6058f5c32 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -3,6 +3,8 @@ 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'; @@ -19,8 +21,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[]; /** * Conventional `skills//SKILL.md` documents that explicit `skills` * configuration leaves uncovered — the confusable shadowed state surfaced @@ -94,6 +111,69 @@ const discoverAssets = async ( }))); }; +/** + * 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 || !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 (isInside(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 || !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 (!isInside(projectRoot, source)) continue; + let stats; + try { + stats = await stat(source); + } catch { + // A payload the consumer's own build has not produced yet has no files. + } + if (stats?.isDirectory() !== true) { + 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, @@ -128,8 +208,10 @@ export const discoverProject = async ( } const shadowedConventionalSkills = [...shadowedByDir.values()]; + const payloads = await discoverPayloads(projectRoot, config.payload); return { assets: await discoverAssets(projectRoot, config.assets, rules), + ...(payloads.length === 0 ? {} : { payloads }), ...(shadowedConventionalSkills.length === 0 ? {} : { shadowedConventionalSkills }), 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 80527b186..1c0db6ad9 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -4,13 +4,15 @@ import { readFile } from 'node:fs/promises'; 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 { parseNativeHookToolSelector, pathTokens } from '../core/types.ts'; +import { isRecord } from '../core/strict-json.ts'; +import { isPrebuiltEntryInput, parseNativeHookToolSelector, pathTokens } from '../core/types.ts'; import type { AgentBundleBinEntry, AgentBundleConfig, @@ -33,6 +35,7 @@ import type { NormalizedMcpServer, NormalizedNativeHook, NormalizedPackageBuild, + NormalizedPayload, NormalizedPlugin, NormalizedRuntime, NormalizedScript, @@ -204,6 +207,64 @@ 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 || !isRecord(configured)) return []; + const discoveredByName = new Map((discovered.payloads ?? []).map((payload) => [payload.name, payload])); + const payloads: NormalizedPayload[] = []; + 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({ + 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; +}; + +/** + * 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 (!isInside(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 +298,17 @@ 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 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 + ? 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 +316,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 +331,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 +349,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 +364,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 +415,7 @@ const normalizeMcpServer = ( root: string, defaultTargets: readonly string[], provenance: SourceProvenance, + payloads: readonly NormalizedPayload[], ): NormalizedMcpServer => { const targets = sortedUnique(server.targets ?? defaultTargets); const conventionalEntry = @@ -357,6 +431,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 +486,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 +495,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 +510,16 @@ 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. + 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, @@ -667,7 +765,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); @@ -684,9 +783,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 8e4fb5418..447245cf7 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,7 @@ import type { AgentBundleLibEntry, AgentBundleMcpApp, AgentBundleMcpServer, + AgentBundlePrebuiltEntry, AgentBundleScriptInput, CanonicalHookEvent, NormalizationTargetRegistry, @@ -27,6 +28,7 @@ import { conventionalCliEntrySource, conventionalIndexEntrySource, conventionalMcpEntrySource, + reservedPayloadDestinations, } from './normalize.ts'; import type { DiscoveredProject } from './discover.ts'; import type { LoadedConfig } from './load.ts'; @@ -71,6 +73,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 +87,44 @@ 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 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}`, + handler, + hookTargets, + loaded, + payloads, + )); + } else if (typeof handler !== 'string' || 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,285 @@ 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, declaration] of Object.entries(configured)) { + 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: 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. + * 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, declaration] 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 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') { + diagnostics.push(...payloadTargetDiagnostics(name, declaration.targets, loaded, registry)); + } + 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; +}; + /** * AB4734: explicit `skills` configuration leaves a conventional * `skills//SKILL.md` document uncovered, so the convention is silently @@ -1041,11 +1373,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)); @@ -1086,6 +1420,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); @@ -1261,6 +1596,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 b12409140..36dcc18e1 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; @@ -185,6 +224,7 @@ export interface AgentBundleConfig extends AgentBundleConfigExtensions { lib?: AgentBundleLibConfig; marketplace?: boolean; mcp?: AgentBundleMcpConfig; + payload?: AgentBundlePayloadConfig; plugin: AgentBundlePluginConfig; runtime?: AgentBundleRuntimeConfig; scripts?: Readonly>; @@ -194,7 +234,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; @@ -273,6 +313,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; @@ -320,11 +366,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[]; @@ -333,6 +387,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'; @@ -378,6 +453,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..759bce7f1 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,16 @@ export class ProjectService { let diagnostics: Diagnostic[]; try { - diagnostics = [...validateModel(model, registry)]; + // 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 : []), + ...validateModel(model, registry), + ]; for (const target of model.targets) { if (!registry.has(target.name)) continue; const adapter = registry.get(target.name); @@ -792,6 +839,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..c3da56656 --- /dev/null +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -0,0 +1,259 @@ +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 }); + } +}); + +// 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: [ + ' 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/main.tsx b/packages/workbench/src/main.tsx index 21f67bd8c..65a6e8037 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 { buildId, ...(previous === undefined ? {} : { previous }), state: 'loading' }; + }); void loadWorkbenchCapabilities({ artifactClient: artifactClient.current!, buildId, @@ -1229,7 +1251,15 @@ 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 { + buildId, + message: errorMessage(reason), + ...(previous === undefined ? {} : { previous }), + state: 'error', + }; + }); }, ); return () => request.abort(); 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/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; 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]');