From 9606914a5df07d8464a3bd01c9b5164c57a9ae4c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 22:34:16 +0000 Subject: [PATCH] feat(workbench): surface request provenance and finish identity migration Expose honest receipt-sourced context in lifecycle replays and remove remaining runtime identity restatements while deprecating plugin.version. --- .changeset/workbench-request-provenance.md | 7 ++ docs/diagnostics.md | 16 +++-- docs/entry-conventions.md | 15 +++++ examples/mcp-app/views/status-panel.ts | 3 +- .../rsc-agent-runtime/agent-bundle.config.ts | 5 +- examples/rsc-agent-runtime/package.json | 1 + .../src/dev/rsbuild-runtime-session.ts | 3 +- .../src/mcp/create-server.ts | 3 +- .../rsc-agent-runtime/src/project-identity.ts | 5 ++ examples/rsc-agent-runtime/src/widget/App.tsx | 3 +- .../agent-bundle/src/contracts/lifecycles.ts | 9 +-- .../src/contracts/request-provenance.ts | 37 +++++++++++ packages/agent-bundle/src/core/types.ts | 4 ++ .../dev/playground/lifecycle-render-child.ts | 11 ++++ .../playground/lifecycle-render-protocol.ts | 2 + .../playground/lifecycle-replay-service.ts | 64 ++++++++++++++++--- .../tests/examples-contract.test.ts | 8 ++- .../tests/lifecycle-replay-dev-server.test.ts | 10 ++- .../tests/lifecycle-replay-routes.test.ts | 19 ++++-- .../tests/lifecycle-replay-service.test.ts | 60 +++++++++++++++++ .../tests/route-unit/lifecycle-replay.test.ts | 11 ++-- .../src/lifecycles/lifecycle-client.ts | 10 +-- .../src/lifecycles/lifecycles-model.ts | 30 +++++++-- packages/workbench/src/request-provenance.ts | 49 ++++++++++++++ .../lifecycles-page-browser-fixture.tsx | 21 ++++-- .../workbench/tests/lifecycle-client.test.ts | 31 +++++++-- .../workbench/tests/lifecycles-model.test.ts | 24 ++++--- .../workbench/tests/lifecycles-page.test.ts | 18 ++++-- .../workbench/tests/lifecycles.e2e.test.ts | 9 +++ 29 files changed, 409 insertions(+), 79 deletions(-) create mode 100644 .changeset/workbench-request-provenance.md create mode 100644 examples/rsc-agent-runtime/src/project-identity.ts create mode 100644 packages/agent-bundle/src/contracts/request-provenance.ts create mode 100644 packages/workbench/src/request-provenance.ts diff --git a/.changeset/workbench-request-provenance.md b/.changeset/workbench-request-provenance.md new file mode 100644 index 000000000..aa616dfcd --- /dev/null +++ b/.changeset/workbench-request-provenance.md @@ -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. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 087e562aa..c77093a71 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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`, diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 765656a33..b115b1dc9 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -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 diff --git a/examples/mcp-app/views/status-panel.ts b/examples/mcp-app/views/status-panel.ts index 34a9fd0c4..c1fb9d678 100644 --- a/examples/mcp-app/views/status-panel.ts +++ b/examples/mcp-app/views/status-panel.ts @@ -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('#service')!; const statusIndicator = document.querySelector('#status-indicator')!; const status = document.querySelector('#status')!; diff --git a/examples/rsc-agent-runtime/agent-bundle.config.ts b/examples/rsc-agent-runtime/agent-bundle.config.ts index 7bd0e2a0d..423ffd735 100644 --- a/examples/rsc-agent-runtime/agent-bundle.config.ts +++ b/examples/rsc-agent-runtime/agent-bundle.config.ts @@ -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, @@ -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'], }); diff --git a/examples/rsc-agent-runtime/package.json b/examples/rsc-agent-runtime/package.json index 297bd0e72..e22a81cc8 100644 --- a/examples/rsc-agent-runtime/package.json +++ b/examples/rsc-agent-runtime/package.json @@ -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", diff --git a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts index 08c301d3e..3ac712466 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -12,6 +12,7 @@ import { type RscRuntimeCompileFailureKind, type RscRuntimeCompileSnapshot, } from '../../rsbuild.config.js'; +import { projectName, projectVersion } from '../project-identity.js'; import { createRscEnvironmentCheckpointStore, type RscEnvironmentCheckpointStore, @@ -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({ diff --git a/examples/rsc-agent-runtime/src/mcp/create-server.ts b/examples/rsc-agent-runtime/src/mcp/create-server.ts index 63bec7f4b..5fb801bfc 100644 --- a/examples/rsc-agent-runtime/src/mcp/create-server.ts +++ b/examples/rsc-agent-runtime/src/mcp/create-server.ts @@ -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'; @@ -20,7 +21,7 @@ const defaultWidgetPath = (): string => const defaultWidgetHtml = async (): Promise => 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) { diff --git a/examples/rsc-agent-runtime/src/project-identity.ts b/examples/rsc-agent-runtime/src/project-identity.ts new file mode 100644 index 000000000..3c687cfce --- /dev/null +++ b/examples/rsc-agent-runtime/src/project-identity.ts @@ -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; diff --git a/examples/rsc-agent-runtime/src/widget/App.tsx b/examples/rsc-agent-runtime/src/widget/App.tsx index 473fff255..efebce90f 100644 --- a/examples/rsc-agent-runtime/src/widget/App.tsx +++ b/examples/rsc-agent-runtime/src/widget/App.tsx @@ -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'; @@ -89,7 +90,7 @@ export const App = () => { const [selectedEventId, setSelectedEventId] = useState(); 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 = () => ({}); diff --git a/packages/agent-bundle/src/contracts/lifecycles.ts b/packages/agent-bundle/src/contracts/lifecycles.ts index 637123790..edc761026 100644 --- a/packages/agent-bundle/src/contracts/lifecycles.ts +++ b/packages/agent-bundle/src/contracts/lifecycles.ts @@ -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; @@ -62,13 +63,7 @@ export interface LifecycleReplay { readonly nativeInput: Readonly>; readonly nativeResponse?: Readonly>; 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; } diff --git a/packages/agent-bundle/src/contracts/request-provenance.ts b/packages/agent-bundle/src/contracts/request-provenance.ts new file mode 100644 index 000000000..7794d82cb --- /dev/null +++ b/packages/agent-bundle/src/contracts/request-provenance.ts @@ -0,0 +1,37 @@ +export type RequestProvenanceSource = 'native' | 'receipt' | 'derived'; + +export type RequestProvenanceUnavailableReason = + | 'not-provided' + | 'unsupported-surface' + | 'host-omitted' + | 'unauthenticated'; + +export type RequestProvenanceAxis = + | 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 host: RequestProvenanceAxis>; + readonly invocation: RequestInvocationProvenance; + readonly session: RequestProvenanceAxis>; + readonly workspace: RequestProvenanceAxis>; +} diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index eff59874b..830a2ee2b 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -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; diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts b/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts index c8bd919da..5bcce874a 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts @@ -53,6 +53,17 @@ const render = async (request: LifecycleRenderChildRequest): Promise>; + readonly requestContext: RequestContextProvenance; readonly routeId: string; readonly routeSource: string; readonly target: string; diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts index bee2f91d9..42328529d 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts @@ -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 { @@ -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 { @@ -42,6 +43,50 @@ import type { const concreteHosts = new Set(['claude', 'codex', 'cursor']); const projectionDiagnosticCode = 'lifecycle.projection.unsupported'; +const nativeText = (native: Readonly>, key: string): string | undefined => { + const value = native[key]; + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +}; + +const replayRequestContext = ( + event: CanonicalAgentEvent, + native: Readonly>, + routeId: string, + target: string, + hostContractRevision: string, +): RequestContextProvenance => { + const sessionId = nativeText(native, 'session_id') ?? nativeText(native, 'conversation_id'); + const workspaceRoot = nativeText(native, 'cwd'); + 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; @@ -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( @@ -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, @@ -406,6 +459,7 @@ export class LifecycleReplayService { hostContractRevision: target.hostContractRevision, nativeEvent: target.nativeEvent, nativeInput, + requestContext, routeId: route.id, routeSource: route.source, target: target.target, @@ -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, }); } diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index bc7fddc82..4fa0a0783 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -175,8 +175,12 @@ it('publishes the MCP App example service readiness across targets and returns d } 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 }) => diff --git a/packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts b/packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts index 775a25302..645aed253 100644 --- a/packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts +++ b/packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts @@ -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', }); diff --git a/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts b/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts index 924dafa35..e095a3cb7 100644 --- a/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts +++ b/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts @@ -57,11 +57,20 @@ class RecordingService implements LifecycleReplayRouteService { events: Object.freeze([]), nativeInput: Object.freeze({}), requestContext: Object.freeze({ - hostContractRevision: '2.1.250', - invocationKind: 'event', - nativeEvent: 'PostToolUse', - routeId: 'event:tool/after', - target: 'claude', + actor: Object.freeze({ reason: 'not-provided', state: 'unavailable' }), + host: Object.freeze({ + source: 'receipt', + state: 'available', + value: Object.freeze({ name: 'claude' }), + }), + invocation: Object.freeze({ + hostContractRevision: '2.1.250', + kind: 'event', + operationId: 'event:tool/after', + surface: 'tool/after', + }), + session: Object.freeze({ reason: 'not-provided', state: 'unavailable' }), + workspace: Object.freeze({ reason: 'not-provided', state: 'unavailable' }), }), source: 'observed', }); diff --git a/packages/agent-bundle/tests/lifecycle-replay-service.test.ts b/packages/agent-bundle/tests/lifecycle-replay-service.test.ts index 1cc5a0012..fb27e7132 100644 --- a/packages/agent-bundle/tests/lifecycle-replay-service.test.ts +++ b/packages/agent-bundle/tests/lifecycle-replay-service.test.ts @@ -3,6 +3,7 @@ import { expect, it } from '@rstest/core'; import type { AgentRouteModule } from '../src/test/types.ts'; import { LifecycleReplayService } from '../src/dev/playground/lifecycle-replay-service.ts'; import type { CompiledRouteGraph } from '../src/routes/types.ts'; +import type { RenderRouteContext } from '../src/test/render.ts'; const graph = Object.freeze({ diagnostics: Object.freeze([]), @@ -87,3 +88,62 @@ it('surfaces the real native envelope validator message as a malformed request', status: 400, }); }); + +it('mounts and reports honest receipt provenance for a Workbench replay', async () => { + let renderedContext: RenderRouteContext | undefined; + const replayService = new LifecycleReplayService({ + prepared: () => ({ + graph, + targets: ['claude'], + }), + loadRouteModule: async () => ({ default: async () => undefined }) as AgentRouteModule, + render: async (_target, options = {}) => { + renderedContext = options.context; + return { + document: { + root: { children: [], kind: 'result' }, + status: 'success', + version: 1, + }, + events: [], + } as never; + }, + }); + + const result = await replayService.replay({ + binding: { manifestDigest: 'manifest-a', routeId: 'event:tool/after', target: 'claude' }, + native: { + cwd: '/tmp/lifecycle-replay', + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_input: { file_path: 'README.md' }, + tool_name: 'Write', + tool_response: { success: true }, + tool_use_id: 'tool-1', + transcript_path: '/tmp/lifecycle-replay/transcript.jsonl', + }, + source: 'observed', + }); + if ('diagnostics' in result) throw new Error('Expected a lifecycle replay.'); + + const requestContext = { + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { source: 'receipt', state: 'available', value: { name: 'claude' } }, + invocation: { + hostContractRevision: '2.1.250', + kind: 'event', + operationId: 'event:tool/after', + surface: 'tool/after', + }, + session: { source: 'receipt', state: 'available', value: { sessionId: 'session-1' } }, + workspace: { source: 'receipt', state: 'available', value: { root: '/tmp/lifecycle-replay' } }, + }; + expect(result.requestContext).toEqual(requestContext); + expect(renderedContext).toEqual({ + actor: requestContext.actor, + host: requestContext.host, + invocation: { hostContractRevision: '2.1.250' }, + session: requestContext.session, + workspace: requestContext.workspace, + }); +}); diff --git a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts index be4a9dd74..c07e7d993 100644 --- a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts +++ b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts @@ -99,10 +99,13 @@ it('replays Claude and Codex PostToolUse through decode, route execution, render }); expect(replay.nativeInput).toEqual(fixture.native); expect(replay.requestContext).toMatchObject({ - invocationKind: 'event', - nativeEvent: 'PostToolUse', - routeId: 'event:tool/after', - target: fixture.target, + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { source: 'receipt', state: 'available', value: { name: fixture.target } }, + invocation: { + kind: 'event', + operationId: 'event:tool/after', + surface: 'tool/after', + }, }); expect(replay.events[0]?.type).toBe('shell'); expect(replay.events.at(-1)?.type).toBe('complete'); diff --git a/packages/workbench/src/lifecycles/lifecycle-client.ts b/packages/workbench/src/lifecycles/lifecycle-client.ts index 3696d7e1e..287baf012 100644 --- a/packages/workbench/src/lifecycles/lifecycle-client.ts +++ b/packages/workbench/src/lifecycles/lifecycle-client.ts @@ -17,6 +17,7 @@ import { agentRenderEventSchema, } from '../runtime/agent-document-client.ts'; import { deeplyFrozenHookValue } from '../hooks/hook-client.ts'; +import { requestContextProvenanceSchema } from '../request-provenance.ts'; export type { Lifecycle, @@ -116,13 +117,6 @@ const canonicalSchema = z.strictObject({ }), sequence: z.number().int().nonnegative(), }); -const requestContextSchema = z.strictObject({ - hostContractRevision: textSchema, - invocationKind: z.literal('event'), - nativeEvent: textSchema, - routeId: textSchema, - target: textSchema, -}); const replaySchema = z.strictObject({ binding: bindingSchema, canonical: canonicalSchema, @@ -131,7 +125,7 @@ const replaySchema = z.strictObject({ nativeInput: jsonRecordSchema, nativeResponse: jsonRecordSchema.optional(), projectionDiagnostic: z.strictObject({ code: textSchema, message: textSchema }).optional(), - requestContext: requestContextSchema, + requestContext: requestContextProvenanceSchema, source: z.enum(['fixture', 'observed']), }); const replayDiagnosticSchema = z.strictObject({ diff --git a/packages/workbench/src/lifecycles/lifecycles-model.ts b/packages/workbench/src/lifecycles/lifecycles-model.ts index 3caa69908..85cba9cd7 100644 --- a/packages/workbench/src/lifecycles/lifecycles-model.ts +++ b/packages/workbench/src/lifecycles/lifecycles-model.ts @@ -1,4 +1,5 @@ import { deeplyFrozenHookValue } from '../hooks/hook-client.ts'; +import type { RequestProvenanceAxis } from '../../../agent-bundle/src/contracts/request-provenance.ts'; import type { LifecycleDiagnostic, LifecycleListResponse, @@ -66,6 +67,20 @@ const noResultDiagnostics: readonly LifecycleResultDiagnostic[] = Object.freeze( const row = (label: string, value: string): LifecycleDetailRow => Object.freeze({ label, value }); +const observedRow = ( + label: string, + axis: RequestProvenanceAxis, + display: (value: Value) => string, +): LifecycleDetailRow => row( + label, + axis.state === 'available' + ? `${display(axis.value)} · ${axis.source}` + : `Unavailable · ${axis.reason}`, +); + +const optionalRow = (label: string, value: string | undefined): LifecycleDetailRow => + row(label, value ?? 'Unavailable · not-provided'); + export const lifecycleOptionKeyFor = (routeId: string, target: string): string => `${target}/${routeId}`; export const lifecycleOptionsFor = (list: LifecycleListResponse): readonly LifecycleOption[] => Object.freeze( @@ -108,11 +123,14 @@ export const canonicalRowsFor = (replay: LifecycleReplay): readonly LifecycleDet ]); export const requestRowsFor = (replay: LifecycleReplay): readonly LifecycleDetailRow[] => Object.freeze([ - row('Invocation kind', replay.requestContext.invocationKind), - row('Route ID', replay.requestContext.routeId), - row('Target', replay.requestContext.target), - row('Native event', replay.requestContext.nativeEvent), - row('Host contract revision', replay.requestContext.hostContractRevision), + row('Invocation kind', replay.requestContext.invocation.kind), + optionalRow('Operation ID', replay.requestContext.invocation.operationId), + optionalRow('Surface', replay.requestContext.invocation.surface), + optionalRow('Host contract revision', replay.requestContext.invocation.hostContractRevision), + observedRow('Host', replay.requestContext.host, ({ name }) => name), + observedRow('Session', replay.requestContext.session, ({ sessionId }) => sessionId), + observedRow('Actor', replay.requestContext.actor, ({ id }) => id), + observedRow('Workspace', replay.requestContext.workspace, ({ root }) => root), ]); export const resultDiagnosticsFor = (replay: LifecycleReplay): readonly LifecycleResultDiagnostic[] => Object.freeze([ @@ -143,7 +161,7 @@ const summaryFor = (state: LifecyclesViewState, replay: LifecycleReplay | undefi case 'replayed': return replay === undefined ? 'The deterministic lifecycle replay completed.' - : `Replayed ${replay.canonical.event} for ${replay.requestContext.target} from ${replay.source} input.`; + : `Replayed ${replay.canonical.event} for ${replay.binding.target} from ${replay.source} input.`; case 'ready': return 'Choose a compiled event route and host target, then run a deterministic replay.'; default: { diff --git a/packages/workbench/src/request-provenance.ts b/packages/workbench/src/request-provenance.ts new file mode 100644 index 000000000..03f8426d4 --- /dev/null +++ b/packages/workbench/src/request-provenance.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +import type { RequestContextProvenance } from '../../agent-bundle/src/contracts/request-provenance.ts'; + +const textSchema = z.string().min(1); +const sourceSchema = z.enum(['native', 'receipt', 'derived']); +const unavailableSchema = z.strictObject({ + reason: z.enum(['not-provided', 'unsupported-surface', 'host-omitted', 'unauthenticated']), + state: z.literal('unavailable'), +}); +const availableHostSchema = z.strictObject({ + source: sourceSchema, + state: z.literal('available'), + value: z.strictObject({ name: textSchema }), +}); +const availableSessionSchema = z.strictObject({ + source: sourceSchema, + state: z.literal('available'), + value: z.strictObject({ sessionId: textSchema }), +}); +const availableActorSchema = z.strictObject({ + source: sourceSchema, + state: z.literal('available'), + value: z.strictObject({ id: textSchema }), +}); +const availableWorkspaceSchema = z.strictObject({ + source: sourceSchema, + state: z.literal('available'), + value: z.strictObject({ root: textSchema }), +}); + +export const requestContextProvenanceSchema: z.ZodType = z.strictObject({ + actor: z.union([availableActorSchema, unavailableSchema]), + host: z.union([availableHostSchema, unavailableSchema]), + invocation: z.strictObject({ + hostContractRevision: textSchema.optional(), + kind: z.enum(['tool', 'event', 'cli', 'script', 'workbench']), + operationId: textSchema.optional(), + surface: textSchema.optional(), + }), + session: z.union([availableSessionSchema, unavailableSchema]), + workspace: z.union([availableWorkspaceSchema, unavailableSchema]), +}); + +/** Strictly decodes the credential-free request context carried by Workbench routes. */ +export const decodeRequestContextProvenance = (value: unknown): RequestContextProvenance | undefined => { + const parsed = requestContextProvenanceSchema.safeParse(value); + return parsed.success ? Object.freeze(parsed.data) : undefined; +}; diff --git a/packages/workbench/tests/fixtures/lifecycles-page-browser-fixture.tsx b/packages/workbench/tests/fixtures/lifecycles-page-browser-fixture.tsx index 1c17beb0d..d111e1e9b 100644 --- a/packages/workbench/tests/fixtures/lifecycles-page-browser-fixture.tsx +++ b/packages/workbench/tests/fixtures/lifecycles-page-browser-fixture.tsx @@ -69,6 +69,8 @@ const listing = (manifestDigest = 'manifest-a'): LifecycleListResponse => ({ const replayFor = (request: LifecycleReplayRequest): LifecycleReplay => { const target = listing().lifecycles[0]!.targets.find((candidate) => candidate.target === request.binding.target)!; + const sessionId = typeof request.native.session_id === 'string' ? request.native.session_id : undefined; + const workspaceRoot = typeof request.native.cwd === 'string' ? request.native.cwd : undefined; return { binding: request.binding, canonical: { @@ -93,11 +95,20 @@ const replayFor = (request: LifecycleReplayRequest): LifecycleReplay => { ? { hookSpecificOutput: { additionalContext: 'Recorded browser.txt' } } : { output: { context: 'Recorded browser.txt' } }, requestContext: { - hostContractRevision: target.hostContractRevision, - invocationKind: 'event', - nativeEvent: target.nativeEvent, - routeId: request.binding.routeId, - target: request.binding.target, + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { source: 'receipt', state: 'available', value: { name: request.binding.target } }, + invocation: { + hostContractRevision: target.hostContractRevision, + kind: 'event', + operationId: request.binding.routeId, + surface: 'tool/after', + }, + 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 } }, }, source: request.source, }; diff --git a/packages/workbench/tests/lifecycle-client.test.ts b/packages/workbench/tests/lifecycle-client.test.ts index 5d62c4b86..1a3745634 100644 --- a/packages/workbench/tests/lifecycle-client.test.ts +++ b/packages/workbench/tests/lifecycle-client.test.ts @@ -46,11 +46,16 @@ const replay = { nativeInput: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, nativeResponse: { hookSpecificOutput: { additionalContext: 'Recorded README.md' } }, requestContext: { - hostContractRevision: 'claude-hooks@1', - invocationKind: 'event' as const, - nativeEvent: 'PostToolUse', - routeId: 'event:tool/after', - target: 'claude', + actor: { reason: 'not-provided' as const, state: 'unavailable' as const }, + host: { source: 'receipt' as const, state: 'available' as const, value: { name: 'claude' } }, + invocation: { + hostContractRevision: 'claude-hooks@1', + kind: 'event' as const, + operationId: 'event:tool/after', + surface: 'tool/after', + }, + session: { reason: 'not-provided' as const, state: 'unavailable' as const }, + workspace: { reason: 'not-provided' as const, state: 'unavailable' as const }, }, source: 'fixture' as const, }; @@ -128,6 +133,12 @@ it('posts the exact replay binding, native receipt, and honest source', async () replay: { binding: request.binding, canonical: { provenance: { host: 'claude', nativeEvent: 'PostToolUse' } }, + requestContext: { + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { source: 'receipt', state: 'available', value: { name: 'claude' } }, + session: { reason: 'not-provided', state: 'unavailable' }, + workspace: { reason: 'not-provided', state: 'unavailable' }, + }, source: 'fixture', }, }); @@ -173,6 +184,16 @@ it('rejects surplus fields at every lifecycle response boundary', async () => { }, { replay: { ...replay, version: 1 } }, { replay: { ...replay, canonical: { ...replay.canonical, version: 1 } } }, + { replay: { ...replay, requestContext: { ...replay.requestContext, version: 1 } } }, + { + replay: { + ...replay, + requestContext: { + ...replay.requestContext, + host: { ...replay.requestContext.host, version: 1 }, + }, + }, + }, { replay: { ...replay, events: [{ ...replay.events[0], version: 1 }] } }, { replay: { ...replay, document: { ...replay.document, version: 1, extra: true } } }, { diff --git a/packages/workbench/tests/lifecycles-model.test.ts b/packages/workbench/tests/lifecycles-model.test.ts index 7607a8ab0..b0040908d 100644 --- a/packages/workbench/tests/lifecycles-model.test.ts +++ b/packages/workbench/tests/lifecycles-model.test.ts @@ -73,11 +73,16 @@ const replay: LifecycleReplay = { nativeInput: { hook_event_name: 'PostToolUse' }, projectionDiagnostic: { code: 'projection.partial', message: 'Optional host field was omitted.' }, requestContext: { - hostContractRevision: 'claude-hooks@1', - invocationKind: 'event', - nativeEvent: 'PostToolUse', - routeId: 'event:tool/after', - target: 'claude', + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { source: 'receipt', state: 'available', value: { name: 'claude' } }, + invocation: { + hostContractRevision: 'claude-hooks@1', + kind: 'event', + operationId: 'event:tool/after', + surface: 'tool/after', + }, + session: { source: 'receipt', state: 'available', value: { sessionId: 'session-1' } }, + workspace: { source: 'receipt', state: 'available', value: { root: '/workspace' } }, }, source: 'fixture', }; @@ -132,10 +137,13 @@ it('derives one correlated replay view with identity, context, and diagnostics', ]); expect(view.requestRows).toEqual([ { label: 'Invocation kind', value: 'event' }, - { label: 'Route ID', value: 'event:tool/after' }, - { label: 'Target', value: 'claude' }, - { label: 'Native event', value: 'PostToolUse' }, + { label: 'Operation ID', value: 'event:tool/after' }, + { label: 'Surface', value: 'tool/after' }, { label: 'Host contract revision', value: 'claude-hooks@1' }, + { label: 'Host', value: 'claude · receipt' }, + { label: 'Session', value: 'session-1 · receipt' }, + { label: 'Actor', value: 'Unavailable · not-provided' }, + { label: 'Workspace', value: '/workspace · receipt' }, ]); expect(view.resultDiagnostics).toEqual([ { code: 'projection.partial', message: 'Optional host field was omitted.', source: 'projection' }, diff --git a/packages/workbench/tests/lifecycles-page.test.ts b/packages/workbench/tests/lifecycles-page.test.ts index c0857dc45..01e71896c 100644 --- a/packages/workbench/tests/lifecycles-page.test.ts +++ b/packages/workbench/tests/lifecycles-page.test.ts @@ -64,11 +64,16 @@ const replay: LifecycleReplay = { nativeResponse: { hookSpecificOutput: { additionalContext: 'Recorded README.md' } }, projectionDiagnostic: { code: 'projection.partial', message: 'Optional output was omitted.' }, requestContext: { - hostContractRevision: 'claude-hooks@1', - invocationKind: 'event', - nativeEvent: 'PostToolUse', - routeId: 'event:tool/after', - target: 'claude', + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { source: 'receipt', state: 'available', value: { name: 'claude' } }, + invocation: { + hostContractRevision: 'claude-hooks@1', + kind: 'event', + operationId: 'event:tool/after', + surface: 'tool/after', + }, + session: { reason: 'not-provided', state: 'unavailable' }, + workspace: { source: 'receipt', state: 'available', value: { root: '/workspace' } }, }, source: 'fixture', }; @@ -88,6 +93,9 @@ it('renders one honest correlated replay view around the shared Agent Document s expect(markup).toContain('not evidence that claude dispatched this event'); expect(markup).toContain('Canonical identity'); expect(markup).toContain('Request context'); + expect(markup).toContain('claude · receipt'); + expect(markup).toContain('Unavailable · not-provided'); + expect(markup).toContain('/workspace · receipt'); expect(markup).toContain('Native input'); expect(markup).toContain('Agent Document'); expect(markup).toContain('Recorded README.md from claude.'); diff --git a/packages/workbench/tests/lifecycles.e2e.test.ts b/packages/workbench/tests/lifecycles.e2e.test.ts index f325e3b83..3c81c9c8c 100644 --- a/packages/workbench/tests/lifecycles.e2e.test.ts +++ b/packages/workbench/tests/lifecycles.e2e.test.ts @@ -80,6 +80,13 @@ e2e( await run.click(); await expect(provenance).toContainText('Observed', { timeout: browserTimeout }); await expect(stage).toContainText('Recorded observed-lifecycle.txt from claude', { timeout: browserTimeout }); + const requestContext = page.locator('.lifecycle-detail').filter({ + has: page.getByRole('heading', { name: 'Request context' }), + }); + await expect(requestContext).toContainText('claude · receipt'); + await expect(requestContext).toContainText('lifecycle-observed · receipt'); + await expect(requestContext).toContainText('/tmp · receipt'); + await expect(requestContext).toContainText('Unavailable · not-provided'); const sessionToken = await page.evaluate(async () => { const response = await fetch('/api/project/session', { credentials: 'same-origin' }); @@ -115,6 +122,8 @@ e2e( await run.click(); await expect(provenance).toContainText('Observed', { timeout: browserTimeout }); await expect(stage).toContainText('Recorded observed-lifecycle.txt from claude', { timeout: browserTimeout }); + await expect(requestContext).toContainText('lifecycle-observed · receipt'); + await expect(requestContext).toContainText('Unavailable · not-provided'); expect(pageErrors).toEqual([]); } finally { await fixture.close();