From 21706e73eab027dd23688d934579aa79ad764b23 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 03:23:30 +0000 Subject: [PATCH 1/3] fix(entry,state): accept CommonJS server factories and pin journal nullability `scanEntryExportsSource` now counts a top-level `module.exports = ` as a default export, so AB4730 no longer rejects a `.cjs` stdio MCP entry that the generated lifecycle shell can run: the shell imports the entry namespace and the bundler exposes `module.exports` as its `default`. `exports.foo` and `module.exports.foo` stay named. The SQLite state driver's generic table check now compares each column's NOT NULL flag from `PRAGMA table_info` against the CREATE TABLE statements, so a journal whose `result_state` is nullable fails at open with the existing typed `corrupt` error instead of on the first NULL row. Docs record both, plus the resource/app contract fixture shape, in en and zh. --- .changeset/legacy-removal-followups.md | 10 ++++ docs/diagnostics.md | 5 +- docs/entry-conventions.md | 9 +++- .../agent-bundle/src/build/entry-exports.ts | 21 +++++++++ packages/agent-bundle/src/config/validate.ts | 3 +- .../agent-bundle/tests/entry-shell.test.ts | 14 ++++++ packages/agent-bundle/tests/mcp.test.ts | 46 +++++++++++++++++++ packages/rsc-runtime/src/state/sqlite.ts | 42 +++++++++++++---- .../rsc-runtime/tests/state-sqlite.test.ts | 33 +++++++++++++ website/docs/en/guide/authoring/mcp.mdx | 13 ++++++ website/docs/en/guide/development/testing.mdx | 5 ++ .../docs/en/reference/runtime-environment.mdx | 19 ++++++++ website/docs/zh/guide/authoring/mcp.mdx | 12 +++++ website/docs/zh/guide/development/testing.mdx | 5 ++ .../docs/zh/reference/runtime-environment.mdx | 14 ++++++ 15 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 .changeset/legacy-removal-followups.md diff --git a/.changeset/legacy-removal-followups.md b/.changeset/legacy-removal-followups.md new file mode 100644 index 000000000..5a3604cfb --- /dev/null +++ b/.changeset/legacy-removal-followups.md @@ -0,0 +1,10 @@ +--- +"agent-bundle": patch +"@agent-bundle/runtime": patch +--- + +Accept a CommonJS `module.exports` server factory as the default export of a +stdio MCP entry, so `AB4730` no longer rejects a `.cjs` entry the generated +lifecycle shell can run, and reject a SQLite state store at open with the +typed `corrupt` error when its journal schema differs in column nullability +from the one the current kernel writes. (#PR) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 70aafc2e1..92ec0d169 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -648,7 +648,10 @@ in the framework stdio lifecycle shell (console-to-stderr guard, SIGINT/SIGTERM, stdin-EOF exit, bounded shutdown, heartbeat), and the shell calls the module's default export to build the server, so a module without one cannot be built. The detection is the same static default-export scan the -build uses, so the diagnostic and the build always agree. +build uses, so the diagnostic and the build always agree. A CommonJS entry's +top-level `module.exports = ` counts as that default export, because +the bundler exposes the assigned value as the module's `default`; a narrower +`exports.foo = …` or `module.exports.foo = …` does not. Recover: default-export the server factory from the entry module, or declare a prebuilt server with `command` or `url`, which the framework launches diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index d2f88371a..6ffdc1ccf 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -713,7 +713,9 @@ real child process's probe (two pipes). A test that wants other values injects Source validation reports `AB4730` as an **error** when a local stdio MCP entry has no default export: the framework lifecycle shell calls that export -to build the server, so such a module cannot be built. It reports +to build the server, so such a module cannot be built. A CommonJS entry may +assign the factory to `module.exports` instead; the bundler exposes that +value as the module's `default`, and the scan counts it. It reports **informational** nudges (never errors) when `src/cli.ts`, `src/index.ts`, or `src/mcp/.ts` exists but explicit configuration shadows it (`AB4731`/`AB4732`/`AB4733`). `bin: false` / `lib: false` opt-outs stay @@ -1074,7 +1076,10 @@ name). A module that constructs and connects a transport at top level without a default export cannot be built: source validation reports `AB4730` as an -error. A server the framework should launch as-is instead of compiling is +error. A CommonJS entry's top-level `module.exports = ` is that +default export under bundling; `exports.foo = …` and `module.exports.foo = …` +are named and do not satisfy it. A server the framework should launch as-is +instead of compiling is declared with `command` or `url`, or as a `{ prebuilt: ... }` entry the consumer's own build produced. The operator `.env` layer (#469) comes from `agent-bundle/launch-env`, which the shell's prelude applies; that module is diff --git a/packages/agent-bundle/src/build/entry-exports.ts b/packages/agent-bundle/src/build/entry-exports.ts index 723870c86..e4de3f7b6 100644 --- a/packages/agent-bundle/src/build/entry-exports.ts +++ b/packages/agent-bundle/src/build/entry-exports.ts @@ -17,6 +17,23 @@ export interface EntryExportScan { const hasModifier = (statement: ts.Statement, kind: ts.SyntaxKind): boolean => ts.canHaveModifiers(statement) && (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === kind); +/** + * A CommonJS entry's `module.exports = ` is a default export: the + * bundler exposes the assigned value as the namespace's `default`, which is + * what the generated shells read. Only the top-level whole-object assignment + * qualifies; `exports.foo = …` and `module.exports.foo = …` are named. + */ +const assignsModuleExports = (statement: ts.Statement): boolean => { + if (!ts.isExpressionStatement(statement)) return false; + const assignment = statement.expression; + return ts.isBinaryExpression(assignment) + && assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isPropertyAccessExpression(assignment.left) + && ts.isIdentifier(assignment.left.expression) + && assignment.left.expression.text === 'module' + && assignment.left.name.text === 'exports'; +}; + const declaresMain = (statement: ts.Statement): boolean => { if (ts.isFunctionDeclaration(statement)) return statement.name?.text === 'main'; if (ts.isVariableStatement(statement)) { @@ -46,6 +63,10 @@ export const scanEntryExportsSource = (source: string, fileName = 'entry.ts'): E } continue; } + if (assignsModuleExports(statement)) { + hasDefaultExport = true; + continue; + } if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword) || hasModifier(statement, ts.SyntaxKind.DeclareKeyword)) continue; if (hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) hasDefaultExport = true; else if (declaresMain(statement)) hasMainExport = true; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index c8aca060f..3f4d60b6c 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -616,7 +616,8 @@ const relativePosix = toPosixRelative; /** * AB4730: every local stdio entry is wrapped in the framework stdio lifecycle - * shell, which imports the entry's default export as its server factory. The + * shell, which imports the entry's default export as its server factory (a + * CommonJS entry's `module.exports` is that default under bundling). The * detection is the same static export scan the build uses to build the wrap, * so the diagnostic and the build always agree. */ diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 86870fbde..d0038bd5b 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -59,6 +59,20 @@ describe('entry export scanning', () => { expect(scanEntryExportsSource('export type { main } from "./types.ts";').hasMainExport).toBe(false); }); + it('counts a top-level module.exports assignment as the default export, and nothing narrower', () => { + expect(scanEntryExportsSource('module.exports = () => server;', '/app/entry.cjs')).toEqual({ + hasDefaultExport: true, + hasMainExport: false, + }); + expect(scanEntryExportsSource('exports.foo = () => server;', '/app/entry.cjs')).toEqual({ + hasDefaultExport: false, + hasMainExport: false, + }); + expect(scanEntryExportsSource('module.exports.foo = 1;', '/app/entry.cjs').hasDefaultExport).toBe(false); + expect(scanEntryExportsSource('if (ok) { module.exports = 1; }', '/app/entry.cjs').hasDefaultExport).toBe(false); + expect(scanEntryExportsSource('const module = {}; module.exports === 1;', '/app/entry.cjs').hasDefaultExport).toBe(false); + }); + it('never matches inside comments, strings, or template literals', () => { expect(scanEntryExportsSource('// export default nothing\nconst a = 1;').hasDefaultExport).toBe(false); expect(scanEntryExportsSource('/* export const main = 1 */ const a = 1;').hasMainExport).toBe(false); diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index 697abc301..14cf45c24 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -1444,6 +1444,52 @@ it('creates session state only after setup succeeds and always inherits the stdi } }, 30_000); +it('accepts a CommonJS stdio entry whose server factory is module.exports, and runs it', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-cjs-entry-')); + 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'); + await writeFile(join(root, 'src', 'server.cjs'), [ + "const { McpServer } = require('@modelcontextprotocol/server');", + '', + 'module.exports = () => {', + " const server = new McpServer({ name: 'cjs-server', version: '1.0.0' });", + " server.registerTool('ping', { description: 'Answer a ping.' }, async () => ({", + " content: [{ type: 'text', text: 'pong' }],", + ' }));', + ' return server;', + '};', + '', + ].join('\n')); + const config: AgentBundleConfig = { + mcp: { servers: { cjs: { entry: './src/server.cjs' } } }, + plugin: { name: 'mcp-cjs-fixture' }, + targets: ['portable'], + }; + // The shell reads the entry's `default`, which is what the bundler makes + // of `module.exports`; AB4730 must not refuse an entry it can run. + expect(validateSource(loadedProject(root, config), { skills: [] }, registry)).toEqual([]); + + const model = await normalizeProject(loadedProject(root, config), { skills: [] }, registry); + const artifact = join(root, 'dist'); + await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); + + await expect(new McpService().list({ artifact, server: 'cjs', target: 'portable' })).resolves.toMatchObject({ + server: { name: 'cjs-server', version: '1.0.0' }, + tools: [{ name: 'ping' }], + }); + } finally { + await removeTree(root); + } +}, 30_000); + it('serves compiler-bundled MCP App resources from a copied artifact without project source', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-resource-')); const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-consumer-')); diff --git a/packages/rsc-runtime/src/state/sqlite.ts b/packages/rsc-runtime/src/state/sqlite.ts index ea9c5c437..fc6db1e23 100644 --- a/packages/rsc-runtime/src/state/sqlite.ts +++ b/packages/rsc-runtime/src/state/sqlite.ts @@ -100,18 +100,40 @@ const COMPACTED_KERNEL_FORMAT = 2; const READABLE_KERNEL_FORMATS: readonly number[] = Object.freeze([KERNEL_FORMAT, COMPACTED_KERNEL_FORMAT]); /** - * Column order of every table `initialize` creates. A pre-existing table - * whose columns differ is not this kernel's schema and fails closed as - * `corrupt` instead of surfacing a raw SQLite error on the first statement - * that names a missing column. + * Column order and nullability of every table `initialize` creates, written + * as the `PRAGMA table_info` signature each `CREATE TABLE` below produces. A + * pre-existing table whose columns differ is not this kernel's schema and + * fails closed as `corrupt` instead of surfacing a raw SQLite error on the + * first statement that names a missing column. Nullability is part of that + * identity: a column this kernel reads unconditionally but an earlier layout + * left nullable holds rows it cannot decode, and those must fail at open + * rather than on the row that happens to be NULL. */ const TABLE_COLUMNS: Readonly> = Object.freeze({ - agent_state_head: ['id', 'revision', 'state'], - agent_state_journal: ['revision', 'kind', 'name', 'payload', 'state', 'result_state', 'to_version', 'idempotency_key', 'committed_at'], - agent_state_meta: ['id', 'definition_id', 'schema_version', 'kernel_format'], - agent_state_pruned_keys: ['idempotency_key', 'revision', 'canonical_input'], + agent_state_head: ['id', 'revision NOT NULL', 'state NOT NULL'], + agent_state_journal: [ + 'revision', + 'kind NOT NULL', + 'name', + 'payload', + 'state', + 'result_state NOT NULL', + 'to_version', + 'idempotency_key NOT NULL', + 'committed_at NOT NULL', + ], + agent_state_meta: ['id', 'definition_id NOT NULL', 'schema_version NOT NULL', 'kernel_format NOT NULL'], + agent_state_pruned_keys: ['idempotency_key', 'revision NOT NULL', 'canonical_input NOT NULL'], }); +interface TableInfoRow { + readonly name: string; + readonly notnull: number; +} + +const columnSignature = (column: TableInfoRow): string => + column.notnull === 0 ? column.name : `${column.name} NOT NULL`; + export interface SqliteStateDriverOptions { /** * SQLite lock wait budget per operation in milliseconds (default 5000). @@ -848,8 +870,8 @@ class SqliteStore implements Age `); const definition = this.#definition; for (const [table, columns] of Object.entries(TABLE_COLUMNS)) { - const actual = (transactionDb.prepare(`PRAGMA table_info(${table})`).all() as unknown as { readonly name: string }[]) - .map((column) => column.name); + const actual = (transactionDb.prepare(`PRAGMA table_info(${table})`).all() as unknown as TableInfoRow[]) + .map(columnSignature); if (actual.join(',') !== columns.join(',')) { throw new AgentStateError( 'corrupt', diff --git a/packages/rsc-runtime/tests/state-sqlite.test.ts b/packages/rsc-runtime/tests/state-sqlite.test.ts index 09531b1a1..6a5bf255e 100644 --- a/packages/rsc-runtime/tests/state-sqlite.test.ts +++ b/packages/rsc-runtime/tests/state-sqlite.test.ts @@ -189,6 +189,39 @@ describe('sqlite driver storage behavior', () => { }); })); + it('fails closed with a typed corrupt error when a column that must be NOT NULL is nullable', () => + withRoot(async (root) => { + const file = join(root, 'state.sqlite'); + const store = await createSqliteStateDriver({ file }).open(counterDefinition()); + await store.dispatch('bumped', { by: 1 }, { idempotencyKey: 'k1' }); + await store.close(); + const db = new DatabaseSync(file); + // Same column names in the same order, so only the NOT NULL flags tell + // this journal apart from the one the current kernel writes. + db.exec(` + ALTER TABLE agent_state_journal RENAME TO agent_state_journal_old; + CREATE TABLE agent_state_journal ( + revision INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('event', 'reset', 'migrate')), + name TEXT, + payload TEXT, + state TEXT, + result_state TEXT, + to_version INTEGER, + idempotency_key TEXT NOT NULL UNIQUE, + committed_at TEXT NOT NULL + ); + INSERT INTO agent_state_journal SELECT * FROM agent_state_journal_old; + DROP TABLE agent_state_journal_old; + `); + db.close(); + await expect(createSqliteStateDriver({ file }).open(counterDefinition())).rejects.toMatchObject({ + code: 'corrupt', + message: expect.stringContaining('table agent_state_journal') as string, + name: 'AgentStateError', + }); + })); + it('rejects a pending open when the driver closes before initialization resumes', () => withRoot(async (root) => { const driver = createSqliteStateDriver({ root }); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index f9409a62e..bc80a0572 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -571,6 +571,19 @@ sixty-second activity throttle, labeled with the server name). That guard matters because stdout carries JSON-RPC framing: one stray `console.log` from any imported module would corrupt the protocol stream. +A CommonJS entry assigns the same factory to `module.exports`, which the bundler exposes as the +module's default export: + +```js +// src/mcp/curator.cjs +const { McpServer } = require('@modelcontextprotocol/server'); + +module.exports = () => new McpServer({ name: 'curator', version: '1.0.0' }); +``` + +Only the whole-object assignment counts. `exports.curator = …` and `module.exports.curator = …` +are named exports, and the shell reads neither. + A module that constructs and connects a transport at top level without a default export cannot be built: source validation reports `AB4730` as an **error**. Declare a server you do not want compiled with `command` or `url`, or as a `{ prebuilt: ... }` entry your own build produced. diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index c620f7ecd..1d26f24bc 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -361,6 +361,11 @@ export const matrix = async (): Promise => { }; ``` +A resource route's fixture, and an MCP App route's fixture under `apps: 'explicit'`, must be +exactly `{ kind: 'resource' }`. A bare `{}` entry fails that route's `coverage` check with +`resource/app route fixture must be { kind: "resource" }`; the key alone does not cover the route. +The same `kind` is refused on a tool, prompt, or CLI route, whose fixtures carry `input` instead. + The packed and installed-host entry points take the same fixture shape plus the session they run against: `runPackedContractMatrix` needs the open packed session and the manifest compiled *before* source removal, and `runInstalledHostContractMatrix` needs the session diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 4fc565927..559b2ec83 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -123,6 +123,25 @@ Default state projection budgets are 5,000 ms per commit, 262,144 bytes per even of state, and 100,000 revisions. `agent-bundle inspect --state` reports the resolved driver, lifetime, durable location, and budget source for each definition. +### Stores written before the current SQLite layout + +The driver reads only the layout it writes today. It neither adopts nor migrates a store written +before that layout, in either of two ways. + +A database under the pre-#201 file name, whose suffix was the state id's own leading bytes in hex +rather than a SHA-256 digest, is never opened. The driver leaves the old file untouched and +creates a new, empty store beside it under the current name, so the old data is still on disk and +simply unread. + +A database under the current file name whose `agent_state_journal` table differs from the one the +driver creates fails at open with a `corrupt` `AgentStateError` naming the table. Column names, +their order, and their `NOT NULL` flags all count, so a journal whose `result_state` column is +nullable, as an older runtime's `ALTER TABLE` left it, is rejected before any row is read. + +Recover from either by deleting the stale database files, each `*.sqlite` plus its `-wal` and +`-shm` sidecars, or the whole state root. The next launch creates a fresh store at the definition's +initial state. Nothing reconstructs the old history. + ## Next - [Security](./security.mdx): the credential and network boundaries around these processes. diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index fac19a77d..8c557876f 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -501,6 +501,18 @@ export default () => new McpServer({ name: 'curator', version: '1.0.0' }); 这层保护之所以重要,是因为 stdout 承载着 JSON-RPC 帧:任何被导入模块中一次走神的 `console.log` 都会 破坏协议流。 +CommonJS 入口把同一个工厂函数赋给 `module.exports`,打包器会把它暴露为该模块的默认导出: + +```js +// src/mcp/curator.cjs +const { McpServer } = require('@modelcontextprotocol/server'); + +module.exports = () => new McpServer({ name: 'curator', version: '1.0.0' }); +``` + +只有整体赋值才算数。`exports.curator = …` 与 `module.exports.curator = …` 都是具名导出,外壳两者都 +不读取。 + 在顶层构造并连接传输层、且没有默认导出的模块无法构建:源码校验会把 `AB4730` 报告为**错误**。不希望 被编译的服务器请用 `command` 或 `url` 声明,或声明为由你自己的构建产出的 `{ prebuilt: ... }` 入口。 diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index d608b45b6..9c12921b2 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -312,6 +312,11 @@ export const matrix = async (): Promise => { }; ``` +资源路由的 fixture,以及 `apps: 'explicit'` 下 MCP App 路由的 fixture,必须恰好是 +`{ kind: 'resource' }`。写成空的 `{}` 会让该路由的 `coverage` 检查以 +`resource/app route fixture must be { kind: "resource" }` 失败;仅有键名并不算覆盖该路由。反过来, +工具、prompt 与命令行路由拒绝这个 `kind`,它们的 fixture 携带的是 `input`。 + 打包与已安装宿主这两个入口接受同样的 fixture 形状,外加它们所针对的会话:`runPackedContractMatrix` 需要那个已打开的打包会话,以及在移除源码*之前*编译出的清单;而 `runInstalledHostContractMatrix` 需要 `openInstalledHostMcpServer` 返回的会话以及同一份清单。 diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index c11224e5c..17cebac78 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -107,6 +107,20 @@ Workbench 路由调用会把 `AGENT_BUNDLE_STATE_ROOT` 固定为 `/.age 默认的状态投影预算为:每次提交 5,000 毫秒、每个事件 262,144 字节、状态 1,048,576 字节、100,000 次修订。 `agent-bundle inspect --state` 会为每个定义报告解析出的驱动、生命期、持久位置与预算来源。 +### 早于当前 SQLite 布局的存储 + +驱动只读取它今天写出的布局。对早于该布局写出的存储,它既不接管也不迁移,具体有两种情形。 + +使用 #201 之前文件名的数据库(后缀是状态 id 自身起始字节的十六进制,而不是 SHA-256 摘要)永远不会被 +打开。驱动原样保留旧文件,并在旁边按当前文件名新建一个空存储,因此旧数据仍在磁盘上,只是不再被读取。 + +使用当前文件名、但 `agent_state_journal` 表与驱动所创建的不一致的数据库,会在打开时以点名该表的 +`corrupt` `AgentStateError` 失败。列名、列顺序与各列的 `NOT NULL` 标志都参与比较,因此像旧版运行时用 +`ALTER TABLE` 留下的可空 `result_state` 列,会在读取任何行之前就被拒绝。 + +两种情形的恢复方式相同:删除过期的数据库文件(每个 `*.sqlite` 及其 `-wal`、`-shm` 附属文件),或删除 +整个状态根目录。下次启动会按定义的初始状态新建存储。旧历史不会被任何机制重建。 + ## 下一步 - [安全](./security.mdx):这些进程周围的凭据与网络边界。 From 9abccc153f71964e1909fac628e3e3ce6f61aed1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 03:30:42 +0000 Subject: [PATCH 2/3] chore: link changeset to #850 --- .changeset/legacy-removal-followups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/legacy-removal-followups.md b/.changeset/legacy-removal-followups.md index 5a3604cfb..c470ddbd0 100644 --- a/.changeset/legacy-removal-followups.md +++ b/.changeset/legacy-removal-followups.md @@ -7,4 +7,4 @@ Accept a CommonJS `module.exports` server factory as the default export of a stdio MCP entry, so `AB4730` no longer rejects a `.cjs` entry the generated lifecycle shell can run, and reject a SQLite state store at open with the typed `corrupt` error when its journal schema differs in column nullability -from the one the current kernel writes. (#PR) +from the one the current kernel writes. (#850) From 3c3a1afe409b9d69fb470bb6587c5622a309908c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 03:40:23 +0000 Subject: [PATCH 3/3] fix(config): scope CommonJS factory detection to AB4730 and skip a local module binding --- docs/diagnostics.md | 11 +++--- .../agent-bundle/src/build/entry-exports.ts | 39 ++++++++++++++----- packages/agent-bundle/src/config/validate.ts | 11 +++--- .../agent-bundle/tests/entry-shell.test.ts | 20 +++++----- website/docs/en/guide/development/testing.mdx | 4 +- website/docs/zh/guide/development/testing.mdx | 4 +- 6 files changed, 55 insertions(+), 34 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 92ec0d169..37f43f594 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -647,11 +647,12 @@ A local MCP server entry module (explicit `entry:` or the conventional in the framework stdio lifecycle shell (console-to-stderr guard, SIGINT/SIGTERM, stdin-EOF exit, bounded shutdown, heartbeat), and the shell calls the module's default export to build the server, so a module without -one cannot be built. The detection is the same static default-export scan the -build uses, so the diagnostic and the build always agree. A CommonJS entry's -top-level `module.exports = ` counts as that default export, because -the bundler exposes the assigned value as the module's `default`; a narrower -`exports.foo = …` or `module.exports.foo = …` does not. +one cannot be built. Validation finds the default export with a static scan of +the entry's top-level statements. A CommonJS entry's top-level +`module.exports = ` counts as that default export, because the +bundler exposes the assigned value as the module's `default`, unless the file +declares its own `module` binding. A narrower `exports.foo = …` or +`module.exports.foo = …` does not count. Recover: default-export the server factory from the entry module, or declare a prebuilt server with `command` or `url`, which the framework launches diff --git a/packages/agent-bundle/src/build/entry-exports.ts b/packages/agent-bundle/src/build/entry-exports.ts index e4de3f7b6..fa68d9f01 100644 --- a/packages/agent-bundle/src/build/entry-exports.ts +++ b/packages/agent-bundle/src/build/entry-exports.ts @@ -17,12 +17,6 @@ export interface EntryExportScan { const hasModifier = (statement: ts.Statement, kind: ts.SyntaxKind): boolean => ts.canHaveModifiers(statement) && (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === kind); -/** - * A CommonJS entry's `module.exports = ` is a default export: the - * bundler exposes the assigned value as the namespace's `default`, which is - * what the generated shells read. Only the top-level whole-object assignment - * qualifies; `exports.foo = …` and `module.exports.foo = …` are named. - */ const assignsModuleExports = (statement: ts.Statement): boolean => { if (!ts.isExpressionStatement(statement)) return false; const assignment = statement.expression; @@ -34,6 +28,35 @@ const assignsModuleExports = (statement: ts.Statement): boolean => { && assignment.left.name.text === 'exports'; }; +const bindsModule = (statement: ts.Statement): boolean => { + if (ts.isVariableStatement(statement)) { + return statement.declarationList.declarations.some((declaration) => + ts.isIdentifier(declaration.name) && declaration.name.text === 'module'); + } + if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) return statement.name?.text === 'module'; + if (ts.isImportDeclaration(statement)) { + const clause = statement.importClause; + if (clause === undefined) return false; + if (clause.name?.text === 'module') return true; + const bindings = clause.namedBindings; + if (bindings === undefined) return false; + return ts.isNamespaceImport(bindings) + ? bindings.name.text === 'module' + : bindings.elements.some((element) => element.name.text === 'module'); + } + return false; +}; + +/** + * A CommonJS module's top-level `module.exports = `, which the bundler + * exposes as the namespace's `default`. A file that binds its own `module` + * never qualifies. + */ +export const assignsModuleExportsSource = (source: string, fileName = 'entry.js'): boolean => { + const { statements } = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false); + return statements.some(assignsModuleExports) && !statements.some(bindsModule); +}; + const declaresMain = (statement: ts.Statement): boolean => { if (ts.isFunctionDeclaration(statement)) return statement.name?.text === 'main'; if (ts.isVariableStatement(statement)) { @@ -63,10 +86,6 @@ export const scanEntryExportsSource = (source: string, fileName = 'entry.ts'): E } continue; } - if (assignsModuleExports(statement)) { - hasDefaultExport = true; - continue; - } if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword) || hasModifier(statement, ts.SyntaxKind.DeclareKeyword)) continue; if (hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) hasDefaultExport = true; else if (declaresMain(statement)) hasMainExport = true; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 3f4d60b6c..44a70e506 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -3,7 +3,7 @@ import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } fr import { capabilityIsSupported, cliBinCapability, webSurfaceCapability } from '../adapters/capability-state.ts'; import { builtInHostNames, isBuiltInHost } from '../adapters/composite-layout.ts'; -import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts'; +import { assignsModuleExportsSource, type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts'; import { externalizedSpecifiers } from '../build/external-policy.ts'; import { frameworkOwnedPluginCollisions, frameworkOwnedRsbuildPlugins } from '../build/framework-plugins.ts'; import type { CapabilityState } from '../core/capabilities.ts'; @@ -616,10 +616,8 @@ const relativePosix = toPosixRelative; /** * AB4730: every local stdio entry is wrapped in the framework stdio lifecycle - * shell, which imports the entry's default export as its server factory (a - * CommonJS entry's `module.exports` is that default under bundling). The - * detection is the same static export scan the build uses to build the wrap, - * so the diagnostic and the build always agree. + * shell, which imports the entry's default export as its server factory. A + * CommonJS entry's top-level `module.exports` is that default under bundling. */ const missingServerFactoryErrors = ( name: string, @@ -634,7 +632,8 @@ const missingServerFactoryErrors = ( : conventionalEntry; if (source === undefined || !bundleScriptExtensions.has(extname(source).toLowerCase())) return []; try { - if (scanEntryExportsSource(readFileSync(source, 'utf8'), source).hasDefaultExport) return []; + const text = readFileSync(source, 'utf8'); + if (scanEntryExportsSource(text, source).hasDefaultExport || assignsModuleExportsSource(text, source)) return []; } catch { // An unreadable entry is already reported by the existence diagnostics. return []; diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index eb4133f4d..b795158fe 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -8,7 +8,7 @@ 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 } from '../src/build/entry-exports.ts'; +import { assignsModuleExportsSource, scanEntryExportsSource } 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'; @@ -59,18 +59,20 @@ describe('entry export scanning', () => { expect(scanEntryExportsSource('export type { main } from "./types.ts";').hasMainExport).toBe(false); }); - it('counts a top-level module.exports assignment as the default export, and nothing narrower', () => { + it('keeps module.exports out of the shared export scan', () => { expect(scanEntryExportsSource('module.exports = () => server;', '/app/entry.cjs')).toEqual({ - hasDefaultExport: true, - hasMainExport: false, - }); - expect(scanEntryExportsSource('exports.foo = () => server;', '/app/entry.cjs')).toEqual({ hasDefaultExport: false, hasMainExport: false, }); - expect(scanEntryExportsSource('module.exports.foo = 1;', '/app/entry.cjs').hasDefaultExport).toBe(false); - expect(scanEntryExportsSource('if (ok) { module.exports = 1; }', '/app/entry.cjs').hasDefaultExport).toBe(false); - expect(scanEntryExportsSource('const module = {}; module.exports === 1;', '/app/entry.cjs').hasDefaultExport).toBe(false); + }); + + it('recognizes only a top-level module.exports assignment to an unbound module', () => { + expect(assignsModuleExportsSource('module.exports = () => server;', '/app/entry.cjs')).toBe(true); + expect(assignsModuleExportsSource('exports.foo = () => server;', '/app/entry.cjs')).toBe(false); + expect(assignsModuleExportsSource('module.exports.foo = 1;', '/app/entry.cjs')).toBe(false); + expect(assignsModuleExportsSource('if (ok) { module.exports = 1; }', '/app/entry.cjs')).toBe(false); + expect(assignsModuleExportsSource('const module = { exports: null };\nmodule.exports = factory;', '/app/entry.mts')).toBe(false); + expect(assignsModuleExportsSource("import module from './m.ts';\nmodule.exports = factory;", '/app/entry.ts')).toBe(false); }); it('never matches inside comments, strings, or template literals', () => { diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index 1d26f24bc..cefbafe8c 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -361,8 +361,8 @@ export const matrix = async (): Promise => { }; ``` -A resource route's fixture, and an MCP App route's fixture under `apps: 'explicit'`, must be -exactly `{ kind: 'resource' }`. A bare `{}` entry fails that route's `coverage` check with +A resource route's fixture, and an MCP App route's fixture under `apps: 'explicit'`, must set +`kind: 'resource'`. A bare `{}` entry fails that route's `coverage` check with `resource/app route fixture must be { kind: "resource" }`; the key alone does not cover the route. The same `kind` is refused on a tool, prompt, or CLI route, whose fixtures carry `input` instead. diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index 9c12921b2..bb7f17ece 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -312,8 +312,8 @@ export const matrix = async (): Promise => { }; ``` -资源路由的 fixture,以及 `apps: 'explicit'` 下 MCP App 路由的 fixture,必须恰好是 -`{ kind: 'resource' }`。写成空的 `{}` 会让该路由的 `coverage` 检查以 +资源路由的 fixture,以及 `apps: 'explicit'` 下 MCP App 路由的 fixture,必须设置 +`kind: 'resource'`。写成空的 `{}` 会让该路由的 `coverage` 检查以 `resource/app route fixture must be { kind: "resource" }` 失败;仅有键名并不算覆盖该路由。反过来, 工具、prompt 与命令行路由拒绝这个 `kind`,它们的 fixture 携带的是 `input`。