From de840644e440e642faca030eca6ce7b043cb632f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 14:39:15 +0000 Subject: [PATCH 1/3] fix: fail closed on unreadable route module source compileRouteGraph treated every readFile failure as a racing deletion. Keep ENOENT as a silent skip so extract/validate match a later snapshot, and propagate other I/O errors through the existing isErrno helper. Co-authored-by: Zack Jackson --- .changeset/fail-closed-route-module-reads.md | 5 ++ packages/agent-bundle/src/routes/graph.ts | 12 ++-- .../agent-bundle/tests/route-graph.test.ts | 60 ++++++++++++++++++- 3 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 .changeset/fail-closed-route-module-reads.md diff --git a/.changeset/fail-closed-route-module-reads.md b/.changeset/fail-closed-route-module-reads.md new file mode 100644 index 000000000..48379886a --- /dev/null +++ b/.changeset/fail-closed-route-module-reads.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Fail closed when `compileRouteGraph` cannot read a discovered route module: only a racing `ENOENT` stays silent, so `inspect` and `validate` no longer treat permission or I/O failures as a vanished file. diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index c5768965b..478bc849a 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -42,6 +42,7 @@ import { isLayoutRouteKind } from './layouts.ts'; import { providerKeyFromName } from './providers.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { digest } from '../core/digest.ts'; +import { isErrno } from '../core/errors.ts'; import { deepFreeze } from '../core/freeze.ts'; import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleConfig } from '../core/types.ts'; @@ -522,14 +523,17 @@ const compiledRoute = ( }); /** - * Reads one route module's source text. A racing deletion returns no text so - * extract/validate skip the same way a later snapshot would. + * Reads one route module's source text. A racing deletion (`ENOENT`) returns + * no text so extract/validate skip the same way a later snapshot would. + * Permission, I/O, and other read failures propagate — they are not + * disappearance. */ const readRouteModuleText = async (source: string): Promise => { try { return await readFile(source, 'utf8'); - } catch { - return undefined; + } catch (error) { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; } }; diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 4142d89de..e09792e5d 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -1,8 +1,10 @@ -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { unlinkSync } from 'node:fs'; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, expect, it } from '@rstest/core'; +import ignore from 'ignore'; import ts from 'typescript-5'; import { inspect, type ReadyInspectResult, validate } from '../src/api.ts'; @@ -69,6 +71,24 @@ const conventionalTree: Readonly> = { const codesOf = (diagnostics: readonly { readonly code: string }[]): string[] => diagnostics.map((diagnostic) => diagnostic.code); +/** + * Runs `mutate` after glob lists `relativePath` and before the source read, so + * the test can reproduce a scan-to-read race without mocking `readFile`. + */ +const mutateDiscoveredSource = ( + root: string, + relativePath: string, + mutate: (source: string) => void, +) => { + const rules = ignore(); + const ignores = rules.ignores.bind(rules); + rules.ignores = (pathname: string) => { + if (pathname === relativePath) mutate(join(root, pathname)); + return ignores(pathname); + }; + return rules; +}; + const createInspectProject = async (files: Readonly>): Promise => { const root = await createRoot(); await writeTree(root, { @@ -2283,3 +2303,41 @@ it('rejects orphan views and misplaced view configuration', async () => { const graph = await compileRouteGraph(root, fixtureConfig()); expect(graph.diagnostics.map(({ code }) => code)).toEqual(['AB4840', 'AB4840']); }); + +it('skips extract and validate when a discovered route module disappears before read', async () => { + const root = await createRoot(); + const relativePath = 'src/mcp/curator/tools/inspect.tsx'; + await writeTree(root, { + [relativePath]: `export const config = { title: 'Inspect' }; ${moduleSource}`, + }); + + const graph = await compileRouteGraph( + root, + fixtureConfig(), + mutateDiscoveredSource(root, relativePath, unlinkSync), + ); + + expect(graph.diagnostics).toEqual([]); + expect(graph.servers[0]!.routes).toEqual([ + expect.objectContaining({ + config: emptyRouteConfig, + id: 'tool:curator/inspect', + }), + ]); +}); + +it('fails closed when a discovered route module cannot be read', async () => { + if (process.getuid?.() === 0) return; + const root = await createRoot(); + const relativePath = 'src/mcp/curator/tools/inspect.tsx'; + await writeTree(root, { + [relativePath]: `export const config = { title: 'Inspect' }; ${moduleSource}`, + }); + const source = join(root, relativePath); + await chmod(source, 0o000); + try { + await expect(compileRouteGraph(root, fixtureConfig())).rejects.toMatchObject({ code: 'EACCES' }); + } finally { + await chmod(source, 0o644); + } +}); From e6f1b535e0b196c926a2e6340cfb1c88b2228bea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 14:40:17 +0000 Subject: [PATCH 2/3] chore: cite #791 in the fail-closed route read changeset Co-authored-by: Zack Jackson --- .changeset/fail-closed-route-module-reads.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fail-closed-route-module-reads.md b/.changeset/fail-closed-route-module-reads.md index 48379886a..903d47a2f 100644 --- a/.changeset/fail-closed-route-module-reads.md +++ b/.changeset/fail-closed-route-module-reads.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Fail closed when `compileRouteGraph` cannot read a discovered route module: only a racing `ENOENT` stays silent, so `inspect` and `validate` no longer treat permission or I/O failures as a vanished file. +Fail closed when `compileRouteGraph` cannot read a discovered route module: only a racing `ENOENT` stays silent, so `inspect` and `validate` no longer treat permission or I/O failures as a vanished file. (#791) From 3881ffc7856542bc2f389f34f518df04dde3d301 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 15:02:50 +0000 Subject: [PATCH 3/3] test: prove fail-closed route reads with EISDIR Replace the chmod/EACCES case, which is skipped under root and unreliable on Windows, with a deterministic unlink-then-directory race via mutateDiscoveredSource. Co-authored-by: Zack Jackson --- .../agent-bundle/tests/route-graph.test.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index e09792e5d..7c03be454 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -1,5 +1,5 @@ -import { unlinkSync } from 'node:fs'; -import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { mkdirSync, unlinkSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -2327,17 +2327,18 @@ it('skips extract and validate when a discovered route module disappears before }); it('fails closed when a discovered route module cannot be read', async () => { - if (process.getuid?.() === 0) return; const root = await createRoot(); const relativePath = 'src/mcp/curator/tools/inspect.tsx'; await writeTree(root, { [relativePath]: `export const config = { title: 'Inspect' }; ${moduleSource}`, }); - const source = join(root, relativePath); - await chmod(source, 0o000); - try { - await expect(compileRouteGraph(root, fixtureConfig())).rejects.toMatchObject({ code: 'EACCES' }); - } finally { - await chmod(source, 0o644); - } + + await expect(compileRouteGraph( + root, + fixtureConfig(), + mutateDiscoveredSource(root, relativePath, (source) => { + unlinkSync(source); + mkdirSync(source); + }), + )).rejects.toMatchObject({ code: 'EISDIR' }); });