From 09323c7b5659ae8ab5af25f58fdd2ca7d8a9e6fb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 19:24:23 +0000 Subject: [PATCH 1/9] fix(scripts): choose the TypeScript transform flag Node actually supports (Node 26 drops --experimental-transform-types) runScript spawned every plain .ts script under node --experimental-transform-types. Node 26 removed the flag (nodejs/node#61803) and rejects it as a bad option (exit code 9), so the Verify (Node 26) leg failed on every main push. typeScriptTransformFlags (core/runtime.ts) decides from process.allowedNodeEnvironmentFlags: the transform flag where the binary accepts it (Node 22, 24), nothing on Node 26, which strips types unflagged. Unit-tested against the flag sets of each release line. --- .changeset/node26-transform-types.md | 5 +++ packages/agent-bundle/src/core/runtime.ts | 30 ++++++++++++++ packages/agent-bundle/src/test/script.ts | 11 +++-- packages/agent-bundle/tests/core.test.ts | 41 +++++++++++++++++++ website/docs/en/guide/development/testing.mdx | 2 +- website/docs/zh/guide/development/testing.mdx | 2 +- 6 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 .changeset/node26-transform-types.md diff --git a/.changeset/node26-transform-types.md b/.changeset/node26-transform-types.md new file mode 100644 index 000000000..8a7fa51a8 --- /dev/null +++ b/.changeset/node26-transform-types.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Run plain `.ts` scripts through `runScript` (`agent-bundle/test`) on Node 26: the child process gets `--experimental-transform-types` only where the running Node accepts it (Node 22 and 24) and no TypeScript flag on Node 26, which removed that flag and strips types by default. Previously every plain-script dispatch on Node 26 exited with code 9 (`node: bad option: --experimental-transform-types`) before the script ran; TypeScript-only syntax such as `enum` in a plain script now fails on Node 26 exactly as it does under `node file.ts`. (#PR) diff --git a/packages/agent-bundle/src/core/runtime.ts b/packages/agent-bundle/src/core/runtime.ts index df5dbfde8..623074633 100644 --- a/packages/agent-bundle/src/core/runtime.ts +++ b/packages/agent-bundle/src/core/runtime.ts @@ -27,3 +27,33 @@ export const satisfiesGeneratedRuntimeFloor = (candidate: readonly [number, numb /** Canonical `major.minor.patch` form of a parsed runtime version. */ export const formatRuntimeVersion = (version: readonly [number, number, number]): string => `${version[0]}.${version[1]}.${version[2]}`; + +/** + * The flag Node 22 and 24 take to lower TypeScript-only syntax while loading + * a `.ts` source. Node 26 removed it (nodejs/node#61803) and rejects it as a + * bad option (exit code 9). + */ +const TRANSFORM_TYPES_FLAG = '--experimental-transform-types'; + +/** + * The `node` flags a child of this process needs to run a TypeScript source + * with the fullest TypeScript support its binary has, decided by the flags + * the binary accepts rather than by its version: + * + * - Node 22 and 24 accept `--experimental-transform-types`, which lowers + * TypeScript-only syntax (enums, namespaces, parameter properties) on top + * of stripping type annotations; + * - Node 26 removed the flag with no stable successor and strips types by + * default (`process.features.typescript === 'strip'`), so the child gets no + * flag and TypeScript-only syntax fails there exactly as it does under + * `node file.ts`: `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. + * + * `allowedFlags` defaults to the parent's `process.allowedNodeEnvironmentFlags`; + * a child launched over `process.execPath` runs the same binary, so what the + * parent accepts the child accepts. Every supported Node (`engines`) either + * has the transform flag or strips types unflagged, so no strip flag is + * ever needed. + */ +export const typeScriptTransformFlags = ( + allowedFlags: ReadonlySet = process.allowedNodeEnvironmentFlags, +): readonly string[] => Object.freeze(allowedFlags.has(TRANSFORM_TYPES_FLAG) ? [TRANSFORM_TYPES_FLAG] : []); diff --git a/packages/agent-bundle/src/test/script.ts b/packages/agent-bundle/src/test/script.ts index 34743b416..ca8965bb2 100644 --- a/packages/agent-bundle/src/test/script.ts +++ b/packages/agent-bundle/src/test/script.ts @@ -33,6 +33,7 @@ import { terminalCapabilityRuntimePath } from '../build/entry-shell.ts'; import { metaModuleSpecifier } from '../build/meta.ts'; import { runGeneratedRenderedScript } from '../cli-entry.ts'; import type { CliRenderedEvent } from '../cli-entry.ts'; +import { typeScriptTransformFlags } from '../core/runtime.ts'; import { testMetaModuleSource } from '../rstest/meta-module.ts'; import { createProviderProcessLifetime } from '../routes/provider-execution.ts'; import { AgentTestError, captured } from './errors.ts'; @@ -245,8 +246,8 @@ const rsbuildCorePath = ((): string | undefined => { * (the build's generated module over the manifest's plugin identity, or the * AB4760 module when the compiler pass produced no model), relative `.js` specifiers that name TypeScript sources resolve to * them (as the bundler resolves them for the generated executable), Node's - * own type transform handles `.ts`, and `.tsx` / `.jsx` lower through the - * bundler's SWC. + * own TypeScript loading handles `.ts` (see `typeScriptTransformFlags`), and + * `.tsx` / `.jsx` lower through the bundler's SWC. */ const hooksSource = (manifest: AgentBundleTestManifest): string => ` import { createRequire, registerHooks } from 'node:module'; @@ -366,9 +367,11 @@ const runPlainScript = async ( // can start without the abort reaching it. signal.throwIfAborted(); // A generated executable runs under plain `node`; the test runner's own - // flags are not inherited, only the type transform the source needs. + // flags are not inherited, only the TypeScript loading the source needs — + // the transform flag where this Node still has one (22, 24), nothing on + // Node 26, which rejects the old flag and strips types unflagged. const child = spawn(process.execPath, [ - '--experimental-transform-types', + ...typeScriptTransformFlags(), '--disable-warning=ExperimentalWarning', '--import', `data:text/javascript,${encodeURIComponent(hooksSource(manifest))}`, '--input-type=module', diff --git a/packages/agent-bundle/tests/core.test.ts b/packages/agent-bundle/tests/core.test.ts index 808db9376..f5ba9718f 100644 --- a/packages/agent-bundle/tests/core.test.ts +++ b/packages/agent-bundle/tests/core.test.ts @@ -7,6 +7,7 @@ import { } from '../src/core/diagnostics.ts'; import { digest, stableJson } from '../src/core/digest.ts'; import { assertInside } from '../src/core/paths.ts'; +import { typeScriptTransformFlags } from '../src/core/runtime.ts'; import type { McpTransport } from '../src/index.ts'; // Type-level contract: only modern MCP transports are public. @@ -98,3 +99,43 @@ it('throws stable error summaries containing only error diagnostics', () => { throw new Error('Expected DiagnosticBag.throwIfErrors() to throw.'); }); + +// The TypeScript-related entries of `process.allowedNodeEnvironmentFlags`, as +// observed on each release line. +const node22Flags: ReadonlySet = new Set([ + '--experimental-default-type', + '--experimental-strip-types', + '--experimental-transform-types', + '--input-type', + '--no-experimental-strip-types', + '--no-experimental-transform-types', +]); +const node24Flags: ReadonlySet = new Set([ + '--experimental-strip-types', + '--experimental-transform-types', + '--input-type', + '--no-experimental-transform-types', + '--no-strip-types', + '--strip-types', +]); +// Node 26 removed --experimental-transform-types (nodejs/node#61803). +const node26Flags: ReadonlySet = new Set([ + '--experimental-strip-types', + '--input-type', + '--no-strip-types', + '--strip-types', +]); + +it('passes --experimental-transform-types to a TypeScript child only where the binary accepts it', () => { + expect(typeScriptTransformFlags(node22Flags)).toEqual(['--experimental-transform-types']); + expect(typeScriptTransformFlags(node24Flags)).toEqual(['--experimental-transform-types']); + // Node 26 strips types unflagged and rejects the removed flag as a bad option. + expect(typeScriptTransformFlags(node26Flags)).toEqual([]); + expect(Object.isFrozen(typeScriptTransformFlags(node26Flags))).toBe(true); +}); + +it('defaults to the flags this process accepts, so a child over process.execPath never gets a bad option', () => { + const flags = typeScriptTransformFlags(); + expect(flags).toEqual(typeScriptTransformFlags(process.allowedNodeEnvironmentFlags)); + for (const flag of flags) expect(process.allowedNodeEnvironmentFlags.has(flag)).toBe(true); +}); diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index 4dc859ab8..7e2f7d4de 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -202,7 +202,7 @@ and prints it in every failure, because a pass at one level is never a receipt f | `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface`, `runContractMatrix` | The real generated MCP server's protocol contract, over the SDK's in-memory transport. | | `dev-epoch` | `runDevEpochContractMatrix` | An epoch-pinned generated stdio process opened through the Workbench session service; the caller owns the epoch lease and process lifetime, and MCP App routes are covered (surface plus `ui://` sweep). | | `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | A plain or rendered argv vector resolved and run through the routed CLI's own shell — including rendered Markdown, explicit TTY, JSON, and NDJSON modes — in-process. | -| `script-dispatch` | `runScript`, `scriptJson`, `scriptNdjson` | A conventional `src/scripts/*` module run through its generated executable's contract, without bundling: a rendered `.tsx` script through the rendered-script shell in-process (piped Markdown, TTY, `--json`, `--ndjson`, with the project's conventional providers mounted), a plain `.ts` script as a Node process of its own through the `main` envelope — real `process.exit`, exit code, stdout, stderr, optional `stdin`. `testManifest().scripts` lists the scripts that ship; a nested (`AB4808`) or conflicting (`AB4809`) script is never a target. | +| `script-dispatch` | `runScript`, `scriptJson`, `scriptNdjson` | A conventional `src/scripts/*` module run through its generated executable's contract, without bundling: a rendered `.tsx` script through the rendered-script shell in-process (piped Markdown, TTY, `--json`, `--ndjson`, with the project's conventional providers mounted), a plain `.ts` script as a Node process of its own through the `main` envelope, over the source under Node's own TypeScript loading (`--experimental-transform-types` on Node 22 and 24; type stripping only on Node 26, which removed that flag, so TypeScript-only syntax such as `enum` fails there as it does under `node file.ts`) — real `process.exit`, exit code, stdout, stderr, optional `stdin`. `testManifest().scripts` lists the scripts that ship; a nested (`AB4808`) or conflicting (`AB4809`) script is never a target. | | `workbench-surface` | `inspectWorkbenchSurface` | What the dev server would hand the Workbench for this project — route manifest, grouped route catalog, state declaration, lifecycle-replay fixtures, page availability — from the same compiler pass, with no browser and no dev server; a project the compiler rejects reports `manifest-unavailable` with its error diagnostics. | | `packed-stdio` | `openPackedMcpServer`, `runPackedContractMatrix` | A built artifact's generated entry running as a real process over stdio. | | `packed-deleted-source` | `removeProjectSource`, `openPackedMcpServer({ deletedSource })`, `runPackedContractMatrix` | The packed stdio process still runs after project source and configuration are removed and verified absent. | diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index af6343381..00a9e86e6 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -175,7 +175,7 @@ try { | `mcp-in-memory` | `openInMemoryMcpServer`、`invokeMcpTool`、`readMcpResource`、`getMcpPrompt`、`listMcpSurface`、`runContractMatrix` | 真实生成式 MCP 服务器的协议契约,经由 SDK 的内存内传输。 | | `dev-epoch` | `runDevEpochContractMatrix` | 通过 Workbench 会话服务打开的、锁定到某个 epoch 的生成式 stdio 进程;调用方拥有 epoch 租约与进程生命周期,MCP App 路由被覆盖(表面加 `ui://` 扫描)。 | | `cli-dispatch` | `invokeCli`、`cliJson`、`cliNdjson` | 一个普通或渲染式 argv 向量在路由式 CLI 自己的 shell 中被解析并执行——包括渲染式 Markdown、显式 TTY、JSON 与 NDJSON 模式——全部在进程内完成。 | -| `script-dispatch` | `runScript`、`scriptJson`、`scriptNdjson` | 一个约定式 `src/scripts/*` 模块按其生成可执行文件的契约运行,无需打包:渲染式 `.tsx` 脚本在进程内经由渲染式脚本外壳运行(管道 Markdown、TTY、`--json`、`--ndjson`,并挂载项目的约定式 provider),普通 `.ts` 脚本则作为独立 Node 进程经由 `main` 封套运行——真实的 `process.exit`、退出码、stdout、stderr 与可选的 `stdin`。`testManifest().scripts` 列出实际发布的脚本;嵌套(`AB4808`)或冲突(`AB4809`)的脚本绝不会成为目标。 | +| `script-dispatch` | `runScript`、`scriptJson`、`scriptNdjson` | 一个约定式 `src/scripts/*` 模块按其生成可执行文件的契约运行,无需打包:渲染式 `.tsx` 脚本在进程内经由渲染式脚本外壳运行(管道 Markdown、TTY、`--json`、`--ndjson`,并挂载项目的约定式 provider),普通 `.ts` 脚本则作为独立 Node 进程经由 `main` 封套直接运行源码,由 Node 自身加载 TypeScript(Node 22 与 24 上使用 `--experimental-transform-types`;Node 26 已移除该标志,仅做类型剥离,因此 `enum` 等仅 TypeScript 才有的语法在那里会像 `node file.ts` 一样失败)——真实的 `process.exit`、退出码、stdout、stderr 与可选的 `stdin`。`testManifest().scripts` 列出实际发布的脚本;嵌套(`AB4808`)或冲突(`AB4809`)的脚本绝不会成为目标。 | | `workbench-surface` | `inspectWorkbenchSurface` | dev 服务器会交给 Workbench 的本项目内容——路由清单、分组路由目录、state 声明、生命周期回放 fixture、页面可用性——来自同一次编译器处理,无需浏览器也无需 dev 服务器;被编译器拒绝的项目报告 `manifest-unavailable` 及其 error 诊断。 | | `packed-stdio` | `openPackedMcpServer`、`runPackedContractMatrix` | 已构建产物的生成入口作为真实进程通过 stdio 运行。 | | `packed-deleted-source` | `removeProjectSource`、`openPackedMcpServer({ deletedSource })`、`runPackedContractMatrix` | 在项目源码与配置被移除并核实缺失之后,打包后的 stdio 进程仍然可以运行。 | From b37356af509b7d7f96815daed4941427f91ae6c1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 19:25:09 +0000 Subject: [PATCH 2/9] chore: name #554 in the changeset --- .changeset/node26-transform-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/node26-transform-types.md b/.changeset/node26-transform-types.md index 8a7fa51a8..23f7fb239 100644 --- a/.changeset/node26-transform-types.md +++ b/.changeset/node26-transform-types.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Run plain `.ts` scripts through `runScript` (`agent-bundle/test`) on Node 26: the child process gets `--experimental-transform-types` only where the running Node accepts it (Node 22 and 24) and no TypeScript flag on Node 26, which removed that flag and strips types by default. Previously every plain-script dispatch on Node 26 exited with code 9 (`node: bad option: --experimental-transform-types`) before the script ran; TypeScript-only syntax such as `enum` in a plain script now fails on Node 26 exactly as it does under `node file.ts`. (#PR) +Run plain `.ts` scripts through `runScript` (`agent-bundle/test`) on Node 26: the child process gets `--experimental-transform-types` only where the running Node accepts it (Node 22 and 24) and no TypeScript flag on Node 26, which removed that flag and strips types by default. Previously every plain-script dispatch on Node 26 exited with code 9 (`node: bad option: --experimental-transform-types`) before the script ran; TypeScript-only syntax such as `enum` in a plain script now fails on Node 26 exactly as it does under `node file.ts`. (#554) From fa1164e8d257777d00066b967bc7a033daa32521 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 19:56:47 +0000 Subject: [PATCH 3/9] fix(scripts): name --strip-types on Node 26 so an inherited NODE_OPTIONS=--no-strip-types cannot switch TypeScript loading off Codex review on #554: with no command-line flag the child inherited the environment's --no-strip-types and failed on every typed .ts source. The helper now picks the first flag the binary accepts, strongest first: --experimental-transform-types (22, 24), then --strip-types (26). Covered by a script-dispatch test that sets the version-appropriate switch in NODE_OPTIONS and expects the source run to succeed regardless. --- .changeset/node26-transform-types.md | 2 +- packages/agent-bundle/src/core/runtime.ts | 40 ++++++++++--------- packages/agent-bundle/src/test/script.ts | 4 +- packages/agent-bundle/tests/core.test.ts | 8 +++- .../tests/projection/script-dispatch.test.ts | 21 ++++++++++ website/docs/en/guide/development/testing.mdx | 2 +- website/docs/zh/guide/development/testing.mdx | 2 +- 7 files changed, 54 insertions(+), 25 deletions(-) diff --git a/.changeset/node26-transform-types.md b/.changeset/node26-transform-types.md index 23f7fb239..870e07364 100644 --- a/.changeset/node26-transform-types.md +++ b/.changeset/node26-transform-types.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Run plain `.ts` scripts through `runScript` (`agent-bundle/test`) on Node 26: the child process gets `--experimental-transform-types` only where the running Node accepts it (Node 22 and 24) and no TypeScript flag on Node 26, which removed that flag and strips types by default. Previously every plain-script dispatch on Node 26 exited with code 9 (`node: bad option: --experimental-transform-types`) before the script ran; TypeScript-only syntax such as `enum` in a plain script now fails on Node 26 exactly as it does under `node file.ts`. (#554) +Run plain `.ts` scripts through `runScript` (`agent-bundle/test`) on Node 26: the child process gets `--experimental-transform-types` only where the running Node accepts it (Node 22 and 24) and `--strip-types` on Node 26, which removed the transform flag and only strips types; the flag is always named on the command line, so an inherited `NODE_OPTIONS=--no-strip-types` cannot switch TypeScript loading off. Previously every plain-script dispatch on Node 26 exited with code 9 (`node: bad option: --experimental-transform-types`) before the script ran; TypeScript-only syntax such as `enum` in a plain script now fails on Node 26 exactly as it does under `node file.ts`. (#554) diff --git a/packages/agent-bundle/src/core/runtime.ts b/packages/agent-bundle/src/core/runtime.ts index 623074633..57b5c09cb 100644 --- a/packages/agent-bundle/src/core/runtime.ts +++ b/packages/agent-bundle/src/core/runtime.ts @@ -29,31 +29,35 @@ export const formatRuntimeVersion = (version: readonly [number, number, number]) `${version[0]}.${version[1]}.${version[2]}`; /** - * The flag Node 22 and 24 take to lower TypeScript-only syntax while loading - * a `.ts` source. Node 26 removed it (nodejs/node#61803) and rejects it as a - * bad option (exit code 9). + * The flags that make `node` load a TypeScript source, strongest first: + * + * - `--experimental-transform-types` (Node 22 and 24) lowers TypeScript-only + * syntax — enums, namespaces, parameter properties — on top of stripping + * type annotations. Node 26 removed it (nodejs/node#61803) with no stable + * successor and rejects it as a bad option (exit code 9). + * - `--strip-types` (Node 24 and 26) strips type annotations only; it is on + * by default there (`process.features.typescript === 'strip'`), but naming + * it on the command line outranks a `--no-strip-types` the child would + * otherwise inherit through `NODE_OPTIONS`. */ -const TRANSFORM_TYPES_FLAG = '--experimental-transform-types'; +const TYPESCRIPT_FLAGS: readonly string[] = Object.freeze(['--experimental-transform-types', '--strip-types']); /** * The `node` flags a child of this process needs to run a TypeScript source - * with the fullest TypeScript support its binary has, decided by the flags - * the binary accepts rather than by its version: - * - * - Node 22 and 24 accept `--experimental-transform-types`, which lowers - * TypeScript-only syntax (enums, namespaces, parameter properties) on top - * of stripping type annotations; - * - Node 26 removed the flag with no stable successor and strips types by - * default (`process.features.typescript === 'strip'`), so the child gets no - * flag and TypeScript-only syntax fails there exactly as it does under - * `node file.ts`: `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. + * with the fullest TypeScript support its binary has: the first of + * {@link TYPESCRIPT_FLAGS} the binary accepts, decided by the flags it + * accepts rather than by its version. On Node 26 that is `--strip-types`, so + * TypeScript-only syntax fails there exactly as it does under `node file.ts` + * (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`). * * `allowedFlags` defaults to the parent's `process.allowedNodeEnvironmentFlags`; * a child launched over `process.execPath` runs the same binary, so what the - * parent accepts the child accepts. Every supported Node (`engines`) either - * has the transform flag or strips types unflagged, so no strip flag is - * ever needed. + * parent accepts the child accepts. Every supported Node (`engines`) accepts + * one of the two; a binary accepting neither gets no flag at all. */ export const typeScriptTransformFlags = ( allowedFlags: ReadonlySet = process.allowedNodeEnvironmentFlags, -): readonly string[] => Object.freeze(allowedFlags.has(TRANSFORM_TYPES_FLAG) ? [TRANSFORM_TYPES_FLAG] : []); +): readonly string[] => { + const flag = TYPESCRIPT_FLAGS.find((candidate) => allowedFlags.has(candidate)); + return Object.freeze(flag === undefined ? [] : [flag]); +}; diff --git a/packages/agent-bundle/src/test/script.ts b/packages/agent-bundle/src/test/script.ts index ca8965bb2..abe3c0259 100644 --- a/packages/agent-bundle/src/test/script.ts +++ b/packages/agent-bundle/src/test/script.ts @@ -368,8 +368,8 @@ const runPlainScript = async ( signal.throwIfAborted(); // A generated executable runs under plain `node`; the test runner's own // flags are not inherited, only the TypeScript loading the source needs — - // the transform flag where this Node still has one (22, 24), nothing on - // Node 26, which rejects the old flag and strips types unflagged. + // the transform flag where this Node still has one (22, 24), `--strip-types` + // on Node 26, which rejects the old flag and only strips types. const child = spawn(process.execPath, [ ...typeScriptTransformFlags(), '--disable-warning=ExperimentalWarning', diff --git a/packages/agent-bundle/tests/core.test.ts b/packages/agent-bundle/tests/core.test.ts index f5ba9718f..e59aaf654 100644 --- a/packages/agent-bundle/tests/core.test.ts +++ b/packages/agent-bundle/tests/core.test.ts @@ -129,9 +129,13 @@ const node26Flags: ReadonlySet = new Set([ it('passes --experimental-transform-types to a TypeScript child only where the binary accepts it', () => { expect(typeScriptTransformFlags(node22Flags)).toEqual(['--experimental-transform-types']); expect(typeScriptTransformFlags(node24Flags)).toEqual(['--experimental-transform-types']); - // Node 26 strips types unflagged and rejects the removed flag as a bad option. - expect(typeScriptTransformFlags(node26Flags)).toEqual([]); + // Node 26 rejects the removed flag as a bad option; it strips types by + // default, and the stable flag names that explicitly so an inherited + // NODE_OPTIONS=--no-strip-types cannot switch it off. + expect(typeScriptTransformFlags(node26Flags)).toEqual(['--strip-types']); expect(Object.isFrozen(typeScriptTransformFlags(node26Flags))).toBe(true); + // A binary that accepts neither gets no flag rather than a bad option. + expect(typeScriptTransformFlags(new Set(['--input-type']))).toEqual([]); }); it('defaults to the flags this process accepts, so a child over process.execPath never gets a bad option', () => { diff --git a/packages/agent-bundle/tests/projection/script-dispatch.test.ts b/packages/agent-bundle/tests/projection/script-dispatch.test.ts index cfa320c34..1ba800838 100644 --- a/packages/agent-bundle/tests/projection/script-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/script-dispatch.test.ts @@ -637,6 +637,27 @@ describe('the plain-script process contract', () => { expect(run.stdout).toBe('checksum execArgv []\n'); }); + it('asks for the TypeScript loading the source needs on the command line, so an inherited NODE_OPTIONS cannot switch it off', async () => { + // The child inherits this environment; a command-line flag outranks it. + // Node 22 spells the switch --no-experimental-strip-types, 24 and 26 + // --no-strip-types. + const off = process.allowedNodeEnvironmentFlags.has('--no-strip-types') + ? '--no-strip-types' + : '--no-experimental-strip-types'; + const before = process.env.NODE_OPTIONS; + process.env.NODE_OPTIONS = before === undefined || before === '' ? off : `${before} ${off}`; + try { + const run = await runScript('checksum', ['--exec-argv']); + + expect(run.stderr).toBe(''); + expect(run.exitCode).toBe(0); + expect(run.stdout).toBe('checksum execArgv []\n'); + } finally { + if (before === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = before; + } + }); + it('pipes stdin to a plain script when given, and ends it at once otherwise', async () => { const fed = await runScript('checksum', ['--stdin'], { stdin: 'piped input\n' }); expect(fed.exitCode).toBe(0); diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index 7e2f7d4de..86ee423e4 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -202,7 +202,7 @@ and prints it in every failure, because a pass at one level is never a receipt f | `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface`, `runContractMatrix` | The real generated MCP server's protocol contract, over the SDK's in-memory transport. | | `dev-epoch` | `runDevEpochContractMatrix` | An epoch-pinned generated stdio process opened through the Workbench session service; the caller owns the epoch lease and process lifetime, and MCP App routes are covered (surface plus `ui://` sweep). | | `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | A plain or rendered argv vector resolved and run through the routed CLI's own shell — including rendered Markdown, explicit TTY, JSON, and NDJSON modes — in-process. | -| `script-dispatch` | `runScript`, `scriptJson`, `scriptNdjson` | A conventional `src/scripts/*` module run through its generated executable's contract, without bundling: a rendered `.tsx` script through the rendered-script shell in-process (piped Markdown, TTY, `--json`, `--ndjson`, with the project's conventional providers mounted), a plain `.ts` script as a Node process of its own through the `main` envelope, over the source under Node's own TypeScript loading (`--experimental-transform-types` on Node 22 and 24; type stripping only on Node 26, which removed that flag, so TypeScript-only syntax such as `enum` fails there as it does under `node file.ts`) — real `process.exit`, exit code, stdout, stderr, optional `stdin`. `testManifest().scripts` lists the scripts that ship; a nested (`AB4808`) or conflicting (`AB4809`) script is never a target. | +| `script-dispatch` | `runScript`, `scriptJson`, `scriptNdjson` | A conventional `src/scripts/*` module run through its generated executable's contract, without bundling: a rendered `.tsx` script through the rendered-script shell in-process (piped Markdown, TTY, `--json`, `--ndjson`, with the project's conventional providers mounted), a plain `.ts` script as a Node process of its own through the `main` envelope, over the source under Node's own TypeScript loading (`--experimental-transform-types` on Node 22 and 24; `--strip-types` on Node 26, which removed the transform flag, so TypeScript-only syntax such as `enum` fails there as it does under `node file.ts`) — real `process.exit`, exit code, stdout, stderr, optional `stdin`. `testManifest().scripts` lists the scripts that ship; a nested (`AB4808`) or conflicting (`AB4809`) script is never a target. | | `workbench-surface` | `inspectWorkbenchSurface` | What the dev server would hand the Workbench for this project — route manifest, grouped route catalog, state declaration, lifecycle-replay fixtures, page availability — from the same compiler pass, with no browser and no dev server; a project the compiler rejects reports `manifest-unavailable` with its error diagnostics. | | `packed-stdio` | `openPackedMcpServer`, `runPackedContractMatrix` | A built artifact's generated entry running as a real process over stdio. | | `packed-deleted-source` | `removeProjectSource`, `openPackedMcpServer({ deletedSource })`, `runPackedContractMatrix` | The packed stdio process still runs after project source and configuration are removed and verified absent. | diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index 00a9e86e6..62d8c92b8 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -175,7 +175,7 @@ try { | `mcp-in-memory` | `openInMemoryMcpServer`、`invokeMcpTool`、`readMcpResource`、`getMcpPrompt`、`listMcpSurface`、`runContractMatrix` | 真实生成式 MCP 服务器的协议契约,经由 SDK 的内存内传输。 | | `dev-epoch` | `runDevEpochContractMatrix` | 通过 Workbench 会话服务打开的、锁定到某个 epoch 的生成式 stdio 进程;调用方拥有 epoch 租约与进程生命周期,MCP App 路由被覆盖(表面加 `ui://` 扫描)。 | | `cli-dispatch` | `invokeCli`、`cliJson`、`cliNdjson` | 一个普通或渲染式 argv 向量在路由式 CLI 自己的 shell 中被解析并执行——包括渲染式 Markdown、显式 TTY、JSON 与 NDJSON 模式——全部在进程内完成。 | -| `script-dispatch` | `runScript`、`scriptJson`、`scriptNdjson` | 一个约定式 `src/scripts/*` 模块按其生成可执行文件的契约运行,无需打包:渲染式 `.tsx` 脚本在进程内经由渲染式脚本外壳运行(管道 Markdown、TTY、`--json`、`--ndjson`,并挂载项目的约定式 provider),普通 `.ts` 脚本则作为独立 Node 进程经由 `main` 封套直接运行源码,由 Node 自身加载 TypeScript(Node 22 与 24 上使用 `--experimental-transform-types`;Node 26 已移除该标志,仅做类型剥离,因此 `enum` 等仅 TypeScript 才有的语法在那里会像 `node file.ts` 一样失败)——真实的 `process.exit`、退出码、stdout、stderr 与可选的 `stdin`。`testManifest().scripts` 列出实际发布的脚本;嵌套(`AB4808`)或冲突(`AB4809`)的脚本绝不会成为目标。 | +| `script-dispatch` | `runScript`、`scriptJson`、`scriptNdjson` | 一个约定式 `src/scripts/*` 模块按其生成可执行文件的契约运行,无需打包:渲染式 `.tsx` 脚本在进程内经由渲染式脚本外壳运行(管道 Markdown、TTY、`--json`、`--ndjson`,并挂载项目的约定式 provider),普通 `.ts` 脚本则作为独立 Node 进程经由 `main` 封套直接运行源码,由 Node 自身加载 TypeScript(Node 22 与 24 上使用 `--experimental-transform-types`;Node 26 已移除该转换标志,改用 `--strip-types` 仅做类型剥离,因此 `enum` 等仅 TypeScript 才有的语法在那里会像 `node file.ts` 一样失败)——真实的 `process.exit`、退出码、stdout、stderr 与可选的 `stdin`。`testManifest().scripts` 列出实际发布的脚本;嵌套(`AB4808`)或冲突(`AB4809`)的脚本绝不会成为目标。 | | `workbench-surface` | `inspectWorkbenchSurface` | dev 服务器会交给 Workbench 的本项目内容——路由清单、分组路由目录、state 声明、生命周期回放 fixture、页面可用性——来自同一次编译器处理,无需浏览器也无需 dev 服务器;被编译器拒绝的项目报告 `manifest-unavailable` 及其 error 诊断。 | | `packed-stdio` | `openPackedMcpServer`、`runPackedContractMatrix` | 已构建产物的生成入口作为真实进程通过 stdio 运行。 | | `packed-deleted-source` | `removeProjectSource`、`openPackedMcpServer({ deletedSource })`、`runPackedContractMatrix` | 在项目源码与配置被移除并核实缺失之后,打包后的 stdio 进程仍然可以运行。 | From f81a516b48b111abfc20566284a0547942048103 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 20:57:09 +0000 Subject: [PATCH 4/9] test(routes): prove the provider request view across built surfaces; fix framework-mode.md ordering (from #556) --- docs/framework-mode.md | 10 ++- .../tests/cli-routes-build.test.ts | 78 ++++++++++++++++--- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 05d79d380..e0da719ce 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -147,9 +147,13 @@ export default async function library({ invocation }: AgentProviderContext): Pro } ``` -Providers run once per request in deterministic key order before the request -scope opens; a thrown factory fails that request closed, so return an honest -unavailable-shaped value for expected degradation. The compiler validates the +Providers run once per request in deterministic key order as the request's own +resolver: after `runAgentRequest` freezes the identity axes and opens the +notice lease, before the route runs, so the factory context carries `host`, +`session`, `workspace`, `lineage`, and `plugin` as the route will read them plus +the read-only `state` (`read`) and `notices` (`inbox`) handles (#459; see +`entry-conventions.md`). A thrown factory fails that request closed, so return +an honest unavailable-shaped value for expected degradation. The compiler validates the default export (`AB4940`), unique keys (`AB4941`), and the reserved framework-owned `processLifetime` key (`AB4942`). diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 7a59ed108..d79765619 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -21,6 +21,25 @@ const writeProjectFile = async (root: string, path: string, contents: string): P await writeFile(output, contents); }; +/** + * What the fixture's `library-tooling` provider observes of the request on + * every generated surface (#459): a routed-CLI executable mounts no host + * conversation, so `host`/`lineage` carry the typed `unsupported-surface` + * reason the route reads too; the process-lifetime `src/state.ts` mounts the + * `read`-only state handle and the `inbox`/`published`-only notice handle; `useAgent()` + * throws `outside-invocation` because the resolver runs outside the request. + */ +const providerView = { + handle: 'outside-invocation', + host: 'unsupported-surface', + lineage: 'unsupported-surface', + notices: ['inbox', 'published'], + plugin: 'available', + session: 'not-provided', + state: { keys: ['lifetime', 'read'], lifetime: 'process', revision: 0 }, + workspace: process.cwd(), +}; + /** * The routed-CLI packaging proof (#102 stage 2): `src/cli/**` routes feed the * existing package-build pipeline as one generated Rslib executable, and the @@ -68,16 +87,50 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 '}', '', ].join('\n')), + // Process-lifetime state, so every generated scope mounts a state handle + // and a notice ledger without touching the plugin root on disk. + writeProjectFile(root, 'src/state.ts', [ + "import { defineState } from '@agent-bundle/runtime/state';", + "import { z } from 'zod';", + 'export default defineState({', + ' events: { noted: z.object({ note: z.string() }).strict() },', + " id: 'cli-bin-fixture/notes',", + ' initial: { notes: [] },', + " lifetime: 'process',", + ' reduce: (state, event) => ({ notes: [...state.notes, event.payload.note] }),', + ' schema: z.object({ notes: z.array(z.string()) }).strict(),', + '});', + '', + ].join('\n')), // A conventional request context provider (#313): every generated request // scope — plain CLI, rendered CLI, projected MCP command, rendered script — - // mounts the same value. + // mounts the same value. Beside the invocation it reports the request view + // the scope resolved it over (#459): the identity axes as the route reads + // them, the read-only state and notice handles, and the runtime error + // `useAgent()` raises because providers run outside the request context. writeProjectFile(root, 'src/providers/library-tooling.ts', [ - 'export default async function libraryTooling({ invocation, signal }) {', + "import { AgentRequestError, useAgent } from '@agent-bundle/runtime';", + 'export default async function libraryTooling(context) {', + ' const { invocation, signal } = context;', " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", + ' let handle;', + " try { useAgent(); handle = 'reachable'; } catch (error) { handle = error instanceof AgentRequestError ? error.code : 'unexpected'; }", + ' const view = {', + ' handle,', + " host: context.host.state === 'available' ? context.host.value.name : context.host.reason,", + " lineage: context.lineage.state === 'available' ? context.lineage.value.conversation : context.lineage.reason,", + ' notices: context.notices === undefined ? null : Object.keys(context.notices).sort(),', + ' plugin: context.plugin.state,', + " session: context.session.state === 'available' ? context.session.value.sessionId : context.session.reason,", + ' state: context.state === undefined', + ' ? null', + ' : { keys: Object.keys(context.state).sort(), lifetime: context.state.lifetime, revision: (await context.state.read()).revision },', + " workspace: context.workspace.state === 'available' ? context.workspace.value.root : context.workspace.reason,", + ' };', // Branching on the documented kind fails loudly if a surface ever posts // no invocation to its worker again (#319 review). " switch (invocation.kind) {", - " case 'cli': case 'script': case 'tool': return { kind: invocation.kind, tool: 'ffprobe 6.1' };", + " case 'cli': case 'script': case 'tool': return { kind: invocation.kind, tool: 'ffprobe 6.1', view };", " default: throw new Error(`unexpected invocation kind ${String(invocation.kind)}`);", ' }', '}', @@ -90,7 +143,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 'export const inputSchema = z.object({}).strict();', 'export const resultSchema = z.object({', ' hits: z.number().int().min(1),', - " libraryTooling: z.object({ kind: z.literal('cli'), tool: z.string() }).strict(),", + " libraryTooling: z.object({ kind: z.literal('cli'), tool: z.string(), view: z.unknown() }).strict(),", '}).strict();', 'export default async function tooling() {', ' const context = await agent();', @@ -121,12 +174,12 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 "import { z } from 'zod';", "export const config = { description: 'Render a library report.', positionals: ['root'] };", 'export const inputSchema = z.object({ root: z.string().min(1) }).strict();', - 'export const resultSchema = z.object({ books: z.number(), root: z.string(), tooling: z.string() }).strict();', + 'export const resultSchema = z.object({ books: z.number(), root: z.string(), tooling: z.string(), view: z.unknown() }).strict();', 'export default async function Report({ input, signal }) {', " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", ' const context = await agent();', " await context.progress.report({ completed: 1, message: 'scanning', total: 2 });", - ' const result = { books: 2, root: input.root, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}` };', + ' const result = { books: 2, root: input.root, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}`, view: context.providers.libraryTooling.view };', ' return (', ' ', ' {`Found **2** books under ${input.root}.`}', @@ -149,11 +202,11 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 "import { z } from 'zod';", "export const config = { annotations: { readOnlyHint: true }, description: 'Looks up one value.' };", 'export const inputSchema = z.object({ message: z.string().default("ready") }).strict();', - "export const resultSchema = z.object({ invocation: z.literal('tool'), message: z.string(), operationId: z.string(), tooling: z.string() }).strict();", + "export const resultSchema = z.object({ invocation: z.literal('tool'), message: z.string(), operationId: z.string(), tooling: z.string(), view: z.unknown() }).strict();", 'export default async function Lookup({ input }) {', ' const context = await agent();', " await context.progress.report({ completed: 1, message: 'lookup', total: 1 });", - ' const result = { invocation: context.invocation.kind, message: input.message, operationId: context.invocation.operationId, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}` };', + ' const result = { invocation: context.invocation.kind, message: input.message, operationId: context.invocation.operationId, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}`, view: context.providers.libraryTooling.view };', ' return {`Lookup: ${input.message}`};', '}', '', @@ -186,7 +239,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 'export default async function Summarize({ argv, signal }) {', " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", ' const context = await agent();', - ' const result = { arguments: argv.length, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}` };', + ' const result = { arguments: argv.length, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}`, view: context.providers.libraryTooling.view };', ' return (', ' ', ' {`Summarized ${String(argv.length)} arguments.`}', @@ -236,7 +289,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 // Plain .ts commands mount conventional providers once per request (#313), // with the framework-owned processLifetime value beside them. const tooling = await execFile(binPath, ['tooling']); - expect(JSON.parse(tooling.stdout)).toEqual({ hits: 1, libraryTooling: { kind: 'cli', tool: 'ffprobe 6.1' } }); + expect(JSON.parse(tooling.stdout)).toEqual({ hits: 1, libraryTooling: { kind: 'cli', tool: 'ffprobe 6.1', view: providerView } }); // Nested commands parse positionals/options and honor the result exit-code policy. const audit = await execFile(binPath, ['library', 'audit', 'a', 'b']); @@ -282,7 +335,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 // --json returns the canonical validated final value; the rendered command // observed the same conventional provider as the plain command (#313). const reportJson = await execFile(binPath, ['report', '/library', '--json']); - expect(JSON.parse(reportJson.stdout)).toEqual({ books: 2, root: '/library', tooling: 'cli:ffprobe 6.1' }); + expect(JSON.parse(reportJson.stdout)).toEqual({ books: 2, root: '/library', tooling: 'cli:ffprobe 6.1', view: providerView }); // --ndjson exposes the sequence-numbered render-event stream, including // the progress the component reported through the request context. const reportEvents = await execFile(binPath, ['report', '/library', '--ndjson']); @@ -306,6 +359,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 message: 'packed', operationId: 'tool:harness/lookup', tooling: 'tool:ffprobe 6.1', + view: providerView, }); const projectedNdjson = await execFile(binPath, [ 'harness', 'lookup', '--input', '{"message":"events"}', '--ndjson', @@ -347,7 +401,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n'); // The rendered script's provider sees `invocation.kind === 'script'` (#313). const scriptJson = await execFile(process.execPath, [scriptPath, 'alpha', '--json']); - expect(JSON.parse(scriptJson.stdout)).toEqual({ arguments: 1, tooling: 'script:ffprobe 6.1' }); + expect(JSON.parse(scriptJson.stdout)).toEqual({ arguments: 1, tooling: 'script:ffprobe 6.1', view: providerView }); // #102 acceptance: one build ships custom, MCP-generated, plain, and rendered commands/scripts. const plainScriptPath = join(root, 'artifact', 'portable', 'scripts', 'checksum.mjs'); From 99f234ca5c6cc29e40a90c57590589f779e82810 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 20:45:35 +0000 Subject: [PATCH 5/9] fix(install): apply the operator .env layer before plugin modules evaluate and below manifest env defaults (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the #538 self-review. Precedence: a host merges the stdio server's manifest `env` block into the child environment, so the shell could not tell a manifest default from a host export and reserved both — manifest env beat the file, contrary to the documented `manifest < .env < .env.local < process.env`. The emitted stdio entry now embeds the server's normalized `env` block as build-time literals and `applyOperatorEnv` takes it as `manifestEnv`: a present variable is reserved only when its value differs from the embedded default, so a passed-through default yields to the file while a host or operator export is kept. An operator export equal to the default is indistinguishable from the pass-through and yields too; a default carrying a path token never equals its expanded value and is always kept. Host manifests are unchanged. Import timing: the layer was a statement after the consumer imports, and ESM evaluates static imports first, so module-level `process.env` reads in hook handlers and CLI route/provider modules never saw the file. A dynamic `import()` after the statement does not help either — Rspack inlines a single-chunk bundle into one scope and places the dynamic target ahead of the static imports. The layer is now a generated virtual module (`agent-bundle/launch-env-layer`) that every stdio entry, hook wrapper, and artifact CLI bin imports first, with the server module, handler, routes, providers, and state definition as static imports after it; the build marks generated modules side-effectful so a consumer `"sideEffects": false` cannot drop the bare import. The MCP shell's `loadEntry` becomes a static import for the same reason, so the console guard now covers the factory call and the running server rather than the module's top-level evaluation. Tests build each shell through the real pipeline and run it under node with a `process.env` read at module top level: manifest-only key takes the file, host-exported key keeps the host value, absent key takes the file, `AGENT_BUNDLE_ENV_FILE=none` restores the previous behaviour. --- .changeset/469-env-precedence-followup.md | 5 + docs/entry-conventions.md | 55 +++++++-- .../src/adapters/hook-contract.ts | 42 ++++--- packages/agent-bundle/src/build/cli-bins.ts | 3 +- packages/agent-bundle/src/build/entries.ts | 24 ++-- .../agent-bundle/src/build/entry-shell.ts | 38 ++++--- .../agent-bundle/src/build/inspect-bundler.ts | 5 +- .../src/build/launch-env-shell.ts | 62 +++++++--- packages/agent-bundle/src/build/rslib.ts | 10 ++ packages/agent-bundle/src/launch-env.ts | 35 +++++- packages/agent-bundle/src/mcp-entry.ts | 9 +- .../tests/artifact-cli-bin.test.ts | 37 +++++- .../agent-bundle/tests/entry-shell.test.ts | 73 +++++++++--- .../tests/hook-handler-contract.test.ts | 9 +- packages/agent-bundle/tests/hooks.test.ts | 44 ++++++-- .../agent-bundle/tests/launch-env.test.ts | 42 +++++++ packages/agent-bundle/tests/mcp.test.ts | 106 ++++++++++++++++++ website/docs/en/guide/authoring/mcp.mdx | 11 +- .../en/guide/distribution/installation.mdx | 29 +++-- .../docs/en/reference/runtime-environment.mdx | 11 +- website/docs/zh/guide/authoring/mcp.mdx | 14 ++- .../zh/guide/distribution/installation.mdx | 24 ++-- .../docs/zh/reference/runtime-environment.mdx | 6 +- 23 files changed, 557 insertions(+), 137 deletions(-) create mode 100644 .changeset/469-env-precedence-followup.md diff --git a/.changeset/469-env-precedence-followup.md b/.changeset/469-env-precedence-followup.md new file mode 100644 index 000000000..f21a0a9fa --- /dev/null +++ b/.changeset/469-env-precedence-followup.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Make the operator `.env` layer of an installed pack follow the documented `manifest env < .env < .env.local < process.env` order and reach plugin code before it evaluates. A host merges a stdio server's manifest `env` block into the child environment, so the emitted MCP entry now carries that block as build-time literals and `applyOperatorEnv` (new `manifestEnv` option on `agent-bundle/launch-env`) treats a variable still holding its manifest default as unset — the file overrides it, while an exported variable still wins; an operator export equal to the default reads as the default, and a value carrying a path token (`AGENT_BUNDLE_PLUGIN_ROOT`) is always kept. The layer is now the first import of every emitted stdio entry, hook wrapper, and artifact CLI `bin/.mjs` rather than a statement after the consumer imports, so a `process.env` read at the top level of a server, handler, route, provider, or state module sees the composed environment; the build marks generated modules side-effectful so a consumer `"sideEffects": false` cannot drop the import. `AGENT_BUNDLE_ENV_FILE=none` still disables the layer entirely. Host manifests are unchanged. (#554) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 515299c12..5ec9b221e 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1015,8 +1015,10 @@ name). Self-connecting entries — modules that construct and connect a transport at top level without a default export — keep today's behavior byte for byte: no lifecycle shell and no operator `.env` layer (#469); an entry that wants the -layer calls `applyOperatorEnv` from `agent-bundle/launch-env` itself. That -module is aliased into every stdio entry, shell or not, so the import is +layer calls `applyOperatorEnv` from `agent-bundle/launch-env` itself, +passing its own declared `env` block as `manifestEnv` if a passed-through +manifest default should yield to the file as it does in the generated shell. +That module is aliased into every stdio entry, shell or not, so the import is inlined from this package rather than resolved through the plugin's own `node_modules`, and a `tools` hatch can never externalize it. @@ -1352,16 +1354,16 @@ canonical precedence order (highest 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. | -Installed packs get the same layer without `mcp run` (#469): every artifact -shell that runs plugin code — the stdio MCP entry of a factory-exporting -server (before its deferred server import; a self-connecting entry has no -shell), the hook wrappers that execute handlers or render standalone, and the -artifact CLI `bin/.mjs` — applies `agent-bundle/launch-env` -(`src/launch-env.ts`, plain Node, inlined into the bundle) at startup. It -reads `/.env` then `.env.local`, where the plugin root is the -expanded `AGENT_BUNDLE_PLUGIN_ROOT` or the shell's parent directory, or the -files `AGENT_BUNDLE_ENV_FILE` names (platform-delimited list; `none` disables -the layer); it fills only variables the host did not set, never logs a value, +Installed packs get the same layer and the same order without `mcp run` +(#469): every artifact shell that runs plugin code — the stdio MCP entry of a +factory-exporting server (a self-connecting entry has no shell), the hook +wrappers that execute handlers or render standalone, and the artifact CLI +`bin/.mjs` — applies `agent-bundle/launch-env` (`src/launch-env.ts`, +plain Node, inlined into the bundle) at startup. It reads `/.env` then `.env.local`, where the plugin root is the expanded +`AGENT_BUNDLE_PLUGIN_ROOT` or the shell's parent directory, or the files +`AGENT_BUNDLE_ENV_FILE` names (platform-delimited list; `none` disables the +layer); it fills only variables the host did not set, never logs a value, treats a missing file as the normal case and an unreadable one as skipped. The dotenv grammar has no `${VAR}` interpolation. Under `mcp run` the plugin root is the project root, so the shell's pass is a no-op; `--env-file` and @@ -1369,6 +1371,35 @@ root is the project root, so the shell's pass is a no-op; `--env-file` and operator's choice. The npm package bin reads no pack file. Doctor reports the presence and variable count of each file (`AB7331`). +Two details keep the installed order equal to the `mcp run` table above: + +- **Manifest env stays lowest.** A host merges the server's manifest `env` + block into the child environment before launch, so the child cannot tell a + manifest default from a host export by looking at `process.env`. The stdio + entry's layer module therefore embeds the server's declared `env` block + (normalized, path tokens unexpanded) as `manifestEnv`, and + `applyOperatorEnv` reserves a present variable only when its value differs + from that default: a passed-through default yields to the file, a host or + operator export is kept. The accepted ambiguity: an operator export equal to + the manifest default reads as the default and yields too. A default carrying + a path token never equals its host-expanded value and is always kept — this + covers the injected `AGENT_BUNDLE_PLUGIN_ROOT`. Hook wrappers and the CLI + bin have no manifest env and embed none. The host manifests are unchanged; + hosts still show `env` in their UIs. +- **The layer precedes every consumer module.** Rspack inlines the modules of + a single-chunk bundle into one scope, evaluates all of them before the + entry module's own body, and places a dynamic import's target ahead of the + static ones — so neither a statement in the shell body nor an awaited + `import()` after it runs before a consumer module's top level. The layer is + instead a generated virtual module (`agent-bundle/launch-env-layer`, + `src/build/launch-env-shell.ts`) that each shell imports first, and the + server module, hook handler, routes, providers, and state definition are + static imports after it; ESM import order is what the bundler preserves. A + consumer `package.json` declaring `"sideEffects": false` would let the + bundler drop that bare import, so the build marks generated modules + side-effectful (`src/build/rslib.ts`). Module-level `process.env` reads in + plugin code see the composed environment. + ### Durable-state anchors Under `mcp run` the artifact is an ephemeral build product, so both diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index f3ceeb942..d9bcb2790 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -1,7 +1,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 { operatorEnvImports, operatorEnvStatement } from '../build/launch-env-shell.ts'; +import { operatorEnvLayerImport } from '../build/launch-env-shell.ts'; import type { CanonicalAgentEvent } from '../routes/public.ts'; import { canonicalHookEvents, @@ -626,6 +626,21 @@ export const eventProjectRuntimeSpecifier = 'agent-bundle/event-project'; export const eventArtifactEpochToken = '__AGENT_BUNDLE_EVENT_ARTIFACT_EPOCH__'; export const eventFlightArtifactEpochToken = '__AGENT_BUNDLE_EVENT_FLIGHT_ARTIFACT_EPOCH__'; +/** True when an event-route wrapper may render in-process rather than forward to the warm MCP runtime. */ +const standaloneEventRoute = (route: NonNullable): boolean => + route.runtime === 'standalone' || route.fallback === 'standalone'; + +/** + * True when a wrapper runs plugin code in its own process and therefore + * imports the operator `.env` layer (#469): every handler-executing wrapper, + * and an event-route wrapper that can render standalone. A shared-runtime + * event-route wrapper forwards the event to the warm MCP process, which + * applied the layer itself when it started. The build serves the layer module + * to exactly these wrappers. + */ +export const hookWrapperAppliesOperatorEnv = (entry: TargetHookWrapper): boolean => + entry.hook.eventRoute === undefined || standaloneEventRoute(entry.hook.eventRoute); + const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, hostContractRevision: string, @@ -633,7 +648,7 @@ const eventRouteHookWrapperSource = ( durableLineage = false, ): string => { const route = entry.hook.eventRoute!; - const standalone = route.runtime === 'standalone' || route.fallback === 'standalone'; + const standalone = standaloneEventRoute(route); // A standalone `session/end` (the warm runtime has usually already exited by // then) retires the durable lineage journal itself, so roots never outlive // their session; only projects whose state is workspace-durable have one. @@ -649,11 +664,12 @@ const eventRouteHookWrapperSource = ( ] : ['const target = artifactTarget;']; return [ - "import { dirname, resolve } from 'node:path';", // Only a wrapper that can render in-process needs the operator `.env` // layer (#469): a shared-runtime wrapper forwards the event to the warm - // MCP process, which applied the layer itself when it started. - ...(standalone ? operatorEnvImports({ importsFileUrlToPath: retiresLineage }) : []), + // MCP process, which applied the layer itself when it started. First, + // so it evaluates before every other module of the bundle. + ...(standalone ? [operatorEnvLayerImport] : []), + "import { dirname, resolve } from 'node:path';", ...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []), ...(standalone ? [ @@ -681,7 +697,6 @@ const eventRouteHookWrapperSource = ( `const fallbackMode = ${JSON.stringify(route.fallback)};`, `const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`, "const endpointId = `${artifactEpoch}:${artifactTarget}:${dirname(dirname(resolve(process.argv[1])))}`;", - ...(standalone ? [operatorEnvStatement] : []), '', 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', ...(standalone @@ -824,11 +839,11 @@ const eventRouteHookWrapperSource = ( /** Emits the published Cursor hook wrapper source; see encodeCursorPlaygroundInput for the envelope contract. */ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ - ...operatorEnvImports({ importsFileUrlToPath: false }), + // The installed pack's operator `.env` layer (#469): the first import, so + // 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)};`, - // The installed pack's operator `.env` layer (#469), applied before the - // handler runs; the handler module itself is a static import. - operatorEnvStatement, 'const target = "cursor";', `const canonicalEvent = ${JSON.stringify(entry.event)};`, `const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`, @@ -1243,11 +1258,10 @@ export const nativeHookWrapperSource = ( ] : [`const target = ${JSON.stringify(entry.target)};`]; return [ - ...operatorEnvImports({ importsFileUrlToPath: false }), + // 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)};`, - // The installed pack's operator `.env` layer (#469), applied before the - // handler runs; the handler module itself is a static import. - operatorEnvStatement, ...targetSource, `const canonicalEvent = ${JSON.stringify(entry.event)};`, `const nativeEvent = ${JSON.stringify(nativeEvent)};`, diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index ddf5c6441..079feede4 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -7,7 +7,7 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import type { NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts'; import { resolveArtifactDestination } from './emit.ts'; import { runtimeIgnoredRoot, type CompiledEntry } from './entries.ts'; -import { launchEnvRuntimeSpecifier } from './launch-env-shell.ts'; +import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; import { cliEntryRuntimePath, cliEntryRuntimeSpecifier, @@ -116,6 +116,7 @@ export const cliBinRslibEntries = ( // The artifact-hosted bin applies the pack's operator `.env` layer (#469). aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath(), [launchEnvRuntimeSpecifier]: launchEnvRuntimePath() }, name: `bin-${entry.name}`, + virtualModules: [operatorEnvLayerVirtualModule()], outputRelativePath: cliBinArtifactPath(entry.name), ...(entry.rendered ? { rscManifest: true as const } : {}), source: entry.source, diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index f4138c207..969ae0ff4 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -9,6 +9,7 @@ import { eventFlightArtifactEpochToken, eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier, + hookWrapperAppliesOperatorEnv, type TargetHookEntry, } from '../adapters/hook-contract.ts'; import type { @@ -23,7 +24,7 @@ import { stableJson } from '../core/digest.ts'; import { emitPlanEntries, resolveArtifactDestination } from './emit.ts'; import { scanEntryExports } from './entry-exports.ts'; import { deepFreeze } from '../core/freeze.ts'; -import { launchEnvRuntimeSpecifier } from './launch-env-shell.ts'; +import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; import { cliEntryRuntimePath, launchEnvRuntimePath, @@ -413,17 +414,12 @@ export const planMcpEntriesSurface = async ( // for byte. The shell is aliased onto the local runtime module so emitted // bundles stay self-contained (no residual `agent-bundle` import). 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: entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name, - }); + return generatedStdioMcpEntrySource({ entrySource: routeModuleSpecifier, serverName }); } return (await scanEntryExports(entry.source)).hasDefaultExport - ? generatedStdioMcpEntrySource({ - entrySource: entry.source, - serverName: entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name, - }) + ? generatedStdioMcpEntrySource({ entrySource: entry.source, serverName }) : undefined; })); const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined; @@ -475,6 +471,13 @@ export const planMcpEntriesSurface = async ( name: routeModuleSpecifier, source: generatedRouteSources[index], }]), + // The shell's operator `.env` layer (#469) carries the server's manifest + // `env` block, so the layer can tell a passed-through default from a + // host export; a self-connecting entry has no shell and applies the + // layer itself if it wants it. + ...(entryShells[index] === undefined + ? [] + : [operatorEnvLayerVirtualModule(servers.find((candidate) => candidate.id === id)?.env)]), ], })); const workerEntries = compiled.flatMap((entry, index) => { @@ -624,6 +627,9 @@ export const planHooksSurface = ( virtualSource: entries[index]!.virtualSource .replaceAll(eventArtifactEpochToken, options.artifactEpoch) .replaceAll(eventFlightArtifactEpochToken, workerArtifactEpoch), + // The layer module the wrapper imports first; a shared-runtime + // event-route wrapper runs no plugin code and imports none. + ...(hookWrapperAppliesOperatorEnv(entries[index]!) ? { virtualModules: [operatorEnvLayerVirtualModule()] } : {}), })), ...(workerEntry === undefined ? [] : [workerEntry]), ], diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index dd65bd494..61c5f0649 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts'; -import { operatorEnvImports, operatorEnvStatement } from './launch-env-shell.ts'; +import { operatorEnvLayerImport } from './launch-env-shell.ts'; import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import { stableJson } from '../core/digest.ts'; import type { NormalizedHook, NormalizedNoticeRetentionPolicy, NormalizedStateDefinition } from '../core/types.ts'; @@ -69,22 +69,27 @@ export const terminalCapabilityRuntimePath = (): string => runtimeModulePath('te export type GeneratedExecutableSurface = 'cli' | 'script'; /** - * The generated stdio MCP entry body for a factory-exporting server module: - * the lifecycle installs the console guard before the consumer module - * evaluates, so `loadEntry` stays a deferred dynamic import — which is also - * what lets the operator `.env` layer land before any server code reads - * `process.env`. + * The generated stdio MCP entry body for a factory-exporting server module. + * The operator `.env` layer (#469) is the first import and the server module + * a static import after it: the bundler inlines every module of the + * single-chunk bundle ahead of the entry body and places a dynamic import's + * target ahead of the static ones, so only static import order puts the + * layer before the server module's own top level (see + * launchEnvLayerSpecifier). The layer module carries the server's manifest + * `env` defaults. The lifecycle installs its console guard before the + * factory runs; the module's top-level evaluation precedes the shell body + * under bundling whichever way it is imported. */ export const generatedStdioMcpEntrySource = (options: { readonly entrySource: string; readonly serverName: string; }): string => [ - ...operatorEnvImports({ importsFileUrlToPath: false }), + operatorEnvLayerImport, `import { runGeneratedStdioMcpEntry } from ${JSON.stringify(mcpEntryRuntimeSpecifier)};`, + `import * as serverModule from ${JSON.stringify(options.entrySource)};`, '', - operatorEnvStatement, 'await runGeneratedStdioMcpEntry({', - ` loadEntry: () => import(${JSON.stringify(options.entrySource)}),`, + ' loadEntry: async () => serverModule,', ` serverName: ${JSON.stringify(options.serverName)},`, '});', '', @@ -344,6 +349,12 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) const plainIndent = options.state === undefined ? ' ' : ' '; const stateFallback = options.stateFallback ?? 'cwd'; return [ + // The artifact-hosted executable is part of an installed pack, so it + // applies the pack's operator `.env` layer (#469) — first, before the + // route, provider, and state modules it imports evaluate, so a + // module-level `process.env` read sees the composed environment. The + // npm package bin runs from the operator's own shell and reads none. + ...(stateFallback === 'artifact' ? [operatorEnvLayerImport] : []), `import { cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, rendered ? "import { available, createAgentRenderDispatcher, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';" @@ -351,18 +362,9 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ...pluginRootImports(stateFallback), ...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []), ...generatedStateImports(options.state), - // The artifact-hosted executable is part of an installed pack, so it - // reads the pack's operator `.env` layer (#469); the npm package bin - // runs from the operator's own shell and reads none. The artifact - // plugin-root imports (#468) already bind `fileURLToPath`. - ...(stateFallback === 'artifact' ? operatorEnvImports({ importsFileUrlToPath: true }) : []), ...routeImports(commandRoutes), ...providerImports(providers), '', - // Before the state owner opens and before any command runs; route modules - // are static imports, so a module-level `process.env` read still sees the - // host environment only (documented). - ...(stateFallback === 'artifact' ? [operatorEnvStatement] : []), pluginRootDeclaration(stateFallback), ...generatedStateOwner(options.state, options), 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index c1cd1d6f0..a1557a910 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -1,3 +1,4 @@ +import { hookWrapperAppliesOperatorEnv } from '../adapters/hook-contract.ts'; import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import type { TargetHookEntry } from '../adapters/types.ts'; import { isPlainRecord } from '../core/strict-json.ts'; @@ -17,7 +18,7 @@ import { terminalCapabilityRuntimePath, terminalCapabilityRuntimeSpecifier, } from './entry-shell.ts'; -import { launchEnvRuntimeSpecifier } from './launch-env-shell.ts'; +import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts'; import { planCompiledMcpEntries } from './entries.ts'; import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; @@ -255,6 +256,7 @@ const mcpEntryEntries = async ( source: '/* The MCP App registry virtual module is generated from built app HTML at build time. */', }, ...(routeSource === undefined ? [] : [{ name: 'agent-bundle/generated-route-server', source: routeSource }]), + ...(wrapped ? [operatorEnvLayerVirtualModule(server?.env)] : []), ], }, kind: 'mcp-entry', @@ -318,6 +320,7 @@ const hookEntries = ( source: entry.hook.source, sourceInputs: [], virtualSource: entry.virtualSource, + ...(hookWrapperAppliesOperatorEnv(entry) ? { virtualModules: [operatorEnvLayerVirtualModule()] } : {}), }, kind: 'hook', meta, diff --git a/packages/agent-bundle/src/build/launch-env-shell.ts b/packages/agent-bundle/src/build/launch-env-shell.ts index cfe3809a9..86ade79cc 100644 --- a/packages/agent-bundle/src/build/launch-env-shell.ts +++ b/packages/agent-bundle/src/build/launch-env-shell.ts @@ -13,22 +13,56 @@ export const launchEnvRuntimeSpecifier = 'agent-bundle/launch-env'; // so it must stay free of filesystem probing and `new URL(…, import.meta.url)`: // `launchEnvRuntimePath` lives in `entry-shell.ts` beside the other paths. - /** - * The import lines of the operator env layer. `fileURLToPath` is emitted - * only when the module does not already import it, so a bundle never - * declares one binding twice. + * The generated module that applies the layer, served virtually to each + * shell's compilation and imported by the shell before anything else. + * + * Why a module and not a statement in the shell body: Rspack inlines the + * modules of a single-chunk bundle into one scope, and every inlined module + * — a statically imported handler as much as a `loadEntry: () => import()` + * target — evaluates before the entry module's own body. A statement in the + * shell therefore ran after every consumer module had already read + * `process.env` at its top level. ESM import order is what the bundler does + * preserve, so the layer is the shell's first import and evaluates before + * the handler, the route and provider modules, the state definition, and + * the server module. */ -export const operatorEnvImports = (options: { readonly importsFileUrlToPath: boolean }): readonly string[] => [ - ...(options.importsFileUrlToPath ? [] : ["import { fileURLToPath } from 'node:url';"]), - `import { applyOperatorEnv, operatorEnvPluginRoot } from ${JSON.stringify(launchEnvRuntimeSpecifier)};`, -]; +export const launchEnvLayerSpecifier = 'agent-bundle/launch-env-layer'; + +/** The import every artifact shell that runs plugin code places first. */ +export const operatorEnvLayerImport = `import ${JSON.stringify(launchEnvLayerSpecifier)};`; /** - * The statement that applies the layer. Every artifact shell lives one - * directory below the plugin root (`mcp/`, `hooks/`, `bin/`), so the fallback - * anchor — used when the host set no `AGENT_BUNDLE_PLUGIN_ROOT` — is the - * module's parent directory, the same fallback the durable-state kernel uses. + * The source of the layer module. Every artifact shell lives one directory + * below the plugin root (`mcp/`, `hooks/`, `bin/`), so the fallback anchor — + * used when the host set no `AGENT_BUNDLE_PLUGIN_ROOT` — is the bundle's + * parent directory, the same fallback the durable-state kernel uses + * (`import.meta.url` stays native in the emitted ESM, so it names the bundle). + * + * A stdio MCP shell embeds its server's manifest `env` block as build-time + * literals (`manifestEnv`): the host merges that block into the child + * environment before launch, and only a shell that knows the defaults can + * let the file beat them while an exported variable still wins. Hook + * wrappers and the CLI bin have no manifest env and embed none. */ -export const operatorEnvStatement = - "applyOperatorEnv({ pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });"; +export const operatorEnvLayerModuleSource = (manifestEnv?: Readonly>): string => { + const defaults = Object.entries(manifestEnv ?? {}).sort(([left], [right]) => left.localeCompare(right)); + const manifestField = defaults.length === 0 + ? '' + : `manifestEnv: ${JSON.stringify(Object.fromEntries(defaults))}, `; + return [ + "import { fileURLToPath } from 'node:url';", + `import { applyOperatorEnv, operatorEnvPluginRoot } from ${JSON.stringify(launchEnvRuntimeSpecifier)};`, + '', + `applyOperatorEnv({ ${manifestField}pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });`, + '', + ].join('\n'); +}; + +/** The layer as the virtual module an Rslib entry serves beside its wrapper. */ +export const operatorEnvLayerVirtualModule = ( + manifestEnv?: Readonly>, +): { readonly name: string; readonly source: string } => ({ + name: launchEnvLayerSpecifier, + source: operatorEnvLayerModuleSource(manifestEnv), +}); diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index ec8b6663a..b33eacee0 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -477,6 +477,16 @@ export const composeEntryLibConfig = ( }, }; } + // Generated modules live under the project root, so a consumer + // package.json declaring `"sideEffects": false` would otherwise let the + // bundler drop a bare `import "agent-bundle/launch-env-layer"` (#469) as + // an unused side-effect-free import. They exist for their side effects. + if (generatedModules.length > 0) { + config.module = { + ...config.module, + rules: [...(config.module?.rules ?? []), { include: generatedModulesRoot(options.cwd), sideEffects: true }], + }; + } // Framework plugins are added after the hatch mutator (this hook is // merged last), so a consumer cannot strip the RSC manifest stub or the // generated sources out of the compiler. diff --git a/packages/agent-bundle/src/launch-env.ts b/packages/agent-bundle/src/launch-env.ts index 556c79144..ca8d73d26 100644 --- a/packages/agent-bundle/src/launch-env.ts +++ b/packages/agent-bundle/src/launch-env.ts @@ -7,8 +7,13 @@ * `process.env` for the server it spawns. Hosts launch installed packs * directly, so the emitted shells apply the same layer themselves: the file * fills only variables the host did not set, so an exported variable always - * wins, and manifest env (already in `process.env` by the time a shell runs) - * loses to the file exactly as under `mcp run`. Values are never logged. + * wins. A host merges the manifest `env` block into the child environment + * before launch, so by the time a shell runs a manifest default is + * indistinguishable from an exported variable — unless the shell knows the + * defaults. The stdio MCP shell therefore carries its server's manifest `env` + * (`manifestEnv`); a variable that still holds its manifest default is not + * reserved, so the file beats manifest env exactly as under `mcp run`. + * Values are never logged. */ import { readFileSync } from 'node:fs'; import { delimiter, join, resolve } from 'node:path'; @@ -45,6 +50,17 @@ export interface OperatorEnvResult { export interface OperatorEnvOptions { /** The environment to fill; `process.env` by default. Mutated in place. */ readonly env?: NodeJS.ProcessEnv; + /** + * The manifest `env` defaults the host merged into `env` before launch (the + * stdio server's declared block, as built). A variable whose current value + * equals its default is treated as the pass-through it almost always is, + * so the file may override it; one that differs was exported by the host or + * the operator and stays reserved. The one ambiguity is accepted: an + * operator export that equals the manifest default reads as the default. + * A default that still carries a path token never matches its expanded + * value, so such a variable stays reserved. + */ + readonly manifestEnv?: Readonly>; /** * The platform whose environment-key rules apply; `process.platform` by * default. Windows environment names are case-insensitive, so a host `Path` @@ -170,7 +186,8 @@ const readOptional = (path: string): string | undefined | null => { /** * Applies the operator `.env` layer to `env` in place, filling only variables - * the host did not set, and reports what happened without ever touching a + * the host did not set (a variable still holding its `manifestEnv` default + * counts as unset), and reports what happened without ever touching a * value. Missing files are the normal case (most packs need none); an * unreadable one is reported and skipped, never fatal — a pack must start * even when its operator file has the wrong permissions. @@ -182,7 +199,17 @@ export const applyOperatorEnv = (options: OperatorEnvOptions): OperatorEnvResult const reservedKey = (options.platform ?? process.platform) === 'win32' ? (key: string): string => key.toUpperCase() : (key: string): string => key; - const reserved = new Set(Object.keys(env).filter((key) => env[key] !== undefined).map(reservedKey)); + const manifestDefaults = new Map( + Object.entries(options.manifestEnv ?? {}).map(([key, value]) => [reservedKey(key), value] as const), + ); + // A variable the host passed through unchanged from the manifest is the + // lowest layer under `mcp run` too, so the file may fill it; anything + // else present was exported by the host or the operator and is reserved. + const reserved = new Set( + Object.keys(env) + .filter((key) => env[key] !== undefined && manifestDefaults.get(reservedKey(key)) !== env[key]) + .map(reservedKey), + ); const files: OperatorEnvFile[] = []; const applied = new Set(); for (const path of operatorEnvFilePaths(options.pluginRoot, env)) { diff --git a/packages/agent-bundle/src/mcp-entry.ts b/packages/agent-bundle/src/mcp-entry.ts index 70f354d7c..82cfaa088 100644 --- a/packages/agent-bundle/src/mcp-entry.ts +++ b/packages/agent-bundle/src/mcp-entry.ts @@ -231,8 +231,11 @@ export interface GeneratedStdioMcpEntryModule { export interface RunGeneratedStdioMcpEntryOptions { /** - * Loads the consumer entry module. Deferred so the console guard is active - * before any consumer module side effect can print to the protocol channel. + * Loads the consumer entry module. The generated shell imports the module + * statically, after the operator `.env` layer (#469), and resolves it here: + * under bundling every module of the single-chunk entry evaluates before + * the shell body whichever way it is imported, so the console guard covers + * the factory call and everything after it, not the module's top level. */ readonly loadEntry: () => Promise; /** Test seam mirroring {@link RunStdioServerOptions}. */ @@ -242,7 +245,7 @@ export interface RunGeneratedStdioMcpEntryOptions { /** * The body of every generated stdio MCP entry: install the stdout guard, - * evaluate the consumer module, build the server from its default-exported + * take the consumer module, build the server from its default-exported * factory, hand raw stdout back for protocol frames, and serve under the * managed lifecycle. */ diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts index 9045e9dec..fd4ddc4b3 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -119,6 +119,29 @@ const createFixture = async (options: { '}', '', ].join('\n')), + // The operator `.env` probe (#469): a route and a provider that both read + // `process.env` at module top level — what a static import evaluates + // before any statement of the bin — and again when they run. + writeProjectFile(root, 'src/cli/env-probe.ts', [ + "import { agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "const atImport = process.env.CLI_OPERATOR_TOKEN ?? 'unset';", + "export const config = { description: 'Report the operator token as the bin sees it.' };", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ atImport: z.string(), atRun: z.string(), providerAtImport: z.string() }).strict();', + 'export default async function envProbe() {', + ' const context = await agent();', + " return { atImport, atRun: process.env.CLI_OPERATOR_TOKEN ?? 'unset', providerAtImport: context.providers.operatorToken };", + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/providers/operator-token.ts', [ + "const atImport = process.env.CLI_OPERATOR_TOKEN ?? 'unset';", + 'export default async function operatorToken() {', + ' return atImport;', + '}', + '', + ].join('\n')), // A plain script route forwarding to the routed CLI through the // documented sibling convention: `../bin/.mjs` relative to // the script's own `import.meta.url` inside the artifact. @@ -267,7 +290,7 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew .sort()); }); -it('lets a skill reach the artifact bin through the plugin-root token', { retry: 1, timeout: 240_000 }, async () => { +it('lets a skill reach the artifact bin through the plugin-root token, and the bin applies the operator .env layer before its route and provider modules evaluate (#469)', { retry: 1, timeout: 240_000 }, async () => { const root = await createFixture({ skill: true, targets: ['claude'] }); const result = await build({ output: 'artifact', root }); const claudeRoot = join(root, 'artifact', 'claude'); @@ -282,6 +305,18 @@ it('lets a skill reach the artifact bin through the plugin-root token', { retry: const status = await execFile(process.execPath, [binPath, 'status', '--json']); expect(parseJsonLine(status.stdout)).toEqual({ invocation: 'cli', status: 'idle', surface: 'status' }); expect(result.diagnostics.filter((entry) => entry.code === 'AB4765')).toEqual([]); + + // `/.env` is read before the route and provider modules + // evaluate, so their module-level reads agree with the read at run time; + // an exported variable still wins, and `none` disables the layer. + const { CLI_OPERATOR_TOKEN: _token, ...hostEnv } = process.env; + const probe = async (env: Readonly>): Promise => + parseJsonLine((await execFile(process.execPath, [binPath, 'env-probe', '--json'], { env: { ...hostEnv, ...env } })).stdout); + expect(await probe({})).toEqual({ atImport: 'unset', atRun: 'unset', providerAtImport: 'unset' }); + await writeFile(join(claudeRoot, '.env'), 'CLI_OPERATOR_TOKEN=from-file\n'); + expect(await probe({})).toEqual({ atImport: 'from-file', atRun: 'from-file', providerAtImport: 'from-file' }); + expect(await probe({ CLI_OPERATOR_TOKEN: 'from-host' })).toEqual({ atImport: 'from-host', atRun: 'from-host', providerAtImport: 'from-host' }); + expect(await probe({ AGENT_BUNDLE_ENV_FILE: 'none' })).toEqual({ atImport: 'unset', atRun: 'unset', providerAtImport: 'unset' }); }); it('refuses a host-emitted file that collides with the routed CLI bin (AB4766)', { timeout: 120_000 }, async () => { diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index b68d67d90..9a6ed7b2c 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -10,6 +10,7 @@ import { claudeAdapter } from '../src/adapters/claude.ts'; import type { NoticeDeliveryAdvertisement } from '../src/adapters/notice-delivery.ts'; import { scanEntryExportsSource, stripCommentsAndStrings } from '../src/build/entry-exports.ts'; import * as entryShellModule from '../src/build/entry-shell.ts'; +import { launchEnvLayerSpecifier, operatorEnvLayerImport, operatorEnvLayerModuleSource, operatorEnvLayerVirtualModule } from '../src/build/launch-env-shell.ts'; import { stableJson } from '../src/core/digest.ts'; import { generatedExecutableEntrySource, @@ -85,20 +86,44 @@ describe('generated entry templates', () => { expect(path.endsWith('mcp-entry.ts') || path.endsWith('mcp-entry.js')).toBe(true); }); - it('generates a stdio entry that defers the consumer import behind the lifecycle', () => { + it('generates a stdio entry that imports the operator .env layer before the server module (#469)', () => { const source = generatedStdioMcpEntrySource({ entrySource: '/proj/src/mcp/curator.ts', serverName: 'curator' }); expect(source).toContain(`from ${JSON.stringify(mcpEntryRuntimeSpecifier)}`); - expect(source).toContain('loadEntry: () => import("/proj/src/mcp/curator.ts")'); expect(source).toContain('serverName: "curator"'); - // The consumer module must never be statically imported: the console - // guard has to activate before its side effects can reach stdout. - expect(source).not.toMatch(/^import[^\n]*curator\.ts/mu); - // The operator `.env` layer (#469) lands before the deferred import, so - // server code reads a composed process.env; the anchor is the artifact - // root (the parent of `mcp/`) unless the host set AGENT_BUNDLE_PLUGIN_ROOT. - expect(source).toContain("import { applyOperatorEnv, operatorEnvPluginRoot } from \"agent-bundle/launch-env\";"); - expect(source.indexOf("applyOperatorEnv({ pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });")) - .toBeLessThan(source.indexOf('await runGeneratedStdioMcpEntry(')); + // The layer is the shell's first import and the server module a static + // import after it: the bundler inlines every module ahead of the entry + // body and a dynamic import's target ahead of the static ones, so only + // static import order puts the layer before the server module's own top + // level (pinned end to end by tests/mcp.test.ts). + expect(source.startsWith(`${operatorEnvLayerImport}\n`)).toBe(true); + expect(source.indexOf(operatorEnvLayerImport)).toBeLessThan(source.indexOf('import * as serverModule from "/proj/src/mcp/curator.ts";')); + expect(source).toContain('loadEntry: async () => serverModule,'); + expect(source).not.toContain('import('); + expect(source).not.toContain('applyOperatorEnv'); + }); + + it('generates the operator .env layer module with the manifest env defaults it must recognise (#469)', () => { + // The anchor is the artifact root (the parent of `mcp/`, `hooks/`, `bin/`) + // unless the host set AGENT_BUNDLE_PLUGIN_ROOT; without manifest env the + // layer reserves every variable the host set. + const bare = operatorEnvLayerModuleSource(); + expect(bare).toContain("import { fileURLToPath } from 'node:url';"); + expect(bare).toContain('import { applyOperatorEnv, operatorEnvPluginRoot } from "agent-bundle/launch-env";'); + expect(bare).toContain("applyOperatorEnv({ pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });"); + expect(operatorEnvLayerModuleSource({})).toBe(bare); + // A stdio shell's layer embeds its server's manifest `env` block as sorted + // literals so a passed-through default is told from a host export; path + // tokens stay unexpanded (the host expands them, so they never match). + expect(operatorEnvLayerModuleSource({ ZED: 'last', API_URL: 'https://api.example', DATA_DIR: 'agent-bundle:path:plugin-root/data' })) + .toContain( + 'applyOperatorEnv({ manifestEnv: {"API_URL":"https://api.example","DATA_DIR":"agent-bundle:path:plugin-root/data","ZED":"last"}, ' + + "pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });", + ); + expect(operatorEnvLayerVirtualModule({ API_URL: 'x' })).toEqual({ + name: launchEnvLayerSpecifier, + source: operatorEnvLayerModuleSource({ API_URL: 'x' }), + }); + expect(operatorEnvLayerImport).toBe('import "agent-bundle/launch-env-layer";'); }); it('applies the operator .env layer in every artifact shell that runs plugin code, and only there (#469)', () => { @@ -116,14 +141,21 @@ describe('generated entry templates', () => { routes: [route], stateFallback: 'artifact', }); - expect(artifactBin).toContain("from \"agent-bundle/launch-env\""); - expect(artifactBin).toContain("import { fileURLToPath } from 'node:url';"); - expect(artifactBin.indexOf("applyOperatorEnv({ pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });")) - .toBeLessThan(artifactBin.indexOf('const processLifetime')); - // With durable state the bin already imports fileURLToPath; the layer must not declare it twice. + // The layer is the first import, ahead of the route, provider, and state + // modules, so a module-level `process.env` read in any of them sees the + // composed environment; the consumer imports themselves stay static. + expect(artifactBin.startsWith(`${operatorEnvLayerImport}\n`)).toBe(true); + expect(artifactBin).not.toContain('applyOperatorEnv'); + expect(artifactBin).toContain('import * as route0 from "/project/src/cli/report.ts";'); const durableBin = entryShellModule.generatedCliBinEntrySource({ commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, + providers: [{ + id: 'provider:project-auth', + name: 'project-auth', + provenance: { kind: 'conventional', relativePath: 'src/providers/project-auth.ts' }, + source: '/project/src/providers/project-auth.ts', + }], routes: [route], state: { id: 'project/tasks', @@ -133,7 +165,14 @@ describe('generated entry templates', () => { }, stateFallback: 'artifact', }); - expect(durableBin.match(/import \{ fileURLToPath \} from 'node:url';/gu)).toHaveLength(1); + expect(durableBin.startsWith(`${operatorEnvLayerImport}\n`)).toBe(true); + for (const consumer of [ + 'import stateDefinition from "/project/src/state.ts";', + 'import * as route0 from "/project/src/cli/report.ts";', + 'import * as provider0 from "/project/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({ commands: [command], diff --git a/packages/agent-bundle/tests/hook-handler-contract.test.ts b/packages/agent-bundle/tests/hook-handler-contract.test.ts index c685a386d..9966d8867 100644 --- a/packages/agent-bundle/tests/hook-handler-contract.test.ts +++ b/packages/agent-bundle/tests/hook-handler-contract.test.ts @@ -22,7 +22,7 @@ import { type HookResult, } from '../src/adapters/hook-handler.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; -import { launchEnvRuntimeSpecifier, operatorEnvStatement } from '../src/build/launch-env-shell.ts'; +import { launchEnvLayerSpecifier } from '../src/build/launch-env-shell.ts'; import { canonicalHookEvents } from '../src/core/types.ts'; import type { CanonicalAgentEvent } from '../src/routes/public.ts'; @@ -73,13 +73,14 @@ const wrapperInternalsSource = (host: Host, entry: TargetHookWrapper): string => : nativeHookWrapperSource(entry, host === 'claude' ? 'Claude' : 'Codex'); const mainIndex = source.indexOf('if (import.meta.main) {'); expect(mainIndex).toBeGreaterThan(0); - // The handler module and the operator `.env` layer (#469) are the wrapper's - // bundled runtime; the codec internals under test need neither. + // The handler module and the operator `.env` layer module (#469, a virtual + // module the build serves) are the wrapper's bundled runtime; the codec + // internals under test need neither. const body = source .slice(0, mainIndex) .split('\n') .filter((line) => !line.startsWith('import * as handlerModule from ')) - .filter((line) => !line.includes(JSON.stringify(launchEnvRuntimeSpecifier)) && line !== operatorEnvStatement) + .filter((line) => !line.includes(JSON.stringify(launchEnvLayerSpecifier))) .join('\n'); const decoder = host === 'cursor' ? 'decodeCursorNative' : 'decodeNative'; return `${body}\nexport { ${decoder} as decodeNative, validateNativeInput, validateResult };\n`; diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 16cb16dda..035e176eb 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1051,7 +1051,7 @@ it('compiles each native hook through a virtual Rslib entry without sibling chun } }); -it('applies the operator .env layer of the installed pack before a hook handler runs, filling only what the host did not set (#469)', async () => { +it('applies the operator .env layer of the installed pack before a hook handler module evaluates, filling only what the host did not set (#469)', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-operator-env-')); const sourceRoot = join(root, 'src', 'hooks'); const outputRoot = join(root, 'dist'); @@ -1061,9 +1061,17 @@ it('applies the operator .env layer of the installed pack before a hook handler await mkdir(sourceRoot, { recursive: true }); await Promise.all([ writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'), - writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + // `sideEffects: false` is the consumer package.json declaration that + // would let the bundler drop the wrapper's bare import of the layer + // module; the build marks generated modules side-effectful regardless. + writeFile(join(root, 'package.json'), '{"type":"module","sideEffects":false}\n'), + // The module-level read is what the handler import evaluates before any + // statement of the wrapper; it must agree with the read inside the + // handler, so the layer import has to precede the handler import and + // survive bundling in that order. writeFile(join(sourceRoot, 'session-start.ts'), [ - "export default () => ({ outcome: 'continue' as const, additionalContext: `${process.env.OPERATOR_TOKEN ?? 'unset'}:${process.env.HOST_WINS ?? 'unset'}` });", + "const atImport = process.env.OPERATOR_TOKEN ?? 'unset';", + "export default () => ({ outcome: 'continue' as const, additionalContext: `${atImport}=${process.env.OPERATOR_TOKEN ?? 'unset'}:${process.env.HOST_WINS ?? 'unset'}` });", '', ].join('\n')), writeFile(join(sourceRoot, 'check-command.ts'), "export default () => ({ outcome: 'continue' as const });\n"), @@ -1083,25 +1091,25 @@ it('applies the operator .env layer of the installed pack before a hook handler // No file: the wrapper is a no-op and the handler sees the host environment only. const withoutFile = await runNativeHook(wrapper, event); expect(withoutFile).toMatchObject({ code: 0, stderr: '' }); - expect(context(withoutFile)).toBe('unset:unset'); + expect(context(withoutFile)).toBe('unset=unset:unset'); // `/.env` fills the gap; an exported variable still wins. await writeFile(join(pluginRoot, '.env'), 'OPERATOR_TOKEN=from-file\nHOST_WINS=from-file\n'); const withFile = await runNodeScript({ args: [wrapper], env: { HOST_WINS: 'host' }, input: JSON.stringify(event) }); expect(withFile).toMatchObject({ code: 0, stderr: '' }); - expect(context(withFile)).toBe('from-file:host'); + expect(context(withFile)).toBe('from-file=from-file:host'); // The anchor the host injects decides the location: pointed elsewhere, the artifact's file is not read. const elsewhere = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-operator-env-anchor-')); try { await writeFile(join(elsewhere, '.env'), 'OPERATOR_TOKEN=from-anchor\n'); const anchored = await runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_PLUGIN_ROOT: elsewhere }, input: JSON.stringify(event) }); - expect(context(anchored)).toBe('from-anchor:unset'); + expect(context(anchored)).toBe('from-anchor=from-anchor:unset'); // An explicit AGENT_BUNDLE_ENV_FILE replaces the convention; `none` disables the layer. const explicit = await runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_ENV_FILE: join(elsewhere, '.env') }, input: JSON.stringify(event) }); - expect(context(explicit)).toBe('from-anchor:unset'); + expect(context(explicit)).toBe('from-anchor=from-anchor:unset'); const disabled = await runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_ENV_FILE: 'none' }, input: JSON.stringify(event) }); - expect(context(disabled)).toBe('unset:unset'); + expect(context(disabled)).toBe('unset=unset:unset'); } finally { await rm(elsewhere, { force: true, recursive: true }); } @@ -1382,9 +1390,15 @@ it('round-trips the documented Cursor subagent envelopes through published Curso join(sourceRoot, 'subagent-start.ts'), "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.sessionId)}:${String(event.agentId)}:${String(event.agentType)}:${String(event.toolUseId)}:${String(event.model)}` });\n", ), + // The module-level read proves the Cursor wrapper binds the handler + // after the operator `.env` layer (#469), like the native wrappers. writeFile( join(sourceRoot, 'subagent-stop.ts'), - "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.agentTranscriptPath)}:${String(event.stopHookActive)}:${String(event.lastAssistantMessage)}:${String(event.agentType)}` });\n", + [ + "const atImport = process.env.CURSOR_OPERATOR ?? 'unset';", + "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.agentTranscriptPath)}:${String(event.stopHookActive)}:${String(event.lastAssistantMessage)}:${String(event.agentType)}:${atImport}` });", + '', + ].join('\n'), ), ]); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); @@ -1421,9 +1435,19 @@ it('round-trips the documented Cursor subagent envelopes through published Curso code: 0, stderr: '', stdout: JSON.stringify({ - followup_message: `${String(stopInput.agent_transcript_path)}:false:${String(stopInput.summary)}:${String(stopInput.subagent_type)}`, + followup_message: `${String(stopInput.agent_transcript_path)}:false:${String(stopInput.summary)}:${String(stopInput.subagent_type)}:unset`, + }), + }); + // `/.env` is read before the handler module evaluates. + await writeFile(join(outputRoot, 'cursor', '.env'), 'CURSOR_OPERATOR=from-file\n'); + await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ + code: 0, + stderr: '', + stdout: JSON.stringify({ + followup_message: `${String(stopInput.agent_transcript_path)}:false:${String(stopInput.summary)}:${String(stopInput.subagent_type)}:from-file`, }), }); + await rm(join(outputRoot, 'cursor', '.env')); // The Claude/Codex agent_id/agent_type spelling is not the Cursor envelope. await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), { agent_id: 'abc-123', diff --git a/packages/agent-bundle/tests/launch-env.test.ts b/packages/agent-bundle/tests/launch-env.test.ts index 469724ad6..f2a05236b 100644 --- a/packages/agent-bundle/tests/launch-env.test.ts +++ b/packages/agent-bundle/tests/launch-env.test.ts @@ -130,6 +130,48 @@ describe('the operator .env layer of an installed pack (#469)', () => { expect(JSON.stringify(result)).not.toContain('s3cr3t'); }); + it('lets the file beat a manifest default the host passed through, but never a value that differs from it', async () => { + const root = await createRoot(); + await writeFile(join(root, '.env'), 'MANIFEST_ONLY=from-file\nHOST_EXPORTED=from-file\nHOST_ONLY=from-file\nABSENT_EVERYWHERE=from-file\n'); + const manifestEnv = { + HOST_EXPORTED: 'manifest-default', + MANIFEST_KEPT: 'manifest-default', + MANIFEST_ONLY: 'manifest-default', + // A path token is expanded by the host before launch, so the running + // value never equals the embedded default and stays reserved. + TOKENIZED: 'agent-bundle:path:plugin-root/data', + }; + // What a host hands the child: the manifest block merged with its own exports. + const env: NodeJS.ProcessEnv = { + HOST_EXPORTED: 'from-host', + HOST_ONLY: 'from-host', + MANIFEST_KEPT: 'manifest-default', + MANIFEST_ONLY: 'manifest-default', + TOKENIZED: '/installs/curator/data', + }; + + expect(applyOperatorEnv({ env, manifestEnv, pluginRoot: root }).applied).toEqual(['ABSENT_EVERYWHERE', 'MANIFEST_ONLY']); + expect(env).toEqual({ + ABSENT_EVERYWHERE: 'from-file', + HOST_EXPORTED: 'from-host', + HOST_ONLY: 'from-host', + MANIFEST_KEPT: 'manifest-default', + MANIFEST_ONLY: 'from-file', + TOKENIZED: '/installs/curator/data', + }); + + // Without the defaults every present variable is reserved (the self-connecting entry's position). + const blind: NodeJS.ProcessEnv = { MANIFEST_ONLY: 'manifest-default' }; + expect(applyOperatorEnv({ env: blind, pluginRoot: root }).applied).toEqual(['ABSENT_EVERYWHERE', 'HOST_EXPORTED', 'HOST_ONLY']); + expect(blind.MANIFEST_ONLY).toBe('manifest-default'); + + // The default is matched under Windows' case-insensitive names too. + const windows: NodeJS.ProcessEnv = { Manifest_Only: 'manifest-default' }; + expect(applyOperatorEnv({ env: windows, manifestEnv, platform: 'win32', pluginRoot: root }).applied) + .toEqual(['ABSENT_EVERYWHERE', 'HOST_EXPORTED', 'HOST_ONLY', 'MANIFEST_ONLY']); + expect(windows.MANIFEST_ONLY).toBe('from-file'); + }); + it('is a no-op with absent files and reports them as absent', async () => { const root = await createRoot(); const env: NodeJS.ProcessEnv = { KEEP: '1' }; diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index c79c3328b..70d53ac37 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -741,6 +741,112 @@ it('inlines agent-bundle/launch-env into a self-connecting entry so it can apply } }, 30_000); +it('lets the operator .env beat a manifest env default the host passed through, never a host export, before the server module evaluates (#469)', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-manifest-env-')); + try { + await mkdir(join(root, 'src'), { recursive: true }); + await mkdir(join(root, 'node_modules'), { recursive: true }); + await symlink( + join(agentBundleNodeModules, '@modelcontextprotocol'), + join(root, 'node_modules', '@modelcontextprotocol'), + 'dir', + ); + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + await writeFile(join(root, 'package.json'), '{"type":"module"}\n'); + // A factory entry that reports the composed environment twice: once at + // module top level (what a static import evaluates first) and once when + // the factory runs, then exits before the lifecycle opens the transport. + const names = ['MANIFEST_ONLY', 'MANIFEST_KEPT', 'HOST_EXPORTED', 'HOST_ONLY', 'ABSENT_EVERYWHERE'] as const; + await writeFile(join(root, 'src', 'server.ts'), [ + "import { McpServer } from '@modelcontextprotocol/server';", + `const names = ${JSON.stringify(names)};`, + "const snapshot = () => Object.fromEntries(names.map((name) => [name, process.env[name] ?? null]));", + 'const atImport = snapshot();', + 'export default () => {', + " process.stderr.write(`${JSON.stringify({ atImport, atRun: snapshot() })}\\n`);", + ' process.exit(0);', + " return new McpServer({ name: 'manifest-env', version: '1.0.0' });", + '};', + '', + ].join('\n')); + const model = await normalizeProject( + loadedProject(root, { + mcp: { + servers: { + probe: { + entry: './src/server.ts', + env: { HOST_EXPORTED: 'manifest-default', MANIFEST_KEPT: 'manifest-default', MANIFEST_ONLY: 'manifest-default' }, + }, + }, + }, + plugin: { name: 'mcp-manifest-env', version: '1.0.0' }, + targets: ['claude'], + }), + { skills: [] }, + registry, + ); + const outputRoot = join(root, 'artifact'); + const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + const [entry] = result.compiledMcpEntries; + const pluginRoot = join(outputRoot, 'claude'); + + // The host reads the manifest, expands its plugin-root token, and merges + // the `env` block into the child environment beneath its own exports — + // so the child sees a manifest default and a host export the same way. + const manifest = JSON.parse(await readFile(join(pluginRoot, '.mcp.json'), 'utf8')) as { + readonly mcpServers: Readonly> }>>; + }; + const manifestEnv = Object.fromEntries(Object.entries(manifest.mcpServers['probe']!.env) + .map(([key, value]) => [key, value.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot)])); + expect(manifestEnv).toEqual({ + AGENT_BUNDLE_PLUGIN_ROOT: pluginRoot, + HOST_EXPORTED: 'manifest-default', + MANIFEST_KEPT: 'manifest-default', + MANIFEST_ONLY: 'manifest-default', + }); + const hostExports = { HOST_EXPORTED: 'from-host', HOST_ONLY: 'from-host' }; + const launch = async (overrides: Readonly> = {}): Promise => { + const run = await runNodeScript({ args: [entry!.output], env: { ...manifestEnv, ...hostExports, ...overrides } }); + expect(run).toMatchObject({ code: 0, stdout: '' }); + const report = JSON.parse(run.stderr) as { readonly atImport: unknown; readonly atRun: unknown }; + // The layer lands before the server module's own top level. + expect(report.atImport).toEqual(report.atRun); + return report.atRun; + }; + // No file: the host environment as delivered. + const delivered = { + ABSENT_EVERYWHERE: null, + HOST_EXPORTED: 'from-host', + HOST_ONLY: 'from-host', + MANIFEST_KEPT: 'manifest-default', + MANIFEST_ONLY: 'manifest-default', + }; + expect(await launch()).toEqual(delivered); + + await writeFile(join(pluginRoot, '.env'), [ + 'MANIFEST_ONLY=from-file', + 'HOST_EXPORTED=from-file', + 'HOST_ONLY=from-file', + 'ABSENT_EVERYWHERE=from-file', + '', + ].join('\n')); + // manifest < .env < host: a passed-through manifest default yields to the + // file, a host export never does, a gap is filled, an untouched manifest + // default stays. + expect(await launch()).toEqual({ + ABSENT_EVERYWHERE: 'from-file', + HOST_EXPORTED: 'from-host', + HOST_ONLY: 'from-host', + MANIFEST_KEPT: 'manifest-default', + MANIFEST_ONLY: 'from-file', + }); + // `AGENT_BUNDLE_ENV_FILE=none` disables the layer: the manifest default stands. + expect(await launch({ AGENT_BUNDLE_ENV_FILE: 'none' })).toEqual(delivered); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 60_000); + it('builds one deterministic self-contained MCP App view and injects it through the virtual module', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-build-')); try { diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 2252cf53e..d979ce639 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -482,12 +482,15 @@ Source validation reports the informational `AB4730` nudge suggesting the factor never an error. The same lifecycle is public API for hand-rolled entries, and so is the operator `.env` layer the -generated shell applies before it imports the server module (see +generated shell imports ahead of the server module, so that even a `process.env` read at the +module's top level sees the composed environment (see [Installation](../distribution/installation.mdx#operator-configuration-the-installed-packs-env)). Both imports are inlined into the emitted entry, which stays self-contained; `agent-bundle/launch-env` is aliased into every stdio entry, so it resolves whether or not the entry has a shell. A self-connecting entry that needs the layer applies it first thing, anchored -the way the generated shell is (the artifact root is the parent of `mcp/`): +the way the generated shell is (the artifact root is the parent of `mcp/`), and may pass its own +declared `env` block as `manifestEnv` so that a manifest default the host passed through yields to +the file the way it does in the generated shell: ```ts import { fileURLToPath } from 'node:url'; @@ -495,12 +498,14 @@ import { applyOperatorEnv, operatorEnvPluginRoot } from 'agent-bundle/launch-env import { redirectConsoleToStderr, runStdioServer } from 'agent-bundle/mcp-entry'; applyOperatorEnv({ + manifestEnv: { LIBRARY_URL: 'https://library.example' }, pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))), }); ``` `operatorEnvPluginRoot` prefers an `AGENT_BUNDLE_PLUGIN_ROOT` the host set over the directory you -pass, so the entry reads the same files as the pack's hook wrappers and CLI. +pass, so the entry reads the same files as the pack's hook wrappers and CLI. Without `manifestEnv` +every variable the host set is kept, manifest defaults included. ## Declaring servers in config diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 20527ec87..80e16f443 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -120,9 +120,22 @@ the artifact CLI `bin/.mjs` — under one rule: | Precedence | Layer | Contents | | --- | --- | --- | -| 3 (highest) | Host environment | What the host exported and what its manifest `env` declared (including `AGENT_BUNDLE_PLUGIN_ROOT`). An exported variable always wins. | -| 2 | `.env.local` | Fills gaps only; beats `.env` for the same key. | -| 1 (lowest) | `.env` | Fills gaps only. | +| 4 (highest) | Host environment | What the host exported. An exported variable always wins. | +| 3 | `.env.local` | Fills gaps only; beats `.env` for the same key. | +| 2 | `.env` | Fills gaps only. | +| 1 (lowest) | Manifest `env` | What the server's manifest `env` block declared, as the host merged it into the process environment. | + +The same order `agent-bundle mcp run` composes — manifest `env` under the files under the +operator's exports. A host hands a stdio server its manifest `env` and its own exports as one +environment, so the generated entry carries the server's declared `env` block as build-time +literals and treats a variable that still holds its manifest default as unset: the file may +override it. A variable whose value differs from the default was exported by the host or the +operator and is kept. Two consequences follow. An operator export that happens to equal the +manifest default is indistinguishable from the pass-through and yields to the file too. A manifest +value carrying a path token (`AGENT_BUNDLE_PLUGIN_ROOT`, or a declared `env` value that uses one) +is expanded by the host before launch, so it never equals its literal default and is always kept. +Hook wrappers and the artifact CLI have no manifest `env`, so for them the host environment is +simply what the host exported. The location is the plugin root the pack resolved — `AGENT_BUNDLE_PLUGIN_ROOT` when the host set it, otherwise the directory above the shell's own (`mcp/`, `hooks/`, `bin/`). `AGENT_BUNDLE_ENV_FILE` @@ -133,11 +146,11 @@ double-, or backtick-quoted values (double quotes expand `\n`), multi-line quote skipped so the pack still starts. Values are never logged. The file is not owned by the install receipt, so `install --replace` and a same-version rebuild leave it in place, and `uninstall` never removes unowned entries: the file survives an uninstall too (Doctor then reports the directory as a -remnant, `AB7307`), so delete it by hand when the credentials should go. Route and provider code sees the composed -environment when it runs; a hook handler or CLI route module that reads `process.env` at import -time still sees the host environment only, because those modules are static imports of their -wrapper — MCP server modules load after the layer and see it everywhere. `agent-bundle mcp run` -composes the same three layers itself and hands `--env-file` / `--no-env` down as +remnant, `AB7307`), so delete it by hand when the credentials should go. The layer is the first +import of every shell that applies it, ahead of the server module, the hook handler, and the CLI +route, provider, and state modules, so plugin code sees the composed environment even in a +`process.env` read at module top level, not only when it runs. `agent-bundle mcp run` +composes the same layers itself and hands `--env-file` / `--no-env` down as `AGENT_BUNDLE_ENV_FILE`, so a rehearsal and an installed pack read the same files. `agent-bundle doctor` reports whether an installed copy carries `.env` or `.env.local` and how many diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index f0eafc5df..03c56e578 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -69,10 +69,13 @@ scratch object, so the real `process.env` is never mutated. A host launches an installed pack directly, so the pack's own shells apply the same layer themselves: the stdio MCP entries, the hook wrappers that run plugin code, and the artifact CLI `bin/.mjs` read `/.env` then `.env.local` at launch (or the files -`AGENT_BUNDLE_ENV_FILE` names), filling only variables the host did not set — host environment and -manifest `env` win, `.env.local` beats `.env`, values are never logged, a missing file costs -nothing, an unreadable one is skipped. The plugin root is `AGENT_BUNDLE_PLUGIN_ROOT` when the host -set it, otherwise the directory above the shell's own. No `${VAR}` interpolation is performed. +`AGENT_BUNDLE_ENV_FILE` names), filling only variables the host did not set — an exported variable +wins, a manifest `env` default the host passed through yields to the file (the stdio entry carries +its server's declared block to tell the two apart), `.env.local` beats `.env`, values are never +logged, a missing file costs nothing, an unreadable one is skipped. The layer is applied before the +server, handler, route, provider, and state modules evaluate, so a module-level `process.env` read +sees it. The plugin root is `AGENT_BUNDLE_PLUGIN_ROOT` when the host set it, otherwise the +directory above the shell's own. No `${VAR}` interpolation is performed. Under `mcp run` the plugin root is the project root, so the shell re-reads the files `mcp run` already composed and changes nothing; `--env-file` and `--no-env` travel down as `AGENT_BUNDLE_ENV_FILE`. The npm package bin reads no pack file. Doctor reports the presence and diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index e8001edd9..3a4b812df 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -427,10 +427,12 @@ export default () => createRscMcpServer(application, 'curator'); 外壳,也没有操作者 `.env` 层,因为没有外壳能在模块自身的顶层代码运行之前应用它。源码校验 会报告信息级的 `AB4730` 提示,建议升级为工厂函数写法;它永远不是错误。 -同一套生命周期对手写入口也是公开 API,生成外壳在导入服务器模块之前应用的操作者 `.env` 层同样如此 -(见[安装](../distribution/installation.mdx#操作者配置已安装包的-env))。这两个导入都会被内联进产出的 -入口,入口保持自包含;`agent-bundle/launch-env` 会被别名到每一个 stdio 入口,因此无论入口有没有外壳都能 -解析。需要这一层的自行连接入口应在第一行自行应用它,并像生成外壳一样锚定(产物根目录是 `mcp/` 的上一级): +同一套生命周期对手写入口也是公开 API,生成外壳先于服务器模块导入的操作者 `.env` 层同样如此——因此即使 +在模块顶层读取 `process.env` 也能看到合成后的环境(见[安装](../distribution/installation.mdx#操作者配置已安装包的-env))。 +这两个导入都会被内联进产出的入口,入口保持自包含;`agent-bundle/launch-env` 会被别名到每一个 stdio +入口,因此无论入口有没有外壳都能解析。需要这一层的自行连接入口应在第一行自行应用它,并像生成外壳一样 +锚定(产物根目录是 `mcp/` 的上一级);还可以把自己声明的 `env` 块作为 `manifestEnv` 传入,让宿主透传的 +清单默认值像在生成外壳中那样让位于文件: ```ts import { fileURLToPath } from 'node:url'; @@ -438,12 +440,14 @@ import { applyOperatorEnv, operatorEnvPluginRoot } from 'agent-bundle/launch-env import { redirectConsoleToStderr, runStdioServer } from 'agent-bundle/mcp-entry'; applyOperatorEnv({ + manifestEnv: { LIBRARY_URL: 'https://library.example' }, pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))), }); ``` `operatorEnvPluginRoot` 会优先采用宿主设置的 `AGENT_BUNDLE_PLUGIN_ROOT`,否则回退到你传入的目录, -因此该入口读取的文件与同一个包的钩子包装器和 CLI 完全一致。 +因此该入口读取的文件与同一个包的钩子包装器和 CLI 完全一致。不传 `manifestEnv` 时,宿主设置的每个变量 +都会被保留,清单默认值也在其中。 ## 在配置中声明服务器 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index c3d695dfd..12f57ea26 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -102,9 +102,18 @@ RTORRENT_SSH_HOST=nas.local | 优先级 | 层 | 内容 | | --- | --- | --- | -| 3(最高) | 宿主环境 | 宿主导出的变量以及其清单 `env` 声明的变量(包括 `AGENT_BUNDLE_PLUGIN_ROOT`)。导出的变量永远胜出。 | -| 2 | `.env.local` | 只填补空缺;同一个键上胜过 `.env`。 | -| 1(最低) | `.env` | 只填补空缺。 | +| 4(最高) | 宿主环境 | 宿主导出的变量。导出的变量永远胜出。 | +| 3 | `.env.local` | 只填补空缺;同一个键上胜过 `.env`。 | +| 2 | `.env` | 只填补空缺。 | +| 1(最低) | 清单 `env` | 服务器清单 `env` 块声明的变量,以宿主合并进进程环境的形态出现。 | + +这与 `agent-bundle mcp run` 合成的顺序相同——清单 `env` 在文件之下,文件在操作者导出的变量之下。宿主把 +清单 `env` 和自己导出的变量作为同一份环境交给 stdio 服务器,因此生成入口会把该服务器声明的 `env` 块作为 +构建时字面量随行携带,并把仍持有清单默认值的变量视为未设置:文件可以覆盖它。取值与默认值不同的变量是 +宿主或操作者导出的,予以保留。由此有两个推论。操作者恰好导出了与清单默认值相同的值时,无法与透传区分, +同样让位于文件。带有路径 token 的清单值(`AGENT_BUNDLE_PLUGIN_ROOT`,或使用了 token 的声明 `env` 值) +会在启动前被宿主展开,因此永远不等于其字面默认值,总是被保留。hook 包装器与产物 CLI 没有清单 `env`, +对它们而言宿主环境就是宿主导出的变量。 位置是包解析出的插件根目录——宿主设置了 `AGENT_BUNDLE_PLUGIN_ROOT` 时取它,否则取外壳自身所在目录 (`mcp/`、`hooks/`、`bin/`)的上一级。`AGENT_BUNDLE_ENV_FILE` 可指定改为读取的一个或多个文件(以平台路径 @@ -112,10 +121,11 @@ RTORRENT_SSH_HOST=nas.local `#` 注释、单引号、双引号或反引号包裹的值(双引号内展开 `\n`)、跨行的引号值,且不做 `${VAR}` 插值。 文件缺失是常态且没有开销;无法读取的文件会被跳过,包照常启动。取值永远不会被记录。该文件不归安装回执 所有,因此 `install --replace` 与同版本重建都会保留它,而 `uninstall` 绝不移除非归属条目:卸载后该文件仍会 -留下(Doctor 随后以 `AB7307` 把该目录报告为残留),凭据应当消失时请手动删除。路由与 provider 代码在运行时看到的是合成后的环境;在导入时就读取 `process.env` 的 hook 处理器或 -CLI 路由模块仍只看到宿主环境,因为这些模块是其包装器的静态导入——MCP 服务器模块在这一层之后加载, -处处可见。`agent-bundle mcp run` 自己也合成同样的三层,并把 `--env-file` / `--no-env` 以 -`AGENT_BUNDLE_ENV_FILE` 传给子进程,因此演练与已安装包读取的是同一组文件。 +留下(Doctor 随后以 `AB7307` 把该目录报告为残留),凭据应当消失时请手动删除。这一层是每个应用它的 +外壳的第一个导入,先于服务器模块、hook 处理器以及 CLI 的路由、provider 与 state 模块,因此插件代码即使 +在模块顶层读取 `process.env` 看到的也是合成后的环境,而不只是在运行时。`agent-bundle mcp run` 自己也 +合成同样的几层,并把 `--env-file` / `--no-env` 以 `AGENT_BUNDLE_ENV_FILE` 传给子进程,因此演练与已安装包 +读取的是同一组文件。 `agent-bundle doctor` 会报告已安装副本是否带有 `.env` 或 `.env.local` 以及各自声明了多少个变量 (`AB7331`,信息级)——绝不报告名字或取值。 diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index 549e28be2..8dd13a4fe 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -65,8 +65,10 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 宿主直接启动已安装的包,因此包自己的外壳会自行应用同一层:stdio MCP 入口、运行插件代码的 hook 包装器 以及产物 CLI `bin/.mjs` 在启动时读取 `<插件根目录>/.env` 再读取 `.env.local`(或 -`AGENT_BUNDLE_ENV_FILE` 指定的文件),只填补宿主未设置的变量——宿主环境与清单 `env` 胜出,`.env.local` -胜过 `.env`,取值永不记录,文件缺失没有开销,无法读取则跳过。插件根目录在宿主设置了 +`AGENT_BUNDLE_ENV_FILE` 指定的文件),只填补宿主未设置的变量——导出的变量胜出,宿主透传的清单 `env` +默认值让位于文件(stdio 入口随行携带其服务器声明的 `env` 块以区分二者),`.env.local` 胜过 `.env`,取值 +永不记录,文件缺失没有开销,无法读取则跳过。这一层在服务器、处理器、路由、provider 与 state 模块求值之前 +应用,因此模块顶层的 `process.env` 读取也能看到它。插件根目录在宿主设置了 `AGENT_BUNDLE_PLUGIN_ROOT` 时取它,否则取外壳自身所在目录的上一级。不做 `${VAR}` 插值。在 `mcp run` 下 插件根目录就是项目根目录,因此外壳重读的正是 `mcp run` 已经合成过的文件,不会改变任何东西;`--env-file` 与 `--no-env` 以 `AGENT_BUNDLE_ENV_FILE` 传给子进程。npm 包 bin 不读取任何包文件。Doctor 会报告每个文件 From 9d3bc2f280832bd0a9be056748785bc2e9c3f2d6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 20:57:10 +0000 Subject: [PATCH 6/9] chore: drop the tracked .superpowers scratch notes and ignore the folder --- .gitignore | 1 + .../task-9a-mcp-launch-config-report.md | 29 ---------- .../task-9b-mcp-session-timeout-report.md | 34 ------------ .../task-9c-integrated-inspector-report.md | 35 ------------ ...9d-current-protocol-trace-export-report.md | 53 ------------------- 5 files changed, 1 insertion(+), 151 deletions(-) delete mode 100644 .superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9a-mcp-launch-config-report.md delete mode 100644 .superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9b-mcp-session-timeout-report.md delete mode 100644 .superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9c-integrated-inspector-report.md delete mode 100644 .superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9d-current-protocol-trace-export-report.md diff --git a/.gitignore b/.gitignore index 9b790cb4e..302c22b99 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ website/docs/*/reference/hosts.md website/docs/*/reference/events.md website/docs/*/reference/notices.md website/docs/*/reference/diagnostics.md +.superpowers/ diff --git a/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9a-mcp-launch-config-report.md b/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9a-mcp-launch-config-report.md deleted file mode 100644 index 231e91ecf..000000000 --- a/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9a-mcp-launch-config-report.md +++ /dev/null @@ -1,29 +0,0 @@ -# Task 9A: MCP launch configuration report - -## Outcome - -- Added the Session-local `Launch configuration` display using only `McpBrowserSessionModel.config`. -- `stdio` renders transport, command, ordered arguments, cwd, and lexical environment rows with explicit empty states. -- `streamable-http` renders only its transport and sanitized URL. -- The existing Inspector-config download and display now both derive from the same local `config` snapshot. -- No server or model sanitizer changes were needed. - -## TDD evidence - -- RED: `npm test -- packages/workbench/tests/mcp-page.test.ts` exited 1 because the current page did not contain `Launch configuration`. -- RED: `npm exec rstest -- --config rstest.config.ts run packages/workbench/tests/overview.e2e.test.ts --testNamePattern 'renders the safe launch configuration for one real artifact MCP session'` exited 1 at the missing `.mcp-page-launch-configuration` locator. -- GREEN: the same dedicated Chrome command exited 0 (1 passed, 7 skipped). It opens a real artifact session, asserts safe values and secret absence, checks 390px overflow, and records page errors. - -## Verification - -- `npm test -- packages/workbench/tests/mcp-page.test.ts packages/workbench/tests/mcp-session-model.test.ts packages/workbench/tests/mcp-session-controller.test.ts` — 38 passed. -- `npm run typecheck --workspace agent-bundle-workbench` — passed. -- `npm exec rslint -- packages/workbench/src/mcp/mcp-page.tsx packages/workbench/tests/mcp-page.test.ts packages/workbench/tests/overview.e2e.test.ts` — 0 errors, 0 warnings. -- `npm run build --workspace agent-bundle-workbench` — production Rsbuild passed. -- `git diff --check` — passed. -- Review regression: `git diff --unified=0 bc25a0c -- packages/workbench/tests/overview.e2e.test.ts` shows only the dedicated helper and test outside the broad lifecycle body. -- TraceDecay file diagnostics — 0 diagnostics; unsafe-pattern and redundancy scans found no production findings. - -## Browser note - -The in-app Browser was unavailable, so the task-authorized Chrome fixture was used. Launch-configuration display, secret-absence, responsive-layout, and page-error coverage live solely in the dedicated Chrome test; the pre-existing broad lifecycle body was restored to its `bc25a0c` form. Its normal close-button click still has the independently reproduced baseline timeout, including when the Task 9A session subsection is temporarily removed. diff --git a/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9b-mcp-session-timeout-report.md b/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9b-mcp-session-timeout-report.md deleted file mode 100644 index 0092d26ce..000000000 --- a/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9b-mcp-session-timeout-report.md +++ /dev/null @@ -1,34 +0,0 @@ -# Task 9B: MCP session timeout report - -## Delivered contract - -- `timeoutMs` is selected before session open, defaults to 5,000 ms, and is stored immutably on the session rather than in `McpSessionBinding`. -- The MCP create route admits only a finite, positive own JSON number and returns the established `AB8016` invalid-shape diagnostic for invalid values or smuggled fields. -- Initialization, catalog reads, prompt/resource/tool calls, and restart resolve omitted timeouts from the session value; internal callers can still supply an explicit request override. -- Route, transport, controller, and page retain the value. The page exposes an accessible `Session timeout (ms)` control, validates locally, locks it once opening begins, and displays the exact active timeout. - -## TDD evidence - -- Backend RED: a create request with `timeoutMs` received HTTP 400; the opened session exposed no timeout. -- Browser RED: transport POST body omitted `timeoutMs`, controller factory options omitted it, and page markup had no labeled control. -- Chrome RED: dedicated interaction contract was written before the page control existed. -- GREEN: all contracts pass with the implementation below. - -## Verification - -- `npm test -- packages/agent-bundle/tests/mcp-session-service.test.ts packages/agent-bundle/tests/mcp-session-routes.test.ts` — 34 passed. -- `npm run typecheck --workspace agent-bundle-workbench` — passed. -- Scoped `npx rslint` for the five MCP sources and focused tests — 0 errors, 0 warnings. -- `npm test -- packages/workbench/tests/agent-bundle-remote-transport.test.ts packages/workbench/tests/mcp-session-controller.test.ts packages/workbench/tests/mcp-page.test.ts` — 41 passed. -- `npx rstest --config rstest.config.ts packages/workbench/tests/mcp-session-timeout.e2e.test.ts --reporter verbose` — 1 Chrome test passed, including restart, invalid local input/no route request, no page errors, and 390 px overflow check. -- `npm run build --workspace agent-bundle-workbench` and `git diff --check` — passed. -- TraceDecay diagnostics reported 0 errors; simplify, redundancy, unsafe-pattern, and test-risk scans reported no findings in scope. - -## Commits - -1. `9f868fa feat(mcp): persist session request timeouts` -2. `feat(workbench): control MCP session timeouts` - -## Scope guard - -No per-operation browser timeout, launch-config/download, trace-export, Inspector, Playground, eval, raw-log, or broad lifecycle E2E changes were made. diff --git a/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9c-integrated-inspector-report.md b/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9c-integrated-inspector-report.md deleted file mode 100644 index b25529c41..000000000 --- a/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9c-integrated-inspector-report.md +++ /dev/null @@ -1,35 +0,0 @@ -# Task 9C: Integrated Inspector report - -## Delivered contract - -- The Workbench rail and page union no longer expose Inspector as a peer page. -- MCP owns an internal, accessible Playground / Inspector tab control. Both tab panels stay mounted and are hidden and inert when inactive, so switching does not recreate either presentation. -- `McpPage` and `InspectorSessionAdapter` share the existing controller; the adapter receives the app-level subscription of that same controller model. No second transport, session, bootstrap, route client, or iframe was introduced. -- `#mcp` is the sole MCP route. Direct `#mcp` and the rail MCP action select Playground, and internal tabs leave browser history unchanged. Unsupported `#inspector` remains in the URL, resolves to Overview, renders no Inspector tab, and opens no MCP session. -- The responsive tab shell keeps the 390 px page width within the viewport. - -## TDD evidence - -- RED: the dedicated-Inspector Chrome fixture was first rewritten to require canonical `#mcp`, no Inspector rail link, a selected internal Inspector tab, idle/no POST, the shared real-session flow, and a separate unsupported-`#inspector` Overview fallback with no session. The pre-change dedicated page retained `#inspector` and had no internal Inspector tab. -- GREEN: the same real Chrome fixture now verifies the unsupported hash stays `#inspector` while showing Overview without a session, then exercises the canonical `#mcp` flow with one POST, exact shared Inspector catalogs/protocol/logging, preserved Playground form state, reset propagation, no page errors, and 390 px overflow. - -## Verification - -- `npm exec rstest -- run packages/workbench/tests/inspector-shell.e2e.test.ts --reporter verbose` — 3 Chrome tests passed: unsupported-hash fallback, production `#mcp` session flow, and explicit development artifact. -- `npm exec rstest -- run packages/workbench/tests/inspector-session-adapter.test.ts packages/workbench/tests/inspector-session-adapter-fixture.test.ts packages/workbench/tests/mcp-session-controller.test.ts packages/workbench/tests/mcp-page.test.ts --reporter verbose` — 47 tests passed. -- `npm run typecheck --workspace agent-bundle-workbench` — passed. -- `npx rslint packages/workbench/src/main.tsx packages/workbench/tests/inspector-shell.e2e.test.ts` — 0 errors, 0 warnings. -- `npm run build --workspace agent-bundle-workbench` and `git diff --check` — passed. -- TraceDecay package diagnostics — 0 errors, 0 warnings; simplify scan — no findings (the index noted only `styles.css` stale); unsafe-pattern scan — 0 matches; test-risk scan — no items. -- Code-simplifier and deslop review — no behavior-preserving cleanup needed. - -## Scope guard - -No Inspector vendor/adapter source, export wiring, MCP launch/timeout/App logic, backend routes, or evaluator files changed. - -## Fix round 1: complete tab semantics - -- RED: the expanded real Chrome session contract failed because the selected Playground tab had no `tabindex="0"`; ArrowRight therefore did not move focus or select Inspector. -- GREEN: the two internal tabs now use roving `tabIndex` and select/focus through ArrowLeft, ArrowRight, Home, and End without mutating browser history. -- The existing Chrome fixture now also proves exactly one mounted panel of each kind, inactive `hidden` + `inert` state, selected-panel exposure, stable history through keyboard and pointer switches, retained Inspector Logging state after a Playground roundtrip, and no horizontal overflow for either presentation at 390 px. -- Verification: Inspector Chrome fixture 3/3; focused Inspector adapter/controller/page suite 47/47; Workbench typecheck, scoped Rslint, production Rsbuild, and `git diff --check` all passed. Code-simplifier/deslop found no cleanup needed. diff --git a/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9d-current-protocol-trace-export-report.md b/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9d-current-protocol-trace-export-report.md deleted file mode 100644 index e76b03286..000000000 --- a/.superpowers/sdd/2026-08-14-agent-bundle-workbench-evals-implementation/task-9d-current-protocol-trace-export-report.md +++ /dev/null @@ -1,53 +0,0 @@ -# Task 9D report: current MCP protocol trace export - -## Scope guard - -Implemented only the browser-side current MCP protocol trace export. The export is deliberately distinct from Task 10's durable Playground session export: it has no persistence, append/finalize route, transport call, session POST, backend, hook, artifact, App, timeout, vendor, eval, or launch-config/environment/session-capability surface. - -The shared Task 9C controller/session remains the sole source of truth. The MCP page uses the existing generic Blob browser-download sink, and Inspector Protocol and Logging use their existing `onExportTrace` callback with the full underlying timeline rather than a filtered or cleared presentation list. - -Concurrent unrelated worktree edits (including Hooks, closure, config, and other MCP test changes) were preserved and are excluded from this commit. - -## RED - -Before production code, ran: - -```text -npx rstest run packages/workbench/tests/mcp-page.test.ts packages/workbench/tests/inspector-session-adapter.test.ts -``` - -Expected RED: `packages/workbench/tests/mcp-page.test.ts` failed because `../src/mcp/mcp-protocol-trace.ts` did not exist. The new contracts covered the missing canonical builder/page trace action; the real Inspector Chrome flow was then extended to require the absent browser download wiring. - -## GREEN - -- Added `mcp-protocol-trace.ts`, a pure canonical, versionless builder with `kind: "agent-bundle.mcp-protocol-trace"`, explicit null session facts, full cursor/timeline/history, immediate detached JSON Blob serialization, trailing newline, JSON MIME type, and deterministic opaque-session/idle filename. -- Added the labeled MCP Trace action and explanatory copy: it exports the current browser MCP trace, not a durable Playground session export. -- Reused one generic browser Blob/download sink for Inspector config, MCP trace, and Inspector Protocol/Logging trace downloads. -- Added unit coverage for exact export shape, raw ordered entries including replay gaps/invocations, cursor/history preservation, nulls, MIME/newline/filename, detached bytes after caller mutation, sensitive launch/config/session-capability omission, page sink handoff, and complete Inspector Protocol/Logging timeline handoff. -- Extended the existing artifact-backed Inspector Chrome fixture to parse downloads from MCP Page, Inspector Protocol, and Inspector Logging; it proves matching canonical traces, the real `tools/call` payload, one session POST, no page errors, and 390 px no-overflow behavior. -- Applied explicit `undefined` `useRef` initializers in the owned entry file to satisfy the current Workbench TypeScript version without changing runtime behavior. - -## Verification - -All post-change commands passed: - -```text -npx rstest run packages/workbench/tests/mcp-page.test.ts packages/workbench/tests/inspector-session-adapter.test.ts packages/workbench/tests/mcp-session-controller.test.ts packages/workbench/tests/mcp-session-model.test.ts -# 62 passed - -npx rstest run packages/workbench/tests/inspector-shell.e2e.test.ts -# 3 passed: unsupported-hash, shared real-session, and development artifact coverage - -npm run typecheck --workspace agent-bundle-workbench -npx rslint packages/workbench/src/mcp/mcp-protocol-trace.ts packages/workbench/src/mcp/mcp-page.tsx packages/workbench/src/main.tsx packages/workbench/tests/mcp-page.test.ts packages/workbench/tests/inspector-session-adapter.test.ts packages/workbench/tests/inspector-shell.e2e.test.ts -npm run build --workspace agent-bundle-workbench -git diff --check -``` - -TraceDecay MCP was unavailable. One required CLI fallback attempt failed exactly with: - -```text -Error: config error: TraceDecay daemon socket '/home/zack/.tracedecay/daemon.sock' is not available. Run `tracedecay daemon install-service` and ensure the service is running. -``` - -Used local scoped source/test evidence after that single failed attempt. The requested code-simplifier and deslop reviews found no behavior-preserving cleanup to apply in the Task 9D hunks. From a67737162e53452f8677b27bcfc87a734f7f3d85 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 21:54:20 +0000 Subject: [PATCH 7/9] fix(mcp): install the stdout guard in the stdio entry's first import so module-scope writes never reach the protocol stream (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env-precedence follow-up made the generated stdio entry import the server module statically so the operator .env layer lands by import order — but that put the module's top level ahead of the console guard that `runGeneratedStdioMcpEntry` installs in the shell body. A `console.log` or `process.stdout.write` at module scope in a consumer's server or tool module reached stdout, which carries JSON-RPC framing, contradicting the documented guarantee that redirection precedes the consumer module's evaluation. The stdio shell now imports a generated prelude (`agent-bundle/stdio-prelude`) as its first import: it calls `redirectConsoleToStderr` from `agent-bundle/mcp-entry`, then applies the operator .env layer with the server's manifest env defaults. Hook wrappers and the artifact CLI bin keep the env-only layer (`agent-bundle/launch-env-layer`) — they legitimately write stdout. The guard has one implementation: `redirectConsoleToStderr` returns the guard already installed (recognised by `process.stdout.write` still being its redirect) instead of stacking a second, which would capture the redirect as the original and restore stdout to stderr; the lifecycle adopts the prelude's guard and restores raw stdout from it before serving. Tests: a built stdio entry whose server module writes `console.log('hello')` and `process.stdout.write('raw\n')` at module scope, driven by a real stdio client through initialize, tools/list, and tools/call, asserts both land on stderr (fails on the previous code: stderr held only the factory-time line); the entry-shell unit tests pin the prelude as the stdio entry's first import and the env-only layer for hook wrappers and the CLI bin; the mcp-entry unit test pins guard adoption and re-install after restore. --- .changeset/469-env-precedence-followup.md | 2 +- docs/entry-conventions.md | 27 +++++-- packages/agent-bundle/src/build/entries.ts | 13 ++-- .../agent-bundle/src/build/entry-shell.ts | 52 ++++++++++--- .../agent-bundle/src/build/inspect-bundler.ts | 3 +- .../src/build/launch-env-shell.ts | 36 +++++---- packages/agent-bundle/src/mcp-entry.ts | 41 +++++++--- .../agent-bundle/tests/entry-shell.test.ts | 75 ++++++++++++++++--- packages/agent-bundle/tests/mcp-entry.test.ts | 37 +++++++++ packages/agent-bundle/tests/mcp.test.ts | 65 ++++++++++++++++ 10 files changed, 292 insertions(+), 59 deletions(-) diff --git a/.changeset/469-env-precedence-followup.md b/.changeset/469-env-precedence-followup.md index f21a0a9fa..33ef171b9 100644 --- a/.changeset/469-env-precedence-followup.md +++ b/.changeset/469-env-precedence-followup.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Make the operator `.env` layer of an installed pack follow the documented `manifest env < .env < .env.local < process.env` order and reach plugin code before it evaluates. A host merges a stdio server's manifest `env` block into the child environment, so the emitted MCP entry now carries that block as build-time literals and `applyOperatorEnv` (new `manifestEnv` option on `agent-bundle/launch-env`) treats a variable still holding its manifest default as unset — the file overrides it, while an exported variable still wins; an operator export equal to the default reads as the default, and a value carrying a path token (`AGENT_BUNDLE_PLUGIN_ROOT`) is always kept. The layer is now the first import of every emitted stdio entry, hook wrapper, and artifact CLI `bin/.mjs` rather than a statement after the consumer imports, so a `process.env` read at the top level of a server, handler, route, provider, or state module sees the composed environment; the build marks generated modules side-effectful so a consumer `"sideEffects": false` cannot drop the import. `AGENT_BUNDLE_ENV_FILE=none` still disables the layer entirely. Host manifests are unchanged. (#554) +Make the operator `.env` layer of an installed pack follow the documented `manifest env < .env < .env.local < process.env` order and reach plugin code before it evaluates. A host merges a stdio server's manifest `env` block into the child environment, so the emitted MCP entry now carries that block as build-time literals and `applyOperatorEnv` (new `manifestEnv` option on `agent-bundle/launch-env`) treats a variable still holding its manifest default as unset — the file overrides it, while an exported variable still wins; an operator export equal to the default reads as the default, and a value carrying a path token (`AGENT_BUNDLE_PLUGIN_ROOT`) is always kept. The layer is now the first import of every emitted stdio entry, hook wrapper, and artifact CLI `bin/.mjs` rather than a statement after the consumer imports, so a `process.env` read at the top level of a server, handler, route, provider, or state module sees the composed environment; the build marks generated modules side-effectful so a consumer `"sideEffects": false` cannot drop the import. In the emitted stdio entry that first import is a prelude that also installs the console guard, so stdout written at module scope by server modules — `console.log` or `process.stdout.write` — is redirected to stderr before the protocol stream opens, and `redirectConsoleToStderr` from `agent-bundle/mcp-entry` now returns the guard already installed instead of stacking a second one. `AGENT_BUNDLE_ENV_FILE=none` still disables the layer entirely. Host manifests are unchanged. (#554) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 5ec9b221e..1774b78a7 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1004,7 +1004,8 @@ export default () => createRscMcpServer(application, 'curator'); ``` The generated shell provides, in order: console-to-stderr redirection before -the consumer module evaluates, the factory call, raw `process.stdout.write` +the consumer module evaluates (installed by the shell's first import, its +stdio prelude — see below), the factory call, raw `process.stdout.write` restored for protocol frames, `StdioServerTransport` construction and connect, SIGINT → exit 130, SIGTERM → exit 143, stdin EOF → exit 0 (so the client can respawn), transport-close → exit 0, a 5-second bounded shutdown @@ -1391,14 +1392,26 @@ Two details keep the installed order equal to the `mcp run` table above: entry module's own body, and places a dynamic import's target ahead of the static ones — so neither a statement in the shell body nor an awaited `import()` after it runs before a consumer module's top level. The layer is - instead a generated virtual module (`agent-bundle/launch-env-layer`, - `src/build/launch-env-shell.ts`) that each shell imports first, and the + instead a generated virtual module that each shell imports first, and the server module, hook handler, routes, providers, and state definition are - static imports after it; ESM import order is what the bundler preserves. A - consumer `package.json` declaring `"sideEffects": false` would let the - bundler drop that bare import, so the build marks generated modules + static imports after it; ESM import order is what the bundler preserves. + Hook wrappers and the CLI bin import the env-only layer + (`agent-bundle/launch-env-layer`, `src/build/launch-env-shell.ts`); they + legitimately write stdout. The stdio MCP shell imports its prelude instead + (`agent-bundle/stdio-prelude`, `src/build/entry-shell.ts`), which calls + `redirectConsoleToStderr` from `agent-bundle/mcp-entry` and then applies + the layer — stdout is the protocol wire there, and the same ordering + argument means only an earlier import can put the guard ahead of a + `console.log` or `process.stdout.write` at the server module's top level. + The guard has one implementation: `redirectConsoleToStderr` returns the + guard already installed (recognised by `process.stdout.write` still being + its redirect) instead of stacking a second, so `runGeneratedStdioMcpEntry` + adopts the prelude's guard and restores raw stdout from it before serving. + A consumer `package.json` declaring `"sideEffects": false` would let the + bundler drop either bare import, so the build marks generated modules side-effectful (`src/build/rslib.ts`). Module-level `process.env` reads in - plugin code see the composed environment. + plugin code see the composed environment, and module-level stdout writes in + a server module land on stderr. ### Durable-state anchors diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 969ae0ff4..093faf16d 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -42,6 +42,7 @@ import { mcpEntryRuntimeSpecifier, mcpServerRuntimePath, mcpServerRuntimeSpecifier, + stdioPreludeVirtualModule, } from './entry-shell.ts'; import { emptyRouteConfig, type CompiledLayout, type CompiledProvider } from '../routes/types.ts'; import type { CompiledMcpApp } from './mcp-apps.ts'; @@ -424,7 +425,7 @@ export const planMcpEntriesSurface = async ( })); const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined; // The operator `.env` layer (#469) is public API for every stdio entry: the - // lifecycle shell applies it before the deferred server import, and a + // shell's prelude applies it ahead of the server module, and a // self-connecting entry — which has no shell — imports // `agent-bundle/launch-env` and calls `applyOperatorEnv` itself. The alias // is unconditional so that import resolves to this package's plain-Node @@ -471,13 +472,13 @@ export const planMcpEntriesSurface = async ( name: routeModuleSpecifier, source: generatedRouteSources[index], }]), - // The shell's operator `.env` layer (#469) carries the server's manifest - // `env` block, so the layer can tell a passed-through default from a - // host export; a self-connecting entry has no shell and applies the - // layer itself if it wants it. + // The shell's prelude — stdout guard, then the operator `.env` layer + // (#469) — carries the server's manifest `env` block, so the layer can + // tell a passed-through default from a host export; a self-connecting + // entry has no shell and applies the layer itself if it wants it. ...(entryShells[index] === undefined ? [] - : [operatorEnvLayerVirtualModule(servers.find((candidate) => candidate.id === id)?.env)]), + : [stdioPreludeVirtualModule(servers.find((candidate) => candidate.id === id)?.env)]), ], })); const workerEntries = compiled.flatMap((entry, index) => { diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 61c5f0649..014ece222 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts'; -import { operatorEnvLayerImport } from './launch-env-shell.ts'; +import { operatorEnvLayerImport, operatorEnvLayerImports, operatorEnvLayerStatement } from './launch-env-shell.ts'; import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import { stableJson } from '../core/digest.ts'; import type { NormalizedHook, NormalizedNoticeRetentionPolicy, NormalizedStateDefinition } from '../core/types.ts'; @@ -68,23 +68,53 @@ export const terminalCapabilityRuntimePath = (): string => runtimeModulePath('te /** The surface a `main`-envelope executable reports as its `terminal.hostSurface`. */ export type GeneratedExecutableSurface = 'cli' | 'script'; +/** + * The generated prelude of a stdio MCP entry: the stdout guard, then the + * operator `.env` layer (#469), as one virtual module the shell imports + * first. stdout is the protocol wire on a stdio server, so the guard must be + * in place before the server module's own top level runs — and under + * bundling that top level precedes the shell body whichever way the module + * is imported, so only an earlier import can install it. The guard is the + * one `agent-bundle/mcp-entry` exports; the lifecycle adopts it rather than + * installing a second. Hook wrappers and the CLI bin import the env-only + * layer instead: they legitimately write stdout. + */ +export const stdioPreludeSpecifier = 'agent-bundle/stdio-prelude'; + +/** The import every generated stdio MCP entry places first. */ +export const stdioPreludeImport = `import ${JSON.stringify(stdioPreludeSpecifier)};`; + +/** The prelude source: guard first, so nothing after it can reach stdout; then the layer with the server's manifest `env` defaults. */ +export const stdioPreludeModuleSource = (manifestEnv?: Readonly>): string => [ + ...operatorEnvLayerImports, + `import { redirectConsoleToStderr } from ${JSON.stringify(mcpEntryRuntimeSpecifier)};`, + '', + 'redirectConsoleToStderr();', + operatorEnvLayerStatement(manifestEnv), + '', +].join('\n'); + +/** The prelude as the virtual module an Rslib entry serves beside its shell. */ +export const stdioPreludeVirtualModule = ( + manifestEnv?: Readonly>, +): { readonly name: string; readonly source: string } => ({ + name: stdioPreludeSpecifier, + source: stdioPreludeModuleSource(manifestEnv), +}); + /** * The generated stdio MCP entry body for a factory-exporting server module. - * The operator `.env` layer (#469) is the first import and the server module - * a static import after it: the bundler inlines every module of the - * single-chunk bundle ahead of the entry body and places a dynamic import's - * target ahead of the static ones, so only static import order puts the - * layer before the server module's own top level (see - * launchEnvLayerSpecifier). The layer module carries the server's manifest - * `env` defaults. The lifecycle installs its console guard before the - * factory runs; the module's top-level evaluation precedes the shell body - * under bundling whichever way it is imported. + * The prelude is the first import and the server module a static import + * after it: the bundler inlines every module of the single-chunk bundle + * ahead of the entry body and places a dynamic import's target ahead of the + * static ones, so only static import order puts the guard and the layer + * before the server module's own top level (see launchEnvLayerSpecifier). */ export const generatedStdioMcpEntrySource = (options: { readonly entrySource: string; readonly serverName: string; }): string => [ - operatorEnvLayerImport, + stdioPreludeImport, `import { runGeneratedStdioMcpEntry } from ${JSON.stringify(mcpEntryRuntimeSpecifier)};`, `import * as serverModule from ${JSON.stringify(options.entrySource)};`, '', diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index a1557a910..4225f69f9 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -15,6 +15,7 @@ import { mcpEntryRuntimeSpecifier, mcpServerRuntimePath, mcpServerRuntimeSpecifier, + stdioPreludeVirtualModule, terminalCapabilityRuntimePath, terminalCapabilityRuntimeSpecifier, } from './entry-shell.ts'; @@ -256,7 +257,7 @@ const mcpEntryEntries = async ( source: '/* The MCP App registry virtual module is generated from built app HTML at build time. */', }, ...(routeSource === undefined ? [] : [{ name: 'agent-bundle/generated-route-server', source: routeSource }]), - ...(wrapped ? [operatorEnvLayerVirtualModule(server?.env)] : []), + ...(wrapped ? [stdioPreludeVirtualModule(server?.env)] : []), ], }, kind: 'mcp-entry', diff --git a/packages/agent-bundle/src/build/launch-env-shell.ts b/packages/agent-bundle/src/build/launch-env-shell.ts index 86ade79cc..134d7d366 100644 --- a/packages/agent-bundle/src/build/launch-env-shell.ts +++ b/packages/agent-bundle/src/build/launch-env-shell.ts @@ -29,14 +29,26 @@ export const launchEnvRuntimeSpecifier = 'agent-bundle/launch-env'; */ export const launchEnvLayerSpecifier = 'agent-bundle/launch-env-layer'; -/** The import every artifact shell that runs plugin code places first. */ +/** + * The import the hook wrappers and the artifact CLI bin place first. The + * stdio MCP shell imports its prelude instead (`stdioPreludeImport` in + * `entry-shell.ts`), which applies this same layer after installing the + * stdout guard: stdout is the protocol wire there, while hooks and the CLI + * legitimately write it. + */ export const operatorEnvLayerImport = `import ${JSON.stringify(launchEnvLayerSpecifier)};`; +/** The imports the layer statement needs, shared with the stdio prelude. */ +export const operatorEnvLayerImports: readonly string[] = [ + "import { fileURLToPath } from 'node:url';", + `import { applyOperatorEnv, operatorEnvPluginRoot } from ${JSON.stringify(launchEnvRuntimeSpecifier)};`, +]; + /** - * The source of the layer module. Every artifact shell lives one directory - * below the plugin root (`mcp/`, `hooks/`, `bin/`), so the fallback anchor — - * used when the host set no `AGENT_BUNDLE_PLUGIN_ROOT` — is the bundle's - * parent directory, the same fallback the durable-state kernel uses + * The statement that applies the layer. Every artifact shell lives one + * directory below the plugin root (`mcp/`, `hooks/`, `bin/`), so the fallback + * anchor — used when the host set no `AGENT_BUNDLE_PLUGIN_ROOT` — is the + * bundle's parent directory, the same fallback the durable-state kernel uses * (`import.meta.url` stays native in the emitted ESM, so it names the bundle). * * A stdio MCP shell embeds its server's manifest `env` block as build-time @@ -45,20 +57,18 @@ export const operatorEnvLayerImport = `import ${JSON.stringify(launchEnvLayerSpe * let the file beat them while an exported variable still wins. Hook * wrappers and the CLI bin have no manifest env and embed none. */ -export const operatorEnvLayerModuleSource = (manifestEnv?: Readonly>): string => { +export const operatorEnvLayerStatement = (manifestEnv?: Readonly>): string => { const defaults = Object.entries(manifestEnv ?? {}).sort(([left], [right]) => left.localeCompare(right)); const manifestField = defaults.length === 0 ? '' : `manifestEnv: ${JSON.stringify(Object.fromEntries(defaults))}, `; - return [ - "import { fileURLToPath } from 'node:url';", - `import { applyOperatorEnv, operatorEnvPluginRoot } from ${JSON.stringify(launchEnvRuntimeSpecifier)};`, - '', - `applyOperatorEnv({ ${manifestField}pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });`, - '', - ].join('\n'); + return `applyOperatorEnv({ ${manifestField}pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });`; }; +/** The source of the env-only layer module. */ +export const operatorEnvLayerModuleSource = (manifestEnv?: Readonly>): string => + [...operatorEnvLayerImports, '', operatorEnvLayerStatement(manifestEnv), ''].join('\n'); + /** The layer as the virtual module an Rslib entry serves beside its wrapper. */ export const operatorEnvLayerVirtualModule = ( manifestEnv?: Readonly>, diff --git a/packages/agent-bundle/src/mcp-entry.ts b/packages/agent-bundle/src/mcp-entry.ts index 82cfaa088..318d622b0 100644 --- a/packages/agent-bundle/src/mcp-entry.ts +++ b/packages/agent-bundle/src/mcp-entry.ts @@ -23,28 +23,45 @@ export interface StdoutProtocolGuard { readonly restoreProtocolStdout: () => void; } +/** + * The guard this process currently has installed, recognised by identity: it + * is installed exactly while `process.stdout.write` is still its redirect. + * Lets the generated stdio prelude install the guard as the entry's first + * import and the lifecycle adopt that same guard instead of stacking a + * second one — a second install would capture the redirect as the "original" + * and restore stdout to stderr. + */ +let installedGuard: { readonly guard: StdoutProtocolGuard; readonly redirectedWrite: StdoutWrite } | undefined; + /** * stdout carries JSON-RPC framing on a stdio server: a stray `console.log` * (or direct `process.stdout.write`) from any imported module would corrupt * the protocol stream. Install this guard before evaluating server modules, * then call `restoreProtocolStdout()` right before serving. Console methods * stay on stderr forever; only the raw `process.stdout.write` is restored - * for protocol frames. + * for protocol frames. Calling it while a guard is installed returns that + * guard rather than installing another. */ export const redirectConsoleToStderr = (): StdoutProtocolGuard => { + if (installedGuard !== undefined && process.stdout.write === installedGuard.redirectedWrite) { + return installedGuard.guard; + } const originalStdoutWrite: StdoutWrite = process.stdout.write.bind(process.stdout) as StdoutWrite; const stderrConsole = new console.Console({ stderr: process.stderr, stdout: process.stderr }); const methods = ['debug', 'dir', 'error', 'info', 'log', 'trace', 'warn'] as const; for (const method of methods) { console[method] = stderrConsole[method].bind(stderrConsole) as never; } - process.stdout.write = ((chunk: never, encoding?: never, callback?: never) => + const redirectedWrite = ((chunk: never, encoding?: never, callback?: never) => process.stderr.write(chunk, encoding, callback)) as StdoutWrite; - return Object.freeze({ + process.stdout.write = redirectedWrite; + const guard: StdoutProtocolGuard = Object.freeze({ restoreProtocolStdout: (): void => { process.stdout.write = originalStdoutWrite; }, }); + installedGuard = { guard, redirectedWrite }; + return guard; }; export interface HeartbeatOptions { @@ -232,10 +249,11 @@ export interface GeneratedStdioMcpEntryModule { export interface RunGeneratedStdioMcpEntryOptions { /** * Loads the consumer entry module. The generated shell imports the module - * statically, after the operator `.env` layer (#469), and resolves it here: - * under bundling every module of the single-chunk entry evaluates before - * the shell body whichever way it is imported, so the console guard covers - * the factory call and everything after it, not the module's top level. + * statically, after its stdio prelude — the console guard plus the operator + * `.env` layer (#469) — and resolves it here: under bundling every module + * of the single-chunk entry evaluates before the shell body whichever way + * it is imported, so import order, not this call, is what puts the guard + * and the layer ahead of the module's own top level. */ readonly loadEntry: () => Promise; /** Test seam mirroring {@link RunStdioServerOptions}. */ @@ -244,10 +262,11 @@ export interface RunGeneratedStdioMcpEntryOptions { } /** - * The body of every generated stdio MCP entry: install the stdout guard, - * take the consumer module, build the server from its default-exported - * factory, hand raw stdout back for protocol frames, and serve under the - * managed lifecycle. + * The body of every generated stdio MCP entry: adopt the stdout guard the + * shell's prelude installed as its first import (installing it here only for + * a hand-rolled caller that has none), take the consumer module, build the + * server from its default-exported factory, hand raw stdout back for + * protocol frames, and serve under the managed lifecycle. */ export const runGeneratedStdioMcpEntry = async ( options: RunGeneratedStdioMcpEntryOptions, diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 9a6ed7b2c..52ffb27bf 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from '@rstest/core'; import ts from 'typescript-5'; import { claudeAdapter } from '../src/adapters/claude.ts'; +import { cursorHookWrapperSource, nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; import type { NoticeDeliveryAdvertisement } from '../src/adapters/notice-delivery.ts'; import { scanEntryExportsSource, stripCommentsAndStrings } from '../src/build/entry-exports.ts'; import * as entryShellModule from '../src/build/entry-shell.ts'; @@ -20,6 +21,10 @@ import { mcpEntryRuntimeSpecifier, mcpServerRuntimePath, mcpServerRuntimeSpecifier, + stdioPreludeImport, + stdioPreludeModuleSource, + stdioPreludeSpecifier, + stdioPreludeVirtualModule, } from '../src/build/entry-shell.ts'; import { executeProviders, @@ -86,20 +91,70 @@ describe('generated entry templates', () => { expect(path.endsWith('mcp-entry.ts') || path.endsWith('mcp-entry.js')).toBe(true); }); - it('generates a stdio entry that imports the operator .env layer before the server module (#469)', () => { + 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' }); expect(source).toContain(`from ${JSON.stringify(mcpEntryRuntimeSpecifier)}`); expect(source).toContain('serverName: "curator"'); - // The layer is the shell's first import and the server module a static + // The prelude is the shell's first import and the server module a static // import after it: the bundler inlines every module ahead of the entry // body and a dynamic import's target ahead of the static ones, so only - // static import order puts the layer before the server module's own top - // level (pinned end to end by tests/mcp.test.ts). - expect(source.startsWith(`${operatorEnvLayerImport}\n`)).toBe(true); - expect(source.indexOf(operatorEnvLayerImport)).toBeLessThan(source.indexOf('import * as serverModule from "/proj/src/mcp/curator.ts";')); + // static import order puts the guard and the layer before the server + // 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";')); + // The stdio shell never imports the env-only layer: stdout is its wire. + expect(source).not.toContain(launchEnvLayerSpecifier); expect(source).toContain('loadEntry: async () => serverModule,'); expect(source).not.toContain('import('); expect(source).not.toContain('applyOperatorEnv'); + expect(source).not.toContain('redirectConsoleToStderr'); + }); + + it('generates the stdio prelude module: the mcp-entry guard installed first, then the layer with the manifest env defaults (#469)', () => { + const source = stdioPreludeModuleSource({ API_URL: 'https://api.example' }); + const lines = source.split('\n'); + expect(lines.slice(0, 3)).toEqual([ + "import { fileURLToPath } from 'node:url';", + 'import { applyOperatorEnv, operatorEnvPluginRoot } from "agent-bundle/launch-env";', + 'import { redirectConsoleToStderr } from "agent-bundle/mcp-entry";', + ]); + // One guard implementation: the prelude calls the export the lifecycle + // adopts, and calls it before the layer so nothing after the first + // statement can reach stdout. + expect(lines.indexOf('redirectConsoleToStderr();')).toBeLessThan(lines.findIndex((line) => line.startsWith('applyOperatorEnv('))); + expect(source).toContain( + 'applyOperatorEnv({ manifestEnv: {"API_URL":"https://api.example"}, ' + + "pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });", + ); + expect(stdioPreludeVirtualModule({ API_URL: 'https://api.example' })).toEqual({ name: stdioPreludeSpecifier, source }); + expect(stdioPreludeSpecifier).toBe('agent-bundle/stdio-prelude'); + }); + + it('gives hook wrappers the env-only layer, never the stdio prelude: stdout is the host envelope there (#469)', () => { + const entry: TargetHookWrapper = { + event: 'sessionStart', + hook: { + event: 'sessionStart', + id: 'hook:sessionStart:probe:00000000', + name: 'probe', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + source: '/project/src/hooks/probe.ts', + targets: ['claude'], + tools: [], + }, + nativeEvent: 'SessionStart', + relativePath: 'hooks/sessionStart.mjs', + target: 'claude', + }; + for (const source of [ + nativeHookWrapperSource(entry, 'Claude'), + cursorHookWrapperSource({ ...entry, nativeEvent: 'sessionStart', target: 'cursor' }), + ]) { + expect(source.startsWith(`${operatorEnvLayerImport}\n`)).toBe(true); + expect(source).not.toContain(stdioPreludeSpecifier); + expect(source).not.toContain('redirectConsoleToStderr'); + } }); it('generates the operator .env layer module with the manifest env defaults it must recognise (#469)', () => { @@ -141,10 +196,12 @@ describe('generated entry templates', () => { routes: [route], stateFallback: 'artifact', }); - // The layer is the first import, ahead of the route, provider, and state - // modules, so a module-level `process.env` read in any of them sees the - // composed environment; the consumer imports themselves stay static. + // The env-only layer is the first import, ahead of the route, provider, + // and state modules, so a module-level `process.env` read in any of them + // sees the composed environment; the consumer imports themselves stay + // static. Never the stdio prelude: a CLI bin owns its stdout. expect(artifactBin.startsWith(`${operatorEnvLayerImport}\n`)).toBe(true); + expect(artifactBin).not.toContain(stdioPreludeSpecifier); expect(artifactBin).not.toContain('applyOperatorEnv'); expect(artifactBin).toContain('import * as route0 from "/project/src/cli/report.ts";'); const durableBin = entryShellModule.generatedCliBinEntrySource({ diff --git a/packages/agent-bundle/tests/mcp-entry.test.ts b/packages/agent-bundle/tests/mcp-entry.test.ts index 9323cb4b7..8fe8dd88f 100644 --- a/packages/agent-bundle/tests/mcp-entry.test.ts +++ b/packages/agent-bundle/tests/mcp-entry.test.ts @@ -259,6 +259,43 @@ describe('stdout protocol guard', () => { console.log = originalConsole.log; } }); + + it('adopts an installed guard instead of stacking a second one, and installs anew once restored', () => { + // The generated stdio prelude installs the guard as the entry's first + // import and the lifecycle calls this again: a second install would + // capture the redirect as the original and "restore" stdout to stderr. + const originalConsole = { error: console.error, log: console.log }; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + const stdoutChunks: string[] = []; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdoutChunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + + try { + const first = redirectConsoleToStderr(); + const second = redirectConsoleToStderr(); + expect(second).toBe(first); + second.restoreProtocolStdout(); + process.stdout.write('{"jsonrpc":"2.0"}'); + expect(stdoutChunks).toEqual(['{"jsonrpc":"2.0"}']); + // Restored means uninstalled: the next call installs a fresh guard + // whose original is the real stdout again. + const third = redirectConsoleToStderr(); + expect(third).not.toBe(first); + process.stdout.write('swallowed'); + third.restoreProtocolStdout(); + process.stdout.write('{"id":1}'); + expect(stdoutChunks).toEqual(['{"jsonrpc":"2.0"}', '{"id":1}']); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + console.error = originalConsole.error; + console.log = originalConsole.log; + } + }); }); describe('runGeneratedStdioMcpEntry', () => { diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index 70d53ac37..9b2e9ed50 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -847,6 +847,71 @@ it('lets the operator .env beat a manifest env default the host passed through, } }, 60_000); +it('redirects stdout written at module scope by the server module to stderr before the protocol stream opens', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-module-scope-stdout-')); + try { + await mkdir(join(root, 'src'), { recursive: true }); + await mkdir(join(root, 'node_modules'), { recursive: true }); + await symlink( + join(agentBundleNodeModules, '@modelcontextprotocol'), + join(root, 'node_modules', '@modelcontextprotocol'), + 'dir', + ); + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + await writeFile(join(root, 'package.json'), '{"type":"module"}\n'); + // A factory entry whose module top level writes to stdout both ways a + // consumer module can: the console and the raw stream. The server module + // is a static import of the shell, so it evaluates before the shell body; + // only a guard installed by an earlier import keeps these off the wire. + await writeFile(join(root, 'src', 'server.ts'), [ + "import { McpServer } from '@modelcontextprotocol/server';", + "console.log('hello');", + "process.stdout.write('raw\\n');", + 'export default () => {', + " console.log('factory');", + " const server = new McpServer({ name: 'module-scope-stdout', version: '1.0.0' });", + " server.registerTool('ping', { description: 'Reply.' }, async () => {", + " console.log('tool');", + " return { content: [{ type: 'text' as const, text: 'pong' }] };", + ' });', + ' return server;', + '};', + '', + ].join('\n')); + const model = await normalizeProject( + loadedProject(root, { + mcp: { servers: { chatty: { entry: './src/server.ts' } } }, + plugin: { name: 'mcp-module-scope-stdout', version: '1.0.0' }, + targets: ['portable'], + }), + { skills: [] }, + registry, + ); + const result = await build({ model, outputRoot: join(root, 'artifact'), projectRoot: root, registry: createDefaultRegistry() }); + const [entry] = result.compiledMcpEntries; + + const stderrChunks: string[] = []; + const transport = new StdioClientTransport({ args: [entry!.output], command: process.execPath, stderr: 'pipe' }); + transport.stderr?.on('data', (chunk: Buffer | string) => stderrChunks.push(String(chunk))); + const client = new Client({ name: 'module-scope-stdout-consumer', version: '1.0.0' }); + await client.connect(transport); + try { + // initialize completed above; tools/list and a call prove the protocol + // stream stayed clean end to end. + expect((await client.listTools()).tools).toMatchObject([{ name: 'ping' }]); + expect(await client.callTool({ arguments: {}, name: 'ping' })).toMatchObject({ content: [{ text: 'pong', type: 'text' }] }); + } finally { + await client.close(); + } + const stderr = stderrChunks.join(''); + expect(stderr).toContain('hello\nraw\n'); + expect(stderr).toContain('factory\n'); + expect(stderr).toContain('tool\n'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 60_000); + it('builds one deterministic self-contained MCP App view and injects it through the virtual module', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-build-')); try { From a2796437dcd36300a8d785ca08028716013ea9e9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 22:22:32 +0000 Subject: [PATCH 8/9] fix(mcp): adopt the installed stdout guard regardless of write identity so a consumer wrapper cannot stack a second guard (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adoption by identity (`process.stdout.write === redirectedWrite`) broke the moment a consumer module wrapped `process.stdout.write` at module scope: the lifecycle's `redirectConsoleToStderr()` saw a foreign function, installed a second guard with the wrapper recorded as the original, and restoring for the protocol stream handed stdout to the wrapper — which still forwarded to the first redirect, so every JSON-RPC frame left on stderr and the client hung in initialize. The rule is now: while a guard is installed, `redirectConsoleToStderr()` returns it whatever `process.stdout.write` has become; `restoreProtocolStdout()` restores the real original the guard owns, writes one stderr line if a module replaced the write in the meantime (the replacement is discarded — stdout is the protocol channel and wrapping it is unsupported), and clears the installed guard so a later call installs anew. Tests: the mcp-entry unit test wraps the redirect, adopts the same guard, restores to the real stdout, and installs fresh afterwards (fails on a67737162 at the adoption step); the packed stdio test's server module now also wraps `process.stdout.write` at module scope and the real client still completes initialize, tools/list, and tools/call with the wrapper's output and the warning on stderr (hangs to timeout on a67737162). --- .changeset/469-env-precedence-followup.md | 2 +- docs/entry-conventions.md | 13 +++-- packages/agent-bundle/src/mcp-entry.ts | 33 +++++++---- packages/agent-bundle/tests/mcp-entry.test.ts | 58 +++++++++++++++++++ packages/agent-bundle/tests/mcp.test.ts | 13 ++++- 5 files changed, 101 insertions(+), 18 deletions(-) diff --git a/.changeset/469-env-precedence-followup.md b/.changeset/469-env-precedence-followup.md index 33ef171b9..54124e9f5 100644 --- a/.changeset/469-env-precedence-followup.md +++ b/.changeset/469-env-precedence-followup.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Make the operator `.env` layer of an installed pack follow the documented `manifest env < .env < .env.local < process.env` order and reach plugin code before it evaluates. A host merges a stdio server's manifest `env` block into the child environment, so the emitted MCP entry now carries that block as build-time literals and `applyOperatorEnv` (new `manifestEnv` option on `agent-bundle/launch-env`) treats a variable still holding its manifest default as unset — the file overrides it, while an exported variable still wins; an operator export equal to the default reads as the default, and a value carrying a path token (`AGENT_BUNDLE_PLUGIN_ROOT`) is always kept. The layer is now the first import of every emitted stdio entry, hook wrapper, and artifact CLI `bin/.mjs` rather than a statement after the consumer imports, so a `process.env` read at the top level of a server, handler, route, provider, or state module sees the composed environment; the build marks generated modules side-effectful so a consumer `"sideEffects": false` cannot drop the import. In the emitted stdio entry that first import is a prelude that also installs the console guard, so stdout written at module scope by server modules — `console.log` or `process.stdout.write` — is redirected to stderr before the protocol stream opens, and `redirectConsoleToStderr` from `agent-bundle/mcp-entry` now returns the guard already installed instead of stacking a second one. `AGENT_BUNDLE_ENV_FILE=none` still disables the layer entirely. Host manifests are unchanged. (#554) +Make the operator `.env` layer of an installed pack follow the documented `manifest env < .env < .env.local < process.env` order and reach plugin code before it evaluates. A host merges a stdio server's manifest `env` block into the child environment, so the emitted MCP entry now carries that block as build-time literals and `applyOperatorEnv` (new `manifestEnv` option on `agent-bundle/launch-env`) treats a variable still holding its manifest default as unset — the file overrides it, while an exported variable still wins; an operator export equal to the default reads as the default, and a value carrying a path token (`AGENT_BUNDLE_PLUGIN_ROOT`) is always kept. The layer is now the first import of every emitted stdio entry, hook wrapper, and artifact CLI `bin/.mjs` rather than a statement after the consumer imports, so a `process.env` read at the top level of a server, handler, route, provider, or state module sees the composed environment; the build marks generated modules side-effectful so a consumer `"sideEffects": false` cannot drop the import. In the emitted stdio entry that first import is a prelude that also installs the console guard, so stdout written at module scope by server modules — `console.log` or `process.stdout.write` — is redirected to stderr before the protocol stream opens, and `redirectConsoleToStderr` from `agent-bundle/mcp-entry` now returns the guard already installed instead of stacking a second one; a wrapper a server module installs over `process.stdout.write` at module scope is discarded when the protocol stream opens, with one stderr warning naming it. `AGENT_BUNDLE_ENV_FILE=none` still disables the layer entirely. Host manifests are unchanged. (#554) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 1774b78a7..528969a49 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1403,10 +1403,15 @@ Two details keep the installed order equal to the `mcp run` table above: the layer — stdout is the protocol wire there, and the same ordering argument means only an earlier import can put the guard ahead of a `console.log` or `process.stdout.write` at the server module's top level. - The guard has one implementation: `redirectConsoleToStderr` returns the - guard already installed (recognised by `process.stdout.write` still being - its redirect) instead of stacking a second, so `runGeneratedStdioMcpEntry` - adopts the prelude's guard and restores raw stdout from it before serving. + The guard has one implementation: while a guard is installed, + `redirectConsoleToStderr` returns it instead of stacking a second, whatever + `process.stdout.write` has become since, so `runGeneratedStdioMcpEntry` + adopts the prelude's guard and restores the real stdout from it before + serving; a wrapper a consumer module installed over `process.stdout.write` + at module scope wrapped the redirect, not the protocol stream, and is + discarded at that point with one stderr line saying so (stdout is the + protocol channel; wrapping it is unsupported). Restoring clears the + installed guard, so a later call installs anew. A consumer `package.json` declaring `"sideEffects": false` would let the bundler drop either bare import, so the build marks generated modules side-effectful (`src/build/rslib.ts`). Module-level `process.env` reads in diff --git a/packages/agent-bundle/src/mcp-entry.ts b/packages/agent-bundle/src/mcp-entry.ts index 318d622b0..f96d77a19 100644 --- a/packages/agent-bundle/src/mcp-entry.ts +++ b/packages/agent-bundle/src/mcp-entry.ts @@ -24,12 +24,12 @@ export interface StdoutProtocolGuard { } /** - * The guard this process currently has installed, recognised by identity: it - * is installed exactly while `process.stdout.write` is still its redirect. - * Lets the generated stdio prelude install the guard as the entry's first - * import and the lifecycle adopt that same guard instead of stacking a - * second one — a second install would capture the redirect as the "original" - * and restore stdout to stderr. + * The guard this process currently has installed: set by an install, cleared + * by its `restoreProtocolStdout`. It owns the real stdout write. The + * generated stdio prelude installs the guard as the entry's first import and + * the lifecycle adopts that same guard instead of stacking a second one — a + * second install would record whatever `process.stdout.write` had become as + * the "original" and restore that, not the protocol stream. */ let installedGuard: { readonly guard: StdoutProtocolGuard; readonly redirectedWrite: StdoutWrite } | undefined; @@ -39,13 +39,17 @@ let installedGuard: { readonly guard: StdoutProtocolGuard; readonly redirectedWr * the protocol stream. Install this guard before evaluating server modules, * then call `restoreProtocolStdout()` right before serving. Console methods * stay on stderr forever; only the raw `process.stdout.write` is restored - * for protocol frames. Calling it while a guard is installed returns that - * guard rather than installing another. + * for protocol frames. + * + * While a guard is installed, calling this returns it — whatever + * `process.stdout.write` has become since. A consumer module that wraps + * `process.stdout.write` at module scope wraps the redirect, not the + * protocol stream; stdout is the protocol channel, so such a wrapper is + * unsupported, and `restoreProtocolStdout()` discards it in favour of the + * real stdout, saying so once on stderr. */ export const redirectConsoleToStderr = (): StdoutProtocolGuard => { - if (installedGuard !== undefined && process.stdout.write === installedGuard.redirectedWrite) { - return installedGuard.guard; - } + if (installedGuard !== undefined) return installedGuard.guard; const originalStdoutWrite: StdoutWrite = process.stdout.write.bind(process.stdout) as StdoutWrite; const stderrConsole = new console.Console({ stderr: process.stderr, stdout: process.stderr }); const methods = ['debug', 'dir', 'error', 'info', 'log', 'trace', 'warn'] as const; @@ -57,7 +61,14 @@ export const redirectConsoleToStderr = (): StdoutProtocolGuard => { process.stdout.write = redirectedWrite; const guard: StdoutProtocolGuard = Object.freeze({ restoreProtocolStdout: (): void => { + if (process.stdout.write !== redirectedWrite) { + process.stderr.write( + '[agent-bundle] a module replaced process.stdout.write while console output was redirected to stderr; ' + + 'the replacement is discarded because stdout carries the MCP protocol stream.\n', + ); + } process.stdout.write = originalStdoutWrite; + if (installedGuard?.guard === guard) installedGuard = undefined; }, }); installedGuard = { guard, redirectedWrite }; diff --git a/packages/agent-bundle/tests/mcp-entry.test.ts b/packages/agent-bundle/tests/mcp-entry.test.ts index 8fe8dd88f..974a85978 100644 --- a/packages/agent-bundle/tests/mcp-entry.test.ts +++ b/packages/agent-bundle/tests/mcp-entry.test.ts @@ -296,6 +296,61 @@ describe('stdout protocol guard', () => { console.log = originalConsole.log; } }); + + it('keeps one guard under a consumer wrapper over stdout and restores the real stdout, discarding the wrapper', () => { + // A consumer module that wraps `process.stdout.write` at module scope + // wraps the redirect, not the protocol stream. Adoption must not depend + // on the write's identity: a second guard would record the wrapper as + // the original and "restore" it, sending every JSON-RPC frame through + // the wrapper into stderr. + const originalConsole = { error: console.error, log: console.log }; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const realStdout = ((chunk: string | Uint8Array) => { + stdoutChunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stdout.write = realStdout; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrChunks.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + const guard = redirectConsoleToStderr(); + const redirect = process.stdout.write; + const wrapped: string[] = []; + process.stdout.write = ((chunk: string | Uint8Array, ...rest: never[]) => { + wrapped.push(String(chunk)); + return (redirect as (chunk: string | Uint8Array, ...rest: never[]) => boolean)(chunk, ...rest); + }) as typeof process.stdout.write; + process.stdout.write('module scope through the wrapper'); + expect(wrapped).toEqual(['module scope through the wrapper']); + expect(stdoutChunks).toEqual([]); + expect(stderrChunks).toEqual(['module scope through the wrapper']); + + expect(redirectConsoleToStderr()).toBe(guard); + guard.restoreProtocolStdout(); + process.stdout.write('{"jsonrpc":"2.0"}'); + expect(stdoutChunks).toEqual(['{"jsonrpc":"2.0"}']); + expect(wrapped).toEqual(['module scope through the wrapper']); + expect(stderrChunks.join('')).toContain('process.stdout.write'); + // Restored means uninstalled: the next call installs a fresh guard. + const next = redirectConsoleToStderr(); + expect(next).not.toBe(guard); + process.stdout.write('swallowed'); + next.restoreProtocolStdout(); + process.stdout.write('{"id":1}'); + expect(stdoutChunks).toEqual(['{"jsonrpc":"2.0"}', '{"id":1}']); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + console.error = originalConsole.error; + console.log = originalConsole.log; + } + }); }); describe('runGeneratedStdioMcpEntry', () => { @@ -308,6 +363,9 @@ describe('runGeneratedStdioMcpEntry', () => { try { await run(); } finally { + // A run that threw before serving left its guard installed; release it + // through the guard so the process-wide state is clean for the next test. + redirectConsoleToStderr().restoreProtocolStdout(); process.stdout.write = originalStdoutWrite; Object.assign(console, originalConsole); } diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index 9b2e9ed50..51aad4534 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -847,7 +847,7 @@ it('lets the operator .env beat a manifest env default the host passed through, } }, 60_000); -it('redirects stdout written at module scope by the server module to stderr before the protocol stream opens', async () => { +it('redirects stdout written at module scope by the server module to stderr before the protocol stream opens, and discards a module-scope wrapper over stdout', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-module-scope-stdout-')); try { await mkdir(join(root, 'src'), { recursive: true }); @@ -863,10 +863,16 @@ it('redirects stdout written at module scope by the server module to stderr befo // consumer module can: the console and the raw stream. The server module // is a static import of the shell, so it evaluates before the shell body; // only a guard installed by an earlier import keeps these off the wire. + // It then wraps `process.stdout.write` the way a logging library might: + // the wrapper sits over the redirect, and the guard must discard it when + // the protocol stream opens rather than adopt it as the original. await writeFile(join(root, 'src', 'server.ts'), [ "import { McpServer } from '@modelcontextprotocol/server';", "console.log('hello');", "process.stdout.write('raw\\n');", + 'const previous = process.stdout.write;', + 'process.stdout.write = ((chunk, ...rest) => previous.call(process.stdout, `wrapped:${chunk}`, ...rest)) as typeof process.stdout.write;', + "process.stdout.write('after wrap\\n');", 'export default () => {', " console.log('factory');", " const server = new McpServer({ name: 'module-scope-stdout', version: '1.0.0' });", @@ -904,9 +910,12 @@ it('redirects stdout written at module scope by the server module to stderr befo await client.close(); } const stderr = stderrChunks.join(''); - expect(stderr).toContain('hello\nraw\n'); + expect(stderr).toContain('hello\nraw\nwrapped:after wrap\n'); + expect(stderr).toContain('a module replaced process.stdout.write while console output was redirected to stderr'); expect(stderr).toContain('factory\n'); expect(stderr).toContain('tool\n'); + // The frames themselves never went through the wrapper. + expect(stderr).not.toContain('wrapped:{'); } finally { await rm(root, { force: true, recursive: true }); } From 88db802b09bb345e06c74b0c47094b475f19338d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 22:41:37 +0000 Subject: [PATCH 9/9] fix(mcp): make restoreProtocolStdout once-only so a stale or repeated restore cannot clobber a fresh guard (#469) Two holders of the same guard could restore twice: after the first restore and a fresh install, the stale restore overwrote the fresh redirect with the old original while `installedGuard` still named the fresh guard, so adoption returned a guard that was no longer installed. A plain double restore also emitted the foreign-wrapper warning twice. The guard now records that it has restored and returns immediately on later calls. --- packages/agent-bundle/src/mcp-entry.ts | 8 ++- packages/agent-bundle/tests/mcp-entry.test.ts | 59 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/agent-bundle/src/mcp-entry.ts b/packages/agent-bundle/src/mcp-entry.ts index f96d77a19..73520ffed 100644 --- a/packages/agent-bundle/src/mcp-entry.ts +++ b/packages/agent-bundle/src/mcp-entry.ts @@ -19,7 +19,7 @@ const defaultHeartbeatName = 'agent-bundle'; type StdoutWrite = typeof process.stdout.write; export interface StdoutProtocolGuard { - /** Hands stdout back to the protocol transport; console.* keeps writing to stderr. */ + /** Hands stdout back to the protocol transport; console.* keeps writing to stderr. Once only: later calls are no-ops. */ readonly restoreProtocolStdout: () => void; } @@ -59,8 +59,14 @@ export const redirectConsoleToStderr = (): StdoutProtocolGuard => { const redirectedWrite = ((chunk: never, encoding?: never, callback?: never) => process.stderr.write(chunk, encoding, callback)) as StdoutWrite; process.stdout.write = redirectedWrite; + // Restoring is once-only: a second call, or a stale holder's call after a + // fresh guard replaced this one, would otherwise overwrite that guard's + // redirect with this original while `installedGuard` still names it. + let restored = false; const guard: StdoutProtocolGuard = Object.freeze({ restoreProtocolStdout: (): void => { + if (restored) return; + restored = true; if (process.stdout.write !== redirectedWrite) { process.stderr.write( '[agent-bundle] a module replaced process.stdout.write while console output was redirected to stderr; ' diff --git a/packages/agent-bundle/tests/mcp-entry.test.ts b/packages/agent-bundle/tests/mcp-entry.test.ts index 974a85978..00eeb6738 100644 --- a/packages/agent-bundle/tests/mcp-entry.test.ts +++ b/packages/agent-bundle/tests/mcp-entry.test.ts @@ -351,6 +351,65 @@ describe('stdout protocol guard', () => { console.log = originalConsole.log; } }); + + const withCapturedStreams = (run: (captured: { readonly stdout: string[]; readonly stderr: string[] }) => void): void => { + const originalConsole = { error: console.error, log: console.log }; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + const captured = { stderr: [] as string[], stdout: [] as string[] }; + process.stdout.write = ((chunk: string | Uint8Array) => { + captured.stdout.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + captured.stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + run(captured); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + console.error = originalConsole.error; + console.log = originalConsole.log; + } + }; + + it('restores once: a second restoreProtocolStdout is a no-op and warns at most once', () => { + withCapturedStreams(({ stderr, stdout }) => { + const guard = redirectConsoleToStderr(); + const redirect = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array) => redirect.call(process.stdout, chunk)) as typeof process.stdout.write; + guard.restoreProtocolStdout(); + const afterFirst = process.stdout.write; + expect(stderr.filter((line) => line.includes('replaced process.stdout.write'))).toHaveLength(1); + guard.restoreProtocolStdout(); + expect(process.stdout.write).toBe(afterFirst); + expect(stderr.filter((line) => line.includes('replaced process.stdout.write'))).toHaveLength(1); + process.stdout.write('{"jsonrpc":"2.0"}'); + expect(stdout).toEqual(['{"jsonrpc":"2.0"}']); + }); + }); + + it('ignores a stale restore after a fresh guard was installed: the fresh redirect stays, and adoption still returns the fresh guard', () => { + withCapturedStreams(({ stderr, stdout }) => { + const stale = redirectConsoleToStderr(); + stale.restoreProtocolStdout(); + const fresh = redirectConsoleToStderr(); + const freshRedirect = process.stdout.write; + // A second holder of the first guard restores late: nothing changes. + stale.restoreProtocolStdout(); + expect(process.stdout.write).toBe(freshRedirect); + expect(redirectConsoleToStderr()).toBe(fresh); + process.stdout.write('still guarded'); + expect(stdout).toEqual([]); + expect(stderr).toContain('still guarded'); + fresh.restoreProtocolStdout(); + process.stdout.write('{"jsonrpc":"2.0"}'); + expect(stdout).toEqual(['{"jsonrpc":"2.0"}']); + expect(stderr.some((line) => line.includes('replaced process.stdout.write'))).toBe(false); + }); + }); }); describe('runGeneratedStdioMcpEntry', () => {