diff --git a/.changeset/build-reproducible-artifacts.md b/.changeset/build-reproducible-artifacts.md new file mode 100644 index 000000000..6e9dfb675 --- /dev/null +++ b/.changeset/build-reproducible-artifacts.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Make emitted artifacts byte-reproducible across builds: `agent-bundle build` now emits identical bytes — the same `agent-bundle.manifest.json`, the same per-file `sha256` — from two builds of one unchanged source tree, whatever `--output` names and however the per-build staging directory (`..stage-XXXXXX`) is named. The generated wrapper, route registry, and `agent-bundle/meta` modules every compiled surface imports are now served under the project-rooted `.agent-bundle-virtual/` namespace instead of under the staging root, so the module identifiers Rspack writes into MCP entries (`// NAMESPACE OBJECT: ./.agent-bundle-virtual/…`) no longer carry the staging token that made consecutive builds differ. This keeps install receipts, preview packages, and `doctor`'s bytes-at-rest comparison (`AB7326`) stable for one source revision. `agent-bundle inspect --bundler` shows the same project-rooted paths in each entry's virtual-module aliases and generated entry. Because those paths are predictable, `.agent-bundle-virtual/` under the project root is reserved: the build refuses to compile while anything occupies it. (#518) diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 9d096894e..9d9fa42be 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -543,6 +543,20 @@ invariant layer that no hatch value can override (`src/build/compose-layers.ts`; see the `tools` section of the configuration reference). `agent-bundle inspect --bundler` prints the result. +Builds are byte-reproducible: two builds of one unchanged source tree emit +identical artifacts (same manifest, same digests, same bytes) regardless of +the `--output` name or the per-build `..stage-XXXXXX` staging +directory. The generated wrapper, registry, and identity modules that every +compiled surface imports are served from memory under the reserved +`/.agent-bundle-virtual/` namespace (`src/build/meta.ts`), +which never exists on disk: the virtual paths are predictable, so the build +refuses to compile while anything occupies that directory +(`assertGeneratedModulesRootAbsent`). That namespace hangs off the project root — the +bundler `context` — on purpose: Rspack writes module identifiers relative to +`context` into emitted bundles (the `// NAMESPACE OBJECT: ./…` comments of +concatenated modules), so a namespace under the staging root would stamp the +per-build token into the artifact. + `agent-bundle build` makes each target directory independently distributable. Every target includes `INSTALL.md` generated with its real plugin and marketplace names. Claude and Codex bundles include local marketplace manifests diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 8314b5808..51397ac9d 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -973,6 +973,7 @@ export const inspect = async (options: InspectOptions): Promise = try { bundler = await composeBundlerInspection({ model, + projectRoot: prepared.root, targets: plans.map((plan) => { const noticeDelivery = prepared.registry.noticeDelivery(plan.target); return { diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 0840b2538..876c7c5f8 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -39,7 +39,9 @@ import type { AgentBundleMeta } from '../meta.ts'; * and the synthesized declaration tsconfig (a temporary file the package * build generates under `node_modules`) appears as * ``. Nothing else is redacted; this is a local - * debugging surface. + * debugging surface. The generated-module namespace + * (`/.agent-bundle-virtual/...`) appears exactly as the build + * composes it: it derives from the project root, not from the output root. */ export interface BundlerInspectionEntry { @@ -103,12 +105,14 @@ const rslibInspectionEntry = (options: { readonly name: string; readonly outputPath: string; readonly outputRoot: string; + readonly projectRoot: string; readonly source: string; readonly target?: string; readonly tools?: AgentBundleToolsConfig; }): BundlerInspectionEntry => Object.freeze({ bundler: 'rslib', config: renderConfigValue(composeEntryLibConfig(options.entry, { + cwd: options.projectRoot, meta: options.meta, outputRoot: options.outputRoot, ...(options.tools === undefined ? {} : { tools: options.tools }), @@ -123,6 +127,7 @@ const rslibInspectionEntry = (options: { const scriptEntries = async ( model: NormalizedPlugin, + projectRoot: string, target: string, tools: AgentBundleToolsConfig | undefined, ): Promise => { @@ -152,6 +157,7 @@ const scriptEntries = async ( name: script.name, outputPath: `${target}/scripts/${script.name}.mjs`, outputRoot, + projectRoot, source: script.source, target, ...(tools === undefined ? {} : { tools }), @@ -162,6 +168,7 @@ const scriptEntries = async ( /** The artifact-hosted routed CLI bins of one target (#387), composed by the build's own planner. */ const cliBinEntries = ( model: NormalizedPlugin, + projectRoot: string, target: string, tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { @@ -175,6 +182,7 @@ const cliBinEntries = ( name: entry.name.replace(/^bin-/u, ''), outputPath: `${target}/${entry.outputRelativePath}`, outputRoot, + projectRoot, source: entry.source, target, ...(tools === undefined ? {} : { tools }), @@ -183,6 +191,7 @@ const cliBinEntries = ( const mcpEntryEntries = async ( model: NormalizedPlugin, + projectRoot: string, target: string, tools: AgentBundleToolsConfig | undefined, noticeDelivery: NoticeDeliveryAdvertisement | undefined, @@ -240,6 +249,7 @@ const mcpEntryEntries = async ( name: serverName, outputPath: `${target}/mcp/${entry.name}.mjs`, outputRoot, + projectRoot, source: entry.source, target, ...(tools === undefined ? {} : { tools }), @@ -269,6 +279,7 @@ const mcpEntryEntries = async ( name: `${serverName}:flight`, outputPath: `${target}/mcp/${workerFile}`, outputRoot, + projectRoot, source: entry.source, target, ...(tools === undefined ? {} : { tools }), @@ -281,6 +292,7 @@ const mcpEntryEntries = async ( const hookEntries = ( entries: readonly TargetHookEntry[], meta: AgentBundleMeta, + projectRoot: string, target: string, tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { @@ -298,6 +310,7 @@ const hookEntries = ( name: entry.hook.name, outputPath: `${target}/${entry.relativePath}`, outputRoot, + projectRoot, source: entry.hook.source, target, ...(tools === undefined ? {} : { tools }), @@ -306,6 +319,7 @@ const hookEntries = ( const mcpAppsEntry = ( model: NormalizedPlugin, + projectRoot: string, target: string, tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { @@ -323,6 +337,7 @@ const mcpAppsEntry = ( return [Object.freeze({ bundler: 'rsbuild' as const, config: renderConfigValue(composeMcpAppsRsbuildConfig(sources, { + cwd: projectRoot, meta: projectMeta(model.metadata), outDir: outputRoot, ...(tools === undefined ? {} : { tools }), @@ -336,6 +351,7 @@ const mcpAppsEntry = ( const packageBuildEntries = async ( model: NormalizedPlugin, + projectRoot: string, tools: AgentBundleToolsConfig | undefined, ): Promise => { const packageBuild = model.packageBuild; @@ -352,6 +368,7 @@ const packageBuildEntries = async ( name: bin ? entry.name.replace(/^bin-/u, '') : entry.name, outputPath: `${packageBuild.outputDir}/${entry.outputRelativePath}`, outputRoot: packageBuild.outputDir, + projectRoot, source: entry.source, ...(tools === undefined ? {} : { tools }), }); @@ -365,6 +382,8 @@ const entryOrder = (left: BundlerInspectionEntry, right: BundlerInspectionEntry) export const composeBundlerInspection = async (options: { readonly model: NormalizedPlugin; + /** The project root: the bundler `context` and the root of the generated-module namespace. */ + readonly projectRoot: string; readonly targets: readonly { /** True when the target hosts the routed CLI bin (its adapter publishes the `cli` capability). */ readonly cliBin?: boolean; @@ -378,14 +397,14 @@ export const composeBundlerInspection = async (options: { const meta = projectMeta(options.model.metadata); for (const target of options.targets) { entries.push( - ...(target.cliBin === true ? cliBinEntries(options.model, target.name, options.tools) : []), - ...(await scriptEntries(options.model, target.name, options.tools)), - ...(await mcpEntryEntries(options.model, target.name, options.tools, target.noticeDelivery)), - ...hookEntries(target.hookEntries, meta, target.name, options.tools), - ...mcpAppsEntry(options.model, target.name, options.tools), + ...(target.cliBin === true ? cliBinEntries(options.model, options.projectRoot, target.name, options.tools) : []), + ...(await scriptEntries(options.model, options.projectRoot, target.name, options.tools)), + ...(await mcpEntryEntries(options.model, options.projectRoot, target.name, options.tools, target.noticeDelivery)), + ...hookEntries(target.hookEntries, meta, options.projectRoot, target.name, options.tools), + ...mcpAppsEntry(options.model, options.projectRoot, target.name, options.tools), ); } - entries.push(...(await packageBuildEntries(options.model, options.tools))); + entries.push(...(await packageBuildEntries(options.model, options.projectRoot, options.tools))); return deepFreeze({ entries: entries.sort(entryOrder), }); diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index b325ff3e3..eb7c29712 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -9,9 +9,10 @@ import type { AgentBundleMeta } from '../meta.ts'; import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts'; import { listArtifactFiles, resolveArtifactDestination } from './emit.ts'; import { + assertGeneratedModulesRootAbsent, generatedMetaModulePath, generatedMetaModuleSource, - generatedModulesDirname, + generatedModulesRoot, metaModuleSpecifier, } from './meta.ts'; import { collectBundledOutputEvidence } from './provenance.ts'; @@ -180,13 +181,15 @@ export const planCompiledMcpApps = ( export const composeMcpAppsRsbuildConfig = ( sources: readonly Pick[], options: { + /** The project root: the bundler `context` and the root of the generated-module namespace. */ + readonly cwd: string; /** 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 metaModulePath = generatedMetaModulePath(options.cwd); const profile: RsbuildConfig = { environments: Object.fromEntries(sources.map((source) => [source.name, { ...(usesReactSyntax(source.source) ? { plugins: [pluginReact()] } : {}), @@ -258,6 +261,7 @@ export const compileMcpApps = async ( if (compiled.length === 0) { return compiled; } + await assertGeneratedModulesRootAbsent(options.cwd); const sources = compiled.map((app) => { const source = apps.find((candidate) => candidate.id === app.id); @@ -270,6 +274,7 @@ export const compileMcpApps = async ( const rsbuild = await createRsbuild({ cwd: options.cwd, config: composeMcpAppsRsbuildConfig(sources, { + cwd: options.cwd, meta: options.meta, outDir: options.outDir, ...(options.tools === undefined ? {} : { tools: options.tools }), @@ -289,7 +294,7 @@ export const compileMcpApps = async ( })), // The generated identity module is virtual, but it still surfaces in // stats as a module under this reserved namespace. - ignoredSourcePaths: [resolve(options.outDir, generatedModulesDirname)], + ignoredSourcePaths: [resolve(generatedModulesRoot(options.cwd))], projectRoot: options.cwd, stats: result.stats, }); diff --git a/packages/agent-bundle/src/build/meta.ts b/packages/agent-bundle/src/build/meta.ts index a29d47237..0c7a49b0b 100644 --- a/packages/agent-bundle/src/build/meta.ts +++ b/packages/agent-bundle/src/build/meta.ts @@ -1,18 +1,57 @@ +import { lstat } from 'node:fs/promises'; import { join } from 'node:path'; +import { isErrno } from '../core/errors.ts'; + import type { AgentBundleMeta } from '../meta.ts'; /** - * The reserved namespace (under each build's output root) whose paths + * The reserved namespace (directly under the project 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 + * module ids served from memory by Rspack's `experiments.VirtualModulesPlugin` + * (each compiler keeps its own virtual file store, so one path may serve + * every compiler of a build), chosen to be deterministic for * `inspect --bundler` and collision-safe across entries. The namespace stays * excluded from authored-source provenance. + * + * It is rooted at the project root — the bundler `context` — rather than at + * the per-build staged output root on purpose: Rspack derives the readable + * module identifiers it writes into emitted bundles (the `// NAMESPACE + * OBJECT: ./…` comments of concatenated modules) from a module's path + * relative to `context`, so a namespace under `.artifact.stage-XXXXXX` would + * stamp that per-build token into the artifact and break byte-reproducible + * builds. Under the project root the identifier is always + * `./.agent-bundle-virtual/.mjs`, whatever the output root. */ export const generatedModulesDirname = '.agent-bundle-virtual'; +/** The reserved generated-module namespace of one project. */ +export const generatedModulesRoot = (projectRoot: string): string => + join(projectRoot, generatedModulesDirname); + +/** + * Refuses to compile while anything occupies the reserved namespace on disk. + * The virtual paths are predictable (`meta.mjs`, `-entry.mjs`, …), so + * an authored file at one of them would be shadowed by the generated module + * it names — or serve as an authored entry that compiles from generated + * source. Reserving the whole directory keeps both impossible, as the + * per-build staging root once did. + */ +export const assertGeneratedModulesRootAbsent = async (projectRoot: string): Promise => { + const root = generatedModulesRoot(projectRoot); + try { + await lstat(root); + } catch (error) { + if (isErrno(error, 'ENOENT')) return; + throw error; + } + throw new Error( + `${JSON.stringify(generatedModulesDirname)} under the project root ${JSON.stringify(projectRoot)} is reserved for ` + + 'generated module sources served from memory; remove it before building.', + ); +}; + /** * The reserved specifier every compiled plugin surface resolves to the * generated identity module. It is a package subpath rather than a @@ -25,12 +64,12 @@ 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. + * one identity, so every entry of a project 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'); +export const generatedMetaModulePath = (projectRoot: string): string => + join(generatedModulesRoot(projectRoot), 'meta.mjs'); /** * The identity axes {@link projectMeta} reads. Normalized project metadata diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 451a64ab1..8eb7285e0 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -14,9 +14,10 @@ import type { AgentBundleMeta } from '../meta.ts'; import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts'; import { mcpEntryRuntimeSpecifier } from './entry-shell.ts'; import { + assertGeneratedModulesRootAbsent, generatedMetaModulePath, generatedMetaModuleSource, - generatedModulesDirname, + generatedModulesRoot, metaModuleSpecifier, } from './meta.ts'; import { collectBundledOutputEvidence, type BundledOutputEvidence } from './provenance.ts'; @@ -119,10 +120,12 @@ export const entryLibId = (entry: Pick): strin export const isDeclarationGenerationFailure = (error: unknown): boolean => error instanceof Error && /declaration files/iu.test(error.message); -// join (not resolve) so a tokenized output root (`/`) stays -// a token instead of resolving against the cwd. -const generatedEntryModulePath = (outputRoot: string, entry: RslibEntry): string => - join(outputRoot, generatedModulesDirname, `${entry.name}-entry.mjs`); +// Generated module paths live in the project's reserved namespace (see +// `generatedModulesDirname`), never under the per-build staged output root: +// Rspack writes module identifiers relative to the project root into the +// emitted bundles, and those bytes must not change from one build to the next. +const generatedEntryModulePath = (projectRoot: string, entry: RslibEntry): string => + join(generatedModulesRoot(projectRoot), `${entry.name}-entry.mjs`); /** The import requests of one named entry in a lowered Rspack entry record. */ const entryImportsOf = (entryRecord: unknown, name: string): readonly string[] => { @@ -134,7 +137,7 @@ const entryImportsOf = (entryRecord: unknown, name: string): readonly string[] = }; const virtualRegistryModules = ( - outputRoot: string, + projectRoot: string, entry: RslibEntry, meta: AgentBundleMeta, ): readonly { readonly name: string; readonly path: string; readonly source: string }[] => [ @@ -143,12 +146,12 @@ const virtualRegistryModules = ( // build stamps one identity, and every entry shares one generated module. { name: metaModuleSpecifier, - path: generatedMetaModulePath(outputRoot), + path: generatedMetaModulePath(projectRoot), source: generatedMetaModuleSource(meta), }, ...(entry.virtualModules ?? []).map((module, index) => ({ ...module, - path: join(outputRoot, generatedModulesDirname, `${entry.name}-${index}.mjs`), + path: join(generatedModulesRoot(projectRoot), `${entry.name}-${index}.mjs`), })), ]; @@ -356,9 +359,9 @@ const assertExecutableConfig = ( readonly bundlerConfigs: readonly InspectedBundlerConfig[]; readonly environmentConfigs: Readonly>; }, - outputRoot: string, - meta: AgentBundleMeta, + run: Pick, ): void => { + const { cwd, meta, outputRoot } = run; if ( inspection.bundlerConfigs.length !== entries.length || Object.keys(inspection.environmentConfigs).length !== entries.length @@ -391,14 +394,14 @@ 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, meta); + // reserved generated paths against the real filesystem. + const registryModules = virtualRegistryModules(cwd, 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))) { + && !entryImportsOf(config.entry, entry.name).includes(generatedEntryModulePath(cwd, entry))) { throw new Error('Rslib resolved a generated executable environment without its generated wrapper entry.'); } const expectedAliases = { @@ -436,6 +439,8 @@ const assertExecutableConfig = ( export const composeEntryLibConfig = ( entry: RslibEntry, options: { + /** The project root: the bundler `context` and the root of the generated-module namespace. */ + readonly cwd: string; /** 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. */ @@ -446,13 +451,13 @@ export const composeEntryLibConfig = ( ): LibConfig => { const libId = entryLibId(entry); const virtualSource = entry.virtualSource; - const virtualModules = virtualRegistryModules(options.outputRoot, entry, options.meta); + const virtualModules = virtualRegistryModules(options.cwd, entry, options.meta); // Every generated module this entry serves virtually during its build: // the wrapper entry (when present) plus the registry modules. const generatedModules = [ ...(virtualSource === undefined ? [] - : [{ path: generatedEntryModulePath(options.outputRoot, entry), source: virtualSource }]), + : [{ path: generatedEntryModulePath(options.cwd, entry), source: virtualSource }]), ...virtualModules, ]; const aliases = entry.aliases ?? {}; @@ -508,15 +513,15 @@ export const composeEntryLibConfig = ( // Rslib validates `source.entry` against the real filesystem before // Rspack exists, so the profile keys the entry on the authored program // and this hook redirects the lowered Rspack entry to the generated - // wrapper's guaranteed-nonexistent virtual path, which the plugin - // above serves from memory. Rspack resolves entries through the - // plugin-patched input filesystem, so no real path is ever shadowed. + // wrapper's reserved virtual path, which the plugin above serves from + // memory. Rspack resolves entries through the plugin-patched input + // filesystem, so nothing is ever read from disk under that path. const lowered = config.entry; if (!isRecord(lowered)) { throw new Error('Rslib lowered a generated executable without a keyed entry record.'); } const description = lowered[entry.name]; - const wrapperImport = [generatedEntryModulePath(options.outputRoot, entry)]; + const wrapperImport = [generatedEntryModulePath(options.cwd, entry)]; config.entry = { ...lowered, [entry.name]: isRecord(description) ? { ...description, import: wrapperImport } : wrapperImport, @@ -675,6 +680,7 @@ export const buildRslibSurfaces = async ( return Object.freeze(surfaces.map(() => Object.freeze([]))); } assertDistinctLibIds(entries); + await assertGeneratedModulesRootAbsent(options.cwd); const dependencyRoots = await declaredDependencyRoots(options.cwd); const reservedExternalViolations: string[] = []; @@ -684,6 +690,7 @@ export const buildRslibSurfaces = async ( // The run reports at the most verbose level any surface asks for. logLevel: surfaces.some((surface) => surface.logLevel === 'error') ? 'error' : 'silent', lib: entries.map((entry) => composeEntryLibConfig(entry, { + cwd: options.cwd, meta: options.meta, onReservedExternal: (specifier) => reservedExternalViolations.push(specifier), outputRoot: options.outputRoot, @@ -693,7 +700,7 @@ export const buildRslibSurfaces = async ( }); const inspection = await rslib.inspectConfig(); - assertExecutableConfig(entries, inspection.origin, options.outputRoot, options.meta); + assertExecutableConfig(entries, inspection.origin, options); let result: Awaited> | undefined; try { try { @@ -714,7 +721,7 @@ export const buildRslibSurfaces = async ( ignoredSourcePaths: [ // Generated wrapper/registry modules are virtual, but they still // surface in stats as modules under this reserved namespace. - resolve(options.outputRoot, generatedModulesDirname), + resolve(generatedModulesRoot(options.cwd)), ...dependencyRoots, ], projectRoot: options.cwd, diff --git a/packages/agent-bundle/tests/build-reproducibility.test.ts b/packages/agent-bundle/tests/build-reproducibility.test.ts new file mode 100644 index 000000000..793a78e77 --- /dev/null +++ b/packages/agent-bundle/tests/build-reproducibility.test.ts @@ -0,0 +1,175 @@ +import { mkdir, mkdtemp, readdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; + +import { afterEach, expect, it } from '@rstest/core'; + +import { build } from '../src/api.ts'; +import { parseArtifactManifest } from '../src/build/manifest.ts'; +import { sha256Hex } from '../src/core/digest.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, contents: string): Promise => { + const output = join(root, path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents); +}; + +/** + * A project with every surface whose generated wrapper imports a virtual + * module as a namespace: a routed MCP server (its route registry module is + * what Rspack names in a `// NAMESPACE OBJECT` comment), event routes for + * three hosts, a routed CLI command, and a bundled script. + */ +const writeProject = async (root: string): Promise => { + // The audiobook example's installed tree supplies @agent-bundle/runtime, react, and zod. + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + '@modelcontextprotocol/server': '2.0.0', + react: '19.2.8', + zod: '4.4.3', + }, + name: 'reproducible-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + " plugin: { description: 'Reproducible build fixture.', name: 'reproducible-fixture', version: '1.0.0' },", + " targets: ['claude', 'codex', 'cursor', 'portable'],", + '});', + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/harness/tools/lookup.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { annotations: { readOnlyHint: true }, description: 'Looks up one value.' };", + 'export const inputSchema = z.object({ message: z.string().default("ready") }).strict();', + "export const resultSchema = z.object({ message: z.string() }).strict();", + 'export default async function Lookup({ input }) {', + ' return {`Lookup: ${input.message}`};', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/events/session/start.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "export const config = { targets: ['claude', 'codex', 'cursor'] };", + 'export default async function SessionStart() {', + ' return session started;', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/cli/report.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { description: 'Render a report.', positionals: ['root'] };", + 'export const inputSchema = z.object({ root: z.string().min(1) }).strict();', + 'export const resultSchema = z.object({ root: z.string() }).strict();', + 'export default async function Report({ input }) {', + ' return {`Report for ${input.root}.`};', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/scripts/summarize.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + 'export default async function Summarize({ argv }) {', + ' return {`Summarized ${String(argv.length)} arguments.`};', + '}', + '', + ].join('\n')), + ]); +}; + +/** Every regular file under `root`, as POSIX paths relative to it, with its SHA-256. */ +const digestTree = async (root: string): Promise> => { + const digests = new Map(); + const walk = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await walk(path); + else if (entry.isFile()) digests.set(relative(root, path).replaceAll('\\', '/'), sha256Hex(await readFile(path))); + } + }; + await walk(root); + return digests; +}; + +/** + * Two builds of one unchanged source tree — into two different output + * directories, each through its own per-build staging directory + * (`..stage-XXXXXX`) — emit byte-identical artifacts: the same + * manifest, the same file digests, the same bytes. Nothing in an emitted + * bundle may name the staging directory, the output directory, or any + * absolute path of the machine that built it; the generated-module + * namespace Rspack names in its module comments derives from the project + * root alone. + */ +it('emits byte-identical artifacts from two builds of one source into two output directories', { timeout: 240_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-reproducible-')); + roots.push(root); + // Completed outputs move out of the project between builds so the second + // build's source snapshot (and so its project revision) is the first's. + const parked = await mkdtemp(join(tmpdir(), 'agent-bundle-reproducible-outputs-')); + roots.push(parked); + await writeProject(root); + + const outputs: string[] = []; + const manifests: string[] = []; + const stageTokens: string[] = []; + for (const name of ['first-output', 'second-output']) { + const output = join(root, name); + const result = await build({ output, root }); + expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + manifests.push(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); + // The build staged under a directory named after this output; that + // token is what a reproducible artifact must never contain. + stageTokens.push(`.${name}.stage-`); + const parkedOutput = join(parked, name); + await rename(output, parkedOutput); + outputs.push(parkedOutput); + } + + const [first, second] = outputs as [string, string]; + const [firstManifest, secondManifest] = manifests as [string, string]; + expect(secondManifest).toBe(firstManifest); + const manifest = parseArtifactManifest(firstManifest); + expect(manifest.files.length).toBeGreaterThan(0); + + const [firstDigests, secondDigests] = await Promise.all([digestTree(first), digestTree(second)]); + expect([...secondDigests.keys()].sort()).toEqual([...firstDigests.keys()].sort()); + const differing = [...firstDigests].filter(([path, digest]) => secondDigests.get(path) !== digest).map(([path]) => path); + expect(differing).toEqual([]); + // The manifest's own digests describe exactly these bytes. + for (const file of manifest.files) { + expect(firstDigests.get(file.path)).toBe(file.sha256); + } + + // Every compiled surface is present, and the route registry the MCP + // entry imports as a namespace is named by its project-rooted virtual + // path — not by the staged directory, the output directory, or the machine. + const bundles = [...firstDigests.keys()].filter((path) => path.endsWith('.mjs')); + expect(bundles.some((path) => /^portable\/mcp\/mcp-harness-[a-f\d]{8}\.mjs$/u.test(path))).toBe(true); + expect(bundles).toEqual(expect.arrayContaining([ + 'claude/hooks/event-route-session-start.mjs', + 'portable/bin/reproducible-fixture.mjs', + 'portable/scripts/summarize.mjs', + ])); + const forbidden = [root, parked, '.artifact.stage-', ...stageTokens]; + for (const path of bundles) { + const source = await readFile(join(first, path), 'utf8'); + for (const token of forbidden) { + expect(source, `${path} names ${token}`).not.toContain(token); + } + } + const mcpEntry = bundles.find((path) => /^portable\/mcp\/mcp-harness-[a-f\d]{8}\.mjs$/u.test(path))!; + expect(await readFile(join(first, mcpEntry), 'utf8')).toMatch(/NAMESPACE OBJECT: \.\/\.agent-bundle-virtual\/mcp-harness-[a-f\d]{8}-\d+\.mjs/u); +}); diff --git a/packages/agent-bundle/tests/compose-layers.test.ts b/packages/agent-bundle/tests/compose-layers.test.ts index 7929a9ce0..d6b5b47ef 100644 --- a/packages/agent-bundle/tests/compose-layers.test.ts +++ b/packages/agent-bundle/tests/compose-layers.test.ts @@ -61,8 +61,8 @@ describe('composeToolsLayers', () => { }); describe('the shared layering reaches every synthesized config the same way', () => { - const lib = composeEntryLibConfig(entry, { meta, outputRoot: '/staged/portable', tools }); - const apps = composeMcpAppsRsbuildConfig([app], { meta, outDir: '/staged/portable', tools }); + const lib = composeEntryLibConfig(entry, { cwd: '/project', meta, outputRoot: '/staged/portable', tools }); + const apps = composeMcpAppsRsbuildConfig([app], { cwd: '/project', meta, outDir: '/staged/portable', tools }); it('lets a tools.rsbuild fragment reach the MCP Apps config exactly as it reaches an entry lib', () => { expect(lib.output?.legalComments).toBe('linked'); @@ -89,8 +89,8 @@ describe('the shared layering reaches every synthesized config the same way', () }); it('composes only the profile and the invariants without a hatch', () => { - const bareLib = composeEntryLibConfig(entry, { meta, outputRoot: '/staged/portable' }); - const bareApps = composeMcpAppsRsbuildConfig([app], { meta, outDir: '/staged/portable' }); + const bareLib = composeEntryLibConfig(entry, { cwd: '/project', meta, outputRoot: '/staged/portable' }); + const bareApps = composeMcpAppsRsbuildConfig([app], { cwd: '/project', meta, outDir: '/staged/portable' }); for (const config of [bareLib, bareApps]) { const mutators = invariantMutatorOf(config); expect(mutators).toHaveLength(1); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 7829f049d..260edf603 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -34,11 +34,12 @@ const probeMeta: AgentBundleMeta = Object.freeze({ /** * 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. + * virtual-module plugin instance and the exact-match alias onto the + * project-rooted generated-module namespace. */ -const resolvedVirtualModules = (outputRoot: string) => ({ +const resolvedVirtualModules = (projectRoot: string) => ({ plugins: [new rspack.experiments.VirtualModulesPlugin({})], - resolve: { alias: { [`${metaModuleSpecifier}$`]: generatedMetaModulePath(outputRoot) } }, + resolve: { alias: { [`${metaModuleSpecifier}$`]: generatedMetaModulePath(projectRoot) } }, }); const registry: NormalizationTargetRegistry = { @@ -203,7 +204,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), + ...resolvedVirtualModules('/tmp'), }], environmentConfigs: { 'agent-bundle-hooks-cache-probe': { output: { cleanDistPath: false } } }, }, @@ -242,8 +243,12 @@ it('does not share a persistent Rslib cache between generated executables', asyn }); it('closes the Rslib build result and serves the generated wrapper entry virtually without touching disk', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-close-project-')); const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-close-output-')); - const virtualEntryPath = join(outputRoot, '.agent-bundle-virtual', 'close-probe-entry.mjs'); + // The generated-module namespace hangs off the project root (the bundler + // context), never off the per-build output root, so the module identifiers + // Rspack writes into emitted bundles do not carry the staged directory. + const virtualEntryPath = join(projectRoot, '.agent-bundle-virtual', 'close-probe-entry.mjs'); const close = rs.fn(async () => undefined); const buildResult = { close, @@ -260,7 +265,7 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual build: async () => { // The generated wrapper entry is served from memory: its reserved // namespace must not exist on disk even while the build is running. - reservedNamespaceDuringBuild = await readdir(join(outputRoot, '.agent-bundle-virtual')) + reservedNamespaceDuringBuild = await readdir(join(projectRoot, '.agent-bundle-virtual')) .then(() => undefined, (error: unknown) => error); return buildResult; }, @@ -271,7 +276,7 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual name: 'agent-bundle-hooks-close-probe', output: { asyncChunks: false, path: outputRoot }, target: 'node', - ...resolvedVirtualModules(outputRoot), + ...resolvedVirtualModules(projectRoot), }], environmentConfigs: { 'agent-bundle-hooks-close-probe': { output: { cleanDistPath: false } } }, }, @@ -282,12 +287,12 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual await mkdir(join(outputRoot, 'hooks'), { recursive: true }); await writeFile(join(outputRoot, 'hooks', 'close-probe.mjs'), 'export default undefined;\n'); await buildWithRslib({ - cwd: '/tmp', + cwd: projectRoot, entries: [{ name: 'close-probe', outputRelativePath: 'hooks/close-probe.mjs', - source: '/tmp/hook.ts', - sourceInputs: ['/tmp/hook.ts'], + source: join(projectRoot, 'hook.ts'), + sourceInputs: [join(projectRoot, 'hook.ts')], virtualSource: 'export default undefined;', }], meta: probeMeta, @@ -301,12 +306,13 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual expect(close).toHaveBeenCalledOnce(); expect(reservedNamespaceDuringBuild).toMatchObject({ code: 'ENOENT' }); + await expect(readdir(join(projectRoot, '.agent-bundle-virtual'))).rejects.toMatchObject({ code: 'ENOENT' }); await expect(readdir(join(outputRoot, '.agent-bundle-virtual'))).rejects.toMatchObject({ code: 'ENOENT' }); // The composed profile keys the entry on the authored program (Rslib // checks entry existence on the real filesystem), and the invariant hook - // redirects the lowered Rspack entry to the guaranteed-nonexistent - // virtual path it registers with VirtualModulesPlugin. + // redirects the lowered Rspack entry to the reserved virtual path it + // registers with VirtualModulesPlugin. const [{ config }] = createOptions as [{ readonly config: { readonly lib: readonly [{ @@ -315,13 +321,13 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual }]; }; }]; - expect(config.lib[0].source.entry).toEqual({ 'close-probe': '/tmp/hook.ts' }); + expect(config.lib[0].source.entry).toEqual({ 'close-probe': join(projectRoot, 'hook.ts') }); const hooksChain = config.lib[0].tools?.rspack; const mutators = (Array.isArray(hooksChain) ? hooksChain : [hooksChain]) .filter((mutator): mutator is (value: object) => object => typeof mutator === 'function'); expect(mutators.length).toBeGreaterThan(0); const resolved: { entry?: unknown; plugins?: readonly unknown[] } = { - entry: { 'close-probe': ['/tmp/hook.ts'] }, + entry: { 'close-probe': [join(projectRoot, 'hook.ts')] }, }; for (const mutator of mutators) mutator(resolved); expect(resolved.entry).toEqual({ 'close-probe': [virtualEntryPath] }); @@ -329,13 +335,45 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual .filter((plugin) => plugin instanceof rspack.experiments.VirtualModulesPlugin); expect(virtualPlugins).toHaveLength(1); } finally { - await rm(outputRoot, { force: true, recursive: true }); + await Promise.all([outputRoot, projectRoot].map((root) => rm(root, { force: true, recursive: true }))); + } +}); + +it('refuses to compile while anything occupies the reserved generated-module namespace', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-reserved-project-')); + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-reserved-output-')); + // The virtual paths are predictable, so an authored file at one of them + // (here, where the project-identity module is served) would be shadowed by + // the generated module — or compile as an entry from generated source. The + // whole directory is reserved; the build fails before any compiler is created. + await mkdir(join(projectRoot, '.agent-bundle-virtual'), { recursive: true }); + await writeFile(join(projectRoot, '.agent-bundle-virtual', 'meta.mjs'), 'export default undefined;\n'); + const createRslib = rs.fn(async () => { throw new Error('unreachable'); }); + + try { + await expect(buildWithRslib({ + cwd: projectRoot, + entries: [{ + name: 'reserved-probe', + outputRelativePath: 'hooks/reserved-probe.mjs', + source: join(projectRoot, 'hook.ts'), + sourceInputs: [join(projectRoot, 'hook.ts')], + virtualSource: 'export default undefined;', + }], + meta: probeMeta, + outputRoot, + }, { createRslib: createRslib as never })).rejects.toThrow( + `".agent-bundle-virtual" under the project root ${JSON.stringify(projectRoot)} is reserved for generated module sources served from memory; remove it before building.`, + ); + expect(createRslib).not.toHaveBeenCalled(); + } finally { + await Promise.all([outputRoot, projectRoot].map((root) => rm(root, { force: true, recursive: true }))); } }); it('fails closed when the resolved environment lost its virtual modules or wrapper entry', async () => { const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-lost-virtual-')); - const virtualEntryPath = join(outputRoot, '.agent-bundle-virtual', 'lost-probe-entry.mjs'); + const virtualEntryPath = join('/tmp', '.agent-bundle-virtual', 'lost-probe-entry.mjs'); const rslibFor = (bundlerConfig: Record) => ({ build: async () => { throw new Error('inspection must fail before the build starts'); @@ -366,15 +404,15 @@ it('fails closed when the resolved environment lost its virtual modules or wrapp }, { createRslib: async () => rslibFor(bundlerConfig) as never }); try { - // A resolved config without the plugin would resolve the - // guaranteed-nonexistent generated paths against the real filesystem. + // A resolved config without the plugin would resolve the reserved + // generated paths against the real filesystem. await expect(buildLostProbe({ entry: { 'lost-probe': [virtualEntryPath] } })) .rejects.toThrow(/without its virtual modules/u); // A resolved config still keyed on the authored program would compile // without the generated wrapper. await expect(buildLostProbe({ entry: { 'lost-probe': ['/tmp/hook.ts'] }, - ...resolvedVirtualModules(outputRoot), + ...resolvedVirtualModules('/tmp'), })).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. @@ -405,7 +443,7 @@ it('fails closed when an emitted bundle retains a residual reserved import', asy name: 'agent-bundle-hooks-residual-probe', output: { asyncChunks: false, path: outputRoot }, target: 'node', - ...resolvedVirtualModules(outputRoot), + ...resolvedVirtualModules('/tmp'), }], environmentConfigs: { 'agent-bundle-hooks-residual-probe': { output: { cleanDistPath: false } } }, }, @@ -445,7 +483,7 @@ it('closes the Rslib build result when provenance stats are unavailable', async name: 'agent-bundle-hooks-close-error-probe', output: { asyncChunks: false, path: outputRoot }, target: 'node', - ...resolvedVirtualModules(outputRoot), + ...resolvedVirtualModules('/tmp'), }], environmentConfigs: { 'agent-bundle-hooks-close-error-probe': { output: { cleanDistPath: false } } }, }, diff --git a/packages/agent-bundle/tests/target-stages.test.ts b/packages/agent-bundle/tests/target-stages.test.ts index 55bc2b29b..0a1538931 100644 --- a/packages/agent-bundle/tests/target-stages.test.ts +++ b/packages/agent-bundle/tests/target-stages.test.ts @@ -88,12 +88,12 @@ describe('planTargetStages', () => { * generated executable: the virtual-module plugin instance and the exact * match alias of the framework identity module. */ -const resolvedEnvironment = (outputRoot: string, entry: RslibEntry) => ({ +const resolvedEnvironment = (projectRoot: string, outputRoot: string, entry: RslibEntry) => ({ bundler: { name: entryLibId(entry), output: { asyncChunks: false, path: outputRoot }, plugins: [new rspack.experiments.VirtualModulesPlugin({})], - resolve: { alias: { [`${metaModuleSpecifier}$`]: generatedMetaModulePath(outputRoot) } }, + resolve: { alias: { [`${metaModuleSpecifier}$`]: generatedMetaModulePath(projectRoot) } }, target: 'node', }, environment: { output: { cleanDistPath: false } }, @@ -146,10 +146,10 @@ describe('buildRslibSurfaces', () => { }), inspectConfig: async () => ({ origin: { - bundlerConfigs: entries.map((entry) => resolvedEnvironment(outputRoot, entry).bundler), + bundlerConfigs: entries.map((entry) => resolvedEnvironment(project, outputRoot, entry).bundler), environmentConfigs: Object.fromEntries(entries.map((entry) => [ entryLibId(entry), - resolvedEnvironment(outputRoot, entry).environment, + resolvedEnvironment(project, outputRoot, entry).environment, ])), }, }), diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index f59bc91ec..0200bab11 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -17,6 +17,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/artifact-cli-bin.test.ts', 'packages/agent-bundle/tests/artifact-validator.test.ts', 'packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts', + 'packages/agent-bundle/tests/build-reproducibility.test.ts', 'packages/agent-bundle/tests/build.test.ts', 'packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts', 'packages/agent-bundle/tests/cli-routes-build.test.ts', diff --git a/website/docs/en/guide/distribution/index.mdx b/website/docs/en/guide/distribution/index.mdx index 04edc90f8..d8bef5ded 100644 --- a/website/docs/en/guide/distribution/index.mdx +++ b/website/docs/en/guide/distribution/index.mdx @@ -57,6 +57,16 @@ non-interactive plugin install verb. real bytes rather than checking that a path exists. The per-target layouts themselves are covered in [Project structure](../start/project-structure.mdx). +Builds are reproducible: two builds of one unchanged source tree emit byte-identical artifacts — +the same manifest and the same digests — whatever `--output` names and however the per-build +staging directory is named. The module identifiers the bundler writes into compiled entries +derive from the project root only, never from the staging or output directory or from any +absolute path of the building machine, so installed copies, preview packages, and +[`doctor`](./installation.mdx) comparisons see the same bytes from the same source. The +generated modules those identifiers name are served from memory under the reserved +`.agent-bundle-virtual/` directory of the project root; the build refuses to compile while anything +occupies that directory. + ## The npm-facing half A project that also ships as an npm package has a second output: `dist/bin/.js` executables diff --git a/website/docs/zh/guide/distribution/index.mdx b/website/docs/zh/guide/distribution/index.mdx index 86a636b3c..385310c39 100644 --- a/website/docs/zh/guide/distribution/index.mdx +++ b/website/docs/zh/guide/distribution/index.mdx @@ -51,6 +51,12 @@ Cursor、portable 与组合 target 则包含一个独立的 `install.mjs`,因 `agent-bundle.manifest.json` 记录每个输出文件及其 SHA-256,因此校验比对的是真实字节,而不是检查某个 路径是否存在。各 target 自身的布局在[项目结构](../start/project-structure.mdx)中介绍。 +构建是可复现的:对同一份未改动的源码树构建两次,会得到逐字节相同的产物——相同的清单、相同的摘要—— +无论 `--output` 叫什么名字,也无论每次构建的暂存目录叫什么名字。打包器写进编译入口里的模块标识只由 +项目根目录推导,绝不会来自暂存目录、输出目录或构建机器上的任何绝对路径,因此已安装的副本、预览包与 +[`doctor`](./installation.mdx) 的比对在同一源码下看到的都是相同的字节。这些标识所命名的生成模块从内存 +中提供,位于项目根目录下保留的 `.agent-bundle-virtual/` 目录;只要有任何东西占用该目录,构建就会拒绝编译。 + ## 面向 npm 的那一半 同时作为 npm 包发布的项目还有第二份输出:`dist/bin/.js` 可执行文件与一个库入口,见