diff --git a/.changeset/468-plugin-root-request-axis.md b/.changeset/468-plugin-root-request-axis.md new file mode 100644 index 000000000..74e12c2ef --- /dev/null +++ b/.changeset/468-plugin-root-request-axis.md @@ -0,0 +1,6 @@ +--- +'@agent-bundle/runtime': patch +'agent-bundle': patch +--- + +Expose the resolved plugin root on the request context: `(await agent()).plugin` is an observed `{ root, stateRoot }` — `source: 'native'` from an expanded `AGENT_BUNDLE_PLUGIN_ROOT`, `'derived'` from the shell's fallback (the artifact root, or `$PWD/.agent-bundle` for the npm bin) — and conventional providers receive the same value as `plugin` beside `invocation` and `signal` (`AgentProviderContext.plugin`). Every generated shell (MCP entry and Flight worker, routed CLI executable and render worker, hook wrappers) now resolves the anchor once through the new `resolvePluginRoot` export of `@agent-bundle/runtime` and mounts its SQLite state, notice ledger, and lineage journal on that one `stateRoot`, so `plugin.stateRoot` is the directory they use by construction; an unexpanded `${…}` token is treated as unset and reported once on stderr instead of being joined into a path. `renderRoute`, `invokeCli`, `runScript`, and `openInMemoryMcpServer` publish the axis the same way and accept `context.plugin`; `createGeneratedRouteMcpServer` takes `pluginRoot`. `AGENT_REQUEST_STORE_VERSION` is 4. Fixes #468. (#532) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index aea010c45..3afc75b50 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -776,7 +776,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4833` | error | `notices.retention` is malformed: `notices` or `retention` is not an object, carries an unknown key, `terminalTtl` is not a positive integer of milliseconds or a duration such as `"7d"`, `"12h"`, `"30m"`, or `"90s"`, `maxTerminal` / `maxJournalBytes` is not a positive integer — or the policy is declared by a project without a conventional `src/state.ts`, which has no co-mounted notice ledger to retain. Omit a field to keep the runtime default (`7d`, `500`, `16777216`). | | `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. | Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. | | `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. | -| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, signal }`. | +| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 389516efc..02f6e5669 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -82,7 +82,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | -| `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | +| `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, plugin, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | | `src/layout.{ts,tsx}` | Shared document layout: default-exports one component receiving `{ children, route, signal }` that renders `Agent.Result` around every rendered route — generated MCP tools, resources, and prompts, rendered routed CLI commands, projected MCP commands, and rendered scripts. Event routes are never wrapped. | Rename to `_layout.tsx` | | `src/mcp//layout.{ts,tsx}` | Per-server layout nested inside the root layout for that generated server's routes. | Rename to `_layout.tsx`, or set `routes.servers.` to a non-generated mode | @@ -156,7 +156,16 @@ directory. The npm package's routed CLI bin and rendered scripts use `$AGENT_BUNDLE_PLUGIN_ROOT/state` when present and otherwise `$PWD/.agent-bundle/state`; the artifact-hosted routed CLI bin (`/bin/.mjs`) derives the artifact root from the parent of its -own `bin/` directory instead, like the MCP worker. Notice authorization is deliberately permissive +own `bin/` directory instead, like the MCP worker. Each generated process +resolves that anchor exactly once (`resolvePluginRoot` from +`@agent-bundle/runtime`, #468): the state kernel, the notice ledger, the +lineage journal, and every request scope the process opens read the same +value, published as `(await agent()).plugin` — `{ root, stateRoot }` with +`source: 'native'` from `AGENT_BUNDLE_PLUGIN_ROOT` or `'derived'` from the +fallback — and handed to conventional providers as `plugin` beside +`invocation` and `signal`. An anchor still carrying an unexpanded `${…}` +token is treated as unset (reported once on stderr), never joined into a +path. Notice authorization is deliberately permissive in generated mounting v1 (`authorized`); recipient/principal matching remains enforced by the ledger — every generated scope mounts the request's `lineage` on the notice principal, so `recipient.conversation` / `recipient.root` are @@ -252,9 +261,10 @@ an otherwise valid migration. Each direct child of `src/providers/` derives its key by camel-casing the file stem: for example, `src/providers/project-auth.ts` mounts at `(await agent()).providers.projectAuth`. Every module default-exports a factory -with the contract `(context: { invocation, signal }) => value | -Promise`, where `invocation` is the current route invocation and -`signal` is its request abort signal. +with the contract `(context: { invocation, plugin, signal }) => value | +Promise`, where `invocation` is the current route invocation, `plugin` +is the observed plugin root the request will publish as +`(await agent()).plugin` (#468), and `signal` is its request abort signal. Every generated request scope — the shared Flight worker behind generated MCP and event routes, the react-server worker behind rendered routed CLI commands diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/plugin-root.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/plugin-root.tsx new file mode 100644 index 000000000..ba7ba4aaf --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/plugin-root.tsx @@ -0,0 +1,31 @@ +import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const config = { + annotations: { readOnlyHint: true }, + description: 'Reports the plugin root and durable-state anchor this route observes.', + title: 'Plugin root', +}; + +export const inputSchema = z.object({}).strict(); + +export const resultSchema = z.object({ + plugin: z.unknown(), +}).strict(); + +/** + * The #468 probe: `(await agent()).plugin` as the route sees it, so every proof + * level can assert the anchor a generated scope resolved from + * `AGENT_BUNDLE_PLUGIN_ROOT` (or its fallback) reached the request. + */ +export default async function PluginRoot() { + const { plugin } = await agent(); + const observed: JsonValue = plugin.state === 'available' + ? { source: plugin.source, state: plugin.state, value: { root: plugin.value.root, stateRoot: plugin.value.stateRoot } } + : { reason: plugin.reason, state: plugin.state }; + return ( + + {plugin.state === 'available' ? `plugin root: ${plugin.value.root}` : `plugin root unavailable: ${plugin.reason}`} + + ); +} diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 672caa680..608b2f939 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -651,12 +651,13 @@ const eventRouteHookWrapperSource = ( "import { dirname, resolve } from 'node:path';", ...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []), ...(standalone - ? ["import { agent, available, createAgentRenderDispatcher, resolveStandaloneLineage, runAgentRequest, unavailable } from '@agent-bundle/runtime';"] + ? [ + "import { fileURLToPath } from 'node:url';", + "import { agent, available, createAgentRenderDispatcher, resolvePluginRoot, resolveStandaloneLineage, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + ] : []), ...(retiresLineage ? [ - "import { join } from 'node:path';", - "import { fileURLToPath } from 'node:url';", "import { agentLineageStateDefinition, createAgentLineageRegistry } from '@agent-bundle/runtime/lineage';", "import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';", ] @@ -679,6 +680,9 @@ const eventRouteHookWrapperSource = ( 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', ...(standalone ? [ + // The wrapper lives in `hooks/`, so its artifact root is the parent + // directory — the same anchor the generated MCP entry resolves (#468). + "const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });", 'const renderStandalone = async (invocation, signal) => {', ' const worker = new Worker(new URL(/* webpackIgnore: true */ "./hooks-flight.mjs", import.meta.url), { stderr: true, stdout: true });', " worker.stdout?.on('data', (chunk) => process.stderr.write(chunk));", @@ -714,6 +718,7 @@ const eventRouteHookWrapperSource = ( ' id,', ' invocation: dispatch.invocation,', ' lineage: context.lineage,', + ' plugin: context.plugin,', ' requestInvocation: context.invocation,', ' session: context.session,', ' terminal: context.terminal,', @@ -733,8 +738,7 @@ const eventRouteHookWrapperSource = ( ? [ 'const retireLineage = async (native, idempotencyKey, observedAt) => {', " if (target !== 'claude' && target !== 'codex' && target !== 'cursor') return;", - " const anchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));", - " const driver = createSqliteStateDriver({ root: join(anchor, 'state') });", + ' const driver = createSqliteStateDriver({ root: pluginRoot.stateRoot });', ' try {', ' const store = await driver.open(agentLineageStateDefinition());', ' try {', @@ -762,6 +766,7 @@ const eventRouteHookWrapperSource = ( ' host: available({ name: target }, "native"),', ' invocation: { artifactEpoch, hostContractRevision: capabilityRevision, kind: "event", operationId: `event:${canonicalEvent}`, surface: canonicalEvent },', ' lineage,', + ' plugin: pluginRoot.identity,', ' ...(sessionId === undefined ? {} : { session: available({ sessionId }, "native") }),', ' signal,', // A hook's stdout is its host envelope: no terminal, never probed (#511). diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 580c19508..e7f14e613 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -169,18 +169,38 @@ export interface GeneratedCliBinEntryOptions { readonly workerFile?: string; } +/** + * The Node imports the plugin-root fallback expression needs: the artifact + * root is the parent of the module's own directory (`mcp/`, `bin/`, `hooks/`), + * the npm bin anchors on the caller's `.agent-bundle`. Emitted once per + * generated module, ahead of every other `node:path` / `node:url` import. + */ +const pluginRootImports = (fallback: GeneratedStateFallback): readonly string[] => + fallback === 'artifact' + ? ["import { fileURLToPath } from 'node:url';"] + : ["import { join } from 'node:path';"]; + +const pluginRootFallbackExpression = (fallback: GeneratedStateFallback): string => + fallback === 'artifact' + ? "fileURLToPath(new URL('..', import.meta.url))" + : "join(process.cwd(), '.agent-bundle')"; + +/** + * The one plugin-root resolution of a generated module (#468): the SQLite + * kernel, the notice ledger, the lineage journal, and every request scope the + * module opens read `pluginRoot`, so `(await agent()).plugin.stateRoot` is the + * directory they mount by construction. + */ +const pluginRootDeclaration = (fallback: GeneratedStateFallback): string => + `const pluginRoot = resolvePluginRoot({ fallback: ${pluginRootFallbackExpression(fallback)} });`; + const generatedStateImports = ( state: NormalizedStateDefinition | undefined, - fallback: GeneratedStateFallback, ): readonly string[] => { if (state === undefined) return []; return [ ...(state.lifetime === 'workspace-durable' - ? [ - "import { join } from 'node:path';", - ...(fallback === 'artifact' ? ["import { fileURLToPath } from 'node:url';"] : []), - "import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';", - ] + ? ["import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';"] : ["import { createMemoryStateDriver } from '@agent-bundle/runtime/state';"]), "import { createGeneratedRuntimeState } from '@agent-bundle/runtime/mount';", `import stateDefinition from ${JSON.stringify(state.source)};`, @@ -213,9 +233,9 @@ const noticePolicyFields = (policy: GeneratedNoticePolicy): string => [ ...(policy.noticeRetention === undefined ? [] : [', noticeRetention: noticeRetentionPolicy']), ].join(''); +/** The state owner, mounted on the module's `pluginRoot` (declared by {@link pluginRootDeclaration}). */ const generatedStateOwner = ( state: NormalizedStateDefinition | undefined, - fallback: GeneratedStateFallback, policy: GeneratedNoticePolicy, ): readonly string[] => { if (state === undefined) return []; @@ -226,13 +246,9 @@ const generatedStateOwner = ( '', ]; } - const fallbackExpression = fallback === 'artifact' - ? "fileURLToPath(new URL('..', import.meta.url))" - : "join(process.cwd(), '.agent-bundle')"; return [ ...noticePolicyDeclarations(policy), - `const durableAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? ${fallbackExpression};`, - `const runtimeState = createGeneratedRuntimeState({ definition: stateDefinition, driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') })${noticePolicyFields(policy)} });`, + `const runtimeState = createGeneratedRuntimeState({ definition: stateDefinition, driver: createSqliteStateDriver({ root: pluginRoot.stateRoot })${noticePolicyFields(policy)} });`, '', ]; }; @@ -322,14 +338,16 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) return [ `import { cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, rendered - ? "import { available, createAgentRenderDispatcher, runAgentRequest, unavailable } from '@agent-bundle/runtime';" - : "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + ? "import { available, createAgentRenderDispatcher, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';" + : "import { available, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + ...pluginRootImports(stateFallback), ...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []), - ...generatedStateImports(options.state, stateFallback), + ...generatedStateImports(options.state), ...routeImports(commandRoutes), ...providerImports(providers), '', - ...generatedStateOwner(options.state, stateFallback, options), + pluginRootDeclaration(stateFallback), + ...generatedStateOwner(options.state, options), 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', ...providerRegistrySource(providers), 'const routes = Object.freeze({', @@ -364,6 +382,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ...providerExecutionSource(providers, { indent: plainIndent, invocation: "{ kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }", + plugin: 'pluginRoot.identity', signal: 'context.signal', }), `${plainIndent}const result = await runAgentRequest({`, @@ -377,6 +396,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) " invocation: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },", " lineage: unavailable('unsupported-surface'),", ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), + ' plugin: pluginRoot.identity,', ` providers: ${providerValuesExpression(providers)},`, ' signal: context.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), @@ -535,13 +555,15 @@ export const generatedRenderedRouteWorkerSource = ( "import { parentPort } from 'node:worker_threads';", "import { createElement } from 'react';", "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", - "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", - ...generatedStateImports(options.state, stateFallback), + "import { available, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + ...pluginRootImports(stateFallback), + ...generatedStateImports(options.state), ...routeImports(options.routes), ...providerImports(providers), ...layoutImports(layouts), '', - ...generatedStateOwner(options.state, stateFallback, options), + pluginRootDeclaration(stateFallback), + ...generatedStateOwner(options.state, options), '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', "if (parentPort === null) throw new Error('Generated render worker requires a parent port.');", @@ -568,7 +590,7 @@ export const generatedRenderedRouteWorkerSource = ( ? [] : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });']), ...(options.state === undefined ? [] : [' try {']), - ...providerExecutionSource(providers, { indent: ' ', invocation: 'message.invocation', signal: 'controller.signal' }), + ...providerExecutionSource(providers, { indent: ' ', invocation: 'message.invocation', plugin: 'pluginRoot.identity', signal: 'controller.signal' }), ' await runAgentRequest({', ' capabilities: {', ' command: unavailable(),', @@ -580,6 +602,7 @@ export const generatedRenderedRouteWorkerSource = ( ' invocation: message.request,', " lineage: unavailable('unsupported-surface'),", ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), + ' plugin: pluginRoot.identity,', " progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: 'progress', update }); } },", ` providers: ${providerValuesExpression(providers)},`, ' signal: controller.signal,', @@ -769,22 +792,19 @@ const wiresResourceUpdatedRoute = (options: NoticeRouteSelection): boolean => const noticeDeliveryImports = (wired: boolean): readonly string[] => wired ? [ - "import { join } from 'node:path';", - "import { fileURLToPath } from 'node:url';", "import { createGeneratedNoticeRuntime } from '@agent-bundle/runtime/mount';", "import { createNoticeInboxSignaller } from '@agent-bundle/runtime/notices';", - "import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';", ] : []; +/** The server process's notice store, opened on the module's `pluginRoot` (#468). */ const noticeDeliveryOwner = (wired: boolean, policy: GeneratedNoticePolicy): readonly string[] => wired ? [ ...noticePolicyDeclarations(policy), - "const durableAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));", `const noticeDelivery = createNoticeInboxSignaller({ ${ policy.noticeDelivery === undefined ? '' : 'delivery: noticeDeliveryAdvertisement, ' - }store: createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }), lifetime: 'workspace-durable'${noticePolicyFields(policy)} }) });`, + }store: createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: pluginRoot.stateRoot }), lifetime: 'workspace-durable'${noticePolicyFields(policy)} }) });`, '', ] : []; @@ -847,10 +867,16 @@ const processLifetimeValueSource = 'processHit'; */ const providerExecutionSource = ( providers: readonly CompiledProvider[], - expressions: { readonly indent: string; readonly invocation: string; readonly signal: string }, + expressions: { + readonly indent: string; + readonly invocation: string; + /** The observed plugin root the request scope publishes (#468); providers receive the same value. */ + readonly plugin: string; + readonly signal: string; + }, ): readonly string[] => { if (providers.length === 0) return []; - const { indent, invocation, signal } = expressions; + const { indent, invocation, plugin, signal } = expressions; return [ `${indent}const providerValues = { processLifetime: ${processLifetimeValueSource} };`, `${indent}for (const provider of providers) {`, @@ -858,7 +884,7 @@ const providerExecutionSource = ( `${indent} throw new TypeError(\`Context provider "\${provider.key}" (\${provider.source}) must default-export a factory.\`);`, `${indent} }`, `${indent} try {`, - `${indent} providerValues[provider.key] = await provider.module.default({ invocation: ${invocation}, signal: ${signal} });`, + `${indent} providerValues[provider.key] = await provider.module.default({ invocation: ${invocation}, plugin: ${plugin}, signal: ${signal} });`, `${indent} } catch (error) {`, `${indent} throw new Error(\`Context provider "\${provider.key}" (\${provider.source}) failed: \${error instanceof Error ? error.message : String(error)}\`, { cause: error });`, `${indent} }`, @@ -881,8 +907,9 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo "import { parentPort } from 'node:worker_threads';", "import { createElement } from 'react';", "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", - "import { runAgentRequest, unavailable } from '@agent-bundle/runtime';", - ...generatedStateImports(options.state, 'artifact'), + "import { resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + ...pluginRootImports('artifact'), + ...generatedStateImports(options.state), ...noticeInboxImport(wiresInbox), ...routeImports(routes), ...eventRouteImports(eventRoutes, routes.length), @@ -895,7 +922,11 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo 'process.stdout.write = process.stderr.write.bind(process.stderr);', `const ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch)};`, 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', - ...generatedStateOwner(options.state, 'artifact', options), + // The worker resolves the same anchor as the server process beside it + // (same environment, same artifact layout); the server's observed value + // rides each render message and wins when present. + pluginRootDeclaration('artifact'), + ...generatedStateOwner(options.state, options), ...providerRegistrySource(providers), ...composeLayoutsSource(layouts), 'const routes = Object.freeze({', @@ -920,13 +951,15 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...(options.state === undefined ? [] : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });', ' try {']), - ...providerExecutionSource(providers, { indent: ' ', invocation: 'message.invocation', signal: 'controller.signal' }), + ' const plugin = message.plugin ?? pluginRoot.identity;', + ...providerExecutionSource(providers, { indent: ' ', invocation: 'message.invocation', plugin: 'plugin', signal: 'controller.signal' }), ' const bytes = await runAgentRequest({', ' ...(message.actor === undefined ? {} : { actor: message.actor }),', ' ...(message.host === undefined ? {} : { host: message.host }),', ' invocation: { ...message.requestInvocation, artifactEpoch: ARTIFACT_EPOCH, kind: message.invocation.kind, operationId: route.id, surface: route.name },', " lineage: message.lineage ?? unavailable('not-provided'),", ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), + ' plugin,', ' progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: \'progress\', update }); } },', ` providers: ${providerValuesExpression(providers)},`, ' ...(message.session === undefined ? {} : { session: message.session }),', @@ -1036,10 +1069,9 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti // appears inside an artifact that declared none. const durableLineage = options.state?.lifetime === 'workspace-durable'; return [ - ...(hasEvents || durableLineage - ? [`import { ${[...(hasEvents ? ['dirname'] : []), ...(durableLineage ? ['join'] : []), 'resolve'].join(', ')} } from 'node:path';`] - : []), - ...(durableLineage ? ["import { fileURLToPath } from 'node:url';"] : []), + ...(hasEvents ? ["import { dirname, resolve } from 'node:path';"] : []), + ...pluginRootImports('artifact'), + "import { resolvePluginRoot } from '@agent-bundle/runtime';", `import { createFlightWorkerHost, createGeneratedRouteMcpServer } from ${JSON.stringify(mcpServerRuntimeSpecifier)};`, ...(hasEvents ? [ @@ -1048,22 +1080,24 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ] : []), `import { ${durableLineage ? 'agentLineageStateDefinition, ' : ''}createAgentLineageRegistry } from '@agent-bundle/runtime/lineage';`, - ...(durableLineage ? ["import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';"] : []), + ...(durableLineage || wiresResourceUpdated ? ["import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';"] : []), "import mcpApps from 'agent-bundle/mcp-apps';", ...noticeDeliveryImports(wiresResourceUpdated), ...noticeInboxImport(wiresInbox), ...routeImports(routes), '', `const ARTIFACT_EPOCH = ${JSON.stringify(artifactEpoch)};`, + // The server process's one anchor (#468): the lineage journal, the notice + // store, and every request identity it publishes read `pluginRoot`. + pluginRootDeclaration('artifact'), ...(durableLineage ? [ // Beside the project's own durable state, so a restarted MCP process // still knows which subagents are alive. A store that cannot open // degrades to memory rather than failing the server: lineage is an // observed axis, never a precondition. - "const lineageAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));", 'const openLineage = async () => {', - " const driver = createSqliteStateDriver({ root: join(resolve(lineageAnchor), 'state') });", + ' const driver = createSqliteStateDriver({ root: pluginRoot.stateRoot });', ' try {', ' const store = await driver.open(agentLineageStateDefinition());', ' return { dispose: async () => { await store.close(); await driver.close(); }, registry: createAgentLineageRegistry({ store }) };', @@ -1113,6 +1147,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ' lineage: lineage.registry,', ...(wiresResourceUpdated ? [' notices: noticeDelivery,'] : []), ` plugin: ${stableJson(options.plugin)},`, + ' pluginRoot: pluginRoot.identity,', ' routes,', ' });', '};', diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 88d362acd..171ac2e33 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -34,6 +34,8 @@ export type { AgentLayoutRouteKind, AgentProviderContext, AgentProviderFactory, + AgentProviderObservedPluginRoot, + AgentProviderPluginRoot, AgentTerminal, AgentTerminalColor, AgentTerminalStream, diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 1cecf2e7c..0c72ecfcc 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -39,6 +39,7 @@ import type { AgentDocument, AgentHostIdentity, AgentLineage, + AgentPluginIdentity, AgentProgressReporter, AgentRenderDispatch, AgentRenderDispatcher, @@ -110,6 +111,7 @@ interface GeneratedRouteIdentity { readonly actor?: Observed; readonly host?: Observed; readonly lineage: Observed; + readonly plugin?: Observed; readonly session?: Observed; readonly terminal: Observed; readonly workspace: Observed; @@ -194,13 +196,15 @@ const toolCallLineage = async ( }); }; -/** Identity the server derives from the transport's own request context. */ +/** Identity the server derives from the transport's own request context, plus the process's resolved plugin root. */ const requestIdentity = ( context: GeneratedRouteRequestContext, clientName: string | undefined, lineage: Observed, + plugin: Observed | undefined, ): GeneratedRouteIdentity => ({ lineage, + ...(plugin === undefined ? {} : { plugin }), ...(context.http?.authInfo?.clientId === undefined ? {} : { actor: available({ id: context.http.authInfo.clientId }, 'native') }), @@ -253,9 +257,14 @@ export const renderGeneratedRoute = async ( route: GeneratedRouteRecord, input: unknown, context: GeneratedRouteRequestContext, - identity?: { readonly clientName?: string; readonly lineage?: Observed }, + identity?: { + readonly clientName?: string; + readonly lineage?: Observed; + /** The server process's resolved plugin root (#468), published on every request it opens. */ + readonly plugin?: Observed; + }, ): Promise => runAgentRequest({ - ...requestIdentity(context, identity?.clientName, identity?.lineage ?? unavailable('not-provided')), + ...requestIdentity(context, identity?.clientName, identity?.lineage ?? unavailable('not-provided'), identity?.plugin), invocation: { artifactEpoch, kind: 'tool', operationId: route.id, surface: route.name }, signal: context.mcpReq.signal, }, async () => { @@ -358,6 +367,8 @@ export interface RegisterGeneratedRoutesOptions { readonly lineage?: AgentLineageRegistry; /** The artifact's host, used when the negotiated client name maps to none. */ readonly lineageHost?: LineageHost; + /** The process's resolved plugin root (#468); every request the server opens publishes it as `request.plugin`. */ + readonly pluginRoot?: Observed; /** Raw `tools/call` arguments captured off the wire, for lineage correlation. */ readonly rawArguments?: RawToolArgumentsCapture; } @@ -387,7 +398,11 @@ export const registerGeneratedRoutes = ( route, input, context, - { clientName, lineage: await toolCallLineage(options.lineage, context, route.name, rawArguments, clientName, options.lineageHost) }, + { + clientName, + lineage: await toolCallLineage(options.lineage, context, route.name, rawArguments, clientName, options.lineageHost), + ...(options.pluginRoot === undefined ? {} : { plugin: options.pluginRoot }), + }, ); return attachMcpStructuredContent(rendered.toolResult, rendered.result); }, options.afterRender)) as never); @@ -410,7 +425,7 @@ export const registerGeneratedRoutes = ( route, { uri: resourceUri.href }, context, - { clientName }, + { clientName, ...(options.pluginRoot === undefined ? {} : { plugin: options.pluginRoot }) }, )).result; }, options.afterRender)) as never, ); @@ -428,7 +443,7 @@ export const registerGeneratedRoutes = ( route, input, context, - { clientName }, + { clientName, ...(options.pluginRoot === undefined ? {} : { plugin: options.pluginRoot }) }, )).result; }, options.afterRender)) as never); break; @@ -581,6 +596,7 @@ export const createFlightWorkerHost = ( id, invocation, lineage: context.lineage, + plugin: context.plugin, requestInvocation: context.invocation, session: context.session, terminal: context.terminal, @@ -686,6 +702,12 @@ export interface CreateGeneratedRouteMcpServerOptions { readonly lineage?: AgentLineageRegistry; readonly notices?: GeneratedNoticeDeliveryBinding; readonly plugin: { readonly name: string; readonly version: string }; + /** + * The process's resolved plugin root / durable-state anchor (#468), the same + * value the entry mounted its state on; published as `request.plugin` on + * every tool call, resource read, prompt get, and shared-runtime event. + */ + readonly pluginRoot?: Observed; readonly routes: Readonly>; } @@ -731,7 +753,7 @@ const installNoticeInboxSubscriptions = ( // Subscriptions are not tool calls: no pre-tool hook precedes them, so // there is no correlation window to resolve lineage through — a // subscriber therefore never matches a `conversation`/`root` recipient. - const identity = requestIdentity(context, protocol.getClientVersion()?.name, unavailable('not-provided')); + const identity = requestIdentity(context, protocol.getClientVersion()?.name, unavailable('not-provided'), undefined); try { await notices.subscribe({ actor: identity.actor ?? unavailable(), @@ -853,6 +875,7 @@ const startEventRuntime = async ( host: WarmFlightHost, afterRender: GeneratedRenderSettled | undefined, registry: AgentLineageRegistry | undefined, + pluginRoot: Observed | undefined, ): Promise<{ readonly close: () => Promise }> => { const startedAt = new Date().toISOString(); return events.createEventRuntimeServer({ @@ -902,6 +925,7 @@ const startEventRuntime = async ( surface: event, }, lineage, + ...(pluginRoot === undefined ? {} : { plugin: pluginRoot }), ...(sessionId === undefined ? {} : { session: available({ sessionId }, 'native') }), signal, // A hook's stdout is its host envelope: no terminal (#511). @@ -958,9 +982,10 @@ export const createGeneratedRouteMcpServer = async ( : installNoticeInboxSubscriptions(server, options.notices); const events = options.events === undefined ? undefined - : await startEventRuntime(options.events, dispatcher, options.host, afterRender, options.lineage); + : await startEventRuntime(options.events, dispatcher, options.host, afterRender, options.lineage, options.pluginRoot); registerGeneratedRoutes(server, options.routes, dispatcher, options.artifactEpoch, { ...(afterRender === undefined ? {} : { afterRender }), + ...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }), ...(options.lineage === undefined ? {} : { lineage: options.lineage, rawArguments: captureRawToolArguments(server) }), ...(options.events === undefined || lineageHostFor(options.events.target) === undefined ? {} diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index db0a30e68..598fa65a2 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -407,6 +407,6 @@ export const validateProviderModuleContract = ( 'AB4940', `Provider module ${relativePath} does not satisfy the public provider contract: ${defaultExportDetail(exports, 'a function')}.`, sourcePath, - 'Default-export a provider factory receiving { invocation, signal }.', + 'Default-export a provider factory receiving { invocation, plugin, signal }.', )]); }; diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index 610b0016d..d74b18d51 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -65,6 +65,8 @@ export type { AgentLayoutRouteKind, AgentProviderContext, AgentProviderFactory, + AgentProviderObservedPluginRoot, + AgentProviderPluginRoot, AppRouteConfig, CanonicalAgentEvent, CliRouteConfig, diff --git a/packages/agent-bundle/src/routes/provider-execution.ts b/packages/agent-bundle/src/routes/provider-execution.ts index fdaa34d74..a9d2cdd99 100644 --- a/packages/agent-bundle/src/routes/provider-execution.ts +++ b/packages/agent-bundle/src/routes/provider-execution.ts @@ -66,6 +66,8 @@ export interface ExecutableProvider { export interface ExecuteProvidersOptions { /** The surface-specific provider invocation (`tool`, `event`, `cli`, `script`). */ readonly invocation: unknown; + /** The observed plugin root the request scope publishes (#468); handed to every factory unchanged. */ + readonly plugin: unknown; readonly processLifetime: ProviderProcessLifetime; /** Providers already in {@link orderedProviders} order. */ readonly providers: readonly ExecutableProvider[]; @@ -91,8 +93,9 @@ export const executeProviders = async ( try { values[provider.key] = await (factory as (context: { readonly invocation: unknown; + readonly plugin: unknown; readonly signal: AbortSignal; - }) => unknown)({ invocation: options.invocation, signal: options.signal }); + }) => unknown)({ invocation: options.invocation, plugin: options.plugin, signal: options.signal }); } catch (error) { throw new Error(providerFailedMessage(provider.key, provider.source, error), { cause: error }); } diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index f20bb8fc8..bbc953bd3 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -95,9 +95,30 @@ type AgentProviderInvocation = readonly props: { readonly input?: JsonValue; readonly view: string }; }; +/** + * The plugin install root and durable-state anchor a generated scope resolved + * (#468), as `(await agent()).plugin` observes it: `root` is the expanded + * `AGENT_BUNDLE_PLUGIN_ROOT` (`source: 'native'`) or the shell's fallback + * (`'derived'`), and `stateRoot` is `/state`, where the SQLite kernel, + * the notice ledger, and the lineage journal live. Declared here so + * config-only consumers need no runtime import; structurally identical to the + * runtime's `AgentPluginIdentity`. + */ +export interface AgentProviderPluginRoot { + readonly root: string; + readonly stateRoot: string; +} + +/** The observed plugin root a provider receives; the same shape as every `agent()` identity axis. */ +export type AgentProviderObservedPluginRoot = + | { readonly source: 'native' | 'receipt' | 'derived'; readonly state: 'available'; readonly value: AgentProviderPluginRoot } + | { readonly reason: string; readonly state: 'unavailable' }; + /** Request-scoped inputs supplied to a conventional context provider factory. */ export interface AgentProviderContext { readonly invocation: AgentProviderInvocation; + /** The resolved plugin root, exactly what the route will read as `(await agent()).plugin`. */ + readonly plugin: AgentProviderObservedPluginRoot; readonly signal: AbortSignal; } diff --git a/packages/agent-bundle/src/test/cli.ts b/packages/agent-bundle/src/test/cli.ts index 9fa5be46b..026b6adbb 100644 --- a/packages/agent-bundle/src/test/cli.ts +++ b/packages/agent-bundle/src/test/cli.ts @@ -27,7 +27,7 @@ import type { CompiledCliCommand } from '../routes/types.ts'; import { AgentTestError, captured } from './errors.ts'; import { CLI_DISPATCH_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts'; import { parseCanonicalJsonLine, parseRenderedEventLines } from './output-modes.ts'; -import { claimProcessHit, mountProviders } from './providers.ts'; +import { claimProcessHit, harnessPluginRoot, mountProviders } from './providers.ts'; import { registeredRouteLoader, testManifest } from './registry.ts'; import { prepareCliRenderHost, type HarnessOptionsArguments, type RenderRouteContextInit } from './render.ts'; import { harnessTerminal } from './terminal.ts'; @@ -116,6 +116,7 @@ const noCommands = (manifest: AgentBundleTestManifest): AgentTestError => new Ag interface Runtime { readonly available: typeof AgentRuntime.available; + readonly resolvePluginRoot: typeof AgentRuntime.resolvePluginRoot; readonly runAgentRequest: typeof AgentRuntime.runAgentRequest; readonly unavailable: typeof AgentRuntime.unavailable; } @@ -131,6 +132,7 @@ const loadRuntime = async (): Promise => { runtimePromise ??= import('@agent-bundle/runtime') .then((runtime) => ({ available: runtime.available, + resolvePluginRoot: runtime.resolvePluginRoot, runAgentRequest: runtime.runAgentRequest, unavailable: runtime.unavailable, })) @@ -249,11 +251,13 @@ export const invokeCli = async ( throw cliInputError(command, input, error); } const root = process.cwd(); + const plugin = harnessPluginRoot({ context, manifest, resolvePluginRoot: runtime.resolvePluginRoot }); // Same provider invocation the generated plain-command path builds (#366). const providers = await mountProviders({ explicit: context.providers, invocation: { kind: 'cli', props: { args: execution.args, command: commandPath(command) } }, manifest, + plugin, processHit: claimProcessHit(processLifetime), provenance: { ...provenance, kind: 'cli', routeId: command.routeId, source: 'manifest', targets: [] }, signal: execution.signal, @@ -266,6 +270,7 @@ export const invokeCli = async ( projectRoot: runtime.available({ root }, 'derived'), }, host: runtime.unavailable('unsupported-surface'), + plugin, terminal: runtime.available(execution.terminal, 'native'), workspace: runtime.available({ root }, 'derived'), ...context, diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 9a0d1e492..12b36b9e6 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -40,7 +40,7 @@ import { createProviderProcessLifetime } from '../routes/provider-execution.ts'; import { AgentTestError, captured } from './errors.ts'; import { composeLayouts, loadLayoutChain, type LoadedLayout } from './layouts.ts'; import { MCP_IN_MEMORY_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts'; -import { claimProcessHit, mountProviders } from './providers.ts'; +import { claimProcessHit, harnessPluginRoot, mountProviders } from './providers.ts'; import { registeredRouteLoader, testManifest } from './registry.ts'; import type { HarnessOptionsArguments, RenderRouteContext, RenderRouteContextInit } from './render.ts'; import type { RenderedRouteProvenance, TestableRouteDescriptor } from './types.ts'; @@ -263,6 +263,7 @@ interface Renderer { readonly createWarmFlightHost: typeof import('@agent-bundle/runtime').createWarmFlightHost; readonly noticeInboxRoute: typeof import('@agent-bundle/runtime/notices/inbox-route'); readonly renderAgentFlight: typeof import('@agent-bundle/runtime/flight/server').renderAgentFlight; + readonly resolvePluginRoot: typeof import('@agent-bundle/runtime').resolvePluginRoot; readonly runAgentRequest: typeof import('@agent-bundle/runtime').runAgentRequest; } @@ -304,6 +305,7 @@ const loadDependencies = async (): Promise => { createWarmFlightHost: runtime.createWarmFlightHost, noticeInboxRoute, renderAgentFlight: flight.renderAgentFlight, + resolvePluginRoot: runtime.resolvePluginRoot, runAgentRequest: runtime.runAgentRequest, }; })().catch((error: unknown) => { @@ -446,6 +448,8 @@ export const openInMemoryMcpServer = async < const runtimeState = options.state === undefined ? undefined : dependencies.createGeneratedRuntimeState(options.state); + // One resolution per open server, as the generated entry does at startup. + const pluginRoot = harnessPluginRoot({ context, manifest, resolvePluginRoot: dependencies.resolvePluginRoot }); const host = dependencies.createWarmFlightHost({ artifactEpoch, host: { @@ -469,10 +473,15 @@ export const openInMemoryMcpServer = async < // Conventional providers run before the scope opens, over the same // tool invocation the generated Flight worker hands them. const descriptor = manifest.routes[route.id]; + // The server process's anchor, as the artifact's host scope forwards + // it into the Flight worker; the context seam overrides it like every + // other identity axis. + const plugin = transport.plugin.state === 'available' ? transport.plugin : pluginRoot; const providers = await mountProviders({ explicit: context.providers, invocation: request.invocation, manifest, + plugin, processHit, ...(descriptor === undefined ? {} : { provenance: routeProvenance(descriptor, manifest) }), signal: request.signal, @@ -483,6 +492,7 @@ export const openInMemoryMcpServer = async < actor: transport.actor, host: transport.host, lineage: transport.lineage, + plugin, session: transport.session, terminal: transport.terminal, workspace: transport.workspace, @@ -551,6 +561,7 @@ export const openInMemoryMcpServer = async < }), ...(options.limits === undefined ? {} : { limits: options.limits }), plugin: manifest.plugin, + pluginRoot, routes: routes as never, }); const client = new dependencies.Client({ name: 'agent-bundle-in-memory-projection', version: '1.0.0' }); diff --git a/packages/agent-bundle/src/test/providers.ts b/packages/agent-bundle/src/test/providers.ts index 028c4e94e..41ac982a9 100644 --- a/packages/agent-bundle/src/test/providers.ts +++ b/packages/agent-bundle/src/test/providers.ts @@ -1,4 +1,6 @@ -import type { AgentProviderValues } from '@agent-bundle/runtime'; +import { join } from 'node:path'; + +import type { AgentPluginIdentity, AgentProviderValues, Observed, resolvePluginRoot } from '@agent-bundle/runtime'; import { executeProviders, @@ -43,6 +45,8 @@ export interface MountProvidersOptions { readonly invocation: unknown; /** Absent for a module rendered directly: no project, so nothing to discover. */ readonly manifest: AgentBundleTestManifest | undefined; + /** The observed plugin root the simulated scope publishes as `request.plugin` (#468). */ + readonly plugin: unknown; /** * This request's claimed hit on the simulated executable's process identity * (see {@link claimProcessHit}); mounted verbatim as `providers.processLifetime`. @@ -89,6 +93,28 @@ export const claimProcessHit = (processLifetime: ProviderProcessLifetime): Provi return providerProcessLifetimeValue(processLifetime); }; +export interface HarnessPluginRootOptions { + /** The test's context seam; an explicit `plugin` wins, as every other injected axis does. */ + readonly context: { readonly plugin?: Observed }; + /** Absent for a module rendered directly. */ + readonly manifest: AgentBundleTestManifest | undefined; + /** The runtime's resolver, loaded with the rest of the renderer. */ + readonly resolvePluginRoot: typeof resolvePluginRoot; +} + +/** + * The plugin root a harness request scope publishes as `request.plugin` and + * hands its providers (#468): the test's `context.plugin` when injected, + * otherwise the runtime's own resolution — `AGENT_BUNDLE_PLUGIN_ROOT` when the + * environment sets it, else the project root's `.agent-bundle` (the npm + * package bin's fallback; the working directory's for a module rendered + * directly). Harness state itself mounts in a temporary directory, so this + * value describes where an artifact would anchor, not where the test wrote. + */ +export const harnessPluginRoot = (options: HarnessPluginRootOptions): Observed => + options.context.plugin + ?? options.resolvePluginRoot({ fallback: join(options.manifest?.projectRoot ?? process.cwd(), '.agent-bundle') }).identity; + /** * The `providers` value for one harness request scope: the explicit map when * the test supplied one, otherwise the project's conventional providers @@ -105,6 +131,7 @@ export const mountProviders = async (options: MountProvidersOptions): Promise => { createGeneratedRuntimeState: mount.createGeneratedRuntimeState, createMemoryStateDriver: state.createMemoryStateDriver, renderAgentFlight: flight.renderAgentFlight, + resolvePluginRoot: runtime.resolvePluginRoot, runAgentRequest: runtime.runAgentRequest, unavailable: runtime.unavailable, }; @@ -1126,10 +1128,12 @@ export const prepareCliRenderHost = async ( renderer, requestInit: async (request) => { const root = process.cwd(); + const plugin = harnessPluginRoot({ context, manifest: options.manifest, resolvePluginRoot: renderer.resolvePluginRoot }); const providers = await mountProviders({ explicit: context.providers, invocation, manifest: options.manifest, + plugin, processHit: claimProcessHit(options.processLifetime), provenance: { ...options.provenance, routeId: command.routeId }, signal: request.signal, @@ -1142,6 +1146,7 @@ export const prepareCliRenderHost = async ( projectRoot: renderer.available({ root }, 'derived'), }, host: renderer.unavailable('unsupported-surface'), + plugin, terminal: renderer.available(execution.terminal, 'native'), workspace: renderer.available({ root }, 'derived'), ...context, @@ -1324,10 +1329,12 @@ export const prepareScriptRenderHost = async ( const root = process.cwd(); // The generated script's render worker hands its providers the // `script` invocation with the path-derived name, never the route id. + const plugin = harnessPluginRoot({ context, manifest: options.manifest, resolvePluginRoot: renderer.resolvePluginRoot }); const providers = await mountProviders({ explicit: context.providers, invocation, manifest: options.manifest, + plugin, processHit: claimProcessHit(options.processLifetime), provenance: options.provenance, signal: request.signal, @@ -1340,6 +1347,7 @@ export const prepareScriptRenderHost = async ( projectRoot: renderer.available({ root }, 'derived'), }, host: renderer.unavailable('unsupported-surface'), + plugin, terminal: renderer.available(execution.terminal, 'native'), workspace: renderer.available({ root }, 'derived'), ...context, @@ -1425,6 +1433,7 @@ const prepareRender = async ( // request; nothing is warm across renders, so each starts at hit 1. const processLifetime = createProviderProcessLifetime(); const mounted = await mountManifestState(resolved.manifest, resolved.provenance, context, renderer, signal); + const plugin = harnessPluginRoot({ context, manifest: resolved.manifest, resolvePluginRoot: renderer.resolvePluginRoot }); const dispatcher = createFlightDispatcher({ collected, component: resolved.component, @@ -1443,6 +1452,7 @@ const prepareRender = async ( : { limits: { ...options.limits, ...resolved.render } }), renderer, requestInit: async (request) => ({ + plugin, // What the artifact's scope for this route kind mounts (#511): no // terminal under MCP or a hook, the harness's piped shape otherwise. terminal: renderer.available(routeKindTerminal(resolved.kind), 'derived'), @@ -1454,6 +1464,7 @@ const prepareRender = async ( explicit: context.providers, invocation: request.invocation, manifest: resolved.manifest, + plugin, processHit: claimProcessHit(processLifetime), provenance: resolved.provenance, signal: request.signal, diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 7ea4cd541..a2f1e43d8 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -368,8 +368,12 @@ it('journals the lineage registry through sqlite only for workspace-durable proj }); expect(source).toContain("import { agentLineageStateDefinition, createAgentLineageRegistry } from '@agent-bundle/runtime/lineage'"); expect(source).toContain("import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'"); - expect(source).toContain("const lineageAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));"); - expect(source).toContain("createSqliteStateDriver({ root: join(resolve(lineageAnchor), 'state') })"); + // One anchor per process (#468): the lineage journal opens on the same + // `pluginRoot` the server publishes as `request.plugin` and mounts state on. + expect(source).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });"); + expect(source).toContain('createSqliteStateDriver({ root: pluginRoot.stateRoot })'); + expect(source).toContain(' pluginRoot: pluginRoot.identity,'); + expect(source).not.toContain('AGENT_BUNDLE_PLUGIN_ROOT'); expect(source).toContain('agent-bundle lineage registry is in-memory only'); expect(source).toContain('disposeLineage: lineage.dispose,'); }); @@ -417,7 +421,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain("lineage: message.lineage ?? unavailable('not-provided'),"); expect(source).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '16bcae6386fbba1c664806a732e02373cc753d9c3baa976782218bdb88847773', + '93cdfe64b98e0add920ed3f4daa3916620a3f750ec9dbcefc6be6419efab38e5', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -599,7 +603,9 @@ it('generates deterministic per-request provider execution in the shared Flight source.indexOf('/project/src/providers/zeta.ts'), ); expect(source).toContain('key: "alphaValue"'); - expect(source).toContain('await provider.module.default({ invocation: message.invocation, signal: controller.signal })'); + expect(source).toContain('await provider.module.default({ invocation: message.invocation, plugin: plugin, signal: controller.signal })'); + // The server's observed anchor rides each render message; the worker's own resolution backs it. + expect(source).toContain('const plugin = message.plugin ?? pluginRoot.identity;'); expect(source).toContain('Context provider "'); expect(source).toContain('provider.source'); expect(source).toContain('providers: providerValues'); @@ -649,7 +655,7 @@ it('mounts deterministic per-request providers for plain routed CLI commands (#3 ); expect(withProviders).toContain('key: "alphaValue"'); expect(withProviders).toContain( - "await provider.module.default({ invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }, signal: context.signal })", + "await provider.module.default({ invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }, plugin: pluginRoot.identity, signal: context.signal })", ); expect(withProviders).toContain('must default-export a factory.'); expect(withProviders).toContain('failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error })'); @@ -706,7 +712,7 @@ it('mounts deterministic per-request providers in rendered route workers', () => expect(source.indexOf('/project/src/providers/alpha-value.ts')).toBeLessThan( source.indexOf('/project/src/providers/zeta.ts'), ); - expect(source).toContain('await provider.module.default({ invocation: message.invocation, signal: controller.signal })'); + expect(source).toContain('await provider.module.default({ invocation: message.invocation, plugin: pluginRoot.identity, signal: controller.signal })'); expect(source).toContain('providers: providerValues'); expect(source).toContain('processLifetime'); @@ -768,30 +774,36 @@ it('keeps the generated provider loop and the in-process execution helper identi // Behavior: processLifetime seeded first, deterministic order, fail-closed on both defects. const lifetime = { hits: 3, instanceId: 'instance-1', pid: 42 }; const calls: string[] = []; + const plugin = { source: 'derived', state: 'available', value: { root: '/plugin', stateRoot: '/plugin/state' } }; const values = await executeProviders({ invocation: { kind: 'cli', props: { args: [], command: 'report' } }, + plugin, processLifetime: lifetime, providers: [ - { key: 'alphaValue', module: { default: (context: { invocation: unknown }) => { calls.push('alphaValue'); return context.invocation; } }, source: 'src/providers/alpha-value.ts' }, + { key: 'alphaValue', module: { default: (context: { invocation: unknown; plugin: unknown }) => { calls.push('alphaValue'); return [context.invocation, context.plugin]; } }, source: 'src/providers/alpha-value.ts' }, { key: 'zeta', module: { default: async () => { calls.push('zeta'); return 'z'; } }, source: 'src/providers/zeta.ts' }, ], signal: new AbortController().signal, }); expect(Object.keys(values)).toEqual(['processLifetime', 'alphaValue', 'zeta']); + // Providers receive the invocation and the observed plugin root (#468) — the same value the request scope publishes. expect(values).toEqual({ - alphaValue: { kind: 'cli', props: { args: [], command: 'report' } }, + alphaValue: [{ kind: 'cli', props: { args: [], command: 'report' } }, plugin], processLifetime: { hits: 3, instanceId: 'instance-1', pid: 42 }, zeta: 'z', }); + expect(source).toContain('await provider.module.default({ invocation: message.invocation, plugin: pluginRoot.identity, signal: controller.signal })'); expect(calls).toEqual(['alphaValue', 'zeta']); await expect(executeProviders({ invocation: undefined, + plugin: undefined, processLifetime: lifetime, providers: [{ key: 'zeta', module: {}, source: 'src/providers/zeta.ts' }], signal: new AbortController().signal, })).rejects.toThrow('Context provider "zeta" (src/providers/zeta.ts) must default-export a factory.'); await expect(executeProviders({ invocation: undefined, + plugin: undefined, processLifetime: lifetime, providers: [{ key: 'zeta', module: { default: () => { throw new Error('boom'); } }, source: 'src/providers/zeta.ts' }], signal: new AbortController().signal, @@ -1071,11 +1083,11 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(durableEntry).toContain("import { createGeneratedNoticeRuntime } from '@agent-bundle/runtime/mount';"); expect(durableEntry).toContain("import { createNoticeInboxSignaller } from '@agent-bundle/runtime/notices';"); expect(durableEntry).toContain("import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';"); - expect(durableEntry).toContain("const durableAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));"); + expect(durableEntry).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });"); // The host's advertisement is declared once and handed to both the ledger // (whose sensitivity ceilings it carries) and the signaller (#99 item 7). expect(durableEntry).toContain(`const noticeDeliveryAdvertisement = Object.freeze(${stableJson(claudeAdapter.noticeDelivery)});`); - expect(durableEntry).toContain("createNoticeInboxSignaller({ delivery: noticeDeliveryAdvertisement, store: createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }), lifetime: 'workspace-durable', noticeDelivery: noticeDeliveryAdvertisement }) })"); + expect(durableEntry).toContain("createNoticeInboxSignaller({ delivery: noticeDeliveryAdvertisement, store: createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: pluginRoot.stateRoot }), lifetime: 'workspace-durable', noticeDelivery: noticeDeliveryAdvertisement }) })"); expect(durableEntry).not.toContain('noticeRetentionPolicy'); expect(durableEntry).toContain(' notices: noticeDelivery,'); // A declared `notices.retention` travels as one frozen literal too. @@ -1119,7 +1131,6 @@ it('conditionally emits generated state mounting without leaking sqlite into vol for (const identifier of [ 'createGeneratedNoticeRuntime', 'createNoticeInboxSignaller', - 'durableAnchor', 'notices: noticeDelivery', ]) { expect(unsupportedEntry).not.toContain(identifier); @@ -1195,8 +1206,9 @@ it('conditionally emits generated state mounting without leaking sqlite into vol state: state('workspace-durable'), }); expect(durable).toContain("from '@agent-bundle/runtime/state/sqlite'"); - expect(durable).toContain('AGENT_BUNDLE_PLUGIN_ROOT'); - expect(durable).toContain("join(durableAnchor, 'state')"); + expect(durable).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });"); + expect(durable).toContain('createSqliteStateDriver({ root: pluginRoot.stateRoot })'); + expect(durable).not.toContain('AGENT_BUNDLE_PLUGIN_ROOT'); const renderedWorker = entryShellModule.generatedRenderedRouteWorkerSource({ routes: [{ ...route, id: 'script:report', kind: 'script' }], diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index fccaee8ef..2e0984f49 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -169,6 +169,7 @@ it('serves compiled routes and durable state across packed process restarts', as 'layout-probe', 'lifecycle', 'mutation-probe', + 'plugin-root', 'publish-notice', 'strict-report', 'ticket', diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index 7cb51eb05..465538d7c 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -35,6 +35,7 @@ describe('the CLI dispatch level', () => { 'harness layout-probe', 'harness lifecycle', 'harness mutation-probe', + 'harness plugin-root', 'harness publish-notice', 'harness strict-report', 'harness ticket', diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index 486ff18f0..daf191f81 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -33,7 +33,7 @@ describe('the in-memory MCP projection level', () => { it('registers every compiled route kind on the real generated server', async () => { const surface = await listMcpSurface(); - expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'fault', 'journal', 'layout-probe', 'lifecycle', 'mutation-probe', 'publish-notice', 'strict-report', 'ticket', 'tooling', 'unavailable', 'wait']); + expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'fault', 'journal', 'layout-probe', 'lifecycle', 'mutation-probe', 'plugin-root', 'publish-notice', 'strict-report', 'ticket', 'tooling', 'unavailable', 'wait']); expect(surface.prompts).toEqual(['summarize']); expect(surface.resources).toEqual(['harness://notes']); expect(surface.provenance).toMatchObject({ @@ -49,6 +49,7 @@ describe('the in-memory MCP projection level', () => { 'tool:harness/layout-probe', 'tool:harness/lifecycle', 'tool:harness/mutation-probe', + 'tool:harness/plugin-root', 'tool:harness/publish-notice', 'tool:harness/strict-report', 'tool:harness/ticket', @@ -287,6 +288,33 @@ describe('the in-memory MCP projection level', () => { }); }); + it('publishes the plugin root the server process resolved on every tool call, and forwards a context override (#468)', async () => { + const anchor = 'AGENT_BUNDLE_PLUGIN_ROOT'; + const previous = process.env[anchor]; + process.env[anchor] = '/installs/harness'; + try { + // The server resolves the anchor once when it opens, exactly as the + // generated entry does at startup; every request then observes it. + await using session = await openInMemoryMcpServer(); + const result = await session.client.callTool({ arguments: {}, name: 'plugin-root' }); + expect(result).toMatchObject({ + structuredContent: { + plugin: { source: 'native', state: 'available', value: { root: '/installs/harness', stateRoot: '/installs/harness/state' } }, + }, + }); + } finally { + if (previous === undefined) delete process.env[anchor]; + else process.env[anchor] = previous; + } + + const injected = await invokeMcpTool('plugin-root', { + context: { plugin: { source: 'receipt', state: 'available', value: { root: '/fixture', stateRoot: '/fixture/state' } } as never }, + }); + expect(injected.structuredContent).toEqual({ + plugin: { source: 'receipt', state: 'available', value: { root: '/fixture', stateRoot: '/fixture/state' } }, + }); + }); + it('reads a compiled resource route by its configured URI', async () => { const read = await readMcpResource('harness://notes'); diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts index fd10e9418..fe21ac485 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -1,3 +1,5 @@ +import { join } from 'node:path'; + import { Agent, agent, useAgent } from '@agent-bundle/runtime'; import { describe, expect, it } from '@rstest/core'; import { createElement } from 'react'; @@ -320,6 +322,55 @@ describe('renderRoute through the real renderer', () => { }); }); + describe('the plugin root axis (#468)', () => { + const anchor = 'AGENT_BUNDLE_PLUGIN_ROOT'; + const withAnchor = async (value: string | undefined, body: () => Promise): Promise => { + const previous = process.env[anchor]; + if (value === undefined) delete process.env[anchor]; + else process.env[anchor] = value; + try { + return await body(); + } finally { + if (previous === undefined) delete process.env[anchor]; + else process.env[anchor] = previous; + } + }; + + it('observes the expanded AGENT_BUNDLE_PLUGIN_ROOT as the native anchor, with state one level below', async () => { + const rendered = await withAnchor('/installs/harness', () => renderRoute('tool:harness/plugin-root')); + + expectDocument(rendered).toHaveStatus('success').toContainText('plugin root: /installs/harness'); + expect(rendered.result).toEqual({ + plugin: { source: 'native', state: 'available', value: { root: '/installs/harness', stateRoot: '/installs/harness/state' } }, + }); + }); + + it("derives the project root's .agent-bundle when the anchor is unset, the npm bin's fallback", async () => { + const root = join(testManifest().projectRoot, '.agent-bundle'); + const rendered = await withAnchor(undefined, () => renderRoute('tool:harness/plugin-root')); + + expect(rendered.result).toEqual({ + plugin: { source: 'derived', state: 'available', value: { root, stateRoot: join(root, 'state') } }, + }); + }); + + it('treats an unexpanded host token as unset instead of joining it into a path', async () => { + const root = join(testManifest().projectRoot, '.agent-bundle'); + const rendered = await withAnchor('${CLAUDE_PLUGIN_ROOT}', () => renderRoute('tool:harness/plugin-root')); + + expect(rendered.result).toEqual({ + plugin: { source: 'derived', state: 'available', value: { root, stateRoot: join(root, 'state') } }, + }); + }); + + it('lets the context seam inject the axis like every other identity axis', async () => { + const plugin = { source: 'receipt', state: 'available', value: { root: '/fixture', stateRoot: '/fixture/state' } } as never; + const rendered = await renderRoute('tool:harness/plugin-root', { context: { plugin } }); + + expect(rendered.result).toEqual({ plugin }); + }); + }); + it('reports a represented error as the document status the runtime decided', async () => { const rendered = await renderRoute('tool:harness/unavailable'); diff --git a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts index cda7d6af6..a4392ffd5 100644 --- a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts +++ b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts @@ -92,6 +92,7 @@ export const routeHarnessContractFixtures = (): Record { 'tool:harness/layout-probe', 'tool:harness/lifecycle', 'tool:harness/mutation-probe', + 'tool:harness/plugin-root', 'tool:harness/publish-notice', 'tool:harness/strict-report', 'tool:harness/ticket', @@ -329,6 +330,7 @@ describe('the compiled test manifest', () => { projected('layout-probe', 'Renders a bare valued result so the layout chain around it is observable.', false), projected('lifecycle', 'Replays a deterministic durable lifecycle through mounted state.', true), projected('mutation-probe', 'Records how many times the mutation probe executed.', true), + projected('plugin-root', 'Reports the plugin root and durable-state anchor this route observes.', false), projected('publish-notice', 'Publishes a durable notice for a later session event.', true), projected('strict-report', 'Returns a closed-object report that rejects unknown serialized keys.', true), projected('ticket', 'Returns a cargo-conductor-shaped ticket status with optional diagnostics fields.', true), diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 85b89c1be..a028f4f1a 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -8,10 +8,10 @@ import type { } from './notices/contract.js'; import type { AgentStateHandle } from './state/contract.js'; -// Bumped to 3 when `lineage` joined the handle shape and to 4 when `terminal` -// did: a realm that already holds an older store must fail closed rather than -// hand out handles without them. -export const AGENT_REQUEST_STORE_VERSION = 4; +// Bumped to 3 when `lineage` joined the handle shape, to 4 when `terminal` +// did, and to 5 when `plugin` did: a realm that already holds an older store +// must fail closed rather than hand out handles without them. +export const AGENT_REQUEST_STORE_VERSION = 5; const STORE_SYMBOL = Symbol.for('@agent-bundle/runtime/request-store'); @@ -85,6 +85,20 @@ export interface AgentWorkspaceIdentity { readonly root: string; } +/** + * Where this plugin is installed and where its durable state lives (#468) — + * the one anchor every generated shell resolves from `AGENT_BUNDLE_PLUGIN_ROOT` + * (source `native`) or, when the host supplies none, from the artifact root or + * the caller's `.agent-bundle` directory (source `derived`). `stateRoot` is + * `/state`: the directory the SQLite state kernel, the notice ledger, and + * the lineage journal all mount, so a route, layout, or provider that keeps + * its own files beside them reads this instead of re-deriving the anchor. + */ +export interface AgentPluginIdentity { + readonly root: string; + readonly stateRoot: string; +} + /** The subagent a lineage describes when the current conversation is not the root. */ export interface AgentLineageSubagent { /** The host's own id for the subagent (Claude/Codex `agent_id`, Cursor `subagent_id`). */ @@ -346,6 +360,12 @@ export interface AgentRequestContext { readonly session: Observed; readonly actor: Observed; readonly workspace: Observed; + /** + * The plugin install root and durable-state anchor the generated shell + * resolved (#468); `unavailable('not-provided')` outside a generated scope + * that supplied none. + */ + readonly plugin: Observed; /** * Conversation lineage resolved by the warm runtime's registry (fed by the * subagent start/stop event families and pre-tool hooks) or straight from @@ -398,6 +418,7 @@ export interface AgentRequestInitBase { readonly lineage?: Observed; /** Optional durable notice authority; omitted projects load no notice code. */ readonly noticeLedger?: AgentNoticeLedger; + readonly plugin?: Observed; readonly progress?: AgentProgressReporter; readonly services?: AgentServiceRegistry; readonly session?: Observed; @@ -497,6 +518,7 @@ interface FrozenValues { readonly invocation: AgentInvocation; readonly lineage: Observed; readonly notices: AgentNoticesHandle | undefined; + readonly plugin: Observed; readonly progress: AgentProgressReporter; readonly providers: AgentProviderValues; readonly services: AgentServiceRegistry; @@ -568,6 +590,9 @@ const createHandle = (lease: Lease): AgentRequestContext => Object.freeze({ get workspace() { return open(lease).workspace; }, + get plugin() { + return open(lease).plugin; + }, get lineage() { return open(lease).lineage; }, @@ -642,6 +667,7 @@ export const runAgentRequest = async ( const host = snapshotObserved(init.host ?? unavailable()); const invocation = invocationFrom(init.invocation); const lineage = snapshotObserved(init.lineage ?? unavailable()); + const plugin = snapshotObserved(init.plugin ?? unavailable()); const session = snapshotObserved(init.session ?? unavailable()); const signal = init.signal ?? new AbortController().signal; const terminal = snapshotObserved(init.terminal ?? unavailable()); @@ -660,6 +686,7 @@ export const runAgentRequest = async ( invocation, lineage, notices: noticeLease?.handle, + plugin, progress: init.progress ?? silentProgress, providers: Object.freeze({ ...(init.providers ?? {}) }), services: Object.freeze({ ...(init.services ?? {}) }), diff --git a/packages/rsc-runtime/src/plugin-root.ts b/packages/rsc-runtime/src/plugin-root.ts new file mode 100644 index 000000000..6c4aaff32 --- /dev/null +++ b/packages/rsc-runtime/src/plugin-root.ts @@ -0,0 +1,87 @@ +import { join, resolve } from 'node:path'; + +import { available, type AgentPluginIdentity, type Observed } from './agent-request.js'; + +/** + * The environment variable every emitted stdio entry, hook wrapper, and + * artifact CLI receives with the plugin install root in the host's own + * spelling (`${CLAUDE_PLUGIN_ROOT}`, `${CURSOR_PLUGIN_ROOT}`, `${PLUGIN_ROOT}`, + * `./` on Codex). `agent-bundle` exports the same name as `pluginRootEnvAnchor`. + */ +export const PLUGIN_ROOT_ENV_ANCHOR = 'AGENT_BUNDLE_PLUGIN_ROOT'; + +/** The directory below the anchor where durable state (SQLite kernel, notice ledger, lineage journal) lives. */ +export const PLUGIN_STATE_DIRECTORY = 'state'; + +export interface ResolvePluginRootOptions { + /** The environment to read; `process.env` by default. */ + readonly env?: Readonly>; + /** + * Where the plugin anchors when the host supplies no `AGENT_BUNDLE_PLUGIN_ROOT`: + * the artifact root (the parent of `mcp/`, `bin/`, `hooks/`) for artifact + * shells, or the caller's `.agent-bundle` directory for the npm package bin. + */ + readonly fallback: string; + /** Receives one line when the anchor is present but unexpanded; stderr by default. */ + readonly warn?: (message: string) => void; +} + +/** The anchor a generated shell resolved once and mounts everything on. */ +export interface ResolvedPluginRoot extends AgentPluginIdentity { + /** The same value as an observed request axis, ready for `runAgentRequest({ plugin })`. */ + readonly identity: Observed; + /** `native` when `AGENT_BUNDLE_PLUGIN_ROOT` supplied the root, `derived` for the fallback. */ + readonly source: 'native' | 'derived'; +} + +/** A host that passed its manifest through literally leaves `${CLAUDE_PLUGIN_ROOT}`-style tokens in the value. */ +const unexpandedToken = /\$\{[^}]*\}/u; + +const defaultWarn = (message: string): void => { + process.stderr.write(`${message}\n`); +}; + +/** + * The one resolution of the plugin root / durable-state anchor (#468). The + * generated MCP entry, its Flight worker, the routed CLI executable, its + * render worker, and the hook wrappers all call this once at startup, mount + * SQLite at `stateRoot`, and publish `identity` on every request they open, + * so `(await agent()).plugin.stateRoot` is by construction the directory the + * kernel, the notice ledger, and the lineage journal use. + * + * `AGENT_BUNDLE_PLUGIN_ROOT` wins when it is set to a non-blank, expanded + * value (`source: 'native'`), taken exactly as written — a path is never + * trimmed. A blank value or one still carrying a `${…}` + * token is treated as unset — the token case is reported once on stderr, + * because it means the host did not expand its manifest — and the shell's + * `fallback` anchors the plugin (`source: 'derived'`). Both roots are made + * absolute against the working directory, as the kernel always did. + */ +export const resolvePluginRoot = (options: ResolvePluginRootOptions): ResolvedPluginRoot => { + const env = options.env ?? process.env; + const declared = env[PLUGIN_ROOT_ENV_ANCHOR] ?? ''; + let root: string; + let source: 'native' | 'derived'; + if (declared.trim() === '') { + root = resolve(options.fallback); + source = 'derived'; + } else if (unexpandedToken.test(declared)) { + (options.warn ?? defaultWarn)( + `[agent-bundle] ${PLUGIN_ROOT_ENV_ANCHOR} is the unexpanded token ${JSON.stringify(declared)}; anchoring the plugin on ${resolve(options.fallback)} instead.`, + ); + root = resolve(options.fallback); + source = 'derived'; + } else { + // The value is a path: trimming would move the anchor, so only the + // blank check above looks at a trimmed copy. + root = resolve(declared); + source = 'native'; + } + const stateRoot = join(root, PLUGIN_STATE_DIRECTORY); + return Object.freeze({ + identity: available({ root, stateRoot }, source), + root, + source, + stateRoot, + }); +}; diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts index a37925b01..f87491acc 100644 --- a/packages/rsc-runtime/src/plugin.ts +++ b/packages/rsc-runtime/src/plugin.ts @@ -22,6 +22,7 @@ export type { AgentLineageResolution, AgentLineageSubagent, AgentNetworkAuthority, + AgentPluginIdentity, AgentProcessLifetime, AgentProgressReporter, AgentProgressUpdate, @@ -58,6 +59,8 @@ export type { Observed, ObservedSource, } from './agent-request.js'; +export { PLUGIN_ROOT_ENV_ANCHOR, PLUGIN_STATE_DIRECTORY, resolvePluginRoot } from './plugin-root.js'; +export type { ResolvePluginRootOptions, ResolvedPluginRoot } from './plugin-root.js'; // Type-only: the optional ledger implementation stays behind './notices'. export type { AgentNoticeLedger, diff --git a/packages/rsc-runtime/tests/plugin-root.test.ts b/packages/rsc-runtime/tests/plugin-root.test.ts new file mode 100644 index 000000000..e955b41e5 --- /dev/null +++ b/packages/rsc-runtime/tests/plugin-root.test.ts @@ -0,0 +1,93 @@ +import { join, resolve } from 'node:path'; + +import { describe, expect, it } from '@rstest/core'; + +import { agent, runAgentRequest } from '../src/agent-request.js'; +import { PLUGIN_ROOT_ENV_ANCHOR, resolvePluginRoot } from '../src/plugin-root.js'; + +describe('resolvePluginRoot (#468)', () => { + const fallback = '/artifact/claude'; + + it('anchors on an expanded AGENT_BUNDLE_PLUGIN_ROOT as the native source, with state one level below', () => { + const resolved = resolvePluginRoot({ env: { [PLUGIN_ROOT_ENV_ANCHOR]: '/installs/curator' }, fallback }); + + expect(resolved).toEqual({ + identity: { source: 'native', state: 'available', value: { root: '/installs/curator', stateRoot: '/installs/curator/state' } }, + root: '/installs/curator', + source: 'native', + stateRoot: '/installs/curator/state', + }); + expect(Object.isFrozen(resolved)).toBe(true); + }); + + it('takes the configured path exactly as written, whitespace included', () => { + const resolved = resolvePluginRoot({ env: { [PLUGIN_ROOT_ENV_ANCHOR]: '/opt/curator ' }, fallback }); + + expect(resolved.root).toBe('/opt/curator '); + expect(resolved.stateRoot).toBe(join('/opt/curator ', 'state')); + expect(resolved.source).toBe('native'); + }); + + it('makes a relative anchor absolute against the working directory, as Codex hands "./"', () => { + const resolved = resolvePluginRoot({ env: { [PLUGIN_ROOT_ENV_ANCHOR]: './' }, fallback }); + + expect(resolved.root).toBe(resolve('./')); + expect(resolved.stateRoot).toBe(join(resolve('./'), 'state')); + expect(resolved.source).toBe('native'); + }); + + it('falls back to the shell fallback as the derived source when the anchor is unset or blank', () => { + for (const env of [{}, { [PLUGIN_ROOT_ENV_ANCHOR]: '' }, { [PLUGIN_ROOT_ENV_ANCHOR]: ' ' }]) { + const warnings: string[] = []; + const resolved = resolvePluginRoot({ env, fallback, warn: (message) => warnings.push(message) }); + expect(resolved).toMatchObject({ root: fallback, source: 'derived', stateRoot: `${fallback}/state` }); + expect(resolved.identity).toEqual({ source: 'derived', state: 'available', value: { root: fallback, stateRoot: `${fallback}/state` } }); + expect(warnings).toEqual([]); + } + }); + + it('treats an unexpanded host token as unset, reports it once, and never joins it into a path', () => { + const warnings: string[] = []; + const resolved = resolvePluginRoot({ + env: { [PLUGIN_ROOT_ENV_ANCHOR]: '${CLAUDE_PLUGIN_ROOT}' }, + fallback, + warn: (message) => warnings.push(message), + }); + + expect(resolved).toMatchObject({ root: fallback, source: 'derived', stateRoot: `${fallback}/state` }); + expect(resolved.stateRoot).not.toContain('${'); + expect(warnings).toEqual([ + `[agent-bundle] AGENT_BUNDLE_PLUGIN_ROOT is the unexpanded token "\${CLAUDE_PLUGIN_ROOT}"; anchoring the plugin on ${fallback} instead.`, + ]); + }); + + it('reads process.env by default', () => { + const previous = process.env[PLUGIN_ROOT_ENV_ANCHOR]; + process.env[PLUGIN_ROOT_ENV_ANCHOR] = '/from/process/env'; + try { + expect(resolvePluginRoot({ fallback }).root).toBe('/from/process/env'); + } finally { + if (previous === undefined) delete process.env[PLUGIN_ROOT_ENV_ANCHOR]; + else process.env[PLUGIN_ROOT_ENV_ANCHOR] = previous; + } + }); +}); + +describe('the plugin request axis (#468)', () => { + it('is unavailable("not-provided") when the scope supplies none, and the resolved identity when it does', async () => { + const absent = await runAgentRequest({ invocation: { kind: 'tool' } }, async () => (await agent()).plugin); + expect(absent).toEqual({ reason: 'not-provided', state: 'unavailable' }); + + const resolved = resolvePluginRoot({ env: { [PLUGIN_ROOT_ENV_ANCHOR]: '/installs/curator' }, fallback: '/unused' }); + const present = await runAgentRequest( + { invocation: { kind: 'tool' }, plugin: resolved.identity }, + async () => (await agent()).plugin, + ); + expect(present).toEqual({ + source: 'native', + state: 'available', + value: { root: '/installs/curator', stateRoot: '/installs/curator/state' }, + }); + expect(Object.isFrozen(present)).toBe(true); + }); +}); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 9ce7a85d2..4a97ab62f 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -93,7 +93,7 @@ export const config = { ``` Call `await agent()` only when the route needs context. The handle exposes the invocation plus -the `host`, `session`, `actor`, and `workspace` identity axes. Each axis is *observed*: a +the `host`, `session`, `actor`, `workspace`, and `plugin` identity axes. Each axis is *observed*: a transport publishes an `available` value and its source when it knows one, or `unavailable` with a typed reason when it does not. Bare stdio supplies neither a session id nor HTTP actor authentication, so those axes stay honestly unavailable rather than being fabricated. @@ -412,6 +412,32 @@ const readPluginRoot = (env: Record): string | undef env[pluginRootEnvAnchor]; ``` +Inside a route there is no need to read the variable at all. Every generated shell — the MCP +entry and its Flight worker, the routed CLI executable and its render worker, the hook wrappers — +resolves the anchor once at startup with `resolvePluginRoot` from `@agent-bundle/runtime`, mounts +its SQLite state, notice ledger, and lineage journal under `/state`, and publishes the same +value on every request it opens as `(await agent()).plugin`: + +```ts +const { plugin } = await agent(); +if (plugin.state === 'available') { + plugin.value.root; // the install root: AGENT_BUNDLE_PLUGIN_ROOT, or the shell's fallback + plugin.value.stateRoot; // `/state`, where defineState / notices / lineage already live + plugin.source; // 'native' from the variable, 'derived' from the fallback +} +``` + +`source: 'native'` means the host supplied an expanded `AGENT_BUNDLE_PLUGIN_ROOT`; `'derived'` +means the shell fell back — to the artifact root (the parent of `mcp/`, `bin/`, or `hooks/`) inside +a built artifact, or to `$PWD/.agent-bundle` for the npm package bin. A value still carrying an +unexpanded `${…}` token (a host that passed its manifest through literally) is treated as unset, +reported once on stderr, and never joined into a path. Conventional providers receive the same +observed value as `plugin` in their factory context, beside `invocation` and `signal`, so a +provider that keeps files beside the framework's state derives nothing itself. The route-unit and +`mcp-in-memory` harnesses resolve it the same way (falling back to `/.agent-bundle`) +and accept `context.plugin` as an override like every other axis; outside a generated scope the +axis is `unavailable('not-provided')`. + ## MCP Apps An MCP App is a browser surface compiled to self-contained HTML and registered as a resource on diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 8e22d2176..608a0ddf6 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -38,7 +38,7 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | Variable | Read by | Meaning | | --- | --- | --- | -| `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The durable-state anchor. Overrides the built-in fallback. | +| `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The plugin install root and durable-state anchor. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | The bearer token the Agent API requires before it can be enabled. | | `AGENT_BUNDLE_HOOK_HOST` | Generated hook wrappers | Pins the declared host explicitly instead of detecting it. | | `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench hook playground sets it. | @@ -67,7 +67,11 @@ scratch object, so the real `process.env` is never mutated. Durable state resolves to `$AGENT_BUNDLE_PLUGIN_ROOT/state`, falling back to the artifact root, or to `./.agent-bundle/state` for CLI bins. Only a `workspace-durable` state definition uses the -SQLite driver; other lifetimes use the in-memory driver and keep nothing on disk. +SQLite driver; other lifetimes use the in-memory driver and keep nothing on disk. Every generated +process resolves that anchor exactly once (`resolvePluginRoot` from `@agent-bundle/runtime`) and +publishes it as `(await agent()).plugin` — `{ root, stateRoot }` with `source: 'native'` from the +variable or `'derived'` from the fallback — so a route or provider that keeps its own files beside +the framework's reads `plugin.value.stateRoot` instead of re-deriving the path. Under `mcp run`, plugin-root anchors in **env values** expand to the project root by default, not the artifact: the artifact is an ephemeral build product there, and anchoring durable state on diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index a65ba20ef..ad8da6f2c 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -87,7 +87,7 @@ export const config = { ``` 只有在路由确实需要上下文时才调用 `await agent()`。该句柄暴露本次调用,以及 `host`、`session`、 -`actor`、`workspace` 四个身份轴。每个轴都是*被观察到的*:传输层知道时会发布一个 `available` 取值及 +`actor`、`workspace`、`plugin` 五个身份轴。每个轴都是*被观察到的*:传输层知道时会发布一个 `available` 取值及 其来源,不知道时则发布带有类型化原因的 `unavailable`。裸 stdio 既不提供 session id 也不提供 HTTP actor 认证,因此这两个轴保持诚实的不可用,而不是被伪造出来。 @@ -372,6 +372,28 @@ const readPluginRoot = (env: Record): string | undef env[pluginRootEnvAnchor]; ``` +在路由内部完全不需要读取这个变量。每个生成的外壳——MCP 入口及其 Flight worker、路由式 CLI 可执行文件 +及其渲染 worker、hook 包装器——都在启动时用 `@agent-bundle/runtime` 的 `resolvePluginRoot` 解析一次 +锚点,把 SQLite 状态、通知账本与 lineage 日志挂载在 `/state` 之下,并在它打开的每个请求上以 +`(await agent()).plugin` 发布同一个值: + +```ts +const { plugin } = await agent(); +if (plugin.state === 'available') { + plugin.value.root; // 安装根目录:AGENT_BUNDLE_PLUGIN_ROOT,或外壳的回退值 + plugin.value.stateRoot; // `/state`,defineState / 通知 / lineage 已经存放于此 + plugin.source; // 来自变量为 'native',来自回退为 'derived' +} +``` + +`source: 'native'` 表示宿主提供了已展开的 `AGENT_BUNDLE_PLUGIN_ROOT`;`'derived'` 表示外壳使用了回退—— +在构建产物内是产物根目录(`mcp/`、`bin/` 或 `hooks/` 的父目录),对 npm 包 bin 则是 `$PWD/.agent-bundle`。 +仍带有未展开 `${…}` token 的值(宿主把清单原样透传)会被视为未设置,在 stderr 上报告一次,且绝不会被 +拼接进路径。约定式 provider 在其工厂上下文中收到同样的被观察值 `plugin`,与 `invocation`、`signal` 并列, +因此把文件放在框架状态旁边的 provider 自己不必再推导任何东西。route-unit 与 `mcp-in-memory` 测试层级 +以同样方式解析它(回退到 `<项目根目录>/.agent-bundle`),并像其他每个轴一样接受 `context.plugin` 覆盖; +在生成的作用域之外,该轴为 `unavailable('not-provided')`。 + ## MCP App MCP App 是一个浏览器表面,编译为自包含 HTML 并作为资源注册到生成的服务器上。约定位置是 diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index 8cc68abd2..ec8e0fc63 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -35,7 +35,7 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | 变量 | 由谁读取 | 含义 | | --- | --- | --- | -| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 持久状态锚点。覆盖内置的回退值。 | +| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件安装根目录与持久状态锚点。覆盖内置的回退值;以 `(await agent()).plugin`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | Agent API 在启用之前所必需的 bearer token。 | | `AGENT_BUNDLE_HOOK_HOST` | 生成的钩子 wrapper | 显式指定声明的宿主,而不去探测。 | | `AGENT_BUNDLE_HOOK_SIMULATION` | 生成的钩子 wrapper | `1` 标记一次模拟调用;Workbench 的钩子 playground 会设置它。 | @@ -64,7 +64,10 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 持久状态解析到 `$AGENT_BUNDLE_PLUGIN_ROOT/state`,回退到产物根目录,对 CLI bin 则回退到 `./.agent-bundle/state`。只有 `workspace-durable` 状态定义使用 SQLite 驱动;其他生命期使用内存驱动, -不在磁盘上留下任何东西。 +不在磁盘上留下任何东西。每个生成的进程只解析该锚点一次(`@agent-bundle/runtime` 的 +`resolvePluginRoot`),并以 `(await agent()).plugin` 发布——`{ root, stateRoot }`,来自变量时 +`source: 'native'`,来自回退时为 `'derived'`——因此把自己的文件放在框架状态旁边的路由或 provider +读取 `plugin.value.stateRoot` 即可,不必再自行推导路径。 在 `mcp run` 之下,**env 取值**中的 plugin-root 锚点默认展开到项目根目录,而不是产物:在那里产物只是 一个临时构建产物,把持久状态锚定其上会让状态在每次重建时被割裂。若要按字节忠实地演练一次「复制产物后