diff --git a/.changeset/fail-closed-route-module-reads.md b/.changeset/fail-closed-route-module-reads.md new file mode 100644 index 000000000..903d47a2f --- /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. (#791) 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..7c03be454 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -1,8 +1,10 @@ +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'; 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,42 @@ 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 () => { + const root = await createRoot(); + const relativePath = 'src/mcp/curator/tools/inspect.tsx'; + await writeTree(root, { + [relativePath]: `export const config = { title: 'Inspect' }; ${moduleSource}`, + }); + + await expect(compileRouteGraph( + root, + fixtureConfig(), + mutateDiscoveredSource(root, relativePath, (source) => { + unlinkSync(source); + mkdirSync(source); + }), + )).rejects.toMatchObject({ code: 'EISDIR' }); +});