From 00386dc1af378b367cc62ab2693e82db2f5bc1b3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:50:02 +0000 Subject: [PATCH 1/5] feat(rsc-runtime): #95 versioned request store with await agent() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a versioned realm-singleton request store (Symbol.for('@agent-bundle/rsc-runtime/request-store'), AGENT_REQUEST_STORE_VERSION) with closed leases and `const context = await agent()`. Identities are Observed — unavailable host/session/actor/workspace is a typed reason, never a fabricated string. AgentInvocation carries an invocation-kind discriminator (tool | event | cli | script | workbench). MCP and CLI entrypoints install the same typed context; state/notices stay reserved undefined, providers is present. A captured handle throws after the request completes. The rsc-agent-runtime example drops its hand-rolled withRenderContext / useEdit / useRuntimeSnapshot helper in favor of the framework store. Refs #95 --- .changeset/request-store-agent.md | 10 + examples/rsc-agent-runtime/README.md | 13 +- .../rsc-agent-runtime/src/rsc/components.tsx | 23 +- examples/rsc-agent-runtime/src/rsc/worker.tsx | 22 +- .../src/runtime/request-context.ts | 17 - packages/rsc-runtime/README.md | 16 +- packages/rsc-runtime/src/agent-request.ts | 314 ++++++++++++++++++ packages/rsc-runtime/src/cli.ts | 24 +- packages/rsc-runtime/src/mcp-server.ts | 37 ++- packages/rsc-runtime/src/plugin.ts | 34 +- .../rsc-runtime/tests/agent-request.test.ts | 278 ++++++++++++++++ 11 files changed, 737 insertions(+), 51 deletions(-) create mode 100644 .changeset/request-store-agent.md delete mode 100644 examples/rsc-agent-runtime/src/runtime/request-context.ts create mode 100644 packages/rsc-runtime/src/agent-request.ts create mode 100644 packages/rsc-runtime/tests/agent-request.test.ts diff --git a/.changeset/request-store-agent.md b/.changeset/request-store-agent.md new file mode 100644 index 000000000..3ea8de508 --- /dev/null +++ b/.changeset/request-store-agent.md @@ -0,0 +1,10 @@ +--- +"@agent-bundle/rsc-runtime": minor +--- + +Add the versioned realm-singleton request store, `await agent()`, and +Observed identities (#95 Wave 1). MCP and CLI entrypoints install a closed +request lease so `execute` can read the same `AgentInvocation` (kind +`tool` | `event` | `cli` | `script` | `workbench`) without a daemon or +durable state. `state`, `notices`, and `providers` are reserved extension +slots; a captured handle throws after the request completes. diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 79312e2ad..7db33b02e 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -15,16 +15,17 @@ Native hooks are fresh requests: a process normalizes one host event, invokes th ```tsx // A Hook JSX route reads request-scoped context. -import { Hook } from '@agent-bundle/rsc-runtime'; -import { useEdit, useRuntimeSnapshot } from '../runtime/request-context.js'; +import { Hook, agent } from '@agent-bundle/rsc-runtime'; +import type { CanonicalPostToolUse, RuntimeSnapshot } from '../runtime/contracts.js'; -export function AfterFileEdit() { - const edit = useEdit(); - const snapshot = useRuntimeSnapshot(); +export async function AfterFileEdit() { + const context = await agent(); + const edit = context.services.edit as CanonicalPostToolUse; + const snapshot = context.services.snapshot as RuntimeSnapshot; return ( - Recorded {edit.path}; {snapshot.edits.length} edits exist. + {`Recorded ${edit.path}; ${snapshot.edits.length} edits exist.`} ); diff --git a/examples/rsc-agent-runtime/src/rsc/components.tsx b/examples/rsc-agent-runtime/src/rsc/components.tsx index 78147e3b6..8ad123a2f 100644 --- a/examples/rsc-agent-runtime/src/rsc/components.tsx +++ b/examples/rsc-agent-runtime/src/rsc/components.tsx @@ -1,12 +1,23 @@ import { basename } from 'node:path'; -import { Hook, Mcp } from '@agent-bundle/rsc-runtime'; -import type { RuntimeSnapshot } from '../runtime/contracts.js'; -import { useEdit, useRuntimeSnapshot } from '../runtime/request-context.js'; +import { Hook, Mcp, agent } from '@agent-bundle/rsc-runtime'; +import type { CanonicalPostToolUse, RuntimeSnapshot } from '../runtime/contracts.js'; -export const AfterFileEdit = () => { - const edit = useEdit(); - const snapshot = useRuntimeSnapshot(); +const hookServices = async (): Promise<{ edit: CanonicalPostToolUse; snapshot: RuntimeSnapshot }> => { + const context = await agent(); + const edit = context.services.edit; + const snapshot = context.services.snapshot; + if (edit === undefined || snapshot === undefined) { + throw new Error('Hook render requires edit and snapshot services'); + } + return { + edit: edit as CanonicalPostToolUse, + snapshot: snapshot as RuntimeSnapshot, + }; +}; + +export const AfterFileEdit = async () => { + const { edit, snapshot } = await hookServices(); const editCount = snapshot.stateVersion; const editNoun = editCount === 1 ? 'edit' : 'edits'; diff --git a/examples/rsc-agent-runtime/src/rsc/worker.tsx b/examples/rsc-agent-runtime/src/rsc/worker.tsx index 34dc6d6e4..4adb7c5d9 100644 --- a/examples/rsc-agent-runtime/src/rsc/worker.tsx +++ b/examples/rsc-agent-runtime/src/rsc/worker.tsx @@ -3,10 +3,10 @@ import { finished } from 'node:stream/promises'; import { resolve } from 'node:path'; import { writeSync } from 'node:fs'; +import { available, runAgentRequest } from '@agent-bundle/rsc-runtime'; import { renderToReadableStream } from 'react-server-dom-rspack/server.node'; import type { CanonicalPostToolUse, RenderRequest, RuntimeSnapshot } from '../runtime/contracts.js'; -import { withRenderContext } from '../runtime/request-context.js'; import { createFileRuntimeKernel } from '../runtime/state-file.js'; import { renderRoute } from './routes.js'; @@ -135,9 +135,25 @@ const render = async (): Promise => { }; if (request.type === 'hook/after-file-edit') { - await withRenderContext({ edit: request.event, snapshot }, renderFlight); + await runAgentRequest({ + host: available({ name: request.event.host }, 'native'), + invocation: { + id: request.event.idempotencyKey, + kind: 'event', + surface: request.type, + }, + session: available({ sessionId: request.event.sessionId }, 'native'), + services: { edit: request.event, snapshot }, + workspace: available({ root: request.event.cwd }, 'native'), + }, renderFlight); } else { - await renderFlight(); + await runAgentRequest({ + invocation: { + kind: 'tool', + surface: request.type, + }, + services: { snapshot }, + }, renderFlight); } writeSnapshotMetadata(); }; diff --git a/examples/rsc-agent-runtime/src/runtime/request-context.ts b/examples/rsc-agent-runtime/src/runtime/request-context.ts deleted file mode 100644 index 4315709dc..000000000 --- a/examples/rsc-agent-runtime/src/runtime/request-context.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createRscRequestContext } from '@agent-bundle/rsc-runtime'; - -import type { CanonicalPostToolUse, RuntimeSnapshot } from './contracts.js'; - -export interface RenderContext { - edit: CanonicalPostToolUse; - snapshot: RuntimeSnapshot; -} - -const renderContext = createRscRequestContext('RSC runtime hook'); - -export const withRenderContext = (context: RenderContext, operation: () => T): T => - renderContext.run(context, operation); - -export const useEdit = (): CanonicalPostToolUse => renderContext.use().edit; - -export const useRuntimeSnapshot = (): RuntimeSnapshot => renderContext.use().snapshot; diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index f9d3e7e12..11e3bd25e 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -24,10 +24,18 @@ const result = lowerMcpResult( ); ``` -The package exports `Hook`, `Mcp`, `lowerHookResult`, `lowerMcpResult`, and -`createRscRequestContext`. It does not own an RSC renderer, application state, -transport, persistence, or host packaging. React 19 is a peer dependency and Node -22.19 or newer is required. +The package exports `Hook`, `Mcp`, `lowerHookResult`, `lowerMcpResult`, +`createRscRequestContext`, `agent`, `runAgentRequest`, and `AgentRequestError`. It does not own an +RSC renderer, application state, transport, persistence, or host packaging. +React 19 is a peer dependency and Node 22.19 or newer is required. + +Async server utilities and Server Components read the framework request store +with `const context = await agent()`. The store is a versioned realm singleton +installed at MCP and CLI entrypoints (and at any other real invocation via +`runAgentRequest`). Identities are `Observed` — unavailable host, session, +actor, or workspace is a typed reason, never a fabricated string. The context +handle throws after the request completes. `state`, `notices`, and `providers` +are reserved extension slots; provider discovery and `useAgent()` arrive later. Structured MCP metadata and content are copied through a strict finite-JSON boundary before being returned, so later caller mutations do not alter a result. diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts new file mode 100644 index 000000000..c5fa31aa3 --- /dev/null +++ b/packages/rsc-runtime/src/agent-request.ts @@ -0,0 +1,314 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +export const AGENT_REQUEST_STORE_VERSION = 1; + +const STORE_SYMBOL = Symbol.for('@agent-bundle/rsc-runtime/request-store'); + +export type AgentInvocationKind = 'tool' | 'event' | 'cli' | 'script' | 'workbench'; + +export type ObservedSource = 'native' | 'receipt' | 'derived'; + +export type AgentContextUnavailableReason = + | 'not-provided' + | 'unsupported-surface' + | 'host-omitted' + | 'unauthenticated'; + +export type Observed = + | { readonly source: ObservedSource; readonly state: 'available'; readonly value: T } + | { readonly reason: AgentContextUnavailableReason; readonly state: 'unavailable' }; + +export interface AgentHostIdentity { + readonly name: string; +} + +export interface AgentSessionIdentity { + readonly sessionId: string; +} + +export interface AgentActorIdentity { + readonly id: string; +} + +export interface AgentWorkspaceIdentity { + readonly root: string; +} + +export interface AgentFilesystemAuthority { + readonly roots: readonly string[]; +} + +export interface AgentCommandAuthority { + readonly cwd: string; +} + +export interface AgentNetworkAuthority { + readonly allow: readonly string[]; +} + +export interface AgentProjectRootAuthority { + readonly root: string; +} + +export interface AgentRequestCapabilities { + readonly command: Observed; + readonly filesystem: Observed; + readonly network: Observed; + readonly projectRoot: Observed; +} + +export interface AgentProgressUpdate { + readonly completed?: number; + readonly message: string; + readonly total?: number; +} + +export interface AgentProgressReporter { + readonly report: (update: AgentProgressUpdate) => Promise; +} + +export type AgentServiceRegistry = Readonly>; + +export type AgentProviderValues = Readonly>; + +export interface AgentInvocation { + readonly artifactEpoch?: string; + readonly hostContractRevision?: string; + readonly id: string; + readonly kind: AgentInvocationKind; + readonly operationId?: string; + readonly protocolRevision?: string; + readonly sourceRevision?: string; + readonly startedAt: string; + readonly surface?: string; +} + +export type AgentInvocationInput = Pick & Partial>; + +export interface AgentRequestContext { + readonly invocation: AgentInvocation; + readonly host: Observed; + readonly session: Observed; + readonly actor: Observed; + readonly workspace: Observed; + readonly capabilities: AgentRequestCapabilities; + readonly progress: AgentProgressReporter; + readonly signal: AbortSignal; + readonly services: AgentServiceRegistry; + readonly providers: AgentProviderValues; + /** Reserved for the durable state kernel (#98). Wave 1 leaves this undefined. */ + readonly state: undefined; + /** Reserved for recipient-aware notices (#99). Wave 1 leaves this undefined. */ + readonly notices: undefined; +} + +export interface AgentRequestInit { + readonly actor?: Observed; + readonly capabilities?: AgentRequestCapabilities; + readonly host?: Observed; + readonly invocation: AgentInvocationInput; + readonly progress?: AgentProgressReporter; + readonly providers?: AgentProviderValues; + readonly services?: AgentServiceRegistry; + readonly session?: Observed; + readonly signal?: AbortSignal; + readonly workspace?: Observed; +} + +export type AgentRequestErrorCode = 'outside-invocation' | 'request-closed' | 'store-version-conflict'; + +export class AgentRequestError extends Error { + readonly code: AgentRequestErrorCode; + + constructor(code: AgentRequestErrorCode, message: string) { + super(message); + this.code = code; + this.name = 'AgentRequestError'; + } +} + +const freezeValue = (value: T): T => { + if (value !== null && typeof value === 'object') { + return Object.freeze(value) as T; + } + return value; +}; + +export const available = (value: T, source: ObservedSource): Observed => Object.freeze({ + source, + state: 'available', + value: freezeValue(value), +}); + +export const unavailable = (reason: AgentContextUnavailableReason = 'not-provided'): Observed => + Object.freeze({ reason, state: 'unavailable' }); + +const silentProgress: AgentProgressReporter = Object.freeze({ + report: async () => undefined, +}); + +const emptyCapabilities = (): AgentRequestCapabilities => Object.freeze({ + command: unavailable(), + filesystem: unavailable(), + network: unavailable(), + projectRoot: unavailable(), +}); + +const optionalText = (value: string | undefined): string | undefined => { + if (value === undefined) return undefined; + if (value.trim() === '') throw new Error('Agent invocation fields must be non-empty when present'); + return value; +}; + +const invocationFrom = (input: AgentInvocationInput): AgentInvocation => Object.freeze({ + id: optionalText(input.id) ?? crypto.randomUUID(), + kind: input.kind, + startedAt: optionalText(input.startedAt) ?? new Date().toISOString(), + ...(input.artifactEpoch === undefined ? {} : { artifactEpoch: optionalText(input.artifactEpoch) }), + ...(input.hostContractRevision === undefined ? {} : { hostContractRevision: optionalText(input.hostContractRevision) }), + ...(input.operationId === undefined ? {} : { operationId: optionalText(input.operationId) }), + ...(input.protocolRevision === undefined ? {} : { protocolRevision: optionalText(input.protocolRevision) }), + ...(input.sourceRevision === undefined ? {} : { sourceRevision: optionalText(input.sourceRevision) }), + ...(input.surface === undefined ? {} : { surface: optionalText(input.surface) }), +}); + +interface FrozenValues { + readonly actor: Observed; + readonly capabilities: AgentRequestCapabilities; + readonly host: Observed; + readonly invocation: AgentInvocation; + readonly notices: undefined; + readonly progress: AgentProgressReporter; + readonly providers: AgentProviderValues; + readonly services: AgentServiceRegistry; + readonly session: Observed; + readonly signal: AbortSignal; + readonly state: undefined; + readonly workspace: Observed; +} + +interface Lease { + closed: boolean; + handle: AgentRequestContext; + readonly values: FrozenValues; +} + +interface RealmStore { + readonly storage: AsyncLocalStorage; + readonly version: number; +} + +const realm = globalThis as typeof globalThis & { + [STORE_SYMBOL]?: RealmStore; +}; + +const getStore = (): RealmStore => { + const existing = realm[STORE_SYMBOL]; + if (existing !== undefined) { + if (existing.version !== AGENT_REQUEST_STORE_VERSION) { + throw new AgentRequestError( + 'store-version-conflict', + `Incompatible @agent-bundle/rsc-runtime request store version: found ${String(existing.version)}, expected ${String(AGENT_REQUEST_STORE_VERSION)}`, + ); + } + return existing; + } + const created: RealmStore = { + storage: new AsyncLocalStorage(), + version: AGENT_REQUEST_STORE_VERSION, + }; + realm[STORE_SYMBOL] = created; + return created; +}; + +const open = (lease: Lease): FrozenValues => { + if (lease.closed) { + throw new AgentRequestError('request-closed', 'agent() used after the request completed'); + } + return lease.values; +}; + +const createHandle = (lease: Lease): AgentRequestContext => Object.freeze({ + get invocation() { + return open(lease).invocation; + }, + get host() { + return open(lease).host; + }, + get session() { + return open(lease).session; + }, + get actor() { + return open(lease).actor; + }, + get workspace() { + return open(lease).workspace; + }, + get capabilities() { + return open(lease).capabilities; + }, + get progress() { + return open(lease).progress; + }, + get signal() { + return open(lease).signal; + }, + get services() { + return open(lease).services; + }, + get providers() { + return open(lease).providers; + }, + get state() { + return open(lease).state; + }, + get notices() { + return open(lease).notices; + }, +}); + +const currentLease = (): Lease => { + const lease = getStore().storage.getStore(); + if (lease === undefined) { + throw new AgentRequestError('outside-invocation', 'agent() used outside a real invocation'); + } + return lease; +}; + +export const agent = async (): Promise => { + const lease = currentLease(); + open(lease); + return lease.handle; +}; + +export const runAgentRequest = async ( + init: AgentRequestInit, + operation: () => T | Promise, +): Promise => { + const values: FrozenValues = Object.freeze({ + actor: init.actor ?? unavailable(), + capabilities: Object.freeze({ ...(init.capabilities ?? emptyCapabilities()) }), + host: init.host ?? unavailable(), + invocation: invocationFrom(init.invocation), + notices: undefined, + progress: init.progress ?? silentProgress, + providers: Object.freeze({ ...(init.providers ?? {}) }), + services: Object.freeze({ ...(init.services ?? {}) }), + session: init.session ?? unavailable(), + signal: init.signal ?? new AbortController().signal, + state: undefined, + workspace: init.workspace ?? unavailable(), + }); + const lease: Lease = { + closed: false, + handle: undefined as unknown as AgentRequestContext, + values, + }; + lease.handle = createHandle(lease); + + try { + return await getStore().storage.run(lease, operation); + } finally { + lease.closed = true; + } +}; diff --git a/packages/rsc-runtime/src/cli.ts b/packages/rsc-runtime/src/cli.ts index 176da1f60..b011243a9 100644 --- a/packages/rsc-runtime/src/cli.ts +++ b/packages/rsc-runtime/src/cli.ts @@ -1,4 +1,5 @@ import type { RscApplication } from './application.js'; +import { available, runAgentRequest, unavailable } from './agent-request.js'; export interface RscCliOptions { readonly signal?: AbortSignal; @@ -25,14 +26,31 @@ export const runRscCli = async ( } const operation = application.operations.find((candidate) => candidate.cli?.name === commandName); if (operation?.cli === undefined) throw new Error(`Unknown command: ${commandName}`); + const cli = operation.cli; if (commandArguments.length === 1 && (commandArguments[0] === '--help' || commandArguments[0] === '-h')) { - write(`${operation.cli.usage}\n\n${operation.cli.summary}\n`); + write(`${cli.usage}\n\n${cli.summary}\n`); return 0; } const signal = options.signal ?? new AbortController().signal; signal.throwIfAborted(); - const result = await operation.execute(operation.cli.parse(commandArguments), { signal }); + const cwd = process.cwd(); + const result = await runAgentRequest({ + capabilities: Object.freeze({ + command: unavailable(), + filesystem: unavailable(), + network: unavailable(), + projectRoot: available({ root: cwd }, 'derived'), + }), + host: unavailable('unsupported-surface'), + invocation: { + kind: 'cli', + operationId: operation.id, + surface: cli.name, + }, + signal, + workspace: available({ root: cwd }, 'derived'), + }, async () => operation.execute(cli.parse(commandArguments), { signal })); signal.throwIfAborted(); write(`${JSON.stringify(result)}\n`); - return operation.cli.exitCode(result); + return cli.exitCode(result); }; diff --git a/packages/rsc-runtime/src/mcp-server.ts b/packages/rsc-runtime/src/mcp-server.ts index 74d40517f..fcb6daf71 100644 --- a/packages/rsc-runtime/src/mcp-server.ts +++ b/packages/rsc-runtime/src/mcp-server.ts @@ -1,6 +1,7 @@ import { McpServer as ProtocolMcpServer } from '@modelcontextprotocol/server'; import type { RscApplication } from './application.js'; +import { available, runAgentRequest } from './agent-request.js'; import { lowerMcpResult } from './lower-mcp.js'; export const createRscMcpServer = ( @@ -18,25 +19,39 @@ export const createRscMcpServer = ( version: application.version, }); for (const operation of application.operations) { - if (operation.mcp?.server !== serverName) continue; - server.registerTool(operation.mcp.name, { - ...(operation.mcp._meta === undefined ? {} : { _meta: operation.mcp._meta }), + const mcp = operation.mcp; + if (mcp?.server !== serverName || mcp === undefined) continue; + server.registerTool(mcp.name, { + ...(mcp._meta === undefined ? {} : { _meta: mcp._meta }), // Emit exactly the hints the author declared: an absent hint carries // MCP-spec default semantics on the wire, so synthesizing values here // would rewrite the author's contract. annotations: { - ...(operation.mcp.destructive === undefined ? {} : { destructiveHint: operation.mcp.destructive }), - ...(operation.mcp.idempotent === undefined ? {} : { idempotentHint: operation.mcp.idempotent }), - ...(operation.mcp.openWorld === undefined ? {} : { openWorldHint: operation.mcp.openWorld }), - readOnlyHint: operation.mcp.readOnly, + ...(mcp.destructive === undefined ? {} : { destructiveHint: mcp.destructive }), + ...(mcp.idempotent === undefined ? {} : { idempotentHint: mcp.idempotent }), + ...(mcp.openWorld === undefined ? {} : { openWorldHint: mcp.openWorld }), + readOnlyHint: mcp.readOnly, }, - description: operation.mcp.description, + description: mcp.description, inputSchema: operation.inputSchema, - ...(operation.mcp.title === undefined ? {} : { title: operation.mcp.title }), - }, async (input, context) => { + ...(mcp.title === undefined ? {} : { title: mcp.title }), + }, async (input, context) => runAgentRequest({ + ...(context.http?.authInfo?.clientId === undefined + ? {} + : { actor: available({ id: context.http.authInfo.clientId }, 'native') }), + invocation: { + kind: 'tool', + operationId: operation.id, + surface: mcp.name, + }, + ...(typeof context.sessionId === 'string' && context.sessionId.trim() !== '' + ? { session: available({ sessionId: context.sessionId }, 'native') } + : {}), + signal: context.mcpReq.signal, + }, async () => { const result = await operation.execute(input, { signal: context.mcpReq.signal }); return lowerMcpResult(operation.render(result)); - }); + })); } return server; }; diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts index 423b6fbab..1dd77b80a 100644 --- a/packages/rsc-runtime/src/plugin.ts +++ b/packages/rsc-runtime/src/plugin.ts @@ -1,3 +1,35 @@ +export { + AGENT_REQUEST_STORE_VERSION, + AgentRequestError, + agent, + available, + runAgentRequest, + unavailable, +} from './agent-request.js'; +export type { + AgentActorIdentity, + AgentCommandAuthority, + AgentContextUnavailableReason, + AgentFilesystemAuthority, + AgentHostIdentity, + AgentInvocation, + AgentInvocationInput, + AgentInvocationKind, + AgentNetworkAuthority, + AgentProgressReporter, + AgentProgressUpdate, + AgentProjectRootAuthority, + AgentProviderValues, + AgentRequestCapabilities, + AgentRequestContext, + AgentRequestErrorCode, + AgentRequestInit, + AgentServiceRegistry, + AgentSessionIdentity, + AgentWorkspaceIdentity, + Observed, + ObservedSource, +} from './agent-request.js'; export { defineRscApplication } from './application.js'; export type { RscApplication, RscApplicationOptions } from './application.js'; export { runRscCli } from './cli.js'; @@ -11,4 +43,4 @@ export type { RscOperationContext, RscOperationDefinition, RscOperationInput, -} from './operation.js'; \ No newline at end of file +} from './operation.js'; diff --git a/packages/rsc-runtime/tests/agent-request.test.ts b/packages/rsc-runtime/tests/agent-request.test.ts new file mode 100644 index 000000000..8ccba89ae --- /dev/null +++ b/packages/rsc-runtime/tests/agent-request.test.ts @@ -0,0 +1,278 @@ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { afterAll, describe, expect, it } from '@rstest/core'; +import { createElement } from 'react'; +import { z } from 'zod'; + +import { + AGENT_REQUEST_STORE_VERSION, + AgentRequestError, + agent, + available, + createRscMcpServer, + defineOperation, + defineRscApplication, + runAgentRequest, + runRscCli, + unavailable, +} from '../src/index.js'; +import { + AGENT_REQUEST_STORE_VERSION as pluginStoreVersion, + AgentRequestError as PluginAgentRequestError, + agent as pluginAgent, + runAgentRequest as pluginRunAgentRequest, +} from '../src/plugin.js'; + +const STORE_SYMBOL = Symbol.for('@agent-bundle/rsc-runtime/request-store'); + +const init = (kind: 'tool' | 'event' | 'cli' | 'script' | 'workbench', id?: string) => ({ + invocation: { ...(id === undefined ? {} : { id }), kind }, +}); + +describe('agent request store', () => { + it('exposes Observed identities, invocation kind, and reserved extension slots', async () => { + await runAgentRequest({ + host: available({ name: 'claude' }, 'native'), + invocation: { + artifactEpoch: 'epoch-1', + id: 'inv-1', + kind: 'event', + operationId: 'after-file-edit', + protocolRevision: '1', + sourceRevision: 'src-1', + surface: 'hook/after-file-edit', + }, + providers: { gitWorktree: { path: '/tmp/worktree' } }, + session: available({ sessionId: 'session-1' }, 'native'), + services: { snapshot: { stateVersion: 1 } }, + workspace: available({ root: '/tmp/project' }, 'native'), + }, async () => { + const context = await agent(); + expect(context.invocation).toMatchObject({ + artifactEpoch: 'epoch-1', + id: 'inv-1', + kind: 'event', + operationId: 'after-file-edit', + protocolRevision: '1', + sourceRevision: 'src-1', + surface: 'hook/after-file-edit', + }); + expect(context.host).toEqual({ source: 'native', state: 'available', value: { name: 'claude' } }); + expect(context.session).toEqual({ source: 'native', state: 'available', value: { sessionId: 'session-1' } }); + expect(context.actor).toEqual({ reason: 'not-provided', state: 'unavailable' }); + expect(context.workspace).toEqual({ source: 'native', state: 'available', value: { root: '/tmp/project' } }); + expect(context.capabilities.filesystem.state).toBe('unavailable'); + expect(context.capabilities.command.state).toBe('unavailable'); + expect(context.capabilities.network.state).toBe('unavailable'); + expect(context.capabilities.projectRoot.state).toBe('unavailable'); + expect(context.services).toEqual({ snapshot: { stateVersion: 1 } }); + expect(context.providers).toEqual({ gitWorktree: { path: '/tmp/worktree' } }); + expect(context.state).toBeUndefined(); + expect(context.notices).toBeUndefined(); + expect(Object.hasOwn(context, 'state')).toBe(true); + expect(Object.hasOwn(context, 'notices')).toBe(true); + expect(Object.hasOwn(context, 'providers')).toBe(true); + expect(Object.isFrozen(context)).toBe(true); + expect(Object.isFrozen(context.invocation)).toBe(true); + expect(Object.isFrozen(context.host)).toBe(true); + }); + }); + + it('never fabricates an identity string for a missing principal', async () => { + await runAgentRequest(init('tool'), async () => { + const context = await agent(); + expect(context.host).toEqual(unavailable()); + expect(context.session).toEqual(unavailable()); + expect(context.actor).toEqual(unavailable()); + expect(context.workspace).toEqual(unavailable()); + expect(Object.hasOwn(context.host, 'value')).toBe(false); + }); + }); + + it('isolates concurrent invocations including identities and provider values', async () => { + const barrier = Promise.withResolvers(); + const first = runAgentRequest({ + invocation: { id: 'a', kind: 'event' }, + providers: { edit: 'first' }, + session: available({ sessionId: 'session-a' }, 'native'), + }, async () => { + await barrier.promise; + const context = await agent(); + return { + id: context.invocation.id, + provider: context.providers.edit, + session: context.session.state === 'available' ? context.session.value.sessionId : 'missing', + }; + }); + const second = runAgentRequest({ + invocation: { id: 'b', kind: 'cli' }, + providers: { edit: 'second' }, + session: available({ sessionId: 'session-b' }, 'native'), + }, async () => { + barrier.resolve(); + const context = await agent(); + return { + id: context.invocation.id, + provider: context.providers.edit, + session: context.session.state === 'available' ? context.session.value.sessionId : 'missing', + }; + }); + + await expect(Promise.all([first, second])).resolves.toEqual([ + { id: 'a', provider: 'first', session: 'session-a' }, + { id: 'b', provider: 'second', session: 'session-b' }, + ]); + }); + + it('rejects agent() outside a real invocation', async () => { + await expect(agent()).rejects.toMatchObject({ + code: 'outside-invocation', + message: 'agent() used outside a real invocation', + }); + await expect(agent()).rejects.toBeInstanceOf(AgentRequestError); + }); + + it('rejects a captured handle after the request completes', async () => { + let handle: Awaited> | undefined; + await runAgentRequest(init('tool', 'closed'), async () => { + handle = await agent(); + expect(handle.invocation.id).toBe('closed'); + }); + expect(handle).toBeDefined(); + expect(() => handle?.invocation).toThrow(AgentRequestError); + try { + void handle?.invocation; + throw new Error('expected captured handle access to throw'); + } catch (error) { + expect(error).toMatchObject({ code: 'request-closed' }); + } + }); + + it('rejects escaped continuations after the request completes', async () => { + let escaped: Promise | undefined; + await runAgentRequest(init('script'), () => { + escaped = Promise.resolve().then(async () => { + await Promise.resolve(); + return agent(); + }); + void escaped.then(() => undefined, () => undefined); + }); + expect(escaped).toBeDefined(); + try { + await escaped; + throw new Error('expected escaped agent() to reject'); + } catch (error) { + expect(error).toBeInstanceOf(AgentRequestError); + expect(error).toMatchObject({ + code: expect.stringMatching(/^(?:request-closed|outside-invocation)$/u), + }); + } + }); + + it('rejects a conflicting store version planted on the realm singleton', async () => { + const globalSymbols = globalThis as typeof globalThis & Record; + const previous = globalSymbols[STORE_SYMBOL]; + globalSymbols[STORE_SYMBOL] = { version: AGENT_REQUEST_STORE_VERSION + 1 }; + try { + await expect(runAgentRequest(init('tool'), () => undefined)).rejects.toBeInstanceOf(AgentRequestError); + await expect(runAgentRequest(init('tool'), () => undefined)).rejects.toMatchObject({ + code: 'store-version-conflict', + }); + } finally { + if (previous === undefined) { + delete globalSymbols[STORE_SYMBOL]; + } else { + globalSymbols[STORE_SYMBOL] = previous; + } + } + }); + + it('survives await inside the request and is absent afterward', async () => { + const seen = await runAgentRequest(init('workbench', 'awaited'), async () => { + await Promise.resolve(); + return (await agent()).invocation.id; + }); + expect(seen).toBe('awaited'); + await expect(agent()).rejects.toMatchObject({ code: 'outside-invocation' }); + }); + + it('re-exports the request store from the plugin entry', () => { + expect(pluginAgent).toBe(agent); + expect(pluginRunAgentRequest).toBe(runAgentRequest); + expect(PluginAgentRequestError).toBe(AgentRequestError); + expect(pluginStoreVersion).toBe(AGENT_REQUEST_STORE_VERSION); + }); +}); + +describe('entrypoint bindings', () => { + const status = defineOperation({ + cli: { + name: 'status', + parse: () => ({}), + summary: 'Read status.', + usage: 'status', + }, + execute: async () => { + const context = await agent(); + return { + kind: context.invocation.kind, + operationId: context.invocation.operationId, + surface: context.invocation.surface, + }; + }, + id: 'status', + inputSchema: z.object({}).strict(), + mcp: { + description: 'Read status.', + name: 'runtime_status', + readOnly: true, + server: 'runtime', + }, + render: (result) => createElement( + 'mcp-result', + { structuredContent: result }, + createElement('mcp-text', null, result.kind), + ), + resultSchema: z.object({ + kind: z.enum(['tool', 'event', 'cli', 'script', 'workbench']), + operationId: z.string().optional(), + surface: z.string().optional(), + }).strict(), + }); + const application = defineRscApplication({ + name: 'runtime', + operations: [status], + version: '1.0.0', + }); + const openClients: Client[] = []; + + afterAll(async () => { + await Promise.allSettled(openClients.map((client) => client.close())); + }); + + it('installs a cli invocation for runRscCli', async () => { + const output: string[] = []; + await expect(runRscCli(application, ['status'], { write: (value) => output.push(value) })).resolves.toBe(0); + expect(JSON.parse(output.join(''))).toEqual({ + kind: 'cli', + operationId: 'status', + surface: 'status', + }); + await expect(agent()).rejects.toMatchObject({ code: 'outside-invocation' }); + }); + + it('installs a tool invocation for createRscMcpServer', async () => { + const server = createRscMcpServer(application, 'runtime'); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'agent-request-test', version: '0.0.0' }); + openClients.push(client); + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ arguments: {}, name: 'runtime_status' }); + expect(result.structuredContent).toEqual({ + kind: 'tool', + operationId: 'status', + surface: 'runtime_status', + }); + await expect(agent()).rejects.toMatchObject({ code: 'outside-invocation' }); + }); +}); From fc6bf95bba2fe685e770485abdc8b0eb44a8355b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:50:40 +0000 Subject: [PATCH 2/5] docs: drop request-context.ts from the RSC runtime topology The example now uses the framework request store, so the generated topology tree no longer lists the deleted hand-rolled helper. --- docs/architecture/rsc-runtime-workbench.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index 52067ce11..74be82ea3 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -157,7 +157,6 @@ examples/ src/rsc/routes.tsx src/rsc/worker.tsx src/runtime/contracts.ts - src/runtime/request-context.ts src/runtime/state-file-core.ts src/runtime/state-file-test-support.ts src/runtime/state-file.ts From ca537603ce8a2764c45843f084a1113ab10e3a5b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:01:36 +0000 Subject: [PATCH 3/5] fix(rsc-runtime): snapshot nested capability lists into the request store Copy and freeze Observed identity values and capability arrays so a caller cannot mutate roots/allow on a live request handle. --- packages/rsc-runtime/src/agent-request.ts | 11 ++++-- .../rsc-runtime/tests/agent-request.test.ts | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index c5fa31aa3..2cd21fe49 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -128,8 +128,15 @@ export class AgentRequestError extends Error { } const freezeValue = (value: T): T => { - if (value !== null && typeof value === 'object') { - return Object.freeze(value) as T; + if (Array.isArray(value)) { + return Object.freeze(value.map((item) => freezeValue(item))) as T; + } + if (value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + const copy: Record = {}; + for (const [key, nested] of Object.entries(value)) { + copy[key] = freezeValue(nested); + } + return Object.freeze(copy) as T; } return value; }; diff --git a/packages/rsc-runtime/tests/agent-request.test.ts b/packages/rsc-runtime/tests/agent-request.test.ts index 8ccba89ae..bd2ff1149 100644 --- a/packages/rsc-runtime/tests/agent-request.test.ts +++ b/packages/rsc-runtime/tests/agent-request.test.ts @@ -88,6 +88,40 @@ describe('agent request store', () => { }); }); + it('snapshots nested capability lists so caller mutation cannot leak into the request', async () => { + const roots = ['/tmp/project']; + const allow = ['example.test']; + await runAgentRequest({ + capabilities: { + command: unavailable(), + filesystem: available({ roots }, 'native'), + network: available({ allow }, 'native'), + projectRoot: unavailable(), + }, + invocation: { kind: 'tool' }, + }, async () => { + roots.push('/tmp/other'); + allow.push('evil.test'); + const context = await agent(); + expect(context.capabilities.filesystem).toEqual({ + source: 'native', + state: 'available', + value: { roots: ['/tmp/project'] }, + }); + expect(context.capabilities.network).toEqual({ + source: 'native', + state: 'available', + value: { allow: ['example.test'] }, + }); + if (context.capabilities.filesystem.state === 'available') { + expect(Object.isFrozen(context.capabilities.filesystem.value.roots)).toBe(true); + } + if (context.capabilities.network.state === 'available') { + expect(Object.isFrozen(context.capabilities.network.value.allow)).toBe(true); + } + }); + }); + it('isolates concurrent invocations including identities and provider values', async () => { const barrier = Promise.withResolvers(); const first = runAgentRequest({ From 1ad14f7120d7cc2a116f2882077d861339e110b9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 01:09:23 +0000 Subject: [PATCH 4/5] fix(rsc-runtime): use typed invocation validation errors Keep request validation failures within the public error contract and simplify the MCP operation narrowing while touching the reviewed paths. --- packages/rsc-runtime/src/agent-request.ts | 6 ++++-- packages/rsc-runtime/src/mcp-server.ts | 2 +- packages/rsc-runtime/tests/agent-request.test.ts | 9 +++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 2cd21fe49..639f5b5e3 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -115,7 +115,7 @@ export interface AgentRequestInit { readonly workspace?: Observed; } -export type AgentRequestErrorCode = 'outside-invocation' | 'request-closed' | 'store-version-conflict'; +export type AgentRequestErrorCode = 'invalid-invocation' | 'outside-invocation' | 'request-closed' | 'store-version-conflict'; export class AgentRequestError extends Error { readonly code: AgentRequestErrorCode; @@ -163,7 +163,9 @@ const emptyCapabilities = (): AgentRequestCapabilities => Object.freeze({ const optionalText = (value: string | undefined): string | undefined => { if (value === undefined) return undefined; - if (value.trim() === '') throw new Error('Agent invocation fields must be non-empty when present'); + if (value.trim() === '') { + throw new AgentRequestError('invalid-invocation', 'Agent invocation fields must be non-empty when present'); + } return value; }; diff --git a/packages/rsc-runtime/src/mcp-server.ts b/packages/rsc-runtime/src/mcp-server.ts index fcb6daf71..a04cc5b03 100644 --- a/packages/rsc-runtime/src/mcp-server.ts +++ b/packages/rsc-runtime/src/mcp-server.ts @@ -20,7 +20,7 @@ export const createRscMcpServer = ( }); for (const operation of application.operations) { const mcp = operation.mcp; - if (mcp?.server !== serverName || mcp === undefined) continue; + if (mcp?.server !== serverName) continue; server.registerTool(mcp.name, { ...(mcp._meta === undefined ? {} : { _meta: mcp._meta }), // Emit exactly the hints the author declared: an absent hint carries diff --git a/packages/rsc-runtime/tests/agent-request.test.ts b/packages/rsc-runtime/tests/agent-request.test.ts index bd2ff1149..65bee1abc 100644 --- a/packages/rsc-runtime/tests/agent-request.test.ts +++ b/packages/rsc-runtime/tests/agent-request.test.ts @@ -77,6 +77,15 @@ describe('agent request store', () => { }); }); + it('uses a typed error for invalid invocation fields', async () => { + await expect(runAgentRequest({ invocation: { id: ' ', kind: 'tool' } }, () => undefined)).rejects.toMatchObject({ + code: 'invalid-invocation', + }); + await expect(runAgentRequest({ invocation: { id: ' ', kind: 'tool' } }, () => undefined)).rejects.toBeInstanceOf( + AgentRequestError, + ); + }); + it('never fabricates an identity string for a missing principal', async () => { await runAgentRequest(init('tool'), async () => { const context = await agent(); From 4c860b5b7a5810a8a70f8ed9385b4586c7a33406 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:38:33 +0000 Subject: [PATCH 5/5] fix(rsc-runtime): snapshot Observed inputs at the request boundary request boundary copies/freezes host/actor/session/workspace/capability Observed values so callers cannot bypass \`available()\` --- packages/rsc-runtime/src/agent-request.ts | 24 ++++++++--- .../rsc-runtime/tests/agent-request.test.ts | 41 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 639f5b5e3..d5c4e69bb 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -150,6 +150,12 @@ export const available = (value: T, source: ObservedSource): Observed => O export const unavailable = (reason: AgentContextUnavailableReason = 'not-provided'): Observed => Object.freeze({ reason, state: 'unavailable' }); +const snapshotObserved = (observed: Observed): Observed => ( + observed.state === 'available' + ? available(observed.value, observed.source) + : unavailable(observed.reason) +); + const silentProgress: AgentProgressReporter = Object.freeze({ report: async () => undefined, }); @@ -161,6 +167,14 @@ const emptyCapabilities = (): AgentRequestCapabilities => Object.freeze({ projectRoot: unavailable(), }); +const snapshotCapabilities = (capabilities: AgentRequestCapabilities): AgentRequestCapabilities => + Object.freeze({ + command: snapshotObserved(capabilities.command), + filesystem: snapshotObserved(capabilities.filesystem), + network: snapshotObserved(capabilities.network), + projectRoot: snapshotObserved(capabilities.projectRoot), + }); + const optionalText = (value: string | undefined): string | undefined => { if (value === undefined) return undefined; if (value.trim() === '') { @@ -295,18 +309,18 @@ export const runAgentRequest = async ( operation: () => T | Promise, ): Promise => { const values: FrozenValues = Object.freeze({ - actor: init.actor ?? unavailable(), - capabilities: Object.freeze({ ...(init.capabilities ?? emptyCapabilities()) }), - host: init.host ?? unavailable(), + actor: snapshotObserved(init.actor ?? unavailable()), + capabilities: snapshotCapabilities(init.capabilities ?? emptyCapabilities()), + host: snapshotObserved(init.host ?? unavailable()), invocation: invocationFrom(init.invocation), notices: undefined, progress: init.progress ?? silentProgress, providers: Object.freeze({ ...(init.providers ?? {}) }), services: Object.freeze({ ...(init.services ?? {}) }), - session: init.session ?? unavailable(), + session: snapshotObserved(init.session ?? unavailable()), signal: init.signal ?? new AbortController().signal, state: undefined, - workspace: init.workspace ?? unavailable(), + workspace: snapshotObserved(init.workspace ?? unavailable()), }); const lease: Lease = { closed: false, diff --git a/packages/rsc-runtime/tests/agent-request.test.ts b/packages/rsc-runtime/tests/agent-request.test.ts index 65bee1abc..6d06aef67 100644 --- a/packages/rsc-runtime/tests/agent-request.test.ts +++ b/packages/rsc-runtime/tests/agent-request.test.ts @@ -131,6 +131,47 @@ describe('agent request store', () => { }); }); + it('snapshots plain Observed inputs at the request boundary so caller mutation cannot leak', async () => { + const hostValue = { name: 'claude' }; + const host = { state: 'available' as const, source: 'native' as const, value: hostValue }; + const roots = ['/tmp/project']; + const filesystem = { state: 'available' as const, source: 'native' as const, value: { roots } }; + const command = { state: 'unavailable' as const, reason: 'not-provided' as const }; + const capabilities = { + command, + filesystem, + network: { state: 'unavailable' as const, reason: 'not-provided' as const }, + projectRoot: { state: 'unavailable' as const, reason: 'not-provided' as const }, + }; + + await runAgentRequest({ + capabilities, + host, + invocation: { kind: 'tool' }, + }, async () => { + hostValue.name = 'mutated'; + roots.push('/tmp/other'); + (host as { state: string }).state = 'unavailable'; + (command as { reason: string }).reason = 'unauthenticated'; + + const context = await agent(); + expect(context.host).toEqual({ source: 'native', state: 'available', value: { name: 'claude' } }); + expect(context.capabilities.filesystem).toEqual({ + source: 'native', + state: 'available', + value: { roots: ['/tmp/project'] }, + }); + expect(context.capabilities.command).toEqual({ reason: 'not-provided', state: 'unavailable' }); + expect(Object.isFrozen(context.host)).toBe(true); + if (context.host.state === 'available') { + expect(Object.isFrozen(context.host.value)).toBe(true); + } + if (context.capabilities.filesystem.state === 'available') { + expect(Object.isFrozen(context.capabilities.filesystem.value.roots)).toBe(true); + } + }); + }); + it('isolates concurrent invocations including identities and provider values', async () => { const barrier = Promise.withResolvers(); const first = runAgentRequest({