From 673cc74802ee91ecdc92670da2191275310cda6a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:50:12 +0000 Subject: [PATCH 1/4] feat(runtime): separate the framework state root from the plugin code root Artifact-hosted shells (stdio MCP entry, Flight worker, artifact CLI bin and render worker, standalone hook wrappers) resolve their state root under ~/.agent-bundle/state/- (or $XDG_STATE_HOME/agent-bundle/...) instead of /state, so a read-only install launches and two installs never share state. AGENT_BUNDLE_PLUGIN_ROOT keeps naming the code root; AGENT_BUNDLE_STATE_ROOT overrides the state root. Closes #637 --- .changeset/637-state-root.md | 6 + docs/entry-conventions.md | 61 ++++-- docs/framework-mode.md | 6 +- .../agent-bundle.config.ts | 28 +++ .../fixtures/durable-web-surface/package.json | 5 + .../durable-web-surface/src/cli/entries.ts | 25 +++ .../src/mcp/journal/tools/record.tsx | 34 +++ .../fixtures/durable-web-surface/src/state.ts | 15 ++ .../durable-web-surface/views/status.html | 10 + .../durable-web-surface/views/status.ts | 1 + .../src/adapters/hook-contract.ts | 8 +- packages/agent-bundle/src/api.ts | 23 +- packages/agent-bundle/src/build/cli-bins.ts | 7 +- .../agent-bundle/src/build/entry-shell.ts | 51 +++-- .../src/build/launch-env-shell.ts | 6 +- .../agent-bundle/src/core/state-inspection.ts | 2 +- packages/agent-bundle/src/core/types.ts | 11 +- packages/agent-bundle/src/index.ts | 2 +- packages/agent-bundle/src/routes/public.ts | 15 +- packages/agent-bundle/src/services/mcp-run.ts | 10 +- packages/agent-bundle/src/web-host/launch.ts | 25 ++- .../agent-bundle/tests/entry-shell.test.ts | 26 ++- .../agent-bundle/tests/inspect-state.test.ts | 2 +- .../tests/packed-readonly-state-root.test.ts | 205 ++++++++++++++++++ .../tests/packed-stdio-projection.test.ts | 11 +- .../tests/route-manifest-routes.test.ts | 2 +- .../tests/rstest-worker-isolation.test.ts | 7 + .../agent-bundle/tests/web-launch.test.ts | 18 ++ packages/rsc-runtime/src/agent-request.ts | 13 +- packages/rsc-runtime/src/plugin-root.ts | 91 ++++++-- packages/rsc-runtime/src/plugin.ts | 12 +- .../rsc-runtime/tests/plugin-root.test.ts | 149 ++++++++++++- .../tests/route-manifest-client.test.ts | 2 +- rstest.integration-tests.ts | 1 + rstest.worker-isolation.ts | 5 + website/docs/en/guide/authoring/mcp.mdx | 37 ++-- .../en/guide/authoring/package-entries.mdx | 6 +- .../en/guide/distribution/installation.mdx | 18 +- website/docs/en/reference/cli.mdx | 13 +- .../docs/en/reference/runtime-environment.mdx | 24 +- website/docs/zh/guide/authoring/mcp.mdx | 18 +- .../zh/guide/authoring/package-entries.mdx | 6 +- .../zh/guide/distribution/installation.mdx | 10 +- website/docs/zh/reference/cli.mdx | 11 +- .../docs/zh/reference/runtime-environment.mdx | 20 +- 45 files changed, 859 insertions(+), 199 deletions(-) create mode 100644 .changeset/637-state-root.md create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/package.json create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/src/cli/entries.ts create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/src/state.ts create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/views/status.html create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/views/status.ts create mode 100644 packages/agent-bundle/tests/packed-readonly-state-root.test.ts diff --git a/.changeset/637-state-root.md b/.changeset/637-state-root.md new file mode 100644 index 000000000..1fcf0727d --- /dev/null +++ b/.changeset/637-state-root.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": minor +"@agent-bundle/runtime": minor +--- + +Separate the generated plugin's state root from its code root: make every artifact-hosted shell (the stdio MCP entry, Flight worker, artifact CLI bin and render worker, and standalone hook wrappers) keep the SQLite state kernel, notice ledger, and lineage journal under `~/.agent-bundle/state/-` (`$XDG_STATE_HOME/agent-bundle/-` when set) instead of `/state`, so a read-only install launches and two installs never share state; keep `AGENT_BUNDLE_PLUGIN_ROOT` naming the installed code root and let `AGENT_BUNDLE_STATE_ROOT` override the state root; add `stateAnchor` and `home` options and `stateSource` to `resolvePluginRoot`, and export `PLUGIN_STATE_ROOT_ENV_ANCHOR`, `pluginStateSegment`, `userStateHome`, and `userDataStateRoot` from `@agent-bundle/runtime` and `pluginStateRootEnvAnchor` from `agent-bundle`. State existing installs wrote beneath the plugin root is not migrated: after upgrading and rebuilding, an installed plugin starts from an empty state root; set `AGENT_BUNDLE_STATE_ROOT=/state` to keep using it. (#PR) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 67d1efc80..43e854e50 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -150,23 +150,32 @@ Volatile lifetimes use the memory driver. Request lifetime opens and releases fresh project and notice stores per invocation; process lifetime shares them for the generated worker or executable process. -Workspace-durable generated MCP workers store under -`$AGENT_BUNDLE_PLUGIN_ROOT/state`. If that host-provided anchor is absent, -the worker derives the artifact root from the parent of its own `mcp/` -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` in the plugin root) derives the artifact root from the parent of its -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 +Workspace-durable artifact shells — generated MCP workers, artifact CLI bins +and render workers, and standalone hook wrappers — call `resolvePluginRoot` +with `stateAnchor: 'user-data'`. `AGENT_BUNDLE_PLUGIN_ROOT` still names the +code root and otherwise falls back to the artifact root derived from the +shell's own location. An expanded, non-blank `AGENT_BUNDLE_STATE_ROOT` +independently overrides the framework state root and is made absolute with +`resolve()`; otherwise the state root is +`~/.agent-bundle/state/-`, or +`$XDG_STATE_HOME/agent-bundle/-` when `XDG_STATE_HOME` is set. +`` is the code root's safe basename (or `plugin`) and `` +is the first 16 hexadecimal characters of SHA-256 over that code root's +realpath, so symlinked spellings share one state root while distinct installs +do not. `resolvePluginRoot` uses `os.homedir()` unless its `home` test seam is +supplied. The npm package's routed CLI bin and rendered scripts keep the default +`stateAnchor: 'root'`: `$AGENT_BUNDLE_PLUGIN_ROOT/state` when supplied and +otherwise `$PWD/.agent-bundle/state`. + +Each generated process resolves both roots exactly once +(`resolvePluginRoot` from `@agent-bundle/runtime`, #468): the state kernel, +notice ledger, lineage journal, and every request scope the process opens read +the same `stateRoot`, published with the code `root` as +`(await agent()).plugin` and handed to conventional providers as `plugin` +beside `invocation` and `signal`. `source` records whether the code root was +native or derived; `stateSource` does the same independently for the state +root. An unexpanded `${…}` token in either root override is treated as unset +(and 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 @@ -272,7 +281,7 @@ interface AgentProviderContext { host: Observed<{ name }>; // exactly what the route reads on `await agent()` session: Observed<{ sessionId }>; workspace: Observed<{ root }>; - plugin: Observed<{ root; stateRoot }>; // the resolved plugin root (#468) + plugin: Observed<{ root; stateRoot }>; // resolved code and framework state roots (#468) lineage: Observed; // own chain plus the live `tree` (#457) state?: { lifetime; read(options?) }; // the mounted state handle, `read` only notices?: { inbox(); published() }; // the request's notice handle, reads only @@ -911,10 +920,12 @@ executable bit — invoke it as `node /bin/.mjs `, exactly like `scripts/*.mjs`. Help, argv parsing, output modes, exit codes, and signals are identical to the package bin. One deliberate difference: workspace-durable state without a host-supplied -`AGENT_BUNDLE_PLUGIN_ROOT` anchors on the **artifact root** (the parent of -`bin/`, the same fallback the generated MCP worker beside it uses) rather -than `$PWD/.agent-bundle/state`, so a co-installed CLI and server observe -one store. The npm package bin keeps its `cwd` fallback. +`AGENT_BUNDLE_STATE_ROOT` uses `stateAnchor: 'user-data'`, deriving +`~/.agent-bundle/state/-` (or the `XDG_STATE_HOME` equivalent) +from the artifact code root. The generated MCP worker beside it makes the +same derivation, so a co-installed CLI and server observe one store without +writing beneath a read-only artifact. The npm package bin keeps +`stateAnchor: 'root'` and its `cwd` fallback, `$PWD/.agent-bundle/state`. Reaching the bin from the other surfaces: @@ -1652,7 +1663,11 @@ input, and a refresh rebinds that retained result. ` web` keeps the installed artifact immutable: framework-owned per-server web state (`${PLUGIN_DATA}` in declared env) lives under the user's home (`~/.agent-bundle/web-data/-/`), never inside the -plugin root, so a read-only install still launches. +plugin root. The spawned server's SQLite state kernel, notice ledger, and +lineage journal likewise use `stateAnchor: 'user-data'` and live under +`~/.agent-bundle/state/-` (or the `XDG_STATE_HOME` equivalent) +unless `AGENT_BUNDLE_STATE_ROOT` overrides it, so a read-only install still +launches. ## `agent-bundle/app` — the App-side bridge client diff --git a/docs/framework-mode.md b/docs/framework-mode.md index f452e1741..65df08e6e 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -635,7 +635,8 @@ manifest. A root whose selection includes `cursor` or `portable` also includes a standalone `install.mjs`. Its staged copy is idempotent for identical content, records an install receipt (`.agent-bundle-install.json`: plugin, version, content hash, owned files and directories), replaces a same-version stale copy of its -own plugin in place (owned files only; `state/` survives), and accepts +own plugin in place (owned files only; legacy `state/` survives, while current builds keep +framework state outside the plugin root), and accepts `--replace` (alias `--force`) to replace a different installed version or adopt a pre-receipt copy. Foreign directories are refused with a content-hash comparison. It never invokes sudo or changes PATH. `agent-bundle install @@ -669,7 +670,8 @@ node artifact/install.mjs --uninstall [--plan] [--mode marketplace] Uninstall removes exactly what the receipt owns and reverses exactly the registrations it recorded; anything else stays and is listed as retained. -Durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge`; +Legacy durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge` +(current builds keep framework state outside the plugin root); the typed `data.outcome` says what the host itself decided where Agent Bundle cannot (`retained-by-host` for Claude's ~14-day orphaned copy, `removed-by-host` / `unavailable` for Codex, which has no keep-data option). A diff --git a/packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts b/packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts new file mode 100644 index 000000000..67bec4d46 --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts @@ -0,0 +1,28 @@ +// Plain object export keeps this packed fixture independent of the package +// build, like web-surface. The generated `journal` server (src/mcp/journal) +// carries the config-declared status App, and src/state.ts makes the project +// workspace-durable: the packed read-only-install proof spawns its entry and +// its CLI bin against an artifact nothing may write beneath (#637). +export default { + mcp: { + servers: { + journal: { + apps: { + status: { + entry: './views/status.ts', + resourceUri: 'ui://durable-web-surface-fixture/status.html', + targets: ['portable'], + template: './views/status.html', + }, + }, + }, + }, + }, + plugin: { + description: 'A workspace-durable plugin whose MCP App is exposed through web.apps and whose CLI reads the same state.', + name: 'durable-web-surface-fixture', + version: '1.0.0', + }, + targets: ['portable'], + web: { apps: [{ allow: ['call-tool'], app: 'journal/status' }] }, +}; diff --git a/packages/agent-bundle/fixtures/durable-web-surface/package.json b/packages/agent-bundle/fixtures/durable-web-surface/package.json new file mode 100644 index 000000000..a5879a15f --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/package.json @@ -0,0 +1,5 @@ +{ + "name": "durable-web-surface-fixture", + "private": true, + "type": "module" +} diff --git a/packages/agent-bundle/fixtures/durable-web-surface/src/cli/entries.ts b/packages/agent-bundle/fixtures/durable-web-surface/src/cli/entries.ts new file mode 100644 index 000000000..3ab3d63bf --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/src/cli/entries.ts @@ -0,0 +1,25 @@ +import { agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig } from 'agent-bundle'; +import { z } from 'zod'; + +export const config = { + description: 'Lists the journal entries the MCP record tool has written.', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({}).strict(); + +export const resultSchema = z.object({ + entries: z.array(z.object({ note: z.string() }).strict()), + revision: z.number().int().nonnegative(), +}).strict(); + +interface JournalState { + readonly entries: readonly { readonly note: string }[]; +} + +export default async function entries() { + const context = await agent(); + if (context.state === undefined) throw new TypeError('Journal state is unavailable.'); + const snapshot = await context.state.read(); + return { entries: (snapshot.state as JournalState).entries, revision: snapshot.revision }; +} diff --git a/packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx b/packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx new file mode 100644 index 000000000..6deb3ee79 --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx @@ -0,0 +1,34 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const config = { + _meta: { ui: { resourceUri: 'ui://durable-web-surface-fixture/status.html' } }, + description: 'Appends one note to the durable journal and reports every entry.', + title: 'Record', +}; + +export const inputSchema = z.object({ note: z.string().min(1) }).strict(); + +export const resultSchema = z.object({ + entries: z.array(z.object({ note: z.string() }).strict()), + revision: z.number().int().nonnegative(), +}).strict(); + +interface JournalState { + readonly entries: readonly { readonly note: string }[]; +} + +export default async function Record({ input }: { readonly input: z.infer }) { + const context = await agent(); + if (context.state === undefined) throw new TypeError('Journal state is unavailable.'); + // The note is the idempotency key: a replayed note is recorded once. + await context.state.dispatch('recorded', { note: input.note }, { idempotencyKey: `record:${input.note}` }); + const snapshot = await context.state.read(); + const state = snapshot.state as JournalState; + const result = { entries: state.entries, revision: snapshot.revision }; + return ( + + {`recorded ${String(state.entries.length)} note(s)`} + + ); +} diff --git a/packages/agent-bundle/fixtures/durable-web-surface/src/state.ts b/packages/agent-bundle/fixtures/durable-web-surface/src/state.ts new file mode 100644 index 000000000..a6ffeea3e --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/src/state.ts @@ -0,0 +1,15 @@ +import { defineState } from '@agent-bundle/runtime/state'; +import { z } from 'zod'; + +const entrySchema = z.object({ note: z.string() }).strict(); + +export default defineState({ + events: { + recorded: entrySchema, + }, + id: 'durable-web-surface/journal', + initial: { entries: [] }, + lifetime: 'workspace-durable', + reduce: (state, event) => ({ entries: [...state.entries, event.payload] }), + schema: z.object({ entries: z.array(entrySchema) }).strict(), +}); diff --git a/packages/agent-bundle/fixtures/durable-web-surface/views/status.html b/packages/agent-bundle/fixtures/durable-web-surface/views/status.html new file mode 100644 index 000000000..ff379212c --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/views/status.html @@ -0,0 +1,10 @@ + + + + + Status + + +
+ + diff --git a/packages/agent-bundle/fixtures/durable-web-surface/views/status.ts b/packages/agent-bundle/fixtures/durable-web-surface/views/status.ts new file mode 100644 index 000000000..d0087b06b --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/views/status.ts @@ -0,0 +1 @@ +document.querySelector('#view')!.textContent = 'durable-web-surface fixture status'; diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 741f40f61..41a59b98b 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -708,9 +708,11 @@ 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)) });", + // The wrapper lives in `hooks/`, so its code root is the parent + // directory; the state root derives from it in the user state + // directory — the same two roots the generated MCP entry resolves + // (#468, #637), so the lineage journal it retires is the server's. + "const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });", '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));", diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 4e9c14d7b..b17914618 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -672,11 +672,11 @@ export interface RunMcpOptions extends ArtifactOperationOptions { /** Set false to launch the server without any `.env` layer. */ readonly loadEnvFiles?: boolean; /** - * Root the env-declared plugin-root anchors (for example - * `AGENT_BUNDLE_PLUGIN_ROOT`) expand to. Defaults to the project root so - * durable server state survives artifact rebuilds; point it at the - * artifact target root for a byte-faithful rehearsal of a copied-artifact - * launch. + * Root the env-declared `AGENT_BUNDLE_PLUGIN_ROOT` expands to. Defaults to + * the project root so the derived state root (keyed by that root) survives + * artifact rebuilds; `AGENT_BUNDLE_STATE_ROOT` in the operator environment + * overrides the state location. Point it at the artifact target root for a + * byte-faithful rehearsal of a copied-artifact launch. */ readonly pluginRoot?: string; readonly server: string; @@ -1478,12 +1478,13 @@ export const invokeMcp = async (options: InvokeMcpOptions): Promise/`, and env-declared - * plugin-root anchors expand to the project root itself (override with - * `pluginRoot`). The launch environment layers, lowest to highest: manifest - * env, the project-root `.env` set (or `envFiles`), the operator's real - * `process.env`. + * Env-declared `AGENT_BUNDLE_PLUGIN_ROOT` expands to the project root by + * default so the derived state root (keyed by that root) survives artifact + * rebuilds; `AGENT_BUNDLE_STATE_ROOT` in the operator environment overrides + * the state location (override the plugin-root expansion with `pluginRoot`). + * Plugin-data state persists under `.agent-bundle/mcp-run//`. + * The launch environment layers, lowest to highest: manifest env, the + * project-root `.env` set (or `envFiles`), the operator's real `process.env`. */ export const runMcp = async (options: RunMcpOptions): Promise => { const registry = registryFor(options); diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index e7960facf..d2314782a 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -182,9 +182,10 @@ export const cliBinRslibEntries = ( ...(cli.projectionSources === undefined ? {} : { projectionSources: cli.projectionSources }), routes: cli.routes, ...(model.state === undefined ? {} : { state: model.state }), - // Durable state anchors on the artifact root (the parent of `bin/`), - // the same fallback the generated MCP worker beside it uses, so a - // co-installed CLI and server observe one store. + // The code root falls back to the artifact root (the parent of `bin/`) + // and the state root derives from it, the same two roots the generated + // MCP worker beside it resolves, so a co-installed CLI and server + // observe one store. stateFallback: 'artifact', ...(entry.bin.web === true ? { diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index f2d513adb..500c5875a 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -189,11 +189,14 @@ export const generatedInstallBinEntrySource = (options: { ].join('\n'); /** - * Where workspace-durable state anchors when the host supplies no - * `AGENT_BUNDLE_PLUGIN_ROOT`: `cwd` (the caller's `.agent-bundle/state`, the - * npm package bin's contract) or `artifact` (the parent of the executable's - * own directory — the target root — which the artifact-hosted routed CLI - * shares with the generated MCP worker beside it). + * The code root a generated module falls back to when the host supplies no + * `AGENT_BUNDLE_PLUGIN_ROOT`, and with it where its state root derives absent + * `AGENT_BUNDLE_STATE_ROOT`: `cwd` (the caller's `.agent-bundle`, the npm + * package bin's contract; state stays under `/state`) or `artifact` + * (the parent of the executable's own directory — the target root — shared by + * the artifact-hosted CLI, its render worker, the MCP entry and the Flight + * worker; state goes to the user state directory keyed by that root, never + * into the installed, possibly read-only artifact). */ export type GeneratedStateFallback = 'artifact' | 'cwd'; @@ -208,7 +211,7 @@ export interface GeneratedCliBinEntryOptions { /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; - /** Durable-state anchor fallback; defaults to `cwd` (the npm package bin). */ + /** Code-root fallback; defaults to `cwd` (the npm package bin). */ readonly stateFallback?: GeneratedStateFallback; readonly web?: { readonly manifestRelativeUrl: string; @@ -243,16 +246,22 @@ const pluginRootFallbackExpression = ( : "join(process.cwd(), '.agent-bundle')"; /** - * The one plugin-root resolution of a generated module (#468): the SQLite + * The one plugin-root resolution of a generated module (#468, #637): the code + * root (`AGENT_BUNDLE_PLUGIN_ROOT`, else the fallback) and the state root + * (`AGENT_BUNDLE_STATE_ROOT`, else derived from the code root). 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. + * directory they mount by construction. An artifact-anchored module derives + * its state root in the user state directory keyed by the code root; the npm + * bin keeps `/state`. */ const pluginRootDeclaration = ( fallback: GeneratedStateFallback, relativeUrl?: string, ): string => - `const pluginRoot = resolvePluginRoot({ fallback: ${pluginRootFallbackExpression(fallback, relativeUrl)} });`; + `const pluginRoot = resolvePluginRoot({ fallback: ${pluginRootFallbackExpression(fallback, relativeUrl)}${ + fallback === 'artifact' ? ", stateAnchor: 'user-data'" : '' + } });`; const generatedStateImports = ( state: NormalizedStateDefinition | undefined, @@ -579,7 +588,7 @@ export interface GeneratedRenderedRouteWorkerOptions { /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; - /** Durable-state anchor fallback; defaults to `cwd` and must match the owning executable. */ + /** Code-root fallback; defaults to `cwd` and must match the owning executable. */ readonly stateFallback?: GeneratedStateFallback; } @@ -911,8 +920,8 @@ const wiresResourceUpdatedRoute = (options: NoticeRouteSelection): boolean => options.noticeDelivery?.['mcp-resource-updated'].state === 'supported'; /** - * The server process's own handle on the durable notice store. The anchor - * resolution matches the worker's so both open the same files. + * The server process's own handle on the durable notice store. Both roots + * resolve as in the worker, so both open the same files. */ const noticeDeliveryImports = (wired: boolean): readonly string[] => wired @@ -1078,9 +1087,9 @@ 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 };', - // 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. + // The worker resolves the same code and state roots 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), @@ -1220,10 +1229,10 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti const wiresInbox = wiresInboxRoute(options); const wiresResourceUpdated = wiresResourceUpdatedRoute(options); // The lineage registry journals durably only where the project already - // accepted the sqlite kernel and its durable anchor (a workspace-durable + // accepted the sqlite kernel and its state root (a workspace-durable // `src/state.ts`); stateless and volatile projects keep a process-lifetime - // registry so `node:sqlite` never loads for them and no `state/` directory - // appears inside an artifact that declared none. + // registry so `node:sqlite` never loads for them and no state directory + // is created for an artifact that declared none. const durableLineage = options.state?.lifetime === 'workspace-durable'; return [ ...(hasEvents ? ["import { dirname, resolve } from 'node:path';"] : []), @@ -1244,12 +1253,12 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ...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`. + // The server process's one root resolution (#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 + // In the project's own state root, 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. diff --git a/packages/agent-bundle/src/build/launch-env-shell.ts b/packages/agent-bundle/src/build/launch-env-shell.ts index 134d7d366..5419dca09 100644 --- a/packages/agent-bundle/src/build/launch-env-shell.ts +++ b/packages/agent-bundle/src/build/launch-env-shell.ts @@ -48,8 +48,10 @@ export const operatorEnvLayerImports: readonly string[] = [ * The statement that applies the layer. Every artifact shell lives one * directory below the plugin root (`mcp/`, `hooks/`, `bin/`), so the fallback * anchor — used when the host set no `AGENT_BUNDLE_PLUGIN_ROOT` — is the - * bundle's parent directory, the same fallback the durable-state kernel uses - * (`import.meta.url` stays native in the emitted ESM, so it names the bundle). + * bundle's parent directory, the same code-root fallback `resolvePluginRoot` + * uses (`import.meta.url` stays native in the emitted ESM, so it names the + * bundle). This names the code root only; `resolvePluginRoot` derives the + * state root (`AGENT_BUNDLE_STATE_ROOT`, else the user state directory). * * A stdio MCP shell embeds its server's manifest `env` block as build-time * literals (`manifestEnv`): the host merges that block into the child diff --git a/packages/agent-bundle/src/core/state-inspection.ts b/packages/agent-bundle/src/core/state-inspection.ts index e315f0d99..26109ae71 100644 --- a/packages/agent-bundle/src/core/state-inspection.ts +++ b/packages/agent-bundle/src/core/state-inspection.ts @@ -52,7 +52,7 @@ export interface StateDefinitionProjection { } const durableStateLocation = - '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)'; + '$AGENT_BUNDLE_STATE_ROOT, else ~/.agent-bundle/state/- ($XDG_STATE_HOME/agent-bundle/-) for an installed artifact, or ./.agent-bundle/state for the npm package bin'; const noticeLedgerInspection = 'Generated runtimes co-mount the notice ledger store at the same lifetime under reserved id @agent-bundle/runtime/agent-notice-ledger/v1.'; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index d35895def..828e395ed 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -899,9 +899,12 @@ export const pathTokens = Object.freeze({ * MCP server entries, holding the plugin install root in the target's native * representation (`${CLAUDE_PLUGIN_ROOT}`, `${PLUGIN_ROOT}`, * `${CURSOR_PLUGIN_ROOT}`, or Codex's `./` resolved against the entry's - * plugin-root cwd). Server runtime code should resolve persistent state and - * bundled assets against it instead of the process working directory, which - * not every host anchors to the plugin root. A user-declared env entry with - * this key always wins over the injected value. + * plugin-root cwd). Server runtime code should resolve bundled assets against + * it instead of the process working directory, which not every host anchors + * to the plugin root. A user-declared env entry with this key always wins + * over the injected value. */ export const pluginRootEnvAnchor = 'AGENT_BUNDLE_PLUGIN_ROOT'; + +/** Explicit override of the framework state root; the runtime exports the same name as `PLUGIN_STATE_ROOT_ENV_ANCHOR`. */ +export const pluginStateRootEnvAnchor = 'AGENT_BUNDLE_STATE_ROOT'; diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 9d619253d..43e177e94 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -4,7 +4,7 @@ import type { CursorConfigExtension } from './adapters/cursor.ts'; import type { PortableConfigExtension } from './adapters/portable.ts'; import type { AgentBundleConfig as CoreAgentBundleConfig } from './core/types.ts'; -export { defineConfig, definePrebuilt, pathTokens, pluginRootEnvAnchor } from './core/types.ts'; +export { defineConfig, definePrebuilt, pathTokens, pluginRootEnvAnchor, pluginStateRootEnvAnchor } from './core/types.ts'; export { defineSkill, Skill } from './skills/define.ts'; export { classifySkillToken, diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index c822d4f20..dbaccc388 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -132,12 +132,15 @@ export interface AgentProviderWorkspaceIdentity { } /** - * 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. Structurally identical to - * the runtime's `AgentPluginIdentity`. + * The plugin code root and framework state root a generated scope resolved + * (#468), as `(await agent()).plugin` observes it: `root` is the code root — + * the expanded `AGENT_BUNDLE_PLUGIN_ROOT` (`source: 'native'`) or the shell's + * fallback (`'derived'`) — and `stateRoot` is the framework state root + * (`AGENT_BUNDLE_STATE_ROOT`, else `~/.agent-bundle/state/-` + * or `$XDG_STATE_HOME/agent-bundle/-` for an installed + * artifact; `/state` for the npm package bin and the test harnesses), + * where the SQLite kernel, the notice ledger, and the lineage journal live. + * Structurally identical to the runtime's `AgentPluginIdentity`. */ export interface AgentProviderPluginRoot { readonly root: string; diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index bd8d8ac94..c9046c288 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -41,11 +41,11 @@ export interface ResolvedMcpStdioLaunch { export interface ResolveMcpStdioLaunchOptions { readonly artifact: string; /** - * Root that plugin-root path tokens in *env values* expand to — the - * durable-state anchors like `AGENT_BUNDLE_PLUGIN_ROOT`. Defaults to - * `workspaceRoot`: under `mcp run` the artifact is an ephemeral build - * product, so anchoring durable state on it would fragment that state per - * rebuild. Point it back at the artifact target root for a byte-faithful + * Root that plugin-root path tokens in *env values* expand to. Env-declared + * `AGENT_BUNDLE_PLUGIN_ROOT` expands to the project root by default so the + * derived state root (keyed by that root) survives artifact rebuilds; + * `AGENT_BUNDLE_STATE_ROOT` in the operator environment overrides the state + * location. Point it at the artifact target root for a byte-faithful * rehearsal of a copied-artifact launch. */ readonly envPluginRoot?: string; diff --git a/packages/agent-bundle/src/web-host/launch.ts b/packages/agent-bundle/src/web-host/launch.ts index e447c368f..48910abcf 100644 --- a/packages/agent-bundle/src/web-host/launch.ts +++ b/packages/agent-bundle/src/web-host/launch.ts @@ -30,9 +30,12 @@ export class WebLaunchError extends CodedError { const safePluginSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; /** - * One state segment per installed plugin root: the resolved root's digest - * keys the state, so two installs of the same plugin never share it, and the - * basename stays in front only when it is already a safe path segment. + * One data segment per installed plugin root: the resolved root's digest + * keys the directory, so two installs of the same plugin never share it, and + * the basename stays in front only when it is already a safe path segment. + * Spells the segment like `pluginStateSegment` in `@agent-bundle/runtime`, + * which the web host must not import (optional peer); `web-launch.test.ts` + * pins the two on a canonical root. */ const webPluginStateSegment = (pluginRoot: string): string => { const digest = sha256Hex(pluginRoot).slice(0, 16); @@ -41,9 +44,9 @@ const webPluginStateSegment = (pluginRoot: string): string => { }; /** - * Durable per-server web state, outside the installed artifact: the artifact - * stays immutable (it may be installed read-only), so framework-owned - * writable state anchors under the user's home instead of the plugin root. + * The author-facing `${PLUGIN_DATA}` directory of one server, outside the + * installed artifact: the artifact stays immutable (it may be installed + * read-only), so per-server data anchors under the user's home instead. */ export const webPluginDataDirectory = (pluginRoot: string, server: string, home = homedir()): string => join(home, '.agent-bundle', 'web-data', webPluginStateSegment(resolve(pluginRoot)), mcpServerStateDirectory(server)); @@ -52,10 +55,12 @@ const inheritedEnvironment = (env: NodeJS.ProcessEnv): Record => Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); /** - * Declared env overrides inherited env, matching installed hosts. Plugin data - * lives outside the artifact (under the user's home), because the installed - * artifact is immutable — a read-only install must still launch when the - * server declares plugin-data state. + * Declared env overrides inherited env, matching installed hosts. The launch + * names only the code root (`AGENT_BUNDLE_PLUGIN_ROOT`): the spawned shell + * derives its framework state root from it exactly as a host-launched one + * does (`AGENT_BUNDLE_STATE_ROOT` and `XDG_STATE_HOME` pass through as + * inherited env), so a read-only install launches and the web-launched and + * host-launched servers of one install share state. */ export const resolveWebLaunch = async (options: ResolveWebLaunchOptions): Promise => { const pluginRoot = resolve(options.pluginRoot); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 396e24448..d2d65ff84 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -204,6 +204,9 @@ describe('generated entry templates', () => { expect(artifactBin).not.toContain(stdioPreludeSpecifier); expect(artifactBin).not.toContain('applyOperatorEnv'); expect(artifactBin).toContain('import * as route0 from "/project/src/cli/report.ts";'); + // An artifact-hosted bin keeps its state out of the installed artifact: + // the code root is the artifact, the state root the user state directory. + expect(artifactBin).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });"); const durableBin = entryShellModule.generatedCliBinEntrySource({ commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, @@ -238,6 +241,9 @@ describe('generated entry templates', () => { }); expect(npmBin).not.toContain('agent-bundle/launch-env'); expect(npmBin).not.toContain('applyOperatorEnv'); + // The `cwd` fallback stays root-anchored: state lives under `/.agent-bundle/state`. + expect(npmBin).toContain("const pluginRoot = resolvePluginRoot({ fallback: join(process.cwd(), '.agent-bundle') });"); + expect(npmBin).not.toContain('stateAnchor'); }); it('conditionally wires the generated web command without changing non-web entry bytes', () => { @@ -270,7 +276,7 @@ describe('generated entry templates', () => { expect(routed).toContain('import { runWebCommand } from "agent-bundle/web-host";'); expect(routed).toContain("import webHostPage from 'agent-bundle/web-host-page';"); expect(routed).toContain( - "const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL(\"../\", import.meta.url)) });", + "const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL(\"../\", import.meta.url)), stateAnchor: 'user-data' });", ); expect(routed).toContain('const artifactRoot = fileURLToPath(new URL("../", import.meta.url));'); expect(routed).toContain([ @@ -328,7 +334,8 @@ describe('generated entry templates', () => { // A routed bin without `web` carries no web wiring: its bytes are those of // the generator without #564 (hash of the same input on this commit's // `entry-shell.ts`; #596's projection steps and `kind: 'cli'` request - // moved the pin from the pre-#564 value). + // moved the pin from the pre-#564 value, #637's `stateAnchor` moved it + // again). const withoutWeb = entryShellModule.generatedCliBinEntrySource({ commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, @@ -336,7 +343,7 @@ describe('generated entry templates', () => { stateFallback: 'artifact', }); expect(createHash('sha256').update(withoutWeb).digest('hex')) - .toBe('b177c34fc9ef98e972b5f5db1296c01219634572a455796fcae30bfaf070ba72'); + .toBe('ad8c21f371af0043464162750a8ed557d968f6155cdd9521ee63c0275253710a'); expect(withoutWeb).not.toContain('agent-bundle/web-host'); expect(withoutWeb).not.toContain('web: Object.freeze({'); }); @@ -624,9 +631,10 @@ 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'"); - // 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)) });"); + // One root resolution per process (#468): the lineage journal opens on the + // same `pluginRoot` the server publishes as `request.plugin` and mounts + // state on; the state root derives from the user state directory (#637). + expect(source).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });"); expect(source).toContain('createSqliteStateDriver({ root: pluginRoot.stateRoot })'); expect(source).toContain(' pluginRoot: pluginRoot.identity,'); expect(source).not.toContain('AGENT_BUNDLE_PLUGIN_ROOT'); @@ -677,7 +685,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( - '93cdfe64b98e0add920ed3f4daa3916620a3f750ec9dbcefc6be6419efab38e5', + '4e2c248b5358b7e13650f2156cf282b03f6f7ede20e2badabafc4b33ae5b4bd5', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -1599,7 +1607,7 @@ 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 pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });"); + expect(durableEntry).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });"); // 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)});`); @@ -1722,7 +1730,7 @@ 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("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });"); + expect(durable).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });"); expect(durable).toContain('createSqliteStateDriver({ root: pluginRoot.stateRoot })'); expect(durable).not.toContain('AGENT_BUNDLE_PLUGIN_ROOT'); diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts index 088163508..f4a2fc466 100644 --- a/packages/agent-bundle/tests/inspect-state.test.ts +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -114,7 +114,7 @@ it('inspects volatile and workspace-durable state without inventing runtime path }, declared: true, driver: 'sqlite', - durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', + durableLocation: '$AGENT_BUNDLE_STATE_ROOT, else ~/.agent-bundle/state/- ($XDG_STATE_HOME/agent-bundle/-) for an installed artifact, or ./.agent-bundle/state for the npm package bin', id: 'fixture/durable-state', lifetime: 'workspace-durable', }, diff --git a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts new file mode 100644 index 000000000..5a6d5fc26 --- /dev/null +++ b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts @@ -0,0 +1,205 @@ +import { execFile as executeFile } from 'node:child_process'; +import { chmod, cp, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { userDataStateRoot } from '@agent-bundle/runtime'; +import { expect, it } from '@rstest/core'; + +import { rstestWorkerRoot } from '../../../rstest.worker-isolation.ts'; +import { exists } from '../src/core/paths.ts'; +import { openPackedMcpServer, removeProjectSource } from '../src/test/packed.ts'; +import { resolveWebLaunch } from '../src/web-host/launch.ts'; +import { readWebManifest } from '../src/web-host/manifest.ts'; +import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; + +const execFile = promisify(executeFile); +const fixtureRoot = resolve(import.meta.dirname, '../fixtures/durable-web-surface'); +const pluginName = 'durable-web-surface-fixture'; +const app = 'journal/status'; + +/** Every path below `root`, directories marked with a trailing slash and files with their size. */ +const treeListing = async (root: string): Promise => { + const listing: string[] = []; + const walk = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const relativePath = relative(root, path); + if (entry.isDirectory()) { + listing.push(`${relativePath}/`); + await walk(path); + } else { + listing.push(`${relativePath} ${String((await stat(path)).size)}`); + } + } + }; + await walk(root); + return listing.sort(); +}; + +const chmodTree = async (root: string, modes: { readonly directory: number; readonly file: number }): Promise => { + await chmod(root, modes.directory); + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) await chmodTree(path, modes); + else await chmod(path, modes.file); + } +}; + +const stringEnvironment = (env: NodeJS.ProcessEnv): Record => + Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); + +interface JournalResult { + readonly entries: readonly { readonly note: string }[]; + readonly revision: number; +} + +/** + * The regression for #637: a workspace-durable plugin installed read-only. + * + * Before the state-root contract every artifact-hosted shell mounted SQLite + * at `/state`, so a plugin with durable `src/state.ts` could not + * serve its first stateful tool call from an installed artifact nothing may + * write beneath. The proof here is bytes and processes: the packed tarball + * is installed into a clean consumer, the artifact is built, the source is + * removed, the whole artifact tree is made read-only, and the generated MCP + * entry is launched exactly the way ` web` launches it — the + * code-root anchor set, no state env at all. The state must land under the + * user state home (`XDG_STATE_HOME`, per worker in every pool) and be read + * back by the artifact CLI bin and by a fresh MCP process, while the artifact + * listing never changes. + */ +it('serves a state-writing tool from a read-only installed artifact without writing beneath it', async () => { + const [agentBundle, runtime, markdownStream] = await Promise.all([ + sharedPackedTarball('agent-bundle'), + sharedPackedTarball('runtime'), + sharedPackedTarball('markdown-stream'), + ]); + const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-readonly-state-')); + const project = join(consumer, 'project'); + const artifact = join(project, 'artifact'); + let readOnly = false; + + try { + await cp(fixtureRoot, project, { recursive: true }); + await execFile('npm', ['install', ...cachedNpmInstallArguments, + agentBundle.tarball, + runtime.tarball, + markdownStream.tarball, + 'react@19.2.8', + 'react-dom@19.2.8', + 'zod@4.4.3', + ], { cwd: project, env: installedEnvironment() }); + const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); + await execFile(cli, ['build', '--root', project, '--output', artifact], { + cwd: project, + env: installedEnvironment(), + }); + const deletedSource = await removeProjectSource({ extraPaths: ['views'], projectRoot: project }); + expect(deletedSource.removed).toEqual(['agent-bundle.config.ts', 'src', 'views']); + + // An installed artifact a host may own read-only: no shell, worker, or bin + // spawned below may create anything beneath it. + await chmodTree(artifact, { directory: 0o555, file: 0o444 }); + readOnly = true; + const listingBefore = await treeListing(artifact); + expect(listingBefore).toEqual(expect.arrayContaining([ + expect.stringMatching(new RegExp(`^bin/${pluginName}\\.mjs \\d+$`, 'u')), + 'mcp/', + ])); + expect(listingBefore).not.toContain('state/'); + + // No custom state env: the launch inherits the worker's XDG_STATE_HOME + // (rstest.worker-isolation.ts), so the derived user-data state root stays + // under the worker root and never touches the developer's home. + const env = stringEnvironment(installedEnvironment()); + delete env['AGENT_BUNDLE_PLUGIN_ROOT']; + delete env['AGENT_BUNDLE_STATE_ROOT']; + const stateHome = env['XDG_STATE_HOME']; + if (stateHome === undefined || !stateHome.startsWith(rstestWorkerRoot())) { + throw new Error(`XDG_STATE_HOME must name a directory under the worker root ${rstestWorkerRoot()}; got ${String(stateHome)}. Is rstest.setup.ts isolating this worker?`); + } + + // Resolve the launch the way ` web` does: the manifest's web + // section names the App and its artifact-relative entry, and + // resolveWebLaunch anchors the code root without naming a state root. + const webManifest = await readWebManifest(join(artifact, 'agent-bundle.manifest.json')); + const declaredApp = webManifest?.apps.find((candidate) => candidate.app === app); + if (declaredApp === undefined) throw new Error(`The artifact manifest exposes no ${app} App: ${JSON.stringify(webManifest)}`); + const launch = await resolveWebLaunch({ app: declaredApp, env, pluginRoot: artifact }); + expect(launch.command).toBe(process.execPath); + expect(launch.cwd).toBe(artifact); + expect(launch.env['AGENT_BUNDLE_PLUGIN_ROOT']).toBe(artifact); + expect(launch.env['AGENT_BUNDLE_STATE_ROOT']).toBeUndefined(); + expect(launch.env['XDG_STATE_HOME']).toBe(stateHome); + const [entry, ...args] = launch.args; + if (entry === undefined) throw new Error('resolveWebLaunch returned no entry argument.'); + expect(entry.startsWith(join(artifact, 'mcp') + '/')).toBe(true); + const openSession = () => openPackedMcpServer({ + args, + cwd: launch.cwd, + deletedSource, + entry, + env: launch.env, + execPath: launch.command, + }); + + const firstSession = await openSession(); + try { + expect(firstSession.provenance.proofLevel).toBe('packed-deleted-source'); + const tools = await firstSession.client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toContain('record'); + for (const [index, note] of ['first', 'second'].entries()) { + const result = await firstSession.client.callTool({ arguments: { note }, name: 'record' }); + expect(result.isError, `record ${note} failed:\n${JSON.stringify(result.content)}\nserver stderr:\n${firstSession.stderr()}`).not.toBe(true); + expect(result.structuredContent).toEqual({ + entries: ['first', 'second'].slice(0, index + 1).map((written) => ({ note: written })), + revision: index + 1, + }); + } + } finally { + await firstSession.close(); + } + + // Nothing landed beneath the read-only artifact; the SQLite kernel sits + // under the user-data state root the child derived from the same code + // root and inherited env. + expect(await treeListing(artifact)).toEqual(listingBefore); + expect(await exists(join(artifact, 'state'))).toBe(false); + const stateRoot = userDataStateRoot(artifact, launch.env); + expect(stateRoot.startsWith(join(stateHome, 'agent-bundle') + '/')).toBe(true); + expect(await readdir(stateRoot)).toEqual(expect.arrayContaining([ + expect.stringMatching(/\.sqlite$/u), + ])); + + // The artifact CLI bin derives the same code root from its own `bin/` + // parent — no AGENT_BUNDLE_PLUGIN_ROOT, no state env — and reads the + // entries the MCP process wrote. + const bin = join(artifact, 'bin', `${pluginName}.mjs`); + const cliRun = await execFile(process.execPath, [bin, 'entries', '--json'], { cwd: consumer, env }); + expect(JSON.parse(cliRun.stdout) as JournalResult).toEqual({ + entries: [{ note: 'first' }, { note: 'second' }], + revision: 2, + }); + + // Restart durability: a fresh MCP process sees the CLI-visible state. + const secondSession = await openSession(); + try { + const result = await secondSession.client.callTool({ arguments: { note: 'third' }, name: 'record' }); + expect(result.isError, `record third failed:\n${JSON.stringify(result.content)}\nserver stderr:\n${secondSession.stderr()}`).not.toBe(true); + expect(result.structuredContent).toEqual({ + entries: [{ note: 'first' }, { note: 'second' }, { note: 'third' }], + revision: 3, + }); + } finally { + await secondSession.close(); + } + expect(await treeListing(artifact)).toEqual(listingBefore); + expect(await exists(join(artifact, 'state'))).toBe(false); + expect(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')).not.toContain('AGENT_BUNDLE_STATE_ROOT'); + } finally { + if (readOnly) await chmodTree(artifact, { directory: 0o755, file: 0o644 }); + await rm(consumer, { force: true, recursive: true }); + } +}, 300_000); diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index d5cedbe9b..ac235d289 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -4,9 +4,11 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; +import { userDataStateRoot } from '@agent-bundle/runtime'; import { specTypeSchemas as clientSchemas } from '@modelcontextprotocol/client'; import { expect, it } from '@rstest/core'; +import { exists } from '../src/core/paths.ts'; import { requestEventRuntime } from '../src/events/ipc.ts'; import { compileTestManifest } from '../src/test/manifest.ts'; import { runPackedContractMatrix } from '../src/test/contract.ts'; @@ -123,6 +125,9 @@ it('serves compiled routes and durable state across packed process restarts', as expect(workerSource).toContain('node:sqlite'); expect(workerSource).toContain('createSqliteStateDriver'); expect(workerSource).toContain('AGENT_BUNDLE_PLUGIN_ROOT'); + // Artifact-hosted shells anchor their state root under the user's state + // home, never beneath the installed (possibly read-only) code root. + expect(workerSource).toContain("stateAnchor: 'user-data'"); expect(workerSource).toMatch(/new URL\(["']\.\.["'], import\.meta\.url\)/u); const harnessManifest = await compileTestManifest({ root: project }); const artifactManifest = JSON.parse( @@ -383,10 +388,14 @@ it('serves compiled routes and durable state across packed process restarts', as await firstSession.close(); } - const stateRoot = join(pluginRoot, 'state'); + // The server inherited the worker's XDG_STATE_HOME with `env`, so its + // SQLite kernel landed under the user-data state root for this code root + // (#637) and nothing was written beneath the installed artifact. + const stateRoot = userDataStateRoot(pluginRoot, env); expect(await readdir(stateRoot)).toEqual(expect.arrayContaining([ expect.stringMatching(/\.sqlite$/u), ])); + expect(await exists(join(pluginRoot, 'state'))).toBe(false); if (secondSession === undefined) throw new TypeError('Contract matrix did not restart the packed session.'); try { diff --git a/packages/agent-bundle/tests/route-manifest-routes.test.ts b/packages/agent-bundle/tests/route-manifest-routes.test.ts index 7246713c8..f533b17a3 100644 --- a/packages/agent-bundle/tests/route-manifest-routes.test.ts +++ b/packages/agent-bundle/tests/route-manifest-routes.test.ts @@ -95,7 +95,7 @@ it('serves the normalized state catalog on the manifest wire', async () => { source: 'declared', }, driver: 'sqlite', - durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', + durableLocation: '$AGENT_BUNDLE_STATE_ROOT, else ~/.agent-bundle/state/- ($XDG_STATE_HOME/agent-bundle/-) for an installed artifact, or ./.agent-bundle/state for the npm package bin', id: 'fixture/catalog-state', lifetime: 'workspace-durable', noticeRetention: { diff --git a/packages/agent-bundle/tests/rstest-worker-isolation.test.ts b/packages/agent-bundle/tests/rstest-worker-isolation.test.ts index ac0efef0b..dff4e589c 100644 --- a/packages/agent-bundle/tests/rstest-worker-isolation.test.ts +++ b/packages/agent-bundle/tests/rstest-worker-isolation.test.ts @@ -74,6 +74,13 @@ it('pins the Playwright browser registry before the per-worker cache override hi expect(pinned?.startsWith(rstestWorkerRoot())).toBe(false); }); +it('anchors the user state home of every spawned shell under the worker root', () => { + // The setup file already isolated this worker: a generated shell with + // workspace-durable state derives `$XDG_STATE_HOME/agent-bundle/` + // from this value, so no pool writes beneath the developer's home. + expect(process.env['XDG_STATE_HOME']).toBe(join(rstestWorkerRoot(), 'cache', 'xdg-state')); +}); + it('removes only the finished roots owned by one host temporary root', async () => { const parent = await mkdtemp(join(tmpdir(), 'ab-rstest-roots-parent-')); const legTmp = '/tmp/abci-deadbeef-verify-node24'; diff --git a/packages/agent-bundle/tests/web-launch.test.ts b/packages/agent-bundle/tests/web-launch.test.ts index 1b43874bf..60031732a 100644 --- a/packages/agent-bundle/tests/web-launch.test.ts +++ b/packages/agent-bundle/tests/web-launch.test.ts @@ -2,6 +2,7 @@ import { chmod, mkdir, mkdtemp, readdir, realpath, rm, stat, writeFile } from 'n import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pluginStateSegment } from '@agent-bundle/runtime'; import { afterEach, describe, expect, it } from '@rstest/core'; import { exists } from '../src/core/paths.ts'; @@ -140,6 +141,23 @@ describe('resolveWebLaunch', () => { expect(webPluginDataDirectory(first, 'status', home)).toBe(webPluginDataDirectory(`${first}/mcp/..`, 'status', home)); }); + it('keys the web data directory on the same segment the runtime keys the state root on', async () => { + // web-host/launch.ts never loads the optional `@agent-bundle/runtime` + // peer, so its segment is a separate implementation of the runtime's + // `pluginStateSegment`; this pins the two spellings together for a safe + // basename (`-`) and an unsafe one (`plugin-`). + const home = await homeRoot(); + const safe = await artifactRoot(); + const unsafe = join(safe, '.un safe'); + await mkdir(unsafe); + for (const root of [safe, unsafe]) { + expect(webPluginDataDirectory(root, 'status', home)) + .toBe(join(home, '.agent-bundle', 'web-data', pluginStateSegment(root), 'status')); + } + expect(pluginStateSegment(safe)).toMatch(/^agent-bundle-web-launch-[^/]+-[0-9a-f]{16}$/u); + expect(pluginStateSegment(unsafe)).toMatch(/^plugin-[0-9a-f]{16}$/u); + }); + it('creates no data directory when no declared value names plugin-data', async () => { const root = await artifactRoot(); const home = await homeRoot(); diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 7cf242521..6b246d469 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -86,13 +86,12 @@ export interface AgentWorkspaceIdentity { } /** - * 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. + * The plugin's code root and framework state root (#468). `root` comes from + * `AGENT_BUNDLE_PLUGIN_ROOT` or the shell fallback. `stateRoot` comes from + * `AGENT_BUNDLE_STATE_ROOT`; installed artifacts otherwise use + * `~/.agent-bundle/state/-` or + * `$XDG_STATE_HOME/agent-bundle/-`, while root-anchored shells + * use `/state`. */ export interface AgentPluginIdentity { readonly root: string; diff --git a/packages/rsc-runtime/src/plugin-root.ts b/packages/rsc-runtime/src/plugin-root.ts index 6c4aaff32..6ceef8ac0 100644 --- a/packages/rsc-runtime/src/plugin-root.ts +++ b/packages/rsc-runtime/src/plugin-root.ts @@ -1,18 +1,24 @@ -import { join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { existsSync, realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, 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`. + * The environment variable every emitted shell receives with the code root + * in the host's own spelling. */ 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. */ +/** The optional environment override for the framework state root. */ +export const PLUGIN_STATE_ROOT_ENV_ANCHOR = 'AGENT_BUNDLE_STATE_ROOT'; + +/** The state directory below a root-anchored code root. */ export const PLUGIN_STATE_DIRECTORY = 'state'; +export type PluginStateAnchor = 'root' | 'user-data'; + export interface ResolvePluginRootOptions { /** The environment to read; `process.env` by default. */ readonly env?: Readonly>; @@ -24,14 +30,20 @@ export interface ResolvePluginRootOptions { readonly fallback: string; /** Receives one line when the anchor is present but unexpanded; stderr by default. */ readonly warn?: (message: string) => void; + /** Where `stateRoot` anchors when `AGENT_BUNDLE_STATE_ROOT` is unset; `'root'` by default. */ + readonly stateAnchor?: PluginStateAnchor; + /** The user home `'user-data'` anchors under; `os.homedir()` by default. */ + readonly home?: string; } -/** The anchor a generated shell resolved once and mounts everything on. */ +/** The code and framework-state roots a generated shell resolved once. */ 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'; + /** `native` when `AGENT_BUNDLE_STATE_ROOT` supplied the state root. */ + readonly stateSource: 'native' | 'derived'; } /** A host that passed its manifest through literally leaves `${CLAUDE_PLUGIN_ROOT}`-style tokens in the value. */ @@ -41,21 +53,49 @@ const defaultWarn = (message: string): void => { process.stderr.write(`${message}\n`); }; +const safePluginSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; + +/** One stable, filesystem-safe segment per installed code root. */ +export const pluginStateSegment = (root: string): string => { + const canonicalRoot = existsSync(root) ? realpathSync(root) : resolve(root); + const digest = createHash('sha256').update(canonicalRoot).digest('hex').slice(0, 16); + const name = basename(canonicalRoot); + return safePluginSegment.test(name) ? `${name}-${digest}` : `plugin-${digest}`; +}; + +/** The user-level directory that holds framework state for installed plugins. */ +export const userStateHome = ( + env: Readonly> = process.env, + home = homedir(), +): string => { + const xdgStateHome = env.XDG_STATE_HOME ?? ''; + return xdgStateHome.trim() === '' + ? join(home, '.agent-bundle', PLUGIN_STATE_DIRECTORY) + : join(xdgStateHome, 'agent-bundle'); +}; + +/** The framework state root for one installed plugin. */ +export const userDataStateRoot = ( + root: string, + env?: Readonly>, + home?: string, +): string => join(userStateHome(env, home), pluginStateSegment(root)); + /** - * 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. + * Resolves the code root and framework state root once per generated process. + * The code root identifies the installed artifact; the state root holds the + * SQLite kernel, notice ledger, and lineage journal. * * `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. + * `fallback` anchors the plugin (`source: 'derived'`). + * + * `AGENT_BUNDLE_STATE_ROOT` independently overrides `stateRoot`; otherwise it + * derives below the code root or the user's state home according to + * `stateAnchor`. */ export const resolvePluginRoot = (options: ResolvePluginRootOptions): ResolvedPluginRoot => { const env = options.env ?? process.env; @@ -77,11 +117,30 @@ export const resolvePluginRoot = (options: ResolvePluginRootOptions): ResolvedPl root = resolve(declared); source = 'native'; } - const stateRoot = join(root, PLUGIN_STATE_DIRECTORY); + const derivedStateRoot = options.stateAnchor === 'user-data' + ? userDataStateRoot(root, env, options.home) + : join(root, PLUGIN_STATE_DIRECTORY); + const declaredStateRoot = env[PLUGIN_STATE_ROOT_ENV_ANCHOR] ?? ''; + let stateRoot: string; + let stateSource: 'native' | 'derived'; + if (declaredStateRoot.trim() === '') { + stateRoot = derivedStateRoot; + stateSource = 'derived'; + } else if (unexpandedToken.test(declaredStateRoot)) { + (options.warn ?? defaultWarn)( + `[agent-bundle] ${PLUGIN_STATE_ROOT_ENV_ANCHOR} is the unexpanded token ${JSON.stringify(declaredStateRoot)}; anchoring state on ${derivedStateRoot} instead.`, + ); + stateRoot = derivedStateRoot; + stateSource = 'derived'; + } else { + stateRoot = resolve(declaredStateRoot); + stateSource = 'native'; + } return Object.freeze({ identity: available({ root, stateRoot }, source), root, source, stateRoot, + stateSource, }); }; diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts index bdef9e50c..dc9688a84 100644 --- a/packages/rsc-runtime/src/plugin.ts +++ b/packages/rsc-runtime/src/plugin.ts @@ -65,8 +65,16 @@ 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'; +export { + PLUGIN_ROOT_ENV_ANCHOR, + PLUGIN_STATE_DIRECTORY, + PLUGIN_STATE_ROOT_ENV_ANCHOR, + pluginStateSegment, + resolvePluginRoot, + userDataStateRoot, + userStateHome, +} from './plugin-root.js'; +export type { PluginStateAnchor, 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 index e955b41e5..df8684cbb 100644 --- a/packages/rsc-runtime/tests/plugin-root.test.ts +++ b/packages/rsc-runtime/tests/plugin-root.test.ts @@ -1,9 +1,21 @@ -import { join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, 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'; +import { + PLUGIN_ROOT_ENV_ANCHOR, + PLUGIN_STATE_ROOT_ENV_ANCHOR, + pluginStateSegment, + resolvePluginRoot, + userDataStateRoot, + userStateHome, +} from '../src/plugin-root.js'; + +const digest16 = (path: string): string => createHash('sha256').update(path).digest('hex').slice(0, 16); describe('resolvePluginRoot (#468)', () => { const fallback = '/artifact/claude'; @@ -15,6 +27,7 @@ describe('resolvePluginRoot (#468)', () => { identity: { source: 'native', state: 'available', value: { root: '/installs/curator', stateRoot: '/installs/curator/state' } }, root: '/installs/curator', source: 'native', + stateSource: 'derived', stateRoot: '/installs/curator/state', }); expect(Object.isFrozen(resolved)).toBe(true); @@ -26,6 +39,7 @@ describe('resolvePluginRoot (#468)', () => { expect(resolved.root).toBe('/opt/curator '); expect(resolved.stateRoot).toBe(join('/opt/curator ', 'state')); expect(resolved.source).toBe('native'); + expect(resolved.stateSource).toBe('derived'); }); it('makes a relative anchor absolute against the working directory, as Codex hands "./"', () => { @@ -34,13 +48,19 @@ describe('resolvePluginRoot (#468)', () => { expect(resolved.root).toBe(resolve('./')); expect(resolved.stateRoot).toBe(join(resolve('./'), 'state')); expect(resolved.source).toBe('native'); + expect(resolved.stateSource).toBe('derived'); }); 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).toMatchObject({ + root: fallback, + source: 'derived', + stateRoot: `${fallback}/state`, + stateSource: 'derived', + }); expect(resolved.identity).toEqual({ source: 'derived', state: 'available', value: { root: fallback, stateRoot: `${fallback}/state` } }); expect(warnings).toEqual([]); } @@ -54,7 +74,12 @@ describe('resolvePluginRoot (#468)', () => { warn: (message) => warnings.push(message), }); - expect(resolved).toMatchObject({ root: fallback, source: 'derived', stateRoot: `${fallback}/state` }); + expect(resolved).toMatchObject({ + root: fallback, + source: 'derived', + stateRoot: `${fallback}/state`, + stateSource: 'derived', + }); 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.`, @@ -71,6 +96,122 @@ describe('resolvePluginRoot (#468)', () => { else process.env[PLUGIN_ROOT_ENV_ANCHOR] = previous; } }); + + it('uses AGENT_BUNDLE_STATE_ROOT as an independent native state root', () => { + const declaredStateRoot = './framework state '; + const resolved = resolvePluginRoot({ + env: { + [PLUGIN_ROOT_ENV_ANCHOR]: '/installs/curator', + [PLUGIN_STATE_ROOT_ENV_ANCHOR]: declaredStateRoot, + }, + fallback, + }); + + expect(resolved).toMatchObject({ + root: '/installs/curator', + source: 'native', + stateRoot: resolve(declaredStateRoot), + stateSource: 'native', + }); + }); + + it('treats a blank AGENT_BUNDLE_STATE_ROOT as unset', () => { + for (const declared of ['', ' ']) { + const resolved = resolvePluginRoot({ + env: { + [PLUGIN_ROOT_ENV_ANCHOR]: '/installs/curator', + [PLUGIN_STATE_ROOT_ENV_ANCHOR]: declared, + }, + fallback, + }); + + expect(resolved).toMatchObject({ + root: '/installs/curator', + source: 'native', + stateRoot: '/installs/curator/state', + stateSource: 'derived', + }); + } + }); + + it('reports an unexpanded state-root token once and derives the state root', () => { + const warnings: string[] = []; + const resolved = resolvePluginRoot({ + env: { + [PLUGIN_ROOT_ENV_ANCHOR]: '/installs/curator', + [PLUGIN_STATE_ROOT_ENV_ANCHOR]: '${PLUGIN_STATE}', + }, + fallback, + warn: (message) => warnings.push(message), + }); + + expect(resolved).toMatchObject({ + root: '/installs/curator', + source: 'native', + stateRoot: '/installs/curator/state', + stateSource: 'derived', + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('AGENT_BUNDLE_STATE_ROOT'); + expect(warnings[0]).toContain('${PLUGIN_STATE}'); + }); + + it('derives user-data state below an explicit home', () => { + const root = '/installs/curator'; + const home = '/users/tester'; + const segment = `curator-${digest16(resolve(root))}`; + + expect(userStateHome({}, home)).toBe(join(home, '.agent-bundle', 'state')); + expect(userDataStateRoot(root, {}, home)).toBe(join(home, '.agent-bundle', 'state', segment)); + expect(resolvePluginRoot({ + env: { [PLUGIN_ROOT_ENV_ANCHOR]: root }, + fallback, + home, + stateAnchor: 'user-data', + })).toMatchObject({ + root, + stateRoot: join(home, '.agent-bundle', 'state', segment), + stateSource: 'derived', + }); + }); + + it('uses a non-blank XDG_STATE_HOME and ignores a blank one', () => { + const root = '/installs/curator'; + const home = '/users/tester'; + const segment = `curator-${digest16(resolve(root))}`; + + expect(userStateHome({ XDG_STATE_HOME: '/xdg/state' }, home)).toBe('/xdg/state/agent-bundle'); + expect(userDataStateRoot(root, { XDG_STATE_HOME: '/xdg/state' }, home)).toBe( + join('/xdg/state', 'agent-bundle', segment), + ); + expect(userStateHome({ XDG_STATE_HOME: ' ' }, home)).toBe(join(home, '.agent-bundle', 'state')); + }); + + it('uses plugin as the segment name when the root basename is unsafe', () => { + const root = '/installs/unsafe name'; + expect(pluginStateSegment(root)).toBe(`plugin-${digest16(resolve(root))}`); + }); + + it('digests real roots canonically and missing roots by their resolved spelling', () => { + const directory = mkdtempSync(join(tmpdir(), 'agent-bundle-plugin-root-')); + try { + const root = join(directory, 'curator'); + const link = join(directory, 'curator-link'); + mkdirSync(root); + symlinkSync(root, link, 'dir'); + + const canonical = realpathSync(root); + const expectedRealSegment = `${basename(canonical)}-${digest16(canonical)}`; + expect(pluginStateSegment(root)).toBe(expectedRealSegment); + expect(pluginStateSegment(link)).toBe(expectedRealSegment); + + const missing = join(directory, 'missing', '..', 'ghost'); + const resolvedMissing = resolve(missing); + expect(pluginStateSegment(missing)).toBe(`${basename(resolvedMissing)}-${digest16(resolvedMissing)}`); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); }); describe('the plugin request axis (#468)', () => { diff --git a/packages/workbench/tests/route-manifest-client.test.ts b/packages/workbench/tests/route-manifest-client.test.ts index ea660145d..cb99e457b 100644 --- a/packages/workbench/tests/route-manifest-client.test.ts +++ b/packages/workbench/tests/route-manifest-client.test.ts @@ -97,7 +97,7 @@ const manifest = { source: 'defaults', }, driver: 'sqlite', - durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', + durableLocation: '$AGENT_BUNDLE_STATE_ROOT, else ~/.agent-bundle/state/- ($XDG_STATE_HOME/agent-bundle/-) for an installed artifact, or ./.agent-bundle/state for the npm package bin', id: 'library/catalog', lifetime: 'workspace-durable', // The dev server's manifest carries the resolved notice retention policy diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index a6d495479..e73edf8f2 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -156,6 +156,7 @@ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/packed-consumer-typescript.test.ts', 'packages/agent-bundle/tests/packed-host-install-proof.test.ts', 'packages/agent-bundle/tests/packed-native-smoke.test.ts', + 'packages/agent-bundle/tests/packed-readonly-state-root.test.ts', 'packages/agent-bundle/tests/packed-stdio-projection.test.ts', 'packages/agent-bundle/tests/packed-web-command.test.ts', 'packages/agent-bundle/tests/public-api-packed.test.ts', diff --git a/rstest.worker-isolation.ts b/rstest.worker-isolation.ts index 684ed9ad3..f8bc32d6a 100644 --- a/rstest.worker-isolation.ts +++ b/rstest.worker-isolation.ts @@ -118,6 +118,11 @@ export const isolateWorkerEnvironment = (): void => { env['TMP'] = root; env['TEMP'] = root; env['XDG_CACHE_HOME'] = cache; + // Generated shells with workspace-durable state derive their user state + // root from XDG_STATE_HOME (`resolvePluginRoot` with the `user-data` + // anchor), so every shell a pool spawns writes SQLite under this worker's + // root and never beneath the developer's home. + env['XDG_STATE_HOME'] = rstestWorkerCacheDirectory('xdg-state'); }; let commandSerial = 0; diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index b7446015d..704c86133 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -679,18 +679,16 @@ const readPluginRoot = (env: Record): string | undef 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`: +resolves its code and framework state roots once at startup with `resolvePluginRoot` from +`@agent-bundle/runtime` and publishes them 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 + plugin.value.root; // the code root: AGENT_BUNDLE_PLUGIN_ROOT or the shell's fallback + plugin.value.stateRoot; // the framework state root: AGENT_BUNDLE_STATE_ROOT or the default + plugin.source; // the code root is 'native' from the variable or 'derived' } ``` @@ -698,12 +696,17 @@ if (plugin.state === 'available') { 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')`. +reported once on stderr, and never joined into a path. Artifact-hosted shells default +`plugin.value.stateRoot` to `~/.agent-bundle/state/-` (or +`$XDG_STATE_HOME/agent-bundle/-`), derived from the code root's safe basename and +the first 16 hexadecimal characters of the SHA-256 digest of its real path. +`AGENT_BUNDLE_STATE_ROOT` overrides that location; the npm package bin keeps +`$PWD/.agent-bundle/state`. 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 @@ -1039,8 +1042,10 @@ that lists them. Missing `agent-bundle.manifest.json` beside `bin/` exits `1`. T launches the plugin's own MCP server (`node /mcp/.mjs`, `AGENT_BUNDLE_PLUGIN_ROOT=`, declared static env with path tokens expanded — the plugin-data token resolves outside the artifact, under -`~/.agent-bundle/web-data/-/`, so a read-only install still launches), -calls the opening tool once, serves the App at `http://127.0.0.1:/` on a loopback origin with a +`~/.agent-bundle/web-data/-/`, so a read-only install still launches). +The spawned server's own durable framework state likewise lives outside the artifact under +`~/.agent-bundle/state/-` (or `AGENT_BUNDLE_STATE_ROOT`). It calls the opening +tool once and serves the App at `http://127.0.0.1:/` on a loopback origin with a second loopback sandbox origin for the App document (the same host stack and consent behavior as `agent-bundle serve-app` and the Workbench), prints `MCP App / at (tool ; Ctrl-C stops the server)` (or one JSON line with diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index a16e94217..147287c81 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -380,9 +380,9 @@ capability that admits it. The artifact bin is a self-contained ESM module with executable bit — run it as `node /bin/.mjs `, exactly like `scripts/*.mjs`. Help, argv parsing, output modes, exit codes, and signals match the package bin; the one difference is that -workspace-durable state without a host-supplied `AGENT_BUNDLE_PLUGIN_ROOT` anchors on the artifact -root (the parent of `bin/`, the same fallback the generated MCP worker uses) instead of -`$PWD/.agent-bundle/state`, so a co-installed CLI and server share one store. +the artifact-hosted CLI and MCP server derive the same framework state root from the same code +root — `~/.agent-bundle/state/-`, or `AGENT_BUNDLE_STATE_ROOT` when set — so they +share one store. The npm package bin keeps `$PWD/.agent-bundle/state`. Reach it from the other surfaces with the plugin-root token — `${CLAUDE_PLUGIN_ROOT}/bin/.mjs` in Claude Skill Markdown and hook commands, `${PLUGIN_ROOT}/…` in Codex hooks, `${CURSOR_PLUGIN_ROOT}/…` diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 8ce13ddbb..150dd56c2 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -102,10 +102,13 @@ rebuilding without a version bump no longer needs an uninstall and `rm -rf`. A d refused with `AB7005` unless you pass `--replace` (alias `--force`), and a foreign directory — one this plugin's installer did not place — is refused either way. Cursor copies carry an install receipt (`.agent-bundle-install.json`: plugin, version, host, content hash, owned files); replacement is in -place and touches owned files only, never unowned entries such as `state/`, and `--replace` adopts -a pre-receipt copy. Claude replacement runs `claude plugin uninstall --keep-data` before -reinstalling because `plugin update` is version-gated; Codex runs `codex plugin remove` before -`add`. The emitted `INSTALL.md` documents the same recipe per host. +place and touches owned files only, never unowned entries such as legacy or in-place `state/`, and +`--replace` adopts a pre-receipt copy. Current artifact builds keep framework state under +`~/.agent-bundle/state/-` instead (`AGENT_BUNDLE_STATE_ROOT` overrides that +location); `uninstall` does not remove it. Claude replacement runs +`claude plugin uninstall --keep-data` before reinstalling because `plugin update` is +version-gated; Codex runs `codex plugin remove` before `add`. The emitted `INSTALL.md` documents +the same recipe per host. ## Operator configuration: the installed pack's `.env` @@ -170,7 +173,7 @@ variables each declares (`AB7331`, informational) — never a name or a value. ```sh # exact paths, nothing changes npx agent-bundle uninstall cursor --from artifact --plan -# receipt-owned files; state/ kept +# receipt-owned files; legacy or in-place state/ kept npx agent-bundle uninstall cursor --from artifact # claude plugin uninstall --keep-data + marketplace remove npx agent-bundle uninstall claude --from artifact @@ -184,8 +187,9 @@ created, the host registrations it performed, and timestamps. Cursor local copie `.agent-bundle-install.json`; Claude, Codex, and Cursor marketplace-mode installs keep theirs under `/agent-bundle/receipts/`. `uninstall` removes exactly what the receipt owns and reverses exactly the registrations it recorded — never anything else; unowned entries are listed -as retained. Durable runtime state (`state/`, and for a Cursor copy of an Agent Plugins pack the -`PLUGIN_DATA` directory the receipt records) is kept unless you pass `--purge-data --confirm-purge`, and the result states honestly what the host itself decided where +as retained. Legacy or in-place durable runtime state (`state/`, and for a Cursor copy of an Agent +Plugins pack the `PLUGIN_DATA` directory the receipt records) is kept unless you pass +`--purge-data --confirm-purge`, and the result states honestly what the host itself decided where Agent Bundle cannot (Claude orphans its cached copy for a ~14-day grace period; Codex deletes the cached tree and offers no keep-data option). A missing receipt (`AB7009`) or a content mismatch (`AB7007`) is refused unless `--force`; a directory that belongs to another plugin is refused diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 685e6f483..e16ae6719 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -137,8 +137,10 @@ plugin root — `agent-bundle.manifest.json` must sit beside `bin/` (exit `1` ot The command launches the plugin's own MCP server (`node /mcp/.mjs`, `AGENT_BUNDLE_PLUGIN_ROOT=`, declared static env with path tokens expanded — the plugin-data token resolves outside the artifact, under -`~/.agent-bundle/web-data/-/`, so a read-only install still launches), -calls the opening tool once, and serves the App at `http://127.0.0.1:/` on a loopback origin with a +`~/.agent-bundle/web-data/-/`, so a read-only install still launches). +The spawned server's own durable framework state likewise lives outside the artifact under +`~/.agent-bundle/state/-` (or `AGENT_BUNDLE_STATE_ROOT`). It calls the opening +tool once and serves the App at `http://127.0.0.1:/` on a loopback origin with a second loopback sandbox origin for the App document — the same host stack and consent behavior as `agent-bundle serve-app` and the Workbench. Human mode prints `MCP App / at (tool ; Ctrl-C stops the server)` and runs until Ctrl-C / @@ -204,7 +206,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--from ` | `process.cwd()` | The artifact root that identifies the plugin (name, version, marketplace), with the host's manifest directly under it, as for `install`. | | `--scope ` | `user` | The scope the plugin was installed at (Claude). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | -| `--keep-data` | on | Keep durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the recorded `PLUGIN_DATA` directory). This is the default; the flag makes it explicit. | +| `--keep-data` | on | Keep legacy or in-place durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the recorded `PLUGIN_DATA` directory). This is the default; the flag makes it explicit. (Artifacts built from this release keep framework state under `~/.agent-bundle/state/-`, which `uninstall` does not remove.) | | `--purge-data` | off | Also remove durable runtime state. Refused (`AB7008`) without `--confirm-purge`. | | `--force` | off | Proceed without a receipt (legacy Cursor copy, host-only install) or when owned content, version, or staged `HEAD` no longer matches the receipt. A receipt or manifest naming another plugin is refused regardless. | | `--plan` | off | Print the exact paths and host registrations that would be removed and change nothing. | @@ -219,8 +221,9 @@ install at another scope or in another project known only to Claude's `plugins/i registry). Unowned entries are retained and listed; a missing receipt is `AB7009` and a mismatch is `AB7007` unless `--force`; a second run is a `not-installed` no-op. The typed `data.outcome` says what happened to durable state per host: Cursor `kept` / `purged` / `absent`; Claude `retained-by-host` (the cached copy is -orphaned for Claude's ~14-day grace period; a purge also removes `state/` and -`plugins/data//`); Codex `removed-by-host` / `unavailable` (`codex plugin remove` deletes the +orphaned for Claude's ~14-day grace period; a purge also removes legacy or in-place `state/` and +`plugins/data//`, but not the current `~/.agent-bundle/state/-` framework +state); Codex `removed-by-host` / `unavailable` (`codex plugin remove` deletes the cached tree and has no keep-data option). The package-relative installer bin accepts `uninstall ` with the same flags; the emitted `install.mjs` accepts `--uninstall` with `--mode`, `--keep-data`, `--purge-data --confirm-purge`, `--force`, and `--plan`. diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 06a0f3c5f..ef87fc2bc 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -39,7 +39,9 @@ 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 plugin install root — the composite plugin root, the directory holding the host manifests, `mcp/`, `hooks/`, and `bin/` — 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_PLUGIN_ROOT` | Generated executables | The plugin code root — the composite plugin root, the directory holding the host manifests, `mcp/`, `hooks/`, and `bin/`. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin.value.root` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | +| `AGENT_BUNDLE_STATE_ROOT` | Generated executables | The framework state root: the exact directory where the SQLite state kernel, notice ledger, and lineage journal live. Overrides the derived location. An unexpanded `${…}` token is treated as unset. | +| `XDG_STATE_HOME` | Generated executables | When set, the derived state root is `$XDG_STATE_HOME/agent-bundle/-` instead of `~/.agent-bundle/state/-`. | | `AGENT_BUNDLE_ENV_FILE` | Generated executables | The operator env file(s) an installed pack reads at launch instead of `/.env` and `.env.local`: one path, or several joined by the platform path delimiter, later files winning; `none` disables the layer. `mcp run` sets it for its child from `--env-file` / `--no-env`. | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | The bearer token the Agent API requires before it can be enabled. | | `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench event route workspace sets it. | @@ -84,13 +86,19 @@ variable count of each file (`AB7331`). Details and the precedence table: ## Durable state -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. 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. +Installed artifacts — the MCP entry, Flight worker, artifact CLI bin, render worker, and standalone +hook wrappers — derive `~/.agent-bundle/state/-` as their framework state root. +`` is the code root's basename, or `plugin` when that basename is not a safe path segment; +`` is the first 16 hexadecimal characters of the SHA-256 digest of the code root's real +path. Two installs therefore never share state, and a read-only install launches. `XDG_STATE_HOME` +moves the derived root as described above, and `AGENT_BUNDLE_STATE_ROOT` overrides it. The npm +package bin keeps `./.agent-bundle/state`. + +Only a `workspace-durable` state definition uses the SQLite driver; other lifetimes use the +in-memory driver and keep nothing on disk. Every generated process resolves the code root and state +root exactly once (`resolvePluginRoot` from `@agent-bundle/runtime`) and publishes them as +`(await agent()).plugin` — `{ root, stateRoot }` — 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 4643574a6..6c6a4ffb1 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -603,22 +603,25 @@ const readPluginRoot = (env: Record): string | undef 在路由内部完全不需要读取这个变量。每个生成的外壳——MCP 入口及其 Flight worker、路由式 CLI 可执行文件 及其渲染 worker、hook 包装器——都在启动时用 `@agent-bundle/runtime` 的 `resolvePluginRoot` 解析一次 -锚点,把 SQLite 状态、通知账本与 lineage 日志挂载在 `/state` 之下,并在它打开的每个请求上以 -`(await agent()).plugin` 发布同一个值: +代码根目录与框架状态根目录,并在它打开的每个请求上以 `(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' + plugin.value.root; // 代码根目录:AGENT_BUNDLE_PLUGIN_ROOT,或外壳的回退值 + plugin.value.stateRoot; // 框架状态根目录:AGENT_BUNDLE_STATE_ROOT,或默认位置 + 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` 并列, +拼接进路径。产物宿主外壳把 `plugin.value.stateRoot` 默认为 +`~/.agent-bundle/state/-`(或 `$XDG_STATE_HOME/agent-bundle/-`),由 +代码根目录的安全 basename 及其真实路径 SHA-256 摘要的前 16 位十六进制字符推导而来。 +`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置;npm 包 bin 仍使用 `$PWD/.agent-bundle/state`。约定式 +provider 在其工厂上下文中收到同样的被观察值 `plugin`,与 `invocation`、`signal` 并列, 因此把文件放在框架状态旁边的 provider 自己不必再推导任何东西。route-unit 与 `mcp-in-memory` 测试层级 以同样方式解析它(回退到 `<项目根目录>/.agent-bundle`),并像其他每个轴一样接受 `context.plugin` 覆盖; 在生成的作用域之外,该轴为 `unavailable('not-provided')`。 @@ -902,7 +905,8 @@ node /bin/.mjs web [/] [--port N] [--open|--no-open] 缺少 `agent-bundle.manifest.json` 则以 `1` 退出。该命令启动插件自己的 MCP 服务器 (`node /mcp/.mjs`,`AGENT_BUNDLE_PLUGIN_ROOT=`,已声明的静态 env 并展开路径 令牌——plugin-data 令牌解析到产物之外的 `~/.agent-bundle/web-data/-/`, -因此只读安装也能启动),先调用一次开场工具,然后在 `http://127.0.0.1:/` 上用 loopback origin 提供 App,并为 +因此只读安装也能启动)。被拉起的服务器自己的持久框架状态同样落在产物之外的 +`~/.agent-bundle/state/-`(或 `AGENT_BUNDLE_STATE_ROOT`)。随后先调用一次开场工具,然后在 `http://127.0.0.1:/` 上用 loopback origin 提供 App,并为 App 文档再开一个 loopback 沙箱 origin(与 `agent-bundle serve-app` 以及 Workbench 同一套宿主栈与 同意行为),打印 `MCP App / at (tool ; Ctrl-C stops the server)`(或带 `--json` 时打印一行 `{ app, server, tool, url, port, resourceUri, sandboxOrigin }`),并一直运行 diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 1e1af67e4..7b0b42f8e 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -346,9 +346,9 @@ id。只要路由从生成的 CLI 可执行文件运行,无论命令由哪种 `bin/-flight.mjs`),由所有选中宿主共享。每个内置 target 都发布了准入它的 `cli` 能力。产物 bin 是一个 自包含的 ESM 模块,没有 shebang 也没有可执行位——像 `scripts/*.mjs` 一样以 `node /bin/.mjs ` 运行。帮助、argv 解析、输出模式、退出码与信号都与 -包 bin 一致;唯一的区别是,在宿主未提供 `AGENT_BUNDLE_PLUGIN_ROOT` 时,工作区级持久状态锚定在产物根目录 -(`bin/` 的父目录,与生成的 MCP worker 使用同一个回退),而不是 `$PWD/.agent-bundle/state`,这样一起安装 -的 CLI 与服务器观察到的是同一份存储。 +包 bin 一致;唯一的区别是,产物宿主的 CLI 与 MCP 服务器从同一个代码根目录推导出同一个框架状态根目录—— +`~/.agent-bundle/state/-`,或已设置的 `AGENT_BUNDLE_STATE_ROOT`——因此共享同一份存储。 +npm 包 bin 仍使用 `$PWD/.agent-bundle/state`。 从其他表面引用它时使用插件根 token——Claude Skill Markdown 与 hook 命令中写 `${CLAUDE_PLUGIN_ROOT}/bin/.mjs`,Codex hook 中写 `${PLUGIN_ROOT}/…`,Cursor hook 中写 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 42591276c..fea62a5e7 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -85,8 +85,10 @@ node ./install.mjs 内容完全相同的副本是 `already-installed` 空操作。**版本相同但内容哈希不同**的副本会被自动替换,因此不升版本 地重建不再需要卸载加 `rm -rf`。版本不同则以 `AB7005` 拒绝,除非传入 `--replace`(别名 `--force`);外来目录 ——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 副本携带安装回执(`.agent-bundle-install.json`: -插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动 `state/` 之类的非归属条目, -`--replace` 会接管回执出现之前的副本。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, +插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目, +`--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在 +`~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置),`uninstall` +不会删除它。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, 因为 `plugin update` 受版本门控;Codex 先 `codex plugin remove` 再 `add`。输出的 `INSTALL.md` 按宿主记录了 同样的步骤。 @@ -142,7 +144,7 @@ RTORRENT_SSH_HOST=nas.local ```sh # 确切路径,不做改动 npx agent-bundle uninstall cursor --from artifact --plan -# 回执归属的文件;保留 state/ +# 回执归属的文件;保留旧版或就地的 state/ npx agent-bundle uninstall cursor --from artifact # claude plugin uninstall --keep-data + marketplace remove npx agent-bundle uninstall claude --from artifact @@ -153,7 +155,7 @@ node artifact/install.mjs --uninstall [--mode marketplace] 每次安装都会写入生命周期回执(`agent-bundle-install-receipt/2`):版本、内容哈希、投递模式、作用域、归属的文件与 目录、安装器创建的宿主目录、执行过的宿主注册,以及时间戳。Cursor 本地副本以 `.agent-bundle-install.json` 携带它; Claude、Codex 与 Cursor 市场模式的安装把回执放在 `<宿主根目录>/agent-bundle/receipts/` 下。`uninstall` 只删除回执 -归属的内容,只撤销它记录的注册——绝不多删;非归属条目会被列为保留。持久运行时状态(`state/`,以及 Agent Plugins +归属的内容,只撤销它记录的注册——绝不多删;非归属条目会被列为保留。旧版或就地的持久运行时状态(`state/`,以及 Agent Plugins 包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录)除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 副本标为 orphaned 并保留约 14 天;Codex 删除缓存树且没有 keep-data 选项)。缺少回执(`AB7009`)或内容不匹配 (`AB7007`)会被拒绝,除非 `--force`;属于另一个插件的目录无论如何都被拒绝;再次运行是 `not-installed` 空操作。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 235423aca..0160edf22 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -131,8 +131,9 @@ node /bin/.mjs web [/] [--port N] [--open|--no-open] 该命令启动插件自己的 MCP 服务器(`node /mcp/.mjs`,`AGENT_BUNDLE_PLUGIN_ROOT=`, 已声明的静态 env 并展开路径令牌——plugin-data 令牌解析到产物之外的 -`~/.agent-bundle/web-data/-/`,因此只读安装也能启动),先调用一次开场工具, -然后在 `http://127.0.0.1:/` 上用 +`~/.agent-bundle/web-data/-/`,因此只读安装也能启动)。被拉起的服务器自己的 +持久框架状态同样落在产物之外的 `~/.agent-bundle/state/-`(或 +`AGENT_BUNDLE_STATE_ROOT`)。随后先调用一次开场工具,然后在 `http://127.0.0.1:/` 上用 loopback origin 提供 App,并为 App 文档再开一个 loopback 沙箱 origin——与 `agent-bundle serve-app` 以及 Workbench 同一套宿主栈与同意行为。人类模式打印 `MCP App / at (tool ; Ctrl-C stops the server)`,并一直运行到 Ctrl-C / @@ -196,7 +197,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--from ` | `process.cwd()` | 用于识别插件(名称、版本、市场)的产物根目录,宿主清单直接位于其下,与 `install` 相同。 | | `--scope ` | `user` | 安装时使用的作用域(Claude)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | -| `--keep-data` | 开启 | 保留持久运行时状态(`state/`:状态内核、通知日志;对 Agent Plugins 包的 Cursor 副本,还包括回执记录的 `PLUGIN_DATA` 目录)。这是默认行为;该标志只是显式声明。 | +| `--keep-data` | 开启 | 保留旧版或就地的持久运行时状态(`state/`:状态内核、通知日志;对 Agent Plugins 包的 Cursor 副本,还包括回执记录的 `PLUGIN_DATA` 目录)。这是默认行为;该标志只是显式声明。(本发行版构建的产物把框架状态放在 `~/.agent-bundle/state/-`,`uninstall` 不会删除它。) | | `--purge-data` | 关闭 | 同时删除持久运行时状态。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | | `--force` | 关闭 | 在没有回执(旧版 Cursor 副本、仅宿主侧的安装)或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。回执或清单指向另一个插件时无论如何都会被拒绝。 | | `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | @@ -208,8 +209,8 @@ uninstall 只删除回执归属的内容:记录的文件与安装器创建的 包括仅记录在 Claude 的 `plugins/installed_plugins.json` 注册表中、位于另一作用域或另一项目的 Claude 安装)。 非归属条目被保留并列出;缺少回执为 `AB7009`,不匹配为 `AB7007`,除非 `--force`;再次运行是 `not-installed` 空操作。类型化的 `data.outcome` 按宿主如实说明持久状态的去向:Cursor 为 `kept` / `purged` / `absent`; -Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 还会删除 `state/` 与 -`plugins/data//`);Codex 为 `removed-by-host` / `unavailable`(`codex plugin remove` 删除缓存树且没有 +Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 还会删除旧版或就地的 `state/` 与 +`plugins/data//`,但不会删除当前的 `~/.agent-bundle/state/-` 框架状态);Codex 为 `removed-by-host` / `unavailable`(`codex plugin remove` 删除缓存树且没有 keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 `--uninstall`,并支持 `--mode`、`--keep-data`、`--purge-data --confirm-purge`、`--force` 与 `--plan`。 diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index d6feb4bcb..570844b11 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -35,7 +35,9 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | 变量 | 由谁读取 | 含义 | | --- | --- | --- | -| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件安装根目录——即组合插件根目录,存放宿主清单、`mcp/`、`hooks/` 与 `bin/` 的那个目录——同时也是持久状态锚点。覆盖内置的回退值;以 `(await agent()).plugin`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | +| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件代码根目录——即组合插件根目录,存放宿主清单、`mcp/`、`hooks/` 与 `bin/` 的那个目录。覆盖内置的回退值;以 `(await agent()).plugin.root`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | +| `AGENT_BUNDLE_STATE_ROOT` | 生成式可执行文件 | 框架状态根目录:SQLite 状态内核、通知账本与 lineage 日志所在的确切目录。覆盖推导出的位置。未展开的 `${…}` token 视为未设置。 | +| `XDG_STATE_HOME` | 生成式可执行文件 | 设置后,推导出的状态根目录为 `$XDG_STATE_HOME/agent-bundle/-`,而不是 `~/.agent-bundle/state/-`。 | | `AGENT_BUNDLE_ENV_FILE` | 生成式可执行文件 | 已安装包在启动时改为读取的操作者 env 文件:一个路径,或以平台路径分隔符连接的多个路径(后者胜出),代替 `<插件根目录>/.env` 与 `.env.local`;`none` 关闭这一层。`mcp run` 会根据 `--env-file` / `--no-env` 为其子进程设置它。 | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | Agent API 在启用之前所必需的 bearer token。 | | `AGENT_BUNDLE_HOOK_SIMULATION` | 生成的钩子 wrapper | `1` 标记一次模拟调用;Workbench 的事件路由工作区会设置它。 | @@ -76,12 +78,16 @@ 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 入口、Flight worker、产物 CLI bin、渲染 worker 与独立 hook 包装器——把框架状态根目录 +推导为 `~/.agent-bundle/state/-`。`` 是代码根目录的 basename,当它不是安全 +路径段时则为 `plugin`;`` 是代码根目录真实路径的 SHA-256 摘要的前 16 位十六进制字符。因此两次 +安装绝不会共享状态,只读安装也能启动。`XDG_STATE_HOME` 按上表把推导位置挪走,`AGENT_BUNDLE_STATE_ROOT` +则覆盖它。npm 包 bin 仍使用 `./.agent-bundle/state`。 + +只有 `workspace-durable` 状态定义使用 SQLite 驱动;其他生命期使用内存驱动, +不在磁盘上留下任何东西。每个生成的进程只解析代码根目录与状态根目录一次(`@agent-bundle/runtime` 的 +`resolvePluginRoot`),并以 `(await agent()).plugin` 发布——`{ root, stateRoot }`——因此把自己的文件 +放在框架状态旁边的路由或 provider 读取 `plugin.value.stateRoot` 即可,不必再自行推导路径。 在 `mcp run` 之下,**env 取值**中的 plugin-root 锚点默认展开到项目根目录,而不是产物:在那里产物只是 一个临时构建产物,把持久状态锚定其上会让状态在每次重建时被割裂。若要按字节忠实地演练一次「复制产物后 From be99e7893dc0966a38a8a8efa7e3b707f6920061 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:50:57 +0000 Subject: [PATCH 2/4] chore: name #640 in the changeset --- .changeset/637-state-root.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/637-state-root.md b/.changeset/637-state-root.md index 1fcf0727d..05517dc93 100644 --- a/.changeset/637-state-root.md +++ b/.changeset/637-state-root.md @@ -3,4 +3,4 @@ "@agent-bundle/runtime": minor --- -Separate the generated plugin's state root from its code root: make every artifact-hosted shell (the stdio MCP entry, Flight worker, artifact CLI bin and render worker, and standalone hook wrappers) keep the SQLite state kernel, notice ledger, and lineage journal under `~/.agent-bundle/state/-` (`$XDG_STATE_HOME/agent-bundle/-` when set) instead of `/state`, so a read-only install launches and two installs never share state; keep `AGENT_BUNDLE_PLUGIN_ROOT` naming the installed code root and let `AGENT_BUNDLE_STATE_ROOT` override the state root; add `stateAnchor` and `home` options and `stateSource` to `resolvePluginRoot`, and export `PLUGIN_STATE_ROOT_ENV_ANCHOR`, `pluginStateSegment`, `userStateHome`, and `userDataStateRoot` from `@agent-bundle/runtime` and `pluginStateRootEnvAnchor` from `agent-bundle`. State existing installs wrote beneath the plugin root is not migrated: after upgrading and rebuilding, an installed plugin starts from an empty state root; set `AGENT_BUNDLE_STATE_ROOT=/state` to keep using it. (#PR) +Separate the generated plugin's state root from its code root: make every artifact-hosted shell (the stdio MCP entry, Flight worker, artifact CLI bin and render worker, and standalone hook wrappers) keep the SQLite state kernel, notice ledger, and lineage journal under `~/.agent-bundle/state/-` (`$XDG_STATE_HOME/agent-bundle/-` when set) instead of `/state`, so a read-only install launches and two installs never share state; keep `AGENT_BUNDLE_PLUGIN_ROOT` naming the installed code root and let `AGENT_BUNDLE_STATE_ROOT` override the state root; add `stateAnchor` and `home` options and `stateSource` to `resolvePluginRoot`, and export `PLUGIN_STATE_ROOT_ENV_ANCHOR`, `pluginStateSegment`, `userStateHome`, and `userDataStateRoot` from `@agent-bundle/runtime` and `pluginStateRootEnvAnchor` from `agent-bundle`. State existing installs wrote beneath the plugin root is not migrated: after upgrading and rebuilding, an installed plugin starts from an empty state root; set `AGENT_BUNDLE_STATE_ROOT=/state` to keep using it. (#640) From 1aa9847afa44469bb5e2aee2d0266f1af9bac97f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:05:01 +0000 Subject: [PATCH 3/4] fix(state-root): anchor script workers on the artifact, pin dev sessions to /state, ignore relative XDG_STATE_HOME Self-review findings: rendered script workers kept the cwd fallback; dev and Workbench MCP sessions would have derived one user-data root per build epoch; a relative XDG_STATE_HOME would have anchored state on the shell's cwd. uninstall --purge-data / doctor coverage of the derived root is #641. --- .changeset/637-state-root.md | 2 +- docs/entry-conventions.md | 5 +++-- packages/agent-bundle/src/build/entries.ts | 6 +++++- .../src/dev/mcp-session/mcp-session-launch.ts | 9 +++++++-- .../agent-bundle/tests/cli-routes-build.test.ts | 6 +++++- .../tests/mcp-session-service.test.ts | 6 +++++- packages/rsc-runtime/src/plugin-root.ts | 15 ++++++++++----- packages/rsc-runtime/tests/plugin-root.test.ts | 7 ++++++- website/docs/en/reference/runtime-environment.mdx | 8 ++++++-- website/docs/zh/reference/runtime-environment.mdx | 6 +++++- 10 files changed, 53 insertions(+), 17 deletions(-) diff --git a/.changeset/637-state-root.md b/.changeset/637-state-root.md index 05517dc93..47cadcb01 100644 --- a/.changeset/637-state-root.md +++ b/.changeset/637-state-root.md @@ -3,4 +3,4 @@ "@agent-bundle/runtime": minor --- -Separate the generated plugin's state root from its code root: make every artifact-hosted shell (the stdio MCP entry, Flight worker, artifact CLI bin and render worker, and standalone hook wrappers) keep the SQLite state kernel, notice ledger, and lineage journal under `~/.agent-bundle/state/-` (`$XDG_STATE_HOME/agent-bundle/-` when set) instead of `/state`, so a read-only install launches and two installs never share state; keep `AGENT_BUNDLE_PLUGIN_ROOT` naming the installed code root and let `AGENT_BUNDLE_STATE_ROOT` override the state root; add `stateAnchor` and `home` options and `stateSource` to `resolvePluginRoot`, and export `PLUGIN_STATE_ROOT_ENV_ANCHOR`, `pluginStateSegment`, `userStateHome`, and `userDataStateRoot` from `@agent-bundle/runtime` and `pluginStateRootEnvAnchor` from `agent-bundle`. State existing installs wrote beneath the plugin root is not migrated: after upgrading and rebuilding, an installed plugin starts from an empty state root; set `AGENT_BUNDLE_STATE_ROOT=/state` to keep using it. (#640) +Separate the generated plugin's state root from its code root: make every artifact-hosted shell (the stdio MCP entry, Flight worker, artifact CLI bin and render worker, rendered script workers, and standalone hook wrappers) keep the SQLite state kernel, notice ledger, and lineage journal under `~/.agent-bundle/state/-` (`$XDG_STATE_HOME/agent-bundle/-` when set) instead of `/state`, so a read-only install launches and two installs never share state; keep `AGENT_BUNDLE_PLUGIN_ROOT` naming the installed code root and let `AGENT_BUNDLE_STATE_ROOT` override the state root; add `stateAnchor` and `home` options and `stateSource` to `resolvePluginRoot`, and export `PLUGIN_STATE_ROOT_ENV_ANCHOR`, `pluginStateSegment`, `userStateHome`, and `userDataStateRoot` from `@agent-bundle/runtime` and `pluginStateRootEnvAnchor` from `agent-bundle`. State existing installs wrote beneath the plugin root is not migrated: after upgrading and rebuilding, an installed plugin starts from an empty state root; set `AGENT_BUNDLE_STATE_ROOT=/state` to keep using it. `agent-bundle dev` and Workbench MCP sessions pin `AGENT_BUNDLE_STATE_ROOT` to `/state`, so their state still lives beside the build epoch; `uninstall --purge-data` and `doctor` still address only the legacy `/state` directory (#641). (#640) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 43e854e50..f92b1ba4c 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -151,14 +151,15 @@ fresh project and notice stores per invocation; process lifetime shares them for the generated worker or executable process. Workspace-durable artifact shells — generated MCP workers, artifact CLI bins -and render workers, and standalone hook wrappers — call `resolvePluginRoot` +and their render workers, rendered script workers, and standalone hook +wrappers — call `resolvePluginRoot` with `stateAnchor: 'user-data'`. `AGENT_BUNDLE_PLUGIN_ROOT` still names the code root and otherwise falls back to the artifact root derived from the shell's own location. An expanded, non-blank `AGENT_BUNDLE_STATE_ROOT` independently overrides the framework state root and is made absolute with `resolve()`; otherwise the state root is `~/.agent-bundle/state/-`, or -`$XDG_STATE_HOME/agent-bundle/-` when `XDG_STATE_HOME` is set. +`$XDG_STATE_HOME/agent-bundle/-` when `XDG_STATE_HOME` is an absolute path (a relative value is ignored). `` is the code root's safe basename (or `plugin`) and `` is the first 16 hexadecimal characters of SHA-256 over that code root's realpath, so symlinked spellings share one state root while distinct installs diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 4aa939b21..4d10c355b 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -225,7 +225,11 @@ export const planScriptsSurface = async ( source, }], ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), - ...(options.state === undefined ? {} : { state: options.state }), + ...(options.state === undefined ? {} : { state: options.state }), + // The worker lives in `scripts/`, one directory below the + // artifact root, like `bin/` and `mcp/`: same code root, same + // derived state root as the MCP worker of the install. + stateFallback: 'artifact', }), }), ]; diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts index 0741d69e7..a1a4a8163 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts @@ -1,6 +1,7 @@ -import { isAbsolute, resolve } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import { assertInside } from '../../core/paths.ts'; +import { pluginStateRootEnvAnchor } from '../../core/types.ts'; import { resolveMcpPathTokens } from '../../services/mcp-path-tokens.ts'; import type { ModernMcpServer, TargetMcpRuntimeContract } from '../../services/mcp-runtime.ts'; import type { McpSessionInspectorConfig } from './mcp-session-protocol.ts'; @@ -110,11 +111,15 @@ export const resolveMcpSessionLaunch = (options: ResolveMcpSessionLaunchOptions) const inheritedEnv = Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), ); + // A dev session runs a build epoch, not an install: its framework state + // lives beside that epoch and goes with it, instead of accumulating one + // user-data root per rebuild. Declared env still wins, as for every key. + const stateRoot = join(options.resolved.targetRoot, 'state'); return Object.freeze({ args: Object.freeze([...resolved.args]), command: resolved.command, cwd, - env: Object.freeze({ ...inheritedEnv, ...(resolved.env ?? {}) }), + env: Object.freeze({ ...inheritedEnv, [pluginStateRootEnvAnchor]: stateRoot, ...(resolved.env ?? {}) }), inspectorEnv: inspectorEnvironment(resolved.env), kind: 'stdio', }); diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 938927c5d..e5124d795 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -399,7 +399,11 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 // The rendered .tsx script (#102 stage 3) ships beside plain scripts in // the target artifact with the same output contract. const scriptPath = join(root, 'artifact', 'scripts', 'summarize.mjs'); - await expect(stat(join(root, 'artifact', 'scripts', 'summarize-flight.mjs'))).resolves.toMatchObject({}); + // Its render worker anchors on the artifact root like the MCP worker and + // derives its state root from it (#637), never `/.agent-bundle/state`. + const scriptWorker = await readFile(join(root, 'artifact', 'scripts', 'summarize-flight.mjs'), 'utf8'); + expect(scriptWorker).toContain("stateAnchor: 'user-data'"); + expect(scriptWorker).not.toContain("join(process.cwd(), '.agent-bundle')"); const scriptMarkdown = await execFile(process.execPath, [scriptPath, 'alpha', 'beta']); expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n'); // The rendered script's provider sees `invocation.kind === 'script'` (#313). diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 6455c55dc..51b860caf 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -118,7 +118,7 @@ const publishFixtureEpoch = async ( ' return {', " _meta: { ui: { resourceUri: 'ui://fixture/result.html' }, opaque: { nested: ['exact', 42] } },", ' content: [', - " { type: 'text', text: JSON.stringify({ cwd: process.cwd(), data: process.env.FIXTURE_DATA, inherited: process.env.AGENT_BUNDLE_PERSISTENT_INHERITED, pid: process.pid, root: process.env.FIXTURE_ROOT, workspace: process.env.FIXTURE_WORKSPACE }) },", + " { type: 'text', text: JSON.stringify({ cwd: process.cwd(), data: process.env.FIXTURE_DATA, inherited: process.env.AGENT_BUNDLE_PERSISTENT_INHERITED, pid: process.pid, root: process.env.FIXTURE_ROOT, stateRoot: process.env.AGENT_BUNDLE_STATE_ROOT, workspace: process.env.FIXTURE_WORKSPACE }) },", " { type: 'resource_link', name: 'fixture', uri: 'ui://fixture/resource.txt' },", ' ],', " structuredContent: { answer: 42, opaque: { exact: true } },", @@ -252,8 +252,12 @@ it('keeps one generated server and plugin-data directory bound to the selected e readonly inherited: string; readonly pid: number; readonly root: string; + readonly stateRoot: string; }; expect(firstState.root).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1')); + // Dev sessions pin the framework state root beside the epoch (#637), so a + // rebuild never accumulates another `~/.agent-bundle/state` directory. + expect(firstState.stateRoot).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'state')); expect(firstState.inherited).toBe('resolved-on-open'); await expect(access(firstState.data)).resolves.toBeUndefined(); expect(session.events().some((event) => event.type === 'stderr' && event.text === 'fixture stderr\n')).toBe(true); diff --git a/packages/rsc-runtime/src/plugin-root.ts b/packages/rsc-runtime/src/plugin-root.ts index 6ceef8ac0..d3203545a 100644 --- a/packages/rsc-runtime/src/plugin-root.ts +++ b/packages/rsc-runtime/src/plugin-root.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { existsSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; -import { basename, join, resolve } from 'node:path'; +import { basename, isAbsolute, join, resolve } from 'node:path'; import { available, type AgentPluginIdentity, type Observed } from './agent-request.js'; @@ -63,15 +63,20 @@ export const pluginStateSegment = (root: string): string => { return safePluginSegment.test(name) ? `${name}-${digest}` : `plugin-${digest}`; }; -/** The user-level directory that holds framework state for installed plugins. */ +/** + * The user-level directory that holds framework state for installed plugins. + * A relative `XDG_STATE_HOME` is ignored, as the base-directory spec + * requires: a shell runs with the artifact as its cwd, and a relative anchor + * would put state back beneath the install. + */ export const userStateHome = ( env: Readonly> = process.env, home = homedir(), ): string => { const xdgStateHome = env.XDG_STATE_HOME ?? ''; - return xdgStateHome.trim() === '' - ? join(home, '.agent-bundle', PLUGIN_STATE_DIRECTORY) - : join(xdgStateHome, 'agent-bundle'); + return isAbsolute(xdgStateHome) + ? join(xdgStateHome, 'agent-bundle') + : join(home, '.agent-bundle', PLUGIN_STATE_DIRECTORY); }; /** The framework state root for one installed plugin. */ diff --git a/packages/rsc-runtime/tests/plugin-root.test.ts b/packages/rsc-runtime/tests/plugin-root.test.ts index df8684cbb..94a8b7d5b 100644 --- a/packages/rsc-runtime/tests/plugin-root.test.ts +++ b/packages/rsc-runtime/tests/plugin-root.test.ts @@ -175,7 +175,7 @@ describe('resolvePluginRoot (#468)', () => { }); }); - it('uses a non-blank XDG_STATE_HOME and ignores a blank one', () => { + it('uses an absolute XDG_STATE_HOME and ignores a blank or relative one', () => { const root = '/installs/curator'; const home = '/users/tester'; const segment = `curator-${digest16(resolve(root))}`; @@ -185,6 +185,11 @@ describe('resolvePluginRoot (#468)', () => { join('/xdg/state', 'agent-bundle', segment), ); expect(userStateHome({ XDG_STATE_HOME: ' ' }, home)).toBe(join(home, '.agent-bundle', 'state')); + // The base-directory spec ignores a relative value; honoring it would + // anchor state on the shell's cwd, which for an artifact shell is the + // read-only install itself. + expect(userStateHome({ XDG_STATE_HOME: 'state' }, home)).toBe(join(home, '.agent-bundle', 'state')); + expect(userStateHome({ XDG_STATE_HOME: './xdg' }, home)).toBe(join(home, '.agent-bundle', 'state')); }); it('uses plugin as the segment name when the root basename is unsafe', () => { diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index ef87fc2bc..6c98adc80 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -41,7 +41,7 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | --- | --- | --- | | `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The plugin code root — the composite plugin root, the directory holding the host manifests, `mcp/`, `hooks/`, and `bin/`. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin.value.root` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | | `AGENT_BUNDLE_STATE_ROOT` | Generated executables | The framework state root: the exact directory where the SQLite state kernel, notice ledger, and lineage journal live. Overrides the derived location. An unexpanded `${…}` token is treated as unset. | -| `XDG_STATE_HOME` | Generated executables | When set, the derived state root is `$XDG_STATE_HOME/agent-bundle/-` instead of `~/.agent-bundle/state/-`. | +| `XDG_STATE_HOME` | Generated executables | When set to an absolute path (a relative value is ignored, per the base-directory spec), the derived state root is `$XDG_STATE_HOME/agent-bundle/-` instead of `~/.agent-bundle/state/-`. | | `AGENT_BUNDLE_ENV_FILE` | Generated executables | The operator env file(s) an installed pack reads at launch instead of `/.env` and `.env.local`: one path, or several joined by the platform path delimiter, later files winning; `none` disables the layer. `mcp run` sets it for its child from `--env-file` / `--no-env`. | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | The bearer token the Agent API requires before it can be enabled. | | `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench event route workspace sets it. | @@ -104,7 +104,11 @@ Under `mcp run`, plugin-root anchors in **env values** expand to the project roo not the artifact: the artifact is an ephemeral build product there, and anchoring durable state on it would fragment that state on every rebuild. Pass `--plugin-root ` pointing at the artifact root itself — the composite plugin root, not a host subdirectory — for a byte-faithful -rehearsal of a copied-artifact launch. +rehearsal of a copied-artifact launch. The state root then derives from that expanded code root +(`~/.agent-bundle/state/-`) unless `AGENT_BUNDLE_STATE_ROOT` names one. +`agent-bundle dev` and Workbench MCP sessions pin `AGENT_BUNDLE_STATE_ROOT` to `/state` +instead, so a build epoch's state goes with the epoch rather than accumulating one user-data +directory per rebuild. A per-server state directory uses the server name when it is a single safe segment; any other name becomes a content-addressed `server-` segment, so a name like `../shared` can never diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index 570844b11..b4d26674b 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -37,7 +37,7 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | --- | --- | --- | | `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件代码根目录——即组合插件根目录,存放宿主清单、`mcp/`、`hooks/` 与 `bin/` 的那个目录。覆盖内置的回退值;以 `(await agent()).plugin.root`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | | `AGENT_BUNDLE_STATE_ROOT` | 生成式可执行文件 | 框架状态根目录:SQLite 状态内核、通知账本与 lineage 日志所在的确切目录。覆盖推导出的位置。未展开的 `${…}` token 视为未设置。 | -| `XDG_STATE_HOME` | 生成式可执行文件 | 设置后,推导出的状态根目录为 `$XDG_STATE_HOME/agent-bundle/-`,而不是 `~/.agent-bundle/state/-`。 | +| `XDG_STATE_HOME` | 生成式可执行文件 | 设置为绝对路径时(相对值按 base-directory 规范忽略),推导出的状态根目录为 `$XDG_STATE_HOME/agent-bundle/-`,而不是 `~/.agent-bundle/state/-`。 | | `AGENT_BUNDLE_ENV_FILE` | 生成式可执行文件 | 已安装包在启动时改为读取的操作者 env 文件:一个路径,或以平台路径分隔符连接的多个路径(后者胜出),代替 `<插件根目录>/.env` 与 `.env.local`;`none` 关闭这一层。`mcp run` 会根据 `--env-file` / `--no-env` 为其子进程设置它。 | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | Agent API 在启用之前所必需的 bearer token。 | | `AGENT_BUNDLE_HOOK_SIMULATION` | 生成的钩子 wrapper | `1` 标记一次模拟调用;Workbench 的事件路由工作区会设置它。 | @@ -92,6 +92,10 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 在 `mcp run` 之下,**env 取值**中的 plugin-root 锚点默认展开到项目根目录,而不是产物:在那里产物只是 一个临时构建产物,把持久状态锚定其上会让状态在每次重建时被割裂。若要按字节忠实地演练一次「复制产物后 启动」,请传入指向产物根目录本身——组合插件根目录,而不是某个宿主子目录——的 `--plugin-root `。 +除非 `AGENT_BUNDLE_STATE_ROOT` 指定了位置,状态根目录随后从展开后的代码根目录推导 +(`~/.agent-bundle/state/-`)。`agent-bundle dev` 与 Workbench 的 MCP 会话则把 +`AGENT_BUNDLE_STATE_ROOT` 固定为 `/state`,因此构建 epoch 的状态随 epoch 一起存在与消失, +而不会在每次重建时多出一个用户数据目录。 当服务器名是单个安全路径段时,逐服务器状态目录直接使用该名字;其他任何名字都会变成内容寻址的 `server-` 段,因此像 `../shared` 这样的名字绝不可能穿出状态根目录。 From f04259bf6f3dd3fb7008f8cc2dbec0ddb891fef9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:09:41 +0000 Subject: [PATCH 4/4] docs: scope --purge-data to legacy state, fix zh plugin.value.root, XDG absolute-path wording --- .changeset/637-state-root.md | 2 +- website/docs/en/reference/cli.mdx | 2 +- website/docs/zh/reference/cli.mdx | 2 +- website/docs/zh/reference/runtime-environment.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/637-state-root.md b/.changeset/637-state-root.md index 47cadcb01..434709d6b 100644 --- a/.changeset/637-state-root.md +++ b/.changeset/637-state-root.md @@ -3,4 +3,4 @@ "@agent-bundle/runtime": minor --- -Separate the generated plugin's state root from its code root: make every artifact-hosted shell (the stdio MCP entry, Flight worker, artifact CLI bin and render worker, rendered script workers, and standalone hook wrappers) keep the SQLite state kernel, notice ledger, and lineage journal under `~/.agent-bundle/state/-` (`$XDG_STATE_HOME/agent-bundle/-` when set) instead of `/state`, so a read-only install launches and two installs never share state; keep `AGENT_BUNDLE_PLUGIN_ROOT` naming the installed code root and let `AGENT_BUNDLE_STATE_ROOT` override the state root; add `stateAnchor` and `home` options and `stateSource` to `resolvePluginRoot`, and export `PLUGIN_STATE_ROOT_ENV_ANCHOR`, `pluginStateSegment`, `userStateHome`, and `userDataStateRoot` from `@agent-bundle/runtime` and `pluginStateRootEnvAnchor` from `agent-bundle`. State existing installs wrote beneath the plugin root is not migrated: after upgrading and rebuilding, an installed plugin starts from an empty state root; set `AGENT_BUNDLE_STATE_ROOT=/state` to keep using it. `agent-bundle dev` and Workbench MCP sessions pin `AGENT_BUNDLE_STATE_ROOT` to `/state`, so their state still lives beside the build epoch; `uninstall --purge-data` and `doctor` still address only the legacy `/state` directory (#641). (#640) +Separate the generated plugin's state root from its code root: make every artifact-hosted shell (the stdio MCP entry, Flight worker, artifact CLI bin and render worker, rendered script workers, and standalone hook wrappers) keep the SQLite state kernel, notice ledger, and lineage journal under `~/.agent-bundle/state/-` (`$XDG_STATE_HOME/agent-bundle/-` when `XDG_STATE_HOME` is an absolute path) instead of `/state`, so a read-only install launches and two installs never share state; keep `AGENT_BUNDLE_PLUGIN_ROOT` naming the installed code root and let `AGENT_BUNDLE_STATE_ROOT` override the state root; add `stateAnchor` and `home` options and `stateSource` to `resolvePluginRoot`, and export `PLUGIN_STATE_ROOT_ENV_ANCHOR`, `pluginStateSegment`, `userStateHome`, and `userDataStateRoot` from `@agent-bundle/runtime` and `pluginStateRootEnvAnchor` from `agent-bundle`. State existing installs wrote beneath the plugin root is not migrated: after upgrading and rebuilding, an installed plugin starts from an empty state root; set `AGENT_BUNDLE_STATE_ROOT=/state` to keep using it. `agent-bundle dev` and Workbench MCP sessions pin `AGENT_BUNDLE_STATE_ROOT` to `/state`, so their state still lives beside the build epoch; `uninstall --purge-data` and `doctor` still address only the legacy `/state` directory (#641). (#640) diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index e16ae6719..d02c90042 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -207,7 +207,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--scope ` | `user` | The scope the plugin was installed at (Claude). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | | `--keep-data` | on | Keep legacy or in-place durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the recorded `PLUGIN_DATA` directory). This is the default; the flag makes it explicit. (Artifacts built from this release keep framework state under `~/.agent-bundle/state/-`, which `uninstall` does not remove.) | -| `--purge-data` | off | Also remove durable runtime state. Refused (`AB7008`) without `--confirm-purge`. | +| `--purge-data` | off | Also remove the legacy or in-place durable runtime state (`state/`, recorded `PLUGIN_DATA`); the derived `~/.agent-bundle/state/-` root stays. Refused (`AB7008`) without `--confirm-purge`. | | `--force` | off | Proceed without a receipt (legacy Cursor copy, host-only install) or when owned content, version, or staged `HEAD` no longer matches the receipt. A receipt or manifest naming another plugin is refused regardless. | | `--plan` | off | Print the exact paths and host registrations that would be removed and change nothing. | diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 0160edf22..9bbd1f448 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -198,7 +198,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--scope ` | `user` | 安装时使用的作用域(Claude)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | | `--keep-data` | 开启 | 保留旧版或就地的持久运行时状态(`state/`:状态内核、通知日志;对 Agent Plugins 包的 Cursor 副本,还包括回执记录的 `PLUGIN_DATA` 目录)。这是默认行为;该标志只是显式声明。(本发行版构建的产物把框架状态放在 `~/.agent-bundle/state/-`,`uninstall` 不会删除它。) | -| `--purge-data` | 关闭 | 同时删除持久运行时状态。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | +| `--purge-data` | 关闭 | 同时删除旧版或就地的持久运行时状态(`state/`、回执记录的 `PLUGIN_DATA`);推导出的 `~/.agent-bundle/state/-` 保留。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | | `--force` | 关闭 | 在没有回执(旧版 Cursor 副本、仅宿主侧的安装)或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。回执或清单指向另一个插件时无论如何都会被拒绝。 | | `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index b4d26674b..1b46b0fb5 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` | 生成式可执行文件 | 插件代码根目录——即组合插件根目录,存放宿主清单、`mcp/`、`hooks/` 与 `bin/` 的那个目录。覆盖内置的回退值;以 `(await agent()).plugin.root`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | +| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件代码根目录——即组合插件根目录,存放宿主清单、`mcp/`、`hooks/` 与 `bin/` 的那个目录。覆盖内置的回退值;以 `(await agent()).plugin.value.root`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | | `AGENT_BUNDLE_STATE_ROOT` | 生成式可执行文件 | 框架状态根目录:SQLite 状态内核、通知账本与 lineage 日志所在的确切目录。覆盖推导出的位置。未展开的 `${…}` token 视为未设置。 | | `XDG_STATE_HOME` | 生成式可执行文件 | 设置为绝对路径时(相对值按 base-directory 规范忽略),推导出的状态根目录为 `$XDG_STATE_HOME/agent-bundle/-`,而不是 `~/.agent-bundle/state/-`。 | | `AGENT_BUNDLE_ENV_FILE` | 生成式可执行文件 | 已安装包在启动时改为读取的操作者 env 文件:一个路径,或以平台路径分隔符连接的多个路径(后者胜出),代替 `<插件根目录>/.env` 与 `.env.local`;`none` 关闭这一层。`mcp run` 会根据 `--env-file` / `--no-env` 为其子进程设置它。 |