diff --git a/.changeset/optional-plugin-version-and-meta-constant.md b/.changeset/optional-plugin-version-and-meta-constant.md new file mode 100644 index 000000000..d1b7df3cd --- /dev/null +++ b/.changeset/optional-plugin-version-and-meta-constant.md @@ -0,0 +1,7 @@ +--- +"agent-bundle": minor +--- + +Make `plugin.version` optional and derive it from `package.json`, so a packaged plugin declares its release version once. A declared value that is not a nonempty string still reports `AB4001`, and a declared value that disagrees with `package.json` still reports the `AB4008` warning. Development keeps the labeled `0.0.0-dev` fallback, while `agent-bundle build` now refuses a project with no release version at all with the new `AB4013` error, so a development fallback can never reach a release artifact. + +Add the `agent-bundle/meta` build-time identity module. Every compiled plugin surface — script, CLI, MCP entry, hook, and package bundles plus browser MCP App bundles — resolves it to the exact `{ name, version, packageName, packageVersion }` reported by artifact manifests, `inspect`, and dev status, so plugins can delete hand-written `src/lib/version.ts` shims. Types ship with the package export; outside Agent Bundle compilation the module throws instead of reporting a fabricated identity. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index b1a5eb0f9..1ba14e827 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -11,7 +11,7 @@ gate a build, a validation, or a dev rebuild. | Family | Area | | --- | --- | | `AB30xx` | Skill documents: Markdown parsing (`AB3000`–`AB3002`: unreadable, missing or malformed frontmatter) and rendered-skill compilation (`AB3003`: module failed to load, `AB3004`: missing/invalid default component or `frontmatter` export, `AB3005`: content outside the supported Markdown element subset). | -| `AB40xx` | Plugin metadata and Skill source validation (`AB4000`/`AB4001`: missing name/version; `AB4002`–`AB4007`: Skill fields; `AB4008`–`AB4011`: package identity; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project). | +| `AB40xx` | Plugin metadata and Skill source validation (`AB4000`/`AB4001`: name/version; `AB4002`–`AB4007`: Skill fields; `AB4008`–`AB4011` and `AB4013`: release identity, see below; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project). | | `AB41xx` | Normalized model invariants (unknown targets, duplicate IDs and outputs). | | `AB42xx` | Hook configuration and native hook sources. | | `AB43xx` | MCP server and MCP App configuration. | @@ -62,6 +62,38 @@ its module does not export) are invisible to `tsc --noEmit`, so a green `tsc --declaration --emitDeclarationOnly` over the lib entry source directory. +## Release identity (`AB4001`, `AB4008`–`AB4011`, `AB4013`) + +`package.json` is authoritative for release identity (issue #94): its `name` +and `version` become the `packageName` and `packageVersion` axes carried on +the project context, artifact manifests, `inspect` output, and dev status. +`plugin.name` stays the host-native slug and is never derived from the npm +package name. + +`plugin.version` is **optional**. When it is omitted, the version every +surface reports — manifests, host projections, dev status, and the +`agent-bundle/meta` constant compiled into plugin code — is the `package.json` +version. When it is declared, the declared value still wins so a legacy +config never changes meaning mid-migration, and a disagreement reports the +`AB4008` **warning**. Declaring it as anything but a nonempty string is an +`AB4001` error. + +A project with neither an authored `plugin.version` nor a valid `package.json` +version has no release identity. Development commands (`dev`, `inspect`, +`validate`) keep running on the labeled `0.0.0-dev.` fallback, +because an unpackaged scratch project is a normal development state. A +development-only fallback can never produce a release artifact, so +`agent-bundle build` alone refuses it with `AB4013`. + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB4001` | error | `plugin.version` is declared as something other than a nonempty string. Omit the field to derive the version from `package.json`. | +| `AB4008` | warning | A declared `plugin.version` differs from the `package.json` version. Align the two, or drop `plugin.version`. | +| `AB4009` | warning | `package.json` `name` is not a valid npm package name; the `packageName` axis is withheld. | +| `AB4010` | warning | `package.json` `version` is not a valid semantic version; the `packageVersion` axis is withheld. | +| `AB4011` | warning | `package.json` is unusable — unparsable, not a JSON object, or symlinked outside the project root. | +| `AB4013` | error (build) | `agent-bundle build` refuses a project with no release version: `plugin.version` is omitted and `package.json` declares no valid semantic version. | + ## Migration nudges (`AB4730`–`AB4735`) The entry conventions and the framework-owned stdio lifecycle shell (RFC #50) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index e51683ed8..5e8a88c6d 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -268,6 +268,35 @@ 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. +## `agent-bundle/meta` — build-time release identity + +Plugin code reads its own identity from the framework instead of maintaining +a hand-written `src/lib/version.ts`: + +```ts +import meta, { name, packageName, packageVersion, version } from 'agent-bundle/meta'; +``` + +`version` is the resolved plugin version: the authored `plugin.version` when +declared, otherwise the `package.json` version. `name` is the host-native +plugin slug — never the npm package name. `packageName` and `packageVersion` +are the validated npm axes, `undefined` for an unpackaged development +project. Every value is exactly what artifact manifests, `inspect`, and dev +status report for the same build. + +The compiler replaces the specifier in **every** compiled surface: artifact +scripts, the routed CLI, MCP entries, hook wrappers, and the package build +(all through Rslib), plus browser MCP App view bundles (through Rsbuild). The +module is a reserved specifier, so the `tools` hatch cannot externalize it, +and no emitted bundle can still carry an unresolved import of it. + +Types ship with the package export, so no generated declaration file is +involved. Outside Agent Bundle compilation the published module throws rather +than reporting a fabricated identity — a plugin slug exists only in the +config, and a runtime guess at it would silently disagree with the artifact. +A release build refuses a project with no release version at all (`AB4013`), +so a compiled artifact never carries the development fallback. + ## Prebuilt payloads — package what you compiled yourself Some projects legitimately own their compilation — a coordinated @@ -359,8 +388,8 @@ A hatch value that breaks an artifact contract (async chunks, output roots, self-containment) fails the build with a hard diagnostic instead of silently overriding the contract. Reserved module specifiers are protected the same way: a hatch that externalizes `agent-bundle/mcp-entry` or a generated -registry specifier (such as `agent-bundle/mcp-apps`) fails the build with a -hard diagnostic — at config inspection for statically visible `externals`, +module specifier (`agent-bundle/meta`, or a registry specifier such as +`agent-bundle/mcp-apps`) fails the build with a hard diagnostic — at config inspection for statically visible `externals`, and through a post-build scan of the emitted bundle for function-form `externals` — because generated executables must stay self-contained. The hatch customizes *how code compiles*, never *what the artifact promises*. diff --git a/examples/audiobook-curator/agent-bundle.config.ts b/examples/audiobook-curator/agent-bundle.config.ts index 4a9aa86b3..24ef762d2 100644 --- a/examples/audiobook-curator/agent-bundle.config.ts +++ b/examples/audiobook-curator/agent-bundle.config.ts @@ -9,13 +9,9 @@ export default defineConfig({ // package name (`@agent-bundle-example/audiobook-curator`); scoped npm // names never become slugs. name: 'audiobook-curator', - // Release identity is derived from package.json: `packageName` and - // `packageVersion` flow into the project context, artifact manifests, - // inspect output, and dev status. This declared version must match the - // package.json version — a mismatch reports the AB4008 warning. The - // package.json version is the single version source; this field only - // restates it until plugin.version becomes optional (issue #94 stage 3). - version: '1.0.0', + // No `version` field: package.json is the single version source. It flows + // into the project context, artifact manifests, inspect output, dev + // status, and the `agent-bundle/meta` constant this plugin imports. }, runtime: { node: '22.19.0' }, // No `scripts` or `bin` fields needed: the routed `src/cli/` commands diff --git a/examples/mcp-app/src/mcp/status.ts b/examples/mcp-app/src/mcp/status.ts index 573a18081..fe9f6e425 100644 --- a/examples/mcp-app/src/mcp/status.ts +++ b/examples/mcp-app/src/mcp/status.ts @@ -1,5 +1,6 @@ import { McpServer } from '@modelcontextprotocol/server'; import apps from 'agent-bundle/mcp-apps'; +import { name, version } from 'agent-bundle/meta'; import { z } from 'zod'; import { healthyCompilerStatus } from '../compiler-status-contract.ts'; @@ -21,7 +22,9 @@ const serviceCatalog = Object.freeze({ }); export const createStatusServer = (): McpServer => { - const server = new McpServer({ name: 'mcp-app-example', version: '1.0.0' }); + // The compiler stamps this project's identity into `agent-bundle/meta`, so + // the wire identity cannot drift from the config or package.json. + const server = new McpServer({ name, version }); server.registerResource(app.name, app.resourceUri, { _meta: { ui: { resourceUri: app.resourceUri } }, diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 535184f8d..56242fd90 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -61,6 +61,10 @@ "types": "./dist/mcp-apps.d.ts", "import": "./dist/mcp-apps.js" }, + "./meta": { + "types": "./dist/meta.d.ts", + "import": "./dist/meta.js" + }, "./mcp-entry": { "types": "./dist/mcp-entry.d.ts", "import": "./dist/mcp-entry.js" diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index db0830d29..ae401acb9 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ index: './src/index.ts', 'mcp-apps': './src/mcp-apps.ts', 'mcp-entry': './src/mcp-entry.ts', + meta: './src/meta.ts', 'mcp-server-runtime': './src/mcp-server-runtime.ts', rstest: './src/rstest/index.ts', test: './src/test/index.ts', diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index c649d3eb9..e0c17f608 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -21,6 +21,7 @@ import { type CompiledHookEntry, type CompiledMcpEntry, } from './entries.ts'; +import { projectMeta } from './meta.ts'; import { compileMcpApps, planCompiledMcpApps, type CompiledMcpApp } from './mcp-apps.ts'; import { assertUniqueArtifactDestinations, @@ -346,9 +347,13 @@ export const build = async (options: BuildOptions): Promise => { const compiledMcpApps: CompiledMcpApp[] = []; const compiledMcpEntries: CompiledMcpEntry[] = []; const tools = options.tools === undefined ? {} : { tools: options.tools }; + // One identity feeds every compiled surface, exactly the identity the + // manifest, `inspect`, and dev status report (issue #237). + const meta = projectMeta(options.model.metadata); for (const target of stagedTargets) { const targetMcpApps = await compileMcpApps(options.model.mcpApps ?? [], { cwd: options.projectRoot, + meta, outDir: target.root, target: target.name, ...tools, @@ -360,6 +365,7 @@ export const build = async (options: BuildOptions): Promise => { options.model.scripts.filter((script) => script.targets.includes(target.name)), { cwd: options.projectRoot, + meta, outDir: target.root, ...(options.model.state === undefined ? {} : { state: options.model.state }), ...tools, @@ -369,6 +375,7 @@ export const build = async (options: BuildOptions): Promise => { compiledHooks.push(...(await compileHooks(target.hookEntries, { artifactEpoch: options.projectContext.revision, cwd: options.projectRoot, + meta, outDir: target.root, ...tools, }))); @@ -379,6 +386,7 @@ export const build = async (options: BuildOptions): Promise => { eventHooks: target.hookEntries .filter((entry) => entry.hook.eventRoute !== undefined) .map((entry) => entry.hook), + meta, outDir: target.root, plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, providers: options.model.providers ?? [], diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 32f0229c9..44ecf944c 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -16,6 +16,7 @@ import type { NormalizedScript, NormalizedStateDefinition, } from '../core/types.ts'; +import type { AgentBundleMeta } from '../meta.ts'; import { mcpEntryAliasPattern } from '../config/normalize.ts'; import { stableJson } from '../core/digest.ts'; import { emitPlanEntries, resolveArtifactDestination } from './emit.ts'; @@ -156,6 +157,7 @@ export const compileEntries = async ( entries: readonly NormalizedScript[], options: { readonly cwd: string; + readonly meta: AgentBundleMeta; readonly outDir: string; readonly state?: NormalizedStateDefinition; readonly tools?: AgentBundleToolsConfig; @@ -226,6 +228,7 @@ export const compileEntries = async ( })()]; })), ...(cliRuntimeShell === undefined ? {} : { ignoredSourcePaths: [runtimeIgnoredRoot(cliRuntimeShell)] }), + meta: options.meta, outputRoot: options.outDir, ...(options.tools === undefined ? {} : { tools: options.tools }), }); @@ -307,6 +310,7 @@ export const compileMcpEntries = async ( readonly artifactEpoch: string; readonly cwd: string; readonly eventHooks: readonly NormalizedHook[]; + readonly meta: AgentBundleMeta; readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; readonly providers?: readonly CompiledProvider[]; @@ -450,6 +454,7 @@ export const compileMcpEntries = async ( ], }), logLevel: 'error', + meta: options.meta, outputRoot: options.outDir, ...(options.tools === undefined ? {} : { tools: options.tools }), }); @@ -484,6 +489,7 @@ export const compileHooks = async ( options: { readonly artifactEpoch: string; readonly cwd: string; + readonly meta: AgentBundleMeta; readonly outDir: string; readonly tools?: AgentBundleToolsConfig; }, @@ -520,6 +526,7 @@ export const compileHooks = async ( : { ignoredSourcePaths: [runtimeIgnoredRoot(eventIpcRuntime)], }), + meta: options.meta, outputRoot: options.outDir, ...(options.tools === undefined ? {} : { tools: options.tools }), }); diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index bb5c07530..8106c6b52 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -14,9 +14,11 @@ import { } from './entry-shell.ts'; import { planCompiledMcpEntries } from './entries.ts'; import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; +import { projectMeta } from './meta.ts'; import { planPackageEntries } from './package-build.ts'; import { composeEntryLibConfig, type RslibEntry } from './rslib.ts'; import { deepFreeze } from '../core/freeze.ts'; +import type { AgentBundleMeta } from '../meta.ts'; /** @@ -97,6 +99,7 @@ const renderConfigValue = (value: unknown, ancestors = new Set()): unkno const rslibInspectionEntry = (options: { readonly entry: RslibEntry; readonly kind: BundlerInspectionEntry['kind']; + readonly meta: AgentBundleMeta; readonly name: string; readonly outputPath: string; readonly outputRoot: string; @@ -106,6 +109,7 @@ const rslibInspectionEntry = (options: { }): BundlerInspectionEntry => Object.freeze({ bundler: 'rslib', config: renderConfigValue(composeEntryLibConfig(options.entry, { + meta: options.meta, outputRoot: options.outputRoot, ...(options.tools === undefined ? {} : { tools: options.tools }), })), @@ -122,6 +126,7 @@ const scriptEntries = async ( target: string, tools: AgentBundleToolsConfig | undefined, ): Promise => { + const meta = projectMeta(model.metadata); const outputRoot = artifactOutputToken(target); const scripts = model.scripts.filter((script) => script.mode === 'bundle' && script.targets.includes(target)); @@ -143,6 +148,7 @@ const scriptEntries = async ( : {}), }, kind: 'script', + meta, name: script.name, outputPath: `${target}/scripts/${script.name}.mjs`, outputRoot, @@ -158,6 +164,7 @@ const mcpEntryEntries = async ( target: string, tools: AgentBundleToolsConfig | undefined, ): Promise => { + const meta = projectMeta(model.metadata); const outputRoot = artifactOutputToken(target); const planned = planCompiledMcpEntries(model.mcpServers, { outDir: outputRoot, target }); const entries: BundlerInspectionEntry[] = []; @@ -204,6 +211,7 @@ const mcpEntryEntries = async ( ], }, kind: 'mcp-entry', + meta, name: serverName, outputPath: `${target}/mcp/${entry.name}.mjs`, outputRoot, @@ -229,6 +237,7 @@ const mcpEntryEntries = async ( }), }, kind: 'mcp-entry', + meta, name: `${serverName}:flight`, outputPath: `${target}/mcp/${workerFile}`, outputRoot, @@ -243,6 +252,7 @@ const mcpEntryEntries = async ( const hookEntries = ( entries: readonly TargetHookEntry[], + meta: AgentBundleMeta, target: string, tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { @@ -256,6 +266,7 @@ const hookEntries = ( virtualSource: entry.virtualSource, }, kind: 'hook', + meta, name: entry.hook.name, outputPath: `${target}/${entry.relativePath}`, outputRoot, @@ -284,6 +295,7 @@ const mcpAppsEntry = ( return [Object.freeze({ bundler: 'rsbuild' as const, config: renderConfigValue(composeMcpAppsRsbuildConfig(sources, { + meta: projectMeta(model.metadata), outDir: outputRoot, ...(tools === undefined ? {} : { tools }), })), @@ -302,11 +314,13 @@ const packageBuildEntries = async ( if (packageBuild === undefined) return []; const dtsTsconfig = packageBuild.lib?.dts === true ? generatedDtsTsconfigToken : undefined; const planned = await planPackageEntries(model, dtsTsconfig); + const meta = projectMeta(model.metadata); return planned.map((entry) => { const bin = entry.executable; return rslibInspectionEntry({ entry, kind: bin ? 'bin' : 'lib', + meta, name: bin ? entry.name.replace(/^bin-/u, '') : entry.name, outputPath: `${packageBuild.outputDir}/${entry.outputRelativePath}`, outputRoot: packageBuild.outputDir, @@ -327,11 +341,12 @@ export const composeBundlerInspection = async (options: { readonly tools?: AgentBundleToolsConfig; }): Promise => { const entries: BundlerInspectionEntry[] = []; + const meta = projectMeta(options.model.metadata); for (const target of options.targets) { entries.push( ...(await scriptEntries(options.model, target.name, options.tools)), ...(await mcpEntryEntries(options.model, target.name, options.tools)), - ...hookEntries(target.hookEntries, target.name, options.tools), + ...hookEntries(target.hookEntries, meta, target.name, options.tools), ...mcpAppsEntry(options.model, target.name, options.tools), ); } diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index e5d0140b7..58a71f410 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -1,15 +1,41 @@ -import { createRsbuild, mergeRsbuildConfig, type RsbuildConfig, type Rspack } from '@rsbuild/core'; +import { createRsbuild, mergeRsbuildConfig, rspack, type RsbuildConfig, type Rspack } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; import { readFile } from 'node:fs/promises'; import { extname, resolve } from 'node:path'; import type { AgentBundleToolsConfig, NormalizedMcpApp } from '../core/types.ts'; import { stableJson } from '../core/digest.ts'; +import type { AgentBundleMeta } from '../meta.ts'; import { listArtifactFiles, resolveArtifactDestination } from './emit.ts'; +import { + generatedMetaModulePath, + generatedMetaModuleSource, + generatedModulesDirname, + metaModuleSpecifier, +} from './meta.ts'; import { collectBundledOutputEvidence } from './provenance.ts'; export const mcpAppMimeType = 'text/html;profile=mcp-app'; +/** + * Rsbuild and Rslib carry independent Rspack copies (the dual-engine reality + * documented on `AgentBundleToolsConfig`), so the browser path checks its own + * engine for the experimental virtual-module surface rather than borrowing + * the Rslib guard. + */ +const virtualModulesPluginConstructor = (): typeof rspack.experiments.VirtualModulesPlugin => { + const constructor = (rspack as { readonly experiments?: { readonly VirtualModulesPlugin?: unknown } }) + .experiments?.VirtualModulesPlugin; + if (typeof constructor !== 'function') { + throw new Error( + 'The Rspack engine nested in @rsbuild/core no longer exposes experiments.VirtualModulesPlugin, ' + + 'which agent-bundle uses to serve the generated agent-bundle/meta module to browser MCP App bundles. ' + + 'Pin @rsbuild/core to a version whose Rspack ships the plugin, or update agent-bundle.', + ); + } + return constructor as typeof rspack.experiments.VirtualModulesPlugin; +}; + export interface CompiledMcpApp { readonly _meta?: Readonly>; readonly id: string; @@ -152,8 +178,14 @@ export const planCompiledMcpApps = ( */ export const composeMcpAppsRsbuildConfig = ( sources: readonly Pick[], - options: { readonly outDir: string; readonly tools?: AgentBundleToolsConfig }, + options: { + /** The project identity served to widget source as `agent-bundle/meta`. */ + readonly meta: AgentBundleMeta; + readonly outDir: string; + readonly tools?: AgentBundleToolsConfig; + }, ): RsbuildConfig => { + const metaModulePath = generatedMetaModulePath(options.outDir); const profile: RsbuildConfig = { environments: Object.fromEntries(sources.map((source) => [source.name, { ...(usesReactSyntax(source.source) ? { plugins: [pluginReact()] } : {}), @@ -181,6 +213,19 @@ export const composeMcpAppsRsbuildConfig = ( }; const enforceInvariants = (config: Rspack.Configuration): Rspack.Configuration => { config.output = { ...config.output, asyncChunks: false }; + // Exact-match ($) key per the resolve.alias contract: widget source + // imports exactly this specifier, never subpaths beneath it. + config.resolve = { + ...config.resolve, + alias: { ...config.resolve?.alias, [`${metaModuleSpecifier}$`]: metaModulePath }, + }; + // Added after the hatch mutator (this hook is merged last), so a consumer + // cannot strip the generated identity module out of the compiler. + const VirtualModulesPlugin = virtualModulesPluginConstructor(); + config.plugins = [ + ...(config.plugins ?? []), + new VirtualModulesPlugin({ [metaModulePath]: generatedMetaModuleSource(options.meta) }), + ]; return config; }; return mergeRsbuildConfig( @@ -198,6 +243,8 @@ export const compileMcpApps = async ( apps: readonly NormalizedMcpApp[], options: Readonly<{ readonly cwd: string; + /** The project identity served to widget source as `agent-bundle/meta`. */ + readonly meta: AgentBundleMeta; readonly outDir: string; readonly tools?: AgentBundleToolsConfig; } & McpAppTargetSelection>, @@ -221,6 +268,7 @@ export const compileMcpApps = async ( const rsbuild = await createRsbuild({ cwd: options.cwd, config: composeMcpAppsRsbuildConfig(sources, { + meta: options.meta, outDir: options.outDir, ...(options.tools === undefined ? {} : { tools: options.tools }), }), @@ -237,6 +285,9 @@ export const compileMcpApps = async ( path: `mcp-apps/${app.name}.html`, sourceInputs: app.sourceInputs, })), + // The generated identity module is virtual, but it still surfaces in + // stats as a module under this reserved namespace. + ignoredSourcePaths: [resolve(options.outDir, generatedModulesDirname)], projectRoot: options.cwd, stats: result.stats, }); diff --git a/packages/agent-bundle/src/build/meta.ts b/packages/agent-bundle/src/build/meta.ts new file mode 100644 index 000000000..f6c9b04d2 --- /dev/null +++ b/packages/agent-bundle/src/build/meta.ts @@ -0,0 +1,61 @@ +import { join } from 'node:path'; + +import type { AgentBundleMeta } from '../meta.ts'; +import type { NormalizedMetadata } from '../core/types.ts'; + +/** + * The reserved namespace (under each build's output root) whose paths + * identify generated module sources — wrapper entries, registry modules, and + * the project-identity module. Nothing ever writes these paths: they are + * guaranteed-nonexistent module ids served from memory by Rspack's + * `experiments.VirtualModulesPlugin`, chosen to be deterministic for + * `inspect --bundler` and collision-safe across entries. The namespace stays + * excluded from authored-source provenance. + */ +export const generatedModulesDirname = '.agent-bundle-virtual'; + +/** + * The reserved specifier every compiled plugin surface resolves to the + * generated identity module. It is a package subpath rather than a + * `scheme:`-shaped id on purpose: Rspack routes anything matching a URI + * scheme through its scheme handlers before `resolve.alias` ever runs, and a + * real subpath also carries its own published type declarations, so plugin + * authors need no generated `.d.ts` to import it. + */ +export const metaModuleSpecifier = 'agent-bundle/meta'; + +/** + * The generated path serving {@link metaModuleSpecifier}. One build stamps + * one identity, so every entry under an output root shares one module — the + * path carries no entry name and never shifts as other generated modules + * come and go. + */ +export const generatedMetaModulePath = (outputRoot: string): string => + join(outputRoot, generatedModulesDirname, 'meta.mjs'); + +/** The exact identity a build stamps into every compiled surface. */ +export const projectMeta = (metadata: NormalizedMetadata): AgentBundleMeta => Object.freeze({ + name: metadata.name, + packageName: metadata.packageName, + packageVersion: metadata.packageVersion, + version: metadata.version, +}); + +const constant = (binding: string, value: string | undefined): string => + `export const ${binding} = ${value === undefined ? 'undefined' : JSON.stringify(value)};`; + +/** + * The generated replacement for `agent-bundle/meta`: plain named constants + * so a bundler can inline and tree-shake each axis independently, plus the + * frozen aggregate the published type declares. + */ +export const generatedMetaModuleSource = (meta: AgentBundleMeta): string => [ + '// Generated by agent-bundle. Do not edit.', + constant('name', meta.name), + constant('packageName', meta.packageName), + constant('packageVersion', meta.packageVersion), + constant('version', meta.version), + 'export const meta = Object.freeze({ name, packageName, packageVersion, version });', + 'export default meta;', + '', +].join('\n'); diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index a1c3a9f8a..46f2a772c 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -16,6 +16,7 @@ import { generatedExecutableEntrySource, generatedRenderedRouteWorkerSource, } from './entry-shell.ts'; +import { projectMeta } from './meta.ts'; import type { BundledOutputEvidence } from './provenance.ts'; import { buildWithRslib, isDeclarationGenerationFailure, type RslibEntry } from './rslib.ts'; @@ -258,6 +259,7 @@ export const buildPackageOutputs = async (options: { entries, ...(cliRuntimeShell === undefined ? {} : { ignoredSourcePaths: [runtimeIgnoredRoot(cliRuntimeShell)] }), logLevel: 'error', + meta: projectMeta(options.model.metadata), outputRoot: stageRoot, ...(options.tools === undefined ? {} : { tools: options.tools }), }, dtsTsconfig === undefined || packageBuild.lib === undefined diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 1f9e7184b..42fbd7f99 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -10,7 +10,14 @@ import { join, resolve } from 'node:path'; import { isErrno } from '../core/errors.ts'; import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleToolsConfig } from '../core/types.ts'; +import type { AgentBundleMeta } from '../meta.ts'; import { mcpEntryRuntimeSpecifier } from './entry-shell.ts'; +import { + generatedMetaModulePath, + generatedMetaModuleSource, + generatedModulesDirname, + metaModuleSpecifier, +} from './meta.ts'; import { collectBundledOutputEvidence, type BundledOutputEvidence } from './provenance.ts'; export interface RslibVirtualModule { @@ -52,16 +59,6 @@ interface RslibDependencies { readonly createRslib?: (options: Parameters[0]) => Promise>; } -/** - * The reserved namespace (under each build's output root) whose paths - * identify generated module sources — wrapper entries and registry modules. - * Nothing ever writes these paths: they are guaranteed-nonexistent module - * ids served from memory by {@link virtualModulesPluginConstructor}, chosen - * to be deterministic for `inspect --bundler` and collision-safe across - * entries. The namespace stays excluded from authored-source provenance. - */ -const generatedModulesDirname = '.agent-bundle-virtual'; - /** * Generated sources ride Rspack's `experiments.VirtualModulesPlugin` instead * of throwaway files on disk — an accepted design decision: the experimental @@ -127,11 +124,21 @@ const entryImportsOf = (entryRecord: unknown, name: string): readonly string[] = const virtualRegistryModules = ( outputRoot: string, entry: RslibEntry, -): readonly { readonly name: string; readonly path: string; readonly source: string }[] => - (entry.virtualModules ?? []).map((module, index) => ({ + meta: AgentBundleMeta, +): readonly { readonly name: string; readonly path: string; readonly source: string }[] => [ + // The framework identity constant (issue #237) reaches every compiled + // surface, so it is composed here rather than declared per entry: one + // build stamps one identity, and every entry shares one generated module. + { + name: metaModuleSpecifier, + path: generatedMetaModulePath(outputRoot), + source: generatedMetaModuleSource(meta), + }, + ...(entry.virtualModules ?? []).map((module, index) => ({ ...module, path: join(outputRoot, generatedModulesDirname, `${entry.name}-${index}.mjs`), - })); + })), +]; /** * The module specifiers one entry's emitted bundle must inline: the runtime @@ -142,6 +149,7 @@ const virtualRegistryModules = ( */ const reservedSpecifiers = (entry: RslibEntry): readonly string[] => Object.freeze([...new Set([ mcpEntryRuntimeSpecifier, + metaModuleSpecifier, ...Object.keys(entry.aliases ?? {}), ...(entry.virtualModules ?? []).map((module) => module.name), ])]); @@ -337,6 +345,7 @@ const assertExecutableConfig = ( readonly environmentConfigs: Readonly>; }, outputRoot: string, + meta: AgentBundleMeta, ): void => { if ( inspection.bundlerConfigs.length !== entries.length || @@ -371,12 +380,10 @@ const assertExecutableConfig = ( // The generated environment must retain the virtual-module source: a // resolved config without the plugin instance would resolve the // guaranteed-nonexistent generated paths against the real filesystem. - const registryModules = virtualRegistryModules(outputRoot, entry); - if (entry.virtualSource !== undefined || registryModules.length > 0) { - const constructor = virtualModulesPluginConstructor(); - if (config.plugins?.some((plugin) => plugin instanceof constructor) !== true) { - throw new Error('Rslib resolved a generated executable environment without its virtual modules.'); - } + const registryModules = virtualRegistryModules(outputRoot, entry, meta); + const constructor = virtualModulesPluginConstructor(); + if (config.plugins?.some((plugin) => plugin instanceof constructor) !== true) { + throw new Error('Rslib resolved a generated executable environment without its virtual modules.'); } if (entry.virtualSource !== undefined && !entryImportsOf(config.entry, entry.name).includes(generatedEntryModulePath(outputRoot, entry))) { @@ -418,6 +425,8 @@ const assertExecutableConfig = ( export const composeEntryLibConfig = ( entry: RslibEntry, options: { + /** The project identity served to plugin source as `agent-bundle/meta`. */ + readonly meta: AgentBundleMeta; /** Receives reserved specifiers that a function-form external resolved at build time. */ readonly onReservedExternal?: (specifier: string) => void; readonly outputRoot: string; @@ -426,7 +435,7 @@ export const composeEntryLibConfig = ( ): LibConfig => { const libId = entryLibId(entry); const virtualSource = entry.virtualSource; - const virtualModules = virtualRegistryModules(options.outputRoot, entry); + const virtualModules = virtualRegistryModules(options.outputRoot, entry, options.meta); // Every generated module this entry serves virtually during its build: // the wrapper entry (when present) plus the registry modules. const generatedModules = [ @@ -586,6 +595,8 @@ export const buildWithRslib = async (options: { readonly ignoredSourcePaths?: readonly string[]; /** 'error' lets declaration-generation failures reach the consumer's terminal. */ readonly logLevel?: 'error' | 'silent'; + /** The project identity served to plugin source as `agent-bundle/meta`. */ + readonly meta: AgentBundleMeta; readonly outputRoot: string; /** The consumer escape hatch, merged last-but-bounded into every synthesized entry. */ readonly tools?: AgentBundleToolsConfig; @@ -601,6 +612,7 @@ export const buildWithRslib = async (options: { config: { logLevel: options.logLevel ?? 'silent', lib: options.entries.map((entry) => composeEntryLibConfig(entry, { + meta: options.meta, onReservedExternal: (specifier) => reservedExternalViolations.push(specifier), outputRoot: options.outputRoot, ...(options.tools === undefined ? {} : { tools: options.tools }), @@ -609,7 +621,7 @@ export const buildWithRslib = async (options: { }); const inspection = await rslib.inspectConfig(); - assertExecutableConfig(options.entries, inspection.origin, options.outputRoot); + assertExecutableConfig(options.entries, inspection.origin, options.outputRoot, options.meta); let result: Awaited> | undefined; try { try { diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 854ffee6c..e4dd6656f 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -12,7 +12,7 @@ import { parseRuntimeVersion, satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; -import { snapshotPackageIdentity } from '../core/project-context.ts'; +import { developmentFallbackVersion, snapshotPackageIdentity } from '../core/project-context.ts'; import { isRecord } from '../core/strict-json.ts'; import { canonicalHookEvents, @@ -964,6 +964,22 @@ const normalizeRules = ( }; }); +/** + * The one plugin version every surface agrees on (issue #94 stage 3): an + * authored `plugin.version` still wins so a legacy declaration never changes + * meaning mid-migration (a disagreement with package.json is the AB4008 + * warning), an omitted one derives the release version from package.json, + * and a project with neither carries the development fallback that + * `agent-bundle build` refuses to package (AB4013). + */ +const resolvePluginVersion = ( + authored: unknown, + packageVersion: string | undefined, +): string => + (typeof authored === 'string' && authored.trim().length > 0 ? authored : undefined) + ?? packageVersion + ?? developmentFallbackVersion; + /** Selects the generated-executable floor; invalid raises fall back to the default the validator rejected. */ const normalizeRuntime = (loaded: LoadedConfig): NormalizedRuntime => { const node = loaded.config.runtime?.node; @@ -1021,6 +1037,7 @@ export const normalizeProject = async ( // is authoritative for release identity (issue #94), while plugin.version // remains the host-facing declared version during the migration. const packageIdentity = snapshotPackageIdentity(loaded.context.projectRoot); + const version = resolvePluginVersion(loaded.config.plugin.version, packageIdentity.packageVersion); const nativeHooks = await normalizeNativeHooks(loaded, targetNames, registry); const payloads = normalizePayloads(loaded, discovered, targetNames); const mcpServers = normalizeMcpServers(loaded, discovered, targetNames, payloads); @@ -1055,7 +1072,7 @@ export const normalizeProject = async ( ...(packageIdentity.packageName === undefined ? {} : { packageName: packageIdentity.packageName }), ...(packageIdentity.packageVersion === undefined ? {} : { packageVersion: packageIdentity.packageVersion }), provenance: configProvenance, - version: loaded.config.plugin.version, + version, }, mcpApps: normalizeMcpApps(loaded, discovered, mcpServers), mcpServers, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 5d3e80e5a..6fcc878cc 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -6,6 +6,7 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; import { unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; import { + developmentFallbackVersion, snapshotPackageIdentity, type PackageIdentityIssueKind, } from '../core/project-context.ts'; @@ -1595,6 +1596,13 @@ export interface ValidateSourceOptions { * false to skip the walk. */ readonly payloadFreshness?: boolean; + /** + * Judge the project as a release build (`agent-bundle build`) rather than + * development preparation. Development flows keep the labeled version + * fallback; a release refuses to package a project that has no release + * identity at all (AB4013). + */ + readonly release?: boolean; } /** @@ -1621,7 +1629,7 @@ const packageIdentityIssueCode = (kind: PackageIdentityIssueKind): string => { } }; -const validatePackageIdentity = (loaded: LoadedConfig): Diagnostic[] => { +const validatePackageIdentity = (loaded: LoadedConfig, release: boolean): Diagnostic[] => { const diagnostics: Diagnostic[] = []; const identity = snapshotPackageIdentity(loaded.context.projectRoot); const packageJsonPath = join(loaded.context.projectRoot, 'package.json'); @@ -1651,6 +1659,20 @@ const validatePackageIdentity = (loaded: LoadedConfig): Diagnostic[] => { 'Align plugin.version with the package.json version, or update package.json.', )); } + const declared = typeof pluginVersion === 'string' && pluginVersion.trim().length > 0; + if (release && !declared && identity.packageVersion === undefined) { + // A development-only fallback may exist, but it can never produce a + // release artifact (issue #94): with no authored plugin.version and no + // valid package.json version, this project has no release identity to + // stamp into manifests, host projections, or compiled surfaces. + diagnostics.push({ + code: 'AB4013', + message: 'This project has no release version: plugin.version is omitted and package.json declares no valid semantic version, so the build would package the development fallback.', + recovery: `Add a valid semantic "version" to package.json, or declare plugin.version in the config. Development commands keep the labeled ${developmentFallbackVersion} fallback.`, + severity: 'error', + sourcePath: packageJsonPath, + }); + } return diagnostics; }; @@ -1754,11 +1776,14 @@ export const validateSource = ( sourceDiagnostic('AB4000', 'Plugin metadata must define a nonempty name.', loaded.configPath), ); } - if (typeof pluginVersion !== 'string' || pluginVersion.trim().length === 0) { + // `plugin.version` is optional since #94 stage 3: omitting it derives the + // version from package.json. Declaring it as anything but a nonempty + // string is still a mistake with no defensible reading. + if (pluginVersion !== undefined && (typeof pluginVersion !== 'string' || pluginVersion.trim().length === 0)) { diagnostics.push( sourceDiagnostic( 'AB4001', - 'Plugin metadata must define a nonempty version.', + 'Plugin metadata version must be a nonempty string when it is declared; omit it to derive the version from package.json.', loaded.configPath, ), ); @@ -1790,7 +1815,7 @@ export const validateSource = ( const payloads = declaredPayloads(loaded, registry); diagnostics.push(...validateAssets(loaded)); - diagnostics.push(...validatePackageIdentity(loaded)); + diagnostics.push(...validatePackageIdentity(loaded, options?.release === true)); diagnostics.push(...validateBin(loaded)); diagnostics.push(...validateHooks(loaded, registry, payloads)); diagnostics.push(...validateLib(loaded)); diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 616f1bcac..dbef4c044 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -138,6 +138,15 @@ export const snapshotPackageIdentity = (root: string): PackageIdentitySnapshot = }); }; +/** + * The version a project carries while it has no release identity at all: + * neither an authored `plugin.version` nor a valid `package.json` version. + * It is a development-only value — `agent-bundle build` refuses to package + * a project resting on it (AB4013), so it can never reach a release + * artifact. + */ +export const developmentFallbackVersion = '0.0.0-dev'; + /** * The human display label for the release-version axis. Without a package * version there is no release identity, so the label is a clearly marked @@ -147,7 +156,7 @@ export const projectVersionLabel = ( context: Pick, ): string => context.packageVersion ?? - `0.0.0-dev.${context.revision.slice(0, 12)} (development fallback — no package.json version)`; + `${developmentFallbackVersion}.${context.revision.slice(0, 12)} (development fallback — no package.json version)`; export interface CreateProjectContextOptions { readonly configPath: string; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 54e695315..b891b80fc 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -14,7 +14,13 @@ export interface AgentBundlePluginConfig { /** Project-relative path to a logo image copied into host artifacts that support it. */ logo?: string; name: string; - version: string; + /** + * The host-facing declared version. Omit it to derive the version from the + * project's `package.json` (issue #94 stage 3): package.json is + * authoritative for release identity, and a declared value that disagrees + * with it reports the AB4008 warning. + */ + version?: string; [key: string]: unknown; } diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index d67577305..08147e951 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -774,9 +774,12 @@ export class ProjectService { let sourceDiagnostics: readonly Diagnostic[]; try { // The AB4750 freshness nudge only surfaces through `validate`; other - // commands skip its full-project mtime walk. + // commands skip its full-project mtime walk. `build` is the one command + // that produces a release artifact, so it alone refuses a project with + // no release identity (AB4013). sourceDiagnostics = freezeDiagnostics(validateSource(preparedLoaded, discovered, registry, { payloadFreshness: command === 'validate', + release: command === 'build', })); } catch { return failedPreparation( diff --git a/packages/agent-bundle/src/meta.ts b/packages/agent-bundle/src/meta.ts new file mode 100644 index 000000000..8b8075e04 --- /dev/null +++ b/packages/agent-bundle/src/meta.ts @@ -0,0 +1,49 @@ +/** + * The build-time project identity constant (issue #237). This package + * subpath is replaced by the compiler in every compiled plugin surface — + * Node script, CLI, MCP entry, hook, and package bundles, plus browser MCP + * App bundles — with the exact identity the artifact manifests, `inspect`, + * and dev status report. Plugin source imports it instead of maintaining a + * hand-written `src/lib/version.ts`. + */ +export interface AgentBundleMeta { + /** The host-native plugin slug from `plugin.name`; never the npm package name. */ + readonly name: string; + /** The validated npm package name, absent for unpackaged development projects. */ + readonly packageName: string | undefined; + /** The validated semantic release version, absent for unpackaged development projects. */ + readonly packageVersion: string | undefined; + /** + * The resolved plugin version: the authored `plugin.version` when declared, + * otherwise the package.json version. A release build refuses to package a + * project that has neither (AB4013), so a compiled artifact never carries + * the development fallback. + */ + readonly version: string; +} + +/** + * Every export must throw through a hoisted function declaration: the rslib + * bundle emits `export default ;` above the const initializers, so a + * top-level `throw` or a `const`-backed default export surfaces a TDZ + * ReferenceError instead of this message (the same contract as + * `agent-bundle/mcp-apps`). + */ +function throwUnavailableEntrypoint(): never { + throw new Error( + 'agent-bundle/meta is available only inside a surface Agent Bundle compiles; ' + + 'a plugin module reached it outside the compiler.', + ); +} + +export const meta: AgentBundleMeta = throwUnavailableEntrypoint(); + +export const name: AgentBundleMeta['name'] = throwUnavailableEntrypoint(); + +export const packageName: AgentBundleMeta['packageName'] = throwUnavailableEntrypoint(); + +export const packageVersion: AgentBundleMeta['packageVersion'] = throwUnavailableEntrypoint(); + +export const version: AgentBundleMeta['version'] = throwUnavailableEntrypoint(); + +export default throwUnavailableEntrypoint(); diff --git a/packages/agent-bundle/src/rstest/browser.ts b/packages/agent-bundle/src/rstest/browser.ts index a4d5d2785..6ccd6183d 100644 --- a/packages/agent-bundle/src/rstest/browser.ts +++ b/packages/agent-bundle/src/rstest/browser.ts @@ -118,6 +118,12 @@ export const agentBundleBrowserRstest = async ( await mkdir(outputRoot, { recursive: true }); const compiled = await compileMcpApps(normalized, { cwd: root, + meta: { + name: manifest.plugin.name, + packageName: manifest.plugin.packageName, + packageVersion: manifest.plugin.packageVersion, + version: manifest.plugin.version, + }, outDir: outputRoot, targets, }); diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index bbf2af109..f737a2417 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -102,6 +102,10 @@ export interface TestableRouteDescriptor { /** The project identity a generated MCP server advertises on the wire. */ export interface TestManifestPluginIdentity { readonly name: string; + /** The validated npm package name, absent for unpackaged development projects. */ + readonly packageName?: string; + /** The validated semantic release version, absent for unpackaged development projects. */ + readonly packageVersion?: string; readonly version: string; } @@ -301,7 +305,14 @@ export const compileTestManifest = async ( graph: prepared.routeGraph ?? emptyCompiledRouteGraph, ...(prepared.model === undefined ? {} - : { plugin: { name: prepared.model.metadata.name, version: prepared.model.metadata.version } }), + : { + plugin: { + name: prepared.model.metadata.name, + ...(prepared.model.metadata.packageName === undefined ? {} : { packageName: prepared.model.metadata.packageName }), + ...(prepared.model.metadata.packageVersion === undefined ? {} : { packageVersion: prepared.model.metadata.packageVersion }), + version: prepared.model.metadata.version, + }, + }), projectRoot: prepared.root, ...(prepared.model?.state === undefined ? {} : { state: prepared.model.state }), targets: prepared.model?.targets.map((target) => target.name) ?? [], diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index fa39ece8d..e4646b2de 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -9,6 +9,7 @@ import { createJiti } from 'jiti'; import { build as buildArtifact, type BuildOptions as LowLevelBuildOptions, type BuildResult } from '../src/build/build.ts'; import { buildWithRslib, type RslibEntry } from '../src/build/rslib.ts'; +import type { AgentBundleMeta } from '../src/meta.ts'; import { publishArtifact } from '../src/build/emit.ts'; import type { TargetHookContract } from '../src/adapters/hook-contract.ts'; import { parseArtifactManifest, serializeArtifactManifest } from '../src/build/manifest.ts'; @@ -18,6 +19,13 @@ import { createProjectContext } from '../src/core/project-context.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; import { sha256Hex } from '../src/core/digest.ts'; +const testMeta: AgentBundleMeta = Object.freeze({ + name: 'reserved-probe-plugin', + packageName: 'reserved-probe-package', + packageVersion: '2.3.4', + version: '2.3.4', +}); + interface TestProject { readonly assetPath: string; readonly localModulePath: string; @@ -1127,6 +1135,7 @@ it('inlines reserved specifiers through exact-match aliases and virtual generate const evidence = await buildWithRslib({ cwd: root, entries: [entry], + meta: testMeta, outputRoot: join(root, 'dist'), // A hatch external naming a non-reserved module is legal: the // invariant rejects reserved specifiers, not the externals mechanism. @@ -1164,6 +1173,7 @@ it('keeps sibling staged outputs alive under a tools hatch that asks to clean th await buildWithRslib({ cwd: root, entries: [entry], + meta: testMeta, outputRoot: join(root, 'dist'), tools: { rsbuild: { output: { cleanDistPath: true } } }, }); @@ -1186,6 +1196,7 @@ it('overrides a tools hatch that strips plugins and repoints the entry away from await buildWithRslib({ cwd: root, entries: [entry], + meta: testMeta, outputRoot: join(root, 'dist'), tools: { rspack: (config) => { @@ -1208,6 +1219,7 @@ it('rejects a tools hatch that externalizes a reserved specifier statically', as await expect(buildWithRslib({ cwd: root, entries: [entry], + meta: testMeta, outputRoot: join(root, 'dist'), tools: { rspack: { externals: { 'agent-bundle/mcp-entry': 'module agent-bundle/mcp-entry' } } }, })).rejects.toThrow(/must not externalize the reserved specifier "agent-bundle\/mcp-entry"/u); @@ -1222,6 +1234,7 @@ it('rejects a tools hatch that externalizes a reserved specifier through functio await expect(buildWithRslib({ cwd: root, entries: [entry], + meta: testMeta, outputRoot: join(root, 'dist'), tools: { // Function externals cannot be inspected statically; the build-time @@ -1253,6 +1266,7 @@ it('rejects a tools hatch alias that shadows a reserved specifier', async () => await expect(buildWithRslib({ cwd: root, entries: [entry], + meta: testMeta, outputRoot: join(root, 'dist'), // A plain (non-$) consumer alias for a reserved specifier would win // over the framework's exact-match alias by insertion order. diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index ed8146ebb..bc7fddc82 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -270,8 +270,8 @@ it('derives the Audiobook Curator release identity from package.json as the one const inspection = await inspect({ root }); expect(inspection.state).toBe('ready'); if (inspection.state !== 'ready') throw new Error('unreachable'); - // package.json declares 1.0.0 once; both derived axes and the config's - // still-required plugin.version agree, so no AB4008 mismatch surfaces. + // package.json declares 1.0.0 once and the config declares no + // plugin.version, so the model version is inferred and cannot mismatch. expect(inspection.projectContext.packageName).toBe('@agent-bundle-example/audiobook-curator'); expect(inspection.projectContext.packageVersion).toBe('1.0.0'); expect(inspection.model.metadata).toMatchObject({ diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 60f1ce334..4b3eefc79 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -13,7 +13,9 @@ import { nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters import { build } from './support/build.ts'; import { runNodeScript } from './support/run-node-script.ts'; import { writeHookIndex } from '../src/build/emit.ts'; +import { generatedMetaModulePath, metaModuleSpecifier } from '../src/build/meta.ts'; import { buildWithRslib } from '../src/build/rslib.ts'; +import type { AgentBundleMeta } from '../src/meta.ts'; import { HookService, isHookSimulationCancellation } from '../src/services/hook-service.ts'; import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; import { normalizeProject } from '../src/config/normalize.ts'; @@ -21,6 +23,23 @@ import type { LoadedConfig } from '../src/config/load.ts'; import type { NormalizationTargetRegistry, NormalizedPlugin } from '../src/core/types.ts'; import { validateModel, validateSource } from '../src/config/validate.ts'; +const probeMeta: AgentBundleMeta = Object.freeze({ + name: 'hook-probe', + packageName: 'hook-probe', + packageVersion: '1.0.0', + version: '1.0.0', +}); + +/** + * Every generated executable resolves the framework identity module, so a + * stubbed Rslib resolution has to carry what a real one would: the + * virtual-module plugin instance and the exact-match alias. + */ +const resolvedVirtualModules = (outputRoot: string) => ({ + plugins: [new rspack.experiments.VirtualModulesPlugin({})], + resolve: { alias: { [`${metaModuleSpecifier}$`]: generatedMetaModulePath(outputRoot) } }, +}); + const registry: NormalizationTargetRegistry = { configExtensions: () => [], defaultTargetNames: () => ['codex', 'claude'], @@ -155,6 +174,7 @@ it('does not share a persistent Rslib cache between generated executables', asyn output: { asyncChunks: false, path: outputRoot }, performance: { buildCache: false }, target: 'node', + ...resolvedVirtualModules(outputRoot), }], environmentConfigs: { 'agent-bundle-cache-probe': { output: { cleanDistPath: false } } }, }, @@ -172,6 +192,7 @@ it('does not share a persistent Rslib cache between generated executables', asyn source: '/tmp/hook.ts', sourceInputs: ['/tmp/hook.ts'], }], + meta: probeMeta, outputRoot, }, { createRslib: async (options) => { @@ -220,8 +241,8 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual entry: { 'close-probe': [virtualEntryPath] }, name: 'agent-bundle-close-probe', output: { asyncChunks: false, path: outputRoot }, - plugins: [new rspack.experiments.VirtualModulesPlugin({})], target: 'node', + ...resolvedVirtualModules(outputRoot), }], environmentConfigs: { 'agent-bundle-close-probe': { output: { cleanDistPath: false } } }, }, @@ -240,6 +261,7 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual sourceInputs: ['/tmp/hook.ts'], virtualSource: 'export default undefined;', }], + meta: probeMeta, outputRoot, }, { createRslib: async (options) => { @@ -310,6 +332,7 @@ it('fails closed when the resolved environment lost its virtual modules or wrapp sourceInputs: ['/tmp/hook.ts'], virtualSource: 'export default undefined;', }], + meta: probeMeta, outputRoot, }, { createRslib: async () => rslibFor(bundlerConfig) as never }); @@ -322,8 +345,14 @@ it('fails closed when the resolved environment lost its virtual modules or wrapp // without the generated wrapper. await expect(buildLostProbe({ entry: { 'lost-probe': ['/tmp/hook.ts'] }, - plugins: [new rspack.experiments.VirtualModulesPlugin({})], + ...resolvedVirtualModules(outputRoot), })).rejects.toThrow(/without its generated wrapper entry/u); + // A resolved config that lost the framework identity alias would resolve + // agent-bundle/meta to the published throwing stub instead. + await expect(buildLostProbe({ + entry: { 'lost-probe': [virtualEntryPath] }, + plugins: [new rspack.experiments.VirtualModulesPlugin({})], + })).rejects.toThrow(/without its reserved module aliases/u); } finally { await rm(outputRoot, { force: true, recursive: true }); } @@ -347,6 +376,7 @@ it('fails closed when an emitted bundle retains a residual reserved import', asy name: 'agent-bundle-residual-probe', output: { asyncChunks: false, path: outputRoot }, target: 'node', + ...resolvedVirtualModules(outputRoot), }], environmentConfigs: { 'agent-bundle-residual-probe': { output: { cleanDistPath: false } } }, }, @@ -367,6 +397,7 @@ it('fails closed when an emitted bundle retains a residual reserved import', asy source: '/tmp/hook.ts', sourceInputs: ['/tmp/hook.ts'], }], + meta: probeMeta, outputRoot, }, { createRslib: async () => rslib as never })).rejects.toThrow(/not self-contained/u); } finally { @@ -385,6 +416,7 @@ it('closes the Rslib build result when provenance stats are unavailable', async name: 'agent-bundle-close-error-probe', output: { asyncChunks: false, path: outputRoot }, target: 'node', + ...resolvedVirtualModules(outputRoot), }], environmentConfigs: { 'agent-bundle-close-error-probe': { output: { cleanDistPath: false } } }, }, @@ -400,6 +432,7 @@ it('closes the Rslib build result when provenance stats are unavailable', async source: '/tmp/hook.ts', sourceInputs: ['/tmp/hook.ts'], }], + meta: probeMeta, outputRoot, }, { createRslib: async () => rslib as never })).rejects.toThrow(/stats/i); diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index 01faafae9..e9eb8fd4a 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -802,6 +802,85 @@ it('builds one deterministic self-contained MCP App view and injects it through } }, 30_000); +it('injects one release identity into both the Node bundle and the browser MCP App bundle', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-meta-')); + try { + await mkdir(join(root, 'src'), { recursive: true }); + await mkdir(join(root, 'views'), { recursive: true }); + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + // package.json is the only version source: the config declares no + // plugin.version, so every compiled surface must agree on 4.5.6. + await writeFile( + join(root, 'package.json'), + `${JSON.stringify({ name: '@scope/meta-fixture', version: '4.5.6' })}\n`, + ); + await symlink(workbenchNodeModules, join(root, 'node_modules'), 'dir'); + await writeFile(join(root, 'src', 'server.ts'), [ + "import meta from 'agent-bundle/meta';", + "import { name, packageName, version } from 'agent-bundle/meta';", + 'export const serverIdentity = [name, version, packageName, meta.packageVersion];', + '', + ].join('\n')); + await writeFile(join(root, 'views', 'dashboard.ts'), [ + "import { createElement } from 'react';", + "import { createRoot } from 'react-dom/client';", + "import { name, packageVersion, version } from 'agent-bundle/meta';", + "createRoot(document.querySelector('#view')!).render(", + " createElement('span', undefined, `${name} ${version} ${packageVersion}`),", + ');', + '', + ].join('\n')); + await writeFile(join(root, 'views', 'shell.html'), '
\n'); + + const loaded = loadedProject(root, { + mcp: { + servers: { + fixture: { + apps: { + dashboard: { + entry: './views/dashboard.ts', + resourceUri: 'ui://agent-bundle/dashboard.html', + template: './views/shell.html', + }, + }, + entry: './src/server.ts', + }, + }, + }, + plugin: { name: 'meta-fixture' }, + targets: ['portable'], + }); + const releaseDiagnostics = validateSource(loaded, { skills: [] }, registry, { release: true }); + expect(releaseDiagnostics.filter((diagnostic) => diagnostic.severity !== 'info')).toEqual([]); + + const model = await normalizeProject(loaded, { skills: [] }, registry); + expect(model.metadata).toMatchObject({ + name: 'meta-fixture', + packageName: '@scope/meta-fixture', + packageVersion: '4.5.6', + version: '4.5.6', + }); + + const outputRoot = join(root, 'dist'); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + + const serverBundle = await readFile(join(outputRoot, 'portable', 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); + for (const injected of ['meta-fixture', '4.5.6', '@scope/meta-fixture']) { + expect(serverBundle).toContain(injected); + } + expect(serverBundle).not.toContain('agent-bundle/meta'); + + const html = await readFile(join(outputRoot, 'portable', 'mcp-apps', 'dashboard.html'), 'utf8'); + for (const injected of ['meta-fixture', '4.5.6']) { + expect(html).toContain(injected); + } + expect(html).not.toContain('agent-bundle/meta'); + expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 30_000); + it('compiles one shared MCP App once and serves it from every identically declaring server', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-shared-')); try { @@ -951,6 +1030,35 @@ it('rejects the built MCP Apps entrypoint with the intended error, not a TDZ Ref expect(stderr).not.toContain('ReferenceError'); }); +it('rejects the built identity module with the intended error, not a TDZ ReferenceError', async () => { + // `agent-bundle/meta` carries the same dist contract as `agent-bundle/mcp-apps`: + // a compiled surface resolves it to the generated identity, and anything + // else must say so rather than report a fabricated identity. + await expect(import('../src/meta.ts')).rejects.toThrow( + 'agent-bundle/meta is available only inside a surface Agent Bundle compiles', + ); + + const distEntry = join(agentBundlePackageRoot, 'dist', 'meta.js'); + const { code, stderr } = await new Promise<{ code: number | null; stderr: string }>((resolve) => { + const child = spawn(process.execPath, [ + '--input-type=module', + '--eval', + `await import(${JSON.stringify(pathToFileURL(distEntry).href)});`, + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + let output = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + output += chunk; + }); + child.on('close', (exitCode) => { + resolve({ code: exitCode, stderr: output }); + }); + }); + expect(code).toBe(1); + expect(stderr).toContain('agent-bundle/meta is available only inside a surface Agent Bundle compiles'); + expect(stderr).not.toContain('ReferenceError'); +}); + it('uses the selected streamable HTTP manifest with propagated cancellation and cleans data before rejecting tampering', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-remote-')); try { diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index ba957d2b8..3bf4b994a 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -485,9 +485,10 @@ it('validates raw optional portable Skill fields before sanitizing the Skill IR' it('diagnoses a missing plugin object instead of throwing', () => { const loaded = loadedProject({} as AgentBundleConfig); + // Only the name is required: an omitted version is inferred from + // package.json, so AB4001 fires on a declared-but-invalid value alone. expect(validateSource(loaded, { skills: [] }, registry)).toMatchObject([ { code: 'AB4000', sourcePath: loaded.configPath }, - { code: 'AB4001', sourcePath: loaded.configPath }, ]); }); diff --git a/packages/agent-bundle/tests/package-identity.test.ts b/packages/agent-bundle/tests/package-identity.test.ts index 24b92a7fd..7ca18451d 100644 --- a/packages/agent-bundle/tests/package-identity.test.ts +++ b/packages/agent-bundle/tests/package-identity.test.ts @@ -9,6 +9,7 @@ import { normalizeProject, validateSource, type NormalizationTargetRegistry } fr import type { LoadedConfig } from '../src/config/load.ts'; import { createProjectContext, + developmentFallbackVersion, isValidPackageName, isValidPackageVersion, projectVersionLabel, @@ -24,11 +25,19 @@ const registry: NormalizationTargetRegistry = { supports: () => false, }; -const config = (version = '1.0.0'): AgentBundleConfig => ({ - plugin: { name: 'identity-fixture', version }, +/** `undeclared` omits `plugin.version` the way an inference-era config does. */ +const undeclared = Symbol('undeclared plugin.version'); + +const config = (version: string | typeof undeclared = '1.0.0'): AgentBundleConfig => ({ + plugin: version === undeclared + ? { name: 'identity-fixture' } + : { name: 'identity-fixture', version }, }); -const loadedProject = (root: string, pluginVersion = '1.0.0'): LoadedConfig => ({ +const loadedProject = ( + root: string, + pluginVersion: string | typeof undeclared = '1.0.0', +): LoadedConfig => ({ config: config(pluginVersion), configPath: join(root, 'agent-bundle.config.ts'), context: { @@ -207,6 +216,86 @@ it('ignores a package.json symlinked outside the project root', async () => { await rm(outside, { force: true, recursive: true }); }); +it('infers the plugin version from package.json when the config omits it', async () => { + await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.3.4' }), async (root) => { + const loaded = loadedProject(root, undeclared); + expect(validateSource(loaded, { skills: [] }, registry, { release: true })).toEqual([]); + + const model = await normalizeProject(loaded, { skills: [] }, registry); + expect(model.metadata).toMatchObject({ + packageName: '@scope/pkg', + packageVersion: '2.3.4', + version: '2.3.4', + }); + }); +}); + +it('rejects a declared plugin.version that is not a nonempty string', async () => { + await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.3.4' }), async (root) => { + for (const declared of ['', ' ', 3 as unknown as string]) { + const loaded = loadedProject(root, declared); + expect(validateSource(loaded, { skills: [] }, registry)).toMatchObject([ + { code: 'AB4001', severity: 'error', sourcePath: join(root, 'agent-bundle.config.ts') }, + ]); + } + }); +}); + +it('keeps the development fallback for an unpackaged project with no declared version', async () => { + await withProject(undefined, async (root) => { + const loaded = loadedProject(root, undeclared); + // Development preparation reports nothing: this is a normal dev state. + expect(validateSource(loaded, { skills: [] }, registry)).toEqual([]); + + const model = await normalizeProject(loaded, { skills: [] }, registry); + expect(model.metadata.version).toBe(developmentFallbackVersion); + }); +}); + +it('fails a release build closed when no source declares a release version', async () => { + const releaseDiagnostics = (root: string) => + validateSource(loadedProject(root, undeclared), { skills: [] }, registry, { release: true }); + + await withProject(undefined, async (root) => { + expect(releaseDiagnostics(root)).toMatchObject([ + { code: 'AB4013', severity: 'error', sourcePath: join(root, 'package.json') }, + ]); + }); + // An unusable or invalid package version fails closed the same way; the + // AB40xx identity warning explains which axis is broken. + await withProject(JSON.stringify({ name: '@scope/pkg', version: 'one.two' }), async (root) => { + expect(releaseDiagnostics(root)).toMatchObject([ + { code: 'AB4010', severity: 'warning' }, + { code: 'AB4013', severity: 'error' }, + ]); + }); + // A declared plugin.version is a release version on its own. + await withProject(undefined, async (root) => { + expect(validateSource(loadedProject(root, '1.0.0'), { skills: [] }, registry, { release: true })) + .toEqual([]); + }); +}); + +it('separates the release refusal from the development fallback across commands', async () => { + await withProject(undefined, async (root) => { + await writeFile( + join(root, 'agent-bundle.config.ts'), + "export default { plugin: { name: 'identity-fixture' }, targets: ['portable'] };\n", + ); + const service = new ProjectService({ root, targets: ['portable'] }); + + const development = await service.prepare('dev'); + expect(development.source.state).toBe('ready'); + expect(development.model?.metadata.version).toBe(developmentFallbackVersion); + expect(projectVersionLabel(development.projectContext!)).toContain('development fallback'); + + const release = await service.prepare('build'); + expect(release.source.state).toBe('invalid'); + expect(release.source.diagnostics).toMatchObject([{ code: 'AB4013', severity: 'error' }]); + expect(release.model).toBeUndefined(); + }); +}); + it('exposes the derived axes on the development source status', async () => { await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.3.4' }), async (root) => { await writeFile(