From 6aa9a2724a0daf3786ff9bd86fd0fb618106b73c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 13:57:21 +0000 Subject: [PATCH 1/2] fix(adapters): expose notice delivery advertisements to the compiler and gate the notice routes on them - add a per-target noticeDelivery advertisement to TargetAdapter and the registry, with the delivery shape defined locally in the compiler (adapters/notice-delivery.ts) so the optional runtime peer never enters public declarations; a test asserts mutual assignability with @agent-bundle/runtime/notices - wire the mcp-inbox route only when the target advertises it as supported, and mcp-resource-updated only when both it and the inbox are advertised; thread noticeDelivery through entries, build, and inspect-bundler - consumer-facing changeset for agent-bundle --- .changeset/notice-delivery-adapter-surface.md | 5 + docs/entry-conventions.md | 29 +++-- .../src/adapters/capability-state.ts | 68 ++++++++++ packages/agent-bundle/src/adapters/claude.ts | 2 + packages/agent-bundle/src/adapters/codex.ts | 2 + packages/agent-bundle/src/adapters/cursor.ts | 2 + .../src/adapters/notice-delivery.ts | 26 ++++ packages/agent-bundle/src/adapters/plugin.ts | 7 + .../agent-bundle/src/adapters/portable.ts | 2 + .../agent-bundle/src/adapters/registry.ts | 47 ++++++- packages/agent-bundle/src/adapters/types.ts | 13 ++ packages/agent-bundle/src/api.ts | 17 ++- packages/agent-bundle/src/build/build.ts | 3 + packages/agent-bundle/src/build/entries.ts | 7 + .../agent-bundle/src/build/entry-shell.ts | 89 +++++++++---- .../agent-bundle/src/build/inspect-bundler.ts | 7 +- .../tests/adapter-capability-states.test.ts | 120 ++++++++++++++++++ .../agent-bundle/tests/entry-shell.test.ts | 97 ++++++++++++++ 18 files changed, 503 insertions(+), 40 deletions(-) create mode 100644 .changeset/notice-delivery-adapter-surface.md create mode 100644 packages/agent-bundle/src/adapters/notice-delivery.ts diff --git a/.changeset/notice-delivery-adapter-surface.md b/.changeset/notice-delivery-adapter-surface.md new file mode 100644 index 000000000..512a61fdb --- /dev/null +++ b/.changeset/notice-delivery-adapter-surface.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Gate the generated MCP server's notice routes on the target host's delivery advertisement. `TargetAdapter` gains the optional `noticeDelivery` field (typed by the new `NoticeDeliveryAdvertisement`, `NoticeDeliveryRoute`, and `NoticeDeliveryRouteState` exports, which resolve without the optional `@agent-bundle/runtime` peer), and `TargetRegistry` gains `noticeDelivery(target)`. The built-in `claude`, `codex`, `cursor`, and `portable` adapters advertise from their pinned capability tables and the `plugin` adapter advertises the three-host intersection; a JavaScript adapter declaring an unknown route state or an undated `unavailable` route is rejected at registration. `agent-bundle build` and `agent-bundle inspect --bundler` register the `agent-bundle://notices/inbox` resource only for hosts advertising `mcp-inbox`, and wire `resources/subscribe` plus `notifications/resources/updated` only where the host additionally advertises `mcp-resource-updated` and the state lifetime is workspace-durable. Built-in hosts all advertise `mcp-inbox`, so their artifacts are unchanged. (#412) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index bdeb18bd2..eb0d087c9 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -160,14 +160,27 @@ own `bin/` directory instead, like the MCP worker. Notice authorization is delib in generated mounting v1 (`authorized`); recipient/principal matching remains enforced by the ledger, while application authorization policy is deferred. -For workspace-durable state only, the generated MCP server process also opens -its own SQLite handle on the notice ledger (`createGeneratedNoticeRuntime` -over the same anchor) and advertises `resources.subscribe`: a client that -subscribes to `agent-bundle://notices/inbox` receives one -`notifications/resources/updated` after a render leaves it newly eligible -pending notices, recorded on the ledger as an availability receipt. Volatile -lifetimes keep the store in the worker's heap, so those servers register no -subscription handlers and advertise no subscribe capability. +Each cross-request notice route is selected from the target host's pinned +`noticeDelivery` table, exposed as `TargetAdapter.noticeDelivery` / +`TargetRegistry.noticeDelivery(target)` (a local `NoticeDeliveryAdvertisement` +shape, structurally identical to the runtime's so it types for +`selectNoticeDeliveryRoutes` without making the optional `@agent-bundle/runtime` +peer a declaration dependency); the unified `plugin` target advertises the +intersection of its three hosts, and a target with no advertisement wires no +cross-request route. The `agent-bundle://notices/inbox` resource is registered +in the server and mounted in its worker only for stateful projects whose host +advertises `mcp-inbox` (the worker still mounts the ledger so routes can +publish; only the unadvertised read surface is withheld, and the reserved name +stays reserved). For workspace-durable state only, and only when the host also +advertises `mcp-resource-updated`, the server process opens its own SQLite +handle on the notice ledger (`createGeneratedNoticeRuntime` over the same +anchor) and advertises `resources.subscribe`: a client that subscribes to the +inbox receives one `notifications/resources/updated` after a render leaves it +newly eligible pending notices, recorded on the ledger as an availability +receipt. Volatile lifetimes keep the store in the worker's heap, and a host +whose table marks the route unavailable has no consumer for the signal, so +those servers register no subscription handlers and advertise no subscribe +capability. #### State mutation budgets diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 4f1e75763..b692aad41 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -2,6 +2,12 @@ import { stableJson } from '../core/digest.ts'; import { CapabilityStateError, unknownCapabilityStateError } from '../core/capabilities.ts'; import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; import { featureCapabilityName } from '../core/components.ts'; +import { + NOTICE_DELIVERY_ROUTES, + type NoticeDeliveryAdvertisement, + type NoticeDeliveryRoute, + type NoticeDeliveryRouteState, +} from './notice-delivery.ts'; import type { TargetAdapterMetadata } from './types.ts'; export { featureCapabilityName } from '../core/components.ts'; @@ -80,6 +86,12 @@ export interface CapabilityTableRow { readonly state: string; } +export interface NoticeDeliveryCapabilityTableEntry { + readonly reason?: string; + /** JSON imports widen literals; unknown table states fail closed below. */ + readonly state: string; +} + /** * Converts one pinned table row into the shared capability-state namespace. * `supported` and `degraded` carry the adapter's pinned evidence identity; @@ -134,6 +146,62 @@ export const frontmatterFeatureCapabilitiesFrom = ( )); }; +/** + * Converts a pinned host table's `noticeDelivery` rows into the typed + * advertisement the notice router consumes (#99 stage 4). Every route in the + * taxonomy must be present and `unavailable` rows must carry their dated + * reason; a row the table does not know how to describe fails closed rather + * than becoming a fabricated channel. + */ +export const noticeDeliveryAdvertisementFrom = ( + target: string, + rows: Readonly>, +): NoticeDeliveryAdvertisement => { + const entries = NOTICE_DELIVERY_ROUTES.map((route): [NoticeDeliveryRoute, NoticeDeliveryRouteState] => { + const row = rows[route]; + if (row === undefined) { + throw new CapabilityStateError(`The pinned ${target} table advertises no notice delivery route ${route}.`); + } + switch (row.state) { + case 'supported': + return [route, Object.freeze({ state: 'supported' })]; + case 'unavailable': + if (typeof row.reason !== 'string' || row.reason.trim() === '') { + throw new CapabilityStateError( + `The pinned ${target} table marks notice delivery route ${route} unavailable without a dated reason.`, + ); + } + return [route, Object.freeze({ reason: row.reason, state: 'unavailable' })]; + default: + throw new CapabilityStateError( + `Unsupported notice delivery route state ${JSON.stringify(row.state)} for ${route} in the pinned ${target} table.`, + ); + } + }); + return Object.freeze(Object.fromEntries(entries)) as NoticeDeliveryAdvertisement; +}; + +/** + * Intersects host advertisements for a composite adapter: a route is + * supported only where every host supports it, and the dated reasons of the + * hosts that do not are kept so the composite stays as honest as its parts. + */ +export const intersectNoticeDeliveryAdvertisements = ( + left: NoticeDeliveryAdvertisement, + right: NoticeDeliveryAdvertisement, +): NoticeDeliveryAdvertisement => Object.freeze(Object.fromEntries( + NOTICE_DELIVERY_ROUTES.map((route): [NoticeDeliveryRoute, NoticeDeliveryRouteState] => { + const reasons = [left[route], right[route]] + .flatMap((entry) => (entry.state === 'unavailable' ? [entry.reason] : [])); + return reasons.length === 0 + ? [route, Object.freeze({ state: 'supported' })] + : [route, Object.freeze({ + reason: [...new Set(reasons)].sort((first, second) => first.localeCompare(second)).join('; '), + state: 'unavailable', + })]; + }), +)) as NoticeDeliveryAdvertisement; + export const capabilityStateFromSupport = ( supported: boolean, evidence: CapabilityEvidence, diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 55010d511..8c9816a4b 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -24,6 +24,7 @@ import { eventRouteCapabilitiesFrom, featureCapabilitiesFrom, frontmatterFeatureCapabilitiesFrom, + noticeDeliveryAdvertisementFrom, supportedEventRouteNamesFrom, cliBinCapability, supportedCapability, @@ -3485,6 +3486,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ metadata, mcpRuntime, name: claudeName, + noticeDelivery: noticeDeliveryAdvertisementFrom(claudeName, capabilityTable.noticeDelivery), binSource: (config: Readonly) => config.claude?.bin, nativeHookSource: (config: Readonly) => config.claude?.nativeHooks, outputStylesSource: (config: Readonly) => config.claude?.outputStyles, diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index e7061ec2b..d1a7a2401 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -21,6 +21,7 @@ import { capabilityStateFromSupport, eventRouteCapabilitiesFrom, featureCapabilitiesFrom, + noticeDeliveryAdvertisementFrom, supportedEventRouteNamesFrom, cliBinCapability, supportedCapability, @@ -1415,6 +1416,7 @@ export const codexAdapter: TargetAdapter = Object.freeze({ metadata, mcpRuntime, name: codexName, + noticeDelivery: noticeDeliveryAdvertisementFrom(codexName, capabilityTable.noticeDelivery), nativeHookSource: (config: Readonly) => config.codex?.nativeHooks, plan: planCodexArtifacts, }); diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 271acb988..e48007a1c 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -21,6 +21,7 @@ import { cliBinCapability, featureCapabilitiesFrom, frontmatterFeatureCapabilitiesFrom, + noticeDeliveryAdvertisementFrom, supportedEventRouteNamesFrom, supportedCapability, type CapabilityTableRow, @@ -725,5 +726,6 @@ export const cursorAdapter: TargetAdapter = Object.freeze({ metadata, mcpRuntime, name: cursorName, + noticeDelivery: noticeDeliveryAdvertisementFrom(cursorName, capabilityTable.noticeDelivery), plan: planCursorArtifacts, }); diff --git a/packages/agent-bundle/src/adapters/notice-delivery.ts b/packages/agent-bundle/src/adapters/notice-delivery.ts new file mode 100644 index 000000000..4ea9852d1 --- /dev/null +++ b/packages/agent-bundle/src/adapters/notice-delivery.ts @@ -0,0 +1,26 @@ +/** + * The #99 notice delivery route taxonomy, spelled in the compiler package so + * that public declarations such as `TargetAdapter` never resolve through + * `@agent-bundle/runtime`, which is an optional peer of `agent-bundle`. The + * shape is structurally identical to the runtime's + * `AgentNoticeDeliveryAdvertisement`; `adapter-capability-states.test.ts` + * asserts the two are mutually assignable so a vocabulary change on either + * side fails the build. + */ +export const NOTICE_DELIVERY_ROUTES = Object.freeze([ + 'current-response', + 'next-event', + 'mcp-inbox', + 'mcp-resource-updated', + 'directed-push', + 'host-toast', +] as const); + +export type NoticeDeliveryRoute = (typeof NOTICE_DELIVERY_ROUTES)[number]; + +export type NoticeDeliveryRouteState = + | { readonly state: 'supported' } + | { readonly reason: string; readonly state: 'unavailable' }; + +/** A host's honest, dated advertisement of which notice delivery routes it can carry. */ +export type NoticeDeliveryAdvertisement = Readonly>; diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 097aeb3be..335559115 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -12,6 +12,7 @@ import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; import { cliBinCapability, intersectCapabilityStates, + intersectNoticeDeliveryAdvertisements, supportedEventRouteNamesFrom, unavailableCapability, unionCapabilityStates, @@ -1126,6 +1127,12 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ metadata, mcpRuntime, name: pluginName, + // A unified bundle's generated MCP entry serves all three hosts, so it may + // only wire the cross-request routes every pinned host advertises. + noticeDelivery: intersectNoticeDeliveryAdvertisements( + intersectNoticeDeliveryAdvertisements(claudeAdapter.noticeDelivery!, codexAdapter.noticeDelivery!), + cursorAdapter.noticeDelivery!, + ), binSource: (config: Readonly) => config.claude?.bin, outputStylesSource: (config: Readonly) => config.claude?.outputStyles, plan, diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index 615c46319..a712fb2e0 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -20,6 +20,7 @@ import { capabilityStateFromSupport, cliBinCapability, eventRouteCapabilitiesFrom, + noticeDeliveryAdvertisementFrom, supportedCapability, featureCapabilitiesFrom, unavailableCapability, @@ -669,5 +670,6 @@ export const portableAdapter: TargetAdapter = Object.freeze({ metadata, mcpRuntime, name: portableName, + noticeDelivery: noticeDeliveryAdvertisementFrom(portableName, capabilityTable.noticeDelivery), plan, }); diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index b02eecd37..c4000cd2e 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -9,7 +9,12 @@ import type { NormalizationNativeHookSource, NormalizationTargetRegistry, } from '../core/types.ts'; -import { capabilityIsSupported, cliBinCapability } from './capability-state.ts'; +import { + capabilityIsSupported, + cliBinCapability, + noticeDeliveryAdvertisementFrom, + type NoticeDeliveryCapabilityTableEntry, +} from './capability-state.ts'; import { claudeAdapter } from './claude.ts'; import { codexAdapter } from './codex.ts'; import { cursorAdapter } from './cursor.ts'; @@ -30,6 +35,7 @@ import { } from './types.ts'; import type { TargetMcpRuntimeContract } from '../services/mcp-runtime.ts'; import { deepFreeze } from '../core/freeze.ts'; +import type { NoticeDeliveryAdvertisement } from './notice-delivery.ts'; const sha256Pattern = /^[0-9a-f]{64}$/; @@ -445,6 +451,32 @@ const snapshotMcpRuntime = (adapter: TargetAdapter): TargetMcpRuntimeContract | }); }; +/** + * Re-validates a declared notice delivery advertisement at the registry + * boundary so a JavaScript adapter cannot smuggle an unknown route state into + * the generated MCP entry's route selection. + */ +const snapshotNoticeDelivery = (adapter: TargetAdapter): NoticeDeliveryAdvertisement | undefined => { + const declared = adapter.noticeDelivery; + if (declared === undefined) return undefined; + const rows = record(declared); + if (rows === undefined) { + throw new CapabilityStateError( + `Target adapter "${adapter.name}" must declare notice delivery advertisements as a record.`, + ); + } + const entries = Object.fromEntries(Object.entries(rows).map(([route, entry]): [string, NoticeDeliveryCapabilityTableEntry] => { + const row = record(entry); + if (row === undefined || typeof row.state !== 'string') { + throw new CapabilityStateError( + `Target adapter "${adapter.name}" notice delivery route "${route}" must declare a state.`, + ); + } + return [route, { ...(typeof row.reason === 'string' ? { reason: row.reason } : {}), state: row.state }]; + })); + return noticeDeliveryAdvertisementFrom(adapter.name, entries); +}; + /** * The registry is a runtime boundary for third-party and JavaScript adapters, * whose declarations the compiler never checked. Rejecting a malformed state @@ -491,6 +523,7 @@ export class TargetRegistry implements NormalizationTargetRegistry { readonly #metadata = new Map(); readonly #mcpRuntimes = new Map(); readonly #nativeHookSources = new Map(); + readonly #noticeDeliveries = new Map(); readonly #outputStylesSources = new Map(); readonly #workflowsSources = new Map(); @@ -513,6 +546,7 @@ export class TargetRegistry implements NormalizationTargetRegistry { const mcpRuntime = snapshotMcpRuntime(adapter); const artifactLayout = snapshotArtifactLayout(adapter, hookContract, mcpRuntime); const lowersConfigExtensions = snapshotLowersConfigExtensions(adapter); + const noticeDelivery = snapshotNoticeDelivery(adapter); this.#adapters.set(adapter.name, adapter); this.#lowersConfigExtensions.set(adapter.name, lowersConfigExtensions); @@ -543,6 +577,9 @@ export class TargetRegistry implements NormalizationTargetRegistry { if (mcpRuntime !== undefined) { this.#mcpRuntimes.set(adapter.name, mcpRuntime); } + if (noticeDelivery !== undefined) { + this.#noticeDeliveries.set(adapter.name, noticeDelivery); + } if (options.default === true) { this.#defaults.push(adapter.name); } @@ -601,6 +638,14 @@ export class TargetRegistry implements NormalizationTargetRegistry { return this.#mcpRuntimes.get(name); } + /** The validated notice delivery advertisement, or undefined for a host that declares none. */ + noticeDelivery(name: string): NoticeDeliveryAdvertisement | undefined { + if (!this.#adapters.has(name)) { + throw new Error(`Unknown target adapter "${name}".`); + } + return this.#noticeDeliveries.get(name); + } + configExtensions(): readonly NormalizationConfigExtension[] { return Object.freeze([...this.#extensions.values()]); } diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 57344e369..732e94b26 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -14,11 +14,17 @@ import { type NormalizedPlugin, } from '../core/types.ts'; import type { TargetHookContract, TargetHookEntry } from './hook-contract.ts'; +import type { NoticeDeliveryAdvertisement } from './notice-delivery.ts'; import type { TargetMcpRuntimeContract } from '../services/mcp-runtime.ts'; import { deepFreeze } from '../core/freeze.ts'; export type { TargetHookEntry, TargetHookWrapper } from './hook-contract.ts'; +export type { + NoticeDeliveryAdvertisement, + NoticeDeliveryRoute, + NoticeDeliveryRouteState, +} from './notice-delivery.ts'; export interface TargetArtifactWrite { readonly content: string; @@ -537,6 +543,13 @@ export interface TargetAdapter { readonly metadata: TargetAdapterMetadata; readonly mcpRuntime?: TargetMcpRuntimeContract; readonly name: string; + /** + * The host's per-route notice delivery advertisement (#99 stage 4), read + * from its pinned capability table. The generated MCP entry selects its + * cross-request delivery routes from this; an adapter that declares none + * advertises no cross-request route and its artifacts wire none. + */ + readonly noticeDelivery?: NoticeDeliveryAdvertisement; binSource?(config: Readonly): string | undefined; nativeHookSource?(config: Readonly): string | undefined; outputStylesSource?(config: Readonly): string | undefined; diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index c88cf67c8..7d6f4c04f 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -198,6 +198,9 @@ export type { HookListOptions, HookSimulationOptions } from './services/hook-ser export { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; export { CapabilityStateError, capabilityStateNames, isCapabilityState } from './core/capabilities.ts'; export type { + NoticeDeliveryAdvertisement, + NoticeDeliveryRoute, + NoticeDeliveryRouteState, TargetAdapter, TargetAdapterMetadata, TargetArtifactCopy, @@ -948,11 +951,15 @@ export const inspect = async (options: InspectOptions): Promise = try { bundler = await composeBundlerInspection({ model, - targets: plans.map((plan) => ({ - cliBin: targetHostsCliBin(prepared.registry, plan.target), - hookEntries: plan.hookEntries, - name: plan.target, - })), + targets: plans.map((plan) => { + const noticeDelivery = prepared.registry.noticeDelivery(plan.target); + return { + cliBin: targetHostsCliBin(prepared.registry, plan.target), + hookEntries: plan.hookEntries, + name: plan.target, + ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + }; + }), ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); } catch { diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 64e6430e1..490f71e23 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -419,10 +419,12 @@ export const build = async (options: BuildOptions): Promise => { }, )), ); + const noticeDelivery = options.registry.noticeDelivery(target.name); compiledHooks.push(...(await compileHooks(target.hookEntries, { artifactEpoch: options.projectContext.revision, cwd: options.projectRoot, meta, + ...(noticeDelivery === undefined ? {} : { noticeDelivery }), outDir: target.root, plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, providers: options.model.providers ?? [], @@ -438,6 +440,7 @@ export const build = async (options: BuildOptions): Promise => { .map((entry) => entry.hook), layouts: options.model.layouts ?? [], meta, + ...(noticeDelivery === undefined ? {} : { noticeDelivery }), outDir: target.root, plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, providers: options.model.providers ?? [], diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index d914e303d..94442f57f 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -3,6 +3,7 @@ import { readFile, stat } from 'node:fs/promises'; import { basename, dirname, extname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import { eventArtifactEpochToken, eventFlightArtifactEpochToken, @@ -322,6 +323,8 @@ export const compileMcpEntries = async ( readonly eventHooks: readonly NormalizedHook[]; readonly layouts?: readonly CompiledLayout[]; readonly meta: AgentBundleMeta; + /** The target adapter's notice delivery advertisement; absent wires no cross-request route. */ + readonly noticeDelivery?: NoticeDeliveryAdvertisement; readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; readonly providers?: readonly CompiledProvider[]; @@ -358,6 +361,7 @@ export const compileMcpEntries = async ( : generatedRouteMcpEntrySource({ artifactEpoch: options.artifactEpoch, eventRoutes: entry.id === eventHostId ? options.eventHooks : [], + ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), plugin: options.plugin, routes: server.generatedRoutes, serverName: server.name, @@ -374,6 +378,7 @@ export const compileMcpEntries = async ( artifactEpoch: generatedRouteArtifactEpoch(options.plugin), eventRoutes: entry.id === eventHostId ? options.eventHooks : [], layouts: options.layouts ?? [], + ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), providers: options.providers ?? [], routes: server.generatedRoutes, serverName: server.name, @@ -516,6 +521,7 @@ export const compileHooks = async ( readonly artifactEpoch: string; readonly cwd: string; readonly meta: AgentBundleMeta; + readonly noticeDelivery?: NoticeDeliveryAdvertisement; readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; readonly providers?: readonly CompiledProvider[]; @@ -550,6 +556,7 @@ export const compileHooks = async ( virtualSource: generatedRouteFlightWorkerSource({ artifactEpoch: workerArtifactEpoch, eventRoutes: standaloneEventRoutes, + ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), providers: options.providers ?? [], routes: [], serverName: 'hooks', diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 280a7276b..dac745e44 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts'; +import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import { stableJson } from '../core/digest.ts'; import type { NormalizedHook, NormalizedStateDefinition } from '../core/types.ts'; import { orderedProviders } from '../routes/provider-execution.ts'; @@ -592,6 +593,13 @@ export const generatedRenderedScriptEntrySource = ( export interface GeneratedRouteMcpEntryOptions { readonly artifactEpoch?: string; readonly eventRoutes?: readonly NormalizedHook[]; + /** + * The target host's notice delivery advertisement (`TargetAdapter.noticeDelivery`). + * Cross-request routes are wired only where the host advertises them; an + * absent advertisement wires none, so a target the registry knows nothing + * about never receives a fabricated channel. + */ + readonly noticeDelivery?: NoticeDeliveryAdvertisement; readonly plugin: { readonly name: string; readonly version: string }; readonly routes: readonly CompiledAgentRoute[]; readonly serverName: string; @@ -604,6 +612,8 @@ export interface GeneratedRouteFlightWorkerOptions { readonly artifactEpoch: string; readonly eventRoutes?: readonly NormalizedHook[]; readonly layouts?: readonly CompiledLayout[]; + /** The target host's advertisement; the worker mounts the inbox route only where it is advertised. */ + readonly noticeDelivery?: NoticeDeliveryAdvertisement; readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; readonly serverName: string; @@ -639,26 +649,52 @@ const routeRecords = ( return ` ${JSON.stringify(route.id)}: Object.freeze({ config: ${stableJson(route.config)}, id: ${JSON.stringify(route.id)}, kind: ${JSON.stringify(route.kind)}${layoutFields}, module: route${String(index)}, name: ${JSON.stringify(routeProtocolName(route))}${serverField} }),`; }); -const noticeInboxImport = (state: NormalizedStateDefinition | undefined): readonly string[] => - state === undefined - ? [] - : [`import * as noticeInboxRoute from ${JSON.stringify(noticeInboxRuntimeSpecifier)};`]; +const noticeInboxImport = (wired: boolean): readonly string[] => + wired + ? [`import * as noticeInboxRoute from ${JSON.stringify(noticeInboxRuntimeSpecifier)};`] + : []; -const noticeInboxRecord = (state: NormalizedStateDefinition | undefined): readonly string[] => - state === undefined - ? [] - : [' [noticeInboxRoute.AGENT_NOTICE_INBOX_ROUTE_ID]: noticeInboxRoute.noticeInboxRouteRecord(noticeInboxRoute),']; +const noticeInboxRecord = (wired: boolean): readonly string[] => + wired + ? [' [noticeInboxRoute.AGENT_NOTICE_INBOX_ROUTE_ID]: noticeInboxRoute.noticeInboxRouteRecord(noticeInboxRoute),'] + : []; + +interface NoticeRouteSelection { + readonly noticeDelivery?: NoticeDeliveryAdvertisement; + readonly state?: NormalizedStateDefinition; +} + +/** + * Whether the generated artifact exposes the `mcp-inbox` route (#99 stage 3): + * the cross-request inbox resource needs a mounted notice ledger, so the + * project must declare state, and the target host must advertise the route + * in its pinned capability table. Each route is selected from its own + * advertised state; a host whose table marks it unavailable, or a target with + * no advertisement at all, exposes nothing it cannot honestly carry. + */ +const wiresInboxRoute = (options: NoticeRouteSelection): boolean => + options.state !== undefined && options.noticeDelivery?.['mcp-inbox'].state === 'supported'; + +/** + * Whether the generated entry wires the `mcp-resource-updated` delivery route + * (#99 stage 4). It signals about the inbox resource, so the inbox must be + * exposed; the host must advertise the route itself; and the project's state + * must be workspace-durable. SQLite is the one lifetime two threads can + * share, so only durable artifacts can give the server process its own handle + * on the notice store its Flight worker mounts; volatile lifetimes live in + * the worker's heap and honestly advertise no subscription capability. + */ +const wiresResourceUpdatedRoute = (options: NoticeRouteSelection): boolean => + wiresInboxRoute(options) && + options.state?.lifetime === 'workspace-durable' && + options.noticeDelivery?.['mcp-resource-updated'].state === 'supported'; /** - * The server process's own handle on the workspace-durable notice store its - * Flight worker mounts (#99 stage 4): SQLite is the one lifetime two threads - * can share, so only durable artifacts wire `resources/subscribe` and - * `notifications/resources/updated` for the inbox; volatile lifetimes live in - * the worker's heap and honestly advertise no subscription capability. The - * anchor resolution matches the worker's so both open the same files. + * The server process's own handle on the durable notice store. The anchor + * resolution matches the worker's so both open the same files. */ -const noticeDeliveryImports = (state: NormalizedStateDefinition | undefined): readonly string[] => - state?.lifetime === 'workspace-durable' +const noticeDeliveryImports = (wired: boolean): readonly string[] => + wired ? [ "import { join } from 'node:path';", "import { fileURLToPath } from 'node:url';", @@ -668,8 +704,8 @@ const noticeDeliveryImports = (state: NormalizedStateDefinition | undefined): re ] : []; -const noticeDeliveryOwner = (state: NormalizedStateDefinition | undefined): readonly string[] => - state?.lifetime === 'workspace-durable' +const noticeDeliveryOwner = (wired: boolean): readonly string[] => + wired ? [ "const durableAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));", "const noticeDelivery = createNoticeInboxSignaller({ store: createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }), lifetime: 'workspace-durable' }) });", @@ -764,13 +800,14 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo const eventRoutes = options.eventRoutes ?? []; const providers = orderedProviders(options.providers ?? []); const layouts = workerLayouts(options.layouts ?? [], routes); + const wiresInbox = wiresInboxRoute(options); return [ "import { parentPort } from 'node:worker_threads';", "import { createElement } from 'react';", "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", "import { runAgentRequest } from '@agent-bundle/runtime';", ...generatedStateImports(options.state, 'artifact'), - ...noticeInboxImport(options.state), + ...noticeInboxImport(wiresInbox), ...routeImports(routes), ...eventRouteImports(eventRoutes, routes.length), ...providerImports(providers), @@ -787,7 +824,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...composeLayoutsSource(layouts), 'const routes = Object.freeze({', ...routeRecords(routes, { layouts }), - ...noticeInboxRecord(options.state), + ...noticeInboxRecord(wiresInbox), ...eventRouteRecords(eventRoutes, routes.length), '});', 'const requests = new Map();', @@ -910,6 +947,8 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti const allowedEventTargets = eventTarget === 'plugin' ? ['claude', 'codex', 'cursor'] : [eventTarget]; + const wiresInbox = wiresInboxRoute(options); + const wiresResourceUpdated = wiresResourceUpdatedRoute(options); return [ ...(hasEvents ? ["import { dirname, resolve } from 'node:path';"] : []), `import { createFlightWorkerHost, createGeneratedRouteMcpServer } from ${JSON.stringify(mcpServerRuntimeSpecifier)};`, @@ -920,17 +959,17 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ] : []), "import mcpApps from 'agent-bundle/mcp-apps';", - ...noticeDeliveryImports(options.state), - ...noticeInboxImport(options.state), + ...noticeDeliveryImports(wiresResourceUpdated), + ...noticeInboxImport(wiresInbox), ...routeImports(routes), '', `const ARTIFACT_EPOCH = ${JSON.stringify(artifactEpoch)};`, 'const routes = Object.freeze({', ...routeRecords(routes), - ...noticeInboxRecord(options.state), + ...noticeInboxRecord(wiresInbox), '});', '', - ...noticeDeliveryOwner(options.state), + ...noticeDeliveryOwner(wiresResourceUpdated), ...(hasEvents ? [ // The endpoint identity is artifact-location dependent, so it stays @@ -955,7 +994,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ' artifactEpoch: ARTIFACT_EPOCH,', ...(hasEvents ? [' events,'] : []), ` host: createFlightWorkerHost(new URL(${JSON.stringify(`./${options.workerFile}`)}, import.meta.url), ARTIFACT_EPOCH),`, - ...(options.state?.lifetime === 'workspace-durable' ? [' notices: noticeDelivery,'] : []), + ...(wiresResourceUpdated ? [' notices: noticeDelivery,'] : []), ` plugin: ${stableJson(options.plugin)},`, ' routes,', '});', diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 7756a44c3..8c5126261 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -1,3 +1,4 @@ +import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import type { TargetHookEntry } from '../adapters/types.ts'; import { isPlainRecord } from '../core/strict-json.ts'; import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; @@ -184,6 +185,7 @@ const mcpEntryEntries = async ( model: NormalizedPlugin, target: string, tools: AgentBundleToolsConfig | undefined, + noticeDelivery: NoticeDeliveryAdvertisement | undefined, ): Promise => { const meta = projectMeta(model.metadata); const outputRoot = artifactOutputToken(target); @@ -198,6 +200,7 @@ const mcpEntryEntries = async ( const routeSource = generatedRoutes === undefined ? undefined : generatedRouteMcpEntrySource({ + ...(noticeDelivery === undefined ? {} : { noticeDelivery }), plugin: { name: model.metadata.name, version: model.metadata.version }, routes: generatedRoutes, serverName, @@ -252,6 +255,7 @@ const mcpEntryEntries = async ( virtualSource: generatedRouteFlightWorkerSource({ artifactEpoch: generatedRouteArtifactEpoch({ name: model.metadata.name, version: model.metadata.version }), layouts: model.layouts ?? [], + ...(noticeDelivery === undefined ? {} : { noticeDelivery }), providers: model.providers ?? [], routes: generatedRoutes, serverName, @@ -364,6 +368,7 @@ export const composeBundlerInspection = async (options: { readonly cliBin?: boolean; readonly hookEntries: readonly TargetHookEntry[]; readonly name: string; + readonly noticeDelivery?: NoticeDeliveryAdvertisement; }[]; readonly tools?: AgentBundleToolsConfig; }): Promise => { @@ -373,7 +378,7 @@ export const composeBundlerInspection = async (options: { entries.push( ...(target.cliBin === true ? cliBinEntries(options.model, target.name, options.tools) : []), ...(await scriptEntries(options.model, target.name, options.tools)), - ...(await mcpEntryEntries(options.model, target.name, options.tools)), + ...(await mcpEntryEntries(options.model, target.name, options.tools, target.noticeDelivery)), ...hookEntries(target.hookEntries, meta, target.name, options.tools), ...mcpAppsEntry(options.model, target.name, options.tools), ); diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 9031281b5..99f795235 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -1,10 +1,15 @@ import { expect, it } from '@rstest/core'; +import { AGENT_NOTICE_DELIVERY_ROUTES, selectNoticeDeliveryRoutes } from '@agent-bundle/runtime/notices'; +import type { AgentNoticeDeliveryAdvertisement, AgentNoticeDeliveryRoute } from '@agent-bundle/runtime/notices'; + import { capabilityBooleanView, capabilityEvidence, capabilityIsSupported, intersectCapabilityStates, + intersectNoticeDeliveryAdvertisements, + noticeDeliveryAdvertisementFrom, supportedCapability, unavailableCapability, unionCapabilityStates, @@ -14,6 +19,8 @@ import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.jso import cursorCapabilityTable from '../src/adapters/capabilities/cursor-2026-08-28.json' with { type: 'json' }; import cursorHooksSchema from '../src/adapters/schemas/cursor/hooks.schema.json' with { type: 'json' }; import { cursorContractCapabilityRows } from '../src/adapters/cursor.ts'; +import { NOTICE_DELIVERY_ROUTES } from '../src/adapters/notice-delivery.ts'; +import type { NoticeDeliveryAdvertisement, NoticeDeliveryRoute } from '../src/adapters/notice-delivery.ts'; import { TargetRegistry, createDefaultRegistry } from '../src/adapters/registry.ts'; import { CapabilityStateError, isCapabilityState } from '../src/core/capabilities.ts'; import type { CapabilityEvidence, CapabilityState } from '../src/core/capabilities.ts'; @@ -1498,3 +1505,116 @@ it('records dated Cursor contract rows and mirrors every one through the unified skills: './skills/', }); }); + +it('exposes each host advertisement through the adapter and registry, typed for the route selector (#99 stage 4)', () => { + const registry = createDefaultRegistry(); + const tables = { + claude: claudeCapabilityTable.noticeDelivery, + codex: codexCapabilityTable.noticeDelivery, + cursor: cursorCapabilityTable.noticeDelivery, + } as const; + for (const [host, rows] of Object.entries(tables)) { + const adapter = registry.get(host); + expect(adapter.noticeDelivery).toEqual(rows); + expect(registry.noticeDelivery(host)).toEqual(adapter.noticeDelivery); + expect(Object.isFrozen(adapter.noticeDelivery)).toBe(true); + // The advertisement feeds the runtime selector unchanged: every pinned host + // runs the inbox, resources/updated, and next-event routes, and nothing else. + expect(selectNoticeDeliveryRoutes(adapter.noticeDelivery!)).toEqual({ + kind: 'selected', + routes: ['mcp-resource-updated', 'mcp-inbox', 'next-event'], + }); + } + // Hookless portable loses the hook-borne route and keeps the MCP ones. + expect(selectNoticeDeliveryRoutes(registry.noticeDelivery('portable')!)).toEqual({ + kind: 'selected', + routes: ['mcp-resource-updated', 'mcp-inbox'], + }); + // The unified bundle serves all three hosts, so it advertises their intersection. + const plugin = registry.noticeDelivery('plugin')!; + expect(plugin).toEqual(intersectNoticeDeliveryAdvertisements( + intersectNoticeDeliveryAdvertisements(registry.noticeDelivery('claude')!, registry.noticeDelivery('codex')!), + registry.noticeDelivery('cursor')!, + )); + expect(selectNoticeDeliveryRoutes(plugin)).toEqual({ + kind: 'selected', + routes: ['mcp-resource-updated', 'mcp-inbox', 'next-event'], + }); + expect(() => registry.noticeDelivery('unknown')).toThrow(/Unknown target adapter/u); +}); + +it('spells the notice delivery taxonomy locally so public declarations never resolve through the optional runtime peer', () => { + // `@agent-bundle/runtime` is an optional peer of `agent-bundle`, so the + // compiler's exported `TargetAdapter.noticeDelivery` uses a local shape. These + // assignments fail to compile if either vocabulary drifts from the other. + const toRuntime = (value: NoticeDeliveryAdvertisement): AgentNoticeDeliveryAdvertisement => value; + const fromRuntime = (value: AgentNoticeDeliveryAdvertisement): NoticeDeliveryAdvertisement => value; + const routeToRuntime = (route: NoticeDeliveryRoute): AgentNoticeDeliveryRoute => route; + const routeFromRuntime = (route: AgentNoticeDeliveryRoute): NoticeDeliveryRoute => route; + const claude = createDefaultRegistry().noticeDelivery('claude')!; + expect(fromRuntime(toRuntime(claude))).toBe(claude); + expect([...NOTICE_DELIVERY_ROUTES].map(routeToRuntime).toSorted()) + .toEqual([...AGENT_NOTICE_DELIVERY_ROUTES].map(routeFromRuntime).toSorted()); +}); + +it('intersects host advertisements so a composite only claims routes every host supports', () => { + const claude = createDefaultRegistry().noticeDelivery('claude')!; + const partial: AgentNoticeDeliveryAdvertisement = Object.freeze({ + ...claude, + 'mcp-resource-updated': Object.freeze({ reason: '2026-09-02: host B drops resources/updated.', state: 'unavailable' as const }), + 'next-event': Object.freeze({ reason: '2026-09-02: host B has no hooks.', state: 'unavailable' as const }), + }); + const merged = intersectNoticeDeliveryAdvertisements(claude, partial); + expect(merged['mcp-inbox']).toEqual({ state: 'supported' }); + expect(merged['mcp-resource-updated']).toEqual({ reason: '2026-09-02: host B drops resources/updated.', state: 'unavailable' }); + expect(merged['next-event']).toEqual({ reason: '2026-09-02: host B has no hooks.', state: 'unavailable' }); + // Both reasons survive, deduplicated and ordered, when both hosts decline. + expect(merged['directed-push']).toEqual(claude['directed-push']); + const twice = intersectNoticeDeliveryAdvertisements(partial, Object.freeze({ + ...claude, + 'next-event': Object.freeze({ reason: '2026-09-02: host C has no hooks.', state: 'unavailable' as const }), + })); + expect(twice['next-event']).toEqual({ + reason: '2026-09-02: host B has no hooks.; 2026-09-02: host C has no hooks.', + state: 'unavailable', + }); + expect(selectNoticeDeliveryRoutes(merged)).toEqual({ kind: 'selected', routes: ['mcp-inbox'] }); +}); + +it('fails closed on a notice delivery table row it cannot describe honestly', () => { + const rows = { ...claudeCapabilityTable.noticeDelivery } as Record; + expect(noticeDeliveryAdvertisementFrom('claude', rows)).toEqual(claudeCapabilityTable.noticeDelivery); + expect(() => noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'mcp-inbox': { state: 'suported' } })) + .toThrow(/Unsupported notice delivery route state "suported" for mcp-inbox/u); + expect(() => noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'host-toast': { state: 'unavailable' } })) + .toThrow(/host-toast unavailable without a dated reason/u); + expect(() => noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'host-toast': { reason: ' ', state: 'unavailable' } })) + .toThrow(CapabilityStateError); + const { 'directed-push': _omitted, ...missing } = rows; + expect(() => noticeDeliveryAdvertisementFrom('fixture', missing)) + .toThrow(/advertises no notice delivery route directed-push/u); + // Degraded and prohibited are capability states, not delivery-route states. + expect(() => noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'mcp-inbox': { reason: 'x', state: 'degraded' } })) + .toThrow(CapabilityStateError); +}); + +it('re-validates a JavaScript adapter advertisement at the registry boundary', () => { + const source = createDefaultRegistry().get('cursor'); + const asAdvertisement = (value: unknown): AgentNoticeDeliveryAdvertisement => value as AgentNoticeDeliveryAdvertisement; + + expect(() => new TargetRegistry().register({ + ...source, + noticeDelivery: asAdvertisement({ ...source.noticeDelivery, 'mcp-inbox': { state: 'suported' } }), + })).toThrow(CapabilityStateError); + expect(() => new TargetRegistry().register({ + ...source, + noticeDelivery: asAdvertisement({ ...source.noticeDelivery, 'mcp-inbox': 'supported' }), + })).toThrow(/notice delivery route "mcp-inbox" must declare a state/u); + expect(() => new TargetRegistry().register({ ...source, noticeDelivery: asAdvertisement('supported') })) + .toThrow(/must declare notice delivery advertisements as a record/u); + const { noticeDelivery: _declared, ...undeclared } = source; + const registry = new TargetRegistry().register(undeclared); + // An adapter that declares no advertisement honestly has no cross-request route. + expect(registry.noticeDelivery('cursor')).toBeUndefined(); + expect(() => new TargetRegistry().register(source)).not.toThrow(); +}); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 06180023d..afe5437ff 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -6,6 +6,8 @@ import { promisify } from 'node:util'; import { describe, expect, it } from '@rstest/core'; import ts from 'typescript-5'; +import { claudeAdapter } from '../src/adapters/claude.ts'; +import type { NoticeDeliveryAdvertisement } from '../src/adapters/notice-delivery.ts'; import { scanEntryExportsSource, stripCommentsAndStrings } from '../src/build/entry-exports.ts'; import * as entryShellModule from '../src/build/entry-shell.ts'; import { @@ -927,6 +929,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol const volatile = entryShellModule.generatedRouteFlightWorkerSource({ ...base, + noticeDelivery: claudeAdapter.noticeDelivery!, state: state('process'), }); expect(volatile).toContain('import stateDefinition from "/project/src/state.ts"'); @@ -949,6 +952,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(statelessEntry).not.toContain('@agent-bundle/runtime/notices/inbox-route'); expect(statelessEntry).not.toContain('agent-bundle:notice-inbox'); const volatileEntry = entryShellModule.generatedRouteMcpEntrySource({ + noticeDelivery: claudeAdapter.noticeDelivery!, plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [route], serverName: 'curator', @@ -972,6 +976,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol } const durableEntry = entryShellModule.generatedRouteMcpEntrySource({ + noticeDelivery: claudeAdapter.noticeDelivery!, plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [route], serverName: 'curator', @@ -988,8 +993,100 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(durableEntry).not.toContain('import stateDefinition from'); expect(durableEntry).not.toContain('createGeneratedRuntimeState'); + // Each route is selected from its own advertised state: a durable store alone + // is not enough. A host whose pinned table marks `mcp-resource-updated` + // unavailable keeps the inbox but wires no subscription signal. + const withoutResourceUpdated: NoticeDeliveryAdvertisement = Object.freeze({ + ...claudeAdapter.noticeDelivery!, + 'mcp-resource-updated': Object.freeze({ + reason: '2026-09-02: fixture host does not forward resources/updated.', + state: 'unavailable' as const, + }), + }); + const unsupportedEntry = entryShellModule.generatedRouteMcpEntrySource({ + noticeDelivery: withoutResourceUpdated, + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [route], + serverName: 'curator', + state: state('workspace-durable'), + workerFile: 'mcp-curator-flight.mjs', + }); + expect(unsupportedEntry).toContain('noticeInboxRoute.noticeInboxRouteRecord(noticeInboxRoute)'); + for (const identifier of [ + 'createGeneratedNoticeRuntime', + 'createNoticeInboxSignaller', + '@agent-bundle/runtime/state/sqlite', + 'notices: noticeDelivery', + ]) { + expect(unsupportedEntry).not.toContain(identifier); + } + + // The inbox is a route of its own: a host that marks `mcp-inbox` unavailable, + // or a target with no advertisement at all, exposes no inbox resource — and + // therefore no subscription signal about it — in the server or its worker, + // however durable the store is. + const withoutInbox: NoticeDeliveryAdvertisement = Object.freeze({ + ...claudeAdapter.noticeDelivery!, + 'mcp-inbox': Object.freeze({ + reason: '2026-09-02: fixture host does not list MCP resources.', + state: 'unavailable' as const, + }), + }); + const unadvertisedEntry = entryShellModule.generatedRouteMcpEntrySource({ + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [route], + serverName: 'curator', + state: state('workspace-durable'), + workerFile: 'mcp-curator-flight.mjs', + }); + const noInboxEntry = entryShellModule.generatedRouteMcpEntrySource({ + noticeDelivery: withoutInbox, + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [route], + serverName: 'curator', + state: state('workspace-durable'), + workerFile: 'mcp-curator-flight.mjs', + }); + const unadvertisedWorker = entryShellModule.generatedRouteFlightWorkerSource({ + ...base, + state: state('workspace-durable'), + }); + const noInboxWorker = entryShellModule.generatedRouteFlightWorkerSource({ + ...base, + noticeDelivery: withoutInbox, + state: state('workspace-durable'), + }); + for (const generated of [unadvertisedEntry, noInboxEntry, unadvertisedWorker, noInboxWorker]) { + for (const identifier of [ + '@agent-bundle/runtime/notices/inbox-route', + 'noticeInboxRoute', + 'createGeneratedNoticeRuntime', + 'createNoticeInboxSignaller', + 'notices: noticeDelivery', + ]) { + expect(generated).not.toContain(identifier); + } + } + // The worker still mounts the durable ledger (routes publish into it); only + // the unadvertised read surface is withheld. + for (const generated of [unadvertisedWorker, noInboxWorker]) { + expect(generated).toContain('noticeLedger'); + expect(generated).toContain('createSqliteStateDriver'); + } + // The reserved inbox name stays reserved so a host that later advertises the + // route cannot collide with an authored one. + expect(() => entryShellModule.generatedRouteMcpEntrySource({ + noticeDelivery: withoutInbox, + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [{ ...route, config: { uri: 'agent-bundle://notices/inbox' }, id: 'resource:curator/inbox', kind: 'resource' }], + serverName: 'curator', + state: state('workspace-durable'), + workerFile: 'mcp-curator-flight.mjs', + })).toThrow(/reserved URI/u); + const durable = entryShellModule.generatedRouteFlightWorkerSource({ ...base, + noticeDelivery: claudeAdapter.noticeDelivery!, state: state('workspace-durable'), }); expect(durable).toContain("from '@agent-bundle/runtime/state/sqlite'"); From a99597c0baa8ffa43709d4ba5e3868186e357d12 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 15:29:31 +0000 Subject: [PATCH 2/2] fix(adapters): require an ISO survey date in unavailable notice-route reasons; state the diagnostic-code impact in the changeset --- .changeset/notice-delivery-adapter-surface.md | 2 +- .../agent-bundle/src/adapters/capability-state.ts | 11 +++++++++-- .../tests/adapter-capability-states.test.ts | 12 ++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.changeset/notice-delivery-adapter-surface.md b/.changeset/notice-delivery-adapter-surface.md index 512a61fdb..0dad8d34a 100644 --- a/.changeset/notice-delivery-adapter-surface.md +++ b/.changeset/notice-delivery-adapter-surface.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Gate the generated MCP server's notice routes on the target host's delivery advertisement. `TargetAdapter` gains the optional `noticeDelivery` field (typed by the new `NoticeDeliveryAdvertisement`, `NoticeDeliveryRoute`, and `NoticeDeliveryRouteState` exports, which resolve without the optional `@agent-bundle/runtime` peer), and `TargetRegistry` gains `noticeDelivery(target)`. The built-in `claude`, `codex`, `cursor`, and `portable` adapters advertise from their pinned capability tables and the `plugin` adapter advertises the three-host intersection; a JavaScript adapter declaring an unknown route state or an undated `unavailable` route is rejected at registration. `agent-bundle build` and `agent-bundle inspect --bundler` register the `agent-bundle://notices/inbox` resource only for hosts advertising `mcp-inbox`, and wire `resources/subscribe` plus `notifications/resources/updated` only where the host additionally advertises `mcp-resource-updated` and the state lifetime is workspace-durable. Built-in hosts all advertise `mcp-inbox`, so their artifacts are unchanged. (#412) +Gate the generated MCP server's notice routes on the target host's delivery advertisement. `TargetAdapter` gains the optional `noticeDelivery` field (typed by the new `NoticeDeliveryAdvertisement`, `NoticeDeliveryRoute`, and `NoticeDeliveryRouteState` exports, which resolve without the optional `@agent-bundle/runtime` peer), and `TargetRegistry` gains `noticeDelivery(target)`. The built-in `claude`, `codex`, `cursor`, and `portable` adapters advertise from their pinned capability tables and the `plugin` adapter advertises the three-host intersection; a JavaScript adapter declaring an unknown route state or an `unavailable` route whose reason carries no ISO survey date (`YYYY-MM-DD`) is rejected at registration with a `CapabilityStateError` (a thrown registration error, not a build diagnostic; no diagnostic codes are added or changed). `agent-bundle build` and `agent-bundle inspect --bundler` register the `agent-bundle://notices/inbox` resource only for hosts advertising `mcp-inbox`, and wire `resources/subscribe` plus `notifications/resources/updated` only where the host additionally advertises `mcp-resource-updated` and the state lifetime is workspace-durable. Built-in hosts all advertise `mcp-inbox`, so their artifacts are unchanged. (#412) diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index b692aad41..5aec5aba8 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -92,6 +92,13 @@ export interface NoticeDeliveryCapabilityTableEntry { readonly state: string; } +/** + * An `unavailable` notice route must say when the host was surveyed: the + * reason carries an ISO calendar date (`YYYY-MM-DD`), as every pinned table + * does, so the advertisement's evidence can be re-checked against a later pin. + */ +const DATED_REASON = /(? noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'host-toast': { reason: ' ', state: 'unavailable' } })) .toThrow(CapabilityStateError); + // A reason without a survey date is not dated evidence, however long it is. + expect(() => noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'host-toast': { reason: 'unsupported', state: 'unavailable' } })) + .toThrow(/host-toast unavailable without a dated reason/u); + expect(() => noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'host-toast': { reason: 'build 20260902 lacks it', state: 'unavailable' } })) + .toThrow(/host-toast unavailable without a dated reason/u); + expect(noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'host-toast': { reason: '2026-09-02: no toast API.', state: 'unavailable' } })) + .toMatchObject({ 'host-toast': { reason: '2026-09-02: no toast API.', state: 'unavailable' } }); const { 'directed-push': _omitted, ...missing } = rows; expect(() => noticeDeliveryAdvertisementFrom('fixture', missing)) .toThrow(/advertises no notice delivery route directed-push/u); @@ -1612,6 +1619,11 @@ it('re-validates a JavaScript adapter advertisement at the registry boundary', ( })).toThrow(/notice delivery route "mcp-inbox" must declare a state/u); expect(() => new TargetRegistry().register({ ...source, noticeDelivery: asAdvertisement('supported') })) .toThrow(/must declare notice delivery advertisements as a record/u); + // The registry never exposes an undated reason as dated evidence. + expect(() => new TargetRegistry().register({ + ...source, + noticeDelivery: asAdvertisement({ ...source.noticeDelivery, 'host-toast': { reason: 'unsupported', state: 'unavailable' } }), + })).toThrow(/host-toast unavailable without a dated reason/u); const { noticeDelivery: _declared, ...undeclared } = source; const registry = new TargetRegistry().register(undeclared); // An adapter that declares no advertisement honestly has no cross-request route.