From f30bcecec9503efee4ac9dd8b6a599ef02aaecfb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 11:39:00 +0000 Subject: [PATCH 1/3] refactor(build): serve generated entries through VirtualModulesPlugin at dedicated virtual paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture reversal of the generated-source transport from PR #79's on-disk materialization, preserving all its conformance invariants: generated wrappers and registry modules keep their deterministic paths under the reserved .agent-bundle-virtual/ namespace but are now guaranteed-nonexistent module ids served from memory. Rslib validates source.entry existence on the real filesystem, so the profile keys each entry on the authored program and the invariant hook redirects the lowered Rspack entry to the virtual wrapper path — no real path is ever shadowed (the old entryAnchor overlay stays dead). A narrow feature check turns an upstream plugin removal into an actionable diagnostic. --- .changeset/build-path-conformance.md | 26 ++-- .changeset/virtual-generated-entries.md | 17 ++ packages/agent-bundle/src/build/rslib.ts | 181 +++++++++++++++------- packages/agent-bundle/tests/build.test.ts | 34 ++-- packages/agent-bundle/tests/hooks.test.ts | 55 +++++-- 5 files changed, 220 insertions(+), 93 deletions(-) create mode 100644 .changeset/virtual-generated-entries.md diff --git a/.changeset/build-path-conformance.md b/.changeset/build-path-conformance.md index afa20e9d0..5ce5c5015 100644 --- a/.changeset/build-path-conformance.md +++ b/.changeset/build-path-conformance.md @@ -6,16 +6,15 @@ 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 + `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 @@ -23,11 +22,10 @@ surfaces (Rspack/Rslib/Rsbuild conformance audit). 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 diff --git a/.changeset/virtual-generated-entries.md b/.changeset/virtual-generated-entries.md new file mode 100644 index 000000000..ff9710fe6 --- /dev/null +++ b/.changeset/virtual-generated-entries.md @@ -0,0 +1,17 @@ +--- +"agent-bundle": patch +--- + +Serve generated wrapper entries and registry modules from memory through +Rspack's `experiments.VirtualModulesPlugin` instead of writing throwaway +files into the staged output root. Every generated module keeps its +deterministic path under the reserved `.agent-bundle-virtual/` namespace, +but the path is now guaranteed-nonexistent: builds no longer create or +delete scratch files inside the artifact tree, and the resolved bundler +config is asserted to retain both the plugin and the redirected wrapper +entry. A narrow feature check turns an upstream removal of the experimental +plugin into an actionable diagnostic. All self-containment invariants are +unchanged: reserved-specifier externals and alias shadowing are still +rejected, emitted bundles are still scanned for residual reserved imports, +and dist cleaning stays pinned off to protect sibling outputs in the shared +staged root. diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 2693d0ab3..3d45a48f7 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -1,9 +1,9 @@ // 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'; @@ -47,14 +47,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 @@ -72,12 +94,24 @@ const asRslibRspackHatch = ( 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. +// join (not resolve) so a tokenized output root (`/`) stays +// a token instead of resolving against the cwd. const generatedEntryModulePath = (outputRoot: string, entry: RslibEntry): string => join(outputRoot, generatedModulesDirname, `${entry.name}-entry.mjs`); -const materializedVirtualModules = ( +/** The import requests of one named entry in a lowered Rspack entry record. */ +const entryImportsOf = (entryRecord: unknown, name: string): readonly string[] => { + if (entryRecord === null || typeof entryRecord !== 'object' || Array.isArray(entryRecord)) return []; + const description = (entryRecord as Record)[name]; + if (typeof description === 'string') return [description]; + if (Array.isArray(description)) return description.filter((item): item is string => typeof item === 'string'); + if (description !== null && typeof description === 'object') { + return entryImportsOf({ [name]: (description as { readonly import?: unknown }).import }, name); + } + return []; +}; + +const virtualRegistryModules = ( outputRoot: string, entry: RslibEntry, ): readonly { readonly name: string; readonly path: string; readonly source: string }[] => @@ -86,15 +120,15 @@ const materializedVirtualModules = ( path: join(outputRoot, generatedModulesDirname, `${entry.name}-${index}.mjs`), })); -/** Every generated module one entry materializes to disk for its build. */ -const plannedGeneratedModules = ( +/** Every generated module one entry serves virtually during its build. */ +const plannedVirtualModules = ( 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 })), + ...virtualRegistryModules(outputRoot, entry).map(({ path, source }) => ({ path, source })), ]; /** @@ -276,9 +310,11 @@ const declaredDependencyRoots = async (cwd: string): Promise }; 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[]; } @@ -312,9 +348,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.'); } @@ -329,9 +366,22 @@ 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. + if (plannedVirtualModules(entry, outputRoot).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) + ...Object.fromEntries(virtualRegistryModules(outputRoot, entry) .map((module) => [module.name, module.path])), }; const alias = aliasRecordOf(config); @@ -374,7 +424,8 @@ export const composeEntryLibConfig = ( ): LibConfig => { const libId = entryLibId(entry); const virtualSource = entry.virtualSource; - const virtualModules = materializedVirtualModules(options.outputRoot, entry); + const virtualModules = virtualRegistryModules(options.outputRoot, entry); + const generatedModules = plannedVirtualModules(entry, options.outputRoot); const aliases = entry.aliases ?? {}; const reserved = reservedSpecifiers(entry); const frameworkAliasKeys = new Set([ @@ -406,6 +457,35 @@ 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 (lowered === undefined || typeof lowered !== 'object' || Array.isArray(lowered)) { + throw new Error('Rslib lowered a generated executable without a keyed entry record.'); + } + const description = (lowered as Record)[entry.name]; + const wrapperImport = [generatedEntryModulePath(options.outputRoot, entry)]; + config.entry = { + ...lowered, + [entry.name]: description !== null && typeof description === 'object' && !Array.isArray(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) { @@ -446,8 +526,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 }), }, @@ -461,11 +545,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]; @@ -491,34 +573,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> | 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) { @@ -534,7 +605,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, ], @@ -544,10 +617,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(); } }; diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 25ee2179b..93fc1eaa1 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -1078,9 +1078,10 @@ it('restores the existing artifact when publication fails after backup', async ( }); /** - * 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. + * A minimal project exercising every generated-source mechanism of one + * executable: a virtual wrapper entry (compiled from a guaranteed-nonexistent + * path under the reserved `.agent-bundle-virtual/` namespace), an alias onto + * an on-disk runtime module, and a virtual generated registry module. */ const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; readonly root: string }> => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-reserved-specifiers-')); @@ -1095,7 +1096,7 @@ const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; // 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);', + 'export const main = () => { console.log(marker, registry, mentioned); };', '', ].join('\n')); return { @@ -1106,12 +1107,17 @@ const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; source: join(sourceRoot, 'entry.ts'), sourceInputs: [join(sourceRoot, 'entry.ts')], virtualModules: [{ name: 'agent-bundle/mcp-apps', source: "export default 'generated-registry';\n" }], + virtualSource: [ + `import { main } from ${JSON.stringify(join(sourceRoot, 'entry.ts'))};`, + 'main();', + '', + ].join('\n'), }, root, }; }; -it('inlines reserved specifiers through exact-match aliases and materialized generated modules', async () => { +it('inlines reserved specifiers through exact-match aliases and virtual generated modules', async () => { const { entry, root } = await reservedSpecifierProject(); try { const evidence = await buildWithRslib({ @@ -1129,8 +1135,9 @@ it('inlines reserved specifiers through exact-match aliases and materialized gen // 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. + // The wrapper entry and registry module were served from memory at + // guaranteed-nonexistent paths: the reserved namespace never reaches the + // filesystem and never counts 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', @@ -1141,13 +1148,14 @@ 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 () => { +it('keeps sibling staged outputs 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. + // Scripts, MCP entries, hooks, and MCP Apps build sequentially into one + // shared staged root, so an honored cleanDistPath hatch would delete + // sibling outputs already emitted there. + await mkdir(join(root, 'dist'), { recursive: true }); + await writeFile(join(root, 'dist', 'sibling.mjs'), 'export default "already-emitted-sibling";\n'); await buildWithRslib({ cwd: root, entries: [entry], @@ -1157,6 +1165,8 @@ it('keeps materialized generated modules alive under a tools hatch that asks to const bundle = await readFile(join(root, 'dist', 'scripts', 'reserved-probe.mjs'), 'utf8'); expect(bundle).toContain('inlined-runtime-shell'); expect(bundle).toContain('generated-registry'); + await expect(readFile(join(root, 'dist', 'sibling.mjs'), 'utf8')) + .resolves.toContain('already-emitted-sibling'); } 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 7c7c2602e..40a066ef9 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { expect, it, rs } from '@rstest/core'; +import { rspack } from '@rslib/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; @@ -190,8 +191,9 @@ 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 and removes materialized generated modules after building a virtual hook entry', async () => { +it('closes the Rslib build result and serves the generated wrapper entry virtually without touching disk', async () => { const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-close-output-')); + const virtualEntryPath = join(outputRoot, '.agent-bundle-virtual', 'close-probe-entry.mjs'); const close = rs.fn(async () => undefined); const buildResult = { close, @@ -202,22 +204,23 @@ it('closes the Rslib build result and removes materialized generated modules aft }), }, }; - let materializedEntry: string | undefined; + let reservedNamespaceDuringBuild: unknown; + const createOptions: unknown[] = []; const rslib = { 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', - ); + // The generated wrapper entry is served from memory: its reserved + // namespace must not exist on disk even while the build is running. + reservedNamespaceDuringBuild = await readdir(join(outputRoot, '.agent-bundle-virtual')) + .then(() => undefined, (error: unknown) => error); return buildResult; }, inspectConfig: async () => ({ origin: { bundlerConfigs: [{ + entry: { 'close-probe': [virtualEntryPath] }, name: 'agent-bundle-close-probe', output: { asyncChunks: false, path: outputRoot }, + plugins: [new rspack.experiments.VirtualModulesPlugin({})], target: 'node', }], environmentConfigs: { 'agent-bundle-close-probe': { output: { cleanDistPath: false } } }, @@ -238,12 +241,42 @@ it('closes the Rslib build result and removes materialized generated modules aft virtualSource: 'export default undefined;', }], outputRoot, - }, { createRslib: async () => rslib as never }); + }, { + createRslib: async (options) => { + createOptions.push(options); + return rslib as never; + }, + }); expect(close).toHaveBeenCalledOnce(); - expect(materializedEntry).toBe('export default undefined;'); - // The reserved directory never survives into artifact listing. + expect(reservedNamespaceDuringBuild).toMatchObject({ code: 'ENOENT' }); await expect(readdir(join(outputRoot, '.agent-bundle-virtual'))).rejects.toMatchObject({ code: 'ENOENT' }); + + // The composed profile keys the entry on the authored program (Rslib + // checks entry existence on the real filesystem), and the invariant hook + // redirects the lowered Rspack entry to the guaranteed-nonexistent + // virtual path it registers with VirtualModulesPlugin. + const [{ config }] = createOptions as [{ + readonly config: { + readonly lib: readonly [{ + readonly source: { readonly entry: Readonly> }; + readonly tools?: { readonly rspack?: unknown }; + }]; + }; + }]; + expect(config.lib[0].source.entry).toEqual({ 'close-probe': '/tmp/hook.ts' }); + const hooksChain = config.lib[0].tools?.rspack; + const mutators = (Array.isArray(hooksChain) ? hooksChain : [hooksChain]) + .filter((mutator): mutator is (value: object) => object => typeof mutator === 'function'); + expect(mutators.length).toBeGreaterThan(0); + const resolved: { entry?: unknown; plugins?: readonly unknown[] } = { + entry: { 'close-probe': ['/tmp/hook.ts'] }, + }; + for (const mutator of mutators) mutator(resolved); + expect(resolved.entry).toEqual({ 'close-probe': [virtualEntryPath] }); + const virtualPlugins = (resolved.plugins ?? []) + .filter((plugin) => plugin instanceof rspack.experiments.VirtualModulesPlugin); + expect(virtualPlugins).toHaveLength(1); } finally { await rm(outputRoot, { force: true, recursive: true }); } From 8f8a6301993e9facf2841d34bfeda8e3597453df Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 11:59:36 +0000 Subject: [PATCH 2/3] test(build): prove invariant-hook ordering and fail-closed inspection; truthful changeset headline Sol review should-fixes: a hostile tools hatch that strips plugins and repoints the entry is overridden by the framework hook (merged last); resolved environments missing the virtual-module plugin or the wrapper entry fail closed before the build; the conformance changeset headline no longer claims fully documented surfaces now that the virtual-module transport deliberately rides one experimental plugin behind a feature check. --- .changeset/build-path-conformance.md | 6 ++- packages/agent-bundle/tests/build.test.ts | 30 +++++++++++++++ packages/agent-bundle/tests/hooks.test.ts | 47 +++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/.changeset/build-path-conformance.md b/.changeset/build-path-conformance.md index 5ce5c5015..6452d0847 100644 --- a/.changeset/build-path-conformance.md +++ b/.changeset/build-path-conformance.md @@ -2,8 +2,10 @@ "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) now live at diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 93fc1eaa1..241430a51 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -1109,6 +1109,10 @@ const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; virtualModules: [{ name: 'agent-bundle/mcp-apps', source: "export default 'generated-registry';\n" }], virtualSource: [ `import { main } from ${JSON.stringify(join(sourceRoot, 'entry.ts'))};`, + // A marker only the generated wrapper contains: its presence in the + // emitted bundle proves the wrapper (not the authored program) was + // the compilation root. + "console.log('generated-wrapper-marker');", 'main();', '', ].join('\n'), @@ -1131,6 +1135,7 @@ it('inlines reserved specifiers through exact-match aliases and virtual generate 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).toContain('generated-wrapper-marker'); 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. @@ -1172,6 +1177,31 @@ it('keeps sibling staged outputs alive under a tools hatch that asks to clean th } }, 20_000); +it('overrides a tools hatch that strips plugins and repoints the entry away from the generated wrapper', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + // The hatch mutator runs before the framework invariant hook, so it + // cannot strip the VirtualModulesPlugin (added afterwards) or keep the + // entry repointed at the authored program (redirected afterwards). + await buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + tools: { + rspack: (config) => { + config.plugins = []; + config.entry = { 'reserved-probe': [join(root, 'src', 'entry.ts')] }; + }, + }, + }); + const bundle = await readFile(join(root, 'dist', 'scripts', 'reserved-probe.mjs'), 'utf8'); + expect(bundle).toContain('generated-wrapper-marker'); + 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 40a066ef9..11f5151e0 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -282,6 +282,53 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual } }); +it('fails closed when the resolved environment lost its virtual modules or wrapper entry', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-lost-virtual-')); + const virtualEntryPath = join(outputRoot, '.agent-bundle-virtual', 'lost-probe-entry.mjs'); + const rslibFor = (bundlerConfig: Record) => ({ + build: async () => { + throw new Error('inspection must fail before the build starts'); + }, + inspectConfig: async () => ({ + origin: { + bundlerConfigs: [{ + name: 'agent-bundle-lost-probe', + output: { asyncChunks: false, path: outputRoot }, + target: 'node', + ...bundlerConfig, + }], + environmentConfigs: { 'agent-bundle-lost-probe': { output: { cleanDistPath: false } } }, + }, + }), + }); + const buildLostProbe = async (bundlerConfig: Record) => buildWithRslib({ + cwd: '/tmp', + entries: [{ + name: 'lost-probe', + outputRelativePath: 'hooks/lost-probe.mjs', + source: '/tmp/hook.ts', + sourceInputs: ['/tmp/hook.ts'], + virtualSource: 'export default undefined;', + }], + outputRoot, + }, { createRslib: async () => rslibFor(bundlerConfig) as never }); + + try { + // A resolved config without the plugin would resolve the + // guaranteed-nonexistent generated paths against the real filesystem. + await expect(buildLostProbe({ entry: { 'lost-probe': [virtualEntryPath] } })) + .rejects.toThrow(/without its virtual modules/u); + // A resolved config still keyed on the authored program would compile + // without the generated wrapper. + await expect(buildLostProbe({ + entry: { 'lost-probe': ['/tmp/hook.ts'] }, + plugins: [new rspack.experiments.VirtualModulesPlugin({})], + })).rejects.toThrow(/without its generated wrapper entry/u); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } +}); + 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 = { From 1c499e7177e87478ff7a1a68585ba590863060ad Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 16:40:11 +0000 Subject: [PATCH 3/3] refactor(build): simplify virtual-entry composition with shared narrowing Reuse the core isRecord guard for entry-record and descriptor narrowing (dropping two casts and the self-recursive entryImportsOf trick), inline the one-shot plannedVirtualModules helper at its composition site, hoist the per-entry registry-module computation in assertExecutableConfig, and drop the patch changeset that described replacing disk materialization no release ever shipped (the amended minor changeset already documents the virtual transport). --- .changeset/virtual-generated-entries.md | 17 --------- packages/agent-bundle/src/build/rslib.ts | 46 ++++++++++-------------- 2 files changed, 19 insertions(+), 44 deletions(-) delete mode 100644 .changeset/virtual-generated-entries.md diff --git a/.changeset/virtual-generated-entries.md b/.changeset/virtual-generated-entries.md deleted file mode 100644 index ff9710fe6..000000000 --- a/.changeset/virtual-generated-entries.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"agent-bundle": patch ---- - -Serve generated wrapper entries and registry modules from memory through -Rspack's `experiments.VirtualModulesPlugin` instead of writing throwaway -files into the staged output root. Every generated module keeps its -deterministic path under the reserved `.agent-bundle-virtual/` namespace, -but the path is now guaranteed-nonexistent: builds no longer create or -delete scratch files inside the artifact tree, and the resolved bundler -config is asserted to retain both the plugin and the redirected wrapper -entry. A narrow feature check turns an upstream removal of the experimental -plugin into an actionable diagnostic. All self-containment invariants are -unchanged: reserved-specifier externals and alias shadowing are still -rejected, emitted bundles are still scanned for residual reserved imports, -and dist cleaning stays pinned off to protect sibling outputs in the shared -staged root. diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 3d45a48f7..0e2fcc901 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -7,6 +7,7 @@ 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'; @@ -101,13 +102,10 @@ const generatedEntryModulePath = (outputRoot: string, entry: RslibEntry): string /** The import requests of one named entry in a lowered Rspack entry record. */ const entryImportsOf = (entryRecord: unknown, name: string): readonly string[] => { - if (entryRecord === null || typeof entryRecord !== 'object' || Array.isArray(entryRecord)) return []; - const description = (entryRecord as Record)[name]; - if (typeof description === 'string') return [description]; - if (Array.isArray(description)) return description.filter((item): item is string => typeof item === 'string'); - if (description !== null && typeof description === 'object') { - return entryImportsOf({ [name]: (description as { readonly import?: unknown }).import }, name); - } + 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 []; }; @@ -120,17 +118,6 @@ const virtualRegistryModules = ( path: join(outputRoot, generatedModulesDirname, `${entry.name}-${index}.mjs`), })); -/** Every generated module one entry serves virtually during its build. */ -const plannedVirtualModules = ( - entry: RslibEntry, - outputRoot: string, -): readonly { readonly path: string; readonly source: string }[] => [ - ...(entry.virtualSource === undefined - ? [] - : [{ path: generatedEntryModulePath(outputRoot, entry), source: entry.virtualSource }]), - ...virtualRegistryModules(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 @@ -369,7 +356,8 @@ const assertExecutableConfig = ( // The generated environment must retain the virtual-module source: a // resolved config without the plugin instance would resolve the // guaranteed-nonexistent generated paths against the real filesystem. - if (plannedVirtualModules(entry, outputRoot).length > 0) { + 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.'); @@ -381,8 +369,7 @@ const assertExecutableConfig = ( } const expectedAliases = { ...entry.aliases, - ...Object.fromEntries(virtualRegistryModules(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}$`)); @@ -425,7 +412,14 @@ export const composeEntryLibConfig = ( const libId = entryLibId(entry); const virtualSource = entry.virtualSource; const virtualModules = virtualRegistryModules(options.outputRoot, entry); - const generatedModules = plannedVirtualModules(entry, options.outputRoot); + // 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([ @@ -474,16 +468,14 @@ export const composeEntryLibConfig = ( // 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 (lowered === undefined || typeof lowered !== 'object' || Array.isArray(lowered)) { + if (!isRecord(lowered)) { throw new Error('Rslib lowered a generated executable without a keyed entry record.'); } - const description = (lowered as Record)[entry.name]; + const description = lowered[entry.name]; const wrapperImport = [generatedEntryModulePath(options.outputRoot, entry)]; config.entry = { ...lowered, - [entry.name]: description !== null && typeof description === 'object' && !Array.isArray(description) - ? { ...description, import: wrapperImport } - : wrapperImport, + [entry.name]: isRecord(description) ? { ...description, import: wrapperImport } : wrapperImport, } as typeof config.entry; } const violation = reservedExternalsViolation(config.externals, reserved);