Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/build-reproducible-artifacts.md
Original file line number Diff line number Diff line change
@@ -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 (`.<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. Because those paths are predictable, `.agent-bundle-virtual/` under the project root is reserved: the build refuses to compile while anything occupies it. (#518)
14 changes: 14 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `.<output>.stage-XXXXXX` staging
directory. The generated wrapper, registry, and identity modules that every
compiled surface imports are served from memory under the reserved
`<project root>/.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
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,7 @@ export const inspect = async (options: InspectOptions): Promise<InspectResult> =
try {
bundler = await composeBundlerInspection({
model,
projectRoot: prepared.root,
targets: plans.map((plan) => {
const noticeDelivery = prepared.registry.noticeDelivery(plan.target);
return {
Expand Down
33 changes: 26 additions & 7 deletions packages/agent-bundle/src/build/inspect-bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<generated-dts-tsconfig>`. Nothing else is redacted; this is a local
* debugging surface.
* debugging surface. The generated-module namespace
* (`<project root>/.agent-bundle-virtual/...`) appears exactly as the build
* composes it: it derives from the project root, not from the output root.
*/

export interface BundlerInspectionEntry {
Expand Down Expand Up @@ -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 }),
Expand All @@ -123,6 +127,7 @@ const rslibInspectionEntry = (options: {

const scriptEntries = async (
model: NormalizedPlugin,
projectRoot: string,
target: string,
tools: AgentBundleToolsConfig | undefined,
): Promise<readonly BundlerInspectionEntry[]> => {
Expand Down Expand Up @@ -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 }),
Expand All @@ -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[] => {
Expand All @@ -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 }),
Expand All @@ -183,6 +191,7 @@ const cliBinEntries = (

const mcpEntryEntries = async (
model: NormalizedPlugin,
projectRoot: string,
target: string,
tools: AgentBundleToolsConfig | undefined,
noticeDelivery: NoticeDeliveryAdvertisement | undefined,
Expand Down Expand Up @@ -240,6 +249,7 @@ const mcpEntryEntries = async (
name: serverName,
outputPath: `${target}/mcp/${entry.name}.mjs`,
outputRoot,
projectRoot,
source: entry.source,
target,
...(tools === undefined ? {} : { tools }),
Expand Down Expand Up @@ -269,6 +279,7 @@ const mcpEntryEntries = async (
name: `${serverName}:flight`,
outputPath: `${target}/mcp/${workerFile}`,
outputRoot,
projectRoot,
source: entry.source,
target,
...(tools === undefined ? {} : { tools }),
Expand All @@ -281,6 +292,7 @@ const mcpEntryEntries = async (
const hookEntries = (
entries: readonly TargetHookEntry[],
meta: AgentBundleMeta,
projectRoot: string,
target: string,
tools: AgentBundleToolsConfig | undefined,
): readonly BundlerInspectionEntry[] => {
Expand All @@ -298,6 +310,7 @@ const hookEntries = (
name: entry.hook.name,
outputPath: `${target}/${entry.relativePath}`,
outputRoot,
projectRoot,
source: entry.hook.source,
target,
...(tools === undefined ? {} : { tools }),
Expand All @@ -306,6 +319,7 @@ const hookEntries = (

const mcpAppsEntry = (
model: NormalizedPlugin,
projectRoot: string,
target: string,
tools: AgentBundleToolsConfig | undefined,
): readonly BundlerInspectionEntry[] => {
Expand All @@ -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 }),
Expand All @@ -336,6 +351,7 @@ const mcpAppsEntry = (

const packageBuildEntries = async (
model: NormalizedPlugin,
projectRoot: string,
tools: AgentBundleToolsConfig | undefined,
): Promise<readonly BundlerInspectionEntry[]> => {
const packageBuild = model.packageBuild;
Expand All @@ -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 }),
});
Expand All @@ -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;
Expand All @@ -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),
});
Expand Down
11 changes: 8 additions & 3 deletions packages/agent-bundle/src/build/mcp-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -180,13 +181,15 @@ export const planCompiledMcpApps = (
export const composeMcpAppsRsbuildConfig = (
sources: readonly Pick<NormalizedMcpApp, 'name' | 'source' | 'template'>[],
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()] } : {}),
Expand Down Expand Up @@ -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);
Expand All @@ -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 }),
Expand All @@ -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,
});
Expand Down
55 changes: 47 additions & 8 deletions packages/agent-bundle/src/build/meta.ts
Original file line number Diff line number Diff line change
@@ -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/<module>.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);
Comment thread
ScriptedAlchemy marked this conversation as resolved.

/**
* Refuses to compile while anything occupies the reserved namespace on disk.
* The virtual paths are predictable (`meta.mjs`, `<entry>-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<void> => {
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
Expand All @@ -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
Expand Down
Loading
Loading