Skip to content

Commit 7377e52

Browse files
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 (.<output>.stage-XXXXXX/<target>/ .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.
1 parent e647336 commit 7377e52

14 files changed

Lines changed: 321 additions & 68 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
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 (`.<output>.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. (#PR)

‎docs/framework-mode.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,18 @@ invariant layer that no hatch value can override
543543
(`src/build/compose-layers.ts`; see the `tools` section of the configuration
544544
reference). `agent-bundle inspect --bundler` prints the result.
545545

546+
Builds are byte-reproducible: two builds of one unchanged source tree emit
547+
identical artifacts (same manifest, same digests, same bytes) regardless of
548+
the `--output` name or the per-build `.<output>.stage-XXXXXX` staging
549+
directory. The generated wrapper, registry, and identity modules that every
550+
compiled surface imports are served from memory under the reserved
551+
`<project root>/.agent-bundle-virtual/` namespace (`src/build/meta.ts`),
552+
which never exists on disk. That namespace hangs off the project root — the
553+
bundler `context` — on purpose: Rspack writes module identifiers relative to
554+
`context` into emitted bundles (the `// NAMESPACE OBJECT: ./…` comments of
555+
concatenated modules), so a namespace under the staging root would stamp the
556+
per-build token into the artifact.
557+
546558
`agent-bundle build` makes each target directory independently distributable.
547559
Every target includes `INSTALL.md` generated with its real plugin and
548560
marketplace names. Claude and Codex bundles include local marketplace manifests

‎packages/agent-bundle/src/api.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,7 @@ export const inspect = async (options: InspectOptions): Promise<InspectResult> =
973973
try {
974974
bundler = await composeBundlerInspection({
975975
model,
976+
projectRoot: prepared.root,
976977
targets: plans.map((plan) => {
977978
const noticeDelivery = prepared.registry.noticeDelivery(plan.target);
978979
return {

‎packages/agent-bundle/src/build/inspect-bundler.ts‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ import type { AgentBundleMeta } from '../meta.ts';
3939
* and the synthesized declaration tsconfig (a temporary file the package
4040
* build generates under `node_modules`) appears as
4141
* `<generated-dts-tsconfig>`. Nothing else is redacted; this is a local
42-
* debugging surface.
42+
* debugging surface. The generated-module namespace
43+
* (`<project root>/.agent-bundle-virtual/...`) appears exactly as the build
44+
* composes it: it derives from the project root, not from the output root.
4345
*/
4446

4547
export interface BundlerInspectionEntry {
@@ -103,12 +105,14 @@ const rslibInspectionEntry = (options: {
103105
readonly name: string;
104106
readonly outputPath: string;
105107
readonly outputRoot: string;
108+
readonly projectRoot: string;
106109
readonly source: string;
107110
readonly target?: string;
108111
readonly tools?: AgentBundleToolsConfig;
109112
}): BundlerInspectionEntry => Object.freeze({
110113
bundler: 'rslib',
111114
config: renderConfigValue(composeEntryLibConfig(options.entry, {
115+
cwd: options.projectRoot,
112116
meta: options.meta,
113117
outputRoot: options.outputRoot,
114118
...(options.tools === undefined ? {} : { tools: options.tools }),
@@ -123,6 +127,7 @@ const rslibInspectionEntry = (options: {
123127

124128
const scriptEntries = async (
125129
model: NormalizedPlugin,
130+
projectRoot: string,
126131
target: string,
127132
tools: AgentBundleToolsConfig | undefined,
128133
): Promise<readonly BundlerInspectionEntry[]> => {
@@ -152,6 +157,7 @@ const scriptEntries = async (
152157
name: script.name,
153158
outputPath: `${target}/scripts/${script.name}.mjs`,
154159
outputRoot,
160+
projectRoot,
155161
source: script.source,
156162
target,
157163
...(tools === undefined ? {} : { tools }),
@@ -162,6 +168,7 @@ const scriptEntries = async (
162168
/** The artifact-hosted routed CLI bins of one target (#387), composed by the build's own planner. */
163169
const cliBinEntries = (
164170
model: NormalizedPlugin,
171+
projectRoot: string,
165172
target: string,
166173
tools: AgentBundleToolsConfig | undefined,
167174
): readonly BundlerInspectionEntry[] => {
@@ -175,6 +182,7 @@ const cliBinEntries = (
175182
name: entry.name.replace(/^bin-/u, ''),
176183
outputPath: `${target}/${entry.outputRelativePath}`,
177184
outputRoot,
185+
projectRoot,
178186
source: entry.source,
179187
target,
180188
...(tools === undefined ? {} : { tools }),
@@ -183,6 +191,7 @@ const cliBinEntries = (
183191

184192
const mcpEntryEntries = async (
185193
model: NormalizedPlugin,
194+
projectRoot: string,
186195
target: string,
187196
tools: AgentBundleToolsConfig | undefined,
188197
noticeDelivery: NoticeDeliveryAdvertisement | undefined,
@@ -240,6 +249,7 @@ const mcpEntryEntries = async (
240249
name: serverName,
241250
outputPath: `${target}/mcp/${entry.name}.mjs`,
242251
outputRoot,
252+
projectRoot,
243253
source: entry.source,
244254
target,
245255
...(tools === undefined ? {} : { tools }),
@@ -269,6 +279,7 @@ const mcpEntryEntries = async (
269279
name: `${serverName}:flight`,
270280
outputPath: `${target}/mcp/${workerFile}`,
271281
outputRoot,
282+
projectRoot,
272283
source: entry.source,
273284
target,
274285
...(tools === undefined ? {} : { tools }),
@@ -281,6 +292,7 @@ const mcpEntryEntries = async (
281292
const hookEntries = (
282293
entries: readonly TargetHookEntry[],
283294
meta: AgentBundleMeta,
295+
projectRoot: string,
284296
target: string,
285297
tools: AgentBundleToolsConfig | undefined,
286298
): readonly BundlerInspectionEntry[] => {
@@ -298,6 +310,7 @@ const hookEntries = (
298310
name: entry.hook.name,
299311
outputPath: `${target}/${entry.relativePath}`,
300312
outputRoot,
313+
projectRoot,
301314
source: entry.hook.source,
302315
target,
303316
...(tools === undefined ? {} : { tools }),
@@ -306,6 +319,7 @@ const hookEntries = (
306319

307320
const mcpAppsEntry = (
308321
model: NormalizedPlugin,
322+
projectRoot: string,
309323
target: string,
310324
tools: AgentBundleToolsConfig | undefined,
311325
): readonly BundlerInspectionEntry[] => {
@@ -323,6 +337,7 @@ const mcpAppsEntry = (
323337
return [Object.freeze({
324338
bundler: 'rsbuild' as const,
325339
config: renderConfigValue(composeMcpAppsRsbuildConfig(sources, {
340+
cwd: projectRoot,
326341
meta: projectMeta(model.metadata),
327342
outDir: outputRoot,
328343
...(tools === undefined ? {} : { tools }),
@@ -336,6 +351,7 @@ const mcpAppsEntry = (
336351

337352
const packageBuildEntries = async (
338353
model: NormalizedPlugin,
354+
projectRoot: string,
339355
tools: AgentBundleToolsConfig | undefined,
340356
): Promise<readonly BundlerInspectionEntry[]> => {
341357
const packageBuild = model.packageBuild;
@@ -352,6 +368,7 @@ const packageBuildEntries = async (
352368
name: bin ? entry.name.replace(/^bin-/u, '') : entry.name,
353369
outputPath: `${packageBuild.outputDir}/${entry.outputRelativePath}`,
354370
outputRoot: packageBuild.outputDir,
371+
projectRoot,
355372
source: entry.source,
356373
...(tools === undefined ? {} : { tools }),
357374
});
@@ -365,6 +382,8 @@ const entryOrder = (left: BundlerInspectionEntry, right: BundlerInspectionEntry)
365382

366383
export const composeBundlerInspection = async (options: {
367384
readonly model: NormalizedPlugin;
385+
/** The project root: the bundler `context` and the root of the generated-module namespace. */
386+
readonly projectRoot: string;
368387
readonly targets: readonly {
369388
/** True when the target hosts the routed CLI bin (its adapter publishes the `cli` capability). */
370389
readonly cliBin?: boolean;
@@ -378,14 +397,14 @@ export const composeBundlerInspection = async (options: {
378397
const meta = projectMeta(options.model.metadata);
379398
for (const target of options.targets) {
380399
entries.push(
381-
...(target.cliBin === true ? cliBinEntries(options.model, target.name, options.tools) : []),
382-
...(await scriptEntries(options.model, target.name, options.tools)),
383-
...(await mcpEntryEntries(options.model, target.name, options.tools, target.noticeDelivery)),
384-
...hookEntries(target.hookEntries, meta, target.name, options.tools),
385-
...mcpAppsEntry(options.model, target.name, options.tools),
400+
...(target.cliBin === true ? cliBinEntries(options.model, options.projectRoot, target.name, options.tools) : []),
401+
...(await scriptEntries(options.model, options.projectRoot, target.name, options.tools)),
402+
...(await mcpEntryEntries(options.model, options.projectRoot, target.name, options.tools, target.noticeDelivery)),
403+
...hookEntries(target.hookEntries, meta, options.projectRoot, target.name, options.tools),
404+
...mcpAppsEntry(options.model, options.projectRoot, target.name, options.tools),
386405
);
387406
}
388-
entries.push(...(await packageBuildEntries(options.model, options.tools)));
407+
entries.push(...(await packageBuildEntries(options.model, options.projectRoot, options.tools)));
389408
return deepFreeze({
390409
entries: entries.sort(entryOrder),
391410
});

‎packages/agent-bundle/src/build/mcp-apps.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { listArtifactFiles, resolveArtifactDestination } from './emit.ts';
1111
import {
1212
generatedMetaModulePath,
1313
generatedMetaModuleSource,
14-
generatedModulesDirname,
14+
generatedModulesRoot,
1515
metaModuleSpecifier,
1616
} from './meta.ts';
1717
import { collectBundledOutputEvidence } from './provenance.ts';
@@ -180,13 +180,15 @@ export const planCompiledMcpApps = (
180180
export const composeMcpAppsRsbuildConfig = (
181181
sources: readonly Pick<NormalizedMcpApp, 'name' | 'source' | 'template'>[],
182182
options: {
183+
/** The project root: the bundler `context` and the root of the generated-module namespace. */
184+
readonly cwd: string;
183185
/** The project identity served to widget source as `agent-bundle/meta`. */
184186
readonly meta: AgentBundleMeta;
185187
readonly outDir: string;
186188
readonly tools?: AgentBundleToolsConfig;
187189
},
188190
): RsbuildConfig => {
189-
const metaModulePath = generatedMetaModulePath(options.outDir);
191+
const metaModulePath = generatedMetaModulePath(options.cwd);
190192
const profile: RsbuildConfig = {
191193
environments: Object.fromEntries(sources.map((source) => [source.name, {
192194
...(usesReactSyntax(source.source) ? { plugins: [pluginReact()] } : {}),
@@ -270,6 +272,7 @@ export const compileMcpApps = async (
270272
const rsbuild = await createRsbuild({
271273
cwd: options.cwd,
272274
config: composeMcpAppsRsbuildConfig(sources, {
275+
cwd: options.cwd,
273276
meta: options.meta,
274277
outDir: options.outDir,
275278
...(options.tools === undefined ? {} : { tools: options.tools }),
@@ -289,7 +292,7 @@ export const compileMcpApps = async (
289292
})),
290293
// The generated identity module is virtual, but it still surfaces in
291294
// stats as a module under this reserved namespace.
292-
ignoredSourcePaths: [resolve(options.outDir, generatedModulesDirname)],
295+
ignoredSourcePaths: [resolve(generatedModulesRoot(options.cwd))],
293296
projectRoot: options.cwd,
294297
stats: result.stats,
295298
});

‎packages/agent-bundle/src/build/meta.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,30 @@ import { join } from 'node:path';
33
import type { AgentBundleMeta } from '../meta.ts';
44

55
/**
6-
* The reserved namespace (under each build's output root) whose paths
6+
* The reserved namespace (directly under the project root) whose paths
77
* identify generated module sources — wrapper entries, registry modules, and
88
* the project-identity module. Nothing ever writes these paths: they are
9-
* guaranteed-nonexistent module ids served from memory by Rspack's
10-
* `experiments.VirtualModulesPlugin`, chosen to be deterministic for
9+
* module ids served from memory by Rspack's `experiments.VirtualModulesPlugin`
10+
* (each compiler keeps its own virtual file store, so one path may serve
11+
* every compiler of a build), chosen to be deterministic for
1112
* `inspect --bundler` and collision-safe across entries. The namespace stays
1213
* excluded from authored-source provenance.
14+
*
15+
* It is rooted at the project root — the bundler `context` — rather than at
16+
* the per-build staged output root on purpose: Rspack derives the readable
17+
* module identifiers it writes into emitted bundles (the `// NAMESPACE
18+
* OBJECT: ./…` comments of concatenated modules) from a module's path
19+
* relative to `context`, so a namespace under `.artifact.stage-XXXXXX` would
20+
* stamp that per-build token into the artifact and break byte-reproducible
21+
* builds. Under the project root the identifier is always
22+
* `./.agent-bundle-virtual/<module>.mjs`, whatever the output root.
1323
*/
1424
export const generatedModulesDirname = '.agent-bundle-virtual';
1525

26+
/** The reserved generated-module namespace of one project. */
27+
export const generatedModulesRoot = (projectRoot: string): string =>
28+
join(projectRoot, generatedModulesDirname);
29+
1630
/**
1731
* The reserved specifier every compiled plugin surface resolves to the
1832
* generated identity module. It is a package subpath rather than a
@@ -25,12 +39,12 @@ export const metaModuleSpecifier = 'agent-bundle/meta';
2539

2640
/**
2741
* The generated path serving {@link metaModuleSpecifier}. One build stamps
28-
* one identity, so every entry under an output root shares one module — the
29-
* path carries no entry name and never shifts as other generated modules
30-
* come and go.
42+
* one identity, so every entry of a project shares one module — the path
43+
* carries no entry name and never shifts as other generated modules come
44+
* and go.
3145
*/
32-
export const generatedMetaModulePath = (outputRoot: string): string =>
33-
join(outputRoot, generatedModulesDirname, 'meta.mjs');
46+
export const generatedMetaModulePath = (projectRoot: string): string =>
47+
join(generatedModulesRoot(projectRoot), 'meta.mjs');
3448

3549
/**
3650
* The identity axes {@link projectMeta} reads. Normalized project metadata

0 commit comments

Comments
 (0)