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
32 changes: 16 additions & 16 deletions .changeset/build-path-conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,32 @@
"agent-bundle": minor
---

Move the generated-executable build path onto fully documented bundler
surfaces (Rspack/Rslib/Rsbuild conformance audit).
Harden the generated-executable build path (Rspack/Rslib/Rsbuild
conformance audit). One deliberate experimental surface remains:
`rspack.experiments.VirtualModulesPlugin` serves generated module sources,
behind a feature check with an actionable diagnostic.

- Generated wrapper entries and registry modules (the stdio MCP entry shell,
`main` process envelopes, `agent-bundle/mcp-apps` registries) are now
materialized as real files under the reserved `.agent-bundle-virtual/`
directory for the duration of one Rslib build — replacing the experimental
`rspack.experiments.VirtualModulesPlugin` and its undocumented
`main` process envelopes, `agent-bundle/mcp-apps` registries) now live at
dedicated, deterministic, guaranteed-nonexistent paths under the reserved
`.agent-bundle-virtual/` namespace — replacing the undocumented
real-file-overlay of the framework's own module as the entry anchor. The
files never reach a published artifact and never count as authored source
provenance; emitted bundles keep their behavior byte for byte (the only
content shift is one scope-hoisting identifier now derived from the stable
generated-entry name instead of the framework's install-dependent bundle
filename).
generated sources never reach the filesystem or a published artifact and
never count as authored source provenance; emitted bundles keep their
behavior byte for byte (the only content shift is one scope-hoisting
identifier now derived from the stable generated-entry name instead of the
framework's install-dependent bundle filename).
- The self-contained-artifact invariant now closes the `output.externals`
hole: a `tools` hatch that externalizes a reserved specifier
(`agent-bundle/mcp-entry`, `agent-bundle/mcp-apps`, or any generated
registry name) fails the build with a hard diagnostic — statically for
string/RegExp/object externals, and via a post-build residual-import scan
of every emitted bundle for function-form externals.
- Dist cleaning is now a framework invariant rather than a profile default:
because generated sources are materialized under the output root, a
`tools.rsbuild.output.cleanDistPath: true` hatch would delete this build's
own entry modules and any sibling entry already emitted into the shared
staged root. It is pinned off after the hatch merge and asserted on the
resolved environment config.
scripts, MCP entries, hooks, and MCP Apps build sequentially into one
shared staged root, so a `tools.rsbuild.output.cleanDistPath: true` hatch
would delete sibling outputs already emitted there. It is pinned off after
the hatch merge and asserted on the resolved environment config.
- Pre-build inspection assertions are keyed by the documented Rslib `lib.id`
(`origin.environmentConfigs[id]` and the Rspack config `name`) instead of
relying on undocumented array ordering, and reserved aliases use Rspack's
Expand Down
191 changes: 126 additions & 65 deletions packages/agent-bundle/src/build/rslib.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// Rslib re-exports its own Rsbuild/Rspack stack (values and types alike);
// installing @rspack/core separately risks version conflicts
// (https://rslib.rs/api/javascript-api/core).
import { createRslib, mergeRslibConfig, type LibConfig, type Rspack } from '@rslib/core';
import { createRslib, mergeRslibConfig, rspack, type LibConfig, type Rspack } from '@rslib/core';
import { init, parse } from 'es-module-lexer';
import { mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { readFile, realpath } from 'node:fs/promises';
import { join, resolve } from 'node:path';

import { isErrno } from '../core/errors.ts';
import { isRecord } from '../core/strict-json.ts';
import type { AgentBundleToolsConfig } from '../core/types.ts';
import { mcpEntryRuntimeSpecifier } from './entry-shell.ts';
import { collectBundledOutputEvidence, type BundledOutputEvidence } from './provenance.ts';
Expand Down Expand Up @@ -47,14 +48,36 @@ interface RslibDependencies {
}

/**
* The reserved directory (under each build's output root) where generated
* module sources — wrapper entries and registry modules — are materialized
* as real files for the duration of one Rslib build. Materialized files are
* excluded from authored-source provenance and removed after the build, so
* they never reach a published artifact.
* The reserved namespace (under each build's output root) whose paths
* identify generated module sources — wrapper entries and registry modules.
* Nothing ever writes these paths: they are guaranteed-nonexistent module
* ids served from memory by {@link virtualModulesPluginConstructor}, chosen
* to be deterministic for `inspect --bundler` and collision-safe across
* entries. The namespace stays excluded from authored-source provenance.
*/
const generatedModulesDirname = '.agent-bundle-virtual';

/**
* Generated sources ride Rspack's `experiments.VirtualModulesPlugin` instead
* of throwaway files on disk — an accepted design decision: the experimental
* surface is the cost of never touching the artifact tree with build-time
* scratch files. This narrow feature check turns an upstream rename or
* removal into an actionable diagnostic instead of an opaque resolution
* failure deep inside a build.
*/
const virtualModulesPluginConstructor = (): typeof rspack.experiments.VirtualModulesPlugin => {
const constructor = (rspack as { readonly experiments?: { readonly VirtualModulesPlugin?: unknown } })
.experiments?.VirtualModulesPlugin;
if (typeof constructor !== 'function') {
throw new Error(
'The Rspack engine nested in @rslib/core no longer exposes experiments.VirtualModulesPlugin, '
+ 'which agent-bundle uses to serve generated wrapper and registry modules. '
+ 'Pin @rslib/core to a version whose Rspack ships the plugin, or update agent-bundle.',
);
}
return constructor as typeof rspack.experiments.VirtualModulesPlugin;
};

/**
* The tools escape hatch is typed against the workspace `@rsbuild/core` (the
* engine of the MCP Apps path), while this build path executes under the
Expand All @@ -72,12 +95,21 @@ const asRslibRspackHatch = (

const entryLibId = (entry: Pick<RslibEntry, 'name'>): string => `agent-bundle-${entry.name}`;

// join (not resolve) so `inspect --bundler`'s tokenized output roots
// (`<output>/<target>`) stay tokens instead of resolving against the cwd.
// join (not resolve) so a tokenized output root (`<output>/<target>`) stays
// a token instead of resolving against the cwd.
const generatedEntryModulePath = (outputRoot: string, entry: RslibEntry): string =>
join(outputRoot, generatedModulesDirname, `${entry.name}-entry.mjs`);

const materializedVirtualModules = (
/** The import requests of one named entry in a lowered Rspack entry record. */
const entryImportsOf = (entryRecord: unknown, name: string): readonly string[] => {
const description = isRecord(entryRecord) ? entryRecord[name] : undefined;
const imports = isRecord(description) ? description.import : description;
if (typeof imports === 'string') return [imports];
if (Array.isArray(imports)) return imports.filter((item): item is string => typeof item === 'string');
return [];
};

const virtualRegistryModules = (
outputRoot: string,
entry: RslibEntry,
): readonly { readonly name: string; readonly path: string; readonly source: string }[] =>
Expand All @@ -86,17 +118,6 @@ const materializedVirtualModules = (
path: join(outputRoot, generatedModulesDirname, `${entry.name}-${index}.mjs`),
}));

/** Every generated module one entry materializes to disk for its build. */
const plannedGeneratedModules = (
entry: RslibEntry,
outputRoot: string,
): readonly { readonly path: string; readonly source: string }[] => [
...(entry.virtualSource === undefined
? []
: [{ path: generatedEntryModulePath(outputRoot, entry), source: entry.virtualSource }]),
...materializedVirtualModules(outputRoot, entry).map(({ path, source }) => ({ path, source })),
];

/**
* The module specifiers one entry's emitted bundle must inline: the runtime
* shell alias targets and generated registry modules, plus the mcp-entry
Expand Down Expand Up @@ -276,9 +297,11 @@ const declaredDependencyRoots = async (cwd: string): Promise<readonly string[]>
};

interface InspectedBundlerConfig {
readonly entry?: unknown;
readonly externals?: unknown;
readonly name?: string;
readonly output?: { readonly asyncChunks?: boolean; readonly path?: string };
readonly plugins?: readonly unknown[];
readonly resolve?: { readonly alias?: unknown };
readonly target?: false | string | readonly string[];
}
Expand Down Expand Up @@ -312,9 +335,10 @@ const assertExecutableConfig = (
if (environment === undefined) {
throw new Error('Rslib did not resolve one environment for every generated executable.');
}
// Dist cleaning would delete this build's own materialized generated
// sources and any sibling entry already emitted into the shared staged
// root, so the composed invariant pins it off after the hatch merge.
// Scripts, MCP entries, hooks, and MCP Apps build sequentially into one
// shared staged root, so an environment that cleans its dist path would
// delete sibling outputs already emitted there; the composed invariant
// pins it off after the hatch merge.
if (environment.output?.cleanDistPath !== false) {
throw new Error('Rslib resolved a generated executable environment that would clean its own output root.');
}
Expand All @@ -329,10 +353,23 @@ const assertExecutableConfig = (
if (config.output?.asyncChunks !== false || config.output.path !== outputRoot || !target.some((value) => value === 'node')) {
throw new Error('Rslib resolved an invalid generated executable configuration.');
}
// The generated environment must retain the virtual-module source: a
// resolved config without the plugin instance would resolve the
// guaranteed-nonexistent generated paths against the real filesystem.
const registryModules = virtualRegistryModules(outputRoot, entry);
if (entry.virtualSource !== undefined || registryModules.length > 0) {
const constructor = virtualModulesPluginConstructor();
if (config.plugins?.some((plugin) => plugin instanceof constructor) !== true) {
throw new Error('Rslib resolved a generated executable environment without its virtual modules.');
}
}
if (entry.virtualSource !== undefined
&& !entryImportsOf(config.entry, entry.name).includes(generatedEntryModulePath(outputRoot, entry))) {
throw new Error('Rslib resolved a generated executable environment without its generated wrapper entry.');
}
const expectedAliases = {
...entry.aliases,
...Object.fromEntries(materializedVirtualModules(outputRoot, entry)
.map((module) => [module.name, module.path])),
...Object.fromEntries(registryModules.map((module) => [module.name, module.path])),
};
const alias = aliasRecordOf(config);
const frameworkAliasKeys = new Set(Object.keys(expectedAliases).map((name) => `${name}$`));
Expand Down Expand Up @@ -374,7 +411,15 @@ export const composeEntryLibConfig = (
): LibConfig => {
const libId = entryLibId(entry);
const virtualSource = entry.virtualSource;
const virtualModules = materializedVirtualModules(options.outputRoot, entry);
const virtualModules = virtualRegistryModules(options.outputRoot, entry);
// 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 }]),
...virtualModules,
];
const aliases = entry.aliases ?? {};
const reserved = reservedSpecifiers(entry);
const frameworkAliasKeys = new Set([
Expand Down Expand Up @@ -406,6 +451,33 @@ export const composeEntryLibConfig = (
},
};
}
if (generatedModules.length > 0) {
// Added after the hatch mutator (this hook is merged last), so a
// consumer cannot strip the generated sources out of the compiler.
const VirtualModulesPlugin = virtualModulesPluginConstructor();
config.plugins = [
...(config.plugins ?? []),
new VirtualModulesPlugin(Object.fromEntries(generatedModules.map((module) => [module.path, module.source]))),
];
}
if (virtualSource !== undefined) {
// 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.
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)];
config.entry = {
...lowered,
[entry.name]: isRecord(description) ? { ...description, import: wrapperImport } : wrapperImport,
} as typeof config.entry;
}
const violation = reservedExternalsViolation(config.externals, reserved);
if (violation !== undefined) throw reservedExternalError(violation);
if (config.externals !== undefined) {
Expand Down Expand Up @@ -446,8 +518,12 @@ export const composeEntryLibConfig = (
target: 'node',
},
source: {
// Always the authored program, even when a generated wrapper is the
// real compilation root: Rslib checks that every entry exists on disk,
// and `enforceInvariants` redirects the lowered Rspack entry to the
// wrapper's virtual path.
entry: {
[entry.name]: virtualSource === undefined ? entry.source : generatedEntryModulePath(options.outputRoot, entry),
[entry.name]: entry.source,
},
...(entry.tsconfigPath === undefined ? {} : { tsconfigPath: entry.tsconfigPath }),
},
Expand All @@ -461,11 +537,9 @@ export const composeEntryLibConfig = (
? undefined
: { lib: [{ id: libId, tools: { rspack: asRslibRspackHatch(options.tools.rspack) } }] },
// Merged last so the hatch cannot reach either invariant. Dist cleaning
// would delete this build's own materialized generated sources (they live
// under the output root) and any sibling entry already emitted into the
// shared staged root, so it stays off no matter what the consumer asks
// for; the emitted output is published atomically from a staged root
// instead.
// would delete sibling entries already emitted into the shared staged
// root, so it stays off no matter what the consumer asks for; the
// emitted output is published atomically from a staged root instead.
{ lib: [{ id: libId, output: { cleanDistPath: false }, tools: { rspack: enforceInvariants } }] },
);
const lib = merged.lib?.[0];
Expand All @@ -491,34 +565,23 @@ export const buildWithRslib = async (options: {
}
const dependencyRoots = await declaredDependencyRoots(options.cwd);

// Generated wrapper entries and registry modules become real files for the
// duration of the build — the stable, documented alternative to serving
// them through the experimental VirtualModulesPlugin — and are removed
// before artifact listing/publication.
const generatedModulesRoot = resolve(options.outputRoot, generatedModulesDirname);
const generatedModules = options.entries.flatMap((entry) => plannedGeneratedModules(entry, options.outputRoot));
const reservedExternalViolations: string[] = [];
const rslib = await (dependencies.createRslib ?? createRslib)({
cwd: options.cwd,
config: {
logLevel: options.logLevel ?? 'silent',
lib: options.entries.map((entry) => composeEntryLibConfig(entry, {
onReservedExternal: (specifier) => reservedExternalViolations.push(specifier),
outputRoot: options.outputRoot,
...(options.tools === undefined ? {} : { tools: options.tools }),
})),
},
});

const inspection = await rslib.inspectConfig();
assertExecutableConfig(options.entries, inspection.origin, options.outputRoot);
let result: Awaited<ReturnType<RslibInstance['build']>> | undefined;
try {
if (generatedModules.length > 0) {
await mkdir(generatedModulesRoot, { recursive: true });
await Promise.all(generatedModules.map((module) => writeFile(module.path, module.source, 'utf8')));
}

const reservedExternalViolations: string[] = [];
const rslib = await (dependencies.createRslib ?? createRslib)({
cwd: options.cwd,
config: {
logLevel: options.logLevel ?? 'silent',
lib: options.entries.map((entry) => composeEntryLibConfig(entry, {
onReservedExternal: (specifier) => reservedExternalViolations.push(specifier),
outputRoot: options.outputRoot,
...(options.tools === undefined ? {} : { tools: options.tools }),
})),
},
});

const inspection = await rslib.inspectConfig();
assertExecutableConfig(options.entries, inspection.origin, options.outputRoot);
try {
result = await rslib.build();
} catch (error) {
Expand All @@ -534,7 +597,9 @@ export const buildWithRslib = async (options: {
sourceInputs: entry.sourceInputs,
})),
ignoredSourcePaths: [
generatedModulesRoot,
// Generated wrapper/registry modules are virtual, but they still
// surface in stats as modules under this reserved namespace.
resolve(options.outputRoot, generatedModulesDirname),
...(options.ignoredSourcePaths ?? []),
...dependencyRoots,
],
Expand All @@ -544,10 +609,6 @@ export const buildWithRslib = async (options: {
await assertNoResidualReservedImports(options.entries, options.outputRoot);
return evidence;
} finally {
try {
await result?.close();
} finally {
await rm(generatedModulesRoot, { force: true, recursive: true });
}
await result?.close();
}
};
Loading
Loading