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/sweep-resource-uri-collision-stage-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Reject two MCP App routes of one generated server that declare the same static `config.resourceUri` with the new `AB4829` diagnostic, naming both route files and the server, instead of registering whichever route was discovered first; the same URI on App routes of different servers still passes, since each generated server registers only its own Apps. Sweep staging files (`.<epoch>.stage-<pid>-<nonce>`) that an exited native Playground catalog publisher left orphaned on the next catalog publication: only singly linked entries of another epoch in the publisher's own directory are removed, a live winner's hard-linked staging entry, a running publisher's file, and foreign files are kept, and the sweep is bounded per publish (#430)
1 change: 1 addition & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,7 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `AB4826` | error | A route's static `config` calls `appResourceUri('<app>')` with a reference that matches no App route of the route's own generated server with a static `config.resourceUri`: an unknown name, another server's App (a generated server registers only its own Apps), or a reference from a non-MCP route. The message names the cause and lists the server's known App route ids; reference the App as `'<app>'`, `'<server>/<app>'`, `'app:<server>/<app>'`, or a relative module path. |
| `AB4827` | error | An MCP App route's `config.template` is ambiguous or missing: both the route-relative and the project-root-relative interpretation name different existing files, or neither exists. The message names both candidate paths; templates resolve relative to the route module, so rewrite the path as `'./<file>.html'` beside the route. |
| `AB4828` | error | A generated MCP route advertises `_meta.ui.resourceUri` of an App on its server (through `appResourceUri()` or a literal) that is not built for every target the server ships to, because the App's `config.targets` (or a config-declared App's `targets`) is narrower. Widen the App's targets or restrict `mcp.servers.<server>.targets`. |
| `AB4829` | error | Two distinct MCP App routes of one generated server declare the same static `config.resourceUri`. The message names both route files and the server; a generated server registers one App per resource URI and never picks a side. The same URI on App routes of *different* servers is not a collision — each server registers only its own Apps. Give each App route of the server a distinct `config.resourceUri`, or remove the duplicate module. |
| `AB4830` | error | A conventional layout module (`src/layout.*`, `src/mcp/<server>/layout.*`) does not satisfy the layout contract: its default export is not a function component, it exports the route-only `config`/`inputSchema`/`resultSchema`, or it exports `execute`/`render`. Default-export one component receiving `{ children, route, signal }` that renders `Agent.Result` around `children`. |
| `AB4831` | error | Two layout modules declare one layout scope (for example `src/layout.ts` beside `src/layout.tsx`). Keep exactly one module per scope. |
| `AB4832` | error | A server layout (`src/mcp/<server>/layout.*`) names an MCP server that declares no tool, resource, or prompt route modules — the server directory is missing or holds only `apps/` routes, which never take a layout. Add routes under that server directory, move the layout, or rename it `_layout.*` to opt out. A server pinned to `custom`, `command`, or `remote` via `routes.servers.<server>` is skipped entirely: its layout is neither validated (`AB4830`) nor retained, because no generated worker composes it. |
Expand Down
2 changes: 1 addition & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `src/index.ts` | Library output with declarations. | `lib: false` |
| `src/mcp/<server-id>.ts` | Stdio entry for the declared MCP server `<server-id>` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly |
| `src/mcp/<server>/{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.<server>` to `custom`, `command`, or `remote` |
| `src/mcp/<server>/apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('<app>')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` |
| `src/mcp/<server>/apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required (`AB4812`), and two App routes of one server sharing a URI are `AB4829` (the same URI on different servers is not a collision). An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('<app>')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` |
| `src/scripts/<name>.ts` | Plain script compiled to `scripts/<name>.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry |
| `src/scripts/<name>.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/<name>.mjs` plus a `scripts/<name>-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry |
| `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/<plugin-name>.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). Nesting is identity: `src/cli/library/audit.ts` runs as `<bin> library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,17 @@ const stagingPublisherExited = (stagingEntry: string): boolean => {
return isErrno(error, 'ESRCH');
}
};
/**
* A staging entry this publisher would have written: `.<epoch>.stage-<pid>-<nonce>`
* (the nonce is hex, or `guard-<hex>` for a recovery guard).
*/
const stagingEntryPattern = /^\.(?<epoch>[a-z0-9][a-z0-9._-]*)\.stage-(?<pid>\d+)-[a-z0-9-]+$/iu;
/**
* @internal Bounds of one publication's orphan-staging sweep: at most
* `candidates` matching directory entries are examined and at most `removals`
* removed per publish; anything beyond waits for the next publication.
*/
export const nativePlaygroundStagingSweepLimits = Object.freeze({ candidates: 64, removals: 16 });
const maximumCatalogSnapshotNodes = 65_536;
const maximumFixtureEntries = 4_096;
const maximumSnapshotDepth = 16;
Expand Down Expand Up @@ -1284,6 +1295,7 @@ export class NativePlaygroundService {
{ cause: cleanupFailures[0] },
);
}
await this.#sweepAbandonedStaging(directory, reference.epoch.id);
const receipt = created && publicationIdentity !== undefined
? this.#publicationReceipt(path, publicationIdentity, true)
: await this.#snapshotReceipt(reference, false);
Expand All @@ -1300,6 +1312,53 @@ export class NativePlaygroundService {
}
}

/**
* A publisher that dies before its staging cleanup leaves its
* `.<epoch>.stage-<pid>-<nonce>` entry behind (#377). One that still aliases
* its epoch's sidecar is a publication the next reader of that epoch adopts
* (`#awaitStagedPublication`); one that aliases nothing — the publisher died
* before `link()`, lost the `EEXIST` race, or its sidecar was withdrawn
* later — is never read again and would otherwise accumulate forever.
*
* Each successful publication therefore sweeps its own catalog directory
* once: no recursion, at most `candidates` matching entries examined and
* `removals` removed. An entry goes only when it carries this publisher's
* naming pattern for an epoch other than the one being published, its
* embedded publisher pid has exited, and it is a singly linked regular file
* that does not alias its epoch's sidecar inode — a live winner's staging
* link, a guard, a foreign file, or anything ambiguous stays. The sweep is
* hygiene: it never changes the outcome of the publication that ran it, so
* any filesystem failure ends the sweep quietly instead of failing the epoch.
*/
async #sweepAbandonedStaging(directory: string, publishingEpochId: string): Promise<void> {
let entries: readonly string[];
try { entries = await readdir(directory); }
catch { return; }
const candidates: { readonly entry: string; readonly epochId: string }[] = [];
for (const entry of [...entries].sort()) {
if (candidates.length >= nativePlaygroundStagingSweepLimits.candidates) break;
const epochId = stagingEntryPattern.exec(entry)?.groups?.['epoch'];
if (epochId === undefined || epochId === publishingEpochId) continue;
candidates.push({ entry, epochId });
}
let removed = 0;
for (const { entry, epochId } of candidates) {
if (removed >= nativePlaygroundStagingSweepLimits.removals) return;
// Our own entries and those of any still-running publisher are theirs.
if (!stagingPublisherExited(entry)) continue;
const stagingPath = join(directory, entry);
try {
const staged = await lstat(stagingPath);
if (!staged.isFile() || staged.nlink !== 1) continue;
if (await this.#sidecarStillLinked(join(directory, `${epochId}.json`), staged)) continue;
await this.#catalogStorage.remove(stagingPath, { force: true });
removed += 1;
} catch (error) {
if (!isErrno(error, 'ENOENT')) return;
}
}
}

async #acceptedPublicationReceipt(reference: NativePlaygroundEpochReference): Promise<NativePlaygroundCatalogPublicationReceipt> {
return this.#snapshotReceipt(reference, false);
}
Expand Down
17 changes: 17 additions & 0 deletions packages/agent-bundle/src/routes/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,11 @@ export const compileRouteGraph = async (
));
}
if (mode === 'generated') {
// A generated server registers each App under its resourceUri, so two
// App routes of one server claiming the same URI would otherwise
// resolve first-wins (AB4829). The same URI on another server is a
// different registry and never collides here.
const appRoutesByResourceUri = new Map<string, CompiledAgentRoute>();
for (const route of routes) {
if (route.kind === 'app') {
const resourceUri = route.config['resourceUri'];
Expand All @@ -848,6 +853,18 @@ export const compileRouteGraph = async (
'Export const config with the App resourceUri, then inspect again.',
route.source,
));
} else {
const claimed = appRoutesByResourceUri.get(resourceUri);
if (claimed === undefined) {
appRoutesByResourceUri.set(resourceUri, route);
} else {
diagnostics.push(routeError(
'AB4829',
`MCP App routes ${claimed.provenance.relativePath} and ${route.provenance.relativePath} of MCP server ${JSON.stringify(name)} both declare config.resourceUri ${JSON.stringify(resourceUri)}; a generated server registers one App per resource URI and never chooses silently.`,
'Give each App route of the server a distinct config.resourceUri, or remove the duplicate route module, then inspect again.',
route.source,
));
}
}
const template = route.config['template'];
if (typeof template === 'string') {
Expand Down
Loading
Loading