Skip to content

fix: Node 26 transform flag; operator .env precedence + import order; MCP negotiation test; provider-view cross-process coverage (integration) - #554

Merged
ScriptedAlchemy merged 12 commits into
mainfrom
fix/node26-transform-types
Sep 4, 2026
Merged

fix: Node 26 transform flag; operator .env precedence + import order; MCP negotiation test; provider-view cross-process coverage (integration)#554
ScriptedAlchemy merged 12 commits into
mainfrom
fix/node26-transform-types

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Integration PR. Besides the Node 26 fix below, this branch carries the remaining small changes queued for main, so they land in one CI run instead of three. See Absorbed at the end.

Symptom

Every main push fails the Verify (Node 26) leg — e.g. run 33907910106 for a69673b; 55026f0 failed identically. All plain-script tests in packages/agent-bundle/tests/projection/script-dispatch.test.ts see exit code 9 with

node: bad option: --experimental-transform-types

The leg only runs on main pushes and manual dispatch (PRs run Node 24 alone), so nothing blocked and it failed silently.

Root cause

runScript (agent-bundle/test, src/test/script.ts) spawns every plain .ts script as node --experimental-transform-types … over process.execPath. Node 26 removed that flag outright as a semver-major change (nodejs/node#61803, rationale in nodejs/typescript#51) and rejects it as a bad option before any script runs. This is production code — runScript is a public export consumers call from their own Rstest suites — so any consumer testing plain scripts on Node 26 hit the same exit-9 failure.

Node 26 flag facts, verified against the local 26.8.1 binary (process.allowedNodeEnvironmentFlags, --help, process.features.typescript) and CHANGELOG_V26.md:

Node 22.23.2 Node 24.19.0 Node 26.8.1
--experimental-transform-types accepted accepted removed (bad option, exit 9)
--transform-types (stable successor?) no no no such flag
--strip-types / --experimental-strip-types --experimental-strip-types only both both, on by default
process.features.typescript 'strip' 'strip' 'strip' (@types/node 26 types it as 'strip' | false — no 'transform')
enum in a .ts needs the transform flag needs the transform flag ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, and no flag can enable it

So there is no transform flag to swap in: Node 26 does type stripping only.

Fix

  • typeScriptTransformFlags(allowedFlags = process.allowedNodeEnvironmentFlags) in src/core/runtime.ts — feature detection, not version sniffing: the first flag the binary accepts, strongest first — --experimental-transform-types (Node 22, 24), then --strip-types (Node 26); [] for a binary accepting neither. The child runs the same binary as the parent (process.execPath), so the parent's flag set is authoritative. Naming --strip-types explicitly (rather than relying on the Node 26 default) means an inherited NODE_OPTIONS=--no-strip-types cannot switch the source run off — a command-line flag outranks NODE_OPTIONS.
  • src/test/script.ts spreads it into the spawn argv; --disable-warning=ExperimentalWarning stays unconditional (it also covers module.registerHooks).
  • Behavioural consequence on Node 26: a plain script using TypeScript-only syntax (enum, namespace, parameter properties) now fails in the harness with Node's own ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, exactly as node file.ts does there — the bundled scripts/<name>.mjs is unaffected because Rslib/SWC lowers it. Documented in the testing guide (en + zh) and the changeset.

Verification

  • Reproduced locally under Node 26.8.1 before the fix: script-dispatch.test.ts → 16 plain-script failures with exit 9.
  • After the fix, pnpm exec rstest --config rstest.projection.config.ts packages/agent-bundle/tests/projection/script-dispatch.test.ts: 49/49 pass under Node 22.23.2, 24.19.0, and 26.8.1; full pnpm test:projection under Node 26: green.
  • New unit tests in tests/core.test.ts run the helper against the observed flag sets of Node 22, 24, and 26, a set with neither flag, and this process's real set; pass on Node 22, 24, and 26.
  • New script-dispatch test sets the version-appropriate switch (--no-experimental-strip-types on 22, --no-strip-types on 24/26) in NODE_OPTIONS and expects a plain typed script to run anyway; it fails on Node 26 without the --strip-types fallback and passes with it.
  • Gate: pnpm typecheck ✓, pnpm lint ✓, pnpm test:unit ✓ (3331 passed), pnpm docs:site:build ✓ (language parity ok).

Self-review

Reviewer: gpt-5.6-sol-medium (change-risk-reviewer) on the integration diff vs origin/main (head 9d3bc2f).

  • stdio output can corrupt JSON-RPC (High). The env-precedence follow-up made the generated stdio entry import the server module statically, ahead of the console guard runGeneratedStdioMcpEntry installed in the shell body, so a module-scope console.log / process.stdout.write in a consumer's server or tool module reached the protocol stream — contradicting the documented guarantee that redirection precedes the consumer module's evaluation. → fixed in a677371: the stdio shell's first import is now a generated prelude (agent-bundle/stdio-prelude) that calls redirectConsoleToStderr from agent-bundle/mcp-entry and then applies the operator .env layer; hook wrappers and the artifact CLI bin keep the env-only agent-bundle/launch-env-layer. One guard implementation: redirectConsoleToStderr returns the guard already installed instead of stacking a second, so the lifecycle adopts the prelude's guard. New tests: mcp.test.ts "redirects stdout written at module scope by the server module to stderr before the protocol stream opens" (real stdio client, initialize + tools/list + tools/call; fails on 9d3bc2f), entry-shell.test.ts prelude-first / env-only-layer pins, mcp-entry.test.ts guard adoption. docs/entry-conventions.md and changeset 469-env-precedence-followup.md updated.
  • No other findings.
  • Second pass (gpt-5.6-sol-medium, delta 9d3bc2f…a67737162): guard adoption by write identity stacked a second guard under a consumer wrapper over process.stdout.write and restored stdout to the wrapper-over-stderr, so every JSON-RPC frame left on stderr → fixed in a279643 (while a guard is installed redirectConsoleToStderr returns it whatever process.stdout.write has become; restoreProtocolStdout restores the real original, warns once on stderr if a module replaced the write, and clears the installed guard; unit test wraps the redirect and fails on a677371 at adoption, packed test wraps at module scope and hung on a677371); no other findings.
  • Third pass (gpt-5.6-sol-medium, delta a677371…a2796437d): restoreProtocolStdout() not idempotent — a stale or repeated restore overwrote a fresh guard's redirect and warned twice → fixed in 88db802 (once-only restore; later calls are no-ops; two unit tests, both failing on a279643); no other findings.

Review threads

  • Codex (P2, core/runtime.ts): with no command-line flag on Node 26, a child inheriting NODE_OPTIONS=--no-strip-types fails on every typed .ts source — accepted and fixed in fa1164e: the helper falls back to --strip-types where the transform flag is unavailable, with a script-dispatch test covering the inherited-NODE_OPTIONS scenario on every supported Node line.

Absorbed

Changesets on this branch: node26-transform-types.md and 469-env-precedence-followup.md, both (#554); pnpm changeset status --since=origin/main reports one agent-bundle patch bump.

Combined-tree gate (head 9d3bc2f)

pnpm build ✓ · pnpm typecheck ✓ · pnpm lint ✓ · pnpm test:unit ✓ (3370) · pnpm test:projection ✓ (172, Node 22) · script-dispatch.test.ts under Node 26.8.1 ✓ (49) · integration mcp, hooks, build, package-build, emitted-artifact-effect-surface, artifact-cli-bin, cli-routes-build ✓ (102) · launch-env, entry-shell, hook-handler-contract, inspect-bundler ✓ (48) · packed packed-stdio-projection ✓ · pnpm docs:site:build ✓ (parity ok).

…orts (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-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e8f942a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T19:27:46.427966Z 09323c7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09323c7b56

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

*/
export const typeScriptTransformFlags = (
allowedFlags: ReadonlySet<string> = process.allowedNodeEnvironmentFlags,
): readonly string[] => Object.freeze(allowedFlags.has(TRANSFORM_TYPES_FLAG) ? [TRANSFORM_TYPES_FLAG] : []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-enable stripping when inherited options disable it

When the parent runs on Node 26 with NODE_OPTIONS=--no-strip-types, this branch returns no CLI flag even though the spawned child inherits that environment, so importing any ordinary typed .ts script fails instead of running. Node exposes the positive --strip-types option, and command-line options override inherited NODE_OPTIONS; select it when the transform flag is unavailable so the harness explicitly restores the TypeScript loading it requires.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted — fixed in fa1164e. typeScriptTransformFlags now returns the first flag the binary accepts, strongest first: --experimental-transform-types (Node 22, 24), then --strip-types (Node 26), so the harness always names the TypeScript loading it needs on the command line and an inherited NODE_OPTIONS=--no-strip-types is outranked. Verified: on 26.8.1, NODE_OPTIONS=--no-strip-types node file.ts fails and … node --strip-types file.ts runs; on 22/24 the transform flag already outranks the negative switch. Covered by a new script-dispatch test that sets the version-appropriate switch in NODE_OPTIONS and passes on 22/24/26 (it failed on 26 before this commit).

@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@554
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@554
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/rsc-markdown-stream@554
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@554

commit: e8f942a

…ONS=--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.
…luate and below manifest env defaults (#469)

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.
@ScriptedAlchemy ScriptedAlchemy changed the title fix(scripts): choose the TypeScript transform flag Node actually supports (Node 26 drops --experimental-transform-types) fix: Node 26 transform flag; operator .env precedence + import order; MCP negotiation test; provider-view cross-process coverage (integration) Sep 4, 2026
ScriptedAlchemy and others added 4 commits September 4, 2026 21:54
…so module-scope writes never reach the protocol stream (#469)

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.
…ty so a consumer wrapper cannot stack a second guard (#469)

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
a677371 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 a677371).
… 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.
@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) September 4, 2026 22:46
@ScriptedAlchemy
ScriptedAlchemy merged commit d88cc10 into main Sep 4, 2026
14 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the fix/node26-transform-types branch September 4, 2026 23:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant