From c4775185930a4c92b149115e61a8f54621c58e0e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 03:39:30 +0000 Subject: [PATCH 1/2] fix(build): root the generated-module namespace at the project root so artifacts are byte-reproducible Rspack writes module identifiers relative to the bundler context into the emitted bundles (the // NAMESPACE OBJECT comments of concatenated modules). The generated wrapper, route registry, and agent-bundle/meta modules were served under the per-build staging root (..stage-XXXXXX// .agent-bundle-virtual/), so every routed MCP entry carried the staging token and two builds of one source differed in those files' sha256 and in the manifest. The namespace now hangs off the project root, independent of the output root, and a build-pool test builds one project twice into two output directories and asserts identical manifests and bytes. --- .changeset/build-reproducible-artifacts.md | 5 + docs/framework-mode.md | 12 ++ packages/agent-bundle/src/api.ts | 1 + .../agent-bundle/src/build/inspect-bundler.ts | 33 +++- packages/agent-bundle/src/build/mcp-apps.ts | 9 +- packages/agent-bundle/src/build/meta.ts | 30 ++- packages/agent-bundle/src/build/rslib.ts | 47 ++--- .../tests/build-reproducibility.test.ts | 175 ++++++++++++++++++ .../agent-bundle/tests/compose-layers.test.ts | 8 +- packages/agent-bundle/tests/hooks.test.ts | 48 ++--- .../agent-bundle/tests/target-stages.test.ts | 8 +- rstest.integration-tests.ts | 1 + website/docs/en/guide/distribution/index.mdx | 7 + website/docs/zh/guide/distribution/index.mdx | 5 + 14 files changed, 321 insertions(+), 68 deletions(-) create mode 100644 .changeset/build-reproducible-artifacts.md create mode 100644 packages/agent-bundle/tests/build-reproducibility.test.ts diff --git a/.changeset/build-reproducible-artifacts.md b/.changeset/build-reproducible-artifacts.md new file mode 100644 index 000000000..c0aff8a42 --- /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. (#518) diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 9d096894e..5ba338920 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -543,6 +543,18 @@ 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. 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..73f1ade2f 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -11,7 +11,7 @@ import { listArtifactFiles, resolveArtifactDestination } from './emit.ts'; import { generatedMetaModulePath, generatedMetaModuleSource, - generatedModulesDirname, + generatedModulesRoot, metaModuleSpecifier, } from './meta.ts'; import { collectBundledOutputEvidence } from './provenance.ts'; @@ -180,13 +180,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()] } : {}), @@ -270,6 +272,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 +292,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..28f322a73 100644 --- a/packages/agent-bundle/src/build/meta.ts +++ b/packages/agent-bundle/src/build/meta.ts @@ -3,16 +3,30 @@ import { join } from 'node:path'; 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); + /** * The reserved specifier every compiled plugin surface resolves to the * generated identity module. It is a package subpath rather than a @@ -25,12 +39,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..22e2ebf46 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -16,7 +16,7 @@ import { mcpEntryRuntimeSpecifier } from './entry-shell.ts'; import { generatedMetaModulePath, generatedMetaModuleSource, - generatedModulesDirname, + generatedModulesRoot, metaModuleSpecifier, } from './meta.ts'; import { collectBundledOutputEvidence, type BundledOutputEvidence } from './provenance.ts'; @@ -119,10 +119,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 +136,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 +145,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 +358,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 +393,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 +438,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 +450,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 +512,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, @@ -684,6 +688,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 +698,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 +719,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..0191c0196 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,13 @@ 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('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 +372,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 +411,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 +451,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..2a702b0dc 100644 --- a/website/docs/en/guide/distribution/index.mdx +++ b/website/docs/en/guide/distribution/index.mdx @@ -57,6 +57,13 @@ 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 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..91ab45807 100644 --- a/website/docs/zh/guide/distribution/index.mdx +++ b/website/docs/zh/guide/distribution/index.mdx @@ -51,6 +51,11 @@ Cursor、portable 与组合 target 则包含一个独立的 `install.mjs`,因 `agent-bundle.manifest.json` 记录每个输出文件及其 SHA-256,因此校验比对的是真实字节,而不是检查某个 路径是否存在。各 target 自身的布局在[项目结构](../start/project-structure.mdx)中介绍。 +构建是可复现的:对同一份未改动的源码树构建两次,会得到逐字节相同的产物——相同的清单、相同的摘要—— +无论 `--output` 叫什么名字,也无论每次构建的暂存目录叫什么名字。打包器写进编译入口里的模块标识只由 +项目根目录推导,绝不会来自暂存目录、输出目录或构建机器上的任何绝对路径,因此已安装的副本、预览包与 +[`doctor`](./installation.mdx) 的比对在同一源码下看到的都是相同的字节。 + ## 面向 npm 的那一半 同时作为 npm 包发布的项目还有第二份输出:`dist/bin/.js` 可执行文件与一个库入口,见 From b22943defe9fa14cc0b480dbd915227a8b35ded3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 04:00:58 +0000 Subject: [PATCH 2/2] Reserve the project-rooted generated-module namespace on disk The virtual paths under .agent-bundle-virtual/ are predictable, so an authored file at one of them would be shadowed by the generated module it names, or compile as an entry from generated source. Refuse to compile while anything occupies the directory, as the fresh staging root once made impossible. --- .changeset/build-reproducible-artifacts.md | 2 +- docs/framework-mode.md | 4 ++- packages/agent-bundle/src/build/mcp-apps.ts | 2 ++ packages/agent-bundle/src/build/meta.ts | 25 +++++++++++++++ packages/agent-bundle/src/build/rslib.ts | 2 ++ packages/agent-bundle/tests/hooks.test.ts | 32 ++++++++++++++++++++ website/docs/en/guide/distribution/index.mdx | 5 ++- website/docs/zh/guide/distribution/index.mdx | 3 +- 8 files changed, 71 insertions(+), 4 deletions(-) diff --git a/.changeset/build-reproducible-artifacts.md b/.changeset/build-reproducible-artifacts.md index c0aff8a42..6e9dfb675 100644 --- a/.changeset/build-reproducible-artifacts.md +++ b/.changeset/build-reproducible-artifacts.md @@ -2,4 +2,4 @@ "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. (#518) +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 5ba338920..9d9fa42be 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -549,7 +549,9 @@ 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. That namespace hangs off the project root — the +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 diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 73f1ade2f..eb7c29712 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -9,6 +9,7 @@ import type { AgentBundleMeta } from '../meta.ts'; import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts'; import { listArtifactFiles, resolveArtifactDestination } from './emit.ts'; import { + assertGeneratedModulesRootAbsent, generatedMetaModulePath, generatedMetaModuleSource, generatedModulesRoot, @@ -260,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); diff --git a/packages/agent-bundle/src/build/meta.ts b/packages/agent-bundle/src/build/meta.ts index 28f322a73..0c7a49b0b 100644 --- a/packages/agent-bundle/src/build/meta.ts +++ b/packages/agent-bundle/src/build/meta.ts @@ -1,5 +1,8 @@ +import { lstat } from 'node:fs/promises'; import { join } from 'node:path'; +import { isErrno } from '../core/errors.ts'; + import type { AgentBundleMeta } from '../meta.ts'; /** @@ -27,6 +30,28 @@ export const generatedModulesDirname = '.agent-bundle-virtual'; 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 diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 22e2ebf46..8eb7285e0 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -14,6 +14,7 @@ import type { AgentBundleMeta } from '../meta.ts'; import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts'; import { mcpEntryRuntimeSpecifier } from './entry-shell.ts'; import { + assertGeneratedModulesRootAbsent, generatedMetaModulePath, generatedMetaModuleSource, generatedModulesRoot, @@ -679,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[] = []; diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 0191c0196..260edf603 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -339,6 +339,38 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual } }); +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('/tmp', '.agent-bundle-virtual', 'lost-probe-entry.mjs'); diff --git a/website/docs/en/guide/distribution/index.mdx b/website/docs/en/guide/distribution/index.mdx index 2a702b0dc..d8bef5ded 100644 --- a/website/docs/en/guide/distribution/index.mdx +++ b/website/docs/en/guide/distribution/index.mdx @@ -62,7 +62,10 @@ the same manifest and the same digests — whatever `--output` names and however 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. +[`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 diff --git a/website/docs/zh/guide/distribution/index.mdx b/website/docs/zh/guide/distribution/index.mdx index 91ab45807..385310c39 100644 --- a/website/docs/zh/guide/distribution/index.mdx +++ b/website/docs/zh/guide/distribution/index.mdx @@ -54,7 +54,8 @@ Cursor、portable 与组合 target 则包含一个独立的 `install.mjs`,因 构建是可复现的:对同一份未改动的源码树构建两次,会得到逐字节相同的产物——相同的清单、相同的摘要—— 无论 `--output` 叫什么名字,也无论每次构建的暂存目录叫什么名字。打包器写进编译入口里的模块标识只由 项目根目录推导,绝不会来自暂存目录、输出目录或构建机器上的任何绝对路径,因此已安装的副本、预览包与 -[`doctor`](./installation.mdx) 的比对在同一源码下看到的都是相同的字节。 +[`doctor`](./installation.mdx) 的比对在同一源码下看到的都是相同的字节。这些标识所命名的生成模块从内存 +中提供,位于项目根目录下保留的 `.agent-bundle-virtual/` 目录;只要有任何东西占用该目录,构建就会拒绝编译。 ## 面向 npm 的那一半