Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fail-closed-route-module-reads.md
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 8 additions & 4 deletions packages/agent-bundle/src/routes/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | undefined> => {
try {
return await readFile(source, 'utf8');
} catch {
return undefined;
} catch (error) {
if (isErrno(error, 'ENOENT')) return undefined;
throw error;

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 Document unreadable route failures in both locales

When a discovered route module produces EACCES, EIO, or another non-ENOENT read error, this new throw changes the observable behavior of the public compileRouteGraph API and the build, inspect, and validate flows, but the commit does not update a matching page under either website/docs/en/** or website/docs/zh/**. Document the new fail-closed behavior in both locales so users know these commands no longer treat unreadable route modules as absent.

AGENTS.md reference: AGENTS.md:L89-L95

Useful? React with 👍 / 👎.

}
};

Expand Down
59 changes: 59 additions & 0 deletions packages/agent-bundle/tests/route-graph.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -69,6 +71,24 @@ const conventionalTree: Readonly<Record<string, string>> = {
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<Record<string, string>>): Promise<string> => {
const root = await createRoot();
await writeTree(root, {
Expand Down Expand Up @@ -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' });
});
Loading