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
7 changes: 7 additions & 0 deletions .changeset/workbench-request-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agent-bundle": minor
---

Expose credential-free request provenance for Workbench lifecycle replays, including explicit host, session, actor, workspace, and invocation axes with typed absence. Lifecycle routes now execute under the same receipt-sourced context shown in the Workbench, and the strict client decoder rejects unsupported wire fields.

Deprecate `plugin.version` in favor of package identity. Compiled MCP App routes now consume compiler-stamped `agent-bundle/meta` identity, while the prebuilt RSC example centralizes its host slug and derives its release version from `package.json`, so runtime registries and App modules no longer restate project identity.
16 changes: 9 additions & 7 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,15 @@ the project context, artifact manifests, `inspect` output, and dev status.
`plugin.name` stays the host-native slug and is never derived from the npm
package name.

`plugin.version` is **optional**. When it is omitted, the version every
surface reports — manifests, host projections, dev status, and the
`agent-bundle/meta` constant compiled into plugin code — is the `package.json`
version. When it is declared, the declared value still wins so a legacy
config never changes meaning mid-migration, and a disagreement reports the
`AB4008` **warning**. Declaring it as anything but a nonempty string is an
`AB4001` error.
`plugin.version` is **deprecated and optional**. New projects declare the
release version only in `package.json`; removal of the compatibility field
follows the normal breaking-change policy rather than a fixed window. When it
is omitted, the version every surface reports — manifests, host projections,
dev status, and the `agent-bundle/meta` constant compiled into plugin code —
is the `package.json` version. When it is declared, the declared value still
wins so a legacy config never changes meaning mid-migration, and a
disagreement reports the `AB4008` **warning**. Declaring it as anything but a
nonempty string is an `AB4001` error.

A project with neither an authored `plugin.version` nor a valid `package.json`
version has no release identity. Development commands (`dev`, `inspect`,
Expand Down
15 changes: 15 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,21 @@ Export detection is a static scan of the entry source (comment-, string-, and
template-safe). The generated shells re-verify the export shape at runtime
with a clear error.

### Workbench lifecycle replay provenance

The Workbench Lifecycles view exposes the request context used for each
deterministic replay. Host, session, actor, and workspace are separate
observed axes beside invocation kind, operation, surface, and host-contract
revision. Values parsed from the checked-in or pasted native receipt use the
`receipt` source — never `native`, because a Workbench replay is not evidence
that the named host dispatched the event. A missing session, actor, or
workspace remains visibly `unavailable` with its typed reason.

The same projected axes are mounted into the route request scope before
rendering, so `await agent()` and the Workbench evidence panel describe one
context rather than parallel snapshots. User-edited business input cannot
replace these axes.

## `agent-bundle/meta` — build-time release identity

Plugin code reads its own identity from the framework instead of maintaining
Expand Down
3 changes: 2 additions & 1 deletion examples/mcp-app/views/status-panel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
import { name, version } from 'agent-bundle/meta';

const app = new App({ name: 'mcp-app-status-panel', version: '1.0.0' }, {});
const app = new App({ name, version }, {});
const serviceHeading = document.querySelector<HTMLHeadingElement>('#service')!;
const statusIndicator = document.querySelector<HTMLElement>('#status-indicator')!;
const status = document.querySelector<HTMLElement>('#status')!;
Expand Down
5 changes: 3 additions & 2 deletions examples/rsc-agent-runtime/agent-bundle.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { defineConfig } from 'agent-bundle/config';

import { projectName } from './src/project-identity.js';

// The RSC runtime and App payloads are compiled by this example's own
// multi-environment Rsbuild build (see rsbuild.config.ts); agent-bundle
// packages those prebuilt trees verbatim and generates the host manifests,
Expand Down Expand Up @@ -38,8 +40,7 @@ export default defineConfig({
portable: {},
plugin: {
description: 'React Server Components agent runtime demonstration.',
name: 'rsc-agent-runtime-demo',
version: '1.0.0',
name: projectName,
},
targets: ['portable', 'claude', 'codex'],
});
1 change: 1 addition & 0 deletions examples/rsc-agent-runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@agent-bundle/rsc-agent-runtime-demo",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "rsbuild build --mode production && agent-bundle build --json --output dist/plugins",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type RscRuntimeCompileFailureKind,
type RscRuntimeCompileSnapshot,
} from '../../rsbuild.config.js';
import { projectName, projectVersion } from '../project-identity.js';
import {
createRscEnvironmentCheckpointStore,
type RscEnvironmentCheckpointStore,
Expand Down Expand Up @@ -809,7 +810,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession {
}),
protocolEra: 'modern',
protocolVersion: '2025-06-18',
server: Object.freeze({ name: 'rsc-agent-runtime-demo', version: '1.0.0' }),
server: Object.freeze({ name: projectName, version: projectVersion }),
});
const sessionReference: { current: RsbuildRuntimeSession | undefined } = { current: undefined };
const connector: RuntimeMcpConnector = Object.freeze({
Expand Down
3 changes: 2 additions & 1 deletion examples/rsc-agent-runtime/src/mcp/create-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@model
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

import { runtimeDefinition } from '../definition.js';
import { projectName, projectVersion } from '../project-identity.js';
import { createMcpHandlers } from './handlers.js';
import { resourceMetadata } from './host-metadata.js';
import type { McpRequestExtra, ResolveStateOptions } from './resolve-state.js';
Expand All @@ -20,7 +21,7 @@ const defaultWidgetPath = (): string =>
const defaultWidgetHtml = async (): Promise<string> => readFile(defaultWidgetPath(), 'utf8');

export const createRuntimeMcpServer = (options: CreateRuntimeMcpServerOptions = {}): McpServer => {
const server = new McpServer({ name: 'rsc-agent-runtime-demo', version: '1.0.0' });
const server = new McpServer({ name: projectName, version: projectVersion });
const handlers = createMcpHandlers(options);

for (const tool of runtimeDefinition.tools) {
Expand Down
5 changes: 5 additions & 0 deletions examples/rsc-agent-runtime/src/project-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import packageManifest from '../package.json' with { type: 'json' };

/** Host-native project slug; package.json remains authoritative for release version. */
export const projectName = 'rsc-agent-runtime-demo';
export const projectVersion = packageManifest.version;
3 changes: 2 additions & 1 deletion examples/rsc-agent-runtime/src/widget/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';

import { projectName, projectVersion } from '../project-identity.js';
import type { EditEvent } from '../runtime/contracts.js';
import { createWidgetStateAdapter, safeAreaCustomProperties, type HostContext } from './host-adapters.js';

Expand Down Expand Up @@ -89,7 +90,7 @@ export const App = () => {
const [selectedEventId, setSelectedEventId] = useState<string>();
const widgetState = useMemo(() => createWidgetStateAdapter(window as Window & { openai?: unknown }), []);
const { app } = useApp({
appInfo: { name: 'rsc-agent-runtime-timeline', version: '1.0.0' },
appInfo: { name: `${projectName}-timeline`, version: projectVersion },
capabilities: {},
onAppCreated: (createdApp) => {
createdApp.onteardown = () => ({});
Expand Down
9 changes: 2 additions & 7 deletions packages/agent-bundle/src/contracts/lifecycles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
AgentEventCanonicalIdentity,
CanonicalAgentEvent,
} from '../routes/public.ts';
import type { RequestContextProvenance } from './request-provenance.ts';

export interface LifecycleBinding {
readonly manifestDigest: string;
Expand Down Expand Up @@ -62,13 +63,7 @@ export interface LifecycleReplay {
readonly nativeInput: Readonly<Record<string, unknown>>;
readonly nativeResponse?: Readonly<Record<string, unknown>>;
readonly projectionDiagnostic?: Readonly<{ readonly code: string; readonly message: string }>;
readonly requestContext: Readonly<{
readonly hostContractRevision: string;
readonly invocationKind: 'event';
readonly nativeEvent: string;
readonly routeId: string;
readonly target: string;
}>;
readonly requestContext: RequestContextProvenance;
readonly source: LifecycleReplaySource;
}

Expand Down
37 changes: 37 additions & 0 deletions packages/agent-bundle/src/contracts/request-provenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export type RequestProvenanceSource = 'native' | 'receipt' | 'derived';

export type RequestProvenanceUnavailableReason =
| 'not-provided'
| 'unsupported-surface'
| 'host-omitted'
| 'unauthenticated';

export type RequestProvenanceAxis<Value> =
| Readonly<{
readonly source: RequestProvenanceSource;
readonly state: 'available';
readonly value: Value;
}>
| Readonly<{
readonly reason: RequestProvenanceUnavailableReason;
readonly state: 'unavailable';
}>;

export interface RequestInvocationProvenance {
readonly hostContractRevision?: string;
readonly kind: 'tool' | 'event' | 'cli' | 'script' | 'workbench';
readonly operationId?: string;
readonly surface?: string;
}

/**
* Credential-free request identity projected onto a Workbench wire response.
* Every observable axis is explicit; unknown values remain typed unavailable.
*/
export interface RequestContextProvenance {
readonly actor: RequestProvenanceAxis<Readonly<{ readonly id: string }>>;
readonly host: RequestProvenanceAxis<Readonly<{ readonly name: string }>>;
readonly invocation: RequestInvocationProvenance;
readonly session: RequestProvenanceAxis<Readonly<{ readonly sessionId: string }>>;
readonly workspace: RequestProvenanceAxis<Readonly<{ readonly root: string }>>;
}
4 changes: 4 additions & 0 deletions packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export interface AgentBundlePluginConfig {
* project's `package.json` (issue #94 stage 3): package.json is
* authoritative for release identity, and a declared value that disagrees
* with it reports the AB4008 warning.
*
* @deprecated Declare the release version only in `package.json`. This
* compatibility field will be removed through the normal breaking-change
* policy.
*/
version?: string;
[key: string]: unknown;
Expand Down
11 changes: 11 additions & 0 deletions packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ const render = async (request: LifecycleRenderChildRequest): Promise<LifecycleRe
);
const module = await importRouteModule(request.routeSource);
const rendered = await renderRouteEvents(module, {
context: {
actor: request.requestContext.actor,
host: request.requestContext.host,
invocation: {
...(request.requestContext.invocation.hostContractRevision === undefined
? {}
: { hostContractRevision: request.requestContext.invocation.hostContractRevision }),
},
Comment on lines +59 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mount all reported invocation fields into the replay scope

For lifecycle routes that read await agent().invocation, the default child-process replay copies only hostContractRevision here, and the in-process renderContext helper repeats the omission. renderRouteEvents therefore synthesizes surface as the route ID and leaves operationId undefined, even though the response reports operationId: event:<event> and surface: <event>. This makes route behavior and replay output diverge from the provenance displayed in Workbench; pass the reported operationId and surface through both render paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on main in #354 (merge bb0754f).

session: request.requestContext.session,
workspace: request.requestContext.workspace,
},
input: props,
kind: 'event-route',
routeId: request.routeId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import type {
AgentEventCanonicalIdentity,
CanonicalAgentEvent,
} from '../../routes/public.ts';
import type { RequestContextProvenance } from '../../contracts/request-provenance.ts';

export interface LifecycleRenderChildRequest {
readonly event: CanonicalAgentEvent;
readonly hostContractRevision: string;
readonly nativeEvent: string;
readonly nativeInput: Readonly<Record<string, unknown>>;
readonly requestContext: RequestContextProvenance;
readonly routeId: string;
readonly routeSource: string;
readonly target: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
LifecycleReplayRequest,
LifecycleTarget,
} from '../../contracts/lifecycles.ts';
import type { RequestContextProvenance } from '../../contracts/request-provenance.ts';
import { deepFreeze } from '../../core/freeze.ts';
import { isJsonRecord, isRecord, snapshotStrictJsonValue } from '../../core/strict-json.ts';
import {
Expand All @@ -30,7 +31,7 @@ import {
type CanonicalAgentEvent,
} from '../../routes/public.ts';
import type { CompiledAgentRoute, CompiledRouteGraph } from '../../routes/types.ts';
import type { renderRouteEvents } from '../../test/render.ts';
import type { RenderRouteContext, renderRouteEvents } from '../../test/render.ts';
import type { AgentRouteModule } from '../../test/types.ts';
import type { DevLogKindFor, DevLogSink } from '../logs/dev-log-service.ts';
import type {
Expand All @@ -42,6 +43,50 @@ import type {
const concreteHosts = new Set(['claude', 'codex', 'cursor']);
const projectionDiagnosticCode = 'lifecycle.projection.unsupported';

const nativeText = (native: Readonly<Record<string, unknown>>, key: string): string | undefined => {
const value = native[key];
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
};

const replayRequestContext = (
event: CanonicalAgentEvent,
native: Readonly<Record<string, unknown>>,
routeId: string,
target: string,
hostContractRevision: string,
): RequestContextProvenance => {
const sessionId = nativeText(native, 'session_id') ?? nativeText(native, 'conversation_id');
const workspaceRoot = nativeText(native, 'cwd');

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 Do not report supplied Cursor workspace roots as missing

For Cursor workspace/open replays, the validated native envelope requires a nonempty workspace_roots array and normally has no cwd, so this lookup always produces an unavailable workspace with reason not-provided. The Workbench consequently claims the workspace was absent, and the replayed route receives the same false absence, even though the receipt supplied workspace roots. Account for workspace_roots when projecting this surface, or use an unsupported-surface representation if multiple roots cannot be represented.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on main in #354 (merge bb0754f).

return deepFreeze({
actor: { reason: 'not-provided', state: 'unavailable' },
host: { source: 'receipt', state: 'available', value: { name: target } },
invocation: {
hostContractRevision,
kind: 'event',
operationId: routeId,
surface: event,
},
session: sessionId === undefined
? { reason: 'not-provided', state: 'unavailable' }
: { source: 'receipt', state: 'available', value: { sessionId } },
workspace: workspaceRoot === undefined
? { reason: 'not-provided', state: 'unavailable' }
: { source: 'receipt', state: 'available', value: { root: workspaceRoot } },
});
};

const renderContext = (requestContext: RequestContextProvenance): RenderRouteContext => deepFreeze({
actor: requestContext.actor,
host: requestContext.host,
invocation: {
...(requestContext.invocation.hostContractRevision === undefined
? {}
: { hostContractRevision: requestContext.invocation.hostContractRevision }),
},
session: requestContext.session,
workspace: requestContext.workspace,
});

export interface LifecyclePreparedProject {
readonly graph: CompiledRouteGraph;
readonly sourceRevision?: string;
Expand Down Expand Up @@ -378,6 +423,13 @@ export class LifecycleReplayService {
const message = error instanceof Error ? error.message : String(error);
throw new LifecycleReplayRequestError('AB8211', message, 400);
}
const requestContext = replayRequestContext(
event,
nativeInput,
route.id,
target.target,
target.hostContractRevision,
);
let rendered: LifecycleRenderChildResult;
if (this.#renderInProcess) {
const props = createCanonicalEventProps(
Expand All @@ -390,6 +442,7 @@ export class LifecycleReplayService {
);
const module = await this.#loadRouteModule(route.source);
const result = await this.#render(module, {
context: renderContext(requestContext),
input: props,
kind: 'event-route',
routeId: route.id,
Expand All @@ -406,6 +459,7 @@ export class LifecycleReplayService {
hostContractRevision: target.hostContractRevision,
nativeEvent: target.nativeEvent,
nativeInput,
requestContext,
routeId: route.id,
routeSource: route.source,
target: target.target,
Expand All @@ -430,13 +484,7 @@ export class LifecycleReplayService {
nativeInput,
...(nativeResponse === undefined ? {} : { nativeResponse }),
...(projectionDiagnostic === undefined ? {} : { projectionDiagnostic }),
requestContext: {
hostContractRevision: target.hostContractRevision,
invocationKind: 'event',
nativeEvent: target.nativeEvent,
routeId: route.id,
target: target.target,
},
requestContext,
source: request.source,
});
}
Expand Down
8 changes: 6 additions & 2 deletions packages/agent-bundle/tests/examples-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,12 @@
} finally {
await writeFile(fixturePath, healthyFixture);
}
await expect(readFile(join(output, 'portable', 'mcp-apps', 'status.html'), 'utf8'))
.resolves.toContain('aria-label="Service checks"');
const appHtml = await readFile(join(output, 'portable', 'mcp-apps', 'status.html'), 'utf8');
expect(appHtml).toContain('aria-label="Service checks"');
expect(appHtml).toContain('mcp-app-example');
expect(appHtml).toContain('1.0.0');
expect(appHtml).not.toContain('mcp-app-status-panel');
expect(appHtml).not.toContain('agent-bundle/meta');
expect(built.build.compiledMcpApps).toMatchObject([{ name: 'status', target: 'portable' }]);
expect(built.build.compiledMcpEntries.map(({ target }) => target).sort()).toEqual(['claude', 'codex', 'portable']);
await Promise.all(built.build.compiledMcpEntries.map(({ output: mcpOutput }) =>
Expand Down Expand Up @@ -301,7 +305,7 @@
const compiled = await build({ output, root, targets: ['claude'] });
await rm(join(root, 'src'), { force: true, recursive: true });
const server = compiled.model.mcpServers.find((candidate) => candidate.name === 'curator');
expect(server?.generatedRoutes).toHaveLength(17);

Check failure on line 308 in packages/agent-bundle/tests/examples-contract.test.ts

View workflow job for this annotation

GitHub Actions / Verify (Node 24)

packages/agent-bundle/tests/examples-contract.test.ts > serves the routed Audiobook Curator artifact through a real MCP client

expected [ { …(7) }%2C …(17) ] to have a length of 17 but got 18 - Expected + Received - 17 + 18

Check failure on line 308 in packages/agent-bundle/tests/examples-contract.test.ts

View workflow job for this annotation

GitHub Actions / Verify (Node 24)

packages/agent-bundle/tests/examples-contract.test.ts > serves the routed Audiobook Curator artifact through a real MCP client

expected [ { …(7) }%2C …(17) ] to have a length of 17 but got 18 - Expected + Received - 17 + 18

Check failure on line 308 in packages/agent-bundle/tests/examples-contract.test.ts

View workflow job for this annotation

GitHub Actions / Verify (Node 24)

packages/agent-bundle/tests/examples-contract.test.ts > serves the routed Audiobook Curator artifact through a real MCP client

expected [ { …(7) }%2C …(17) ] to have a length of 17 but got 18 - Expected + Received - 17 + 18
const entry = join(output, 'claude', server!.args![0]!);
client = new Client({ name: 'audiobook-route-contract', version: '1.0.0' });
await client.connect(new StdioClientTransport({ args: [entry], command: process.execPath, stderr: 'pipe' }));
Expand Down
10 changes: 7 additions & 3 deletions packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,13 @@ it('renders a lifecycle replay through a real default-pool dev server', { timeou
},
},
requestContext: {
invocationKind: 'event',
routeId: 'event:tool/after',
target: 'claude',
actor: { reason: 'not-provided', state: 'unavailable' },
host: { source: 'receipt', state: 'available', value: { name: 'claude' } },
invocation: {
kind: 'event',
operationId: 'event:tool/after',
surface: 'tool/after',
},
},
source: 'fixture',
});
Expand Down
Loading
Loading