From b1b7ed5e8a18b77c671d98bf1121ba035692dba0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 03:09:15 +0000 Subject: [PATCH 1/2] fix(mcp-run): load the project-root .env set and expand env state anchors to the project root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp run now owns the operator-environment seam RFC #50 §3.1 promised: the project-root .env set (rsbuild loadEnv conventions) loads by default, with repeatable --env-file overrides and a --no-env opt-out. The launch environment layers lowest to highest as manifest env < .env files < operator process.env, so an exported variable is never silently beaten by the manifest spread. Plugin-root tokens in env values — including the injected AGENT_BUNDLE_PLUGIN_ROOT durable-state anchor — expand to the resolved project root instead of the rebuildable artifact target root, while args/cwd stay artifact-rooted; --plugin-root restores the byte-faithful copied-artifact rehearsal. Closes #58, closes #59. --- .changeset/mcp-run-env-and-anchor.md | 9 ++ docs/entry-conventions.md | 37 ++++- packages/agent-bundle/README.md | 2 +- packages/agent-bundle/src/api.ts | 28 +++- packages/agent-bundle/src/cli.ts | 20 ++- packages/agent-bundle/src/services/mcp-run.ts | 86 ++++++++++- .../agent-bundle/tests/package-build.test.ts | 141 ++++++++++++++++++ 7 files changed, 313 insertions(+), 10 deletions(-) create mode 100644 .changeset/mcp-run-env-and-anchor.md diff --git a/.changeset/mcp-run-env-and-anchor.md b/.changeset/mcp-run-env-and-anchor.md new file mode 100644 index 000000000..cf209fff6 --- /dev/null +++ b/.changeset/mcp-run-env-and-anchor.md @@ -0,0 +1,9 @@ +--- +"agent-bundle": minor +--- + +`mcp run` now owns the operator-environment seam the RFC #50 launchers were meant to retire, and stops fragmenting durable state per rebuild. + +- The runner loads the project-root `.env` set by default (rsbuild `loadEnv` conventions: `.env`, `.env.local`, `.env.`, `.env..local`), with `--env-file ` (repeatable, replaces the conventional set) and `--no-env` overrides. A named file that cannot be read is an error. +- Launch-environment precedence is now documented and enforced, lowest to highest: manifest env, `.env` file layer, operator `process.env`. Previously manifest env was spread last and silently beat operator exports (for example `AGENT_BUNDLE_PLUGIN_ROOT`). +- Plugin-root path tokens in env values — including the injected `AGENT_BUNDLE_PLUGIN_ROOT` durable-state anchor — now expand to the resolved project root under `mcp run` instead of the ephemeral `artifact/` root, so consumer state survives rebuilds. `args`/`cwd` stay artifact-rooted (the entry is the content-hashed bundle inside the artifact). `--plugin-root ` restores a byte-faithful copied-artifact rehearsal when wanted. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 7daa2e597..8f6925dbe 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -137,6 +137,7 @@ overriding the contract. The hatch customizes *how code compiles*, never ```sh agent-bundle mcp run --server --target [--artifact ] + [--env-file ]... [--no-env] [--plugin-root ] ``` Runs one built stdio MCP server in the foreground with inherited stdio: the @@ -144,5 +145,37 @@ content-hashed generated entry is resolved from the target's MCP manifest (the job previously solved with bash launchers parsing `mcp.json`), path tokens are resolved through the target adapter, and the child's exit code is forwarded (SIGINT/SIGTERM forward to the child). Without `--artifact`, a -temporary artifact is built first. State anchored on the plugin-data token -persists under `.agent-bundle/mcp-run//` in the project root. +temporary artifact is built first. + +### Launch environment + +The runner loads the project-root `.env` set by default — rsbuild's `loadEnv` +conventions (`.env`, `.env.local`, `.env.`, `.env..local`, with +`--mode` selecting the variants), the same files `createRslib` reads for the +same consumers at build time — so operator credentials configured for the +plugin reach a bare `mcp run` without a wrapper script. `--env-file ` +(repeatable, Node's `--env-file` dialect, later files win) replaces the +conventional set with exactly the named files, and `--no-env` skips the layer +entirely; a named file that cannot be read is an error, never a silent skip. + +The child environment is composed from three layers. This table is the +canonical precedence order (highest wins): + +| Precedence | Layer | Contents | +| --- | --- | --- | +| 3 (highest) | Operator `process.env` | The real environment `mcp run` was started with. An exported variable always wins. | +| 2 | `.env` file layer | The conventional project-root set, or the explicit `--env-file` list in order. Fills gaps only; never beats an exported variable. | +| 1 (lowest) | Manifest env | Entries declared in the server config plus the injected plugin-root anchor, path tokens expanded. | + +### Durable-state anchors + +Under `mcp run` the artifact is an ephemeral build product, so both +durable-state anchors point at the project root: state anchored on the +plugin-data token persists under `.agent-bundle/mcp-run//`, +and plugin-root tokens in *env values* — including the injected +`AGENT_BUNDLE_PLUGIN_ROOT` anchor — expand to the project root itself. +`args` and `cwd` stay artifact-rooted (the first argument is the +content-hashed bundle inside the target root). `--plugin-root ` +overrides the env-anchor root, e.g. point it at `artifact/` for a +byte-faithful rehearsal of a copied-artifact launch; under a host install the +anchor still means the durable install root, exactly as before. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index a5371b0f1..fa722f371 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -63,7 +63,7 @@ the repository's `docs/entry-conventions.md` for the full contract. | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect normalized targets and adapter plans from source. | | `agent-bundle mcp list` / `mcp invoke` | List or invoke one MCP tool from an artifact. | -| `agent-bundle mcp run` | Run one built stdio MCP server in the foreground, resolving its hashed entry. | +| `agent-bundle mcp run` | Run one built stdio MCP server in the foreground, resolving its hashed entry, loading the project-root `.env` set (`--env-file`/`--no-env` to override), and expanding env state anchors to the project root (`--plugin-root` to override). Environment precedence: manifest env < `.env` files < operator `process.env`. | | `agent-bundle hooks list` / `hooks simulate` | List generated hooks, or run one emitted wrapper. | | `agent-bundle eval` | Run deterministic or native Claude/Codex eval suites and record a run. | | `agent-bundle dev` | Serve the packaged developer workbench on loopback. | diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index be487b509..e9d0d5255 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -267,6 +267,22 @@ export interface CompareEvalsOptions extends ProjectOptions { } export interface RunMcpOptions extends ArtifactOperationOptions { + /** + * Explicit `.env` files replacing the conventional project-root set. + * Loaded in order, later files winning on collision; relative paths + * resolve from the working directory. + */ + readonly envFiles?: readonly string[]; + /** Set false to launch the server without any `.env` layer. */ + readonly loadEnvFiles?: boolean; + /** + * Root the env-declared plugin-root anchors (for example + * `AGENT_BUNDLE_PLUGIN_ROOT`) expand to. Defaults to the project root so + * durable server state survives artifact rebuilds; point it at the + * artifact target root for a byte-faithful rehearsal of a copied-artifact + * launch. + */ + readonly pluginRoot?: string; readonly server: string; /** Injectable only to make foreground process behavior deterministic in tests. */ readonly spawnProcess?: Parameters[0]['spawnProcess']; @@ -648,14 +664,22 @@ export const invokeMcp = async (options: InvokeMcpOptions): Promise/` in the project root. + * Both durable-state anchors point at the project root: plugin-data state + * persists under `.agent-bundle/mcp-run//`, and env-declared + * plugin-root anchors expand to the project root itself (override with + * `pluginRoot`). The launch environment layers, lowest to highest: manifest + * env, the project-root `.env` set (or `envFiles`), the operator's real + * `process.env`. */ export const runMcp = async (options: RunMcpOptions): Promise => { const registry = registryFor(options); const workspaceRoot = resolve(options.root); return temporaryArtifact({ ...options, registry }, async (artifact) => runMcpForeground({ artifact, + ...(options.envFiles === undefined ? {} : { envFiles: options.envFiles }), + ...(options.pluginRoot === undefined ? {} : { envPluginRoot: resolve(options.pluginRoot) }), + ...(options.loadEnvFiles === undefined ? {} : { loadEnvFiles: options.loadEnvFiles }), + ...(options.mode === undefined ? {} : { mode: options.mode }), pluginDataRoot: join(workspaceRoot, '.agent-bundle', 'mcp-run', options.target, mcpServerStateDirectory(options.server)), registry, server: options.server, diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 691439888..43cacf164 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -457,13 +457,29 @@ export const runCli = async ( const mcpRunCommand = configureArtifactOptions( mcpCommand.command('run').description('Run one stdio MCP server in the foreground from an artifact'), true, - ).requiredOption('--server ', 'MCP server name'); - mcpRunCommand.action(async (options: ArtifactCommandOptions & { readonly server: string; readonly target: string }) => { + ) + .requiredOption('--server ', 'MCP server name') + .option('--env-file ', 'Load exactly this .env file, replacing the project-root set (repeatable)', collect, []) + .option('--no-env', 'Launch without loading any .env files') + .option('--plugin-root ', 'Expand env plugin-root anchors against this root instead of the project root'); + mcpRunCommand.action(async (options: ArtifactCommandOptions & { + readonly env: boolean; + readonly envFile: readonly string[]; + readonly pluginRoot?: string; + readonly server: string; + readonly target: string; + }) => { + if (options.env === false && options.envFile.length > 0) { + throw new TypeError('Use either --env-file or --no-env, not both.'); + } const { runMcp } = await import('./api.ts'); // No stdout writes here: with inherited stdio the server owns the // JSON-RPC channel for the whole foreground run. exitCode = await runMcp({ ...artifactOptions(options), + ...(options.envFile.length === 0 ? {} : { envFiles: options.envFile }), + ...(options.env === false ? { loadEnvFiles: false } : {}), + ...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }), server: options.server, target: options.target, }); diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index 42ebdda2d..8a7215e0f 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -1,6 +1,8 @@ +import { loadEnv } from '@rsbuild/core'; import { spawn, type ChildProcess } from 'node:child_process'; import { mkdir, readFile } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; +import { parseEnv } from 'node:util'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; import { validateArtifact } from '../build/validate-artifact.ts'; @@ -9,7 +11,11 @@ import { sha256Hex } from '../core/digest.ts'; import { assertInside, joinArtifact } from '../core/paths.ts'; import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { resolveMcpPathTokens } from './mcp-path-tokens.ts'; -import { readTargetMcpServer, type ModernMcpStdioServer } from './mcp-runtime.ts'; +import { + readTargetMcpServer, + type ModernMcpStdioServer, + type TargetMcpRuntimeContract, +} from './mcp-runtime.ts'; /** * The foreground MCP server runner behind `agent-bundle mcp run`: it resolves @@ -27,6 +33,15 @@ export interface ResolvedMcpStdioLaunch { export interface ResolveMcpStdioLaunchOptions { readonly artifact: string; + /** + * Root that plugin-root path tokens in *env values* expand to — the + * durable-state anchors like `AGENT_BUNDLE_PLUGIN_ROOT`. Defaults to + * `workspaceRoot`: under `mcp run` the artifact is an ephemeral build + * product, so anchoring durable state on it would fragment that state per + * rebuild. Point it back at the artifact target root for a byte-faithful + * rehearsal of a copied-artifact launch. + */ + readonly envPluginRoot?: string; /** Durable per-server state root replacing the plugin-data path token. */ readonly pluginDataRoot: string; readonly registry?: TargetRegistry; @@ -85,13 +100,27 @@ export const resolveMcpStdioLaunch = async ( throw new Error(`MCP server ${JSON.stringify(options.server)} in target ${JSON.stringify(options.target)} is invalid.`); } + /** + * Per-field plugin-root split: `args`/`cwd` must stay artifact-rooted + * (`args[0]` is the content-hashed bundle inside the target root), but env + * values are durable-state anchors, so their plugin-root tokens expand to + * the durable `envPluginRoot` instead of the rebuildable artifact. + */ + const envPluginRoot = resolve(options.envPluginRoot ?? options.workspaceRoot); + const launchRuntime: TargetMcpRuntimeContract = { + manifestPath: runtime.manifestPath, + readModernServers: (document) => runtime.readModernServers(document), + resolveStdioArgument: (value, roots) => runtime.resolveStdioArgument(value, roots), + resolveValue: (field, roots, value) => + runtime.resolveValue(field, field === 'env' ? { ...roots, pluginRoot: envPluginRoot } : roots, value), + }; const resolved = resolveMcpPathTokens({ roots: { pluginData: resolve(options.pluginDataRoot), pluginRoot: targetRoot, workspaceRoot: resolve(options.workspaceRoot), }, - runtime, + runtime: launchRuntime, server: result.server, target: options.target, }); @@ -108,6 +137,17 @@ export const resolveMcpStdioLaunch = async ( }; export interface RunMcpForegroundOptions extends ResolveMcpStdioLaunchOptions { + /** + * Explicit `.env` files replacing the conventional workspace-root set. + * Files use Node's `--env-file` dialect and load in order, later files + * winning on collision; relative paths resolve from the working directory. + * A named file that cannot be read is an error, never a silent skip. + */ + readonly envFiles?: readonly string[]; + /** Set false to launch without any `.env` layer. */ + readonly loadEnvFiles?: boolean; + /** Configuration mode selecting `.env.` variants of the conventional set. */ + readonly mode?: string; /** Injectable only to make foreground process behavior deterministic in tests. */ readonly spawnProcess?: ( command: string, @@ -116,10 +156,49 @@ export interface RunMcpForegroundOptions extends ResolveMcpStdioLaunchOptions { ) => ChildProcess; } +/** + * The `.env` layer of the launch environment: explicit `--env-file` paths + * when given, otherwise rsbuild's `loadEnv` convention (`.env`, `.env.local`, + * `.env.`, `.env..local`) at the workspace root — the same files + * `createRslib` reads for the same consumers at build time. Loading targets a + * scratch object so the real `process.env` is never mutated; `processEnv` + * still seeds `${VAR}` interpolation inside env-file values. + */ +const loadLaunchFileEnv = async ( + options: RunMcpForegroundOptions, + processEnv: Readonly>, +): Promise> => { + if (options.loadEnvFiles === false) return {}; + if (options.envFiles !== undefined && options.envFiles.length > 0) { + const merged: Record = {}; + for (const file of options.envFiles) { + const path = resolve(file); + let contents: string; + try { + contents = await readFile(path, 'utf8'); + } catch { + throw new Error(`Cannot read env file ${JSON.stringify(path)}.`); + } + Object.assign(merged, parseEnv(contents)); + } + return merged; + } + return loadEnv({ + cwd: resolve(options.workspaceRoot), + ...(options.mode === undefined ? {} : { mode: options.mode }), + processEnv: { ...processEnv }, + }).parsed; +}; + /** * Resolves the server's generated entry from the built artifact and runs it * in the foreground with inherited stdio. SIGINT/SIGTERM forward to the * child; the child's exit code (or 128 + signal number) is returned. + * + * Launch environment precedence, lowest to highest: manifest env (declared + * entries plus the injected plugin-root anchor, path tokens expanded), the + * `.env` file layer, then the operator's real `process.env` — an exported + * variable always beats every file- or manifest-declared value. */ export const runMcpForeground = async (options: RunMcpForegroundOptions): Promise => { const launch = await resolveMcpStdioLaunch(options); @@ -127,10 +206,11 @@ export const runMcpForeground = async (options: RunMcpForegroundOptions): Promis const inheritedEnv = Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), ); + const fileEnv = await loadLaunchFileEnv(options, inheritedEnv); const spawnProcess = options.spawnProcess ?? ((command, args, spawnOptions) => spawn(command, [...args], spawnOptions)); const child = spawnProcess(launch.command, launch.args, { cwd: launch.cwd, - env: { ...inheritedEnv, ...launch.env }, + env: { ...launch.env, ...fileEnv, ...inheritedEnv }, stdio: 'inherit', }); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 9e136da67..b31d14942 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -242,6 +242,147 @@ describe('mcp run', () => { await expect(stat(join(launches[0]!.cwd, launches[0]!.args[0]!))).resolves.toMatchObject({}); }, 120_000); + it('layers the launch environment: manifest env under .env files under operator process.env', async () => { + const root = await fixtureRoot({ + ...conventionFixture(), + 'agent-bundle.config.ts': [ + 'export default {', + ' mcp: { servers: { echoer: { env: {', + " SHARED: 'manifest',", + " STATE_DIR: 'agent-bundle:path:plugin-root/.runtime',", + ' } } } },', + " plugin: { name: 'package-build-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + '.env': 'FROM_DOTENV=dotenv\nSHARED=dotenv\nMCP_RUN_BEATEN_BY_PROCESS=dotenv\n', + '.env.staging': 'FROM_MODE=staging\n', + 'custom.env': 'CUSTOM_ONLY=custom\n', + }); + const artifact = join(root, 'artifact'); + await build({ output: 'artifact', root }); + const base = { artifact, root, server: 'echoer', target: 'portable' }; + + type Launch = { args: readonly string[]; command: string; cwd: string; env: Readonly> }; + const captureLaunch = async (options: Parameters[0]): Promise => { + const launches: Launch[] = []; + const child = new EventEmitter() as import('node:child_process').ChildProcess; + child.kill = () => true; + await expect(runMcp({ + ...options, + spawnProcess: (command, args, spawnOptions) => { + launches.push({ args, command, cwd: spawnOptions.cwd, env: spawnOptions.env }); + queueMicrotask(() => child.emit('exit', 0, null)); + return child; + }, + })).resolves.toBe(0); + expect(launches).toHaveLength(1); + return launches[0]!; + }; + + // Bare run: .env fills gaps, .env beats manifest env, and the plugin-root + // env anchors expand to the durable project root — not the artifact. + const bare = await captureLaunch(base); + expect(bare.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(root); + expect(bare.env.STATE_DIR).toBe(join(root, '.runtime')); + expect(bare.env.FROM_DOTENV).toBe('dotenv'); + expect(bare.env.SHARED).toBe('dotenv'); + // args/cwd stay artifact-rooted: args[0] is the content-hashed bundle. + expect(bare.args[0]).toMatch(/mcp-echoer-[a-f\d]{8}\.mjs$/u); + expect(bare.cwd).toBe(join(artifact, 'portable')); + // Loading never leaks .env values into the runner's own environment. + expect(process.env.FROM_DOTENV).toBeUndefined(); + + // Operator exports beat both the .env layer and the manifest anchor. + process.env.MCP_RUN_BEATEN_BY_PROCESS = 'process'; + process.env.AGENT_BUNDLE_PLUGIN_ROOT = '/operator/pin'; + try { + const exported = await captureLaunch(base); + expect(exported.env.MCP_RUN_BEATEN_BY_PROCESS).toBe('process'); + expect(exported.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe('/operator/pin'); + } finally { + delete process.env.MCP_RUN_BEATEN_BY_PROCESS; + delete process.env.AGENT_BUNDLE_PLUGIN_ROOT; + } + + // The mode variants of the conventional set participate. + const staged = await captureLaunch({ ...base, mode: 'staging' }); + expect(staged.env.FROM_MODE).toBe('staging'); + + // Explicit env files replace the conventional set; opting out drops the + // layer without touching the anchor expansion. + const custom = await captureLaunch({ ...base, envFiles: [join(root, 'custom.env')] }); + expect(custom.env.CUSTOM_ONLY).toBe('custom'); + expect(custom.env.FROM_DOTENV).toBeUndefined(); + const disabled = await captureLaunch({ ...base, loadEnvFiles: false }); + expect(disabled.env.FROM_DOTENV).toBeUndefined(); + expect(disabled.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(root); + + // pluginRoot restores the byte-faithful artifact-rooted rehearsal. + const rehearsal = await captureLaunch({ ...base, pluginRoot: join(artifact, 'portable') }); + expect(rehearsal.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(join(artifact, 'portable')); + expect(rehearsal.env.STATE_DIR).toBe(join(artifact, 'portable', '.runtime')); + + // A named env file that cannot be read is an error, never a silent skip. + await expect(runMcp({ ...base, envFiles: [join(root, 'missing.env')] })) + .rejects.toThrow(/Cannot read env file/u); + }, 120_000); + + it('anchors consumer state at the project root under a bare CLI mcp run', async () => { + const root = await fixtureRoot({ + 'agent-bundle.config.ts': [ + 'export default {', + " mcp: { servers: { pinner: { entry: './src/pin.ts' } } },", + " plugin: { name: 'package-build-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n', + '.env': 'MCP_RUN_TRACKER_COOKIE=secret\n', + // A consumer trusting the documented anchor exactly as PR #49 intends. + 'src/pin.ts': [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "const anchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? '';", + "mkdirSync(join(anchor, '.runtime'), { recursive: true });", + "writeFileSync(join(anchor, '.runtime', 'state.json'), JSON.stringify({", + ' anchor,', + " cookie: process.env.MCP_RUN_TRACKER_COOKIE ?? null,", + '}));', + '', + ].join('\n'), + }); + await build({ output: 'artifact', root }); + const exitCode = await runCli([ + 'mcp', 'run', + '--root', root, + '--artifact', join(root, 'artifact'), + '--target', 'portable', + '--server', 'pinner', + ]); + expect(exitCode).toBe(0); + const state = JSON.parse(await readFile(join(root, '.runtime', 'state.json'), 'utf8')) as { + anchor: string; + cookie: string | null; + }; + expect(state.anchor).toBe(root); + expect(state.cookie).toBe('secret'); + // Nothing durable may land inside the rebuildable artifact. + await expect(stat(join(root, 'artifact', 'portable', '.runtime'))).rejects.toMatchObject({ code: 'ENOENT' }); + }, 120_000); + + it('rejects --env-file combined with --no-env', async () => { + const stderr: string[] = []; + const exitCode = await runCli( + ['mcp', 'run', '--root', '.', '--artifact', 'artifact', '--target', 'portable', '--server', 's', '--env-file', 'x.env', '--no-env'], + { stderr: { write: (chunk: string) => stderr.push(chunk) }, stdout: { write: () => undefined } }, + ); + expect(exitCode).toBe(1); + expect(stderr.join('')).toContain('Use either --env-file or --no-env, not both.'); + }); + it('runs a built server end to end through the CLI and forwards its exit code', async () => { const root = await fixtureRoot({ 'agent-bundle.config.ts': [ From e87b755798b3e727c9be86e0fcab28ff739a4153 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 03:17:22 +0000 Subject: [PATCH 2/2] fix(mcp-run): re-anchor Codex's relative plugin-root env values durably MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex has no path-token interpolation: the adapter serializes the plugin-root env anchor as a literal "./" resolved against a plugin-root cwd. The env-field root swap alone left those values pointing into the artifact via the launch cwd. Env values now also pass through the target's own resolveStdioArgument relative rule against the durable env plugin root — identity for every token-interpolating target. --- docs/entry-conventions.md | 2 ++ packages/agent-bundle/src/services/mcp-run.ts | 11 +++++++++-- packages/agent-bundle/tests/package-build.test.ts | 8 +++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 8f6925dbe..95e569ff2 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -174,6 +174,8 @@ durable-state anchors point at the project root: state anchored on the plugin-data token persists under `.agent-bundle/mcp-run//`, and plugin-root tokens in *env values* — including the injected `AGENT_BUNDLE_PLUGIN_ROOT` anchor — expand to the project root itself. +Targets without token interpolation (Codex serializes the anchor as a `./` +path) re-anchor their relative env values against the same durable root. `args` and `cwd` stay artifact-rooted (the first argument is the content-hashed bundle inside the target root). `--plugin-root ` overrides the env-anchor root, e.g. point it at `artifact/` for a diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index 8a7215e0f..899990257 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -111,8 +111,15 @@ export const resolveMcpStdioLaunch = async ( manifestPath: runtime.manifestPath, readModernServers: (document) => runtime.readModernServers(document), resolveStdioArgument: (value, roots) => runtime.resolveStdioArgument(value, roots), - resolveValue: (field, roots, value) => - runtime.resolveValue(field, field === 'env' ? { ...roots, pluginRoot: envPluginRoot } : roots, value), + resolveValue: (field, roots, value) => { + if (field !== 'env') return runtime.resolveValue(field, roots, value); + const envRoots = { ...roots, pluginRoot: envPluginRoot }; + const resolution = runtime.resolveValue(field, envRoots, value); + // Targets without token interpolation (Codex) serialize the anchor as + // a `./` path instead: the target's own relative-argument rule + // re-anchors it against the durable root the tokens expand to. + return { ...resolution, value: runtime.resolveStdioArgument(resolution.value, envRoots) }; + }, }; const resolved = resolveMcpPathTokens({ roots: { diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index b31d14942..55d7c7f8c 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -252,7 +252,7 @@ describe('mcp run', () => { " STATE_DIR: 'agent-bundle:path:plugin-root/.runtime',", ' } } } },', " plugin: { name: 'package-build-fixture', version: '1.0.0' },", - " targets: ['portable'],", + " targets: ['codex', 'portable'],", '};', '', ].join('\n'), @@ -324,6 +324,12 @@ describe('mcp run', () => { expect(rehearsal.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(join(artifact, 'portable')); expect(rehearsal.env.STATE_DIR).toBe(join(artifact, 'portable', '.runtime')); + // Codex has no token interpolation — its anchor is a `./` path, so the + // target's own relative rule must re-anchor it durably too. + const codex = await captureLaunch({ ...base, target: 'codex' }); + expect(codex.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(root); + expect(codex.env.STATE_DIR).toBe(join(root, '.runtime')); + // A named env file that cannot be read is an error, never a silent skip. await expect(runMcp({ ...base, envFiles: [join(root, 'missing.env')] })) .rejects.toThrow(/Cannot read env file/u);