From 4f0cb1776071d2e629c7864d33e50412feb434cf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 08:22:03 +0000 Subject: [PATCH 1/6] fix(build): stable generated-entry materialization, reserved-external rejection, ID-keyed inspection, typed Rslib composition Conformance fixes from the Rspack/Rslib/Rsbuild audit (build-path cluster): materialize generated wrapper/registry modules as real files under the reserved .agent-bundle-virtual/ directory instead of the experimental VirtualModulesPlugin and its real-file overlay of the module anchor; reject tools-hatch externals that name reserved specifiers and scan emitted bundles for residual reserved imports; key inspection assertions on the documented lib.id instead of array order; compose per-entry configs with mergeRslibConfig and engine-typed invariant hooks (no as-never casts); exact-match ($) alias keys; drop the unused direct @rspack/core dependency; correct bundleless-dts wording; close leaked build results in tests and retire the rsbuildConfig alias. --- .changeset/build-path-conformance.md | 44 +++ .../tests/dev-invocation.integration.test.ts | 3 +- packages/agent-bundle/package.json | 1 - packages/agent-bundle/src/build/mcp-apps.ts | 21 +- .../agent-bundle/src/build/package-build.ts | 9 +- packages/agent-bundle/src/build/rslib.ts | 319 ++++++++++++++---- packages/agent-bundle/src/core/types.ts | 17 +- packages/agent-bundle/tests/build.test.ts | 98 ++++++ packages/agent-bundle/tests/hooks.test.ts | 129 ++++--- .../agent-bundle/tests/public-api.test.ts | 16 +- .../workbench/tests/runtime-inspector.test.ts | 5 +- pnpm-lock.yaml | 3 - 12 files changed, 523 insertions(+), 142 deletions(-) create mode 100644 .changeset/build-path-conformance.md diff --git a/.changeset/build-path-conformance.md b/.changeset/build-path-conformance.md new file mode 100644 index 000000000..cb7ab923f --- /dev/null +++ b/.changeset/build-path-conformance.md @@ -0,0 +1,44 @@ +--- +"agent-bundle": minor +--- + +Move the generated-executable build path onto fully documented bundler +surfaces (Rspack/Rslib/Rsbuild conformance audit). + +- 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 + 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). +- 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. +- 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 + exact-match (`$`) key form. +- Per-entry Rslib configs compose through Rslib's own documented + `mergeRslibConfig` (merged by `id`) with the framework invariant hooks + typed against each executing engine's own `Rspack.Configuration` and + returning the config, removing every `as never` cast; the one remaining + type seam between the public hatch types and Rslib's nested engine is a + single documented conversion. The dual-engine reality of the hatch — + Rslib's nested Rsbuild/Rspack (2.1.x line) on the executable path, the + workspace `@rsbuild/core` (2.2.x) on the MCP Apps path — is now documented + on `AgentBundleToolsConfig` and in the entry-conventions reference, + steering hatch authors to the `{ rspack }` utils argument instead of + importing `@rspack/core`. +- The unused direct `@rspack/core` dependency is dropped per Rslib guidance + (its types resolve through `@rsbuild/core`), the `lib` build's declaration + output is described accurately as a bundleless `.d.ts` graph, and the + `mcp run` docs no longer claim programmatic builds load the same `.env` + set (they load none). diff --git a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts index a13490dc8..139fc2bf4 100644 --- a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts @@ -93,7 +93,8 @@ const buildInvocationEntry = async (compilerRoot: string, cwd = process.cwd()): config: createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }), cwd, }); - await rsbuild.build(); + const buildResult = await rsbuild.build(); + await buildResult.close(); return join(compilerRoot, 'rsc', 'dev', 'invoke.js'); }; diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index e63feb375..9d773619c 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -70,7 +70,6 @@ "@rsbuild/plugin-react": "2.1.0", "@rslib/core": "0.23.2", "@rslint/core": "0.8.2", - "@rspack/core": "2.2.1", "@rstackjs/load-config": "0.1.2", "acorn": "8.18.0", "ajv": "8.20.0", diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 47fb20c0b..8c20aa8dc 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -1,4 +1,4 @@ -import { createRsbuild, mergeRsbuildConfig, type RsbuildConfig } from '@rsbuild/core'; +import { createRsbuild, mergeRsbuildConfig, type RsbuildConfig, type Rspack } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; import { readFile } from 'node:fs/promises'; import { extname, resolve } from 'node:path'; @@ -138,7 +138,7 @@ export const composeMcpAppsRsbuildConfig = ( sources: readonly Pick[], options: { readonly outDir: string; readonly tools?: AgentBundleToolsConfig }, ): RsbuildConfig => { - const profile = { + const profile: RsbuildConfig = { environments: Object.fromEntries(sources.map((source) => [source.name, { ...(usesReactSyntax(source.source) ? { plugins: [pluginReact()] } : {}), html: { @@ -164,15 +164,16 @@ export const composeMcpAppsRsbuildConfig = ( server: { publicDir: false }, splitChunks: false, }; - const enforceInvariants = (config: { output: { asyncChunks?: boolean } }): void => { - config.output.asyncChunks = false; + const enforceInvariants = (config: Rspack.Configuration): Rspack.Configuration => { + config.output = { ...config.output, asyncChunks: false }; + return config; }; - return mergeRsbuildConfig( - profile as never, - ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild as never]), - ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } } as never]), - { tools: { rspack: enforceInvariants } } as never, - ) as RsbuildConfig; + return mergeRsbuildConfig( + profile, + ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild]), + ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } }]), + { tools: { rspack: enforceInvariants } }, + ); }; export const compileMcpApps = async ( diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index e25a7afd8..2cec424b1 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -12,10 +12,11 @@ import { buildWithRslib, type RslibEntry } from './rslib.ts'; /** * The framework-owned npm package build: `bin` entries become self-executing * `dist/bin/.js` bundles (shebang + executable bit) and the `lib` entry - * becomes `dist/.js` (+ bundled `.d.ts`), all through the same Rslib - * synthesis, invariant assertions, and staged atomic publication as artifact - * executables. This is the build audiobook-curator previously needed a second - * bundler config, a tsconfig, and a hand-written bin shim to produce. + * becomes `dist/.js` (+ a bundleless `.d.ts` declaration graph), all + * through the same Rslib synthesis, invariant assertions, and staged atomic + * publication as artifact executables. This is the build audiobook-curator + * previously needed a second bundler config, a tsconfig, and a hand-written + * bin shim to produce. */ const binShebang = '#!/usr/bin/env node'; diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 6abf2c46e..ce3e9a3f9 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -1,13 +1,13 @@ -// Rslib re-exports its own rspack; installing @rspack/core separately risks -// version conflicts (https://rslib.rs/api/javascript-api/core). -import { mergeRsbuildConfig } from '@rsbuild/core'; -import { createRslib, rspack, type LibConfig } from '@rslib/core'; -import { readFile, realpath } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +// 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 { mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; import { isErrno } from '../core/errors.ts'; import type { AgentBundleToolsConfig } from '../core/types.ts'; +import { mcpEntryRuntimeSpecifier } from './entry-shell.ts'; import { collectBundledOutputEvidence, type BundledOutputEvidence } from './provenance.ts'; export interface RslibVirtualModule { @@ -16,7 +16,12 @@ export interface RslibVirtualModule { } export interface RslibEntry { - /** Module specifiers aliased onto existing on-disk modules (e.g. the mcp-entry runtime shell). */ + /** + * Module specifiers aliased onto existing on-disk modules (e.g. the + * mcp-entry runtime shell). Applied as exact-match (`$`) bundler aliases — + * generated code imports exactly these specifiers — and reserved: the + * consumer tools hatch may not externalize them. + */ readonly aliases?: Readonly>; /** Raw JS banner prepended to the emitted bundle (e.g. a bin shebang). */ readonly banner?: string; @@ -34,12 +39,126 @@ export interface RslibEntry { type RslibInstance = Awaited>; type RslibLibConfig = LibConfig; +type RslibToolsRspack = NonNullable['rspack']>; interface RslibDependencies { readonly createRslib?: (options: Parameters[0]) => Promise>; } -const entryAnchor = fileURLToPath(import.meta.url); +/** + * 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. + */ +const generatedModulesDirname = '.agent-bundle-virtual'; + +/** + * 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 + * Rsbuild/Rspack copies nested in `@rslib/core` (the dual-engine reality + * documented on {@link AgentBundleToolsConfig}). These two functions are the + * single deliberate crossing between those type universes; everything else + * in this module stays inside Rslib's own types. + */ +const asRslibEnvironmentFragment = ( + fragment: NonNullable, +): Omit => fragment as Omit; +const asRslibRspackHatch = ( + hatch: NonNullable, +): RslibToolsRspack => hatch as RslibToolsRspack; + +const entryLibId = (entry: Pick): string => `agent-bundle-${entry.name}`; + +// join (not resolve) so `inspect --bundler`'s tokenized output roots +// (`/`) stay tokens instead of resolving against the cwd. +const generatedEntryModulePath = (outputRoot: string, entry: RslibEntry): string => + join(outputRoot, generatedModulesDirname, `${entry.name}-entry.mjs`); + +const materializedVirtualModules = ( + outputRoot: string, + entry: RslibEntry, +): readonly { readonly name: string; readonly path: string; readonly source: string }[] => + (entry.virtualModules ?? []).map((module, index) => ({ + ...module, + 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 + * runtime specifier itself (public API even for hand-rolled entries). A + * consumer tools hatch externalizing any of these would break the + * self-contained artifact contract. + */ +const reservedSpecifiers = (entry: RslibEntry): readonly string[] => Object.freeze([...new Set([ + mcpEntryRuntimeSpecifier, + ...Object.keys(entry.aliases ?? {}), + ...(entry.virtualModules ?? []).map((module) => module.name), +])]); + +/** + * Finds a reserved specifier that a statically inspectable `externals` value + * (string, RegExp, object map, or arrays thereof) would externalize. + * Function externals cannot be inspected here; the post-build + * residual-import scan fails closed for those. + */ +const reservedExternalsViolation = (externals: unknown, reserved: readonly string[]): string | undefined => { + if (externals === undefined || externals === null) return undefined; + if (Array.isArray(externals)) { + for (const item of externals) { + const violation = reservedExternalsViolation(item, reserved); + if (violation !== undefined) return violation; + } + return undefined; + } + if (typeof externals === 'string') return reserved.includes(externals) ? externals : undefined; + if (externals instanceof RegExp) return reserved.find((specifier) => externals.test(specifier)); + if (typeof externals === 'object') { + return Object.keys(externals).find((key) => reserved.includes(key)); + } + return undefined; +}; + +const escapeForRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + +const residualReservedImportPattern = (reserved: readonly string[]): RegExp => + new RegExp(`(?:\\bfrom|\\bimport|\\brequire)\\s*\\(?\\s*(["'])(?:${reserved.map(escapeForRegExp).join('|')})\\1`, 'u'); + +/** + * Fail-closed self-containment check on the emitted bundles themselves: + * no reserved specifier may survive bundling as a live import. This catches + * externalization paths the static invariant cannot see (function-form + * `externals` from the consumer tools hatch). + */ +const assertNoResidualReservedImports = async ( + entries: readonly RslibEntry[], + outputRoot: string, +): Promise => { + await Promise.all(entries.map(async (entry) => { + const pattern = residualReservedImportPattern(reservedSpecifiers(entry)); + const bundle = await readFile(resolve(outputRoot, entry.outputRelativePath), 'utf8'); + if (pattern.test(bundle)) { + throw new Error( + `Generated executable ${JSON.stringify(entry.outputRelativePath)} is not self-contained: ` + + 'a reserved module specifier survived bundling. The tools escape hatch must not externalize ' + + `${reservedSpecifiers(entry).map((specifier) => JSON.stringify(specifier)).join(', ')}.`, + ); + } + })); +}; const declaredDependencyRoots = async (cwd: string): Promise => { let bytes: string; @@ -68,30 +187,67 @@ const declaredDependencyRoots = async (cwd: string): Promise return Object.freeze(roots.filter((root): root is string => root !== undefined)); }; +interface InspectedBundlerConfig { + readonly externals?: unknown; + readonly name?: string; + readonly output?: { readonly asyncChunks?: boolean; readonly path?: string }; + readonly resolve?: { readonly alias?: unknown }; + readonly target?: false | string | readonly string[]; +} + +const aliasRecordOf = (config: InspectedBundlerConfig): Readonly> | undefined => { + const alias = config.resolve?.alias; + return typeof alias === 'object' && alias !== null ? alias as Readonly> : undefined; +}; + const assertExecutableConfig = ( entries: readonly RslibEntry[], - configs: readonly { readonly output?: { readonly asyncChunks?: boolean; readonly path?: string }; readonly plugins?: readonly unknown[]; readonly target?: false | string | readonly string[] }[], + inspection: { + readonly bundlerConfigs: readonly InspectedBundlerConfig[]; + readonly environmentConfigs: Readonly>; + }, outputRoot: string, ): void => { - if (configs.length !== entries.length) { + if ( + inspection.bundlerConfigs.length !== entries.length || + Object.keys(inspection.environmentConfigs).length !== entries.length + ) { throw new Error('Rslib did not resolve one environment for every generated executable.'); } - for (const [index, config] of configs.entries()) { - const entry = entries[index]!; + for (const entry of entries) { + const id = entryLibId(entry); + if (inspection.environmentConfigs[id] === undefined) { + throw new Error('Rslib did not resolve one environment for every generated executable.'); + } + // Rslib documents lib.id as the generated environment key and names each + // Rspack config after it; array position carries no documented meaning. + const matches = inspection.bundlerConfigs.filter((config) => config.name === id); + if (matches.length !== 1) { + throw new Error('Rslib did not resolve one environment for every generated executable.'); + } + const config = matches[0]!; const target = Array.isArray(config.target) ? config.target : [config.target]; if (config.output?.asyncChunks !== false || config.output.path !== outputRoot || !target.some((value) => value === 'node')) { throw new Error('Rslib resolved an invalid generated executable configuration.'); } - const entryHasVirtualModules = - entry.virtualSource !== undefined || (entry.virtualModules?.length ?? 0) > 0; - if (entryHasVirtualModules) { - const hasVirtualModule = config.plugins?.some( - (plugin) => plugin instanceof rspack.experiments.VirtualModulesPlugin, - ); - if (!hasVirtualModule) { - throw new Error('Rslib resolved a generated executable environment without its virtual module.'); + const expectedAliases = { + ...entry.aliases, + ...Object.fromEntries(materializedVirtualModules(outputRoot, entry) + .map((module) => [module.name, module.path])), + }; + const alias = aliasRecordOf(config); + for (const [name, moduleTarget] of Object.entries(expectedAliases)) { + if (alias?.[`${name}$`] !== moduleTarget) { + throw new Error('Rslib resolved a generated executable environment without its reserved module aliases.'); } } + const violation = reservedExternalsViolation(config.externals, reservedSpecifiers(entry)); + if (violation !== undefined) { + throw new Error( + `The tools escape hatch must not externalize the reserved specifier ${JSON.stringify(violation)}; ` + + 'generated executables stay self-contained.', + ); + } } }; @@ -99,42 +255,45 @@ const assertExecutableConfig = ( * Composes the full Rslib lib config for one synthesized entry: the * framework profile, the consumer `tools` escape hatch merged over it * (Rslib's "raw user config highest" priority), and the invariant enforcer - * hook appended last. `buildWithRslib` lowers exactly this composition and - * `inspect --bundler` surfaces it, so the two can never drift. + * hook appended last, all composed with Rslib's own `mergeRslibConfig` + * keyed by the synthesized lib id. `buildWithRslib` lowers exactly this + * composition and `inspect --bundler` surfaces it, so the two can never + * drift. */ export const composeEntryLibConfig = ( entry: RslibEntry, options: { readonly outputRoot: string; readonly tools?: AgentBundleToolsConfig }, ): LibConfig => { + const libId = entryLibId(entry); const virtualSource = entry.virtualSource; - const virtualModules = (entry.virtualModules ?? []).map((module, index) => ({ - ...module, - path: resolve(options.outputRoot, '.agent-bundle-virtual', `${entry.name}-${index}.mjs`), - })); - const hasVirtualModules = virtualSource !== undefined || virtualModules.length > 0; + const virtualModules = materializedVirtualModules(options.outputRoot, entry); const aliases = entry.aliases ?? {}; - const enforceInvariants = (config: { - output: { asyncChunks?: boolean }; - plugins: unknown[]; - resolve: { alias?: Record }; - }): void => { - config.output.asyncChunks = false; - if (hasVirtualModules || Object.keys(aliases).length > 0) { - config.resolve.alias = { - ...config.resolve.alias, - ...aliases, - ...Object.fromEntries(virtualModules.map((module) => [module.name, module.path])), + const reserved = reservedSpecifiers(entry); + const enforceInvariants = (config: Rspack.Configuration): Rspack.Configuration => { + config.output = { ...config.output, asyncChunks: false }; + if (virtualModules.length > 0 || Object.keys(aliases).length > 0) { + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve?.alias, + // Exact-match ($) keys per the resolve.alias contract: generated + // code imports exactly these specifiers, never subpaths beneath. + ...Object.fromEntries(Object.entries(aliases).map(([name, target]) => [`${name}$`, target])), + ...Object.fromEntries(virtualModules.map((module) => [`${module.name}$`, module.path])), + }, }; } - if (hasVirtualModules) { - config.plugins.push(new rspack.experiments.VirtualModulesPlugin({ - ...(virtualSource === undefined ? {} : { [entryAnchor]: virtualSource }), - ...Object.fromEntries(virtualModules.map((module) => [module.path, module.source])), - })); + const violation = reservedExternalsViolation(config.externals, reserved); + if (violation !== undefined) { + throw new Error( + `The tools escape hatch must not externalize the reserved specifier ${JSON.stringify(violation)}; ` + + 'generated executables stay self-contained.', + ); } + return config; }; const profile: RslibLibConfig = { - id: `agent-bundle-${entry.name}`, + id: libId, autoExternal: false, ...(entry.banner === undefined ? {} : { banner: { js: entry.banner } }), bundle: true, @@ -162,17 +321,26 @@ export const composeEntryLibConfig = ( }, source: { entry: { - [entry.name]: virtualSource === undefined ? entry.source : entryAnchor, + [entry.name]: virtualSource === undefined ? entry.source : generatedEntryModulePath(options.outputRoot, entry), }, ...(entry.tsconfigPath === undefined ? {} : { tsconfigPath: entry.tsconfigPath }), }, }; - return mergeRsbuildConfig( - profile as never, - ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild as never]), - ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } } as never]), - { tools: { rspack: enforceInvariants } } as never, - ) as RslibLibConfig; + const merged = mergeRslibConfig( + { lib: [profile] }, + options.tools?.rsbuild === undefined + ? undefined + : { lib: [{ ...asRslibEnvironmentFragment(options.tools.rsbuild), id: libId }] }, + options.tools?.rspack === undefined + ? undefined + : { lib: [{ id: libId, tools: { rspack: asRslibRspackHatch(options.tools.rspack) } }] }, + { lib: [{ id: libId, tools: { rspack: enforceInvariants } }] }, + ); + const lib = merged.lib?.[0]; + if (merged.lib?.length !== 1 || lib === undefined) { + throw new Error(`Rslib config composition did not merge one lib entry for ${JSON.stringify(libId)}.`); + } + return lib; }; export const buildWithRslib = async (options: { @@ -191,37 +359,50 @@ export const buildWithRslib = async (options: { } const dependencyRoots = await declaredDependencyRoots(options.cwd); - const rslib = await (dependencies.createRslib ?? createRslib)({ - cwd: options.cwd, - config: { - logLevel: options.logLevel ?? 'silent', - lib: options.entries.map((entry) => composeEntryLibConfig(entry, { - outputRoot: options.outputRoot, - ...(options.tools === undefined ? {} : { tools: options.tools }), - })), - }, - }); - - const inspection = await rslib.inspectConfig(); - assertExecutableConfig(options.entries, inspection.origin.bundlerConfigs, options.outputRoot); - let result: Awaited> | undefined; + // 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)); + let result: Awaited> | undefined; try { + if (generatedModules.length > 0) { + await mkdir(generatedModulesRoot, { recursive: true }); + await Promise.all(generatedModules.map((module) => writeFile(module.path, module.source, 'utf8'))); + } + + const rslib = await (dependencies.createRslib ?? createRslib)({ + cwd: options.cwd, + config: { + logLevel: options.logLevel ?? 'silent', + lib: options.entries.map((entry) => composeEntryLibConfig(entry, { + outputRoot: options.outputRoot, + ...(options.tools === undefined ? {} : { tools: options.tools }), + })), + }, + }); + + const inspection = await rslib.inspectConfig(); + assertExecutableConfig(options.entries, inspection.origin, options.outputRoot); result = await rslib.build(); - return collectBundledOutputEvidence({ + const evidence = collectBundledOutputEvidence({ expectedAssets: options.entries.map((entry) => ({ path: entry.outputRelativePath, sourceInputs: entry.sourceInputs, })), ignoredSourcePaths: [ - entryAnchor, - resolve(options.outputRoot, '.agent-bundle-virtual'), + generatedModulesRoot, ...(options.ignoredSourcePaths ?? []), ...dependencyRoots, ], projectRoot: options.cwd, stats: result.stats, }); + await assertNoResidualReservedImports(options.entries, options.outputRoot); + return evidence; } finally { await result?.close(); + await rm(generatedModulesRoot, { force: true, recursive: true }); } }; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 14af61dbe..b12409140 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -117,7 +117,11 @@ export type AgentBundleBinConfig = false | Readonly ...`), which always hands the executing + * engine's own `rspack` object. */ export interface AgentBundleToolsConfig { /** Rsbuild environment-config fragment merged after the synthesized profile. */ diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 1f9b82147..9ea8d78b3 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -7,6 +7,7 @@ import { spawn } from 'node:child_process'; import { createJiti } from 'jiti'; import { build as buildArtifact, type BuildOptions as LowLevelBuildOptions, type BuildResult } from '../src/build/build.ts'; +import { buildWithRslib, type RslibEntry } from '../src/build/rslib.ts'; import { publishArtifact } from '../src/build/emit.ts'; import type { TargetHookContract } from '../src/adapters/hook-contract.ts'; import { parseArtifactManifest, serializeArtifactManifest } from '../src/build/manifest.ts'; @@ -1075,3 +1076,100 @@ it('restores the existing artifact when publication fails after backup', async ( await rm(root, { force: true, recursive: true }); } }); + +/** + * A minimal project exercising both reserved-specifier mechanisms of one + * generated executable: an alias onto an on-disk runtime module and a + * materialized generated registry module. + */ +const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; readonly root: string }> => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-reserved-specifiers-')); + const sourceRoot = join(root, 'src'); + await mkdir(sourceRoot, { recursive: true }); + await writeFile(join(root, 'package.json'), '{"type":"module"}\n'); + await writeFile(join(sourceRoot, 'shell.ts'), "export const marker = 'inlined-runtime-shell';\n"); + await writeFile(join(sourceRoot, 'entry.ts'), [ + "import { marker } from 'agent-bundle/mcp-entry';", + "import registry from 'agent-bundle/mcp-apps';", + 'console.log(marker, registry);', + '', + ].join('\n')); + return { + entry: { + aliases: { 'agent-bundle/mcp-entry': join(sourceRoot, 'shell.ts') }, + name: 'reserved-probe', + outputRelativePath: 'scripts/reserved-probe.mjs', + source: join(sourceRoot, 'entry.ts'), + sourceInputs: [join(sourceRoot, 'entry.ts')], + virtualModules: [{ name: 'agent-bundle/mcp-apps', source: "export default 'generated-registry';\n" }], + }, + root, + }; +}; + +it('inlines reserved specifiers through exact-match aliases and materialized generated modules', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + const evidence = await buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + }); + const bundle = await readFile(join(root, 'dist', 'scripts', 'reserved-probe.mjs'), 'utf8'); + expect(bundle).toContain('inlined-runtime-shell'); + expect(bundle).toContain('generated-registry'); + expect(bundle).not.toMatch(/from\s*["']agent-bundle\//u); + // Materialized generated modules never survive into the artifact and + // never count as authored source evidence. + await expect(readdir(join(root, 'dist', '.agent-bundle-virtual'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(evidence).toEqual([{ + path: 'scripts/reserved-probe.mjs', + sourceInputs: [join(root, 'src', 'entry.ts'), join(root, 'src', 'shell.ts')], + }]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); + +it('rejects a tools hatch that externalizes a reserved specifier statically', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + await expect(buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + tools: { rspack: { externals: { 'agent-bundle/mcp-entry': 'module agent-bundle/mcp-entry' } } }, + })).rejects.toThrow(/must not externalize the reserved specifier "agent-bundle\/mcp-entry"/u); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); + +it('rejects a tools hatch that externalizes a reserved specifier through function externals', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + await expect(buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + tools: { + // Function externals cannot be inspected statically, so this hostile + // hatch must be caught by the post-build residual-import scan. + rspack: (config) => { + config.externals = [ + ...(Array.isArray(config.externals) ? config.externals : config.externals === undefined ? [] : [config.externals]), + ({ request }, callback) => { + if (request === 'agent-bundle/mcp-apps') { + callback(undefined, `module ${request}`); + return; + } + callback(); + }, + ]; + }, + }, + })).rejects.toThrow(/not self-contained/u); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 4629cda13..65abdcc4c 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { rspack } from '@rslib/core'; import { expect, it, rs } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; @@ -136,6 +135,7 @@ const importPublishedHook = async (wrapper: string) => }); it('does not share a persistent Rslib cache between generated executables', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-cache-output-')); const createOptions: unknown[] = []; const rslib = { build: async () => ({ @@ -150,29 +150,37 @@ it('does not share a persistent Rslib cache between generated executables', asyn inspectConfig: async () => ({ origin: { bundlerConfigs: [{ - output: { asyncChunks: false, path: '/tmp/agent-bundle-rslib-cache-output' }, + name: 'agent-bundle-cache-probe', + output: { asyncChunks: false, path: outputRoot }, performance: { buildCache: false }, target: 'node', }], + environmentConfigs: { 'agent-bundle-cache-probe': {} }, }, }), }; - await buildWithRslib({ - cwd: '/tmp', - entries: [{ - name: 'cache-probe', - outputRelativePath: 'hooks/cache-probe.mjs', - source: '/tmp/hook.ts', - sourceInputs: ['/tmp/hook.ts'], - }], - outputRoot: '/tmp/agent-bundle-rslib-cache-output', - }, { - createRslib: async (options) => { - createOptions.push(options); - return rslib as never; - }, - }); + try { + await mkdir(join(outputRoot, 'hooks'), { recursive: true }); + await writeFile(join(outputRoot, 'hooks', 'cache-probe.mjs'), 'export default undefined;\n'); + await buildWithRslib({ + cwd: '/tmp', + entries: [{ + name: 'cache-probe', + outputRelativePath: 'hooks/cache-probe.mjs', + source: '/tmp/hook.ts', + sourceInputs: ['/tmp/hook.ts'], + }], + outputRoot, + }, { + createRslib: async (options) => { + createOptions.push(options); + return rslib as never; + }, + }); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } const [{ config }] = createOptions as [{ readonly config: { @@ -182,7 +190,8 @@ it('does not share a persistent Rslib cache between generated executables', asyn expect(config.lib[0].performance).toEqual({ buildCache: false }); }); -it('closes the Rslib build result after building a virtual hook entry', async () => { +it('closes the Rslib build result and removes materialized generated modules after building a virtual hook entry', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-close-output-')); const close = rs.fn(async () => undefined); const buildResult = { close, @@ -193,60 +202,86 @@ it('closes the Rslib build result after building a virtual hook entry', async () }), }, }; + let materializedEntry: string | undefined; const rslib = { - build: async () => buildResult, + build: async () => { + // The generated wrapper entry must exist as a real on-disk module for + // the duration of the build (no virtual-module plugin involved). + materializedEntry = await readFile( + join(outputRoot, '.agent-bundle-virtual', 'close-probe-entry.mjs'), + 'utf8', + ); + return buildResult; + }, inspectConfig: async () => ({ origin: { bundlerConfigs: [{ - output: { asyncChunks: false, path: '/tmp/agent-bundle-rslib-close-output' }, - plugins: [new rspack.experiments.VirtualModulesPlugin({})], + name: 'agent-bundle-close-probe', + output: { asyncChunks: false, path: outputRoot }, target: 'node', }], + environmentConfigs: { 'agent-bundle-close-probe': {} }, }, }), }; - await buildWithRslib({ - cwd: '/tmp', - entries: [{ - name: 'close-probe', - outputRelativePath: 'hooks/close-probe.mjs', - source: '/tmp/hook.ts', - sourceInputs: ['/tmp/hook.ts'], - virtualSource: 'export default undefined;', - }], - outputRoot: '/tmp/agent-bundle-rslib-close-output', - }, { createRslib: async () => rslib as never }); - - expect(close).toHaveBeenCalledOnce(); + try { + await mkdir(join(outputRoot, 'hooks'), { recursive: true }); + await writeFile(join(outputRoot, 'hooks', 'close-probe.mjs'), 'export default undefined;\n'); + await buildWithRslib({ + cwd: '/tmp', + entries: [{ + name: 'close-probe', + outputRelativePath: 'hooks/close-probe.mjs', + source: '/tmp/hook.ts', + sourceInputs: ['/tmp/hook.ts'], + virtualSource: 'export default undefined;', + }], + outputRoot, + }, { createRslib: async () => rslib as never }); + + expect(close).toHaveBeenCalledOnce(); + expect(materializedEntry).toBe('export default undefined;'); + // The reserved directory never survives into artifact listing. + await expect(readdir(join(outputRoot, '.agent-bundle-virtual'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } }); it('closes the Rslib build result when provenance stats are unavailable', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-close-error-output-')); const close = rs.fn(async () => undefined); const rslib = { build: async () => ({ close }), inspectConfig: async () => ({ origin: { bundlerConfigs: [{ - output: { asyncChunks: false, path: '/tmp/agent-bundle-rslib-close-error-output' }, + name: 'agent-bundle-close-error-probe', + output: { asyncChunks: false, path: outputRoot }, target: 'node', }], + environmentConfigs: { 'agent-bundle-close-error-probe': {} }, }, }), }; - await expect(buildWithRslib({ - cwd: '/tmp', - entries: [{ - name: 'close-error-probe', - outputRelativePath: 'hooks/close-error-probe.mjs', - source: '/tmp/hook.ts', - sourceInputs: ['/tmp/hook.ts'], - }], - outputRoot: '/tmp/agent-bundle-rslib-close-error-output', - }, { createRslib: async () => rslib as never })).rejects.toThrow(/stats/i); - - expect(close).toHaveBeenCalledOnce(); + try { + await expect(buildWithRslib({ + cwd: '/tmp', + entries: [{ + name: 'close-error-probe', + outputRelativePath: 'hooks/close-error-probe.mjs', + source: '/tmp/hook.ts', + sourceInputs: ['/tmp/hook.ts'], + }], + outputRoot, + }, { createRslib: async () => rslib as never })).rejects.toThrow(/stats/i); + + expect(close).toHaveBeenCalledOnce(); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } }); it('normalizes a shorthand session-start hook into a frozen stable record', async () => { diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts index 55b290920..57605011c 100644 --- a/packages/agent-bundle/tests/public-api.test.ts +++ b/packages/agent-bundle/tests/public-api.test.ts @@ -1,7 +1,8 @@ import { execFile as executeFile } from 'node:child_process'; import { access, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; @@ -212,15 +213,22 @@ it('keeps bundled config extension types in emitted root declarations', async () ); // The tools escape hatch types the Rsbuild environment-config surface, so // the declaration graph resolves the bundler packages exactly as - // installed consumers do (both are runtime dependencies of the package). + // installed consumers do. @rsbuild/core is a runtime dependency; the + // @rspack/core types it references resolve through it (Rslib guidance: + // never install @rspack/core directly), so the consumer fixture links + // the copy @rsbuild/core itself resolves. await symlink( join(agentBundleNodeModules, '@rsbuild'), join(consumerRoot, 'node_modules', '@rsbuild'), 'dir', ); + const requireFromRsbuildCore = createRequire( + join(agentBundleNodeModules, '@rsbuild', 'core', 'package.json'), + ); + await mkdir(join(consumerRoot, 'node_modules', '@rspack')); await symlink( - join(agentBundleNodeModules, '@rspack'), - join(consumerRoot, 'node_modules', '@rspack'), + dirname(requireFromRsbuildCore.resolve('@rspack/core/package.json')), + join(consumerRoot, 'node_modules', '@rspack', 'core'), 'dir', ); // The bundler-inspection surface types the composed Rslib lib config, so diff --git a/packages/workbench/tests/runtime-inspector.test.ts b/packages/workbench/tests/runtime-inspector.test.ts index 72b259f4d..7f0b420b4 100644 --- a/packages/workbench/tests/runtime-inspector.test.ts +++ b/packages/workbench/tests/runtime-inspector.test.ts @@ -71,8 +71,9 @@ describe('Runtime inspector', () => { entry: { 'runtime-inspector-fixture': entry }, }; config.output = { ...config.output, distPath: { root: output } }; - const rsbuild = await createRsbuild({ rsbuildConfig: config }); - await rsbuild.build(); + const rsbuild = await createRsbuild({ config }); + const buildResult = await rsbuild.build(); + await buildResult.close(); const { server, url } = await startStaticServer(output); const browser = await chromium.launch({ channel: 'chrome' }); try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1327241c2..3b5806197 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,9 +205,6 @@ importers: '@rslint/core': specifier: 0.8.2 version: 0.8.2(jiti@2.7.0) - '@rspack/core': - specifier: 2.2.1 - version: 2.2.1(@swc/helpers@0.5.23) '@rstackjs/load-config': specifier: 0.1.2 version: 0.1.2(jiti@2.7.0) From 741ddd1d6765b0899f25f5d51d88c17285f91928 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 08:25:23 +0000 Subject: [PATCH 2/6] docs(entry-conventions): dual-engine hatch reality, reserved-specifier protection, launch-time-only .env layer --- docs/entry-conventions.md | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 5fe60507f..03cfe5067 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -144,8 +144,26 @@ The hatch is bounded: the framework invariant hook runs after the consumer's `tools.rspack`, and the resolved-config assertions still run after the merge. A hatch value that breaks an artifact contract (async chunks, output roots, self-containment) fails the build with a hard diagnostic instead of silently -overriding the contract. The hatch customizes *how code compiles*, never -*what the artifact promises*. +overriding the contract. Reserved module specifiers are protected the same +way: a hatch that externalizes `agent-bundle/mcp-entry` or a generated +registry specifier (such as `agent-bundle/mcp-apps`) fails the build with a +hard diagnostic — at config inspection for statically visible `externals`, +and through a post-build scan of the emitted bundle for function-form +`externals` — because generated executables must stay self-contained. The +hatch customizes *how code compiles*, never *what the artifact promises*. + +The hatch executes under two different bundler engine copies. Artifact +scripts, MCP entries, hook wrappers, and the package build compile through +Rslib, which runs the Rsbuild/Rspack versions nested inside `@rslib/core` +(currently the 2.1.x line); MCP App views compile through the +workspace-pinned `@rsbuild/core` (currently 2.2.x), until Rslib catches up to +the Rsbuild 2.2 line. So a hatch must never construct plugins or run +`instanceof` checks against an imported `@rspack/core`: a class imported from +a separately installed `@rspack/core` has a different identity than whichever +engine executes the config. Use instead the utils argument Rslib/Rsbuild pass +to `tools.rspack` mutator functions — +`tools: { rspack: (config, { rspack }) => { ... } }` — which always hands the +engine's own `rspack` object. ### `agent-bundle inspect --bundler` @@ -211,12 +229,15 @@ temporary artifact is built first. The runner loads the project-root `.env` set by default — rsbuild's `loadEnv` conventions (`.env`, `.env.local`, `.env.`, `.env..local`, with -`--mode` selecting the variants), the same files `createRslib` reads for the -same consumers at build time — so operator credentials configured for the -plugin reach a bare `mcp run` without a wrapper script. `--env-file ` -(repeatable, Node's `--env-file` dialect, later files win) replaces the -conventional set with exactly the named files, and `--no-env` skips the layer -entirely; a named file that cannot be read is an error, never a silent skip. +`--mode` selecting the variants) — so operator credentials configured for the +plugin reach a bare `mcp run` without a wrapper script. This is a +launch-time-only layer: the framework's programmatic Rslib builds never pass +`loadEnv` to `createRslib`, so `agent-bundle build` and the package build read +no `.env` file at all, and nothing from `.env` can leak into a compiled +artifact. `--env-file ` (repeatable, Node's `--env-file` dialect, later +files win) replaces the conventional set with exactly the named files, and +`--no-env` skips the layer entirely; a named file that cannot be read is an +error, never a silent skip. The child environment is composed from three layers. This table is the canonical precedence order (highest wins): From 86f7d68c76f178d9fc2f83e0a144af1d5702cd31 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 09:07:29 +0000 Subject: [PATCH 3/6] fix(build): close review gaps in the reserved-specifier invariant Guard function-form externals at build time (recording violations so the generic bundler failure surfaces as the actionable diagnostic), reject consumer aliases that would shadow reserved specifiers ahead of the framework's exact-match keys, treat object externals with value false as opt-outs, keep materialized-module cleanup running even when close() rejects, and realpath the pnpm symlink before resolving @rspack/core in the packed declaration test. Adds regression coverage: remapped function externals, alias shadowing, benign non-reserved externals, and a residual-import scan probe. --- packages/agent-bundle/src/build/rslib.ts | 152 ++++++++++++++++-- packages/agent-bundle/tests/build.test.ts | 29 +++- packages/agent-bundle/tests/hooks.test.ts | 45 ++++++ .../agent-bundle/tests/public-api.test.ts | 7 +- 4 files changed, 210 insertions(+), 23 deletions(-) diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index ce3e9a3f9..171a11a8c 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -109,11 +109,18 @@ const reservedSpecifiers = (entry: RslibEntry): readonly string[] => Object.free ...(entry.virtualModules ?? []).map((module) => module.name), ])]); +const reservedExternalError = (specifier: string): Error => new Error( + `The tools escape hatch must not externalize the reserved specifier ${JSON.stringify(specifier)}; ` + + 'generated executables stay self-contained.', +); + /** * Finds a reserved specifier that a statically inspectable `externals` value - * (string, RegExp, object map, or arrays thereof) would externalize. - * Function externals cannot be inspected here; the post-build - * residual-import scan fails closed for those. + * (string, RegExp, object map, or arrays thereof) would externalize. An + * object entry whose value is `false` explicitly opts out of + * externalization, so it is not a violation. Function externals cannot be + * inspected here; {@link guardReservedExternals} intercepts those at build + * time and the post-build residual-import scan fails closed behind both. */ const reservedExternalsViolation = (externals: unknown, reserved: readonly string[]): string | undefined => { if (externals === undefined || externals === null) return undefined; @@ -127,11 +134,81 @@ const reservedExternalsViolation = (externals: unknown, reserved: readonly strin if (typeof externals === 'string') return reserved.includes(externals) ? externals : undefined; if (externals instanceof RegExp) return reserved.find((specifier) => externals.test(specifier)); if (typeof externals === 'object') { - return Object.keys(externals).find((key) => reserved.includes(key)); + return Object.entries(externals).find(([key, value]) => reserved.includes(key) && value !== false)?.[0]; } return undefined; }; +/** A function external's non-result: not externalized, resolution continues. */ +const isExternalizedResult = (result: unknown): boolean => result !== undefined && result !== false; + +/** + * Wraps every function-form external so that resolving a reserved specifier + * as external fails the build instead of silently breaking the + * self-contained artifact. Merely consulting the function for a reserved + * request stays legal (the engine consults every external for every + * request); only a positive externalization is a violation. Both the + * callback and the promise calling conventions are preserved, including the + * arity the engine uses to distinguish them. Violations are also reported + * through `onViolation`, because an error delivered inside the external + * factory surfaces only as a generic bundler failure — the caller uses the + * report to raise the actionable diagnostic. + */ +const guardReservedExternals = ( + externals: unknown, + reserved: readonly string[], + onViolation: (specifier: string) => void, +): unknown => { + if (Array.isArray(externals)) return externals.map((item) => guardReservedExternals(item, reserved, onViolation)); + if (typeof externals !== 'function') return externals; + const external = externals as ( + data: { readonly request?: string }, + callback?: (error?: Error | null, result?: unknown, type?: string) => void, + ) => unknown; + const reservedRequestOf = (data: { readonly request?: string }): string | undefined => + typeof data.request === 'string' && reserved.includes(data.request) ? data.request : undefined; + if (external.length <= 1) { + return async (data: { readonly request?: string }): Promise => { + const result = await external(data); + const request = reservedRequestOf(data); + if (request !== undefined && isExternalizedResult(result)) { + onViolation(request); + throw reservedExternalError(request); + } + return result; + }; + } + return ( + data: { readonly request?: string }, + callback: (error?: Error | null, result?: unknown, type?: string) => void, + ): unknown => external(data, (error, result, type) => { + const request = reservedRequestOf(data); + if ((error === undefined || error === null) && request !== undefined && isExternalizedResult(result)) { + onViolation(request); + callback(reservedExternalError(request)); + return; + } + callback(error, result, type); + }); +}; + +/** + * Finds an alias key (from rslib defaults or the consumer hatch) that could + * capture a reserved specifier: an exact key for the specifier, its + * exact-match (`$`) form, or a prefix key covering it. The framework's own + * reserved aliases are exempted by the caller. + */ +const reservedAliasViolation = ( + alias: Readonly> | undefined, + reserved: readonly string[], + frameworkKeys: ReadonlySet, +): string | undefined => Object.keys(alias ?? {}).find((key) => { + if (frameworkKeys.has(key)) return false; + const exact = key.endsWith('$'); + const base = exact ? key.slice(0, -1) : key; + return reserved.some((specifier) => specifier === base || (!exact && specifier.startsWith(`${base}/`))); +}); + const escapeForRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); const residualReservedImportPattern = (reserved: readonly string[]): RegExp => @@ -236,18 +313,22 @@ const assertExecutableConfig = ( .map((module) => [module.name, module.path])), }; const alias = aliasRecordOf(config); + const frameworkAliasKeys = new Set(Object.keys(expectedAliases).map((name) => `${name}$`)); for (const [name, moduleTarget] of Object.entries(expectedAliases)) { if (alias?.[`${name}$`] !== moduleTarget) { throw new Error('Rslib resolved a generated executable environment without its reserved module aliases.'); } } - const violation = reservedExternalsViolation(config.externals, reservedSpecifiers(entry)); - if (violation !== undefined) { + const reserved = reservedSpecifiers(entry); + const aliasViolation = reservedAliasViolation(alias, reserved, frameworkAliasKeys); + if (aliasViolation !== undefined) { throw new Error( - `The tools escape hatch must not externalize the reserved specifier ${JSON.stringify(violation)}; ` - + 'generated executables stay self-contained.', + `The tools escape hatch must not alias the reserved specifier matched by ${JSON.stringify(aliasViolation)}; ` + + 'generated executables resolve reserved modules through the framework aliases.', ); } + const violation = reservedExternalsViolation(config.externals, reserved); + if (violation !== undefined) throw reservedExternalError(violation); } }; @@ -262,15 +343,35 @@ const assertExecutableConfig = ( */ export const composeEntryLibConfig = ( entry: RslibEntry, - options: { readonly outputRoot: string; readonly tools?: AgentBundleToolsConfig }, + options: { + /** Receives reserved specifiers that a function-form external resolved at build time. */ + readonly onReservedExternal?: (specifier: string) => void; + readonly outputRoot: string; + readonly tools?: AgentBundleToolsConfig; + }, ): LibConfig => { const libId = entryLibId(entry); const virtualSource = entry.virtualSource; const virtualModules = materializedVirtualModules(options.outputRoot, entry); const aliases = entry.aliases ?? {}; const reserved = reservedSpecifiers(entry); + const frameworkAliasKeys = new Set([ + ...Object.keys(aliases).map((name) => `${name}$`), + ...virtualModules.map((module) => `${module.name}$`), + ]); const enforceInvariants = (config: Rspack.Configuration): Rspack.Configuration => { config.output = { ...config.output, asyncChunks: false }; + const aliasViolation = reservedAliasViolation( + config.resolve?.alias as Readonly> | undefined, + reserved, + frameworkAliasKeys, + ); + if (aliasViolation !== undefined) { + throw new Error( + `The tools escape hatch must not alias the reserved specifier matched by ${JSON.stringify(aliasViolation)}; ` + + 'generated executables resolve reserved modules through the framework aliases.', + ); + } if (virtualModules.length > 0 || Object.keys(aliases).length > 0) { config.resolve = { ...config.resolve, @@ -284,11 +385,15 @@ export const composeEntryLibConfig = ( }; } const violation = reservedExternalsViolation(config.externals, reserved); - if (violation !== undefined) { - throw new Error( - `The tools escape hatch must not externalize the reserved specifier ${JSON.stringify(violation)}; ` - + 'generated executables stay self-contained.', - ); + if (violation !== undefined) throw reservedExternalError(violation); + if (config.externals !== undefined) { + // Function externals resolve requests at build time, so they are + // guarded there rather than inspected here. + config.externals = guardReservedExternals( + config.externals, + reserved, + options.onReservedExternal ?? (() => undefined), + ) as typeof config.externals; } return config; }; @@ -372,11 +477,13 @@ export const buildWithRslib = async (options: { 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 }), })), @@ -385,7 +492,15 @@ export const buildWithRslib = async (options: { const inspection = await rslib.inspectConfig(); assertExecutableConfig(options.entries, inspection.origin, options.outputRoot); - result = await rslib.build(); + try { + result = await rslib.build(); + } catch (error) { + // A violation raised inside the external factory reaches here only as + // a generic bundler failure; surface the actionable diagnostic. + if (reservedExternalViolations.length > 0) throw reservedExternalError(reservedExternalViolations[0]!); + throw error; + } + if (reservedExternalViolations.length > 0) throw reservedExternalError(reservedExternalViolations[0]!); const evidence = collectBundledOutputEvidence({ expectedAssets: options.entries.map((entry) => ({ path: entry.outputRelativePath, @@ -402,7 +517,10 @@ export const buildWithRslib = async (options: { await assertNoResidualReservedImports(options.entries, options.outputRoot); return evidence; } finally { - await result?.close(); - await rm(generatedModulesRoot, { force: true, recursive: true }); + try { + await result?.close(); + } finally { + await rm(generatedModulesRoot, { force: true, recursive: true }); + } } }; diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 9ea8d78b3..fa819c273 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -1114,6 +1114,9 @@ it('inlines reserved specifiers through exact-match aliases and materialized gen cwd: root, entries: [entry], outputRoot: join(root, 'dist'), + // A hatch external naming a non-reserved module is legal: the + // invariant rejects reserved specifiers, not the externals mechanism. + tools: { rspack: { externals: { fsevents: 'node-commonjs fsevents' } } }, }); const bundle = await readFile(join(root, 'dist', 'scripts', 'reserved-probe.mjs'), 'utf8'); expect(bundle).toContain('inlined-runtime-shell'); @@ -1153,14 +1156,15 @@ it('rejects a tools hatch that externalizes a reserved specifier through functio entries: [entry], outputRoot: join(root, 'dist'), tools: { - // Function externals cannot be inspected statically, so this hostile - // hatch must be caught by the post-build residual-import scan. + // Function externals cannot be inspected statically; the build-time + // guard intercepts them. The remap to a bare variable leaves no + // reserved text in the output, so only the guard can catch it. rspack: (config) => { config.externals = [ ...(Array.isArray(config.externals) ? config.externals : config.externals === undefined ? [] : [config.externals]), ({ request }, callback) => { if (request === 'agent-bundle/mcp-apps') { - callback(undefined, `module ${request}`); + callback(undefined, 'var Registry'); return; } callback(); @@ -1168,7 +1172,24 @@ it('rejects a tools hatch that externalizes a reserved specifier through functio ]; }, }, - })).rejects.toThrow(/not self-contained/u); + })).rejects.toThrow(/must not externalize the reserved specifier "agent-bundle\/mcp-apps"/u); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); + +it('rejects a tools hatch alias that shadows a reserved specifier', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + await writeFile(join(root, 'src', 'evil.ts'), "export default 'shadowed-registry';\n"); + await expect(buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + // A plain (non-$) consumer alias for a reserved specifier would win + // over the framework's exact-match alias by insertion order. + tools: { rspack: { resolve: { alias: { 'agent-bundle/mcp-apps': join(root, 'src', 'evil.ts') } } } }, + })).rejects.toThrow(/must not alias the reserved specifier/u); } finally { await rm(root, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 65abdcc4c..e752a88e1 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -249,6 +249,51 @@ it('closes the Rslib build result and removes materialized generated modules aft } }); +it('fails closed when an emitted bundle retains a residual reserved import', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-residual-output-')); + const rslib = { + build: async () => ({ + close: async () => undefined, + stats: { + toJson: () => ({ + assets: [{ name: 'hooks/residual-probe.mjs' }], + modules: [], + }), + }, + }), + inspectConfig: async () => ({ + origin: { + bundlerConfigs: [{ + name: 'agent-bundle-residual-probe', + output: { asyncChunks: false, path: outputRoot }, + target: 'node', + }], + environmentConfigs: { 'agent-bundle-residual-probe': {} }, + }, + }), + }; + + try { + await mkdir(join(outputRoot, 'hooks'), { recursive: true }); + await writeFile( + join(outputRoot, 'hooks', 'residual-probe.mjs'), + 'import { runGeneratedStdioMcpEntry } from "agent-bundle/mcp-entry";\nawait runGeneratedStdioMcpEntry({});\n', + ); + await expect(buildWithRslib({ + cwd: '/tmp', + entries: [{ + name: 'residual-probe', + outputRelativePath: 'hooks/residual-probe.mjs', + source: '/tmp/hook.ts', + sourceInputs: ['/tmp/hook.ts'], + }], + outputRoot, + }, { createRslib: async () => rslib as never })).rejects.toThrow(/not self-contained/u); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } +}); + it('closes the Rslib build result when provenance stats are unavailable', async () => { const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-close-error-output-')); const close = rs.fn(async () => undefined); diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts index 57605011c..d02a80878 100644 --- a/packages/agent-bundle/tests/public-api.test.ts +++ b/packages/agent-bundle/tests/public-api.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { access, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -222,8 +222,11 @@ it('keeps bundled config extension types in emitted root declarations', async () join(consumerRoot, 'node_modules', '@rsbuild'), 'dir', ); + // realpath first: under pnpm the @rsbuild/core entry is a symlink into + // the virtual store, and plain-Node resolution walks the literal path + // (where @rspack/core is not visible) rather than the store. const requireFromRsbuildCore = createRequire( - join(agentBundleNodeModules, '@rsbuild', 'core', 'package.json'), + join(await realpath(join(agentBundleNodeModules, '@rsbuild', 'core')), 'package.json'), ); await mkdir(join(consumerRoot, 'node_modules', '@rspack')); await symlink( From abb57676c74720872767673b4d7eaf0d9715c37b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 09:41:22 +0000 Subject: [PATCH 4/6] fix(build): parse emitted bundles for residual reserved imports; align dev-invocation generation waits Replace the text-pattern residual-import scan with an es-module-lexer parse of each emitted bundle (the emitted format is ESM by contract), so string literals or comments mentioning a reserved specifier can no longer fail a legitimate build; a bundle that does not parse as ESM fails closed. Also give the three default-budget "generation two" waits in the RSC dev-invocation suite the same 15s budget every sibling generation wait in that file already uses. --- .../tests/dev-invocation.integration.test.ts | 6 ++-- packages/agent-bundle/src/build/rslib.ts | 35 ++++++++++++------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts index 139fc2bf4..64080fc33 100644 --- a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts @@ -1211,7 +1211,7 @@ setInterval(() => undefined, 1_000); const workerSource = join(copied.projectRoot, 'src', 'rsc', 'worker.tsx'); const source = await readFile(workerSource, 'utf8'); await writeFile(workerSource, source.replace('RSC worker received an invalid event', 'RSC worker received an invalid event generation-two')); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two', 15_000); const g2 = session.status().activeVector!.runtimeGenerationId; await writeFile(g1Worker, originalG1Worker); @@ -1262,7 +1262,7 @@ test('replays an exact historical surface after generation two removes it', asyn const definition = join(copied.projectRoot, 'src', 'definition.ts'); const source = await readFile(definition, 'utf8'); await writeFile(definition, source.replace(" host: 'claude',", " host: 'codex',")); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two', 15_000); const g2 = session.status().activeVector!.runtimeGenerationId; await expect(session.replay({ expectedGenerationId: g1, mode: 'exact', runId: run.id })) @@ -1299,7 +1299,7 @@ test('releases an exact historical lease when four active workers reject its adm const definition = join(copied.projectRoot, 'src', 'definition.ts'); await appendFile(definition, '\n// exact-lease-capacity-g2\n'); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two', 15_000); const g2 = session.status().activeVector!.runtimeGenerationId; const marker = join(storageRoot, 'blocked-exact-lease-workers.txt'); const worker = join(storageRoot, 'generation-store', 'generations', g2, 'rsc', 'rsc', 'index.js'); diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 171a11a8c..9f6423536 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -2,6 +2,7 @@ // 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 { init, parse } from 'es-module-lexer'; import { mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; @@ -209,29 +210,39 @@ const reservedAliasViolation = ( return reserved.some((specifier) => specifier === base || (!exact && specifier.startsWith(`${base}/`))); }); -const escapeForRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); - -const residualReservedImportPattern = (reserved: readonly string[]): RegExp => - new RegExp(`(?:\\bfrom|\\bimport|\\brequire)\\s*\\(?\\s*(["'])(?:${reserved.map(escapeForRegExp).join('|')})\\1`, 'u'); - /** * Fail-closed self-containment check on the emitted bundles themselves: - * no reserved specifier may survive bundling as a live import. This catches - * externalization paths the static invariant cannot see (function-form - * `externals` from the consumer tools hatch). + * no reserved specifier may survive bundling as a live import. This is the + * belt behind the static externals check and the function-external guard. + * The bundle is parsed as an ES module (the emitted format by contract), so + * string literals or comments that merely mention a reserved specifier are + * not violations. */ const assertNoResidualReservedImports = async ( entries: readonly RslibEntry[], outputRoot: string, ): Promise => { + await init; await Promise.all(entries.map(async (entry) => { - const pattern = residualReservedImportPattern(reservedSpecifiers(entry)); + const reserved = reservedSpecifiers(entry); const bundle = await readFile(resolve(outputRoot, entry.outputRelativePath), 'utf8'); - if (pattern.test(bundle)) { + // A bin banner shebang is legal for Node but not for the ESM lexer. + const source = bundle.startsWith('#!') ? bundle.slice(bundle.indexOf('\n') + 1) : bundle; + let imports: ReturnType[0]; + try { + [imports] = parse(source); + } catch { + throw new Error(`Generated executable ${JSON.stringify(entry.outputRelativePath)} did not parse as an ES module.`); + } + const residual = imports + .map((record) => record.n) + .find((specifier) => specifier !== undefined && reserved.includes(specifier)); + if (residual !== undefined) { throw new Error( `Generated executable ${JSON.stringify(entry.outputRelativePath)} is not self-contained: ` - + 'a reserved module specifier survived bundling. The tools escape hatch must not externalize ' - + `${reservedSpecifiers(entry).map((specifier) => JSON.stringify(specifier)).join(', ')}.`, + + `the reserved module specifier ${JSON.stringify(residual)} survived bundling. ` + + 'The tools escape hatch must not externalize ' + + `${reserved.map((specifier) => JSON.stringify(specifier)).join(', ')}.`, ); } })); From be119faa5753d35b25727c0dfac183c3f79a8f4c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 10:24:19 +0000 Subject: [PATCH 5/6] test(build): prove the residual-import scan tolerates a mentioned reserved specifier The self-containment scan parses each emitted bundle as an ES module rather than grepping it, so a reserved specifier that only appears as a string literal is not a violation. Nothing exercised that distinction. --- packages/agent-bundle/tests/build.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index fa819c273..7e7f59a24 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -1091,7 +1091,11 @@ const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; await writeFile(join(sourceRoot, 'entry.ts'), [ "import { marker } from 'agent-bundle/mcp-entry';", "import registry from 'agent-bundle/mcp-apps';", - 'console.log(marker, registry);', + // A reserved specifier mentioned as data, not imported: the residual-import + // scan parses the emitted bundle instead of grepping it, so this survives + // into the output without failing the self-containment check. + "const mentioned = 'agent-bundle/mcp-entry';", + 'console.log(marker, registry, mentioned);', '', ].join('\n')); return { @@ -1122,6 +1126,9 @@ it('inlines reserved specifiers through exact-match aliases and materialized gen expect(bundle).toContain('inlined-runtime-shell'); expect(bundle).toContain('generated-registry'); expect(bundle).not.toMatch(/from\s*["']agent-bundle\//u); + // The scan tolerates a reserved specifier that is only mentioned as a + // string literal; only a live import fails the build. + expect(bundle).toContain('agent-bundle/mcp-entry'); // Materialized generated modules never survive into the artifact and // never count as authored source evidence. await expect(readdir(join(root, 'dist', '.agent-bundle-virtual'))).rejects.toMatchObject({ code: 'ENOENT' }); From a59bf03bd588f67bd3c2ee58f382a27d0775acef Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 10:33:09 +0000 Subject: [PATCH 6/6] fix(build): pin dist cleaning off across both hatch paths A consumer tools.rsbuild output.cleanDistPath override would delete the build's own materialized generated sources and any sibling entry already emitted into the shared staged root. The final merge fragment now pins cleanDistPath off on both the Rslib executable path and the MCP Apps path (the asyncChunks precedent: reserved knobs are pinned, not honored), and the resolved-config assertions verify the pin. Adds a regression test proving a hatch that asks to clean the root still gets live outputs. Co-authored-by: ScriptedAlchemy --- .changeset/build-path-conformance.md | 6 ++++++ packages/agent-bundle/src/build/mcp-apps.ts | 8 ++++++-- packages/agent-bundle/src/build/rslib.ts | 22 ++++++++++++++++++--- packages/agent-bundle/tests/build.test.ts | 21 ++++++++++++++++++++ packages/agent-bundle/tests/hooks.test.ts | 8 ++++---- 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/.changeset/build-path-conformance.md b/.changeset/build-path-conformance.md index cb7ab923f..afa20e9d0 100644 --- a/.changeset/build-path-conformance.md +++ b/.changeset/build-path-conformance.md @@ -22,6 +22,12 @@ surfaces (Rspack/Rslib/Rsbuild conformance audit). 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. - 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 diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 8c20aa8dc..225e17940 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -42,6 +42,8 @@ const assertResolvedViewConfig = ( } for (const environment of Object.values(environments)) { if ( + // Cleaning the shared staged target root would delete sibling outputs. + environment.output.cleanDistPath !== false || environment.output.filenameHash !== false || environment.output.inlineScripts !== true || environment.output.inlineStyles !== true || @@ -150,7 +152,6 @@ export const composeMcpAppsRsbuildConfig = ( logLevel: 'silent' as const, mode: 'production' as const, output: { - cleanDistPath: false, dataUriLimit: Number.MAX_SAFE_INTEGER, distPath: { html: 'mcp-apps', root: options.outDir }, filename: { css: '[name].css', html: '[name].html', js: '[name].js' }, @@ -172,7 +173,10 @@ export const composeMcpAppsRsbuildConfig = ( profile, ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild]), ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } }]), - { tools: { rspack: enforceInvariants } }, + // Merged last so the hatch cannot reach either invariant: dist cleaning + // would delete sibling outputs already emitted into the shared staged + // target root, so it stays off no matter what the consumer asks for. + { output: { cleanDistPath: false }, tools: { rspack: enforceInvariants } }, ); }; diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 9f6423536..2693d0ab3 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -288,6 +288,10 @@ const aliasRecordOf = (config: InspectedBundlerConfig): Readonly> : undefined; }; +interface InspectedEnvironmentConfig { + readonly output?: { readonly cleanDistPath?: unknown }; +} + const assertExecutableConfig = ( entries: readonly RslibEntry[], inspection: { @@ -304,9 +308,16 @@ const assertExecutableConfig = ( } for (const entry of entries) { const id = entryLibId(entry); - if (inspection.environmentConfigs[id] === undefined) { + const environment = inspection.environmentConfigs[id] as InspectedEnvironmentConfig | undefined; + 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. + if (environment.output?.cleanDistPath !== false) { + throw new Error('Rslib resolved a generated executable environment that would clean its own output root.'); + } // Rslib documents lib.id as the generated environment key and names each // Rspack config after it; array position carries no documented meaning. const matches = inspection.bundlerConfigs.filter((config) => config.name === id); @@ -426,7 +437,6 @@ export const composeEntryLibConfig = ( splitChunks: false, syntax: 'es2022', output: { - cleanDistPath: false, distPath: { root: options.outputRoot }, filename: { js: entry.outputRelativePath }, filenameHash: false, @@ -450,7 +460,13 @@ export const composeEntryLibConfig = ( options.tools?.rspack === undefined ? undefined : { lib: [{ id: libId, tools: { rspack: asRslibRspackHatch(options.tools.rspack) } }] }, - { lib: [{ id: libId, tools: { rspack: enforceInvariants } }] }, + // 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. + { lib: [{ id: libId, output: { cleanDistPath: false }, tools: { rspack: enforceInvariants } }] }, ); const lib = merged.lib?.[0]; if (merged.lib?.length !== 1 || lib === undefined) { diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 7e7f59a24..25ee2179b 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -1141,6 +1141,27 @@ it('inlines reserved specifiers through exact-match aliases and materialized gen } }, 20_000); +it('keeps materialized generated modules alive under a tools hatch that asks to clean the output root', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + // Dist cleaning runs when the build starts, after the generated wrapper + // and registry sources are written into that same tree, so an honored + // hatch would delete this build's own entry modules. Sibling entries + // already emitted into a shared staged root would go with them. + await buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + tools: { rsbuild: { output: { cleanDistPath: true } } }, + }); + const bundle = await readFile(join(root, 'dist', 'scripts', 'reserved-probe.mjs'), 'utf8'); + expect(bundle).toContain('inlined-runtime-shell'); + expect(bundle).toContain('generated-registry'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); + it('rejects a tools hatch that externalizes a reserved specifier statically', async () => { const { entry, root } = await reservedSpecifierProject(); try { diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index e752a88e1..7c7c2602e 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -155,7 +155,7 @@ it('does not share a persistent Rslib cache between generated executables', asyn performance: { buildCache: false }, target: 'node', }], - environmentConfigs: { 'agent-bundle-cache-probe': {} }, + environmentConfigs: { 'agent-bundle-cache-probe': { output: { cleanDistPath: false } } }, }, }), }; @@ -220,7 +220,7 @@ it('closes the Rslib build result and removes materialized generated modules aft output: { asyncChunks: false, path: outputRoot }, target: 'node', }], - environmentConfigs: { 'agent-bundle-close-probe': {} }, + environmentConfigs: { 'agent-bundle-close-probe': { output: { cleanDistPath: false } } }, }, }), }; @@ -268,7 +268,7 @@ it('fails closed when an emitted bundle retains a residual reserved import', asy output: { asyncChunks: false, path: outputRoot }, target: 'node', }], - environmentConfigs: { 'agent-bundle-residual-probe': {} }, + environmentConfigs: { 'agent-bundle-residual-probe': { output: { cleanDistPath: false } } }, }, }), }; @@ -306,7 +306,7 @@ it('closes the Rslib build result when provenance stats are unavailable', async output: { asyncChunks: false, path: outputRoot }, target: 'node', }], - environmentConfigs: { 'agent-bundle-close-error-probe': {} }, + environmentConfigs: { 'agent-bundle-close-error-probe': { output: { cleanDistPath: false } } }, }, }), };