From 261aaa8f1c1c9d03bec84e389f2571320df3b8a2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 24 Sep 2026 23:39:51 +0000 Subject: [PATCH 1/4] test(build): build one source from two checkout paths --- .../tests/build-reproducibility.test.ts | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/agent-bundle/tests/build-reproducibility.test.ts b/packages/agent-bundle/tests/build-reproducibility.test.ts index db1973205..290d89c40 100644 --- a/packages/agent-bundle/tests/build-reproducibility.test.ts +++ b/packages/agent-bundle/tests/build-reproducibility.test.ts @@ -24,8 +24,9 @@ const writeProjectFile = async (root: string, path: string, contents: string): P /** * A project with every surface whose generated wrapper imports a virtual * module as a namespace: a routed MCP server (its route registry module is - * what Rspack names in a `// NAMESPACE OBJECT` comment), event routes for - * three hosts, a routed CLI command, and a bundled script. + * what Rspack names in a `// NAMESPACE OBJECT` comment), an event-route + * handler and view for three hosts, a routed CLI command, and a bundled + * script. */ const writeProject = async (root: string): Promise => { // The audiobook example's installed tree supplies @agent-bundle/runtime, react, and zod. @@ -64,9 +65,13 @@ const writeProject = async (root: string): Promise => { " return {`Lookup: ${input.message}`};", "});", ].join('\n')), - writeProjectFile(root, 'src/events/session/start.tsx', [ + writeProjectFile(root, 'src/events/session/start.ts', [ + "import { events } from 'agent-bundle/routes';", + "export default events.session.start({ targets: ['claude', 'codex', 'cursor'] }, (event) => event.render('./start.view.js', {}));", + '', + ].join('\n')), + writeProjectFile(root, 'src/events/session/start.view.tsx', [ "import { Agent } from '@agent-bundle/runtime';", - "export const config = { targets: ['claude', 'codex', 'cursor'] };", 'export default async function SessionStart() {', ' return session started;', '}', @@ -108,28 +113,30 @@ const digestTree = async (root: string): Promise> => }; /** - * Two builds of one unchanged source tree — into two different output - * directories, each through its own per-build staging directory - * (`..stage-XXXXXX`) — emit byte-identical artifacts: the same - * manifest, the same file digests, the same bytes. Nothing in an emitted - * bundle may name the staging directory, the output directory, or any - * absolute path of the machine that built it; the generated-module - * namespace Rspack names in its module comments derives from the project - * root alone. + * Two builds of one unchanged source tree emit byte-identical artifacts: the + * same manifest, the same file digests, the same bytes. The builds run from + * two checkout paths into two output directories, each through its own + * per-build staging directory (`..stage-XXXXXX`). Nothing in an emitted bundle may name the checkout, the + * staging directory, the output directory, or any absolute path of the + * machine that built it; the generated-module namespace Rspack names in its + * module comments derives from the project root alone. */ -it('emits byte-identical artifacts from two builds of one source into two output directories', { timeout: 240_000 }, async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-reproducible-')); - roots.push(root); +it('emits byte-identical artifacts from two checkouts of one source into two output directories', { timeout: 240_000 }, async () => { + // Both checkouts sit at one depth: the fixture's node_modules symlink + // resolves outside the project, so its module ids climb out of the root. + const firstRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-reproducible-')); + const secondRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-other-checkout-')); + roots.push(firstRoot, secondRoot); // Completed outputs move out of the project between builds so the second // build's source snapshot (and so its project revision) is the first's. const parked = await mkdtemp(join(tmpdir(), 'agent-bundle-reproducible-outputs-')); roots.push(parked); - await writeProject(root); + await Promise.all([writeProject(firstRoot), writeProject(secondRoot)]); const outputs: string[] = []; const manifests: string[] = []; const stageTokens: string[] = []; - for (const name of ['first-output', 'second-output']) { + for (const [root, name] of [[firstRoot, 'first-output'], [secondRoot, 'second-output']] as const) { const output = join(root, name); const result = await build({ output, root }); expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); @@ -144,14 +151,13 @@ it('emits byte-identical artifacts from two builds of one source into two output const [first, second] = outputs as [string, string]; const [firstManifest, secondManifest] = manifests as [string, string]; - expect(secondManifest).toBe(firstManifest); - const manifest = parseArtifactManifest(firstManifest); - expect(manifest.files.length).toBeGreaterThan(0); - const [firstDigests, secondDigests] = await Promise.all([digestTree(first), digestTree(second)]); expect([...secondDigests.keys()].sort()).toEqual([...firstDigests.keys()].sort()); const differing = [...firstDigests].filter(([path, digest]) => secondDigests.get(path) !== digest).map(([path]) => path); expect(differing).toEqual([]); + expect(secondManifest).toBe(firstManifest); + const manifest = parseArtifactManifest(firstManifest); + expect(manifest.files.length).toBeGreaterThan(0); // The manifest's own digests describe exactly these bytes. for (const file of manifest.files) { expect(firstDigests.get(file.path)).toBe(file.sha256); @@ -171,7 +177,7 @@ it('emits byte-identical artifacts from two builds of one source into two output 'hooks/event-route-session-start.cursor.mjs', 'scripts/summarize.mjs', ])); - const forbidden = [root, parked, '.artifact.stage-', ...stageTokens]; + const forbidden = [firstRoot, secondRoot, parked, '.artifact.stage-', ...stageTokens]; for (const path of bundles) { const source = await readFile(join(first, path), 'utf8'); for (const token of forbidden) { From 7c9722c7acd3e8fc771b7e32661cf0763b98165b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 24 Sep 2026 23:52:02 +0000 Subject: [PATCH 2/4] fix(build): keep the checkout path out of artifact bytes Every generated-module emitter names project modules through generatedModuleSpecifier, which returns a project-relative POSIX specifier. NormalizedPlugin carries projectRoot so hook wrappers and entry shells receive it; model identity excludes it. canonicalizeNormalizedModel now canonicalizes generated route, event handler, web, and state paths. Fixes #833 --- .changeset/checkout-independent-artifacts.md | 5 + packages/agent-bundle/src/adapters/amp.ts | 3 +- .../src/adapters/hook-contract.ts | 10 +- packages/agent-bundle/src/build/build.ts | 2 + packages/agent-bundle/src/build/cli-bins.ts | 2 + packages/agent-bundle/src/build/entries.ts | 14 ++- .../agent-bundle/src/build/entry-shell.ts | 72 ++++++----- .../agent-bundle/src/build/inspect-bundler.ts | 4 + packages/agent-bundle/src/build/meta.ts | 22 +++- .../agent-bundle/src/build/package-build.ts | 2 +- packages/agent-bundle/src/config/normalize.ts | 1 + .../agent-bundle/src/core/project-context.ts | 54 ++++++++- packages/agent-bundle/src/core/types.ts | 2 + .../agent-bundle/tests/amp-adapter.test.ts | 1 + .../tests/artifact-validator.test.ts | 1 + packages/agent-bundle/tests/build.test.ts | 2 + .../tests/claude-hook-event-name.test.ts | 1 + .../claude-plugin-validate-acceptance.test.ts | 1 + .../tests/codex-distribution.test.ts | 1 + .../tests/codex-hook-contract.test.ts | 1 + .../tests/compiler-evidence.test.ts | 1 + .../agent-bundle/tests/cursor-adapter.test.ts | 1 + packages/agent-bundle/tests/doctor.test.ts | 1 + packages/agent-bundle/tests/entries.test.ts | 5 +- .../agent-bundle/tests/entry-shell.test.ts | 113 ++++++++++++------ .../tests/generated-module-evidence.test.ts | 5 +- .../tests/generated-module-specifier.test.ts | 19 +++ .../tests/hook-handler-contract.test.ts | 1 + packages/agent-bundle/tests/hooks.test.ts | 6 +- .../tests/host-adapters.native.test.ts | 2 + .../agent-bundle/tests/host-adapters.test.ts | 1 + .../tests/inspect-bundler.test.ts | 1 + .../tests/install-surface.test.ts | 1 + .../agent-bundle/tests/plugin-logo.test.ts | 1 + .../tests/portable-adapter.test.ts | 1 + .../tests/target-hook-contract.test.ts | 8 +- .../tests/target-mcp-runtime.test.ts | 1 + 37 files changed, 288 insertions(+), 81 deletions(-) create mode 100644 .changeset/checkout-independent-artifacts.md create mode 100644 packages/agent-bundle/tests/generated-module-specifier.test.ts diff --git a/.changeset/checkout-independent-artifacts.md b/.changeset/checkout-independent-artifacts.md new file mode 100644 index 000000000..700fcfcea --- /dev/null +++ b/.changeset/checkout-independent-artifacts.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Make `agent-bundle build` emit byte-identical artifacts from any checkout path and host platform: generated wrappers import project modules by project-relative POSIX specifiers, and the manifest `modelDigest` no longer hashes absolute route, handler, `web`, or `state` paths (#835). diff --git a/packages/agent-bundle/src/adapters/amp.ts b/packages/agent-bundle/src/adapters/amp.ts index fdbd75827..e79dddca4 100644 --- a/packages/agent-bundle/src/adapters/amp.ts +++ b/packages/agent-bundle/src/adapters/amp.ts @@ -22,6 +22,7 @@ import { type TargetArtifactPlan, } from './types.ts'; import { operatorEnvLayerImport } from '../build/launch-env-shell.ts'; +import { generatedModuleSpecifier } from '../build/meta.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; import { isPortablePathSegment } from '../core/paths.ts'; @@ -169,7 +170,7 @@ const encodeAmpPlaygroundOutput = ( const ampHookWrapperSource = (entry: TargetHookWrapper): string => [ operatorEnvLayerImport, - `import * as handlerModule from ${JSON.stringify(entry.hook.source)};`, + `import * as handlerModule from ${JSON.stringify(generatedModuleSpecifier(entry.projectRoot, entry.hook.source))};`, '', 'const target = "amp";', `const canonicalEvent = ${JSON.stringify(entry.event)};`, diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index bed4c21f4..c811e7c83 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -2,6 +2,7 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { dataArrayValues, hasDataKeys, isPlainDataRecord, isRecord, ownDataValue } from '../core/strict-json.ts'; import { escapeRegExp } from '../core/strings.ts'; import { operatorEnvLayerImport } from '../build/launch-env-shell.ts'; +import { generatedModuleSpecifier } from '../build/meta.ts'; import type { CanonicalAgentEvent } from '../routes/public.ts'; import { canonicalHookEvents, @@ -23,6 +24,8 @@ export interface TargetHookWrapper { readonly nativeEvent: string; /** The computed native tool matcher, absent when the host applies the hook unconditionally. */ readonly nativeMatcher?: string; + /** The project root the hook's absolute sources lie under. */ + readonly projectRoot: string; readonly relativePath: string; readonly target: string; } @@ -934,7 +937,7 @@ const eventRouteHandlerWrapperSource = ( "import { spawn } from 'node:child_process';", "import { fileURLToPath } from 'node:url';", `import { ${projectBindings.join(', ')} } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`, - `import gateHandler from ${JSON.stringify(handler.source)};`, + `import gateHandler from ${JSON.stringify(generatedModuleSpecifier(entry.projectRoot, handler.source))};`, "import { available, resolvePluginRoot, runAgentRequest, unavailable, useAgent } from '@agent-bundle/runtime/request';", "import { resolveStandaloneLineage } from '@agent-bundle/runtime/lineage';", "const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });", @@ -1061,7 +1064,7 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ // it evaluates before the handler module — a module-level `process.env` // read there sees the composed environment. operatorEnvLayerImport, - `import * as handlerModule from ${JSON.stringify(entry.hook.source)};`, + `import * as handlerModule from ${JSON.stringify(generatedModuleSpecifier(entry.projectRoot, entry.hook.source))};`, 'const target = "cursor";', `const canonicalEvent = ${JSON.stringify(entry.event)};`, `const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`, @@ -1415,6 +1418,7 @@ export const planHooks = ( ...(contract.indexedWrappers === false ? { indexed: false as const } : {}), nativeEvent, ...(matcher === undefined ? {} : { nativeMatcher: matcher }), + projectRoot: model.projectRoot, relativePath, target, ...(timeout === undefined ? {} : { timeout }), @@ -1473,7 +1477,7 @@ export const nativeHookWrapperSource = ( // The installed pack's operator `.env` layer (#469): the first import, so // it evaluates before the handler module (see cursorHookWrapperSource). operatorEnvLayerImport, - `import * as handlerModule from ${JSON.stringify(entry.hook.source)};`, + `import * as handlerModule from ${JSON.stringify(generatedModuleSpecifier(entry.projectRoot, entry.hook.source))};`, `const target = ${JSON.stringify(entry.target)};`, `const canonicalEvent = ${JSON.stringify(entry.event)};`, `const nativeEvent = ${JSON.stringify(nativeEvent)};`, diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 9ecbee8c6..a4de37403 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -800,6 +800,7 @@ export const build = async (options: BuildOptions): Promise => { ...noticePolicy, outDir: stageRoot, plugin, + projectRoot: options.model.projectRoot, providers: options.model.providers ?? [], ...(options.model.state === undefined ? {} : { state: options.model.state }), }), @@ -814,6 +815,7 @@ export const build = async (options: BuildOptions): Promise => { ...noticePolicy, outDir: stageRoot, plugin, + projectRoot: options.model.projectRoot, providers: options.model.providers ?? [], ...(options.model.state === undefined ? {} : { state: options.model.state }), target: composite.identity, diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index d3f9f086e..a96bd00df 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -172,6 +172,7 @@ export const cliBinRslibEntries = ( source: entry.source, sourceInputs: entry.sourceInputs, virtualSource: generatedCliBinEntrySource({ + projectRoot: model.projectRoot, commands: cli.commands, plugin: { ...(model.metadata.description === undefined ? {} : { description: model.metadata.description }), @@ -210,6 +211,7 @@ export const cliBinRslibEntries = ( source: entry.source, sourceInputs: entry.sourceInputs, virtualSource: generatedRenderedRouteWorkerSource({ + projectRoot: model.projectRoot, layouts: model.layouts ?? [], ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), providers: model.providers ?? [], diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 8c26ba581..b593c7cff 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -221,6 +221,7 @@ export const planScriptsSurface = async ( source, sourceInputs: workerSourceInputs, virtualSource: generatedRenderedRouteWorkerSource({ + projectRoot: options.cwd, ...(options.layouts === undefined ? {} : { layouts: options.layouts }), ...(options.providers === undefined ? {} : { providers: options.providers }), routes: [{ @@ -252,7 +253,7 @@ export const planScriptsSurface = async ( ...(mainExports.get(source) === true ? { aliases: { [terminalCapabilityRuntimeSpecifier]: terminalProbe! }, - virtualSource: generatedExecutableEntrySource({ entrySource: source, exportName: 'main', hostSurface: 'script' }), + virtualSource: generatedExecutableEntrySource({ projectRoot: options.cwd, entrySource: source, exportName: 'main', hostSurface: 'script' }), } : {}), })]; @@ -395,6 +396,7 @@ export const planMcpEntriesSurface = async ( readonly noticeDelivery?: NoticeDeliveryAdvertisement; readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; + readonly projectRoot: string; readonly providers?: readonly CompiledProvider[]; readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; @@ -429,6 +431,7 @@ export const planMcpEntriesSurface = async ( return server?.generatedRoutes === undefined ? undefined : generatedRouteMcpEntrySource({ + projectRoot: options.projectRoot, artifactEpoch: options.artifactEpoch, eventRoutes: hostsRuntime(entry.id) ? options.eventHooks : [], ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), @@ -447,6 +450,7 @@ export const planMcpEntriesSurface = async ( return server?.generatedRoutes === undefined ? undefined : generatedRouteFlightWorkerSource({ + projectRoot: options.projectRoot, artifactEpoch: generatedRouteArtifactEpoch(options.plugin), eventRoutes: hostsRuntime(entry.id) ? options.eventHooks : [], layouts: options.layouts ?? [], @@ -465,10 +469,10 @@ export const planMcpEntriesSurface = async ( const entryShells = await Promise.all(compiled.map(async (entry, index) => { const serverName = entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name; if (generatedRouteSources[index] !== undefined) { - return generatedStdioMcpEntrySource({ entrySource: routeModuleSpecifier, serverName }); + return generatedStdioMcpEntrySource({ projectRoot: options.projectRoot, entrySource: routeModuleSpecifier, serverName }); } return (await scanEntryExports(entry.source)).hasDefaultExport - ? generatedStdioMcpEntrySource({ entrySource: entry.source, serverName }) + ? generatedStdioMcpEntrySource({ projectRoot: options.projectRoot, entrySource: entry.source, serverName }) : undefined; })); const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined; @@ -636,6 +640,7 @@ export const planHooksSurface = ( readonly noticeDelivery?: NoticeDeliveryAdvertisement; readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; + readonly projectRoot: string; readonly providers?: readonly CompiledProvider[]; readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; @@ -669,6 +674,7 @@ export const planHooksSurface = ( ]), ]), virtualSource: generatedRouteFlightWorkerSource({ + projectRoot: options.projectRoot, artifactEpoch: workerArtifactEpoch, eventRoutes: standaloneEventRoutes, ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), @@ -706,7 +712,7 @@ export const planHooksSurface = ( source: entry.source, sourceInputs: entry.sourceInputs, virtualSource: hook.virtualSource - .replace(eventProviderRegistryToken, [...eventHandlerStateSource(options.state, options), ...providerRegistrySource(options.providers ?? [])].join('\n')) + .replace(eventProviderRegistryToken, [...eventHandlerStateSource(options.projectRoot, options.state, options), ...providerRegistrySource(options.projectRoot, options.providers ?? [])].join('\n')) .replace(eventProviderFieldsToken, providersFieldSource(options.providers ?? [], { indent: ' ', invocation: '{ kind: "event", props: { event: canonicalEvent, payload: native } }', observe: 'true', observer: 'observeProvider' }).join('\n')) .replaceAll(eventArtifactEpochToken, options.artifactEpoch) .replaceAll(eventFlightArtifactEpochToken, workerArtifactEpoch), diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 84d132c93..fab30f8cf 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts'; import { operatorEnvLayerImport, operatorEnvLayerImports, operatorEnvLayerStatement } from './launch-env-shell.ts'; +import { generatedModuleSpecifier } from './meta.ts'; import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import { stableJson } from '../core/digest.ts'; import type { NormalizedHook, NormalizedNoticeRetentionPolicy, NormalizedStateDefinition } from '../core/types.ts'; @@ -115,11 +116,13 @@ export const stdioPreludeVirtualModule = ( */ export const generatedStdioMcpEntrySource = (options: { readonly entrySource: string; + /** The project root; generated imports name its modules through {@link generatedModuleSpecifier}. */ + readonly projectRoot: string; readonly serverName: string; }): string => [ stdioPreludeImport, `import { runGeneratedStdioMcpEntry } from ${JSON.stringify(mcpEntryRuntimeSpecifier)};`, - `import * as serverModule from ${JSON.stringify(options.entrySource)};`, + `import * as serverModule from ${JSON.stringify(generatedModuleSpecifier(options.projectRoot, options.entrySource))};`, '', 'await runGeneratedStdioMcpEntry({', ' loadEntry: async () => serverModule,', @@ -137,16 +140,18 @@ export const generatedStdioMcpEntrySource = (options: { */ export const generatedExecutableEntrySource = (options: { readonly entrySource: string; + /** The project root; generated imports name its modules through {@link generatedModuleSpecifier}. */ + readonly projectRoot: string; readonly exportName: 'default' | 'main'; /** `cli` for a package bin, `script` for an artifact script; defaults to `script`. */ readonly hostSurface?: GeneratedExecutableSurface; }): string => [ `import { detectProcessTerminal } from ${JSON.stringify(terminalCapabilityRuntimeSpecifier)};`, - `import * as entry from ${JSON.stringify(options.entrySource)};`, + `import * as entry from ${JSON.stringify(generatedModuleSpecifier(options.projectRoot, options.entrySource))};`, '', `const main = entry[${JSON.stringify(options.exportName)}];`, "if (typeof main !== 'function') {", - ` throw new TypeError('Executable entry must export a ${options.exportName} function: ' + ${JSON.stringify(options.entrySource)});`, + ` throw new TypeError('Executable entry must export a ${options.exportName} function: ' + ${JSON.stringify(generatedModuleSpecifier(options.projectRoot, options.entrySource))});`, '}', `const code = await main(process.argv.slice(2), Object.freeze({ terminal: detectProcessTerminal(${JSON.stringify(options.hostSurface ?? 'script')}) }));`, "if (typeof code === 'number') process.exitCode = code;", @@ -180,6 +185,8 @@ export const webHostRuntimePath = (): string => runtimeModulePath('web-host'); export type GeneratedStateFallback = 'artifact' | 'cwd'; export interface GeneratedCliBinEntryOptions { + /** The project root; generated imports name its modules through {@link generatedModuleSpecifier}. */ + readonly projectRoot: string; readonly commands: readonly CompiledCliCommand[]; readonly plugin: { readonly description?: string; readonly name: string; readonly version: string }; /** Absolute projection-module sources keyed by their backing tool route id. */ @@ -243,6 +250,7 @@ const pluginRootDeclaration = ( } });`; const generatedStateImports = ( + projectRoot: string, state: NormalizedStateDefinition | undefined, ): readonly string[] => { if (state === undefined) return []; @@ -251,7 +259,7 @@ const generatedStateImports = ( ? ["import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';"] : ["import { createMemoryStateDriver } from '@agent-bundle/runtime/state';"]), "import { createGeneratedRuntimeState } from '@agent-bundle/runtime/mount';", - `import stateDefinition from ${JSON.stringify(state.source)};`, + `import stateDefinition from ${JSON.stringify(generatedModuleSpecifier(projectRoot, state.source))};`, ]; }; @@ -303,10 +311,11 @@ const generatedStateOwner = ( /** Reuse the generated state owner for a lightweight event request. */ export const eventHandlerStateSource = ( + projectRoot: string, state: NormalizedStateDefinition | undefined, policy: GeneratedNoticePolicy, ): readonly string[] => [ - ...generatedStateImports(state), + ...generatedStateImports(projectRoot, state), ...generatedStateOwner(state, policy), ...(state === undefined ? ['const withEventState = (_signal, run) => run(undefined);'] @@ -441,10 +450,10 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): : []), ...pluginRootImports(stateFallback, options.web?.pluginRootRelativeUrl), ...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []), - ...generatedStateImports(options.state), - ...routeImports(commandRoutes), + ...generatedStateImports(options.projectRoot, options.state), + ...routeImports(options.projectRoot, commandRoutes), ...projectionSources.map((source, index) => - `import * as projection${String(index)} from ${JSON.stringify(source)};`), + `import * as projection${String(index)} from ${JSON.stringify(generatedModuleSpecifier(input.projectRoot, source))};`), '', ...(runtimeBacked ? [pluginRootDeclaration(stateFallback, options.web?.pluginRootRelativeUrl)] : []), // Launch from the artifact carrying the manifest, not an environment override. @@ -453,7 +462,7 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): : [`const artifactRoot = ${pluginRootFallbackExpression(stateFallback, options.web.pluginRootRelativeUrl)};`]), ...generatedStateOwner(options.state, options), 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', - ...providerRegistrySource(providers), + ...providerRegistrySource(options.projectRoot, providers), 'const routes = Object.freeze({', ...commandRoutes.map((route, index) => { const projectionIndex = projectionIndexByRoute.get(route.id); @@ -584,6 +593,8 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): }; export interface GeneratedRenderedRouteWorkerOptions { + /** The project root; generated imports name its modules through {@link generatedModuleSpecifier}. */ + readonly projectRoot: string; readonly layouts?: readonly CompiledLayout[]; readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; @@ -608,8 +619,8 @@ const workerLayouts = ( return layouts.filter((layout) => applicable.has(layout)).sort((left, right) => left.id.localeCompare(right.id)); }; -const layoutImports = (layouts: readonly CompiledLayout[]): readonly string[] => - layouts.map((layout, index) => `import * as layout${String(index)} from ${JSON.stringify(layout.source)};`); +const layoutImports = (projectRoot: string, layouts: readonly CompiledLayout[]): readonly string[] => + layouts.map((layout, index) => `import * as layout${String(index)} from ${JSON.stringify(generatedModuleSpecifier(projectRoot, layout.source))};`); const layoutRecords = (layouts: readonly CompiledLayout[]): readonly string[] => layouts.map((layout, index) => @@ -682,9 +693,9 @@ export const generatedRenderedRouteWorkerSource = ( "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", "import { available, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", ...pluginRootImports(stateFallback), - ...generatedStateImports(options.state), - ...routeImports(options.routes), - ...layoutImports(layouts), + ...generatedStateImports(options.projectRoot, options.state), + ...routeImports(options.projectRoot, options.routes), + ...layoutImports(options.projectRoot, layouts), '', pluginRootDeclaration(stateFallback), ...generatedStateOwner(options.state, options), @@ -694,7 +705,7 @@ export const generatedRenderedRouteWorkerSource = ( '// Machine output owns the parent stdout; anything a route logs goes to stderr.', 'process.stdout.write = process.stderr.write.bind(process.stderr);', 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', - ...providerRegistrySource(providers), + ...providerRegistrySource(options.projectRoot, providers), ...composeLayoutsSource(layouts), 'const routes = Object.freeze({', ...options.routes.map((route, index) => @@ -811,6 +822,8 @@ export const generatedRenderedScriptEntrySource = ( ].join('\n'); export interface GeneratedRouteMcpEntryOptions { + /** The project root; generated imports name its modules through {@link generatedModuleSpecifier}. */ + readonly projectRoot: string; readonly artifactEpoch?: string; readonly eventRoutes?: readonly NormalizedHook[]; /** @@ -846,6 +859,8 @@ export interface GeneratedRouteMcpEntryOptions { } export interface GeneratedRouteFlightWorkerOptions { + /** The project root; generated imports name its modules through {@link generatedModuleSpecifier}. */ + readonly projectRoot: string; readonly artifactEpoch: string; readonly eventRoutes?: readonly NormalizedHook[]; readonly layouts?: readonly CompiledLayout[]; @@ -867,9 +882,9 @@ export const generatedRouteArtifactEpoch = (plugin: { const executableMcpRoutes = (routes: readonly CompiledAgentRoute[]): readonly CompiledAgentRoute[] => routes.filter((route) => route.kind !== 'app'); -const routeImports = (routes: readonly CompiledAgentRoute[]): readonly string[] => +const routeImports = (projectRoot: string, routes: readonly CompiledAgentRoute[]): readonly string[] => routes.flatMap((route, index) => [ - `import * as routeModule${String(index)} from ${JSON.stringify(route.source)};`, + `import * as routeModule${String(index)} from ${JSON.stringify(generatedModuleSpecifier(projectRoot, route.source))};`, `const route${String(index)} = Object.assign({}, Reflect.get(routeModule${String(index)}, 'default'), routeModule${String(index)});`, ]); @@ -955,10 +970,11 @@ const noticeDeliveryOwner = (wired: boolean, policy: GeneratedNoticePolicy): rea : []; const eventRouteImports = ( + projectRoot: string, routes: readonly NormalizedHook[], offset: number, ): readonly string[] => routes.map((route, index) => - `import * as route${String(offset + index)} from ${JSON.stringify(route.eventRoute?.handler?.view ?? route.source)};`); + `import * as route${String(offset + index)} from ${JSON.stringify(generatedModuleSpecifier(projectRoot, route.eventRoute?.handler?.view ?? route.source))};`); /** * Event route records stay keyed by the hook identity the worker resolves @@ -974,15 +990,15 @@ const eventRouteRecords = ( ): readonly string[] => routes.map((route, index) => ` ${JSON.stringify(route.id)}: Object.freeze({ event: ${JSON.stringify(route.eventRoute!.event)}, id: ${JSON.stringify(`event:${route.eventRoute!.event}`)}, kind: 'event-route', module: route${String(offset + index)}, name: ${JSON.stringify(route.eventRoute!.event)} }),`); -const providerRecords = (providers: readonly CompiledProvider[]): readonly string[] => +const providerRecords = (projectRoot: string, providers: readonly CompiledProvider[]): readonly string[] => providers.map((provider) => - ` Object.freeze({ key: ${JSON.stringify(providerKeyFromName(provider.name))}, load: () => import(${JSON.stringify(provider.source)}), source: ${JSON.stringify(provider.provenance.relativePath)} }),`); + ` Object.freeze({ key: ${JSON.stringify(providerKeyFromName(provider.name))}, load: () => import(${JSON.stringify(generatedModuleSpecifier(projectRoot, provider.source))}), source: ${JSON.stringify(provider.provenance.relativePath)} }),`); /** The frozen provider registry a generated request scope iterates; empty when the project declares none. */ -export const providerRegistrySource = (providers: readonly CompiledProvider[]): readonly string[] => +export const providerRegistrySource = (projectRoot: string, providers: readonly CompiledProvider[]): readonly string[] => providers.length === 0 ? [] - : ['const providers = Object.freeze([', ...providerRecords(providers), ']);']; + : ['const providers = Object.freeze([', ...providerRecords(projectRoot, providers), ']);']; /** * Claims this request's hit on the process identity and snapshots it in the @@ -1057,11 +1073,11 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", "import { Agent, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", ...pluginRootImports('artifact'), - ...generatedStateImports(options.state), + ...generatedStateImports(options.projectRoot, options.state), ...noticeInboxImport(wiresInbox), - ...routeImports(routes), - ...eventRouteImports(eventRoutes, routes.length), - ...layoutImports(layouts), + ...routeImports(options.projectRoot, routes), + ...eventRouteImports(options.projectRoot, eventRoutes, routes.length), + ...layoutImports(options.projectRoot, layouts), '', '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', @@ -1074,7 +1090,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo // observed value rides each render message and wins when present. pluginRootDeclaration('artifact'), ...generatedStateOwner(options.state, options), - ...providerRegistrySource(providers), + ...providerRegistrySource(options.projectRoot, providers), ...composeLayoutsSource(layouts), 'const routes = Object.freeze({', ...routeRecords(routes, { layouts }), @@ -1252,7 +1268,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti "import mcpApps from 'agent-bundle/mcp-apps';", ...noticeDeliveryImports(wiresResourceUpdated), ...noticeInboxImport(wiresInbox), - ...routeImports(routes), + ...routeImports(options.projectRoot, routes), '', `const ARTIFACT_EPOCH = ${JSON.stringify(artifactEpoch)};`, // The server process's one root resolution (#468): the lineage journal, diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index ef420f92f..e044b3d1c 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -162,6 +162,7 @@ const scriptEntries = async ( ? { aliases: { [terminalCapabilityRuntimeSpecifier]: terminalCapabilityRuntimePath() }, virtualSource: generatedExecutableEntrySource({ + projectRoot: model.projectRoot, entrySource: script.source, exportName: 'main', hostSurface: 'script', @@ -208,6 +209,7 @@ const mcpEntryEntries = async ( const routeSource = generatedRoutes === undefined || server === undefined ? undefined : generatedRouteMcpEntrySource({ + projectRoot: model.projectRoot, allowedTargets: hosting.serverIds.has(server.id) ? hosting.allowedTargets : [], hosts: selectedServerHosts(server, composite.selected), ...(noticeDelivery === undefined ? {} : { noticeDelivery }), @@ -234,6 +236,7 @@ const mcpEntryEntries = async ( ...(wrapped ? { virtualSource: generatedStdioMcpEntrySource({ + projectRoot: model.projectRoot, entrySource: routeSource === undefined ? entry.source : 'agent-bundle/generated-route-server', serverName, }), @@ -268,6 +271,7 @@ const mcpEntryEntries = async ( source: entry.source, sourceInputs: [], virtualSource: generatedRouteFlightWorkerSource({ + projectRoot: model.projectRoot, artifactEpoch: generatedRouteArtifactEpoch({ name: model.metadata.name, version: model.metadata.version }), layouts: model.layouts ?? [], ...(noticeDelivery === undefined ? {} : { noticeDelivery }), diff --git a/packages/agent-bundle/src/build/meta.ts b/packages/agent-bundle/src/build/meta.ts index 203cd250c..d3a6c728b 100644 --- a/packages/agent-bundle/src/build/meta.ts +++ b/packages/agent-bundle/src/build/meta.ts @@ -1,5 +1,5 @@ import { lstat } from 'node:fs/promises'; -import { join } from 'node:path'; +import nodePath, { join } from 'node:path'; import { isErrno } from '../core/errors.ts'; @@ -30,6 +30,26 @@ export const generatedModulesDirname = '.agent-bundle-virtual'; export const generatedModulesRoot = (projectRoot: string): string => join(projectRoot, generatedModulesDirname); +/** + * The specifier a generated module imports `source` by. Every generated + * module sits one directory below the project root, so a project path becomes + * `../`: Rspack names each import binding after its request, and + * the emitted bytes then match across checkout paths and host platforms. + * Bare specifiers and paths outside the project pass through unchanged. + */ +export const generatedModuleSpecifier = ( + projectRoot: string, + source: string, + path: typeof nodePath = nodePath, +): string => { + if (!path.isAbsolute(source)) return source; + const relative = path.relative(projectRoot, source); + if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return source; + } + return `../${relative.split(path.sep).join('/')}`; +}; + /** * Refuses to compile while anything occupies the reserved namespace on disk. * The virtual paths are predictable (`meta.mjs`, `-entry.mjs`, …), so diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 81d1f893f..51ed177f9 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -143,7 +143,7 @@ export const planPackageEntries = async ( // The envelope probes the terminal (#511) through the aliased // dependency-free runtime module, like the cli-entry shell. aliases: { [terminalCapabilityRuntimeSpecifier]: terminalCapabilityRuntimePath() }, - virtualSource: generatedExecutableEntrySource({ entrySource: bin.source, exportName, hostSurface: 'cli' }), + virtualSource: generatedExecutableEntrySource({ projectRoot: model.projectRoot, entrySource: bin.source, exportName, hostSurface: 'cli' }), }), }); } diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index db9cb9608..4b704e7d4 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -1350,6 +1350,7 @@ export const normalizeProject = async ( const extensions = normalizeExtensions(loaded, registry, configProvenance); const lspServers = normalizeLspServers(extensions, targetNames); const model: NormalizedPlugin = { + projectRoot: loaded.context.projectRoot, ...(assets.length === 0 ? {} : { assets }), ...(commands.length === 0 ? {} : { commands }), ...(loaded.config.marketplace === true ? { marketplace: true as const } : {}), diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index bb4d7862d..9ed846cbf 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -1,6 +1,7 @@ import { lstatSync, readFileSync, readlinkSync, realpathSync } from 'node:fs'; import { dirname, isAbsolute, join, parse, resolve, sep } from 'node:path'; +import type { CompiledAgentRoute, CompiledEventHandler } from '../routes/types.ts'; import type { SkillHostDocument, SkillIr, SkillSidecarRef } from '../skills/ir.ts'; import type { DescriptiveMetadataResult } from './descriptive-metadata.ts'; import { packageDescriptiveMetadata } from './descriptive-metadata.ts'; @@ -436,6 +437,20 @@ const canonicalProvenance = (root: string, provenance: SourceProvenance): Source sourcePath: canonicalCompilerPath(root, provenance.sourcePath, 'Model provenance path'), }); +const canonicalHandler = (root: string, handler: CompiledEventHandler): CompiledEventHandler => ({ + ...handler, + source: canonicalCompilerPath(root, handler.source, 'Route handler source path'), + ...(handler.view === undefined + ? {} + : { view: canonicalCompilerPath(root, handler.view, 'Route view source path') }), +}); + +const canonicalRoute = (root: string, route: CompiledAgentRoute): CompiledAgentRoute => ({ + ...route, + ...(route.handler === undefined ? {} : { handler: canonicalHandler(root, route.handler) }), + source: canonicalCompilerPath(root, route.source, 'Route source path'), +}); + const canonicalDiagnostic = (root: string, diagnostic: Diagnostic): Diagnostic => ({ ...diagnostic, ...(diagnostic.generatedPath === undefined @@ -567,7 +582,7 @@ export const canonicalizeNormalizedModel = ( root: string, model: NormalizedPlugin, ): Readonly> => { - const detached = snapshotStrictJsonValue(model) as unknown as NormalizedPlugin; + const { projectRoot: _projectRoot, ...detached } = snapshotStrictJsonValue(model) as unknown as NormalizedPlugin; return deepFreeze({ ...detached, ...(detached.assets === undefined @@ -651,6 +666,9 @@ export const canonicalizeNormalizedModel = ( }), hooks: detached.hooks.map((hook) => ({ ...hook, + ...(hook.eventRoute?.handler === undefined + ? {} + : { eventRoute: { ...hook.eventRoute, handler: canonicalHandler(root, hook.eventRoute.handler) } }), provenance: canonicalProvenance(root, hook.provenance), // Prebuilt hook `source` may not exist yet; identity is the enumerated // payload files, matching `modelPathReferences`. Relative sources stay @@ -678,6 +696,9 @@ export const canonicalizeNormalizedModel = ( ...(server.cwd === undefined ? {} : { cwd: canonicalCompilerPath(root, server.cwd, 'MCP server working directory') }), + ...(server.generatedRoutes === undefined + ? {} + : { generatedRoutes: server.generatedRoutes.map((route) => canonicalRoute(root, route)) }), provenance: canonicalProvenance(root, server.provenance), ...(server.source === undefined ? {} @@ -728,6 +749,20 @@ export const canonicalizeNormalizedModel = ( ...detached.packageBuild, bins: detached.packageBuild.bins.map((bin) => ({ ...bin, + ...(bin.generatedCli === undefined + ? {} + : { + generatedCli: { + ...bin.generatedCli, + ...(bin.generatedCli.projectionSources === undefined + ? {} + : { + projectionSources: Object.fromEntries(Object.entries(bin.generatedCli.projectionSources) + .map(([routeId, source]) => [routeId, canonicalCompilerPath(root, source, 'CLI projection source path')])), + }), + routes: bin.generatedCli.routes.map((route) => canonicalRoute(root, route)), + }, + }), provenance: canonicalProvenance(root, bin.provenance), source: canonicalCompilerPath(root, bin.source, 'Bin entry source path'), })), @@ -770,10 +805,27 @@ export const canonicalizeNormalizedModel = ( ...(skill.skillIr === undefined ? {} : { skillIr: canonicalSkillIr(root, skill.skillIr) }), source: canonicalCompilerPath(root, skill.source, 'Skill source path'), })), + ...(detached.state === undefined + ? {} + : { + state: { + ...detached.state, + provenance: canonicalProvenance(root, detached.state.provenance), + source: canonicalCompilerPath(root, detached.state.source, 'State source path'), + }, + }), targets: detached.targets.map((target) => ({ ...target, provenance: canonicalProvenance(root, target.provenance), })), + ...(detached.web === undefined + ? {} + : { + web: { + ...detached.web, + provenance: { sourcePath: canonicalCompilerPath(root, detached.web.provenance.sourcePath, 'Web provenance path') }, + }, + }), }); }; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 1a0703d36..31373a807 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -804,6 +804,8 @@ export interface NormalizedPlugin { * models predating the package build stay valid. */ readonly packageBuild?: NormalizedPackageBuild; + /** The absolute project root the model's absolute paths lie under; excluded from model identity. */ + readonly projectRoot: string; /** * Declared prebuilt payload directories packaged verbatim. Present only * when the config declares a `payload` block; optional so hand-constructed diff --git a/packages/agent-bundle/tests/amp-adapter.test.ts b/packages/agent-bundle/tests/amp-adapter.test.ts index e4f733106..4f6a1a369 100644 --- a/packages/agent-bundle/tests/amp-adapter.test.ts +++ b/packages/agent-bundle/tests/amp-adapter.test.ts @@ -22,6 +22,7 @@ const configPath = '/workspace/agent-bundle.config.ts'; const skillSource = '/workspace/src/skills/review/SKILL.md'; const plugin = (): NormalizedPlugin => ({ + projectRoot: '/workspace', extensions: {}, hooks: [], mcpServers: [ diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index de7cda7f9..5bcbaf91a 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -2685,6 +2685,7 @@ it('documents recovery for every stable artifact diagnostic code', async () => { }); const installSurfaceModel = (target: string): NormalizedPlugin => ({ + projectRoot: '/project', extensions: {}, hooks: [], mcpServers: [], diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 84dff8faa..64e080289 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -213,6 +213,7 @@ const buildFromSource = async ( }; const modelFor = (project: TestProject): NormalizedPlugin => ({ + projectRoot: '/project', assets: [{ bytes: Buffer.byteLength('project logo\n'), id: 'asset:branding/logo.svg', @@ -647,6 +648,7 @@ it('rejects hook entries stamped for a target other than their selected adapter' diagnostics: [], entries: [], hookEntries: [{ + projectRoot: '/project', event: hook.event, hook, nativeEvent: 'SessionStart', diff --git a/packages/agent-bundle/tests/claude-hook-event-name.test.ts b/packages/agent-bundle/tests/claude-hook-event-name.test.ts index 31afed58d..c6485cb36 100644 --- a/packages/agent-bundle/tests/claude-hook-event-name.test.ts +++ b/packages/agent-bundle/tests/claude-hook-event-name.test.ts @@ -122,6 +122,7 @@ const routeHook = ( }; const model = (target: string, hooks: readonly NormalizedHook[]): NormalizedPlugin => ({ + projectRoot: '/project', extensions: {}, hooks, mcpServers: [], diff --git a/packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts b/packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts index 28c11502c..7ebb6ad62 100644 --- a/packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts +++ b/packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts @@ -32,6 +32,7 @@ const hook = (event: NormalizedHook['event'], name: string): NormalizedHook => ( * under `hooks` (#462/#463, fixed in #470). */ const cargoHaulerShape: NormalizedPlugin = { + projectRoot: '/workspace', extensions: {}, hooks: [ hook('sessionStart', 'session-start'), diff --git a/packages/agent-bundle/tests/codex-distribution.test.ts b/packages/agent-bundle/tests/codex-distribution.test.ts index d8719547d..d0021c83b 100644 --- a/packages/agent-bundle/tests/codex-distribution.test.ts +++ b/packages/agent-bundle/tests/codex-distribution.test.ts @@ -29,6 +29,7 @@ const distributionRows = { } as const; const plugin: NormalizedPlugin = Object.freeze({ + projectRoot: '/workspace', extensions: Object.freeze({}), hooks: Object.freeze([]), marketplace: true as const, diff --git a/packages/agent-bundle/tests/codex-hook-contract.test.ts b/packages/agent-bundle/tests/codex-hook-contract.test.ts index dfabf8892..78422bd9e 100644 --- a/packages/agent-bundle/tests/codex-hook-contract.test.ts +++ b/packages/agent-bundle/tests/codex-hook-contract.test.ts @@ -59,6 +59,7 @@ const releaseEvents = [ ]; const plugin: NormalizedPlugin = Object.freeze({ + projectRoot: '/workspace', extensions: Object.freeze({}), hooks: Object.freeze([]), marketplace: true as const, diff --git a/packages/agent-bundle/tests/compiler-evidence.test.ts b/packages/agent-bundle/tests/compiler-evidence.test.ts index b51682cad..10a432ba0 100644 --- a/packages/agent-bundle/tests/compiler-evidence.test.ts +++ b/packages/agent-bundle/tests/compiler-evidence.test.ts @@ -57,6 +57,7 @@ const padScript = [ ].join('\n'); const modelFor = (root: string, scripts: Readonly>): NormalizedPlugin => ({ + projectRoot: '/project', assets: [], extensions: {}, hooks: [], diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 30b428a8c..0225455ea 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -15,6 +15,7 @@ import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; const configPath = '/workspace/agent-bundle.config.ts'; const plugin = (): NormalizedPlugin => ({ + projectRoot: '/workspace', extensions: {}, hooks: [], metadata: { diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 741e0cc3e..567868dc2 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -369,6 +369,7 @@ it('proves Agent Plugins stdio launch on Cursor: unexpanded spec forms warn, the // contract is checked against the bundle's document, so the absolute paths and §9.1 keys in the copy are no error. const bundle = join(fixture.root, 'portable-bundle'); const installerSource = composeProjections({ + projectRoot: '/project', extensions: {}, hooks: [], mcpServers: [], diff --git a/packages/agent-bundle/tests/entries.test.ts b/packages/agent-bundle/tests/entries.test.ts index 9ee738072..f65ed083d 100644 --- a/packages/agent-bundle/tests/entries.test.ts +++ b/packages/agent-bundle/tests/entries.test.ts @@ -105,6 +105,7 @@ describe('event-route handler source graph (#595)', () => { tools: [], }; const model: NormalizedPlugin = { + projectRoot: '/project', extensions: {}, hooks: [hook], mcpServers: [], @@ -152,6 +153,7 @@ describe('event-route handler source graph (#595)', () => { it('aliases the cheap event runtimes onto the per-host wrapper and applies the operator env layer', () => { const surface = planHooksSurface(planned, { + projectRoot: '/project', artifactEpoch: 'handler-entries@1.0.0', outDir: '/tmp/artifact', plugin: { name: 'handler-entries', version: '1.0.0' }, @@ -164,7 +166,7 @@ describe('event-route handler source graph (#595)', () => { 'agent-bundle/event-project': expect.any(String), }); expect(entry.virtualSource).toContain('executeEventHandler'); - expect(entry.virtualSource).toContain(handler.source); + expect(entry.virtualSource).toContain('import gateHandler from "../src/events/tool/before.handler.ts";'); expect(entry.virtualSource).not.toContain('__AGENT_BUNDLE_EVENT_ARTIFACT_EPOCH__'); expect(entry.virtualModules).toBeDefined(); expect(entry.rscManifest).toBeUndefined(); @@ -208,6 +210,7 @@ describe('event-route handler source graph (#595)', () => { .flatMap((entry) => entry.workerOutput === undefined ? [] : [entry.workerOutput])) .toEqual(workers.map((worker) => `/tmp/artifact/${worker}`)); const outputs = planHooksSurface(combined, { + projectRoot: '/project', artifactEpoch: 'handler-entries@1.0.0', outDir: '/tmp/artifact', plugin: { name: 'handler-entries', version: '1.0.0' }, diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 1f0d7e0b0..0d77a896b 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -102,7 +102,7 @@ describe('generated entry templates', () => { }); it('generates a stdio entry whose first import is the prelude — stdout guard, then the operator .env layer — ahead of the server module (#469)', () => { - const source = generatedStdioMcpEntrySource({ entrySource: '/proj/src/mcp/curator.ts', serverName: 'curator' }); + const source = generatedStdioMcpEntrySource({ projectRoot: '/proj', entrySource: '/proj/src/mcp/curator.ts', serverName: 'curator' }); expect(source).toContain(`from ${JSON.stringify(mcpEntryRuntimeSpecifier)}`); expect(source).toContain('serverName: "curator"'); // The prelude is the shell's first import and the server module a static @@ -112,7 +112,7 @@ describe('generated entry templates', () => { // module's own top level (pinned end to end by tests/mcp.test.ts). expect(source.startsWith(`${stdioPreludeImport}\n`)).toBe(true); expect(stdioPreludeImport).toBe('import "agent-bundle/stdio-prelude";'); - expect(source.indexOf(stdioPreludeImport)).toBeLessThan(source.indexOf('import * as serverModule from "/proj/src/mcp/curator.ts";')); + expect(source.indexOf(stdioPreludeImport)).toBeLessThan(source.indexOf('import * as serverModule from "../src/mcp/curator.ts";')); // The stdio shell never imports the env-only layer: stdout is its wire. expect(source).not.toContain(launchEnvLayerSpecifier); expect(source).toContain('loadEntry: async () => serverModule,'); @@ -143,6 +143,7 @@ describe('generated entry templates', () => { it('gives hook wrappers the env-only layer, never the stdio prelude: stdout is the host envelope there (#469)', () => { const entry: TargetHookWrapper = { + projectRoot: '/project', event: 'sessionStart', hook: { event: 'sessionStart', @@ -201,6 +202,7 @@ describe('generated entry templates', () => { }; const command = { aliases: [], exitCode: 'zero' as const, options: [], path: ['report'], rendered: false, routeId: 'cli:report' }; const artifactBin = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, routes: [route], @@ -213,11 +215,12 @@ describe('generated entry templates', () => { expect(artifactBin.startsWith(`${operatorEnvLayerImport}\n`)).toBe(true); expect(artifactBin).not.toContain(stdioPreludeSpecifier); expect(artifactBin).not.toContain('applyOperatorEnv'); - expect(artifactBin).toContain('import * as routeModule0 from "/project/src/cli/report.ts";'); + expect(artifactBin).toContain('import * as routeModule0 from "../src/cli/report.ts";'); // An artifact-hosted bin keeps its state out of the installed artifact: // the code root is the artifact, the state root the user state directory. expect(artifactBin).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });"); const durableBin = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, providers: [{ @@ -237,14 +240,15 @@ describe('generated entry templates', () => { }); expect(durableBin.startsWith(`${operatorEnvLayerImport}\n`)).toBe(true); for (const consumer of [ - 'import stateDefinition from "/project/src/state.ts";', - 'import * as routeModule0 from "/project/src/cli/report.ts";', - 'load: () => import("/project/src/providers/project-auth.ts")', + 'import stateDefinition from "../src/state.ts";', + 'import * as routeModule0 from "../src/cli/report.ts";', + 'load: () => import("../src/providers/project-auth.ts")', ]) { expect(durableBin).toContain(consumer); } // The npm package bin runs from the operator's own shell and reads no pack file. const npmBin = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, routes: [route], @@ -277,6 +281,7 @@ describe('generated entry templates', () => { pluginRootRelativeUrl: '../', }; const routed = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, routes: [route], @@ -302,6 +307,7 @@ describe('generated entry templates', () => { ].join('\n')); const webOnly = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [], plugin: { name: 'fixture', version: '1.0.0' }, routes: [], @@ -321,6 +327,7 @@ describe('generated entry templates', () => { // no command opens none, so the web-only bin mounts neither and their // modules cannot keep ` web` from starting. const webOnlyWithState = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [], plugin: { name: 'fixture', version: '1.0.0' }, providers: [{ @@ -347,6 +354,7 @@ describe('generated entry templates', () => { // moved the pin from the pre-#564 value, #637's `stateAnchor` moved it // again, #643's `routeInvocationExitCode` export moved it once more). const withoutWeb = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, routes: [route], @@ -357,14 +365,14 @@ describe('generated entry templates', () => { }); it('generates a process envelope that adopts numeric exit codes and hands main the terminal capability (#511)', () => { - const source = generatedExecutableEntrySource({ entrySource: '/proj/src/cli.ts', exportName: 'main', hostSurface: 'cli' }); - expect(source).toContain('import * as entry from "/proj/src/cli.ts"'); + const source = generatedExecutableEntrySource({ projectRoot: '/proj', entrySource: '/proj/src/cli.ts', exportName: 'main', hostSurface: 'cli' }); + expect(source).toContain('import * as entry from "../src/cli.ts"'); expect(source).toContain(`import { detectProcessTerminal } from ${JSON.stringify(entryShellModule.terminalCapabilityRuntimeSpecifier)}`); expect(source).toContain('entry["main"]'); expect(source).toContain('await main(process.argv.slice(2), Object.freeze({ terminal: detectProcessTerminal("cli") }))'); expect(source).toContain("if (typeof code === 'number') process.exitCode = code;"); // Artifact scripts default to the `script` surface; the envelope never loads the runtime. - const script = generatedExecutableEntrySource({ entrySource: '/e.ts', exportName: 'default' }); + const script = generatedExecutableEntrySource({ projectRoot: '/project', entrySource: '/e.ts', exportName: 'default' }); expect(script).toContain('entry["default"]'); expect(script).toContain('detectProcessTerminal("script")'); expect(script).not.toContain('@agent-bundle/runtime'); @@ -440,6 +448,7 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { if (generate === undefined) return; const source = generate({ + projectRoot: '/project', artifactEpoch: 'epoch-1', eventRoutes: [{ event: 'afterTool', @@ -485,7 +494,7 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { // rather than a second copy of it (#103 stage 2). expect(source).toContain(`from ${JSON.stringify(mcpServerRuntimeSpecifier)}`); expect(source).toContain("from 'agent-bundle/mcp-apps'"); - expect(source).toContain('import * as routeModule0 from "/project/src/mcp/curator/tools/inspect.tsx"'); + expect(source).toContain('import * as routeModule0 from "../src/mcp/curator/tools/inspect.tsx"'); expect(source).toContain('const ARTIFACT_EPOCH = "route-fixture@1.2.3"'); expect(source).toContain('"tool:curator/inspect": Object.freeze({ config: {"annotations":{"readOnlyHint":true}'); expect(source).toContain('"resource:curator/catalog"'); @@ -550,6 +559,7 @@ it('keeps the generated server behaviour in the shared runtime module the entry it('fails the build on an MCP route the generated server cannot register', () => { const generate = entryShellModule.generatedRouteMcpEntrySource; const entry = (routes: readonly Readonly>[]): string => generate({ + projectRoot: '/project', plugin: { name: 'route-fixture', version: '1.2.3' }, routes: routes as never, serverName: 'curator', @@ -569,6 +579,7 @@ it('fails the build on an MCP route the generated server cannot register', () => source: '/project/src/cli/migrate.tsx', }])).toThrow('non-MCP route'); expect(() => generate({ + projectRoot: '/project', plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [{ config: {}, @@ -587,6 +598,7 @@ it('fails the build on an MCP route the generated server cannot register', () => workerFile: 'mcp-curator-flight.mjs', })).toThrow('reserved protocol name'); expect(() => generate({ + projectRoot: '/project', plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [{ config: { uri: 'agent-bundle://notices/inbox' }, @@ -609,6 +621,7 @@ it('fails the build on an MCP route the generated server cannot register', () => it('journals the lineage registry through sqlite only for workspace-durable projects', () => { const source = entryShellModule.generatedRouteMcpEntrySource({ + projectRoot: '/project', plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [{ config: {}, @@ -646,6 +659,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(typeof generate).toBe('function'); if (generate === undefined) return; const source = generate({ + projectRoot: '/project', artifactEpoch: 'route-fixture@1.2.3', eventRoutes: [{ event: 'afterTool', @@ -670,8 +684,8 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain('runAgentRequest'); expect(source).toContain('processLifetime'); expect(source).toContain('route-fixture@1.2.3'); - expect(source).toContain('/project/src/mcp/curator/tools/inspect.tsx'); - expect(source).toContain('/project/src/events/tool/after.tsx'); + expect(source).toContain('../src/mcp/curator/tools/inspect.tsx'); + expect(source).toContain('../src/events/tool/after.tsx'); expect(source).toContain("message.invocation.kind === 'event'"); // The worker resolves the event route by its hook identity but mounts the // compiled route id as `operationId`, the same id the hook shell, the @@ -696,6 +710,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source.indexOf("type: 'end'")).toBeGreaterThan(source.indexOf("type: 'observed-render-finish'")); expect(generate({ + projectRoot: '/project', artifactEpoch: 'route-fixture@1.2.3', eventRoutes: [{ event: 'afterTool', @@ -728,6 +743,7 @@ it('generates bulk-projected MCP commands with the CLI invocation and preserves source: '/project/src/mcp/curator/tools/read_item.tsx', }; const source = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [{ aliases: [], exitCode: 'zero', @@ -749,7 +765,7 @@ it('generates bulk-projected MCP commands with the CLI invocation and preserves workerFile: 'route-fixture-flight.mjs', }); - expect(source).toContain('import * as routeModule0 from "/project/src/mcp/curator/tools/read_item.tsx"'); + expect(source).toContain('import * as routeModule0 from "../src/mcp/curator/tools/read_item.tsx"'); expect(source).toContain("invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }"); expect(source).toContain("request: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') }"); expect(source).toContain('props: { input: parsed }'); @@ -770,6 +786,7 @@ it('imports explicit CLI projections and maps their input before canonical valid source: '/project/src/mcp/curator/tools/submit.tsx', }; const source = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [{ aliases: [], exitCode: 'zero', @@ -817,7 +834,7 @@ it('imports explicit CLI projections and maps their input before canonical valid workerFile: 'route-fixture-flight.mjs', }); - expect(source).toContain('import * as projection0 from "/project/src/mcp/curator/tools/submit.cli.ts";'); + expect(source).toContain('import * as projection0 from "../src/mcp/curator/tools/submit.cli.ts";'); expect(source).toContain( '"tool:curator/submit": Object.freeze({ module: route0, projection: projection0 })', ); @@ -843,6 +860,7 @@ it('mounts the shell-probed terminal on every routed-CLI surface and forwards it source: '/project/src/cli/doctor.ts', }; const bin = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [{ aliases: [], exitCode: 'zero', options: [], path: ['doctor'], rendered: false, routeId: plainRoute.id }], plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [plainRoute], @@ -850,12 +868,13 @@ it('mounts the shell-probed terminal on every routed-CLI surface and forwards it // Plain commands run in the executable itself: the shell's probe is the value. expect(bin).toContain("terminal: available(context.terminal, 'native'),"); - const worker = entryShellModule.generatedRenderedRouteWorkerSource({ routes: [plainRoute] }); + const worker = entryShellModule.generatedRenderedRouteWorkerSource({ projectRoot: '/project', routes: [plainRoute] }); // A worker thread's own streams are pipes to the parent; it must never probe them. expect(worker).toContain("terminal: message.terminal === undefined ? unavailable('not-provided') : available(message.terminal, 'native'),"); expect(worker).not.toContain('detectProcessTerminal'); const flightWorker = entryShellModule.generatedRouteFlightWorkerSource({ + projectRoot: '/project', artifactEpoch: 'route-fixture@1.2.3', routes: [], serverName: 'curator', @@ -918,6 +937,7 @@ it('forwards the dispatched invocation to the rendered worker in every rendered it('generates deterministic per-request provider execution in the shared Flight worker', () => { const source = entryShellModule.generatedRouteFlightWorkerSource({ + projectRoot: '/project', artifactEpoch: 'route-fixture@1.2.3', providers: [ { @@ -943,10 +963,10 @@ it('generates deterministic per-request provider execution in the shared Flight serverName: 'curator', }); - expect(source).toContain('load: () => import("/project/src/providers/alpha-value.ts")'); - expect(source).toContain('load: () => import("/project/src/providers/zeta.ts")'); - expect(source.indexOf('/project/src/providers/alpha-value.ts')).toBeLessThan( - source.indexOf('/project/src/providers/zeta.ts'), + expect(source).toContain('load: () => import("../src/providers/alpha-value.ts")'); + expect(source).toContain('load: () => import("../src/providers/zeta.ts")'); + expect(source.indexOf('../src/providers/alpha-value.ts')).toBeLessThan( + source.indexOf('../src/providers/zeta.ts'), ); expect(source).toContain('key: "alphaValue"'); expect(source).toContain('await module.default({ ...request, invocation: message.invocation })'); @@ -976,6 +996,7 @@ it('mounts deterministic per-request providers for plain routed CLI commands (#3 routeId: route.id, }; const withProviders = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [command], plugin: { name: 'route-fixture', version: '1.2.3' }, providers: [ @@ -996,10 +1017,10 @@ it('mounts deterministic per-request providers for plain routed CLI commands (#3 }); // Same registry, ordering, invocation contract, and fail-closed wrapping as the Flight workers. - expect(withProviders).toContain('load: () => import("/project/src/providers/alpha-value.ts")'); - expect(withProviders).toContain('load: () => import("/project/src/providers/zeta.ts")'); - expect(withProviders.indexOf('/project/src/providers/alpha-value.ts')).toBeLessThan( - withProviders.indexOf('/project/src/providers/zeta.ts'), + expect(withProviders).toContain('load: () => import("../src/providers/alpha-value.ts")'); + expect(withProviders).toContain('load: () => import("../src/providers/zeta.ts")'); + expect(withProviders.indexOf('../src/providers/alpha-value.ts')).toBeLessThan( + withProviders.indexOf('../src/providers/zeta.ts'), ); expect(withProviders).toContain('key: "alphaValue"'); expect(withProviders).toContain( @@ -1026,6 +1047,7 @@ it('mounts deterministic per-request providers for plain routed CLI commands (#3 // A project without providers still mounts only the framework-owned process identity. const withoutProviders = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [command], plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [route], @@ -1037,6 +1059,7 @@ it('mounts deterministic per-request providers for plain routed CLI commands (#3 it('mounts deterministic per-request providers in rendered route workers', () => { const source = entryShellModule.generatedRenderedRouteWorkerSource({ + projectRoot: '/project', providers: [ { id: 'provider:zeta', @@ -1060,10 +1083,10 @@ it('mounts deterministic per-request providers in rendered route workers', () => }], }); - expect(source).toContain('load: () => import("/project/src/providers/alpha-value.ts")'); - expect(source).toContain('load: () => import("/project/src/providers/zeta.ts")'); - expect(source.indexOf('/project/src/providers/alpha-value.ts')).toBeLessThan( - source.indexOf('/project/src/providers/zeta.ts'), + expect(source).toContain('load: () => import("../src/providers/alpha-value.ts")'); + expect(source).toContain('load: () => import("../src/providers/zeta.ts")'); + expect(source.indexOf('../src/providers/alpha-value.ts')).toBeLessThan( + source.indexOf('../src/providers/zeta.ts'), ); expect(source).toContain('await module.default({ ...request, invocation: message.invocation })'); expect(source).toContain('resolveProvider: async (key, request) => {'); @@ -1095,6 +1118,7 @@ it('keeps the generated provider loop and the in-process execution helper identi }, ]; const source = entryShellModule.generatedRenderedRouteWorkerSource({ + projectRoot: '/project', providers, routes: [{ config: {}, @@ -1186,6 +1210,7 @@ const layoutFixtures = [ it('composes the root and server layout chain around generated MCP routes and never around event routes', () => { const source = entryShellModule.generatedRouteFlightWorkerSource({ + projectRoot: '/project', artifactEpoch: 'route-fixture@1.2.3', eventRoutes: [{ event: 'afterTool', @@ -1220,8 +1245,8 @@ it('composes the root and server layout chain around generated MCP routes and ne }); // Layout imports are ordered by id so the emitted worker is deterministic. - expect(source).toContain('import * as layout0 from "/project/src/mcp/curator/layout.tsx"'); - expect(source).toContain('import * as layout1 from "/project/src/layout.tsx"'); + expect(source).toContain('import * as layout0 from "../src/mcp/curator/layout.tsx"'); + expect(source).toContain('import * as layout1 from "../src/layout.tsx"'); // Root first, then the owning server's layout — the outer-to-inner chain. expect(source).toContain('id: "tool:curator/inspect", kind: "tool", layouts: Object.freeze([1,0])'); expect(source).toContain('serverId: "mcp:curator"'); @@ -1243,6 +1268,7 @@ it('imports only the layouts some route of the worker composes through, never an // only root layouts apply to its routes; the curator server layout must not // be evaluated in that process at all. const rendered = entryShellModule.generatedRenderedRouteWorkerSource({ + projectRoot: '/project', layouts: layoutFixtures, routes: [ { @@ -1261,13 +1287,14 @@ it('imports only the layouts some route of the worker composes through, never an }, ], }); - expect(rendered).toContain('import * as layout0 from "/project/src/layout.tsx"'); - expect(rendered).not.toContain('/project/src/mcp/curator/layout.tsx'); + expect(rendered).toContain('import * as layout0 from "../src/layout.tsx"'); + expect(rendered).not.toContain('../src/mcp/curator/layout.tsx'); expect(rendered).toContain('"cli:library/audit": Object.freeze({ id: "cli:library/audit", kind: "cli", name: "library audit", module: route0, layouts: Object.freeze([0]) })'); expect(rendered).toContain('"script:rebuild-index": Object.freeze({ id: "script:rebuild-index", kind: "script", name: "rebuild-index", module: route1, layouts: Object.freeze([0]) })'); // Another generated server's worker likewise skips the curator layout. const otherServer = entryShellModule.generatedRouteFlightWorkerSource({ + projectRoot: '/project', artifactEpoch: 'route-fixture@1.2.3', layouts: layoutFixtures, routes: [{ @@ -1280,8 +1307,8 @@ it('imports only the layouts some route of the worker composes through, never an }], serverName: 'other', }); - expect(otherServer).toContain('import * as layout0 from "/project/src/layout.tsx"'); - expect(otherServer).not.toContain('/project/src/mcp/curator/layout.tsx'); + expect(otherServer).toContain('import * as layout0 from "../src/layout.tsx"'); + expect(otherServer).not.toContain('../src/mcp/curator/layout.tsx'); expect(otherServer).toContain('id: "resource:other/catalog", kind: "resource", layouts: Object.freeze([0])'); // A server layout alone, for a worker whose routes never take it, leaves the @@ -1294,12 +1321,13 @@ it('imports only the layouts some route of the worker composes through, never an provenance: { kind: 'conventional' as const, relativePath: 'src/cli/library/audit.tsx' }, source: '/project/src/cli/library/audit.tsx', }]; - expect(entryShellModule.generatedRenderedRouteWorkerSource({ layouts: serverOnly, routes: cliRoutes })) - .toBe(entryShellModule.generatedRenderedRouteWorkerSource({ routes: cliRoutes })); + expect(entryShellModule.generatedRenderedRouteWorkerSource({ projectRoot: '/project', layouts: serverOnly, routes: cliRoutes })) + .toBe(entryShellModule.generatedRenderedRouteWorkerSource({ projectRoot: '/project', routes: cliRoutes })); }); it('emits an identity composition when no layout exists so layout-free workers render exactly the route element', () => { const source = entryShellModule.generatedRouteFlightWorkerSource({ + projectRoot: '/project', artifactEpoch: 'route-fixture@1.2.3', routes: [{ config: {}, @@ -1319,6 +1347,7 @@ it('emits an identity composition when no layout exists so layout-free workers r it('hands rendered CLI, projected MCP, and script routes their layout chain and protocol-facing name', () => { const source = entryShellModule.generatedRenderedRouteWorkerSource({ + projectRoot: '/project', layouts: layoutFixtures, routes: [ { @@ -1367,6 +1396,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol }); const base = { artifactEpoch: 'route-fixture@1.2.3', + projectRoot: '/project', routes: [route], serverName: 'curator', }; @@ -1386,7 +1416,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol noticeDelivery: claudeAdapter.noticeDelivery!, state: state('process'), }); - expect(volatile).toContain('import stateDefinition from "/project/src/state.ts"'); + expect(volatile).toContain('import stateDefinition from "../src/state.ts"'); expect(volatile).toContain("createGeneratedRuntimeState"); expect(volatile).toContain('createMemoryStateDriver({ lifetime: "process" })'); expect(volatile).toContain('noticeLedger'); @@ -1398,6 +1428,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(stateless).not.toContain('agent-bundle:notice-inbox'); const statelessEntry = entryShellModule.generatedRouteMcpEntrySource({ + projectRoot: '/project', plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [route], serverName: 'curator', @@ -1406,6 +1437,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(statelessEntry).not.toContain('@agent-bundle/runtime/notices/inbox-route'); expect(statelessEntry).not.toContain('agent-bundle:notice-inbox'); const volatileEntry = entryShellModule.generatedRouteMcpEntrySource({ + projectRoot: '/project', noticeDelivery: claudeAdapter.noticeDelivery!, plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [route], @@ -1430,6 +1462,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol } const durableEntry = entryShellModule.generatedRouteMcpEntrySource({ + projectRoot: '/project', noticeDelivery: claudeAdapter.noticeDelivery!, plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [route], @@ -1449,6 +1482,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(durableEntry).toContain(' notices: noticeDelivery,'); // A declared `notices.retention` travels as one frozen literal too. const retainingEntry = entryShellModule.generatedRouteMcpEntrySource({ + projectRoot: '/project', noticeDelivery: claudeAdapter.noticeDelivery!, noticeRetention: { maxJournalBytes: 1024, maxTerminal: 3, terminalTtlMs: 60_000 }, plugin: { name: 'route-fixture', version: '1.2.3' }, @@ -1474,6 +1508,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol }), }); const unsupportedEntry = entryShellModule.generatedRouteMcpEntrySource({ + projectRoot: '/project', noticeDelivery: withoutResourceUpdated, plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [route], @@ -1506,6 +1541,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol }), }); const unadvertisedEntry = entryShellModule.generatedRouteMcpEntrySource({ + projectRoot: '/project', plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [route], serverName: 'curator', @@ -1513,6 +1549,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol workerFile: 'mcp-curator-flight.mjs', }); const noInboxEntry = entryShellModule.generatedRouteMcpEntrySource({ + projectRoot: '/project', noticeDelivery: withoutInbox, plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [route], @@ -1549,6 +1586,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol // The reserved inbox name stays reserved so a host that later advertises the // route cannot collide with an authored one. expect(() => entryShellModule.generatedRouteMcpEntrySource({ + projectRoot: '/project', noticeDelivery: withoutInbox, plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [{ ...route, config: { uri: 'agent-bundle://notices/inbox' }, id: 'resource:curator/inbox', kind: 'resource' }], @@ -1568,6 +1606,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(durable).not.toContain('AGENT_BUNDLE_PLUGIN_ROOT'); const renderedWorker = entryShellModule.generatedRenderedRouteWorkerSource({ + projectRoot: '/project', routes: [{ ...route, id: 'script:report', kind: 'script' }], state: state('workspace-durable'), }); @@ -1585,6 +1624,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol } as const; const cliRoute = { ...route, id: command.routeId, kind: 'cli' as const }; const statelessCli = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, routes: [cliRoute], @@ -1592,6 +1632,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(statelessCli).not.toContain('@agent-bundle/runtime/mount'); expect(statelessCli).not.toContain('noticeLedger'); const volatileCli = entryShellModule.generatedCliBinEntrySource({ + projectRoot: '/project', commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, routes: [cliRoute], diff --git a/packages/agent-bundle/tests/generated-module-evidence.test.ts b/packages/agent-bundle/tests/generated-module-evidence.test.ts index ce56fa6b1..0825890ea 100644 --- a/packages/agent-bundle/tests/generated-module-evidence.test.ts +++ b/packages/agent-bundle/tests/generated-module-evidence.test.ts @@ -96,6 +96,7 @@ const configHook = (root: string): NormalizedHook => ({ }); const claudeWrapper = (root: string): TargetHookWrapper => ({ + projectRoot: '/project', event: 'sessionStart', hook: configHook(root), nativeEvent: 'SessionStart', @@ -124,6 +125,7 @@ const generators: ReadonlyArray<{ outputRelativePath: 'bin/main.js', source, virtualSource: generatedExecutableEntrySource({ + projectRoot: '/project', entrySource: source, exportName: 'main', hostSurface: 'cli', @@ -139,6 +141,7 @@ const generators: ReadonlyArray<{ outputRelativePath: 'bin/cli.js', source: join(root, 'src', 'cli', 'report.ts'), virtualSource: generatedCliBinEntrySource({ + projectRoot: '/project', commands: [plainCommand], plugin, routes: [cliRoute(root)], @@ -158,7 +161,7 @@ const generators: ReadonlyArray<{ outputRelativePath: 'mcp/curator.mjs', source, virtualModules: [stdioPreludeVirtualModule()], - virtualSource: generatedStdioMcpEntrySource({ entrySource: source, serverName: 'curator' }), + virtualSource: generatedStdioMcpEntrySource({ projectRoot: '/project', entrySource: source, serverName: 'curator' }), }); }, }, diff --git a/packages/agent-bundle/tests/generated-module-specifier.test.ts b/packages/agent-bundle/tests/generated-module-specifier.test.ts new file mode 100644 index 000000000..fd324646a --- /dev/null +++ b/packages/agent-bundle/tests/generated-module-specifier.test.ts @@ -0,0 +1,19 @@ +import { posix, win32 } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { generatedModuleSpecifier } from '../src/build/meta.ts'; + +it('names a project module relative to the generated-module directory in POSIX form', () => { + expect(generatedModuleSpecifier('/repo', '/repo/src/events/tool/before.ts', posix)).toBe('../src/events/tool/before.ts'); + expect(generatedModuleSpecifier('/elsewhere/checkout', '/elsewhere/checkout/src/events/tool/before.ts', posix)) + .toBe('../src/events/tool/before.ts'); + expect(generatedModuleSpecifier('C:\\repo', 'C:\\repo\\src\\events\\tool\\before.ts', win32)).toBe('../src/events/tool/before.ts'); +}); + +it('passes bare specifiers and paths outside the project through unchanged', () => { + expect(generatedModuleSpecifier('/repo', 'agent-bundle/route-registry', posix)).toBe('agent-bundle/route-registry'); + expect(generatedModuleSpecifier('/repo', '/repo-other/src/x.ts', posix)).toBe('/repo-other/src/x.ts'); + expect(generatedModuleSpecifier('/repo', '/repo', posix)).toBe('/repo'); + expect(generatedModuleSpecifier('C:\\repo', 'D:\\repo\\src\\x.ts', win32)).toBe('D:\\repo\\src\\x.ts'); +}); diff --git a/packages/agent-bundle/tests/hook-handler-contract.test.ts b/packages/agent-bundle/tests/hook-handler-contract.test.ts index 699a1b070..8c02f019c 100644 --- a/packages/agent-bundle/tests/hook-handler-contract.test.ts +++ b/packages/agent-bundle/tests/hook-handler-contract.test.ts @@ -47,6 +47,7 @@ const agentEventFor: Readonly> }; const wrapperEntry = (host: Host, event: HookHandlerEventName, nativeEvent: string): TargetHookWrapper => ({ + projectRoot: '/project', event, hook: { event, diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 90c14930b..a7bc8657b 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -225,6 +225,7 @@ it('orders manifest hook hosts by tuple without sentinel concatenation', () => { it('keeps the Claude and Codex native wrapper codecs byte-identical apart from identifiers and the target constant', () => { const entry: TargetHookWrapper = { + projectRoot: '/project', event: 'beforeTool', hook: { event: 'beforeTool', @@ -278,6 +279,7 @@ it('wires Compiled event handler into each per-host wrapper and keeps built-in h tools: [], }; const model: NormalizedPlugin = { + projectRoot: '/project', extensions: {}, hooks: [hook], mcpServers: [], @@ -311,7 +313,7 @@ it('wires Compiled event handler into each per-host wrapper and keeps built-in h expect(entry.target).toBe(host); expect(entry.virtualSource).toContain(`const target = ${JSON.stringify(host)};`); expect(entry.virtualSource).toContain('executeEventHandler'); - expect(entry.virtualSource).toContain(`from ${JSON.stringify(handler.source)}`); + expect(entry.virtualSource).toContain('import gateHandler from "../src/events/tool/before.handler.ts";'); expect(entry.virtualSource).toContain('projectEventHandlerResult'); expect(entry.virtualSource).toContain(`./event-route-tool-before.${host}.execute.mjs`); expect(entry.executeVirtualSource).toContain('requestEventRuntime'); @@ -1372,6 +1374,7 @@ it('runs the Cursor workspace/open lifecycle starter through a generated wrapper await compileRslibSurfaces( { cwd: buildRoot, meta: projectMeta(model.metadata), outputRoot }, [planHooksSurface(plan.hookEntries ?? [], { + projectRoot: '/project', artifactEpoch: 'cursor-workspace-open-test', outDir: outputRoot, plugin: { name: model.metadata.name, version: model.metadata.version }, @@ -1819,6 +1822,7 @@ it('rejects malformed native hook input, exports, and handler results concisely' }, 15_000); const hookModel = (root: string): NormalizedPlugin => ({ + projectRoot: '/project', extensions: {}, hooks: [ { diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 3439e2ce6..eb373fc5e 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -116,6 +116,7 @@ const runCodex = async ( }); const model: NormalizedPlugin = { + projectRoot: '/workspace', extensions: {}, hooks: [], marketplace: true, @@ -1292,6 +1293,7 @@ nativeIt('accepts emitted Claude userConfig under strict native validation', asy const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-user-config-')); const outputRoot = join(root, 'plugin'); const model: NormalizedPlugin = { + projectRoot: '/workspace', extensions: { claude: { id: 'extension:claude', diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 0f7e0e2f4..2780d5f63 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -20,6 +20,7 @@ import { removeTree } from './support/remove-tree.ts'; const installFormats = addFormats as unknown as (target: Ajv2020) => void; const plugin = Object.freeze({ + projectRoot: '/workspace', extensions: Object.freeze({}), hooks: Object.freeze([]), marketplace: true as const, diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index 0521c3fb3..91b4b77e3 100644 --- a/packages/agent-bundle/tests/inspect-bundler.test.ts +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -344,6 +344,7 @@ it('inspects the per-host handler wrapper under the composite identity', async ( tools: [], }; const model: NormalizedPlugin = { + projectRoot: '/project', extensions: {}, hooks: [hook], mcpServers: [], diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index fd6468eea..dcaf62889 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -25,6 +25,7 @@ import { removeTree } from './support/remove-tree.ts'; const execFile = promisify(executeFile); const modelFor = (target: string): NormalizedPlugin => ({ + projectRoot: '/project', extensions: {}, hooks: [], mcpServers: [], diff --git a/packages/agent-bundle/tests/plugin-logo.test.ts b/packages/agent-bundle/tests/plugin-logo.test.ts index f37e19777..6a7854091 100644 --- a/packages/agent-bundle/tests/plugin-logo.test.ts +++ b/packages/agent-bundle/tests/plugin-logo.test.ts @@ -43,6 +43,7 @@ const loadedProject = async ( }; const logoModel = (target: 'cursor'): NormalizedPlugin => ({ + projectRoot: '/workspace', extensions: {}, hooks: [], metadata: { diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index be133d250..7665e4080 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -10,6 +10,7 @@ import type { NormalizedPlugin } from '../src/core/types.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; const plugin = (): NormalizedPlugin => ({ + projectRoot: '/workspace', extensions: {}, hooks: [], metadata: { diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index f75aa0577..2dbd215df 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -24,8 +24,8 @@ import { removeTree } from './support/remove-tree.ts'; const eventHandler: CompiledEventHandler = Object.freeze({ provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/tool/before.handler.ts' }), - source: '/project/src/events/tool/before.handler.ts', - view: '/project/src/events/tool/before.view.tsx', + source: '/workspace/src/events/tool/before.handler.ts', + view: '/workspace/src/events/tool/before.view.tsx', }); const metadata = Object.freeze({ @@ -40,6 +40,7 @@ const playgroundCodec = Object.freeze({ }); const planningModel = (hooks: readonly NormalizedHook[]): NormalizedPlugin => ({ + projectRoot: '/workspace', extensions: {}, hooks, mcpServers: [], @@ -186,6 +187,7 @@ it('builds adapter-owned native hook event, layout, and wrapper source', async ( tools: ['file.write' as const], }; const model: NormalizedPlugin = { + projectRoot: '/project', extensions: {}, hooks: [hook], mcpServers: [], @@ -412,7 +414,7 @@ it('runs event-route handler in the per-host wrapper before shared IPC', () => { expect(entry.relativePath).toBe('hooks/beforeTool.synthetic.mjs'); expect(entry.target).toBe('synthetic'); - expect(source).toContain(`from ${JSON.stringify(eventHandler.source)}`); + expect(source).toContain('import gateHandler from "../src/events/tool/before.handler.ts";'); expect(source).toContain('validateNativeEventEnvelope'); expect(source).toContain('createCanonicalEventProps'); expect(source).toContain('executeEventHandler'); diff --git a/packages/agent-bundle/tests/target-mcp-runtime.test.ts b/packages/agent-bundle/tests/target-mcp-runtime.test.ts index 68bbac40b..b82a79db2 100644 --- a/packages/agent-bundle/tests/target-mcp-runtime.test.ts +++ b/packages/agent-bundle/tests/target-mcp-runtime.test.ts @@ -31,6 +31,7 @@ const metadata = Object.freeze({ }); const model = (source: string): NormalizedPlugin => ({ + projectRoot: '/project', extensions: {}, hooks: [], mcpServers: [], From 4a41a814b601bacd13b78e2f19d7c2d32b2550db Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 02:37:44 +0000 Subject: [PATCH 3/4] docs(build): document checkout-independent artifact bytes; mark NormalizedPlugin.projectRoot breaking --- .changeset/checkout-independent-artifacts.md | 4 ++-- website/docs/en/reference/artifact-manifest.mdx | 2 +- website/docs/en/reference/cli.mdx | 4 ++++ website/docs/zh/reference/artifact-manifest.mdx | 2 +- website/docs/zh/reference/cli.mdx | 3 +++ 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.changeset/checkout-independent-artifacts.md b/.changeset/checkout-independent-artifacts.md index 700fcfcea..65d18d338 100644 --- a/.changeset/checkout-independent-artifacts.md +++ b/.changeset/checkout-independent-artifacts.md @@ -1,5 +1,5 @@ --- -"agent-bundle": patch +"agent-bundle": minor --- -Make `agent-bundle build` emit byte-identical artifacts from any checkout path and host platform: generated wrappers import project modules by project-relative POSIX specifiers, and the manifest `modelDigest` no longer hashes absolute route, handler, `web`, or `state` paths (#835). +Make `agent-bundle build` emit byte-identical artifacts from any checkout path: generated wrappers import project modules by project-relative POSIX specifiers, and the manifest `modelDigest` hashes route, handler, `web`, and `state` paths relative to the project root. The exported `NormalizedPlugin` type now requires `projectRoot`; hand-constructed models passed to `build()` must set it (#835). diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx index f04729e87..844a44a5a 100644 --- a/website/docs/en/reference/artifact-manifest.mdx +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -227,7 +227,7 @@ object has exactly seven keys, all required: `adapters`, `agentSkills`, `produce | --- | --- | --- | | `configPath` | yes | The configuration file, relative to the project root. It must appear in `sourceInputs` with a hash equal to `configDigest`. | | `configDigest` | yes | SHA-256 of the configuration file. | -| `modelDigest` | yes | SHA-256 of the normalized model. | +| `modelDigest` | yes | SHA-256 of the normalized model, with project paths hashed relative to the project root, so one source built from two checkout paths yields the same digest. | | `revision` | yes | The digest over `sourceInputs`; a reader recomputes it and rejects a mismatch. | | `sourceInputs[]` | yes | Every project input the build read: `{ path, sha256, executable? }`, sorted by `path`. | | `packageName` | no | The validated npm package name; absent for an unpackaged development project. | diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index d82f3c48b..fc0dce476 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -161,6 +161,10 @@ Both take the source option group plus: Both build package outputs when the project declares `bin` or `lib`. The layout of the root is in [Targets and artifacts](./targets-artifacts.mdx). +The artifact bytes do not depend on where the project is checked out: generated wrappers import +project modules by project-relative specifiers, so one source built from two checkout paths emits +byte-identical files and an identical manifest. + `build` alone also takes: | Option | Default | Meaning | diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx index 64229e96a..e276f8f6f 100644 --- a/website/docs/zh/reference/artifact-manifest.mdx +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -211,7 +211,7 @@ Amp 的 `index.js` 入口,以及 Claude、Codex、Cursor 与 portable 的插 | --- | --- | --- | | `configPath` | 是 | 相对项目根目录的配置文件。它必须出现在 `sourceInputs` 中,且哈希等于 `configDigest`。 | | `configDigest` | 是 | 配置文件的 SHA-256。 | -| `modelDigest` | 是 | 规范化模型的 SHA-256。 | +| `modelDigest` | 是 | 规范化模型的 SHA-256;项目路径按相对项目根目录的形式参与哈希,因此同一份源码从两个检出路径构建会得到相同的摘要。 | | `revision` | 是 | 对 `sourceInputs` 的摘要;读取方会重新计算并拒绝不匹配。 | | `sourceInputs[]` | 是 | 构建读取的每个项目输入:`{ path, sha256, executable? }`,按 `path` 排序。 | | `packageName` | 否 | 经校验的 npm 包名;未打包的开发项目中不存在。 | diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index f693981b6..558efd483 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -153,6 +153,9 @@ SIGTERM。当配置了 `web` 时,手写的 `src/cli/web.ts`(或任何拼作 当项目声明了 `bin` 或 `lib` 时,两者都会构建包输出。根目录的布局见 [Target 与产物](./targets-artifacts.mdx)。 +产物字节与项目的检出位置无关:生成的包装模块以相对项目根目录的说明符导入项目模块,因此同一份源码 +从两个检出路径构建会输出逐字节相同的文件和相同的清单。 + 仅 `build` 还接受: | 选项 | 默认值 | 含义 | From 32374cf5b99b8456abbabe232b74432cf40d1d65 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 02:46:55 +0000 Subject: [PATCH 4/4] fix(build): report project-relative entry paths; exercise relative imports in build fixtures --- .changeset/checkout-independent-artifacts.md | 4 ++-- packages/agent-bundle/src/build/entry-shell.ts | 2 +- packages/agent-bundle/tests/build.test.ts | 4 ++-- packages/agent-bundle/tests/compiler-evidence.test.ts | 2 +- .../agent-bundle/tests/generated-module-evidence.test.ts | 8 ++++---- packages/agent-bundle/tests/hooks.test.ts | 2 +- website/docs/en/reference/artifact-manifest.mdx | 2 +- website/docs/en/reference/cli.mdx | 7 ++++--- website/docs/zh/reference/artifact-manifest.mdx | 2 +- website/docs/zh/reference/cli.mdx | 5 +++-- 10 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.changeset/checkout-independent-artifacts.md b/.changeset/checkout-independent-artifacts.md index 65d18d338..82533fe51 100644 --- a/.changeset/checkout-independent-artifacts.md +++ b/.changeset/checkout-independent-artifacts.md @@ -1,5 +1,5 @@ --- -"agent-bundle": minor +"agent-bundle": patch --- -Make `agent-bundle build` emit byte-identical artifacts from any checkout path: generated wrappers import project modules by project-relative POSIX specifiers, and the manifest `modelDigest` hashes route, handler, `web`, and `state` paths relative to the project root. The exported `NormalizedPlugin` type now requires `projectRoot`; hand-constructed models passed to `build()` must set it (#835). +Make `agent-bundle build` emit byte-identical artifacts when one source is built from different checkout paths: generated wrappers import project modules by project-relative POSIX specifiers, and the manifest `modelDigest` hashes route, handler, `web`, and `state` paths relative to the project root. The `NormalizedPlugin` model returned by `validate`, `inspect`, and `build` now carries `projectRoot` (#835). diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index fab30f8cf..56ded7fb7 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -151,7 +151,7 @@ export const generatedExecutableEntrySource = (options: { '', `const main = entry[${JSON.stringify(options.exportName)}];`, "if (typeof main !== 'function') {", - ` throw new TypeError('Executable entry must export a ${options.exportName} function: ' + ${JSON.stringify(generatedModuleSpecifier(options.projectRoot, options.entrySource))});`, + ` throw new TypeError('Executable entry must export a ${options.exportName} function: ' + ${JSON.stringify(generatedModuleSpecifier(options.projectRoot, options.entrySource).replace(/^\.\.\//u, ''))});`, '}', `const code = await main(process.argv.slice(2), Object.freeze({ terminal: detectProcessTerminal(${JSON.stringify(options.hostSurface ?? 'script')}) }));`, "if (typeof code === 'number') process.exitCode = code;", diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 64e080289..eca652913 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -213,7 +213,7 @@ const buildFromSource = async ( }; const modelFor = (project: TestProject): NormalizedPlugin => ({ - projectRoot: '/project', + projectRoot: project.root, assets: [{ bytes: Buffer.byteLength('project logo\n'), id: 'asset:branding/logo.svg', @@ -648,7 +648,7 @@ it('rejects hook entries stamped for a target other than their selected adapter' diagnostics: [], entries: [], hookEntries: [{ - projectRoot: '/project', + projectRoot: project.root, event: hook.event, hook, nativeEvent: 'SessionStart', diff --git a/packages/agent-bundle/tests/compiler-evidence.test.ts b/packages/agent-bundle/tests/compiler-evidence.test.ts index 10a432ba0..913470f00 100644 --- a/packages/agent-bundle/tests/compiler-evidence.test.ts +++ b/packages/agent-bundle/tests/compiler-evidence.test.ts @@ -57,7 +57,7 @@ const padScript = [ ].join('\n'); const modelFor = (root: string, scripts: Readonly>): NormalizedPlugin => ({ - projectRoot: '/project', + projectRoot: root, assets: [], extensions: {}, hooks: [], diff --git a/packages/agent-bundle/tests/generated-module-evidence.test.ts b/packages/agent-bundle/tests/generated-module-evidence.test.ts index 0825890ea..12ace3c1d 100644 --- a/packages/agent-bundle/tests/generated-module-evidence.test.ts +++ b/packages/agent-bundle/tests/generated-module-evidence.test.ts @@ -96,7 +96,7 @@ const configHook = (root: string): NormalizedHook => ({ }); const claudeWrapper = (root: string): TargetHookWrapper => ({ - projectRoot: '/project', + projectRoot: root, event: 'sessionStart', hook: configHook(root), nativeEvent: 'SessionStart', @@ -125,7 +125,7 @@ const generators: ReadonlyArray<{ outputRelativePath: 'bin/main.js', source, virtualSource: generatedExecutableEntrySource({ - projectRoot: '/project', + projectRoot: root, entrySource: source, exportName: 'main', hostSurface: 'cli', @@ -141,7 +141,7 @@ const generators: ReadonlyArray<{ outputRelativePath: 'bin/cli.js', source: join(root, 'src', 'cli', 'report.ts'), virtualSource: generatedCliBinEntrySource({ - projectRoot: '/project', + projectRoot: root, commands: [plainCommand], plugin, routes: [cliRoute(root)], @@ -161,7 +161,7 @@ const generators: ReadonlyArray<{ outputRelativePath: 'mcp/curator.mjs', source, virtualModules: [stdioPreludeVirtualModule()], - virtualSource: generatedStdioMcpEntrySource({ projectRoot: '/project', entrySource: source, serverName: 'curator' }), + virtualSource: generatedStdioMcpEntrySource({ projectRoot: root, entrySource: source, serverName: 'curator' }), }); }, }, diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index a7bc8657b..07a21c913 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1822,7 +1822,7 @@ it('rejects malformed native hook input, exports, and handler results concisely' }, 15_000); const hookModel = (root: string): NormalizedPlugin => ({ - projectRoot: '/project', + projectRoot: root, extensions: {}, hooks: [ { diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx index 844a44a5a..c1fef04b0 100644 --- a/website/docs/en/reference/artifact-manifest.mdx +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -227,7 +227,7 @@ object has exactly seven keys, all required: `adapters`, `agentSkills`, `produce | --- | --- | --- | | `configPath` | yes | The configuration file, relative to the project root. It must appear in `sourceInputs` with a hash equal to `configDigest`. | | `configDigest` | yes | SHA-256 of the configuration file. | -| `modelDigest` | yes | SHA-256 of the normalized model, with project paths hashed relative to the project root, so one source built from two checkout paths yields the same digest. | +| `modelDigest` | yes | SHA-256 of the normalized model, with project paths hashed relative to the project root, so the digest does not change with the checkout path. | | `revision` | yes | The digest over `sourceInputs`; a reader recomputes it and rejects a mismatch. | | `sourceInputs[]` | yes | Every project input the build read: `{ path, sha256, executable? }`, sorted by `path`. | | `packageName` | no | The validated npm package name; absent for an unpackaged development project. | diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index fc0dce476..554af6e2d 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -161,9 +161,10 @@ Both take the source option group plus: Both build package outputs when the project declares `bin` or `lib`. The layout of the root is in [Targets and artifacts](./targets-artifacts.mdx). -The artifact bytes do not depend on where the project is checked out: generated wrappers import -project modules by project-relative specifiers, so one source built from two checkout paths emits -byte-identical files and an identical manifest. +Generated wrappers import project modules by project-relative specifiers, so when every compiled +module lies inside the project root (its own `node_modules` included), one source built from two +checkout paths emits byte-identical files and an identical manifest. A module resolved outside the +root keeps a path relative to it. `build` alone also takes: diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx index e276f8f6f..349cfa12d 100644 --- a/website/docs/zh/reference/artifact-manifest.mdx +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -211,7 +211,7 @@ Amp 的 `index.js` 入口,以及 Claude、Codex、Cursor 与 portable 的插 | --- | --- | --- | | `configPath` | 是 | 相对项目根目录的配置文件。它必须出现在 `sourceInputs` 中,且哈希等于 `configDigest`。 | | `configDigest` | 是 | 配置文件的 SHA-256。 | -| `modelDigest` | 是 | 规范化模型的 SHA-256;项目路径按相对项目根目录的形式参与哈希,因此同一份源码从两个检出路径构建会得到相同的摘要。 | +| `modelDigest` | 是 | 规范化模型的 SHA-256;项目路径按相对项目根目录的形式参与哈希,因此摘要不随检出路径变化。 | | `revision` | 是 | 对 `sourceInputs` 的摘要;读取方会重新计算并拒绝不匹配。 | | `sourceInputs[]` | 是 | 构建读取的每个项目输入:`{ path, sha256, executable? }`,按 `path` 排序。 | | `packageName` | 否 | 经校验的 npm 包名;未打包的开发项目中不存在。 | diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 558efd483..ca98c4189 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -153,8 +153,9 @@ SIGTERM。当配置了 `web` 时,手写的 `src/cli/web.ts`(或任何拼作 当项目声明了 `bin` 或 `lib` 时,两者都会构建包输出。根目录的布局见 [Target 与产物](./targets-artifacts.mdx)。 -产物字节与项目的检出位置无关:生成的包装模块以相对项目根目录的说明符导入项目模块,因此同一份源码 -从两个检出路径构建会输出逐字节相同的文件和相同的清单。 +生成的包装模块以相对项目根目录的说明符导入项目模块,因此当所有被编译的模块都位于项目根目录内(包括其 +自身的 `node_modules`)时,同一份源码从两个检出路径构建会输出逐字节相同的文件和相同的清单。解析到根目录 +之外的模块会保留相对根目录的路径。 仅 `build` 还接受: